From ea8392a4a1000d568037d8e1eecf88fedb1185cf Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 29 Mar 2025 06:48:51 -1000 Subject: [PATCH 001/505] Fix ESPHome entities not being removed when the ESPHome config removes an entire platform (#141708) * Fix old ESPHome entities not being removed when configuration changes fixes #140756 * make sure all callbacks fire * make sure all callbacks fire * make sure all callbacks fire * make sure all callbacks fire * revert * cover --- .../components/esphome/entry_data.py | 21 +++--- tests/components/esphome/test_entity.py | 70 +++++++++++++++++++ 2 files changed, 81 insertions(+), 10 deletions(-) diff --git a/homeassistant/components/esphome/entry_data.py b/homeassistant/components/esphome/entry_data.py index fc41ee99a00..1c535d98e40 100644 --- a/homeassistant/components/esphome/entry_data.py +++ b/homeassistant/components/esphome/entry_data.py @@ -312,18 +312,19 @@ class RuntimeEntryData: # Make a dict of the EntityInfo by type and send # them to the listeners for each specific EntityInfo type - infos_by_type: dict[type[EntityInfo], list[EntityInfo]] = {} + infos_by_type: defaultdict[type[EntityInfo], list[EntityInfo]] = defaultdict( + list + ) for info in infos: - info_type = type(info) - if info_type not in infos_by_type: - infos_by_type[info_type] = [] - infos_by_type[info_type].append(info) + infos_by_type[type(info)].append(info) - callbacks_by_type = self.entity_info_callbacks - for type_, entity_infos in infos_by_type.items(): - if callbacks_ := callbacks_by_type.get(type_): - for callback_ in callbacks_: - callback_(entity_infos) + for type_, callbacks in self.entity_info_callbacks.items(): + # If all entities for a type are removed, we + # still need to call the callbacks with an empty list + # to make sure the entities are removed. + entity_infos = infos_by_type.get(type_, []) + for callback_ in callbacks: + callback_(entity_infos) # Finally update static info subscriptions for callback_ in self.static_info_update_subscriptions: diff --git a/tests/components/esphome/test_entity.py b/tests/components/esphome/test_entity.py index 296d61b664d..977ec50ab30 100644 --- a/tests/components/esphome/test_entity.py +++ b/tests/components/esphome/test_entity.py @@ -260,6 +260,76 @@ async def test_entities_removed_after_reload( assert len(hass_storage[storage_key]["data"]["binary_sensor"]) == 1 +async def test_entities_for_entire_platform_removed( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + mock_client: APIClient, + hass_storage: dict[str, Any], + mock_esphome_device: Callable[ + [APIClient, list[EntityInfo], list[UserService], list[EntityState]], + Awaitable[MockESPHomeDevice], + ], +) -> None: + """Test removing all entities for a specific platform when static info changes.""" + entity_info = [ + BinarySensorInfo( + object_id="mybinary_sensor_to_be_removed", + key=1, + name="my binary_sensor to be removed", + unique_id="mybinary_sensor_to_be_removed", + ), + ] + states = [ + BinarySensorState(key=1, state=True, missing_state=False), + ] + user_service = [] + mock_device = await mock_esphome_device( + mock_client=mock_client, + entity_info=entity_info, + user_service=user_service, + states=states, + ) + entry = mock_device.entry + entry_id = entry.entry_id + storage_key = f"esphome.{entry_id}" + state = hass.states.get("binary_sensor.test_mybinary_sensor_to_be_removed") + assert state is not None + assert state.state == STATE_ON + + await hass.config_entries.async_unload(entry.entry_id) + await hass.async_block_till_done() + + assert len(hass_storage[storage_key]["data"]["binary_sensor"]) == 1 + + state = hass.states.get("binary_sensor.test_mybinary_sensor_to_be_removed") + assert state is not None + reg_entry = entity_registry.async_get( + "binary_sensor.test_mybinary_sensor_to_be_removed" + ) + assert reg_entry is not None + assert state.attributes[ATTR_RESTORED] is True + + entity_info = [] + states = [] + mock_device = await mock_esphome_device( + mock_client=mock_client, + entity_info=entity_info, + user_service=user_service, + states=states, + entry=entry, + ) + assert mock_device.entry.entry_id == entry_id + state = hass.states.get("binary_sensor.test_mybinary_sensor_to_be_removed") + assert state is None + reg_entry = entity_registry.async_get( + "binary_sensor.test_mybinary_sensor_to_be_removed" + ) + assert reg_entry is None + await hass.config_entries.async_unload(entry.entry_id) + await hass.async_block_till_done() + assert len(hass_storage[storage_key]["data"]["binary_sensor"]) == 0 + + async def test_entity_info_object_ids( hass: HomeAssistant, mock_client: APIClient, From 6d48fc183a39d464e18d04f254f3bf5397fb31c7 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 29 Mar 2025 06:53:01 -1000 Subject: [PATCH 002/505] Fix ESPHome update entities being loaded before device_info is available (#141704) * Fix ESPHome update entities being loaded before device_info is available Since we load platforms when restoring config, the update platform could be loaded before the connection to the device was finished which meant device_info could still be empty. Wait until device_info is available to load the update platform. fixes #135906 * Apply suggestions from code review * move comment * Update entry_data.py Co-authored-by: TheJulianJES --------- Co-authored-by: TheJulianJES --- .../components/esphome/entry_data.py | 19 +++--- tests/components/esphome/test_update.py | 58 ++++++++++--------- 2 files changed, 42 insertions(+), 35 deletions(-) diff --git a/homeassistant/components/esphome/entry_data.py b/homeassistant/components/esphome/entry_data.py index 1c535d98e40..023c6f70da4 100644 --- a/homeassistant/components/esphome/entry_data.py +++ b/homeassistant/components/esphome/entry_data.py @@ -282,15 +282,18 @@ class RuntimeEntryData: ) -> None: """Distribute an update of static infos to all platforms.""" # First, load all platforms - needed_platforms = set() - if async_get_dashboard(hass): - needed_platforms.add(Platform.UPDATE) + needed_platforms: set[Platform] = set() - if self.device_info and self.device_info.voice_assistant_feature_flags_compat( - self.api_version - ): - needed_platforms.add(Platform.BINARY_SENSOR) - needed_platforms.add(Platform.SELECT) + if self.device_info: + if async_get_dashboard(hass): + # Only load the update platform if the device_info is set + # When we restore the entry, the device_info may not be set yet + # and we don't want to load the update platform since it needs + # a complete device_info. + needed_platforms.add(Platform.UPDATE) + if self.device_info.voice_assistant_feature_flags_compat(self.api_version): + needed_platforms.add(Platform.BINARY_SENSOR) + needed_platforms.add(Platform.SELECT) ent_reg = er.async_get(hass) registry_get_entity = ent_reg.async_get_entity_id diff --git a/tests/components/esphome/test_update.py b/tests/components/esphome/test_update.py index 5060471f5d2..76c0a9b1a70 100644 --- a/tests/components/esphome/test_update.py +++ b/tests/components/esphome/test_update.py @@ -86,26 +86,28 @@ def stub_reconnect(): ) async def test_update_entity( hass: HomeAssistant, - stub_reconnect, - mock_config_entry, - mock_device_info, mock_dashboard: dict[str, Any], - devices_payload, - expected_state, - expected_attributes, + devices_payload: list[dict[str, Any]], + expected_state: str, + expected_attributes: dict[str, Any], + mock_client: APIClient, + mock_esphome_device: Callable[ + [APIClient, list[EntityInfo], list[UserService], list[EntityState]], + Awaitable[MockESPHomeDevice], + ], ) -> None: """Test ESPHome update entity.""" mock_dashboard["configured"] = devices_payload await async_get_dashboard(hass).async_refresh() - with patch( - "homeassistant.components.esphome.update.DomainData.get_entry_data", - return_value=Mock(available=True, device_info=mock_device_info, info={}), - ): - assert await hass.config_entries.async_setup(mock_config_entry.entry_id) - await hass.async_block_till_done() + await mock_esphome_device( + mock_client=mock_client, + entity_info=[], + user_service=[], + states=[], + ) - state = hass.states.get("update.none_firmware") + state = hass.states.get("update.test_firmware") assert state is not None assert state.state == expected_state for key, expected_value in expected_attributes.items(): @@ -130,7 +132,7 @@ async def test_update_entity( await hass.services.async_call( "update", "install", - {"entity_id": "update.none_firmware"}, + {"entity_id": "update.test_firmware"}, blocking=True, ) @@ -155,7 +157,7 @@ async def test_update_entity( await hass.services.async_call( "update", "install", - {"entity_id": "update.none_firmware"}, + {"entity_id": "update.test_firmware"}, blocking=True, ) @@ -177,7 +179,7 @@ async def test_update_entity( await hass.services.async_call( "update", "install", - {"entity_id": "update.none_firmware"}, + {"entity_id": "update.test_firmware"}, blocking=True, ) @@ -274,28 +276,30 @@ async def test_update_device_state_for_availability( async def test_update_entity_dashboard_not_available_startup( hass: HomeAssistant, - stub_reconnect, - mock_config_entry, - mock_device_info, + mock_client: APIClient, + mock_esphome_device: Callable[ + [APIClient, list[EntityInfo], list[UserService], list[EntityState]], + Awaitable[MockESPHomeDevice], + ], mock_dashboard: dict[str, Any], ) -> None: """Test ESPHome update entity when dashboard is not available at startup.""" with ( - patch( - "homeassistant.components.esphome.update.DomainData.get_entry_data", - return_value=Mock(available=True, device_info=mock_device_info, info={}), - ), patch( "esphome_dashboard_api.ESPHomeDashboardAPI.get_devices", side_effect=TimeoutError, ), ): await async_get_dashboard(hass).async_refresh() - assert await hass.config_entries.async_setup(mock_config_entry.entry_id) - await hass.async_block_till_done() + await mock_esphome_device( + mock_client=mock_client, + entity_info=[], + user_service=[], + states=[], + ) # We have a dashboard but it is not available - state = hass.states.get("update.none_firmware") + state = hass.states.get("update.test_firmware") assert state is None mock_dashboard["configured"] = [ @@ -308,7 +312,7 @@ async def test_update_entity_dashboard_not_available_startup( await async_get_dashboard(hass).async_refresh() await hass.async_block_till_done() - state = hass.states.get("update.none_firmware") + state = hass.states.get("update.test_firmware") assert state.state == STATE_ON expected_attributes = { "latest_version": "2023.2.0-dev", From e2ff0b265de7c02bc40737cddee527cd36012bb4 Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Sat, 29 Mar 2025 18:07:38 +0100 Subject: [PATCH 003/505] Replace "Stopped" with common state in `prusalink` (#141743) * Replace "Stopped" with common state in `prusalink` * Sentence-case "Nozzle diameter" --- homeassistant/components/prusalink/strings.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/prusalink/strings.json b/homeassistant/components/prusalink/strings.json index 7c6f0bbf2dd..036bd2c9c6e 100644 --- a/homeassistant/components/prusalink/strings.json +++ b/homeassistant/components/prusalink/strings.json @@ -36,7 +36,7 @@ "printing": "Printing", "paused": "[%key:common::state::paused%]", "finished": "Finished", - "stopped": "Stopped", + "stopped": "[%key:common::state::stopped%]", "error": "Error", "attention": "Attention", "ready": "Ready" @@ -85,7 +85,7 @@ "name": "Z-Height" }, "nozzle_diameter": { - "name": "Nozzle Diameter" + "name": "Nozzle diameter" } }, "button": { From 4e4446cef42811a6e8b834d930147037aba6a639 Mon Sep 17 00:00:00 2001 From: Simone Chemelli Date: Sat, 29 Mar 2025 18:22:03 +0100 Subject: [PATCH 004/505] Fix immediate state update for Comelit (#141735) --- homeassistant/components/comelit/cover.py | 21 +++++++++++---------- homeassistant/components/comelit/light.py | 3 ++- homeassistant/components/comelit/switch.py | 3 ++- 3 files changed, 15 insertions(+), 12 deletions(-) diff --git a/homeassistant/components/comelit/cover.py b/homeassistant/components/comelit/cover.py index 9bcf52ac111..befcb0c35d4 100644 --- a/homeassistant/components/comelit/cover.py +++ b/homeassistant/components/comelit/cover.py @@ -8,7 +8,7 @@ from aiocomelit import ComelitSerialBridgeObject from aiocomelit.const import COVER, STATE_COVER, STATE_OFF, STATE_ON from homeassistant.components.cover import CoverDeviceClass, CoverEntity, CoverState -from homeassistant.core import HomeAssistant, callback +from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.restore_state import RestoreEntity from homeassistant.helpers.update_coordinator import CoordinatorEntity @@ -98,13 +98,20 @@ class ComelitCoverEntity( """Return if the cover is opening.""" return self._current_action("opening") + async def _cover_set_state(self, action: int, state: int) -> None: + """Set desired cover state.""" + self._last_state = self.state + await self._api.set_device_status(COVER, self._device.index, action) + self.coordinator.data[COVER][self._device.index].status = state + self.async_write_ha_state() + async def async_close_cover(self, **kwargs: Any) -> None: """Close cover.""" - await self._api.set_device_status(COVER, self._device.index, STATE_OFF) + await self._cover_set_state(STATE_OFF, 2) async def async_open_cover(self, **kwargs: Any) -> None: """Open cover.""" - await self._api.set_device_status(COVER, self._device.index, STATE_ON) + await self._cover_set_state(STATE_ON, 1) async def async_stop_cover(self, **_kwargs: Any) -> None: """Stop the cover.""" @@ -112,13 +119,7 @@ class ComelitCoverEntity( return action = STATE_ON if self.is_closing else STATE_OFF - await self._api.set_device_status(COVER, self._device.index, action) - - @callback - def _handle_coordinator_update(self) -> None: - """Handle device update.""" - self._last_state = self.state - self.async_write_ha_state() + await self._cover_set_state(action, 0) async def async_added_to_hass(self) -> None: """Handle entity which will be added.""" diff --git a/homeassistant/components/comelit/light.py b/homeassistant/components/comelit/light.py index 09180d628a6..53cf6bdcb46 100644 --- a/homeassistant/components/comelit/light.py +++ b/homeassistant/components/comelit/light.py @@ -59,7 +59,8 @@ class ComelitLightEntity(CoordinatorEntity[ComelitSerialBridge], LightEntity): async def _light_set_state(self, state: int) -> None: """Set desired light state.""" await self.coordinator.api.set_device_status(LIGHT, self._device.index, state) - await self.coordinator.async_request_refresh() + self.coordinator.data[LIGHT][self._device.index].status = state + self.async_write_ha_state() async def async_turn_on(self, **kwargs: Any) -> None: """Turn the light on.""" diff --git a/homeassistant/components/comelit/switch.py b/homeassistant/components/comelit/switch.py index db89bd082f6..2c751cbe2cb 100644 --- a/homeassistant/components/comelit/switch.py +++ b/homeassistant/components/comelit/switch.py @@ -67,7 +67,8 @@ class ComelitSwitchEntity(CoordinatorEntity[ComelitSerialBridge], SwitchEntity): await self.coordinator.api.set_device_status( self._device.type, self._device.index, state ) - await self.coordinator.async_request_refresh() + self.coordinator.data[self._device.type][self._device.index].status = state + self.async_write_ha_state() async def async_turn_on(self, **kwargs: Any) -> None: """Turn the switch on.""" From ed4ebe122219ae19266530403be418ab8c0b881c Mon Sep 17 00:00:00 2001 From: Josef Zweck Date: Sat, 29 Mar 2025 18:38:19 +0100 Subject: [PATCH 005/505] Add unkown to uncalibrated state for tedee (#141262) --- homeassistant/components/tedee/binary_sensor.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/tedee/binary_sensor.py b/homeassistant/components/tedee/binary_sensor.py index a01b889ef8f..6570d9c5428 100644 --- a/homeassistant/components/tedee/binary_sensor.py +++ b/homeassistant/components/tedee/binary_sensor.py @@ -41,7 +41,7 @@ ENTITIES: tuple[TedeeBinarySensorEntityDescription, ...] = ( TedeeBinarySensorEntityDescription( key="semi_locked", translation_key="semi_locked", - is_on_fn=lambda lock: lock.state == TedeeLockState.HALF_OPEN, + is_on_fn=lambda lock: lock.state is TedeeLockState.HALF_OPEN, entity_category=EntityCategory.DIAGNOSTIC, ), TedeeBinarySensorEntityDescription( @@ -53,7 +53,10 @@ ENTITIES: tuple[TedeeBinarySensorEntityDescription, ...] = ( TedeeBinarySensorEntityDescription( key="uncalibrated", translation_key="uncalibrated", - is_on_fn=lambda lock: lock.state == TedeeLockState.UNCALIBRATED, + is_on_fn=( + lambda lock: lock.state is TedeeLockState.UNCALIBRATED + or lock.state is TedeeLockState.UNKNOWN + ), device_class=BinarySensorDeviceClass.PROBLEM, entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, From 20e2de200ffdaf6133070ccd5f518f8d7583d005 Mon Sep 17 00:00:00 2001 From: Marcel van der Veldt Date: Sat, 29 Mar 2025 18:39:59 +0100 Subject: [PATCH 006/505] Always set pause feature on Music Assistant mediaplayers (#141686) --- .../components/music_assistant/media_player.py | 8 ++++++-- .../components/music_assistant/test_media_player.py | 13 ------------- 2 files changed, 6 insertions(+), 15 deletions(-) diff --git a/homeassistant/components/music_assistant/media_player.py b/homeassistant/components/music_assistant/media_player.py index 56bde7bbae7..7d26f5b3a0c 100644 --- a/homeassistant/components/music_assistant/media_player.py +++ b/homeassistant/components/music_assistant/media_player.py @@ -94,6 +94,12 @@ SUPPORTED_FEATURES_BASE = ( | MediaPlayerEntityFeature.MEDIA_ENQUEUE | MediaPlayerEntityFeature.MEDIA_ANNOUNCE | MediaPlayerEntityFeature.SEEK + # we always add pause support, + # regardless if the underlying player actually natively supports pause + # because the MA behavior is to internally handle pause with stop + # (and a resume position) and we'd like to keep the UX consistent + # background info: https://github.com/home-assistant/core/issues/140118 + | MediaPlayerEntityFeature.PAUSE ) QUEUE_OPTION_MAP = { @@ -697,8 +703,6 @@ class MusicAssistantPlayer(MusicAssistantEntity, MediaPlayerEntity): supported_features = SUPPORTED_FEATURES_BASE if PlayerFeature.SET_MEMBERS in self.player.supported_features: supported_features |= MediaPlayerEntityFeature.GROUPING - if PlayerFeature.PAUSE in self.player.supported_features: - supported_features |= MediaPlayerEntityFeature.PAUSE if self.player.mute_control != PLAYER_CONTROL_NONE: supported_features |= MediaPlayerEntityFeature.VOLUME_MUTE if self.player.volume_control != PLAYER_CONTROL_NONE: diff --git a/tests/components/music_assistant/test_media_player.py b/tests/components/music_assistant/test_media_player.py index 44317d4977a..ad321a1cc29 100644 --- a/tests/components/music_assistant/test_media_player.py +++ b/tests/components/music_assistant/test_media_player.py @@ -694,19 +694,6 @@ async def test_media_player_supported_features( assert state assert state.attributes["supported_features"] == expected_features - # remove pause capability from player, trigger subscription callback - # and check if the supported features got updated - music_assistant_client.players._players[mass_player_id].supported_features.remove( - PlayerFeature.PAUSE - ) - await trigger_subscription_callback( - hass, music_assistant_client, EventType.PLAYER_CONFIG_UPDATED, mass_player_id - ) - expected_features &= ~MediaPlayerEntityFeature.PAUSE - state = hass.states.get(entity_id) - assert state - assert state.attributes["supported_features"] == expected_features - # remove grouping capability from player, trigger subscription callback # and check if the supported features got updated music_assistant_client.players._players[mass_player_id].supported_features.remove( From 43b83c855f3a33eca036b46c4c40570b595dc54a Mon Sep 17 00:00:00 2001 From: Simone Chemelli Date: Sat, 29 Mar 2025 18:42:12 +0100 Subject: [PATCH 007/505] Align code styling in Vodafone Station tests (#141745) --- .../vodafone_station/test_coordinator.py | 11 +++++------ .../vodafone_station/test_device_tracker.py | 6 ++---- tests/components/vodafone_station/test_sensor.py | 15 +++++---------- 3 files changed, 12 insertions(+), 20 deletions(-) diff --git a/tests/components/vodafone_station/test_coordinator.py b/tests/components/vodafone_station/test_coordinator.py index 1a9470245c7..5f75b538803 100644 --- a/tests/components/vodafone_station/test_coordinator.py +++ b/tests/components/vodafone_station/test_coordinator.py @@ -40,8 +40,7 @@ async def test_coordinator_device_cleanup( device_tracker = f"device_tracker.{DEVICE_1_HOST}" - state = hass.states.get(device_tracker) - assert state is not None + assert hass.states.get(device_tracker) mock_vodafone_station_router.get_devices_data.return_value = { DEVICE_2_MAC: VodafoneStationDevice( @@ -59,10 +58,10 @@ async def test_coordinator_device_cleanup( async_fire_time_changed(hass) await hass.async_block_till_done(wait_background_tasks=True) - state = hass.states.get(device_tracker) - assert state is None + assert hass.states.get(device_tracker) is None assert f"Skipping entity {DEVICE_2_HOST}" in caplog.text - device = device_registry.async_get_device(identifiers={(DOMAIN, DEVICE_1_MAC)}) - assert device is None + assert ( + device_registry.async_get_device(identifiers={(DOMAIN, DEVICE_1_MAC)}) is None + ) assert f"Removing device: {DEVICE_1_HOST}" in caplog.text diff --git a/tests/components/vodafone_station/test_device_tracker.py b/tests/components/vodafone_station/test_device_tracker.py index e172fa76de5..a94f4ad05c4 100644 --- a/tests/components/vodafone_station/test_device_tracker.py +++ b/tests/components/vodafone_station/test_device_tracker.py @@ -47,8 +47,7 @@ async def test_consider_home( device_tracker = f"device_tracker.{DEVICE_1_HOST}" - state = hass.states.get(device_tracker) - assert state + assert (state := hass.states.get(device_tracker)) assert state.state == STATE_HOME mock_vodafone_station_router.get_devices_data.return_value[ @@ -59,6 +58,5 @@ async def test_consider_home( async_fire_time_changed(hass) await hass.async_block_till_done(wait_background_tasks=True) - state = hass.states.get(device_tracker) - assert state + assert (state := hass.states.get(device_tracker)) assert state.state == STATE_NOT_HOME diff --git a/tests/components/vodafone_station/test_sensor.py b/tests/components/vodafone_station/test_sensor.py index ddf97824c75..5f27b67e3dd 100644 --- a/tests/components/vodafone_station/test_sensor.py +++ b/tests/components/vodafone_station/test_sensor.py @@ -55,8 +55,7 @@ async def test_active_connection_type( active_connection_entity = "sensor.vodafone_station_m123456789_active_connection" - state = hass.states.get(active_connection_entity) - assert state + assert (state := hass.states.get(active_connection_entity)) assert state.state == STATE_UNKNOWN mock_vodafone_station_router.get_sensor_data.return_value[connection_type] = ( @@ -67,8 +66,7 @@ async def test_active_connection_type( async_fire_time_changed(hass) await hass.async_block_till_done(wait_background_tasks=True) - state = hass.states.get(active_connection_entity) - assert state + assert (state := hass.states.get(active_connection_entity)) assert state.state == LINE_TYPES[index] @@ -85,8 +83,7 @@ async def test_uptime( uptime = "2024-11-19T20:19:00+00:00" uptime_entity = "sensor.vodafone_station_m123456789_uptime" - state = hass.states.get(uptime_entity) - assert state + assert (state := hass.states.get(uptime_entity)) assert state.state == uptime mock_vodafone_station_router.get_sensor_data.return_value["sys_uptime"] = "12:17:23" @@ -95,8 +92,7 @@ async def test_uptime( async_fire_time_changed(hass) await hass.async_block_till_done(wait_background_tasks=True) - state = hass.states.get(uptime_entity) - assert state + assert (state := hass.states.get(uptime_entity)) assert state.state == uptime @@ -124,6 +120,5 @@ async def test_coordinator_client_connector_error( async_fire_time_changed(hass) await hass.async_block_till_done(wait_background_tasks=True) - state = hass.states.get("sensor.vodafone_station_m123456789_uptime") - assert state + assert (state := hass.states.get("sensor.vodafone_station_m123456789_uptime")) assert state.state == STATE_UNAVAILABLE From 1800e6fb8eac883bf747f9cc473323fee3cadc1a Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Sat, 29 Mar 2025 19:19:30 +0100 Subject: [PATCH 008/505] Add common states for "Opening" and "Closing" (#141747) --- homeassistant/strings.json | 2 ++ 1 file changed, 2 insertions(+) diff --git a/homeassistant/strings.json b/homeassistant/strings.json index c1c763bb7cb..13a6d1ef759 100644 --- a/homeassistant/strings.json +++ b/homeassistant/strings.json @@ -120,6 +120,7 @@ "active": "Active", "charging": "Charging", "closed": "Closed", + "closing": "Closing", "connected": "Connected", "disabled": "Disabled", "discharging": "Discharging", @@ -133,6 +134,7 @@ "off": "Off", "on": "On", "open": "Open", + "opening": "Opening", "paused": "Paused", "standby": "Standby", "stopped": "Stopped", From 83f4f4cc965abc255aac9ebb2bd60649027d9730 Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Sat, 29 Mar 2025 19:19:56 +0100 Subject: [PATCH 009/505] Replace "Stopped" with common state in `ipp` (#141750) --- homeassistant/components/ipp/strings.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/ipp/strings.json b/homeassistant/components/ipp/strings.json index ac879ef0ab3..b4c092c8ae3 100644 --- a/homeassistant/components/ipp/strings.json +++ b/homeassistant/components/ipp/strings.json @@ -38,7 +38,7 @@ "state": { "printing": "Printing", "idle": "[%key:common::state::idle%]", - "stopped": "Stopped" + "stopped": "[%key:common::state::stopped%]" } }, "uptime": { From 4398af51c88859a05996846356e179e2b43fd843 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Sat, 29 Mar 2025 21:57:43 +0100 Subject: [PATCH 010/505] Fix spamming log message in QNAP (#141752) --- homeassistant/components/qnap/coordinator.py | 36 +++++++++++++++----- 1 file changed, 28 insertions(+), 8 deletions(-) diff --git a/homeassistant/components/qnap/coordinator.py b/homeassistant/components/qnap/coordinator.py index 297f6569d2b..a6d654ddbbd 100644 --- a/homeassistant/components/qnap/coordinator.py +++ b/homeassistant/components/qnap/coordinator.py @@ -2,11 +2,13 @@ from __future__ import annotations +from contextlib import contextmanager, nullcontext from datetime import timedelta import logging from typing import Any from qnapstats import QNAPStats +import urllib3 from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( @@ -28,6 +30,17 @@ UPDATE_INTERVAL = timedelta(minutes=1) _LOGGER = logging.getLogger(__name__) +@contextmanager +def suppress_insecure_request_warning(): + """Context manager to suppress InsecureRequestWarning. + + Was added in here to solve the following issue, not being solved upstream. + https://github.com/colinodell/python-qnapstats/issues/96 + """ + with urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning): + yield + + class QnapCoordinator(DataUpdateCoordinator[dict[str, dict[str, Any]]]): """Custom coordinator for the qnap integration.""" @@ -42,24 +55,31 @@ class QnapCoordinator(DataUpdateCoordinator[dict[str, dict[str, Any]]]): ) protocol = "https" if config_entry.data[CONF_SSL] else "http" + self._verify_ssl = config_entry.data.get(CONF_VERIFY_SSL) + self._api = QNAPStats( f"{protocol}://{config_entry.data.get(CONF_HOST)}", config_entry.data.get(CONF_PORT), config_entry.data.get(CONF_USERNAME), config_entry.data.get(CONF_PASSWORD), - verify_ssl=config_entry.data.get(CONF_VERIFY_SSL), + verify_ssl=self._verify_ssl, timeout=config_entry.data.get(CONF_TIMEOUT), ) def _sync_update(self) -> dict[str, dict[str, Any]]: """Get the latest data from the Qnap API.""" - return { - "system_stats": self._api.get_system_stats(), - "system_health": self._api.get_system_health(), - "smart_drive_health": self._api.get_smart_disk_health(), - "volumes": self._api.get_volumes(), - "bandwidth": self._api.get_bandwidth(), - } + with ( + suppress_insecure_request_warning() + if not self._verify_ssl + else nullcontext() + ): + return { + "system_stats": self._api.get_system_stats(), + "system_health": self._api.get_system_health(), + "smart_drive_health": self._api.get_smart_disk_health(), + "volumes": self._api.get_volumes(), + "bandwidth": self._api.get_bandwidth(), + } async def _async_update_data(self) -> dict[str, dict[str, Any]]: """Get the latest data from the Qnap API.""" From aba01d436181be7259fd356945909c52a90320ef Mon Sep 17 00:00:00 2001 From: Florent Thoumie Date: Sat, 29 Mar 2025 14:03:35 -0700 Subject: [PATCH 011/505] Remove iaqualink warning caused by via_device (#141761) Remove warning caused by via_device --- homeassistant/components/iaqualink/entity.py | 1 - 1 file changed, 1 deletion(-) diff --git a/homeassistant/components/iaqualink/entity.py b/homeassistant/components/iaqualink/entity.py index 437611e5a5f..d0176ed8bfe 100644 --- a/homeassistant/components/iaqualink/entity.py +++ b/homeassistant/components/iaqualink/entity.py @@ -32,7 +32,6 @@ class AqualinkEntity(Entity): manufacturer=dev.manufacturer, model=dev.model, name=dev.label, - via_device=(DOMAIN, dev.system.serial), ) async def async_added_to_hass(self) -> None: From 35b9564ed4b5570b54ce5abcd7aeb78f0bdf7c29 Mon Sep 17 00:00:00 2001 From: Benjamin Bender Date: Sat, 29 Mar 2025 22:04:57 +0100 Subject: [PATCH 012/505] Show external cover art in music-assistant-integration (#141716) * fix: handling of external album-art in music-assistant-integration * chore: refinements * make the image-logic more readable * fix code comment to be accurate --- .../music_assistant/media_player.py | 23 ++++++++++++------- 1 file changed, 15 insertions(+), 8 deletions(-) diff --git a/homeassistant/components/music_assistant/media_player.py b/homeassistant/components/music_assistant/media_player.py index 7d26f5b3a0c..01a103f9bc4 100644 --- a/homeassistant/components/music_assistant/media_player.py +++ b/homeassistant/components/music_assistant/media_player.py @@ -592,17 +592,24 @@ class MusicAssistantPlayer(MusicAssistantEntity, MediaPlayerEntity): def _update_media_image_url( self, player: Player, queue: PlayerQueue | None ) -> None: - """Update image URL for the active queue item.""" - if queue is None or queue.current_item is None: - self._attr_media_image_url = None - return - if image_url := self.mass.get_media_item_image_url(queue.current_item): + """Update image URL.""" + if queue and queue.current_item: + # image_url is provided by an music-assistant queue + image_url = self.mass.get_media_item_image_url(queue.current_item) + elif player.current_media and player.current_media.image_url: + # image_url is provided by an external source + image_url = player.current_media.image_url + else: + image_url = None + + # check if the image is provided via music-assistant and therefore + # not accessible from the outside + if image_url: self._attr_media_image_remotely_accessible = ( self.mass.server_url not in image_url ) - self._attr_media_image_url = image_url - return - self._attr_media_image_url = None + + self._attr_media_image_url = image_url def _update_media_attributes( self, player: Player, queue: PlayerQueue | None From bcead72265fa37c3da4c91f9fa8b6a8843c8e0c8 Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Sat, 29 Mar 2025 22:05:34 +0100 Subject: [PATCH 013/505] Replace "Stopped" with common state in `traccar_server` (#141751) --- homeassistant/components/traccar_server/strings.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/traccar_server/strings.json b/homeassistant/components/traccar_server/strings.json index 8bec4b112ac..3487f41efaa 100644 --- a/homeassistant/components/traccar_server/strings.json +++ b/homeassistant/components/traccar_server/strings.json @@ -47,7 +47,7 @@ "motion": { "name": "Motion", "state": { - "off": "Stopped", + "off": "[%key:common::state::stopped%]", "on": "Moving" } }, From b65b5aacb6d16e92af3ccec9f8739853043e9fd8 Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Sat, 29 Mar 2025 22:06:15 +0100 Subject: [PATCH 014/505] Add common state references to `cover`, `valve` and `lock` (#141754) * Add common states to `cover` * @NoRi2909 Add common states to `valve` * Add common states to `lock` --- homeassistant/components/cover/strings.json | 6 +++--- homeassistant/components/lock/strings.json | 2 +- homeassistant/components/valve/strings.json | 6 +++--- 3 files changed, 7 insertions(+), 7 deletions(-) diff --git a/homeassistant/components/cover/strings.json b/homeassistant/components/cover/strings.json index 0afef8a200f..6ca8b50620f 100644 --- a/homeassistant/components/cover/strings.json +++ b/homeassistant/components/cover/strings.json @@ -38,10 +38,10 @@ "name": "[%key:component::cover::title%]", "state": { "open": "[%key:common::state::open%]", - "opening": "Opening", + "opening": "[%key:common::state::opening%]", "closed": "[%key:common::state::closed%]", - "closing": "Closing", - "stopped": "Stopped" + "closing": "[%key:common::state::closing%]", + "stopped": "[%key:common::state::stopped%]" }, "state_attributes": { "current_position": { diff --git a/homeassistant/components/lock/strings.json b/homeassistant/components/lock/strings.json index fd8636acf97..fd2854b7932 100644 --- a/homeassistant/components/lock/strings.json +++ b/homeassistant/components/lock/strings.json @@ -28,7 +28,7 @@ "locked": "[%key:common::state::locked%]", "locking": "Locking", "open": "[%key:common::state::open%]", - "opening": "Opening", + "opening": "[%key:common::state::opening%]", "unlocked": "[%key:common::state::unlocked%]", "unlocking": "Unlocking" }, diff --git a/homeassistant/components/valve/strings.json b/homeassistant/components/valve/strings.json index b86ec371b34..39dc297fe7d 100644 --- a/homeassistant/components/valve/strings.json +++ b/homeassistant/components/valve/strings.json @@ -5,10 +5,10 @@ "name": "[%key:component::valve::title%]", "state": { "open": "[%key:common::state::open%]", - "opening": "Opening", + "opening": "[%key:common::state::opening%]", "closed": "[%key:common::state::closed%]", - "closing": "Closing", - "stopped": "Stopped" + "closing": "[%key:common::state::closing%]", + "stopped": "[%key:common::state::stopped%]" }, "state_attributes": { "current_position": { From a2194457516bdf6daad76b0399b8f42618240938 Mon Sep 17 00:00:00 2001 From: puddly <32534428+puddly@users.noreply.github.com> Date: Sat, 29 Mar 2025 17:26:37 -0400 Subject: [PATCH 015/505] Add helper methods to simplify USB integration testing (#141733) * Add some helper methods to simplify USB integration testing * Re-export `usb_device_from_port` --- homeassistant/components/usb/__init__.py | 40 +-- homeassistant/components/usb/utils.py | 12 + tests/components/usb/__init__.py | 67 ++-- tests/components/usb/test_init.py | 415 +++++++++++------------ 4 files changed, 266 insertions(+), 268 deletions(-) diff --git a/homeassistant/components/usb/__init__.py b/homeassistant/components/usb/__init__.py index 994f4f71c35..90433b0f728 100644 --- a/homeassistant/components/usb/__init__.py +++ b/homeassistant/components/usb/__init__.py @@ -14,8 +14,6 @@ import sys from typing import Any, overload from aiousbwatcher import AIOUSBWatcher, InotifyNotAvailableError -from serial.tools.list_ports import comports -from serial.tools.list_ports_common import ListPortInfo import voluptuous as vol from homeassistant import config_entries @@ -43,7 +41,10 @@ from homeassistant.loader import USBMatcher, async_get_usb from .const import DOMAIN from .models import USBDevice -from .utils import usb_device_from_port +from .utils import ( + scan_serial_ports, + usb_device_from_port, # noqa: F401 +) _LOGGER = logging.getLogger(__name__) @@ -241,6 +242,13 @@ def _is_matching(device: USBDevice, matcher: USBMatcher | USBCallbackMatcher) -> return True +async def async_request_scan(hass: HomeAssistant) -> None: + """Request a USB scan.""" + usb_discovery: USBDiscovery = hass.data[DOMAIN] + if not usb_discovery.observer_active: + await usb_discovery.async_request_scan() + + class USBDiscovery: """Manage USB Discovery.""" @@ -417,14 +425,8 @@ class USBDiscovery: service_info, ) - async def _async_process_ports(self, ports: Sequence[ListPortInfo]) -> None: + async def _async_process_ports(self, usb_devices: Sequence[USBDevice]) -> None: """Process each discovered port.""" - _LOGGER.debug("Processing ports: %r", ports) - usb_devices = { - usb_device_from_port(port) - for port in ports - if port.vid is not None or port.pid is not None - } _LOGGER.debug("USB devices: %r", usb_devices) # CP2102N chips create *two* serial ports on macOS: `/dev/cu.usbserial-` and @@ -436,7 +438,7 @@ class USBDiscovery: if dev.device.startswith("/dev/cu.SLAB_USBtoUART") } - usb_devices = { + filtered_usb_devices = { dev for dev in usb_devices if dev.serial_number not in silabs_serials @@ -445,10 +447,12 @@ class USBDiscovery: and dev.device.startswith("/dev/cu.SLAB_USBtoUART") ) } + else: + filtered_usb_devices = set(usb_devices) - added_devices = usb_devices - self._last_processed_devices - removed_devices = self._last_processed_devices - usb_devices - self._last_processed_devices = usb_devices + added_devices = filtered_usb_devices - self._last_processed_devices + removed_devices = self._last_processed_devices - filtered_usb_devices + self._last_processed_devices = filtered_usb_devices _LOGGER.debug( "Added devices: %r, removed devices: %r", added_devices, removed_devices @@ -461,7 +465,7 @@ class USBDiscovery: except Exception: _LOGGER.exception("Error in USB port event callback") - for usb_device in usb_devices: + for usb_device in filtered_usb_devices: await self._async_process_discovered_usb_device(usb_device) @hass_callback @@ -483,7 +487,7 @@ class USBDiscovery: _LOGGER.debug("Executing comports scan") async with self._scan_lock: await self._async_process_ports( - await self.hass.async_add_executor_job(comports) + await self.hass.async_add_executor_job(scan_serial_ports) ) if self.initial_scan_done: return @@ -521,9 +525,7 @@ async def websocket_usb_scan( msg: dict[str, Any], ) -> None: """Scan for new usb devices.""" - usb_discovery: USBDiscovery = hass.data[DOMAIN] - if not usb_discovery.observer_active: - await usb_discovery.async_request_scan() + await async_request_scan(hass) connection.send_result(msg["id"]) diff --git a/homeassistant/components/usb/utils.py b/homeassistant/components/usb/utils.py index d1d6fb17f3c..1bb620ec5f7 100644 --- a/homeassistant/components/usb/utils.py +++ b/homeassistant/components/usb/utils.py @@ -2,6 +2,9 @@ from __future__ import annotations +from collections.abc import Sequence + +from serial.tools.list_ports import comports from serial.tools.list_ports_common import ListPortInfo from .models import USBDevice @@ -17,3 +20,12 @@ def usb_device_from_port(port: ListPortInfo) -> USBDevice: manufacturer=port.manufacturer, description=port.description, ) + + +def scan_serial_ports() -> Sequence[USBDevice]: + """Scan serial ports for USB devices.""" + return [ + usb_device_from_port(port) + for port in comports() + if port.vid is not None or port.pid is not None + ] diff --git a/tests/components/usb/__init__.py b/tests/components/usb/__init__.py index 96d671d0958..6db0cea1ffe 100644 --- a/tests/components/usb/__init__.py +++ b/tests/components/usb/__init__.py @@ -1,44 +1,29 @@ """Tests for the USB Discovery integration.""" -from homeassistant.components.usb.models import USBDevice +from unittest.mock import patch -conbee_device = USBDevice( - device="/dev/cu.usbmodemDE24338801", - vid="1CF1", - pid="0030", - serial_number="DE2433880", - manufacturer="dresden elektronik ingenieurtechnik GmbH", - description="ConBee II", -) -slae_sh_device = USBDevice( - device="/dev/cu.usbserial-110", - vid="10C4", - pid="EA60", - serial_number="00_12_4B_00_22_98_88_7F", - manufacturer="Silicon Labs", - description="slae.sh cc2652rb stick - slaesh's iot stuff", -) -electro_lama_device = USBDevice( - device="/dev/cu.usbserial-110", - vid="1A86", - pid="7523", - serial_number=None, - manufacturer=None, - description="USB2.0-Serial", -) -skyconnect_macos_correct = USBDevice( - device="/dev/cu.SLAB_USBtoUART", - vid="10C4", - pid="EA60", - serial_number="9ab1da1ea4b3ed11956f4eaca7669f5d", - manufacturer="Nabu Casa", - description="SkyConnect v1.0", -) -skyconnect_macos_incorrect = USBDevice( - device="/dev/cu.usbserial-2110", - vid="10C4", - pid="EA60", - serial_number="9ab1da1ea4b3ed11956f4eaca7669f5d", - manufacturer="Nabu Casa", - description="SkyConnect v1.0", -) +from aiousbwatcher import InotifyNotAvailableError +import pytest + +from homeassistant.components.usb import async_request_scan as usb_async_request_scan +from homeassistant.core import HomeAssistant + + +@pytest.fixture(name="force_usb_polling_watcher") +def force_usb_polling_watcher(): + """Patch the USB integration to not use inotify and fall back to polling.""" + with patch( + "homeassistant.components.usb.AIOUSBWatcher.async_start", + side_effect=InotifyNotAvailableError, + ): + yield + + +def patch_scanned_serial_ports(**kwargs) -> None: + """Patch the USB integration's list of scanned serial ports.""" + return patch("homeassistant.components.usb.scan_serial_ports", **kwargs) + + +async def async_request_scan(hass: HomeAssistant) -> None: + """Request a USB scan.""" + return await usb_async_request_scan(hass) diff --git a/tests/components/usb/test_init.py b/tests/components/usb/test_init.py index 9730dba53d7..3a56e929b22 100644 --- a/tests/components/usb/test_init.py +++ b/tests/components/usb/test_init.py @@ -7,31 +7,40 @@ import os from typing import Any from unittest.mock import MagicMock, Mock, call, patch, sentinel -from aiousbwatcher import InotifyNotAvailableError import pytest from homeassistant.components import usb -from homeassistant.components.usb.utils import usb_device_from_port +from homeassistant.components.usb.models import USBDevice from homeassistant.const import EVENT_HOMEASSISTANT_STARTED, EVENT_HOMEASSISTANT_STOP from homeassistant.core import HomeAssistant from homeassistant.helpers.service_info.usb import UsbServiceInfo from homeassistant.setup import async_setup_component from homeassistant.util import dt as dt_util -from . import conbee_device, slae_sh_device +from . import ( + force_usb_polling_watcher, # noqa: F401 + patch_scanned_serial_ports, +) from tests.common import async_fire_time_changed, import_and_test_deprecated_constant from tests.typing import WebSocketGenerator - -@pytest.fixture(name="aiousbwatcher_no_inotify") -def aiousbwatcher_no_inotify(): - """Patch AIOUSBWatcher to not use inotify.""" - with patch( - "homeassistant.components.usb.AIOUSBWatcher.async_start", - side_effect=InotifyNotAvailableError, - ): - yield +conbee_device = USBDevice( + device="/dev/cu.usbmodemDE24338801", + vid="1CF1", + pid="0030", + serial_number="DE2433880", + manufacturer="dresden elektronik ingenieurtechnik GmbH", + description="ConBee II", +) +slae_sh_device = USBDevice( + device="/dev/cu.usbserial-110", + vid="10C4", + pid="EA60", + serial_number="00_12_4B_00_22_98_88_7F", + manufacturer="Silicon Labs", + description="slae.sh cc2652rb stick - slaesh's iot stuff", +) async def test_aiousbwatcher_discovery( @@ -40,11 +49,11 @@ async def test_aiousbwatcher_discovery( """Test that aiousbwatcher can discover a device without raising an exception.""" new_usb = [{"domain": "test1", "vid": "3039"}, {"domain": "test2", "vid": "0FA0"}] - mock_comports = [ - MagicMock( + mock_ports = [ + USBDevice( device=slae_sh_device.device, - vid=12345, - pid=12345, + vid="3039", + pid="3039", serial_number=slae_sh_device.serial_number, manufacturer=slae_sh_device.manufacturer, description=slae_sh_device.description, @@ -63,7 +72,7 @@ async def test_aiousbwatcher_discovery( with ( patch("sys.platform", "linux"), patch("homeassistant.components.usb.async_get_usb", return_value=new_usb), - patch("homeassistant.components.usb.comports", return_value=mock_comports), + patch_scanned_serial_ports(return_value=mock_ports), patch( "homeassistant.components.usb.AIOUSBWatcher", return_value=MockAIOUSBWatcher ), @@ -81,11 +90,11 @@ async def test_aiousbwatcher_discovery( await hass.async_block_till_done() assert len(mock_config_flow.mock_calls) == 1 - mock_comports.append( - MagicMock( + mock_ports.append( + USBDevice( device=slae_sh_device.device, - vid=4000, - pid=4000, + vid="0FA0", + pid="0FA0", serial_number=slae_sh_device.serial_number, manufacturer=slae_sh_device.manufacturer, description=slae_sh_device.description, @@ -107,7 +116,7 @@ async def test_aiousbwatcher_discovery( await hass.async_block_till_done() -@pytest.mark.usefixtures("aiousbwatcher_no_inotify") +@pytest.mark.usefixtures("force_usb_polling_watcher") async def test_polling_discovery( hass: HomeAssistant, hass_ws_client: WebSocketGenerator ) -> None: @@ -115,19 +124,19 @@ async def test_polling_discovery( new_usb = [{"domain": "test1", "vid": "3039"}] mock_comports_found_device = asyncio.Event() - def get_comports() -> list: - nonlocal mock_comports + def scan_serial_ports() -> list: + nonlocal mock_ports # Only "find" a device after a few invocations - if len(mock_comports.mock_calls) < 5: + if len(mock_ports.mock_calls) < 5: return [] mock_comports_found_device.set() return [ - MagicMock( + USBDevice( device=slae_sh_device.device, - vid=12345, - pid=12345, + vid="3039", + pid="3039", serial_number=slae_sh_device.serial_number, manufacturer=slae_sh_device.manufacturer, description=slae_sh_device.description, @@ -141,9 +150,7 @@ async def test_polling_discovery( timedelta(seconds=0.01), ), patch("homeassistant.components.usb.async_get_usb", return_value=new_usb), - patch( - "homeassistant.components.usb.comports", side_effect=get_comports - ) as mock_comports, + patch_scanned_serial_ports(side_effect=scan_serial_ports) as mock_ports, patch.object(hass.config_entries.flow, "async_init") as mock_config_flow, ): assert await async_setup_component(hass, "usb", {"usb": {}}) @@ -163,16 +170,16 @@ async def test_polling_discovery( await hass.async_block_till_done() -@pytest.mark.usefixtures("aiousbwatcher_no_inotify") +@pytest.mark.usefixtures("force_usb_polling_watcher") async def test_removal_by_aiousbwatcher_before_started(hass: HomeAssistant) -> None: """Test a device is removed by the aiousbwatcher before started.""" new_usb = [{"domain": "test1", "vid": "3039", "pid": "3039"}] - mock_comports = [ - MagicMock( + mock_ports = [ + USBDevice( device=slae_sh_device.device, - vid=12345, - pid=12345, + vid="3039", + pid="3039", serial_number=slae_sh_device.serial_number, manufacturer=slae_sh_device.manufacturer, description=slae_sh_device.description, @@ -181,13 +188,13 @@ async def test_removal_by_aiousbwatcher_before_started(hass: HomeAssistant) -> N with ( patch("homeassistant.components.usb.async_get_usb", return_value=new_usb), - patch("homeassistant.components.usb.comports", return_value=mock_comports), + patch_scanned_serial_ports(return_value=mock_ports), patch.object(hass.config_entries.flow, "async_init") as mock_config_flow, ): assert await async_setup_component(hass, "usb", {"usb": {}}) await hass.async_block_till_done() - with patch("homeassistant.components.usb.comports", return_value=[]): + with patch_scanned_serial_ports(return_value=[]): hass.bus.async_fire(EVENT_HOMEASSISTANT_STARTED) await hass.async_block_till_done() @@ -197,18 +204,18 @@ async def test_removal_by_aiousbwatcher_before_started(hass: HomeAssistant) -> N await hass.async_block_till_done() -@pytest.mark.usefixtures("aiousbwatcher_no_inotify") +@pytest.mark.usefixtures("force_usb_polling_watcher") async def test_discovered_by_websocket_scan( hass: HomeAssistant, hass_ws_client: WebSocketGenerator ) -> None: """Test a device is discovered from websocket scan.""" new_usb = [{"domain": "test1", "vid": "3039", "pid": "3039"}] - mock_comports = [ - MagicMock( + mock_ports = [ + USBDevice( device=slae_sh_device.device, - vid=12345, - pid=12345, + vid="3039", + pid="3039", serial_number=slae_sh_device.serial_number, manufacturer=slae_sh_device.manufacturer, description=slae_sh_device.description, @@ -217,7 +224,7 @@ async def test_discovered_by_websocket_scan( with ( patch("homeassistant.components.usb.async_get_usb", return_value=new_usb), - patch("homeassistant.components.usb.comports", return_value=mock_comports), + patch_scanned_serial_ports(return_value=mock_ports), patch.object(hass.config_entries.flow, "async_init") as mock_config_flow, ): assert await async_setup_component(hass, "usb", {"usb": {}}) @@ -234,7 +241,7 @@ async def test_discovered_by_websocket_scan( assert mock_config_flow.mock_calls[0][1][0] == "test1" -@pytest.mark.usefixtures("aiousbwatcher_no_inotify") +@pytest.mark.usefixtures("force_usb_polling_watcher") async def test_discovered_by_websocket_scan_limited_by_description_matcher( hass: HomeAssistant, hass_ws_client: WebSocketGenerator ) -> None: @@ -243,11 +250,11 @@ async def test_discovered_by_websocket_scan_limited_by_description_matcher( {"domain": "test1", "vid": "3039", "pid": "3039", "description": "*2652*"} ] - mock_comports = [ - MagicMock( + mock_ports = [ + USBDevice( device=slae_sh_device.device, - vid=12345, - pid=12345, + vid="3039", + pid="3039", serial_number=slae_sh_device.serial_number, manufacturer=slae_sh_device.manufacturer, description=slae_sh_device.description, @@ -256,7 +263,7 @@ async def test_discovered_by_websocket_scan_limited_by_description_matcher( with ( patch("homeassistant.components.usb.async_get_usb", return_value=new_usb), - patch("homeassistant.components.usb.comports", return_value=mock_comports), + patch_scanned_serial_ports(return_value=mock_ports), patch.object(hass.config_entries.flow, "async_init") as mock_config_flow, ): assert await async_setup_component(hass, "usb", {"usb": {}}) @@ -273,7 +280,7 @@ async def test_discovered_by_websocket_scan_limited_by_description_matcher( assert mock_config_flow.mock_calls[0][1][0] == "test1" -@pytest.mark.usefixtures("aiousbwatcher_no_inotify") +@pytest.mark.usefixtures("force_usb_polling_watcher") async def test_most_targeted_matcher_wins( hass: HomeAssistant, hass_ws_client: WebSocketGenerator ) -> None: @@ -283,11 +290,11 @@ async def test_most_targeted_matcher_wins( {"domain": "more", "vid": "3039", "pid": "3039", "description": "*2652*"}, ] - mock_comports = [ - MagicMock( + mock_ports = [ + USBDevice( device=slae_sh_device.device, - vid=12345, - pid=12345, + vid="3039", + pid="3039", serial_number=slae_sh_device.serial_number, manufacturer=slae_sh_device.manufacturer, description=slae_sh_device.description, @@ -296,7 +303,7 @@ async def test_most_targeted_matcher_wins( with ( patch("homeassistant.components.usb.async_get_usb", return_value=new_usb), - patch("homeassistant.components.usb.comports", return_value=mock_comports), + patch_scanned_serial_ports(return_value=mock_ports), patch.object(hass.config_entries.flow, "async_init") as mock_config_flow, ): assert await async_setup_component(hass, "usb", {"usb": {}}) @@ -313,7 +320,7 @@ async def test_most_targeted_matcher_wins( assert mock_config_flow.mock_calls[0][1][0] == "more" -@pytest.mark.usefixtures("aiousbwatcher_no_inotify") +@pytest.mark.usefixtures("force_usb_polling_watcher") async def test_discovered_by_websocket_scan_rejected_by_description_matcher( hass: HomeAssistant, hass_ws_client: WebSocketGenerator ) -> None: @@ -322,11 +329,11 @@ async def test_discovered_by_websocket_scan_rejected_by_description_matcher( {"domain": "test1", "vid": "3039", "pid": "3039", "description": "*not_it*"} ] - mock_comports = [ - MagicMock( + mock_ports = [ + USBDevice( device=slae_sh_device.device, - vid=12345, - pid=12345, + vid="3039", + pid="3039", serial_number=slae_sh_device.serial_number, manufacturer=slae_sh_device.manufacturer, description=slae_sh_device.description, @@ -335,7 +342,7 @@ async def test_discovered_by_websocket_scan_rejected_by_description_matcher( with ( patch("homeassistant.components.usb.async_get_usb", return_value=new_usb), - patch("homeassistant.components.usb.comports", return_value=mock_comports), + patch_scanned_serial_ports(return_value=mock_ports), patch.object(hass.config_entries.flow, "async_init") as mock_config_flow, ): assert await async_setup_component(hass, "usb", {"usb": {}}) @@ -351,7 +358,7 @@ async def test_discovered_by_websocket_scan_rejected_by_description_matcher( assert len(mock_config_flow.mock_calls) == 0 -@pytest.mark.usefixtures("aiousbwatcher_no_inotify") +@pytest.mark.usefixtures("force_usb_polling_watcher") async def test_discovered_by_websocket_scan_limited_by_serial_number_matcher( hass: HomeAssistant, hass_ws_client: WebSocketGenerator ) -> None: @@ -365,11 +372,11 @@ async def test_discovered_by_websocket_scan_limited_by_serial_number_matcher( } ] - mock_comports = [ - MagicMock( + mock_ports = [ + USBDevice( device=slae_sh_device.device, - vid=12345, - pid=12345, + vid="3039", + pid="3039", serial_number=slae_sh_device.serial_number, manufacturer=slae_sh_device.manufacturer, description=slae_sh_device.description, @@ -378,7 +385,7 @@ async def test_discovered_by_websocket_scan_limited_by_serial_number_matcher( with ( patch("homeassistant.components.usb.async_get_usb", return_value=new_usb), - patch("homeassistant.components.usb.comports", return_value=mock_comports), + patch_scanned_serial_ports(return_value=mock_ports), patch.object(hass.config_entries.flow, "async_init") as mock_config_flow, ): assert await async_setup_component(hass, "usb", {"usb": {}}) @@ -395,7 +402,7 @@ async def test_discovered_by_websocket_scan_limited_by_serial_number_matcher( assert mock_config_flow.mock_calls[0][1][0] == "test1" -@pytest.mark.usefixtures("aiousbwatcher_no_inotify") +@pytest.mark.usefixtures("force_usb_polling_watcher") async def test_discovered_by_websocket_scan_rejected_by_serial_number_matcher( hass: HomeAssistant, hass_ws_client: WebSocketGenerator ) -> None: @@ -404,11 +411,11 @@ async def test_discovered_by_websocket_scan_rejected_by_serial_number_matcher( {"domain": "test1", "vid": "3039", "pid": "3039", "serial_number": "123*"} ] - mock_comports = [ - MagicMock( + mock_ports = [ + USBDevice( device=slae_sh_device.device, - vid=12345, - pid=12345, + vid="3039", + pid="3039", serial_number=slae_sh_device.serial_number, manufacturer=slae_sh_device.manufacturer, description=slae_sh_device.description, @@ -417,7 +424,7 @@ async def test_discovered_by_websocket_scan_rejected_by_serial_number_matcher( with ( patch("homeassistant.components.usb.async_get_usb", return_value=new_usb), - patch("homeassistant.components.usb.comports", return_value=mock_comports), + patch_scanned_serial_ports(return_value=mock_ports), patch.object(hass.config_entries.flow, "async_init") as mock_config_flow, ): assert await async_setup_component(hass, "usb", {"usb": {}}) @@ -433,7 +440,7 @@ async def test_discovered_by_websocket_scan_rejected_by_serial_number_matcher( assert len(mock_config_flow.mock_calls) == 0 -@pytest.mark.usefixtures("aiousbwatcher_no_inotify") +@pytest.mark.usefixtures("force_usb_polling_watcher") async def test_discovered_by_websocket_scan_limited_by_manufacturer_matcher( hass: HomeAssistant, hass_ws_client: WebSocketGenerator ) -> None: @@ -447,11 +454,11 @@ async def test_discovered_by_websocket_scan_limited_by_manufacturer_matcher( } ] - mock_comports = [ - MagicMock( + mock_ports = [ + USBDevice( device=conbee_device.device, - vid=12345, - pid=12345, + vid="3039", + pid="3039", serial_number=conbee_device.serial_number, manufacturer=conbee_device.manufacturer, description=conbee_device.description, @@ -460,7 +467,7 @@ async def test_discovered_by_websocket_scan_limited_by_manufacturer_matcher( with ( patch("homeassistant.components.usb.async_get_usb", return_value=new_usb), - patch("homeassistant.components.usb.comports", return_value=mock_comports), + patch_scanned_serial_ports(return_value=mock_ports), patch.object(hass.config_entries.flow, "async_init") as mock_config_flow, ): assert await async_setup_component(hass, "usb", {"usb": {}}) @@ -477,7 +484,7 @@ async def test_discovered_by_websocket_scan_limited_by_manufacturer_matcher( assert mock_config_flow.mock_calls[0][1][0] == "test1" -@pytest.mark.usefixtures("aiousbwatcher_no_inotify") +@pytest.mark.usefixtures("force_usb_polling_watcher") async def test_discovered_by_websocket_scan_rejected_by_manufacturer_matcher( hass: HomeAssistant, hass_ws_client: WebSocketGenerator ) -> None: @@ -491,11 +498,11 @@ async def test_discovered_by_websocket_scan_rejected_by_manufacturer_matcher( } ] - mock_comports = [ - MagicMock( + mock_ports = [ + USBDevice( device=conbee_device.device, - vid=12345, - pid=12345, + vid="3039", + pid="3039", serial_number=conbee_device.serial_number, manufacturer=conbee_device.manufacturer, description=conbee_device.description, @@ -504,7 +511,7 @@ async def test_discovered_by_websocket_scan_rejected_by_manufacturer_matcher( with ( patch("homeassistant.components.usb.async_get_usb", return_value=new_usb), - patch("homeassistant.components.usb.comports", return_value=mock_comports), + patch_scanned_serial_ports(return_value=mock_ports), patch.object(hass.config_entries.flow, "async_init") as mock_config_flow, ): assert await async_setup_component(hass, "usb", {"usb": {}}) @@ -520,7 +527,7 @@ async def test_discovered_by_websocket_scan_rejected_by_manufacturer_matcher( assert len(mock_config_flow.mock_calls) == 0 -@pytest.mark.usefixtures("aiousbwatcher_no_inotify") +@pytest.mark.usefixtures("force_usb_polling_watcher") async def test_discovered_by_websocket_rejected_with_empty_serial_number_only( hass: HomeAssistant, hass_ws_client: WebSocketGenerator ) -> None: @@ -529,11 +536,11 @@ async def test_discovered_by_websocket_rejected_with_empty_serial_number_only( {"domain": "test1", "vid": "3039", "pid": "3039", "serial_number": "123*"} ] - mock_comports = [ - MagicMock( + mock_ports = [ + USBDevice( device=conbee_device.device, - vid=12345, - pid=12345, + vid="3039", + pid="3039", serial_number=None, manufacturer=None, description=None, @@ -542,7 +549,7 @@ async def test_discovered_by_websocket_rejected_with_empty_serial_number_only( with ( patch("homeassistant.components.usb.async_get_usb", return_value=new_usb), - patch("homeassistant.components.usb.comports", return_value=mock_comports), + patch_scanned_serial_ports(return_value=mock_ports), patch.object(hass.config_entries.flow, "async_init") as mock_config_flow, ): assert await async_setup_component(hass, "usb", {"usb": {}}) @@ -558,18 +565,18 @@ async def test_discovered_by_websocket_rejected_with_empty_serial_number_only( assert len(mock_config_flow.mock_calls) == 0 -@pytest.mark.usefixtures("aiousbwatcher_no_inotify") +@pytest.mark.usefixtures("force_usb_polling_watcher") async def test_discovered_by_websocket_scan_match_vid_only( hass: HomeAssistant, hass_ws_client: WebSocketGenerator ) -> None: """Test a device is discovered from websocket scan only matching vid.""" new_usb = [{"domain": "test1", "vid": "3039"}] - mock_comports = [ - MagicMock( + mock_ports = [ + USBDevice( device=slae_sh_device.device, - vid=12345, - pid=12345, + vid="3039", + pid="3039", serial_number=slae_sh_device.serial_number, manufacturer=slae_sh_device.manufacturer, description=slae_sh_device.description, @@ -578,7 +585,7 @@ async def test_discovered_by_websocket_scan_match_vid_only( with ( patch("homeassistant.components.usb.async_get_usb", return_value=new_usb), - patch("homeassistant.components.usb.comports", return_value=mock_comports), + patch_scanned_serial_ports(return_value=mock_ports), patch.object(hass.config_entries.flow, "async_init") as mock_config_flow, ): assert await async_setup_component(hass, "usb", {"usb": {}}) @@ -595,18 +602,18 @@ async def test_discovered_by_websocket_scan_match_vid_only( assert mock_config_flow.mock_calls[0][1][0] == "test1" -@pytest.mark.usefixtures("aiousbwatcher_no_inotify") +@pytest.mark.usefixtures("force_usb_polling_watcher") async def test_discovered_by_websocket_scan_match_vid_wrong_pid( hass: HomeAssistant, hass_ws_client: WebSocketGenerator ) -> None: """Test a device is discovered from websocket scan only matching vid but wrong pid.""" new_usb = [{"domain": "test1", "vid": "3039", "pid": "9999"}] - mock_comports = [ - MagicMock( + mock_ports = [ + USBDevice( device=slae_sh_device.device, - vid=12345, - pid=12345, + vid="3039", + pid="3039", serial_number=slae_sh_device.serial_number, manufacturer=slae_sh_device.manufacturer, description=slae_sh_device.description, @@ -615,7 +622,7 @@ async def test_discovered_by_websocket_scan_match_vid_wrong_pid( with ( patch("homeassistant.components.usb.async_get_usb", return_value=new_usb), - patch("homeassistant.components.usb.comports", return_value=mock_comports), + patch_scanned_serial_ports(return_value=mock_ports), patch.object(hass.config_entries.flow, "async_init") as mock_config_flow, ): assert await async_setup_component(hass, "usb", {"usb": {}}) @@ -631,15 +638,15 @@ async def test_discovered_by_websocket_scan_match_vid_wrong_pid( assert len(mock_config_flow.mock_calls) == 0 -@pytest.mark.usefixtures("aiousbwatcher_no_inotify") +@pytest.mark.usefixtures("force_usb_polling_watcher") async def test_discovered_by_websocket_no_vid_pid( hass: HomeAssistant, hass_ws_client: WebSocketGenerator ) -> None: """Test a device is discovered from websocket scan with no vid or pid.""" new_usb = [{"domain": "test1", "vid": "3039", "pid": "9999"}] - mock_comports = [ - MagicMock( + mock_ports = [ + USBDevice( device=slae_sh_device.device, vid=None, pid=None, @@ -651,7 +658,7 @@ async def test_discovered_by_websocket_no_vid_pid( with ( patch("homeassistant.components.usb.async_get_usb", return_value=new_usb), - patch("homeassistant.components.usb.comports", return_value=mock_comports), + patch_scanned_serial_ports(return_value=mock_ports), patch.object(hass.config_entries.flow, "async_init") as mock_config_flow, ): assert await async_setup_component(hass, "usb", {"usb": {}}) @@ -667,18 +674,18 @@ async def test_discovered_by_websocket_no_vid_pid( assert len(mock_config_flow.mock_calls) == 0 -@pytest.mark.usefixtures("aiousbwatcher_no_inotify") +@pytest.mark.usefixtures("force_usb_polling_watcher") async def test_non_matching_discovered_by_scanner_after_started( hass: HomeAssistant, hass_ws_client: WebSocketGenerator ) -> None: """Test a websocket scan that does not match.""" new_usb = [{"domain": "test1", "vid": "4444", "pid": "4444"}] - mock_comports = [ - MagicMock( + mock_ports = [ + USBDevice( device=slae_sh_device.device, - vid=12345, - pid=12345, + vid="3039", + pid="3039", serial_number=slae_sh_device.serial_number, manufacturer=slae_sh_device.manufacturer, description=slae_sh_device.description, @@ -687,7 +694,7 @@ async def test_non_matching_discovered_by_scanner_after_started( with ( patch("homeassistant.components.usb.async_get_usb", return_value=new_usb), - patch("homeassistant.components.usb.comports", return_value=mock_comports), + patch_scanned_serial_ports(return_value=mock_ports), patch.object(hass.config_entries.flow, "async_init") as mock_config_flow, ): assert await async_setup_component(hass, "usb", {"usb": {}}) @@ -709,11 +716,11 @@ async def test_aiousbwatcher_on_wsl_fallback_without_throwing_exception( """Test that aiousbwatcher on WSL failure results in fallback to scanning without raising an exception.""" new_usb = [{"domain": "test1", "vid": "3039"}] - mock_comports = [ - MagicMock( + mock_ports = [ + USBDevice( device=slae_sh_device.device, - vid=12345, - pid=12345, + vid="3039", + pid="3039", serial_number=slae_sh_device.serial_number, manufacturer=slae_sh_device.manufacturer, description=slae_sh_device.description, @@ -722,7 +729,7 @@ async def test_aiousbwatcher_on_wsl_fallback_without_throwing_exception( with ( patch("homeassistant.components.usb.async_get_usb", return_value=new_usb), - patch("homeassistant.components.usb.comports", return_value=mock_comports), + patch_scanned_serial_ports(return_value=mock_ports), patch.object(hass.config_entries.flow, "async_init") as mock_config_flow, ): assert await async_setup_component(hass, "usb", {"usb": {}}) @@ -743,17 +750,17 @@ async def test_discovered_by_aiousbwatcher_before_started(hass: HomeAssistant) - """Test a device is discovered since aiousbwatcher is now running.""" new_usb = [{"domain": "test1", "vid": "3039", "pid": "3039"}] - mock_comports = [ - MagicMock( + mock_ports = [ + USBDevice( device=slae_sh_device.device, - vid=12345, - pid=12345, + vid="3039", + pid="3039", serial_number=slae_sh_device.serial_number, manufacturer=slae_sh_device.manufacturer, description=slae_sh_device.description, ) ] - initial_mock_comports = [] + initial_ports = [] aiousbwatcher_callback = None def async_register_callback(callback): @@ -766,9 +773,7 @@ async def test_discovered_by_aiousbwatcher_before_started(hass: HomeAssistant) - with ( patch("sys.platform", "linux"), patch("homeassistant.components.usb.async_get_usb", return_value=new_usb), - patch( - "homeassistant.components.usb.comports", return_value=initial_mock_comports - ), + patch_scanned_serial_ports(return_value=initial_ports), patch( "homeassistant.components.usb.AIOUSBWatcher", return_value=MockAIOUSBWatcher ), @@ -782,7 +787,7 @@ async def test_discovered_by_aiousbwatcher_before_started(hass: HomeAssistant) - assert len(mock_config_flow.mock_calls) == 0 - initial_mock_comports.extend(mock_comports) + initial_ports.extend(mock_ports) aiousbwatcher_callback() await hass.async_block_till_done() @@ -874,18 +879,18 @@ def test_human_readable_device_name() -> None: assert "8A2A" in name -@pytest.mark.usefixtures("aiousbwatcher_no_inotify") +@pytest.mark.usefixtures("force_usb_polling_watcher") async def test_async_is_plugged_in( hass: HomeAssistant, hass_ws_client: WebSocketGenerator ) -> None: """Test async_is_plugged_in.""" new_usb = [{"domain": "test1", "vid": "3039", "pid": "3039"}] - mock_comports = [ - MagicMock( + mock_ports = [ + USBDevice( device=slae_sh_device.device, - vid=12345, - pid=12345, + vid="3039", + pid="3039", serial_number=slae_sh_device.serial_number, manufacturer=slae_sh_device.manufacturer, description=slae_sh_device.description, @@ -899,7 +904,7 @@ async def test_async_is_plugged_in( with ( patch("homeassistant.components.usb.async_get_usb", return_value=new_usb), - patch("homeassistant.components.usb.comports", return_value=[]), + patch_scanned_serial_ports(return_value=[]), patch.object(hass.config_entries.flow, "async_init"), ): assert await async_setup_component(hass, "usb", {"usb": {}}) @@ -909,7 +914,7 @@ async def test_async_is_plugged_in( assert not usb.async_is_plugged_in(hass, matcher) with ( - patch("homeassistant.components.usb.comports", return_value=mock_comports), + patch_scanned_serial_ports(return_value=mock_ports), patch.object(hass.config_entries.flow, "async_init"), ): ws_client = await hass_ws_client(hass) @@ -920,7 +925,7 @@ async def test_async_is_plugged_in( assert usb.async_is_plugged_in(hass, matcher) -@pytest.mark.usefixtures("aiousbwatcher_no_inotify") +@pytest.mark.usefixtures("force_usb_polling_watcher") @pytest.mark.parametrize( "matcher", [ @@ -940,7 +945,7 @@ async def test_async_is_plugged_in_case_enforcement( with ( patch("homeassistant.components.usb.async_get_usb", return_value=new_usb), - patch("homeassistant.components.usb.comports", return_value=[]), + patch_scanned_serial_ports(return_value=[]), patch.object(hass.config_entries.flow, "async_init"), ): assert await async_setup_component(hass, "usb", {"usb": {}}) @@ -952,7 +957,7 @@ async def test_async_is_plugged_in_case_enforcement( usb.async_is_plugged_in(hass, matcher) -@pytest.mark.usefixtures("aiousbwatcher_no_inotify") +@pytest.mark.usefixtures("force_usb_polling_watcher") async def test_web_socket_triggers_discovery_request_callbacks( hass: HomeAssistant, hass_ws_client: WebSocketGenerator ) -> None: @@ -961,7 +966,7 @@ async def test_web_socket_triggers_discovery_request_callbacks( with ( patch("homeassistant.components.usb.async_get_usb", return_value=[]), - patch("homeassistant.components.usb.comports", return_value=[]), + patch_scanned_serial_ports(return_value=[]), patch.object(hass.config_entries.flow, "async_init"), ): assert await async_setup_component(hass, "usb", {"usb": {}}) @@ -987,7 +992,7 @@ async def test_web_socket_triggers_discovery_request_callbacks( assert len(mock_callback.mock_calls) == 1 -@pytest.mark.usefixtures("aiousbwatcher_no_inotify") +@pytest.mark.usefixtures("force_usb_polling_watcher") async def test_initial_scan_callback( hass: HomeAssistant, hass_ws_client: WebSocketGenerator ) -> None: @@ -997,7 +1002,7 @@ async def test_initial_scan_callback( with ( patch("homeassistant.components.usb.async_get_usb", return_value=[]), - patch("homeassistant.components.usb.comports", return_value=[]), + patch_scanned_serial_ports(return_value=[]), patch.object(hass.config_entries.flow, "async_init"), ): assert await async_setup_component(hass, "usb", {"usb": {}}) @@ -1023,7 +1028,7 @@ async def test_initial_scan_callback( cancel_2() -@pytest.mark.usefixtures("aiousbwatcher_no_inotify") +@pytest.mark.usefixtures("force_usb_polling_watcher") async def test_cancel_initial_scan_callback( hass: HomeAssistant, hass_ws_client: WebSocketGenerator ) -> None: @@ -1032,7 +1037,7 @@ async def test_cancel_initial_scan_callback( with ( patch("homeassistant.components.usb.async_get_usb", return_value=[]), - patch("homeassistant.components.usb.comports", return_value=[]), + patch_scanned_serial_ports(return_value=[]), patch.object(hass.config_entries.flow, "async_init"), ): assert await async_setup_component(hass, "usb", {"usb": {}}) @@ -1049,18 +1054,18 @@ async def test_cancel_initial_scan_callback( assert len(mock_callback.mock_calls) == 0 -@pytest.mark.usefixtures("aiousbwatcher_no_inotify") +@pytest.mark.usefixtures("force_usb_polling_watcher") async def test_resolve_serial_by_id( hass: HomeAssistant, hass_ws_client: WebSocketGenerator ) -> None: """Test the discovery data resolves to serial/by-id.""" new_usb = [{"domain": "test1", "vid": "3039", "pid": "3039"}] - mock_comports = [ - MagicMock( + mock_ports = [ + USBDevice( device=slae_sh_device.device, - vid=12345, - pid=12345, + vid="3039", + pid="3039", serial_number=slae_sh_device.serial_number, manufacturer=slae_sh_device.manufacturer, description=slae_sh_device.description, @@ -1069,7 +1074,7 @@ async def test_resolve_serial_by_id( with ( patch("homeassistant.components.usb.async_get_usb", return_value=new_usb), - patch("homeassistant.components.usb.comports", return_value=mock_comports), + patch_scanned_serial_ports(return_value=mock_ports), patch( "homeassistant.components.usb.get_serial_by_id", return_value="/dev/serial/by-id/bla", @@ -1091,73 +1096,73 @@ async def test_resolve_serial_by_id( assert mock_config_flow.mock_calls[0][2]["data"].device == "/dev/serial/by-id/bla" -@pytest.mark.usefixtures("aiousbwatcher_no_inotify") +@pytest.mark.usefixtures("force_usb_polling_watcher") @pytest.mark.parametrize( "ports", [ [ - MagicMock( + USBDevice( device="/dev/cu.usbserial-2120", - vid=0x3039, - pid=0x3039, + vid="3039", + pid="3039", serial_number=conbee_device.serial_number, manufacturer=conbee_device.manufacturer, description=conbee_device.description, ), - MagicMock( + USBDevice( device="/dev/cu.usbserial-1120", - vid=0x3039, - pid=0x3039, + vid="3039", + pid="3039", serial_number=slae_sh_device.serial_number, manufacturer=slae_sh_device.manufacturer, description=slae_sh_device.description, ), - MagicMock( + USBDevice( device="/dev/cu.SLAB_USBtoUART", - vid=0x3039, - pid=0x3039, + vid="3039", + pid="3039", serial_number=conbee_device.serial_number, manufacturer=conbee_device.manufacturer, description=conbee_device.description, ), - MagicMock( + USBDevice( device="/dev/cu.SLAB_USBtoUART2", - vid=0x3039, - pid=0x3039, + vid="3039", + pid="3039", serial_number=slae_sh_device.serial_number, manufacturer=slae_sh_device.manufacturer, description=slae_sh_device.description, ), ], [ - MagicMock( + USBDevice( device="/dev/cu.SLAB_USBtoUART2", - vid=0x3039, - pid=0x3039, + vid="3039", + pid="3039", serial_number=slae_sh_device.serial_number, manufacturer=slae_sh_device.manufacturer, description=slae_sh_device.description, ), - MagicMock( + USBDevice( device="/dev/cu.SLAB_USBtoUART", - vid=0x3039, - pid=0x3039, + vid="3039", + pid="3039", serial_number=conbee_device.serial_number, manufacturer=conbee_device.manufacturer, description=conbee_device.description, ), - MagicMock( + USBDevice( device="/dev/cu.usbserial-1120", - vid=0x3039, - pid=0x3039, + vid="3039", + pid="3039", serial_number=slae_sh_device.serial_number, manufacturer=slae_sh_device.manufacturer, description=slae_sh_device.description, ), - MagicMock( + USBDevice( device="/dev/cu.usbserial-2120", - vid=0x3039, - pid=0x3039, + vid="3039", + pid="3039", serial_number=conbee_device.serial_number, manufacturer=conbee_device.manufacturer, description=conbee_device.description, @@ -1177,7 +1182,7 @@ async def test_cp2102n_ordering_on_macos( with ( patch("sys.platform", "darwin"), patch("homeassistant.components.usb.async_get_usb", return_value=new_usb), - patch("homeassistant.components.usb.comports", return_value=ports), + patch_scanned_serial_ports(return_value=ports), patch.object(hass.config_entries.flow, "async_init") as mock_config_flow, ): assert await async_setup_component(hass, "usb", {"usb": {}}) @@ -1224,34 +1229,31 @@ def test_deprecated_constants( ) -@pytest.mark.usefixtures("aiousbwatcher_no_inotify") +@pytest.mark.usefixtures("force_usb_polling_watcher") @patch("homeassistant.components.usb.REQUEST_SCAN_COOLDOWN", 0) async def test_register_port_event_callback( hass: HomeAssistant, hass_ws_client: WebSocketGenerator ) -> None: """Test the registration of a port event callback.""" - port1 = Mock( + port1 = USBDevice( device=slae_sh_device.device, - vid=12345, - pid=12345, + vid="3039", + pid="3039", serial_number=slae_sh_device.serial_number, manufacturer=slae_sh_device.manufacturer, description=slae_sh_device.description, ) - port2 = Mock( + port2 = USBDevice( device=conbee_device.device, - vid=12346, - pid=12346, + vid="303A", + pid="303A", serial_number=conbee_device.serial_number, manufacturer=conbee_device.manufacturer, description=conbee_device.description, ) - port1_usb = usb_device_from_port(port1) - port2_usb = usb_device_from_port(port2) - ws_client = await hass_ws_client(hass) mock_callback1 = Mock() @@ -1259,7 +1261,7 @@ async def test_register_port_event_callback( # Start off with no ports with ( - patch("homeassistant.components.usb.comports", return_value=[]), + patch_scanned_serial_ports(return_value=[]), ): assert await async_setup_component(hass, "usb", {"usb": {}}) @@ -1270,13 +1272,13 @@ async def test_register_port_event_callback( assert mock_callback2.mock_calls == [] # Add two new ports - with patch("homeassistant.components.usb.comports", return_value=[port1, port2]): + with patch_scanned_serial_ports(return_value=[port1, port2]): await ws_client.send_json({"id": 1, "type": "usb/scan"}) response = await ws_client.receive_json() assert response["success"] - assert mock_callback1.mock_calls == [call({port1_usb, port2_usb}, set())] - assert mock_callback2.mock_calls == [call({port1_usb, port2_usb}, set())] + assert mock_callback1.mock_calls == [call({port1, port2}, set())] + assert mock_callback2.mock_calls == [call({port1, port2}, set())] # Cancel the second callback cancel2() @@ -1286,20 +1288,20 @@ async def test_register_port_event_callback( mock_callback2.reset_mock() # Remove port 2 - with patch("homeassistant.components.usb.comports", return_value=[port1]): + with patch_scanned_serial_ports(return_value=[port1]): await ws_client.send_json({"id": 2, "type": "usb/scan"}) response = await ws_client.receive_json() assert response["success"] await hass.async_block_till_done() - assert mock_callback1.mock_calls == [call(set(), {port2_usb})] + assert mock_callback1.mock_calls == [call(set(), {port2})] assert mock_callback2.mock_calls == [] # The second callback was unregistered mock_callback1.reset_mock() mock_callback2.reset_mock() # Keep port 2 removed - with patch("homeassistant.components.usb.comports", return_value=[port1]): + with patch_scanned_serial_ports(return_value=[port1]): await ws_client.send_json({"id": 3, "type": "usb/scan"}) response = await ws_client.receive_json() assert response["success"] @@ -1310,17 +1312,17 @@ async def test_register_port_event_callback( assert mock_callback2.mock_calls == [] # Unplug one and plug in the other - with patch("homeassistant.components.usb.comports", return_value=[port2]): + with patch_scanned_serial_ports(return_value=[port2]): await ws_client.send_json({"id": 4, "type": "usb/scan"}) response = await ws_client.receive_json() assert response["success"] await hass.async_block_till_done() - assert mock_callback1.mock_calls == [call({port2_usb}, {port1_usb})] + assert mock_callback1.mock_calls == [call({port2}, {port1})] assert mock_callback2.mock_calls == [] -@pytest.mark.usefixtures("aiousbwatcher_no_inotify") +@pytest.mark.usefixtures("force_usb_polling_watcher") @patch("homeassistant.components.usb.REQUEST_SCAN_COOLDOWN", 0) async def test_register_port_event_callback_failure( hass: HomeAssistant, @@ -1329,27 +1331,24 @@ async def test_register_port_event_callback_failure( ) -> None: """Test port event callback failure handling.""" - port1 = Mock( + port1 = USBDevice( device=slae_sh_device.device, - vid=12345, - pid=12345, + vid="3039", + pid="3039", serial_number=slae_sh_device.serial_number, manufacturer=slae_sh_device.manufacturer, description=slae_sh_device.description, ) - port2 = Mock( + port2 = USBDevice( device=conbee_device.device, - vid=12346, - pid=12346, + vid="303A", + pid="303A", serial_number=conbee_device.serial_number, manufacturer=conbee_device.manufacturer, description=conbee_device.description, ) - port1_usb = usb_device_from_port(port1) - port2_usb = usb_device_from_port(port2) - ws_client = await hass_ws_client(hass) mock_callback1 = Mock(side_effect=RuntimeError("Failure 1")) @@ -1357,7 +1356,7 @@ async def test_register_port_event_callback_failure( # Start off with no ports with ( - patch("homeassistant.components.usb.comports", return_value=[]), + patch_scanned_serial_ports(return_value=[]), ): assert await async_setup_component(hass, "usb", {"usb": {}}) @@ -1369,7 +1368,7 @@ async def test_register_port_event_callback_failure( # Add two new ports with ( - patch("homeassistant.components.usb.comports", return_value=[port1, port2]), + patch_scanned_serial_ports(return_value=[port1, port2]), caplog.at_level(logging.ERROR, logger="homeassistant.components.usb"), ): await ws_client.send_json({"id": 1, "type": "usb/scan"}) @@ -1378,8 +1377,8 @@ async def test_register_port_event_callback_failure( await hass.async_block_till_done() # Both were called even though they raised exceptions - assert mock_callback1.mock_calls == [call({port1_usb, port2_usb}, set())] - assert mock_callback2.mock_calls == [call({port1_usb, port2_usb}, set())] + assert mock_callback1.mock_calls == [call({port1, port2}, set())] + assert mock_callback2.mock_calls == [call({port1, port2}, set())] assert caplog.text.count("Error in USB port event callback") == 2 assert "Failure 1" in caplog.text From a6c1f1e485dbafa8333ee43410a4b2fa3a16a1a0 Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Sat, 29 Mar 2025 22:48:28 +0100 Subject: [PATCH 016/505] Replace "Opening" / "Closing" with common states in `shelly` (#141767) --- homeassistant/components/shelly/strings.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/shelly/strings.json b/homeassistant/components/shelly/strings.json index afc3f92a3ce..3465891dc68 100644 --- a/homeassistant/components/shelly/strings.json +++ b/homeassistant/components/shelly/strings.json @@ -210,10 +210,10 @@ "state": { "checking": "Checking", "closed": "[%key:common::state::closed%]", - "closing": "Closing", + "closing": "[%key:common::state::closing%]", "failure": "Failure", "opened": "Opened", - "opening": "Opening" + "opening": "[%key:common::state::opening%]" } } } From ed99686cc13269901cdf3205d016ecefb72c482f Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 29 Mar 2025 13:10:08 -1000 Subject: [PATCH 017/505] Bump propcache to 0.3.1 (#141770) * Bump propcache to 0.3.1 changelog: https://github.com/aio-libs/propcache/compare/v0.3.0...v0.3.1 * revert --- homeassistant/package_constraints.txt | 2 +- pyproject.toml | 2 +- requirements.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 8172bfb450d..eff2b89e0e8 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -49,7 +49,7 @@ orjson==3.10.16 packaging>=23.1 paho-mqtt==2.1.0 Pillow==11.1.0 -propcache==0.3.0 +propcache==0.3.1 psutil-home-assistant==0.0.1 PyJWT==2.10.1 pymicro-vad==1.0.1 diff --git a/pyproject.toml b/pyproject.toml index 0a56de0f6f7..50fd8770f0d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -59,7 +59,7 @@ dependencies = [ # PyJWT has loose dependency. We want the latest one. "cryptography==44.0.1", "Pillow==11.1.0", - "propcache==0.3.0", + "propcache==0.3.1", "pyOpenSSL==25.0.0", "orjson==3.10.16", "packaging>=23.1", diff --git a/requirements.txt b/requirements.txt index 378240607cf..b13ef7b02e5 100644 --- a/requirements.txt +++ b/requirements.txt @@ -31,7 +31,7 @@ lru-dict==1.3.0 PyJWT==2.10.1 cryptography==44.0.1 Pillow==11.1.0 -propcache==0.3.0 +propcache==0.3.1 pyOpenSSL==25.0.0 orjson==3.10.16 packaging>=23.1 From 2be2d54a5c62238c60cf15b5e267ee026f22a4bf Mon Sep 17 00:00:00 2001 From: tdfountain <174762217+tdfountain@users.noreply.github.com> Date: Sat, 29 Mar 2025 18:19:41 -0700 Subject: [PATCH 018/505] Replace hard coded attributes with constants for test cases in NUT (#141774) Replace hard coded attributes with constants --- tests/components/nut/test_sensor.py | 41 ++++++++++++++++------------- 1 file changed, 22 insertions(+), 19 deletions(-) diff --git a/tests/components/nut/test_sensor.py b/tests/components/nut/test_sensor.py index cdec6c5083b..0bc6fb24c7b 100644 --- a/tests/components/nut/test_sensor.py +++ b/tests/components/nut/test_sensor.py @@ -7,6 +7,9 @@ import pytest from homeassistant.components.nut.const import DOMAIN from homeassistant.components.sensor import SensorDeviceClass, SensorStateClass from homeassistant.const import ( + ATTR_DEVICE_CLASS, + ATTR_FRIENDLY_NAME, + ATTR_UNIT_OF_MEASUREMENT, CONF_HOST, CONF_PORT, CONF_RESOURCES, @@ -53,9 +56,9 @@ async def test_ups_devices( assert state.state == "100" expected_attributes = { - "device_class": "battery", - "friendly_name": "Ups1 Battery charge", - "unit_of_measurement": PERCENTAGE, + ATTR_DEVICE_CLASS: "battery", + ATTR_FRIENDLY_NAME: "Ups1 Battery charge", + ATTR_UNIT_OF_MEASUREMENT: PERCENTAGE, } # Only test for a subset of attributes in case # HA changes the implementation and a new one appears @@ -88,9 +91,9 @@ async def test_ups_devices_with_unique_ids( assert state.state == "100" expected_attributes = { - "device_class": "battery", - "friendly_name": "Ups1 Battery charge", - "unit_of_measurement": PERCENTAGE, + ATTR_DEVICE_CLASS: "battery", + ATTR_FRIENDLY_NAME: "Ups1 Battery charge", + ATTR_UNIT_OF_MEASUREMENT: PERCENTAGE, } # Only test for a subset of attributes in case # HA changes the implementation and a new one appears @@ -126,10 +129,10 @@ async def test_pdu_devices_with_unique_ids( device_id="sensor.ups1_input_voltage", state_value="122.91", expected_attributes={ - "device_class": SensorDeviceClass.VOLTAGE, + ATTR_DEVICE_CLASS: SensorDeviceClass.VOLTAGE, "state_class": SensorStateClass.MEASUREMENT, - "friendly_name": "Ups1 Input voltage", - "unit_of_measurement": UnitOfElectricPotential.VOLT, + ATTR_FRIENDLY_NAME: "Ups1 Input voltage", + ATTR_UNIT_OF_MEASUREMENT: UnitOfElectricPotential.VOLT, }, ) @@ -141,8 +144,8 @@ async def test_pdu_devices_with_unique_ids( device_id="sensor.ups1_ambient_humidity_status", state_value="good", expected_attributes={ - "device_class": SensorDeviceClass.ENUM, - "friendly_name": "Ups1 Ambient humidity status", + ATTR_DEVICE_CLASS: SensorDeviceClass.ENUM, + ATTR_FRIENDLY_NAME: "Ups1 Ambient humidity status", }, ) @@ -154,8 +157,8 @@ async def test_pdu_devices_with_unique_ids( device_id="sensor.ups1_ambient_temperature_status", state_value="good", expected_attributes={ - "device_class": SensorDeviceClass.ENUM, - "friendly_name": "Ups1 Ambient temperature status", + ATTR_DEVICE_CLASS: SensorDeviceClass.ENUM, + ATTR_FRIENDLY_NAME: "Ups1 Ambient temperature status", }, ) @@ -305,9 +308,9 @@ async def test_pdu_dynamic_outlets( device_id="sensor.ups1_outlet_a1_current", state_value="0", expected_attributes={ - "device_class": SensorDeviceClass.CURRENT, - "friendly_name": "Ups1 Outlet A1 current", - "unit_of_measurement": UnitOfElectricCurrent.AMPERE, + ATTR_DEVICE_CLASS: SensorDeviceClass.CURRENT, + ATTR_FRIENDLY_NAME: "Ups1 Outlet A1 current", + ATTR_UNIT_OF_MEASUREMENT: UnitOfElectricCurrent.AMPERE, }, ) @@ -319,9 +322,9 @@ async def test_pdu_dynamic_outlets( device_id="sensor.ups1_outlet_a24_current", state_value="0.19", expected_attributes={ - "device_class": SensorDeviceClass.CURRENT, - "friendly_name": "Ups1 Outlet A24 current", - "unit_of_measurement": UnitOfElectricCurrent.AMPERE, + ATTR_DEVICE_CLASS: SensorDeviceClass.CURRENT, + ATTR_FRIENDLY_NAME: "Ups1 Outlet A24 current", + ATTR_UNIT_OF_MEASUREMENT: UnitOfElectricCurrent.AMPERE, }, ) From 9f2232fad13d8e5f60d49988bd90afa22623e0a2 Mon Sep 17 00:00:00 2001 From: Florent Thoumie Date: Sat, 29 Mar 2025 21:49:18 -0700 Subject: [PATCH 019/505] Bump iaqualink to 0.5.3 (#141709) * Update to iaqualink 0.5.3 and silence warning * Update to iaqualink 0.5.3 and silence warning * Re-add via_device line --- homeassistant/components/iaqualink/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/iaqualink/manifest.json b/homeassistant/components/iaqualink/manifest.json index 2531632075c..7e05bd72f0b 100644 --- a/homeassistant/components/iaqualink/manifest.json +++ b/homeassistant/components/iaqualink/manifest.json @@ -6,6 +6,6 @@ "documentation": "https://www.home-assistant.io/integrations/iaqualink", "iot_class": "cloud_polling", "loggers": ["iaqualink"], - "requirements": ["iaqualink==0.5.0", "h2==4.1.0"], + "requirements": ["iaqualink==0.5.3", "h2==4.1.0"], "single_config_entry": true } diff --git a/requirements_all.txt b/requirements_all.txt index 6df4fdeb607..f937a25aecc 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1184,7 +1184,7 @@ hyperion-py==0.7.5 iammeter==0.2.1 # homeassistant.components.iaqualink -iaqualink==0.5.0 +iaqualink==0.5.3 # homeassistant.components.ibeacon ibeacon-ble==1.2.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 22138ec650d..12b7aa7c95c 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1005,7 +1005,7 @@ huum==0.7.12 hyperion-py==0.7.5 # homeassistant.components.iaqualink -iaqualink==0.5.0 +iaqualink==0.5.3 # homeassistant.components.ibeacon ibeacon-ble==1.2.0 From 92034aeecc75d277ed928c23e28519274de9ad84 Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Sun, 30 Mar 2025 08:42:28 +0200 Subject: [PATCH 020/505] Replace "Opening" / "Closing" with common states in `homee` (#141766) --- homeassistant/components/homee/strings.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/homee/strings.json b/homeassistant/components/homee/strings.json index da8357d16bc..3dbbdcd2004 100644 --- a/homeassistant/components/homee/strings.json +++ b/homeassistant/components/homee/strings.json @@ -297,8 +297,8 @@ "open": "[%key:common::state::open%]", "closed": "[%key:common::state::closed%]", "partial": "Partially open", - "opening": "Opening", - "closing": "Closing" + "opening": "[%key:common::state::opening%]", + "closing": "[%key:common::state::closing%]" } }, "uv": { From 02aa823d25c2f9b5cee437c12f2044d3c265a481 Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Sun, 30 Mar 2025 08:42:48 +0200 Subject: [PATCH 021/505] Replace "Stopped" with common state in `matter` (#141768) --- homeassistant/components/matter/strings.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/matter/strings.json b/homeassistant/components/matter/strings.json index c82f46ef085..c34666c03bb 100644 --- a/homeassistant/components/matter/strings.json +++ b/homeassistant/components/matter/strings.json @@ -258,7 +258,7 @@ "operational_state": { "name": "Operational state", "state": { - "stopped": "Stopped", + "stopped": "[%key:common::state::stopped%]", "running": "Running", "paused": "[%key:common::state::paused%]", "error": "Error", From 4a833fb4899351310a4900aa5bdb0d0f64b72c1e Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 29 Mar 2025 20:59:40 -1000 Subject: [PATCH 022/505] Fix blocking late import of httpcore from httpx (#141771) There is a late import that blocks the event loop in newer version https://github.com/encode/httpx/blob/9e8ab40369bd3ec2cc8bff37ab79bf5769c8b00f/httpx/_transports/default.py#L75 --- homeassistant/helpers/httpx_client.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/homeassistant/helpers/httpx_client.py b/homeassistant/helpers/httpx_client.py index ade2ce747d5..49b12e0aa60 100644 --- a/homeassistant/helpers/httpx_client.py +++ b/homeassistant/helpers/httpx_client.py @@ -7,6 +7,9 @@ import sys from types import TracebackType from typing import Any, Self +# httpx dynamically imports httpcore, so we need to import it +# to avoid it being imported later when the event loop is running +import httpcore # noqa: F401 import httpx from homeassistant.const import APPLICATION_NAME, EVENT_HOMEASSISTANT_CLOSE, __version__ From ea5cf3d85416bc5047bc4904aec02b2e400f7dd7 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 29 Mar 2025 20:59:56 -1000 Subject: [PATCH 023/505] Bump aiohomekit to 3.2.13 (#141764) changelog: https://github.com/Jc2k/aiohomekit/compare/3.2.8...3.2.13 --- homeassistant/components/homekit_controller/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/homekit_controller/manifest.json b/homeassistant/components/homekit_controller/manifest.json index 98db9a397d3..6562a3edcc9 100644 --- a/homeassistant/components/homekit_controller/manifest.json +++ b/homeassistant/components/homekit_controller/manifest.json @@ -14,6 +14,6 @@ "documentation": "https://www.home-assistant.io/integrations/homekit_controller", "iot_class": "local_push", "loggers": ["aiohomekit", "commentjson"], - "requirements": ["aiohomekit==3.2.8"], + "requirements": ["aiohomekit==3.2.13"], "zeroconf": ["_hap._tcp.local.", "_hap._udp.local."] } diff --git a/requirements_all.txt b/requirements_all.txt index f937a25aecc..4143fc60caf 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -267,7 +267,7 @@ aiohasupervisor==0.3.0 aiohomeconnect==0.16.3 # homeassistant.components.homekit_controller -aiohomekit==3.2.8 +aiohomekit==3.2.13 # homeassistant.components.mcp_server aiohttp_sse==2.2.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 12b7aa7c95c..526cc14e6c3 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -252,7 +252,7 @@ aiohasupervisor==0.3.0 aiohomeconnect==0.16.3 # homeassistant.components.homekit_controller -aiohomekit==3.2.8 +aiohomekit==3.2.13 # homeassistant.components.mcp_server aiohttp_sse==2.2.0 From c8d3fa67682e8c1c1d9b6c759da0f5fde5b33fcc Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 29 Mar 2025 21:00:13 -1000 Subject: [PATCH 024/505] Small cleanups to the device registry (#141773) Remove some calls to internal functions that are now available directly on the devices and deleted_devices objects Remove internal functions that are no longer used --- homeassistant/helpers/device_registry.py | 34 +++++++----------------- 1 file changed, 10 insertions(+), 24 deletions(-) diff --git a/homeassistant/helpers/device_registry.py b/homeassistant/helpers/device_registry.py index 991a6cf5a57..79d6774c407 100644 --- a/homeassistant/helpers/device_registry.py +++ b/homeassistant/helpers/device_registry.py @@ -581,8 +581,8 @@ class DeviceRegistryItems[_EntryTypeT: (DeviceEntry, DeletedDeviceEntry)]( def get_entry( self, - identifiers: set[tuple[str, str]] | None, - connections: set[tuple[str, str]] | None, + identifiers: set[tuple[str, str]] | None = None, + connections: set[tuple[str, str]] | None = None, ) -> _EntryTypeT | None: """Get entry from identifiers or connections.""" if identifiers: @@ -709,22 +709,6 @@ class DeviceRegistry(BaseRegistry[dict[str, list[dict[str, Any]]]]): """Check if device is registered.""" return self.devices.get_entry(identifiers, connections) - def _async_get_deleted_device( - self, - identifiers: set[tuple[str, str]], - connections: set[tuple[str, str]], - ) -> DeletedDeviceEntry | None: - """Check if device is deleted.""" - return self.deleted_devices.get_entry(identifiers, connections) - - def _async_get_deleted_devices( - self, - identifiers: set[tuple[str, str]] | None = None, - connections: set[tuple[str, str]] | None = None, - ) -> Iterable[DeletedDeviceEntry]: - """List devices that are deleted.""" - return self.deleted_devices.get_entries(identifiers, connections) - def _substitute_name_placeholders( self, domain: str, @@ -839,10 +823,12 @@ class DeviceRegistry(BaseRegistry[dict[str, list[dict[str, Any]]]]): else: connections = _normalize_connections(connections) - device = self.async_get_device(identifiers=identifiers, connections=connections) + device = self.devices.get_entry( + identifiers=identifiers, connections=connections + ) if device is None: - deleted_device = self._async_get_deleted_device(identifiers, connections) + deleted_device = self.deleted_devices.get_entry(identifiers, connections) if deleted_device is None: device = DeviceEntry(is_new=True) else: @@ -869,7 +855,7 @@ class DeviceRegistry(BaseRegistry[dict[str, list[dict[str, Any]]]]): name = default_name if via_device is not None and via_device is not UNDEFINED: - if (via := self.async_get_device(identifiers={via_device})) is None: + if (via := self.devices.get_entry(identifiers={via_device})) is None: report_usage( "calls `device_registry.async_get_or_create` referencing a " f"non existing `via_device` {via_device}, " @@ -1172,7 +1158,7 @@ class DeviceRegistry(BaseRegistry[dict[str, list[dict[str, Any]]]]): # NOTE: Once we solve the broader issue of duplicated devices, we might # want to revisit it. Instead of simply removing the duplicated deleted device, # we might want to merge the information from it into the non-deleted device. - for deleted_device in self._async_get_deleted_devices( + for deleted_device in self.deleted_devices.get_entries( added_identifiers, added_connections ): del self.deleted_devices[deleted_device.id] @@ -1214,7 +1200,7 @@ class DeviceRegistry(BaseRegistry[dict[str, list[dict[str, Any]]]]): # conflict, the index will only see the last one and we will not # be able to tell which one caused the conflict if ( - existing_device := self.async_get_device(connections={connection}) + existing_device := self.devices.get_entry(connections={connection}) ) and existing_device.id != device_id: raise DeviceConnectionCollisionError( normalized_connections, existing_device @@ -1238,7 +1224,7 @@ class DeviceRegistry(BaseRegistry[dict[str, list[dict[str, Any]]]]): # conflict, the index will only see the last one and we will not # be able to tell which one caused the conflict if ( - existing_device := self.async_get_device(identifiers={identifier}) + existing_device := self.devices.get_entry(identifiers={identifier}) ) and existing_device.id != device_id: raise DeviceIdentifierCollisionError(identifiers, existing_device) From 7fbf15edc9e96100f9d369bd2f984b65eef68d3a Mon Sep 17 00:00:00 2001 From: tdfountain <174762217+tdfountain@users.noreply.github.com> Date: Sun, 30 Mar 2025 00:00:53 -0700 Subject: [PATCH 025/505] Add ambient state translations in NUT (#141772) Add ambient state translations --- homeassistant/components/nut/strings.json | 22 +++++++++++++-- tests/components/nut/test_sensor.py | 33 ++++++++++++++++++++++- 2 files changed, 52 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/nut/strings.json b/homeassistant/components/nut/strings.json index 4bde5742b64..56952778753 100644 --- a/homeassistant/components/nut/strings.json +++ b/homeassistant/components/nut/strings.json @@ -83,9 +83,27 @@ }, "sensor": { "ambient_humidity": { "name": "Ambient humidity" }, - "ambient_humidity_status": { "name": "Ambient humidity status" }, + "ambient_humidity_status": { + "name": "Ambient humidity status", + "state": { + "good": "Good", + "warning-low": "Warning low", + "critical-low": "Critical low", + "warning-high": "Warning high", + "critical-high": "Critical high" + } + }, "ambient_temperature": { "name": "Ambient temperature" }, - "ambient_temperature_status": { "name": "Ambient temperature status" }, + "ambient_temperature_status": { + "name": "Ambient temperature status", + "state": { + "good": "[%key:component::nut::entity::sensor::ambient_humidity_status::state::good%]", + "warning-low": "[%key:component::nut::entity::sensor::ambient_humidity_status::state::warning-low%]", + "critical-low": "[%key:component::nut::entity::sensor::ambient_humidity_status::state::critical-low%]", + "warning-high": "[%key:component::nut::entity::sensor::ambient_humidity_status::state::warning-high%]", + "critical-high": "[%key:component::nut::entity::sensor::ambient_humidity_status::state::critical-high%]" + } + }, "battery_alarm_threshold": { "name": "Battery alarm threshold" }, "battery_capacity": { "name": "Battery capacity" }, "battery_charge": { "name": "Battery charge" }, diff --git a/tests/components/nut/test_sensor.py b/tests/components/nut/test_sensor.py index 0bc6fb24c7b..89f06c934f8 100644 --- a/tests/components/nut/test_sensor.py +++ b/tests/components/nut/test_sensor.py @@ -15,11 +15,12 @@ from homeassistant.const import ( CONF_RESOURCES, PERCENTAGE, STATE_UNKNOWN, + Platform, UnitOfElectricCurrent, UnitOfElectricPotential, ) from homeassistant.core import HomeAssistant -from homeassistant.helpers import entity_registry as er +from homeassistant.helpers import entity_registry as er, translation from .util import ( _get_mock_nutclient, @@ -249,6 +250,36 @@ async def test_stale_options( assert state.state == "10" +async def test_state_ambient_translation(hass: HomeAssistant) -> None: + """Test translation of ambient state sensor.""" + entry = MockConfigEntry( + domain=DOMAIN, + data={CONF_HOST: "mock", CONF_PORT: "mock"}, + ) + entry.add_to_hass(hass) + + mock_pynut = _get_mock_nutclient( + list_ups={"ups1": "UPS 1"}, list_vars={"ambient.humidity.status": "good"} + ) + + with patch( + "homeassistant.components.nut.AIONUTClient", + return_value=mock_pynut, + ): + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + key = "ambient_humidity_status" + state = hass.states.get(f"sensor.ups1_{key}") + assert state.state == "good" + + result = translation.async_translate_state( + hass, state.state, Platform.SENSOR, DOMAIN, key, None + ) + + assert result == "Good" + + @pytest.mark.parametrize( ("model", "unique_id_base"), [ From 29219afb7fa00930af5489db07fbae65da2e44b7 Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Sun, 30 Mar 2025 10:16:42 +0200 Subject: [PATCH 026/505] Replace "Charging" state in `renault` with common string (#141787) --- homeassistant/components/renault/strings.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/renault/strings.json b/homeassistant/components/renault/strings.json index 8649a5c7b47..727e8cf32f1 100644 --- a/homeassistant/components/renault/strings.json +++ b/homeassistant/components/renault/strings.json @@ -118,7 +118,7 @@ "charge_ended": "Charge ended", "waiting_for_current_charge": "Waiting for current charge", "energy_flap_opened": "Energy flap opened", - "charge_in_progress": "Charging", + "charge_in_progress": "[%key:common::state::charging%]", "charge_error": "Not charging or plugged in", "unavailable": "Unavailable" } From d4970f81aa3c956ec64e862c94fb7e676aab4673 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 29 Mar 2025 22:30:06 -1000 Subject: [PATCH 027/505] Cleanup ESPHome update tests to avoid accessing integration internals (#141786) We should not access DomainData directly in the test --- tests/components/esphome/test_update.py | 23 ++++++++++++++--------- 1 file changed, 14 insertions(+), 9 deletions(-) diff --git a/tests/components/esphome/test_update.py b/tests/components/esphome/test_update.py index 76c0a9b1a70..910463f6e30 100644 --- a/tests/components/esphome/test_update.py +++ b/tests/components/esphome/test_update.py @@ -2,7 +2,7 @@ from collections.abc import Awaitable, Callable from typing import Any -from unittest.mock import Mock, patch +from unittest.mock import patch from aioesphomeapi import ( APIClient, @@ -374,17 +374,22 @@ async def test_update_entity_dashboard_discovered_after_startup_but_update_faile async def test_update_entity_not_present_without_dashboard( - hass: HomeAssistant, stub_reconnect, mock_config_entry, mock_device_info + hass: HomeAssistant, + mock_client: APIClient, + mock_esphome_device: Callable[ + [APIClient, list[EntityInfo], list[UserService], list[EntityState]], + Awaitable[MockESPHomeDevice], + ], ) -> None: """Test ESPHome update entity does not get created if there is no dashboard.""" - with patch( - "homeassistant.components.esphome.update.DomainData.get_entry_data", - return_value=Mock(available=True, device_info=mock_device_info, info={}), - ): - assert await hass.config_entries.async_setup(mock_config_entry.entry_id) - await hass.async_block_till_done() + await mock_esphome_device( + mock_client=mock_client, + entity_info=[], + user_service=[], + states=[], + ) - state = hass.states.get("update.none_firmware") + state = hass.states.get("update.test_firmware") assert state is None From f1b059c75d3c11cb13a6e9368db9ad58bf4aab6c Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 29 Mar 2025 23:40:25 -1000 Subject: [PATCH 028/505] Bump PyISY to 3.1.15 (#141778) changelog: https://github.com/automicus/PyISY/compare/v3.1.14...v3.1.15 fixes #141517 fixes #132279 --- homeassistant/components/isy994/__init__.py | 2 +- homeassistant/components/isy994/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/isy994/__init__.py b/homeassistant/components/isy994/__init__.py index 738c7e2d5ad..e387196ba94 100644 --- a/homeassistant/components/isy994/__init__.py +++ b/homeassistant/components/isy994/__init__.py @@ -138,7 +138,7 @@ async def async_setup_entry( for vtype, _, vid in isy.variables.children: numbers.append(isy.variables[vtype][vid]) if ( - isy.conf[CONFIG_NETWORKING] or isy.conf[CONFIG_PORTAL] + isy.conf[CONFIG_NETWORKING] or isy.conf.get(CONFIG_PORTAL) ) and isy.networking.nobjs: isy_data.devices[CONF_NETWORK] = _create_service_device_info( isy, name=CONFIG_NETWORKING, unique_id=CONF_NETWORK diff --git a/homeassistant/components/isy994/manifest.json b/homeassistant/components/isy994/manifest.json index 3aa81027b4f..eb804d7af09 100644 --- a/homeassistant/components/isy994/manifest.json +++ b/homeassistant/components/isy994/manifest.json @@ -24,7 +24,7 @@ "integration_type": "hub", "iot_class": "local_push", "loggers": ["pyisy"], - "requirements": ["pyisy==3.1.14"], + "requirements": ["pyisy==3.1.15"], "ssdp": [ { "manufacturer": "Universal Devices Inc.", diff --git a/requirements_all.txt b/requirements_all.txt index 4143fc60caf..1c01ba91ccd 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2047,7 +2047,7 @@ pyiskra==0.1.15 pyiss==1.0.1 # homeassistant.components.isy994 -pyisy==3.1.14 +pyisy==3.1.15 # homeassistant.components.itach pyitachip2ir==0.0.7 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 526cc14e6c3..5636721e9a2 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1668,7 +1668,7 @@ pyiskra==0.1.15 pyiss==1.0.1 # homeassistant.components.isy994 -pyisy==3.1.14 +pyisy==3.1.15 # homeassistant.components.ituran pyituran==0.1.4 From beb92a7f9c17dd3eb6870eec024539d21425c2b6 Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Sun, 30 Mar 2025 11:41:01 +0200 Subject: [PATCH 029/505] Replace "Charging" state for `binary_sensor` with common string (#141796) --- homeassistant/components/binary_sensor/strings.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/binary_sensor/strings.json b/homeassistant/components/binary_sensor/strings.json index b86a6374f28..9fac758e168 100644 --- a/homeassistant/components/binary_sensor/strings.json +++ b/homeassistant/components/binary_sensor/strings.json @@ -132,7 +132,7 @@ "name": "Charging", "state": { "off": "Not charging", - "on": "Charging" + "on": "[%key:common::state::charging%]" } }, "carbon_monoxide": { From 65261de7cc61c09efa87cc69bdb7463d7627b0bc Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 29 Mar 2025 23:55:58 -1000 Subject: [PATCH 030/505] Migrate emulated_roku to use runtime_data to fix flakey tests (#141795) --- .../components/emulated_roku/__init__.py | 37 +++++++------ .../components/emulated_roku/binding.py | 53 +++++++++++-------- .../components/emulated_roku/test_binding.py | 31 ++++++----- tests/components/emulated_roku/test_init.py | 14 ----- 4 files changed, 67 insertions(+), 68 deletions(-) diff --git a/homeassistant/components/emulated_roku/__init__.py b/homeassistant/components/emulated_roku/__init__.py index d4466f47ef2..e8c3a00f098 100644 --- a/homeassistant/components/emulated_roku/__init__.py +++ b/homeassistant/components/emulated_roku/__init__.py @@ -46,6 +46,8 @@ CONFIG_SCHEMA = vol.Schema( extra=vol.ALLOW_EXTRA, ) +type EmulatedRokuConfigEntry = ConfigEntry[EmulatedRoku] + async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up the emulated roku component.""" @@ -65,22 +67,21 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: return True -async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool: +async def async_setup_entry( + hass: HomeAssistant, entry: EmulatedRokuConfigEntry +) -> bool: """Set up an emulated roku server from a config entry.""" - config = config_entry.data - - if DOMAIN not in hass.data: - hass.data[DOMAIN] = {} - - name = config[CONF_NAME] - listen_port = config[CONF_LISTEN_PORT] - host_ip = config.get(CONF_HOST_IP) or await async_get_source_ip(hass) - advertise_ip = config.get(CONF_ADVERTISE_IP) - advertise_port = config.get(CONF_ADVERTISE_PORT) - upnp_bind_multicast = config.get(CONF_UPNP_BIND_MULTICAST) + config = entry.data + name: str = config[CONF_NAME] + listen_port: int = config[CONF_LISTEN_PORT] + host_ip: str = config.get(CONF_HOST_IP) or await async_get_source_ip(hass) + advertise_ip: str | None = config.get(CONF_ADVERTISE_IP) + advertise_port: int | None = config.get(CONF_ADVERTISE_PORT) + upnp_bind_multicast: bool | None = config.get(CONF_UPNP_BIND_MULTICAST) server = EmulatedRoku( hass, + entry.entry_id, name, host_ip, listen_port, @@ -88,14 +89,12 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> b advertise_port, upnp_bind_multicast, ) - - hass.data[DOMAIN][name] = server - + entry.runtime_data = server return await server.setup() -async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_unload_entry( + hass: HomeAssistant, entry: EmulatedRokuConfigEntry +) -> bool: """Unload a config entry.""" - name = entry.data[CONF_NAME] - server = hass.data[DOMAIN].pop(name) - return await server.unload() + return await entry.runtime_data.unload() diff --git a/homeassistant/components/emulated_roku/binding.py b/homeassistant/components/emulated_roku/binding.py index a84db4bd77b..6d8d9c4014f 100644 --- a/homeassistant/components/emulated_roku/binding.py +++ b/homeassistant/components/emulated_roku/binding.py @@ -5,7 +5,13 @@ import logging from emulated_roku import EmulatedRokuCommandHandler, EmulatedRokuServer from homeassistant.const import EVENT_HOMEASSISTANT_START, EVENT_HOMEASSISTANT_STOP -from homeassistant.core import CoreState, EventOrigin +from homeassistant.core import ( + CALLBACK_TYPE, + CoreState, + Event, + EventOrigin, + HomeAssistant, +) LOGGER = logging.getLogger(__package__) @@ -27,16 +33,18 @@ class EmulatedRoku: def __init__( self, - hass, - name, - host_ip, - listen_port, - advertise_ip, - advertise_port, - upnp_bind_multicast, - ): + hass: HomeAssistant, + entry_id: str, + name: str, + host_ip: str, + listen_port: int, + advertise_ip: str | None, + advertise_port: int | None, + upnp_bind_multicast: bool | None, + ) -> None: """Initialize the properties.""" self.hass = hass + self.entry_id = entry_id self.roku_usn = name self.host_ip = host_ip @@ -47,21 +55,21 @@ class EmulatedRoku: self.bind_multicast = upnp_bind_multicast - self._api_server = None + self._api_server: EmulatedRokuServer | None = None - self._unsub_start_listener = None - self._unsub_stop_listener = None + self._unsub_start_listener: CALLBACK_TYPE | None = None + self._unsub_stop_listener: CALLBACK_TYPE | None = None - async def setup(self): + async def setup(self) -> bool: """Start the emulated_roku server.""" class EventCommandHandler(EmulatedRokuCommandHandler): """emulated_roku command handler to turn commands into events.""" - def __init__(self, hass): + def __init__(self, hass: HomeAssistant) -> None: self.hass = hass - def on_keydown(self, roku_usn, key): + def on_keydown(self, roku_usn: str, key: str) -> None: """Handle keydown event.""" self.hass.bus.async_fire( EVENT_ROKU_COMMAND, @@ -73,7 +81,7 @@ class EmulatedRoku: EventOrigin.local, ) - def on_keyup(self, roku_usn, key): + def on_keyup(self, roku_usn: str, key: str) -> None: """Handle keyup event.""" self.hass.bus.async_fire( EVENT_ROKU_COMMAND, @@ -85,7 +93,7 @@ class EmulatedRoku: EventOrigin.local, ) - def on_keypress(self, roku_usn, key): + def on_keypress(self, roku_usn: str, key: str) -> None: """Handle keypress event.""" self.hass.bus.async_fire( EVENT_ROKU_COMMAND, @@ -97,7 +105,7 @@ class EmulatedRoku: EventOrigin.local, ) - def launch(self, roku_usn, app_id): + def launch(self, roku_usn: str, app_id: str) -> None: """Handle launch event.""" self.hass.bus.async_fire( EVENT_ROKU_COMMAND, @@ -129,17 +137,19 @@ class EmulatedRoku: bind_multicast=self.bind_multicast, ) - async def emulated_roku_stop(event): + async def emulated_roku_stop(event: Event | None) -> None: """Wrap the call to emulated_roku.close.""" LOGGER.debug("Stopping emulated_roku %s", self.roku_usn) self._unsub_stop_listener = None + assert self._api_server is not None await self._api_server.close() - async def emulated_roku_start(event): + async def emulated_roku_start(event: Event | None) -> None: """Wrap the call to emulated_roku.start.""" try: LOGGER.debug("Starting emulated_roku %s", self.roku_usn) self._unsub_start_listener = None + assert self._api_server is not None await self._api_server.start() except OSError: LOGGER.exception( @@ -165,7 +175,7 @@ class EmulatedRoku: return True - async def unload(self): + async def unload(self) -> bool: """Unload the emulated_roku server.""" LOGGER.debug("Unloading emulated_roku %s", self.roku_usn) @@ -177,6 +187,7 @@ class EmulatedRoku: self._unsub_stop_listener() self._unsub_stop_listener = None + assert self._api_server is not None await self._api_server.close() return True diff --git a/tests/components/emulated_roku/test_binding.py b/tests/components/emulated_roku/test_binding.py index 5bde72d2e4d..ec3f064dfe0 100644 --- a/tests/components/emulated_roku/test_binding.py +++ b/tests/components/emulated_roku/test_binding.py @@ -1,6 +1,7 @@ """Tests for emulated_roku library bindings.""" from unittest.mock import AsyncMock, Mock, patch +from uuid import uuid4 from homeassistant.components.emulated_roku.binding import ( ATTR_APP_ID, @@ -14,14 +15,15 @@ from homeassistant.components.emulated_roku.binding import ( ROKU_COMMAND_LAUNCH, EmulatedRoku, ) -from homeassistant.core import HomeAssistant +from homeassistant.core import Event, HomeAssistant async def test_events_fired_properly(hass: HomeAssistant) -> None: """Test that events are fired correctly.""" - binding = EmulatedRoku( - hass, "Test Emulated Roku", "1.2.3.4", 8060, None, None, None - ) + random_name = uuid4().hex + # Note that this test is accessing the internal EmulatedRoku class + # and should be refactored in the future not to do so. + binding = EmulatedRoku(hass, "x", random_name, "1.2.3.4", 8060, None, None, None) events = [] roku_event_handler = None @@ -41,8 +43,9 @@ async def test_events_fired_properly(hass: HomeAssistant) -> None: return Mock(start=AsyncMock(), close=AsyncMock()) - def listener(event): - events.append(event) + def listener(event: Event) -> None: + if event.data[ATTR_SOURCE_NAME] == random_name: + events.append(event) with patch( "homeassistant.components.emulated_roku.binding.EmulatedRokuServer", instantiate @@ -53,10 +56,10 @@ async def test_events_fired_properly(hass: HomeAssistant) -> None: assert roku_event_handler is not None - roku_event_handler.on_keydown("Test Emulated Roku", "A") - roku_event_handler.on_keyup("Test Emulated Roku", "A") - roku_event_handler.on_keypress("Test Emulated Roku", "C") - roku_event_handler.launch("Test Emulated Roku", "1") + roku_event_handler.on_keydown(random_name, "A") + roku_event_handler.on_keyup(random_name, "A") + roku_event_handler.on_keypress(random_name, "C") + roku_event_handler.launch(random_name, "1") await hass.async_block_till_done() @@ -64,20 +67,20 @@ async def test_events_fired_properly(hass: HomeAssistant) -> None: assert events[0].event_type == EVENT_ROKU_COMMAND assert events[0].data[ATTR_COMMAND_TYPE] == ROKU_COMMAND_KEYDOWN - assert events[0].data[ATTR_SOURCE_NAME] == "Test Emulated Roku" + assert events[0].data[ATTR_SOURCE_NAME] == random_name assert events[0].data[ATTR_KEY] == "A" assert events[1].event_type == EVENT_ROKU_COMMAND assert events[1].data[ATTR_COMMAND_TYPE] == ROKU_COMMAND_KEYUP - assert events[1].data[ATTR_SOURCE_NAME] == "Test Emulated Roku" + assert events[1].data[ATTR_SOURCE_NAME] == random_name assert events[1].data[ATTR_KEY] == "A" assert events[2].event_type == EVENT_ROKU_COMMAND assert events[2].data[ATTR_COMMAND_TYPE] == ROKU_COMMAND_KEYPRESS - assert events[2].data[ATTR_SOURCE_NAME] == "Test Emulated Roku" + assert events[2].data[ATTR_SOURCE_NAME] == random_name assert events[2].data[ATTR_KEY] == "C" assert events[3].event_type == EVENT_ROKU_COMMAND assert events[3].data[ATTR_COMMAND_TYPE] == ROKU_COMMAND_LAUNCH - assert events[3].data[ATTR_SOURCE_NAME] == "Test Emulated Roku" + assert events[3].data[ATTR_SOURCE_NAME] == random_name assert events[3].data[ATTR_APP_ID] == "1" diff --git a/tests/components/emulated_roku/test_init.py b/tests/components/emulated_roku/test_init.py index cf2a415f19c..473e0c662aa 100644 --- a/tests/components/emulated_roku/test_init.py +++ b/tests/components/emulated_roku/test_init.py @@ -86,16 +86,6 @@ async def test_setup_entry_successful(hass: HomeAssistant) -> None: assert await emulated_roku.async_setup_entry(hass, entry) is True assert len(instantiate.mock_calls) == 1 - assert hass.data[emulated_roku.DOMAIN] - - roku_instance = hass.data[emulated_roku.DOMAIN]["Emulated Roku Test"] - - assert roku_instance.roku_usn == "Emulated Roku Test" - assert roku_instance.host_ip == "1.2.3.5" - assert roku_instance.listen_port == 8060 - assert roku_instance.advertise_ip == "1.2.3.4" - assert roku_instance.advertise_port == 8071 - assert roku_instance.bind_multicast is False async def test_unload_entry(hass: HomeAssistant) -> None: @@ -113,10 +103,6 @@ async def test_unload_entry(hass: HomeAssistant) -> None: ): assert await emulated_roku.async_setup_entry(hass, entry) is True - assert emulated_roku.DOMAIN in hass.data - await hass.async_block_till_done() assert await emulated_roku.async_unload_entry(hass, entry) - - assert len(hass.data[emulated_roku.DOMAIN]) == 0 From 24277259adf3446a3dc28c0a3130c40d8cfc70c0 Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Sun, 30 Mar 2025 11:56:50 +0200 Subject: [PATCH 031/505] Use more common states for ESS and PV in `vicare` (#141792) --- homeassistant/components/vicare/strings.json | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/vicare/strings.json b/homeassistant/components/vicare/strings.json index 04049f026bd..6ed0a2f018b 100644 --- a/homeassistant/components/vicare/strings.json +++ b/homeassistant/components/vicare/strings.json @@ -362,9 +362,9 @@ "ess_state": { "name": "Battery state", "state": { - "charge": "Charging", - "discharge": "Discharging", - "standby": "Standby" + "charge": "[%key:common::state::charging%]", + "discharge": "[%key:common::state::discharging%]", + "standby": "[%key:common::state::standby%]" } }, "ess_discharge_today": { @@ -412,7 +412,7 @@ "photovoltaic_status": { "name": "PV state", "state": { - "ready": "Standby", + "ready": "[%key:common::state::standby%]", "production": "Producing" } }, From 391b3ed1e74ba62551cf52c6690dbf08ce81aa69 Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Sun, 30 Mar 2025 11:57:15 +0200 Subject: [PATCH 032/505] Replace "Stopped" with common state in `snoo` (#141788) * Replace "Stopped" with common state in `snoo` * Replace internal reference with common one --- homeassistant/components/snoo/strings.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/snoo/strings.json b/homeassistant/components/snoo/strings.json index 72b0342c7f4..1c86c066c7f 100644 --- a/homeassistant/components/snoo/strings.json +++ b/homeassistant/components/snoo/strings.json @@ -71,7 +71,7 @@ "level2": "Level 2", "level3": "Level 3", "level4": "Level 4", - "stop": "Stopped", + "stop": "[%key:common::state::stopped%]", "pretimeout": "Pre-timeout", "timeout": "Timeout" } @@ -89,7 +89,7 @@ "level2": "[%key:component::snoo::entity::sensor::state::state::level2%]", "level3": "[%key:component::snoo::entity::sensor::state::state::level3%]", "level4": "[%key:component::snoo::entity::sensor::state::state::level4%]", - "stop": "[%key:component::snoo::entity::sensor::state::state::stop%]" + "stop": "[%key:common::state::stopped%]" } } }, From 5b5efb5aaa7f980a565b6d47a2a5d2100ae2325d Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Sun, 30 Mar 2025 11:58:17 +0200 Subject: [PATCH 033/505] Replace "Stopped" with common state in `smartthings` (#141789) * Replace "Stopped" with common state in `smartthings` * Replace internal references with common ones --- homeassistant/components/smartthings/strings.json | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/smartthings/strings.json b/homeassistant/components/smartthings/strings.json index dac7b3cf39a..fc3ca66a3af 100644 --- a/homeassistant/components/smartthings/strings.json +++ b/homeassistant/components/smartthings/strings.json @@ -109,7 +109,7 @@ "state": { "run": "[%key:component::smartthings::entity::sensor::dishwasher_machine_state::state::run%]", "pause": "[%key:common::state::paused%]", - "stop": "[%key:component::smartthings::entity::sensor::dishwasher_machine_state::state::stop%]" + "stop": "[%key:common::state::stopped%]" } } }, @@ -154,7 +154,7 @@ "state": { "pause": "[%key:common::state::paused%]", "run": "Running", - "stop": "Stopped" + "stop": "[%key:common::state::stopped%]" } }, "dishwasher_job_state": { @@ -183,7 +183,7 @@ "state": { "pause": "[%key:common::state::paused%]", "run": "[%key:component::smartthings::entity::sensor::dishwasher_machine_state::state::run%]", - "stop": "[%key:component::smartthings::entity::sensor::dishwasher_machine_state::state::stop%]" + "stop": "[%key:common::state::stopped%]" } }, "dryer_job_state": { @@ -441,7 +441,7 @@ "state": { "pause": "[%key:common::state::paused%]", "run": "[%key:component::smartthings::entity::sensor::dishwasher_machine_state::state::run%]", - "stop": "[%key:component::smartthings::entity::sensor::dishwasher_machine_state::state::stop%]" + "stop": "[%key:common::state::stopped%]" } }, "washer_job_state": { From 600aedc9a1e5f26e5387b721a99fb4269a7469c7 Mon Sep 17 00:00:00 2001 From: Simone Chemelli Date: Sun, 30 Mar 2025 12:04:00 +0200 Subject: [PATCH 034/505] Add tests for Comelit cover platform (#141740) * Add tests for Comelit cover platform * cleanup up --- tests/components/comelit/conftest.py | 5 +- tests/components/comelit/const.py | 2 +- .../comelit/snapshots/test_cover.ambr | 50 ++++++ .../comelit/snapshots/test_diagnostics.ambr | 2 +- tests/components/comelit/test_cover.py | 161 ++++++++++++++++++ 5 files changed, 216 insertions(+), 4 deletions(-) create mode 100644 tests/components/comelit/snapshots/test_cover.ambr create mode 100644 tests/components/comelit/test_cover.py diff --git a/tests/components/comelit/conftest.py b/tests/components/comelit/conftest.py index 1510b3b7968..c315d0fa00e 100644 --- a/tests/components/comelit/conftest.py +++ b/tests/components/comelit/conftest.py @@ -49,10 +49,10 @@ def mock_serial_bridge() -> Generator[AsyncMock]: ), ): bridge = mock_comelit_serial_bridge.return_value - bridge.get_all_devices.return_value = BRIDGE_DEVICE_QUERY + bridge.get_all_devices.return_value = deepcopy(BRIDGE_DEVICE_QUERY) bridge.host = BRIDGE_HOST bridge.port = BRIDGE_PORT - bridge.pin = BRIDGE_PIN + bridge.device_pin = BRIDGE_PIN yield bridge @@ -67,6 +67,7 @@ def mock_serial_bridge_config_entry() -> Generator[MockConfigEntry]: CONF_PIN: BRIDGE_PIN, CONF_TYPE: BRIDGE, }, + entry_id="serial_bridge_config_entry_id", ) diff --git a/tests/components/comelit/const.py b/tests/components/comelit/const.py index efb22ee5cf2..d2f599be658 100644 --- a/tests/components/comelit/const.py +++ b/tests/components/comelit/const.py @@ -35,7 +35,7 @@ BRIDGE_DEVICE_QUERY = { index=0, name="Cover0", status=0, - human_status="closed", + human_status="stopped", type="cover", val=0, protected=0, diff --git a/tests/components/comelit/snapshots/test_cover.ambr b/tests/components/comelit/snapshots/test_cover.ambr new file mode 100644 index 00000000000..17189344cd1 --- /dev/null +++ b/tests/components/comelit/snapshots/test_cover.ambr @@ -0,0 +1,50 @@ +# serializer version: 1 +# name: test_all_entities[cover.cover0-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'cover', + 'entity_category': None, + 'entity_id': 'cover.cover0', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': None, + 'platform': 'comelit', + 'previous_unique_id': None, + 'supported_features': , + 'translation_key': None, + 'unique_id': 'serial_bridge_config_entry_id-0', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[cover.cover0-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'shutter', + 'friendly_name': 'Cover0', + 'supported_features': , + }), + 'context': , + 'entity_id': 'cover.cover0', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- diff --git a/tests/components/comelit/snapshots/test_diagnostics.ambr b/tests/components/comelit/snapshots/test_diagnostics.ambr index 983f6c5c6b1..5d194efbd4b 100644 --- a/tests/components/comelit/snapshots/test_diagnostics.ambr +++ b/tests/components/comelit/snapshots/test_diagnostics.ambr @@ -11,7 +11,7 @@ 'shutter': list([ dict({ '0': dict({ - 'human_status': 'closed', + 'human_status': 'stopped', 'name': 'Cover0', 'power': 0.0, 'power_unit': 'W', diff --git a/tests/components/comelit/test_cover.py b/tests/components/comelit/test_cover.py new file mode 100644 index 00000000000..1d6c1435a5a --- /dev/null +++ b/tests/components/comelit/test_cover.py @@ -0,0 +1,161 @@ +"""Tests for Comelit SimpleHome cover platform.""" + +from unittest.mock import AsyncMock, patch + +from aiocomelit.api import ComelitSerialBridgeObject +from aiocomelit.const import COVER, WATT +from freezegun.api import FrozenDateTimeFactory +from syrupy import SnapshotAssertion + +from homeassistant.components.comelit.const import SCAN_INTERVAL +from homeassistant.components.cover import ( + DOMAIN as COVER_DOMAIN, + SERVICE_CLOSE_COVER, + SERVICE_OPEN_COVER, + SERVICE_STOP_COVER, + STATE_CLOSED, + STATE_CLOSING, + STATE_OPENING, +) +from homeassistant.const import ATTR_ENTITY_ID, STATE_UNKNOWN, Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from . import setup_integration + +from tests.common import MockConfigEntry, async_fire_time_changed, snapshot_platform + +ENTITY_ID = "cover.cover0" + + +async def test_all_entities( + hass: HomeAssistant, + snapshot: SnapshotAssertion, + mock_serial_bridge: AsyncMock, + mock_serial_bridge_config_entry: MockConfigEntry, + entity_registry: er.EntityRegistry, +) -> None: + """Test all entities.""" + with patch("homeassistant.components.comelit.BRIDGE_PLATFORMS", [Platform.COVER]): + await setup_integration(hass, mock_serial_bridge_config_entry) + + await snapshot_platform( + hass, + entity_registry, + snapshot(), + mock_serial_bridge_config_entry.entry_id, + ) + + +async def test_cover_open( + hass: HomeAssistant, + freezer: FrozenDateTimeFactory, + mock_serial_bridge: AsyncMock, + mock_serial_bridge_config_entry: MockConfigEntry, +) -> None: + """Test cover open service.""" + + mock_serial_bridge.reset_mock() + await setup_integration(hass, mock_serial_bridge_config_entry) + + assert (state := hass.states.get(ENTITY_ID)) + assert state.state == STATE_UNKNOWN + + # Open cover + await hass.services.async_call( + COVER_DOMAIN, + SERVICE_OPEN_COVER, + {ATTR_ENTITY_ID: ENTITY_ID}, + blocking=True, + ) + mock_serial_bridge.set_device_status.assert_called() + + assert (state := hass.states.get(ENTITY_ID)) + assert state.state == STATE_OPENING + + # Finish opening, update status + mock_serial_bridge.get_all_devices.return_value[COVER] = { + 0: ComelitSerialBridgeObject( + index=0, + name="Cover0", + status=0, + human_status="stopped", + type="cover", + val=0, + protected=0, + zone="Open space", + power=0.0, + power_unit=WATT, + ), + } + + freezer.tick(SCAN_INTERVAL) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + assert (state := hass.states.get(ENTITY_ID)) + assert state.state == STATE_UNKNOWN + + +async def test_cover_close( + hass: HomeAssistant, + mock_serial_bridge: AsyncMock, + mock_serial_bridge_config_entry: MockConfigEntry, +) -> None: + """Test cover close and stop service.""" + + mock_serial_bridge.reset_mock() + await setup_integration(hass, mock_serial_bridge_config_entry) + + assert (state := hass.states.get(ENTITY_ID)) + assert state.state == STATE_UNKNOWN + + # Close cover + await hass.services.async_call( + COVER_DOMAIN, + SERVICE_CLOSE_COVER, + {ATTR_ENTITY_ID: ENTITY_ID}, + blocking=True, + ) + mock_serial_bridge.set_device_status.assert_called() + + assert (state := hass.states.get(ENTITY_ID)) + assert state.state == STATE_CLOSING + + # Stop cover + await hass.services.async_call( + COVER_DOMAIN, + SERVICE_STOP_COVER, + {ATTR_ENTITY_ID: ENTITY_ID}, + blocking=True, + ) + mock_serial_bridge.set_device_status.assert_called() + + assert (state := hass.states.get(ENTITY_ID)) + assert state.state == STATE_CLOSED + + +async def test_cover_stop_if_stopped( + hass: HomeAssistant, + mock_serial_bridge: AsyncMock, + mock_serial_bridge_config_entry: MockConfigEntry, +) -> None: + """Test cover stop service when already stopped.""" + + mock_serial_bridge.reset_mock() + await setup_integration(hass, mock_serial_bridge_config_entry) + + assert (state := hass.states.get(ENTITY_ID)) + assert state.state == STATE_UNKNOWN + + # Stop cover while not opening/closing + await hass.services.async_call( + COVER_DOMAIN, + SERVICE_STOP_COVER, + {ATTR_ENTITY_ID: ENTITY_ID}, + blocking=True, + ) + mock_serial_bridge.set_device_status.assert_not_called() + + assert (state := hass.states.get(ENTITY_ID)) + assert state.state == STATE_UNKNOWN From 9ee79b87ee884e74dd7482374b7a7b04129643b5 Mon Sep 17 00:00:00 2001 From: Simone Chemelli Date: Sun, 30 Mar 2025 12:10:41 +0200 Subject: [PATCH 035/505] Add full test coverage for Comelit switch platform (#141738) * Add full test coverage for Comelit switch platform * cleanup --- tests/components/comelit/const.py | 15 +++- .../comelit/snapshots/test_diagnostics.ambr | 12 +++ .../comelit/snapshots/test_switch.ambr | 49 ++++++++++++ tests/components/comelit/test_switch.py | 76 +++++++++++++++++++ 4 files changed, 151 insertions(+), 1 deletion(-) create mode 100644 tests/components/comelit/snapshots/test_switch.ambr create mode 100644 tests/components/comelit/test_switch.py diff --git a/tests/components/comelit/const.py b/tests/components/comelit/const.py index d2f599be658..d1bd4f95da3 100644 --- a/tests/components/comelit/const.py +++ b/tests/components/comelit/const.py @@ -58,7 +58,20 @@ BRIDGE_DEVICE_QUERY = { power_unit=WATT, ) }, - OTHER: {}, + OTHER: { + 0: ComelitSerialBridgeObject( + index=0, + name="Switch0", + status=0, + human_status="off", + type="other", + val=0, + protected=0, + zone="Bathroom", + power=0.0, + power_unit=WATT, + ), + }, IRRIGATION: {}, SCENARIO: {}, } diff --git a/tests/components/comelit/snapshots/test_diagnostics.ambr b/tests/components/comelit/snapshots/test_diagnostics.ambr index 5d194efbd4b..3a6af9c3b73 100644 --- a/tests/components/comelit/snapshots/test_diagnostics.ambr +++ b/tests/components/comelit/snapshots/test_diagnostics.ambr @@ -41,6 +41,18 @@ }), dict({ 'other': list([ + dict({ + '0': dict({ + 'human_status': 'off', + 'name': 'Switch0', + 'power': 0.0, + 'power_unit': 'W', + 'protected': 0, + 'status': 0, + 'val': 0, + 'zone': 'Bathroom', + }), + }), ]), }), dict({ diff --git a/tests/components/comelit/snapshots/test_switch.ambr b/tests/components/comelit/snapshots/test_switch.ambr new file mode 100644 index 00000000000..eddecfabb7a --- /dev/null +++ b/tests/components/comelit/snapshots/test_switch.ambr @@ -0,0 +1,49 @@ +# serializer version: 1 +# name: test_all_entities[switch.switch0-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': None, + 'entity_id': 'switch.switch0', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': None, + 'platform': 'comelit', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'serial_bridge_config_entry_id-other-0', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[switch.switch0-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'outlet', + 'friendly_name': 'Switch0', + }), + 'context': , + 'entity_id': 'switch.switch0', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- diff --git a/tests/components/comelit/test_switch.py b/tests/components/comelit/test_switch.py new file mode 100644 index 00000000000..fb9a4aab79a --- /dev/null +++ b/tests/components/comelit/test_switch.py @@ -0,0 +1,76 @@ +"""Tests for Comelit SimpleHome switch platform.""" + +from unittest.mock import AsyncMock, patch + +import pytest +from syrupy import SnapshotAssertion + +from homeassistant.components.switch import ( + DOMAIN as SWITCH_DOMAIN, + SERVICE_TOGGLE, + SERVICE_TURN_OFF, + SERVICE_TURN_ON, +) +from homeassistant.const import ATTR_ENTITY_ID, STATE_OFF, STATE_ON, Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from . import setup_integration + +from tests.common import MockConfigEntry, snapshot_platform + +ENTITY_ID = "switch.switch0" + + +async def test_all_entities( + hass: HomeAssistant, + snapshot: SnapshotAssertion, + mock_serial_bridge: AsyncMock, + mock_serial_bridge_config_entry: MockConfigEntry, + entity_registry: er.EntityRegistry, +) -> None: + """Test all entities.""" + with patch("homeassistant.components.comelit.BRIDGE_PLATFORMS", [Platform.SWITCH]): + await setup_integration(hass, mock_serial_bridge_config_entry) + + await snapshot_platform( + hass, + entity_registry, + snapshot(), + mock_serial_bridge_config_entry.entry_id, + ) + + +@pytest.mark.parametrize( + ("service", "status"), + [ + (SERVICE_TURN_OFF, STATE_OFF), + (SERVICE_TURN_ON, STATE_ON), + (SERVICE_TOGGLE, STATE_ON), + ], +) +async def test_switch_set_state( + hass: HomeAssistant, + mock_serial_bridge: AsyncMock, + mock_serial_bridge_config_entry: MockConfigEntry, + service: str, + status: str, +) -> None: + """Test switch set state service.""" + + await setup_integration(hass, mock_serial_bridge_config_entry) + + assert (state := hass.states.get(ENTITY_ID)) + assert state.state == STATE_OFF + + # Test set temperature + await hass.services.async_call( + SWITCH_DOMAIN, + service, + {ATTR_ENTITY_ID: ENTITY_ID}, + blocking=True, + ) + mock_serial_bridge.set_device_status.assert_called() + + assert (state := hass.states.get(ENTITY_ID)) + assert state.state == status From 9c28e6047527328b0ca722b8c6f8648df2fd53f2 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 30 Mar 2025 01:06:07 -1000 Subject: [PATCH 036/505] Bump pyisy to 3.2.0 (#141798) changelog: https://github.com/automicus/PyISY/compare/v3.1.15...v3.2.0 Fixes some tasks missing a strong reference https://github.com/automicus/PyISY/pull/425 There is a bit of refactoring so I did not tag it for beta. --- homeassistant/components/isy994/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/isy994/manifest.json b/homeassistant/components/isy994/manifest.json index eb804d7af09..ab0367f3db4 100644 --- a/homeassistant/components/isy994/manifest.json +++ b/homeassistant/components/isy994/manifest.json @@ -24,7 +24,7 @@ "integration_type": "hub", "iot_class": "local_push", "loggers": ["pyisy"], - "requirements": ["pyisy==3.1.15"], + "requirements": ["pyisy==3.2.0"], "ssdp": [ { "manufacturer": "Universal Devices Inc.", diff --git a/requirements_all.txt b/requirements_all.txt index 1c01ba91ccd..6e7a5073652 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2047,7 +2047,7 @@ pyiskra==0.1.15 pyiss==1.0.1 # homeassistant.components.isy994 -pyisy==3.1.15 +pyisy==3.2.0 # homeassistant.components.itach pyitachip2ir==0.0.7 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 5636721e9a2..575e5fe1ff0 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1668,7 +1668,7 @@ pyiskra==0.1.15 pyiss==1.0.1 # homeassistant.components.isy994 -pyisy==3.1.15 +pyisy==3.2.0 # homeassistant.components.ituran pyituran==0.1.4 From 11d68cef542763159170c254da43ed92108d7aef Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Sun, 30 Mar 2025 13:14:17 +0200 Subject: [PATCH 037/505] Replace "Standby" with common state in `blue_current` (#141806) --- homeassistant/components/blue_current/strings.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/blue_current/strings.json b/homeassistant/components/blue_current/strings.json index 2e48d768a74..b90a4792f65 100644 --- a/homeassistant/components/blue_current/strings.json +++ b/homeassistant/components/blue_current/strings.json @@ -37,7 +37,7 @@ "vehicle_status": { "name": "Vehicle status", "state": { - "standby": "Standby", + "standby": "[%key:common::state::standby%]", "vehicle_detected": "Detected", "ready": "Ready", "no_power": "No power", From efad20cdff7e28133687e46ade82586d3e401fd4 Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Sun, 30 Mar 2025 13:43:33 +0200 Subject: [PATCH 038/505] Replace "Standby" and "Idle" with common states in `fronius` (#141812) --- homeassistant/components/fronius/strings.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/fronius/strings.json b/homeassistant/components/fronius/strings.json index b77f6fec83c..36778f2ca5f 100644 --- a/homeassistant/components/fronius/strings.json +++ b/homeassistant/components/fronius/strings.json @@ -182,10 +182,10 @@ "state": { "startup": "Startup", "running": "Running", - "standby": "Standby", + "standby": "[%key:common::state::standby%]", "bootloading": "Bootloading", "error": "Error", - "idle": "Idle", + "idle": "[%key:common::state::idle%]", "ready": "Ready", "sleeping": "Sleeping" } From 73acfa6a8e849e7447acc128638306e0a49e2dc8 Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Sun, 30 Mar 2025 13:55:06 +0200 Subject: [PATCH 039/505] Replace "Stand-by" with common state in `incomfort` (#141807) Also fixes the wrong spelling of "Stand-by" by using "Standby" from the common string. --- homeassistant/components/incomfort/strings.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/incomfort/strings.json b/homeassistant/components/incomfort/strings.json index 73ba88078a8..31fec77f455 100644 --- a/homeassistant/components/incomfort/strings.json +++ b/homeassistant/components/incomfort/strings.json @@ -118,7 +118,7 @@ "tapwater_int": "Tap water internal", "sensor_test": "Sensor test", "central_heating": "Central heating", - "standby": "Stand-by", + "standby": "[%key:common::state::standby%]", "postrun_boyler": "Post run boiler", "service": "Service", "tapwater": "Tap water", From dfa80f078728c44f038bfdc085fa7f2bf53df114 Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Sun, 30 Mar 2025 14:07:08 +0200 Subject: [PATCH 040/505] Replace "Standby" with common state in `knx` (#141817) Also reordered the states alphabetically to improve code readability. --- homeassistant/components/knx/strings.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/knx/strings.json b/homeassistant/components/knx/strings.json index 10730d87ed1..b13667a65b0 100644 --- a/homeassistant/components/knx/strings.json +++ b/homeassistant/components/knx/strings.json @@ -316,10 +316,10 @@ "name": "[%key:component::climate::entity_component::_::state_attributes::preset_mode::name%]", "state": { "auto": "Auto", + "building_protection": "Building protection", "comfort": "[%key:component::climate::entity_component::_::state_attributes::preset_mode::state::comfort%]", - "standby": "Standby", "economy": "[%key:component::climate::entity_component::_::state_attributes::preset_mode::state::eco%]", - "building_protection": "Building protection" + "standby": "[%key:common::state::standby%]" } } } From 31ed6a48cbdd44b035e6863d2dd30cf01e35bb5b Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Sun, 30 Mar 2025 14:07:43 +0200 Subject: [PATCH 041/505] Replace "Standby" with common state in `roborock` (#141810) --- homeassistant/components/roborock/strings.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/roborock/strings.json b/homeassistant/components/roborock/strings.json index caad67e4ce6..78d4fa80590 100644 --- a/homeassistant/components/roborock/strings.json +++ b/homeassistant/components/roborock/strings.json @@ -338,7 +338,7 @@ "zeo_state": { "name": "State", "state": { - "standby": "Standby", + "standby": "[%key:common::state::standby%]", "weighing": "Weighing", "soaking": "Soaking", "washing": "Washing", From eb90958341e6fd9e62067adb04f22c2e458842eb Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Sun, 30 Mar 2025 14:08:03 +0200 Subject: [PATCH 042/505] Replace "Stand-by" and "Off" with common states in `palazzetti` (#141809) Also fixes the wrong spelling of "Stand-by" by using "Standby" from the common string. --- homeassistant/components/palazzetti/strings.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/palazzetti/strings.json b/homeassistant/components/palazzetti/strings.json index 501ee777fe9..7a6c47796df 100644 --- a/homeassistant/components/palazzetti/strings.json +++ b/homeassistant/components/palazzetti/strings.json @@ -74,7 +74,7 @@ "status": { "name": "Status", "state": { - "off": "Off", + "off": "[%key:common::state::off%]", "off_timer": "Timer-regulated switch off", "test_fire": "Ignition test", "heatup": "Pellet feed", @@ -83,7 +83,7 @@ "burning": "Operating", "burning_mod": "Operating - Modulating", "unknown": "Unknown", - "cool_fluid": "Stand-by", + "cool_fluid": "[%key:common::state::standby%]", "fire_stop": "Switch off", "clean_fire": "Burn pot cleaning", "cooling": "Cooling in progress", From a48dd05035dca6e388aede06c9cc56db89eddf2f Mon Sep 17 00:00:00 2001 From: Andrew Sayre <6730289+andrewsayre@users.noreply.github.com> Date: Sun, 30 Mar 2025 07:10:05 -0500 Subject: [PATCH 043/505] Refactor registration of HEOS media player entity services (#141666) Refactor entity service registration --- homeassistant/components/heos/media_player.py | 57 ++------------ homeassistant/components/heos/services.py | 74 ++++++++++++++++++- 2 files changed, 77 insertions(+), 54 deletions(-) diff --git a/homeassistant/components/heos/media_player.py b/homeassistant/components/heos/media_player.py index a6bc24099f0..65314439c18 100644 --- a/homeassistant/components/heos/media_player.py +++ b/homeassistant/components/heos/media_player.py @@ -24,12 +24,10 @@ from pyheos import ( const as heos_const, ) from pyheos.util import mediauri as heos_source -import voluptuous as vol from homeassistant.components import media_source from homeassistant.components.media_player import ( ATTR_MEDIA_ENQUEUE, - ATTR_MEDIA_VOLUME_LEVEL, BrowseError, BrowseMedia, MediaClass, @@ -43,32 +41,16 @@ from homeassistant.components.media_player import ( ) from homeassistant.components.media_source import BrowseMediaSource from homeassistant.const import Platform -from homeassistant.core import ( - HomeAssistant, - ServiceResponse, - SupportsResponse, - callback, -) +from homeassistant.core import HomeAssistant, ServiceResponse, callback from homeassistant.exceptions import HomeAssistantError, ServiceValidationError -from homeassistant.helpers import ( - config_validation as cv, - entity_platform, - entity_registry as er, -) +from homeassistant.helpers import entity_registry as er from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity from homeassistant.util.dt import utcnow -from .const import ( - ATTR_QUEUE_IDS, - DOMAIN as HEOS_DOMAIN, - SERVICE_GET_QUEUE, - SERVICE_GROUP_VOLUME_DOWN, - SERVICE_GROUP_VOLUME_SET, - SERVICE_GROUP_VOLUME_UP, - SERVICE_REMOVE_FROM_QUEUE, -) +from . import services +from .const import DOMAIN as HEOS_DOMAIN from .coordinator import HeosConfigEntry, HeosCoordinator PARALLEL_UPDATES = 0 @@ -139,36 +121,7 @@ async def async_setup_entry( async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Add media players for a config entry.""" - # Register custom entity services - platform = entity_platform.async_get_current_platform() - platform.async_register_entity_service( - SERVICE_GET_QUEUE, - None, - "async_get_queue", - supports_response=SupportsResponse.ONLY, - ) - platform.async_register_entity_service( - SERVICE_REMOVE_FROM_QUEUE, - { - vol.Required(ATTR_QUEUE_IDS): vol.All( - cv.ensure_list, - [vol.All(cv.positive_int, vol.Range(min=1))], - vol.Unique(), - ) - }, - "async_remove_from_queue", - ) - platform.async_register_entity_service( - SERVICE_GROUP_VOLUME_SET, - {vol.Required(ATTR_MEDIA_VOLUME_LEVEL): cv.small_float}, - "async_set_group_volume_level", - ) - platform.async_register_entity_service( - SERVICE_GROUP_VOLUME_DOWN, None, "async_group_volume_down" - ) - platform.async_register_entity_service( - SERVICE_GROUP_VOLUME_UP, None, "async_group_volume_up" - ) + services.register_media_player_services() def add_entities_callback(players: Sequence[HeosPlayer]) -> None: """Add entities for each player.""" diff --git a/homeassistant/components/heos/services.py b/homeassistant/components/heos/services.py index dc11bb7a76d..fe8c887691c 100644 --- a/homeassistant/components/heos/services.py +++ b/homeassistant/components/heos/services.py @@ -1,19 +1,33 @@ """Services for the HEOS integration.""" +from dataclasses import dataclass import logging +from typing import Final from pyheos import CommandAuthenticationError, Heos, HeosError import voluptuous as vol +from homeassistant.components.media_player import ATTR_MEDIA_VOLUME_LEVEL from homeassistant.config_entries import ConfigEntryState -from homeassistant.core import HomeAssistant, ServiceCall +from homeassistant.core import HomeAssistant, ServiceCall, SupportsResponse from homeassistant.exceptions import HomeAssistantError, ServiceValidationError -from homeassistant.helpers import config_validation as cv, issue_registry as ir +from homeassistant.helpers import ( + config_validation as cv, + entity_platform, + issue_registry as ir, +) +from homeassistant.helpers.typing import VolDictType, VolSchemaType from .const import ( ATTR_PASSWORD, + ATTR_QUEUE_IDS, ATTR_USERNAME, DOMAIN, + SERVICE_GET_QUEUE, + SERVICE_GROUP_VOLUME_DOWN, + SERVICE_GROUP_VOLUME_SET, + SERVICE_GROUP_VOLUME_UP, + SERVICE_REMOVE_FROM_QUEUE, SERVICE_SIGN_IN, SERVICE_SIGN_OUT, ) @@ -44,6 +58,62 @@ def register(hass: HomeAssistant) -> None: ) +@dataclass(frozen=True) +class EntityServiceDescription: + """Describe an entity service.""" + + name: str + method_name: str + schema: VolDictType | VolSchemaType | None = None + supports_response: SupportsResponse = SupportsResponse.NONE + + def async_register(self, platform: entity_platform.EntityPlatform) -> None: + """Register the service with the platform.""" + platform.async_register_entity_service( + self.name, + self.schema, + self.method_name, + supports_response=self.supports_response, + ) + + +REMOVE_FROM_QUEUE_SCHEMA: Final[VolDictType] = { + vol.Required(ATTR_QUEUE_IDS): vol.All( + cv.ensure_list, + [vol.All(cv.positive_int, vol.Range(min=1))], + vol.Unique(), + ) +} +GROUP_VOLUME_SET_SCHEMA: Final[VolDictType] = { + vol.Required(ATTR_MEDIA_VOLUME_LEVEL): cv.small_float +} + +MEDIA_PLAYER_ENTITY_SERVICES: Final = ( + # Player queue services + EntityServiceDescription( + SERVICE_GET_QUEUE, "async_get_queue", supports_response=SupportsResponse.ONLY + ), + EntityServiceDescription( + SERVICE_REMOVE_FROM_QUEUE, "async_remove_from_queue", REMOVE_FROM_QUEUE_SCHEMA + ), + # Group volume services + EntityServiceDescription( + SERVICE_GROUP_VOLUME_SET, + "async_set_group_volume_level", + GROUP_VOLUME_SET_SCHEMA, + ), + EntityServiceDescription(SERVICE_GROUP_VOLUME_DOWN, "async_group_volume_down"), + EntityServiceDescription(SERVICE_GROUP_VOLUME_UP, "async_group_volume_up"), +) + + +def register_media_player_services() -> None: + """Register media_player entity services.""" + platform = entity_platform.async_get_current_platform() + for service in MEDIA_PLAYER_ENTITY_SERVICES: + service.async_register(platform) + + def _get_controller(hass: HomeAssistant) -> Heos: """Get the HEOS controller instance.""" _LOGGER.warning( From 8f96ccc83593187ee8a2539369c32674e8fcc851 Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Sun, 30 Mar 2025 14:13:35 +0200 Subject: [PATCH 044/505] Fix sentence-casing in a few strings of `bmw_connected_drive` (#141816) * Fix sentence-casing in a few strings of `bmw_connected_drive` Also replace "Standby" state with common string reference. * Update test_select.ambr --- .../bmw_connected_drive/strings.json | 8 ++++---- .../snapshots/test_select.ambr | 20 +++++++++---------- 2 files changed, 14 insertions(+), 14 deletions(-) diff --git a/homeassistant/components/bmw_connected_drive/strings.json b/homeassistant/components/bmw_connected_drive/strings.json index 4b16b719d8d..bd9814476f5 100644 --- a/homeassistant/components/bmw_connected_drive/strings.json +++ b/homeassistant/components/bmw_connected_drive/strings.json @@ -6,7 +6,7 @@ "data": { "username": "[%key:common::config_flow::data::username%]", "password": "[%key:common::config_flow::data::password%]", - "region": "ConnectedDrive Region" + "region": "ConnectedDrive region" }, "data_description": { "username": "The email address of your MyBMW/MINI Connected account.", @@ -113,10 +113,10 @@ }, "select": { "ac_limit": { - "name": "AC Charging Limit" + "name": "AC charging limit" }, "charging_mode": { - "name": "Charging Mode", + "name": "Charging mode", "state": { "immediate_charging": "Immediate charging", "delayed_charging": "Delayed charging", @@ -181,7 +181,7 @@ "cooling": "Cooling", "heating": "Heating", "inactive": "Inactive", - "standby": "Standby", + "standby": "[%key:common::state::standby%]", "ventilation": "Ventilation" } }, diff --git a/tests/components/bmw_connected_drive/snapshots/test_select.ambr b/tests/components/bmw_connected_drive/snapshots/test_select.ambr index de76b07057e..0edead03f26 100644 --- a/tests/components/bmw_connected_drive/snapshots/test_select.ambr +++ b/tests/components/bmw_connected_drive/snapshots/test_select.ambr @@ -30,7 +30,7 @@ }), 'original_device_class': None, 'original_icon': None, - 'original_name': 'Charging Mode', + 'original_name': 'Charging mode', 'platform': 'bmw_connected_drive', 'previous_unique_id': None, 'supported_features': 0, @@ -42,7 +42,7 @@ # name: test_entity_state_attrs[select.i3_rex_charging_mode-state] StateSnapshot({ 'attributes': ReadOnlyDict({ - 'friendly_name': 'i3 (+ REX) Charging Mode', + 'friendly_name': 'i3 (+ REX) Charging mode', 'options': list([ 'immediate_charging', 'delayed_charging', @@ -98,7 +98,7 @@ }), 'original_device_class': None, 'original_icon': None, - 'original_name': 'AC Charging Limit', + 'original_name': 'AC charging limit', 'platform': 'bmw_connected_drive', 'previous_unique_id': None, 'supported_features': 0, @@ -110,7 +110,7 @@ # name: test_entity_state_attrs[select.i4_edrive40_ac_charging_limit-state] StateSnapshot({ 'attributes': ReadOnlyDict({ - 'friendly_name': 'i4 eDrive40 AC Charging Limit', + 'friendly_name': 'i4 eDrive40 AC charging limit', 'options': list([ '6', '7', @@ -167,7 +167,7 @@ }), 'original_device_class': None, 'original_icon': None, - 'original_name': 'Charging Mode', + 'original_name': 'Charging mode', 'platform': 'bmw_connected_drive', 'previous_unique_id': None, 'supported_features': 0, @@ -179,7 +179,7 @@ # name: test_entity_state_attrs[select.i4_edrive40_charging_mode-state] StateSnapshot({ 'attributes': ReadOnlyDict({ - 'friendly_name': 'i4 eDrive40 Charging Mode', + 'friendly_name': 'i4 eDrive40 Charging mode', 'options': list([ 'immediate_charging', 'delayed_charging', @@ -235,7 +235,7 @@ }), 'original_device_class': None, 'original_icon': None, - 'original_name': 'AC Charging Limit', + 'original_name': 'AC charging limit', 'platform': 'bmw_connected_drive', 'previous_unique_id': None, 'supported_features': 0, @@ -247,7 +247,7 @@ # name: test_entity_state_attrs[select.ix_xdrive50_ac_charging_limit-state] StateSnapshot({ 'attributes': ReadOnlyDict({ - 'friendly_name': 'iX xDrive50 AC Charging Limit', + 'friendly_name': 'iX xDrive50 AC charging limit', 'options': list([ '6', '7', @@ -304,7 +304,7 @@ }), 'original_device_class': None, 'original_icon': None, - 'original_name': 'Charging Mode', + 'original_name': 'Charging mode', 'platform': 'bmw_connected_drive', 'previous_unique_id': None, 'supported_features': 0, @@ -316,7 +316,7 @@ # name: test_entity_state_attrs[select.ix_xdrive50_charging_mode-state] StateSnapshot({ 'attributes': ReadOnlyDict({ - 'friendly_name': 'iX xDrive50 Charging Mode', + 'friendly_name': 'iX xDrive50 Charging mode', 'options': list([ 'immediate_charging', 'delayed_charging', From dce9bfd3592db0195bf8e0551975cc1bef6e45f6 Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Sun, 30 Mar 2025 14:34:23 +0200 Subject: [PATCH 045/505] Replace "Idle" with common state in `venstar`, fix sentence-case (#141819) --- homeassistant/components/venstar/strings.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/venstar/strings.json b/homeassistant/components/venstar/strings.json index fdc75162651..1d916d0b8f6 100644 --- a/homeassistant/components/venstar/strings.json +++ b/homeassistant/components/venstar/strings.json @@ -32,7 +32,7 @@ "name": "Filter usage" }, "schedule_part": { - "name": "Schedule Part", + "name": "Schedule part", "state": { "morning": "Morning", "day": "Day", @@ -44,7 +44,7 @@ "active_stage": { "name": "Active stage", "state": { - "idle": "Idle", + "idle": "[%key:common::state::idle%]", "first_stage": "First stage", "second_stage": "Second stage" } From ad3f7f041f6cc7c17d2b2e3df5fbde2541cfb552 Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Sun, 30 Mar 2025 14:34:36 +0200 Subject: [PATCH 046/505] Replace "Idle" with common state in `homekit_controller` (#141820) --- homeassistant/components/homekit_controller/strings.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/homekit_controller/strings.json b/homeassistant/components/homekit_controller/strings.json index d1205645fd3..dcbfae72fe3 100644 --- a/homeassistant/components/homekit_controller/strings.json +++ b/homeassistant/components/homekit_controller/strings.json @@ -141,7 +141,7 @@ "air_purifier_state_current": { "state": { "inactive": "Inactive", - "idle": "Idle", + "idle": "[%key:common::state::idle%]", "purifying": "Purifying" } } From 0eeb6b5fd503a2f35f2f5026d7c3dfa3c59ca5e5 Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Sun, 30 Mar 2025 14:56:36 +0200 Subject: [PATCH 047/505] Replace "Idle" with common state in `backup`, fix sentence-case (#141814) * Replace "Idle" with common state in `backup`, fix sentence-case * Update test_sensors.ambr --- homeassistant/components/backup/strings.json | 4 ++-- tests/components/backup/snapshots/test_sensors.ambr | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/backup/strings.json b/homeassistant/components/backup/strings.json index 487fdd89a7c..357bcdbb72f 100644 --- a/homeassistant/components/backup/strings.json +++ b/homeassistant/components/backup/strings.json @@ -26,9 +26,9 @@ "entity": { "sensor": { "backup_manager_state": { - "name": "Backup Manager State", + "name": "Backup Manager state", "state": { - "idle": "Idle", + "idle": "[%key:common::state::idle%]", "create_backup": "Creating a backup", "receive_backup": "Receiving a backup", "restore_backup": "Restoring a backup" diff --git a/tests/components/backup/snapshots/test_sensors.ambr b/tests/components/backup/snapshots/test_sensors.ambr index 924038ef81f..be12afdbf1e 100644 --- a/tests/components/backup/snapshots/test_sensors.ambr +++ b/tests/components/backup/snapshots/test_sensors.ambr @@ -32,7 +32,7 @@ }), 'original_device_class': , 'original_icon': None, - 'original_name': 'Backup Manager State', + 'original_name': 'Backup Manager state', 'platform': 'backup', 'previous_unique_id': None, 'supported_features': 0, @@ -45,7 +45,7 @@ StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'enum', - 'friendly_name': 'Backup Backup Manager State', + 'friendly_name': 'Backup Backup Manager state', 'options': list([ 'idle', 'create_backup', From 578fece13ec097ff32d32a61c6ee19a7708e7a24 Mon Sep 17 00:00:00 2001 From: Aidan Timson Date: Sun, 30 Mar 2025 13:57:53 +0100 Subject: [PATCH 048/505] Fix System Bridge wait timeout wait condition (#141811) * Fix System Bridge wait timeout wait condition * Add DataMissingException as a timeout condition * Add tests --- .../components/system_bridge/__init__.py | 3 +- .../components/system_bridge/const.py | 4 +- .../components/system_bridge/coordinator.py | 7 ++- tests/components/system_bridge/test_init.py | 50 +++++++++++++++++++ 4 files changed, 60 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/system_bridge/__init__.py b/homeassistant/components/system_bridge/__init__.py index 3bda29867cc..e1ee57e42b2 100644 --- a/homeassistant/components/system_bridge/__init__.py +++ b/homeassistant/components/system_bridge/__init__.py @@ -11,6 +11,7 @@ from systembridgeconnector.exceptions import ( AuthenticationException, ConnectionClosedException, ConnectionErrorException, + DataMissingException, ) from systembridgeconnector.version import Version from systembridgemodels.keyboard_key import KeyboardKey @@ -184,7 +185,7 @@ async def async_setup_entry( "host": entry.data[CONF_HOST], }, ) from exception - except TimeoutError as exception: + except (DataMissingException, TimeoutError) as exception: raise ConfigEntryNotReady( translation_domain=DOMAIN, translation_key="timeout", diff --git a/homeassistant/components/system_bridge/const.py b/homeassistant/components/system_bridge/const.py index 32507f6d84e..235d7e6b986 100644 --- a/homeassistant/components/system_bridge/const.py +++ b/homeassistant/components/system_bridge/const.py @@ -18,4 +18,6 @@ MODULES: Final[list[Module]] = [ Module.SYSTEM, ] -DATA_WAIT_TIMEOUT: Final[int] = 10 +DATA_WAIT_TIMEOUT: Final[int] = 20 + +GET_DATA_WAIT_TIMEOUT: Final[int] = 15 diff --git a/homeassistant/components/system_bridge/coordinator.py b/homeassistant/components/system_bridge/coordinator.py index 1690bad4a4d..7e545f39e46 100644 --- a/homeassistant/components/system_bridge/coordinator.py +++ b/homeassistant/components/system_bridge/coordinator.py @@ -33,7 +33,7 @@ from homeassistant.exceptions import ConfigEntryAuthFailed from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.update_coordinator import DataUpdateCoordinator -from .const import DOMAIN, MODULES +from .const import DOMAIN, GET_DATA_WAIT_TIMEOUT, MODULES from .data import SystemBridgeData @@ -119,7 +119,10 @@ class SystemBridgeDataUpdateCoordinator(DataUpdateCoordinator[SystemBridgeData]) """Get data from WebSocket.""" await self.check_websocket_connected() - modules_data = await self.websocket_client.get_data(GetData(modules=modules)) + modules_data = await self.websocket_client.get_data( + GetData(modules=modules), + timeout=GET_DATA_WAIT_TIMEOUT, + ) # Merge new data with existing data for module in MODULES: diff --git a/tests/components/system_bridge/test_init.py b/tests/components/system_bridge/test_init.py index 7632a0c8157..25ccbdeb46c 100644 --- a/tests/components/system_bridge/test_init.py +++ b/tests/components/system_bridge/test_init.py @@ -81,3 +81,53 @@ async def test_migration_minor_future_version(hass: HomeAssistant) -> None: assert config_entry.minor_version == config_entry_minor_version assert config_entry.data == config_entry_data assert config_entry.state is ConfigEntryState.LOADED + + +async def test_setup_timeout(hass: HomeAssistant) -> None: + """Test setup with timeout error.""" + config_entry = MockConfigEntry( + domain=DOMAIN, + unique_id=FIXTURE_UUID, + data=FIXTURE_USER_INPUT, + version=SystemBridgeConfigFlow.VERSION, + minor_version=SystemBridgeConfigFlow.MINOR_VERSION, + ) + + with patch( + "systembridgeconnector.version.Version.check_supported", + side_effect=TimeoutError, + ): + config_entry.add_to_hass(hass) + result = await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + assert result is False + assert config_entry.state is ConfigEntryState.SETUP_RETRY + + +async def test_coordinator_get_data_timeout(hass: HomeAssistant) -> None: + """Test coordinator handling timeout during get_data.""" + config_entry = MockConfigEntry( + domain=DOMAIN, + unique_id=FIXTURE_UUID, + data=FIXTURE_USER_INPUT, + version=SystemBridgeConfigFlow.VERSION, + minor_version=SystemBridgeConfigFlow.MINOR_VERSION, + ) + + with ( + patch( + "systembridgeconnector.version.Version.check_supported", + return_value=True, + ), + patch( + "homeassistant.components.system_bridge.coordinator.SystemBridgeDataUpdateCoordinator.async_get_data", + side_effect=TimeoutError, + ), + ): + config_entry.add_to_hass(hass) + result = await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + assert result is False + assert config_entry.state is ConfigEntryState.SETUP_RETRY From e725ba403be2b5022a88f46cba0537f17cf96275 Mon Sep 17 00:00:00 2001 From: Thomas55555 <59625598+Thomas55555@users.noreply.github.com> Date: Sun, 30 Mar 2025 14:58:47 +0200 Subject: [PATCH 049/505] Bump ical to 9.0.3 (#141805) --- homeassistant/components/google/manifest.json | 2 +- homeassistant/components/local_calendar/manifest.json | 2 +- homeassistant/components/local_todo/manifest.json | 2 +- homeassistant/components/remote_calendar/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 6 files changed, 6 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/google/manifest.json b/homeassistant/components/google/manifest.json index 81fd2b07de4..efce97a0d6f 100644 --- a/homeassistant/components/google/manifest.json +++ b/homeassistant/components/google/manifest.json @@ -7,5 +7,5 @@ "documentation": "https://www.home-assistant.io/integrations/google", "iot_class": "cloud_polling", "loggers": ["googleapiclient"], - "requirements": ["gcal-sync==7.0.0", "oauth2client==4.1.3", "ical==9.0.1"] + "requirements": ["gcal-sync==7.0.0", "oauth2client==4.1.3", "ical==9.0.3"] } diff --git a/homeassistant/components/local_calendar/manifest.json b/homeassistant/components/local_calendar/manifest.json index fc6d0bc00c7..528552aaa57 100644 --- a/homeassistant/components/local_calendar/manifest.json +++ b/homeassistant/components/local_calendar/manifest.json @@ -7,5 +7,5 @@ "documentation": "https://www.home-assistant.io/integrations/local_calendar", "iot_class": "local_polling", "loggers": ["ical"], - "requirements": ["ical==9.0.1"] + "requirements": ["ical==9.0.3"] } diff --git a/homeassistant/components/local_todo/manifest.json b/homeassistant/components/local_todo/manifest.json index 27d3ccce4a7..6f117131c20 100644 --- a/homeassistant/components/local_todo/manifest.json +++ b/homeassistant/components/local_todo/manifest.json @@ -5,5 +5,5 @@ "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/local_todo", "iot_class": "local_polling", - "requirements": ["ical==9.0.1"] + "requirements": ["ical==9.0.3"] } diff --git a/homeassistant/components/remote_calendar/manifest.json b/homeassistant/components/remote_calendar/manifest.json index fe17a3d2c34..256f5baf0ff 100644 --- a/homeassistant/components/remote_calendar/manifest.json +++ b/homeassistant/components/remote_calendar/manifest.json @@ -8,5 +8,5 @@ "iot_class": "cloud_polling", "loggers": ["ical"], "quality_scale": "silver", - "requirements": ["ical==9.0.1"] + "requirements": ["ical==9.0.3"] } diff --git a/requirements_all.txt b/requirements_all.txt index 6e7a5073652..bcb55249f2e 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1196,7 +1196,7 @@ ibmiotf==0.3.4 # homeassistant.components.local_calendar # homeassistant.components.local_todo # homeassistant.components.remote_calendar -ical==9.0.1 +ical==9.0.3 # homeassistant.components.caldav icalendar==6.1.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 575e5fe1ff0..eef4251201b 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1014,7 +1014,7 @@ ibeacon-ble==1.2.0 # homeassistant.components.local_calendar # homeassistant.components.local_todo # homeassistant.components.remote_calendar -ical==9.0.1 +ical==9.0.3 # homeassistant.components.caldav icalendar==6.1.0 From 89df6a82b0f2f485554b6c414adba10ef9f96c53 Mon Sep 17 00:00:00 2001 From: Martin Hjelmare Date: Sun, 30 Mar 2025 14:59:13 +0200 Subject: [PATCH 050/505] Bump pydroid-ipcam to 3.0.0 (#141739) --- homeassistant/components/android_ip_webcam/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/android_ip_webcam/manifest.json b/homeassistant/components/android_ip_webcam/manifest.json index 57af567ec51..d7a9f8ad97a 100644 --- a/homeassistant/components/android_ip_webcam/manifest.json +++ b/homeassistant/components/android_ip_webcam/manifest.json @@ -5,5 +5,5 @@ "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/android_ip_webcam", "iot_class": "local_polling", - "requirements": ["pydroid-ipcam==2.0.0"] + "requirements": ["pydroid-ipcam==3.0.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index bcb55249f2e..2e3ef15d2b2 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1918,7 +1918,7 @@ pydoods==1.0.2 pydrawise==2025.3.0 # homeassistant.components.android_ip_webcam -pydroid-ipcam==2.0.0 +pydroid-ipcam==3.0.0 # homeassistant.components.ebox pyebox==1.1.4 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index eef4251201b..e4b0d5dde2c 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1566,7 +1566,7 @@ pydiscovergy==3.0.2 pydrawise==2025.3.0 # homeassistant.components.android_ip_webcam -pydroid-ipcam==2.0.0 +pydroid-ipcam==3.0.0 # homeassistant.components.ecoforest pyecoforest==0.4.0 From d3257d96d078f0d9ce5097818caa8bbf687a6f69 Mon Sep 17 00:00:00 2001 From: Simone Chemelli Date: Sun, 30 Mar 2025 14:59:56 +0200 Subject: [PATCH 051/505] Add full test coverage for Comelit light platform (#141736) * Add full test coverage for Comelit light platform * cleanup --- .../comelit/snapshots/test_light.ambr | 57 ++++++++++++++ tests/components/comelit/test_light.py | 76 +++++++++++++++++++ 2 files changed, 133 insertions(+) create mode 100644 tests/components/comelit/snapshots/test_light.ambr create mode 100644 tests/components/comelit/test_light.py diff --git a/tests/components/comelit/snapshots/test_light.ambr b/tests/components/comelit/snapshots/test_light.ambr new file mode 100644 index 00000000000..c60c962e23d --- /dev/null +++ b/tests/components/comelit/snapshots/test_light.ambr @@ -0,0 +1,57 @@ +# serializer version: 1 +# name: test_all_entities[light.light0-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'supported_color_modes': list([ + , + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'light', + 'entity_category': None, + 'entity_id': 'light.light0', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': None, + 'platform': 'comelit', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'serial_bridge_config_entry_id-0', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[light.light0-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'color_mode': None, + 'friendly_name': 'Light0', + 'supported_color_modes': list([ + , + ]), + 'supported_features': , + }), + 'context': , + 'entity_id': 'light.light0', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- diff --git a/tests/components/comelit/test_light.py b/tests/components/comelit/test_light.py new file mode 100644 index 00000000000..6c6de58c8ed --- /dev/null +++ b/tests/components/comelit/test_light.py @@ -0,0 +1,76 @@ +"""Tests for Comelit SimpleHome light platform.""" + +from unittest.mock import AsyncMock, patch + +import pytest +from syrupy import SnapshotAssertion + +from homeassistant.components.light import ( + DOMAIN as LIGHT_DOMAIN, + SERVICE_TOGGLE, + SERVICE_TURN_OFF, + SERVICE_TURN_ON, +) +from homeassistant.const import ATTR_ENTITY_ID, STATE_OFF, STATE_ON, Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from . import setup_integration + +from tests.common import MockConfigEntry, snapshot_platform + +ENTITY_ID = "light.light0" + + +async def test_all_entities( + hass: HomeAssistant, + snapshot: SnapshotAssertion, + mock_serial_bridge: AsyncMock, + mock_serial_bridge_config_entry: MockConfigEntry, + entity_registry: er.EntityRegistry, +) -> None: + """Test all entities.""" + with patch("homeassistant.components.comelit.BRIDGE_PLATFORMS", [Platform.LIGHT]): + await setup_integration(hass, mock_serial_bridge_config_entry) + + await snapshot_platform( + hass, + entity_registry, + snapshot(), + mock_serial_bridge_config_entry.entry_id, + ) + + +@pytest.mark.parametrize( + ("service", "status"), + [ + (SERVICE_TURN_OFF, STATE_OFF), + (SERVICE_TURN_ON, STATE_ON), + (SERVICE_TOGGLE, STATE_ON), + ], +) +async def test_light_set_state( + hass: HomeAssistant, + mock_serial_bridge: AsyncMock, + mock_serial_bridge_config_entry: MockConfigEntry, + service: str, + status: str, +) -> None: + """Test light set state service.""" + + await setup_integration(hass, mock_serial_bridge_config_entry) + + assert (state := hass.states.get(ENTITY_ID)) + assert state.state == STATE_OFF + + # Test set temperature + await hass.services.async_call( + LIGHT_DOMAIN, + service, + {ATTR_ENTITY_ID: ENTITY_ID}, + blocking=True, + ) + mock_serial_bridge.set_device_status.assert_called() + + assert (state := hass.states.get(ENTITY_ID)) + assert state.state == status From bcc767136cb903f43ab5b0a6ab0764f3ee6c81b5 Mon Sep 17 00:00:00 2001 From: Aidan Timson Date: Sun, 30 Mar 2025 14:00:38 +0100 Subject: [PATCH 052/505] Add System Bridge suggested sensor precisions (#141815) --- .../components/system_bridge/sensor.py | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/homeassistant/components/system_bridge/sensor.py b/homeassistant/components/system_bridge/sensor.py index c7cae2f347b..d9226e7de6e 100644 --- a/homeassistant/components/system_bridge/sensor.py +++ b/homeassistant/components/system_bridge/sensor.py @@ -251,6 +251,7 @@ BASE_SENSOR_TYPES: tuple[SystemBridgeSensorEntityDescription, ...] = ( state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=UnitOfFrequency.GIGAHERTZ, device_class=SensorDeviceClass.FREQUENCY, + suggested_display_precision=2, icon="mdi:speedometer", value=cpu_speed, ), @@ -261,6 +262,7 @@ BASE_SENSOR_TYPES: tuple[SystemBridgeSensorEntityDescription, ...] = ( device_class=SensorDeviceClass.TEMPERATURE, state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=UnitOfTemperature.CELSIUS, + suggested_display_precision=2, value=lambda data: data.cpu.temperature, ), SystemBridgeSensorEntityDescription( @@ -270,6 +272,7 @@ BASE_SENSOR_TYPES: tuple[SystemBridgeSensorEntityDescription, ...] = ( device_class=SensorDeviceClass.VOLTAGE, state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=UnitOfElectricPotential.VOLT, + suggested_display_precision=2, value=lambda data: data.cpu.voltage, ), SystemBridgeSensorEntityDescription( @@ -284,6 +287,7 @@ BASE_SENSOR_TYPES: tuple[SystemBridgeSensorEntityDescription, ...] = ( state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=UnitOfInformation.GIGABYTES, device_class=SensorDeviceClass.DATA_SIZE, + suggested_display_precision=2, icon="mdi:memory", value=memory_free, ), @@ -291,6 +295,7 @@ BASE_SENSOR_TYPES: tuple[SystemBridgeSensorEntityDescription, ...] = ( key="memory_used_percentage", state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=PERCENTAGE, + suggested_display_precision=2, icon="mdi:memory", value=lambda data: data.memory.virtual.percent, ), @@ -301,6 +306,7 @@ BASE_SENSOR_TYPES: tuple[SystemBridgeSensorEntityDescription, ...] = ( state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=UnitOfInformation.GIGABYTES, device_class=SensorDeviceClass.DATA_SIZE, + suggested_display_precision=2, icon="mdi:memory", value=memory_used, ), @@ -322,6 +328,7 @@ BASE_SENSOR_TYPES: tuple[SystemBridgeSensorEntityDescription, ...] = ( translation_key="load", state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=PERCENTAGE, + suggested_display_precision=1, icon="mdi:percent", value=lambda data: data.cpu.usage, ), @@ -345,6 +352,7 @@ BATTERY_SENSOR_TYPES: tuple[SystemBridgeSensorEntityDescription, ...] = ( device_class=SensorDeviceClass.BATTERY, state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=PERCENTAGE, + suggested_display_precision=2, value=lambda data: data.battery.percentage, ), SystemBridgeSensorEntityDescription( @@ -381,6 +389,7 @@ async def async_setup_entry( name=f"{partition.mount_point} space used", state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=PERCENTAGE, + suggested_display_precision=2, icon="mdi:harddisk", value=( lambda data, @@ -457,6 +466,7 @@ async def async_setup_entry( state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=UnitOfFrequency.HERTZ, device_class=SensorDeviceClass.FREQUENCY, + suggested_display_precision=0, icon="mdi:monitor", value=lambda data, k=index: display_refresh_rate(data, k), ), @@ -476,6 +486,7 @@ async def async_setup_entry( state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=UnitOfFrequency.MEGAHERTZ, device_class=SensorDeviceClass.FREQUENCY, + suggested_display_precision=0, icon="mdi:speedometer", value=lambda data, k=index: gpu_core_clock_speed(data, k), ), @@ -490,6 +501,7 @@ async def async_setup_entry( state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=UnitOfFrequency.MEGAHERTZ, device_class=SensorDeviceClass.FREQUENCY, + suggested_display_precision=0, icon="mdi:speedometer", value=lambda data, k=index: gpu_memory_clock_speed(data, k), ), @@ -503,6 +515,7 @@ async def async_setup_entry( state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=UnitOfInformation.MEGABYTES, device_class=SensorDeviceClass.DATA_SIZE, + suggested_display_precision=0, icon="mdi:memory", value=lambda data, k=index: gpu_memory_free(data, k), ), @@ -515,6 +528,7 @@ async def async_setup_entry( name=f"{gpu.name} memory used %", state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=PERCENTAGE, + suggested_display_precision=2, icon="mdi:memory", value=lambda data, k=index: gpu_memory_used_percentage(data, k), ), @@ -529,6 +543,7 @@ async def async_setup_entry( state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=UnitOfInformation.MEGABYTES, device_class=SensorDeviceClass.DATA_SIZE, + suggested_display_precision=0, icon="mdi:memory", value=lambda data, k=index: gpu_memory_used(data, k), ), @@ -569,6 +584,7 @@ async def async_setup_entry( device_class=SensorDeviceClass.TEMPERATURE, state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=UnitOfTemperature.CELSIUS, + suggested_display_precision=2, value=lambda data, k=index: gpu_temperature(data, k), ), entry.data[CONF_PORT], @@ -580,6 +596,7 @@ async def async_setup_entry( name=f"{gpu.name} usage %", state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=PERCENTAGE, + suggested_display_precision=2, icon="mdi:percent", value=lambda data, k=index: gpu_usage_percentage(data, k), ), @@ -601,6 +618,7 @@ async def async_setup_entry( state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=PERCENTAGE, icon="mdi:percent", + suggested_display_precision=2, value=lambda data, k=cpu.id: cpu_usage_per_cpu(data, k), ), entry.data[CONF_PORT], @@ -614,6 +632,7 @@ async def async_setup_entry( native_unit_of_measurement=UnitOfPower.WATT, state_class=SensorStateClass.MEASUREMENT, icon="mdi:chip", + suggested_display_precision=2, value=lambda data, k=cpu.id: cpu_power_per_cpu(data, k), ), entry.data[CONF_PORT], From a5b320180a82fdeb7facfae8723632abe6926865 Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Sun, 30 Mar 2025 15:01:06 +0200 Subject: [PATCH 053/505] Correct spelling for 'availability` in MQTT translation strings (#141818) --- homeassistant/components/mqtt/strings.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/mqtt/strings.json b/homeassistant/components/mqtt/strings.json index 2bc8ff3b71f..cedf120def1 100644 --- a/homeassistant/components/mqtt/strings.json +++ b/homeassistant/components/mqtt/strings.json @@ -126,7 +126,7 @@ "payload_not_available": "Payload not available" }, "data_description": { - "availability_topic": "Topic to receive the availabillity payload on", + "availability_topic": "Topic to receive the availability payload on", "availability_template": "A [template](https://www.home-assistant.io/docs/configuration/templating/#using-templates-with-the-mqtt-integration) to render the availability payload received on the availability topic", "payload_available": "The payload that indicates the device is available (defaults to 'online')", "payload_not_available": "The payload that indicates the device is not available (defaults to 'offline')" From b4a6ca63b349d4f05aa9018d475898895ae25aae Mon Sep 17 00:00:00 2001 From: Simone Chemelli Date: Sun, 30 Mar 2025 15:02:15 +0200 Subject: [PATCH 054/505] Add full test coverage for Comelit sensor platform (#141813) --- tests/components/comelit/conftest.py | 1 + .../comelit/snapshots/test_sensor.ambr | 76 ++++++++++++++++ tests/components/comelit/test_sensor.py | 90 +++++++++++++++++++ 3 files changed, 167 insertions(+) create mode 100644 tests/components/comelit/snapshots/test_sensor.ambr create mode 100644 tests/components/comelit/test_sensor.py diff --git a/tests/components/comelit/conftest.py b/tests/components/comelit/conftest.py index c315d0fa00e..1e5e85cd26e 100644 --- a/tests/components/comelit/conftest.py +++ b/tests/components/comelit/conftest.py @@ -104,4 +104,5 @@ def mock_vedo_config_entry() -> Generator[MockConfigEntry]: CONF_PIN: VEDO_PIN, CONF_TYPE: VEDO, }, + entry_id="vedo_config_entry_id", ) diff --git a/tests/components/comelit/snapshots/test_sensor.ambr b/tests/components/comelit/snapshots/test_sensor.ambr new file mode 100644 index 00000000000..dabae2a1bf0 --- /dev/null +++ b/tests/components/comelit/snapshots/test_sensor.ambr @@ -0,0 +1,76 @@ +# serializer version: 1 +# name: test_all_entities[sensor.zone0-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'alarm', + 'armed', + 'open', + 'excluded', + 'faulty', + 'inhibited', + 'isolated', + 'rest', + 'sabotated', + 'unavailable', + 'unknown', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.zone0', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': None, + 'platform': 'comelit', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'zone_status', + 'unique_id': 'vedo_config_entry_id-0', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[sensor.zone0-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'enum', + 'friendly_name': 'Zone0', + 'options': list([ + 'alarm', + 'armed', + 'open', + 'excluded', + 'faulty', + 'inhibited', + 'isolated', + 'rest', + 'sabotated', + 'unavailable', + 'unknown', + ]), + }), + 'context': , + 'entity_id': 'sensor.zone0', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'rest', + }) +# --- diff --git a/tests/components/comelit/test_sensor.py b/tests/components/comelit/test_sensor.py new file mode 100644 index 00000000000..56409083165 --- /dev/null +++ b/tests/components/comelit/test_sensor.py @@ -0,0 +1,90 @@ +"""Tests for Comelit SimpleHome sensor platform.""" + +from unittest.mock import AsyncMock, patch + +from aiocomelit.api import AlarmDataObject, ComelitVedoAreaObject, ComelitVedoZoneObject +from aiocomelit.const import AlarmAreaState, AlarmZoneState +from freezegun.api import FrozenDateTimeFactory +from syrupy import SnapshotAssertion + +from homeassistant.components.comelit.const import SCAN_INTERVAL +from homeassistant.const import STATE_UNKNOWN, Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from . import setup_integration + +from tests.common import MockConfigEntry, async_fire_time_changed, snapshot_platform + +ENTITY_ID = "sensor.zone0" + + +async def test_all_entities( + hass: HomeAssistant, + snapshot: SnapshotAssertion, + mock_vedo: AsyncMock, + mock_vedo_config_entry: MockConfigEntry, + entity_registry: er.EntityRegistry, +) -> None: + """Test all entities.""" + with patch("homeassistant.components.comelit.VEDO_PLATFORMS", [Platform.SENSOR]): + await setup_integration(hass, mock_vedo_config_entry) + + await snapshot_platform( + hass, + entity_registry, + snapshot(), + mock_vedo_config_entry.entry_id, + ) + + +async def test_sensor_state_unknown( + hass: HomeAssistant, + freezer: FrozenDateTimeFactory, + mock_vedo: AsyncMock, + mock_vedo_config_entry: MockConfigEntry, +) -> None: + """Test sensor unknown state.""" + + await setup_integration(hass, mock_vedo_config_entry) + + assert (state := hass.states.get(ENTITY_ID)) + assert state.state == AlarmZoneState.REST.value + + vedo_query = AlarmDataObject( + alarm_areas={ + 0: ComelitVedoAreaObject( + index=0, + name="Area0", + p1=True, + p2=True, + ready=False, + armed=True, + alarm=False, + alarm_memory=False, + sabotage=False, + anomaly=False, + in_time=False, + out_time=False, + human_status=AlarmAreaState.UNKNOWN, + ) + }, + alarm_zones={ + 0: ComelitVedoZoneObject( + index=0, + name="Zone0", + status_api="0x000", + status=0, + human_status=AlarmZoneState.UNKNOWN, + ) + }, + ) + + mock_vedo.get_all_areas_and_zones.return_value = vedo_query + + freezer.tick(SCAN_INTERVAL) + async_fire_time_changed(hass) + await hass.async_block_till_done(wait_background_tasks=True) + + assert (state := hass.states.get(ENTITY_ID)) + assert state.state == STATE_UNKNOWN From 476120709765024d2cf217e65f243e4e6cd15149 Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Sun, 30 Mar 2025 15:03:26 +0200 Subject: [PATCH 055/505] Add boost preset to AVM Fritz!SmartHome climate entities (#141802) * add boost preset to climate entities * add set boost preset test --- homeassistant/components/fritzbox/climate.py | 7 ++- tests/components/fritzbox/test_climate.py | 62 +++++++++++++++++--- 2 files changed, 59 insertions(+), 10 deletions(-) diff --git a/homeassistant/components/fritzbox/climate.py b/homeassistant/components/fritzbox/climate.py index 118e03c391f..57c7e2a696f 100644 --- a/homeassistant/components/fritzbox/climate.py +++ b/homeassistant/components/fritzbox/climate.py @@ -6,6 +6,7 @@ from typing import Any from homeassistant.components.climate import ( ATTR_HVAC_MODE, + PRESET_BOOST, PRESET_COMFORT, PRESET_ECO, ClimateEntity, @@ -38,7 +39,7 @@ from .sensor import value_scheduled_preset HVAC_MODES = [HVACMode.HEAT, HVACMode.OFF] PRESET_HOLIDAY = "holiday" PRESET_SUMMER = "summer" -PRESET_MODES = [PRESET_ECO, PRESET_COMFORT] +PRESET_MODES = [PRESET_ECO, PRESET_COMFORT, PRESET_BOOST] SUPPORTED_FEATURES = ( ClimateEntityFeature.TARGET_TEMPERATURE | ClimateEntityFeature.PRESET_MODE @@ -194,6 +195,8 @@ class FritzboxThermostat(FritzBoxDeviceEntity, ClimateEntity): return PRESET_HOLIDAY if self.data.summer_active: return PRESET_SUMMER + if self.data.target_temperature == ON_API_TEMPERATURE: + return PRESET_BOOST if self.data.target_temperature == self.data.comfort_temperature: return PRESET_COMFORT if self.data.target_temperature == self.data.eco_temperature: @@ -211,6 +214,8 @@ class FritzboxThermostat(FritzBoxDeviceEntity, ClimateEntity): await self.async_set_temperature(temperature=self.data.comfort_temperature) elif preset_mode == PRESET_ECO: await self.async_set_temperature(temperature=self.data.eco_temperature) + elif preset_mode == PRESET_BOOST: + await self.async_set_temperature(temperature=ON_REPORT_SET_TEMPERATURE) @property def extra_state_attributes(self) -> ClimateExtraAttributes: diff --git a/tests/components/fritzbox/test_climate.py b/tests/components/fritzbox/test_climate.py index 0784d7b6188..7766d906f68 100644 --- a/tests/components/fritzbox/test_climate.py +++ b/tests/components/fritzbox/test_climate.py @@ -16,6 +16,7 @@ from homeassistant.components.climate import ( ATTR_PRESET_MODE, ATTR_PRESET_MODES, DOMAIN as CLIMATE_DOMAIN, + PRESET_BOOST, PRESET_COMFORT, PRESET_ECO, SERVICE_SET_HVAC_MODE, @@ -80,7 +81,11 @@ async def test_setup(hass: HomeAssistant, fritz: Mock) -> None: assert state.attributes[ATTR_MAX_TEMP] == 28 assert state.attributes[ATTR_MIN_TEMP] == 8 assert state.attributes[ATTR_PRESET_MODE] is None - assert state.attributes[ATTR_PRESET_MODES] == [PRESET_ECO, PRESET_COMFORT] + assert state.attributes[ATTR_PRESET_MODES] == [ + PRESET_ECO, + PRESET_COMFORT, + PRESET_BOOST, + ] assert state.attributes[ATTR_STATE_BATTERY_LOW] is True assert state.attributes[ATTR_STATE_HOLIDAY_MODE] is False assert state.attributes[ATTR_STATE_SUMMER_MODE] is False @@ -434,11 +439,31 @@ async def test_set_preset_mode_eco( assert device.set_target_temperature.call_args_list == expected_call_args +async def test_set_preset_mode_boost( + hass: HomeAssistant, + fritz: Mock, +) -> None: + """Test setting preset mode.""" + device = FritzDeviceClimateMock() + assert await setup_config_entry( + hass, MOCK_CONFIG[FB_DOMAIN][CONF_DEVICES][0], ENTITY_ID, device, fritz + ) + + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_PRESET_MODE, + {ATTR_ENTITY_ID: ENTITY_ID, ATTR_PRESET_MODE: PRESET_BOOST}, + True, + ) + assert device.set_target_temperature.call_count == 1 + assert device.set_target_temperature.call_args_list == [call(30, True)] + + async def test_preset_mode_update(hass: HomeAssistant, fritz: Mock) -> None: """Test preset mode.""" device = FritzDeviceClimateMock() - device.comfort_temperature = 98 - device.eco_temperature = 99 + device.comfort_temperature = 23 + device.eco_temperature = 20 assert await setup_config_entry( hass, MOCK_CONFIG[FB_DOMAIN][CONF_DEVICES][0], ENTITY_ID, device, fritz ) @@ -447,8 +472,8 @@ async def test_preset_mode_update(hass: HomeAssistant, fritz: Mock) -> None: assert state assert state.attributes[ATTR_PRESET_MODE] is None - device.target_temperature = 98 - + # test comfort preset + device.target_temperature = 23 next_update = dt_util.utcnow() + timedelta(seconds=200) async_fire_time_changed(hass, next_update) await hass.async_block_till_done(wait_background_tasks=True) @@ -458,8 +483,8 @@ async def test_preset_mode_update(hass: HomeAssistant, fritz: Mock) -> None: assert state assert state.attributes[ATTR_PRESET_MODE] == PRESET_COMFORT - device.target_temperature = 99 - + # test eco preset + device.target_temperature = 20 next_update = dt_util.utcnow() + timedelta(seconds=200) async_fire_time_changed(hass, next_update) await hass.async_block_till_done(wait_background_tasks=True) @@ -469,6 +494,17 @@ async def test_preset_mode_update(hass: HomeAssistant, fritz: Mock) -> None: assert state assert state.attributes[ATTR_PRESET_MODE] == PRESET_ECO + # test boost preset + device.target_temperature = 127 # special temp from the api + next_update = dt_util.utcnow() + timedelta(seconds=200) + async_fire_time_changed(hass, next_update) + await hass.async_block_till_done(wait_background_tasks=True) + state = hass.states.get(ENTITY_ID) + + assert fritz().update_devices.call_count == 4 + assert state + assert state.attributes[ATTR_PRESET_MODE] == PRESET_BOOST + async def test_discover_new_device(hass: HomeAssistant, fritz: Mock) -> None: """Test adding new discovered devices during runtime.""" @@ -509,7 +545,11 @@ async def test_holidy_summer_mode( assert state.attributes[ATTR_STATE_SUMMER_MODE] is False assert state.attributes[ATTR_HVAC_MODES] == [HVACMode.HEAT, HVACMode.OFF] assert state.attributes[ATTR_PRESET_MODE] is None - assert state.attributes[ATTR_PRESET_MODES] == [PRESET_ECO, PRESET_COMFORT] + assert state.attributes[ATTR_PRESET_MODES] == [ + PRESET_ECO, + PRESET_COMFORT, + PRESET_BOOST, + ] # test holiday mode device.holiday_active = True @@ -596,4 +636,8 @@ async def test_holidy_summer_mode( assert state.attributes[ATTR_STATE_SUMMER_MODE] is False assert state.attributes[ATTR_HVAC_MODES] == [HVACMode.HEAT, HVACMode.OFF] assert state.attributes[ATTR_PRESET_MODE] is None - assert state.attributes[ATTR_PRESET_MODES] == [PRESET_ECO, PRESET_COMFORT] + assert state.attributes[ATTR_PRESET_MODES] == [ + PRESET_ECO, + PRESET_COMFORT, + PRESET_BOOST, + ] From 5e1bbd8bffa337d95eef245c319a712afe9eb784 Mon Sep 17 00:00:00 2001 From: Simone Chemelli Date: Sun, 30 Mar 2025 15:15:26 +0200 Subject: [PATCH 056/505] Add full test coverage for Comelit climate platform (#140460) * Add climate tests for Comelit * fix climate and humidifier * fix code and tests * fix humidifier * apply review comment * align post merge * add more tests * typo * apply review comment * ruff --- tests/components/comelit/const.py | 19 +- .../comelit/snapshots/test_climate.ambr | 71 +++++ .../comelit/snapshots/test_diagnostics.ambr | 37 +++ tests/components/comelit/test_climate.py | 282 ++++++++++++++++++ 4 files changed, 408 insertions(+), 1 deletion(-) create mode 100644 tests/components/comelit/snapshots/test_climate.ambr create mode 100644 tests/components/comelit/test_climate.py diff --git a/tests/components/comelit/const.py b/tests/components/comelit/const.py index d1bd4f95da3..d06e6cfd8cb 100644 --- a/tests/components/comelit/const.py +++ b/tests/components/comelit/const.py @@ -29,7 +29,24 @@ VEDO_PIN = 5678 FAKE_PIN = 0000 BRIDGE_DEVICE_QUERY = { - CLIMATE: {}, + CLIMATE: { + 0: ComelitSerialBridgeObject( + index=0, + name="Climate0", + status=0, + human_status="off", + type="climate", + val=[ + [221, 0, "U", "M", 50, 0, 0, "U"], + [650, 0, "O", "M", 500, 0, 0, "N"], + [0, 0], + ], + protected=0, + zone="Living room", + power=0.0, + power_unit=WATT, + ), + }, COVER: { 0: ComelitSerialBridgeObject( index=0, diff --git a/tests/components/comelit/snapshots/test_climate.ambr b/tests/components/comelit/snapshots/test_climate.ambr new file mode 100644 index 00000000000..e5201067ee1 --- /dev/null +++ b/tests/components/comelit/snapshots/test_climate.ambr @@ -0,0 +1,71 @@ +# serializer version: 1 +# name: test_all_entities[climate.climate0-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'hvac_modes': list([ + , + , + , + , + ]), + 'max_temp': 30, + 'min_temp': 5, + 'target_temp_step': 0.1, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'climate', + 'entity_category': None, + 'entity_id': 'climate.climate0', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': None, + 'platform': 'comelit', + 'previous_unique_id': None, + 'supported_features': , + 'translation_key': None, + 'unique_id': 'serial_bridge_config_entry_id-0', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[climate.climate0-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'current_temperature': 22.1, + 'friendly_name': 'Climate0', + 'hvac_action': , + 'hvac_modes': list([ + , + , + , + , + ]), + 'max_temp': 30, + 'min_temp': 5, + 'supported_features': , + 'target_temp_step': 0.1, + 'temperature': 5.0, + }), + 'context': , + 'entity_id': 'climate.climate0', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'heat', + }) +# --- diff --git a/tests/components/comelit/snapshots/test_diagnostics.ambr b/tests/components/comelit/snapshots/test_diagnostics.ambr index 3a6af9c3b73..51ea646df9f 100644 --- a/tests/components/comelit/snapshots/test_diagnostics.ambr +++ b/tests/components/comelit/snapshots/test_diagnostics.ambr @@ -5,6 +5,43 @@ 'devices': list([ dict({ 'clima': list([ + dict({ + '0': dict({ + 'human_status': 'off', + 'name': 'Climate0', + 'power': 0.0, + 'power_unit': 'W', + 'protected': 0, + 'status': 0, + 'val': list([ + list([ + 221, + 0, + 'U', + 'M', + 50, + 0, + 0, + 'U', + ]), + list([ + 650, + 0, + 'O', + 'M', + 500, + 0, + 0, + 'N', + ]), + list([ + 0, + 0, + ]), + ]), + 'zone': 'Living room', + }), + }), ]), }), dict({ diff --git a/tests/components/comelit/test_climate.py b/tests/components/comelit/test_climate.py new file mode 100644 index 00000000000..44478d154f4 --- /dev/null +++ b/tests/components/comelit/test_climate.py @@ -0,0 +1,282 @@ +"""Tests for Comelit SimpleHome climate platform.""" + +from typing import Any +from unittest.mock import AsyncMock, patch + +from aiocomelit.api import ComelitSerialBridgeObject +from aiocomelit.const import CLIMATE, WATT +from freezegun.api import FrozenDateTimeFactory +import pytest +from syrupy import SnapshotAssertion + +from homeassistant.components.climate import ( + ATTR_HVAC_MODE, + DOMAIN as CLIMATE_DOMAIN, + SERVICE_SET_HVAC_MODE, + SERVICE_SET_TEMPERATURE, + HVACMode, +) +from homeassistant.components.comelit.const import SCAN_INTERVAL +from homeassistant.const import ATTR_ENTITY_ID, ATTR_TEMPERATURE, Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from . import setup_integration + +from tests.common import MockConfigEntry, async_fire_time_changed, snapshot_platform + +ENTITY_ID = "climate.climate0" + + +async def test_all_entities( + hass: HomeAssistant, + snapshot: SnapshotAssertion, + mock_serial_bridge: AsyncMock, + mock_serial_bridge_config_entry: MockConfigEntry, + entity_registry: er.EntityRegistry, +) -> None: + """Test all entities.""" + with patch("homeassistant.components.comelit.BRIDGE_PLATFORMS", [Platform.CLIMATE]): + await setup_integration(hass, mock_serial_bridge_config_entry) + + await snapshot_platform( + hass, + entity_registry, + snapshot(), + mock_serial_bridge_config_entry.entry_id, + ) + + +@pytest.mark.parametrize( + ("val", "mode", "temp"), + [ + ( + [ + [100, 0, "U", "M", 210, 0, 0, "U"], + [650, 0, "O", "M", 500, 0, 0, "N"], + [0, 0], + ], + HVACMode.HEAT, + 21.0, + ), + ( + [ + [100, 1, "U", "A", 210, 1, 0, "O"], + [650, 0, "O", "M", 500, 0, 0, "N"], + [0, 0], + ], + HVACMode.HEAT, + 21.0, + ), + ( + [ + [100, 0, "O", "A", 210, 0, 0, "O"], + [650, 0, "O", "M", 500, 0, 0, "N"], + [0, 0], + ], + HVACMode.OFF, + 21.0, + ), + ], +) +async def test_climate_data_update( + hass: HomeAssistant, + freezer: FrozenDateTimeFactory, + mock_serial_bridge: AsyncMock, + mock_serial_bridge_config_entry: MockConfigEntry, + val: list[Any, Any], + mode: HVACMode, + temp: float, +) -> None: + """Test climate data update.""" + await setup_integration(hass, mock_serial_bridge_config_entry) + + assert (state := hass.states.get(ENTITY_ID)) + assert state.state == HVACMode.HEAT + assert state.attributes[ATTR_TEMPERATURE] == 5.0 + + mock_serial_bridge.get_all_devices.return_value[CLIMATE] = { + 0: ComelitSerialBridgeObject( + index=0, + name="Climate0", + status=0, + human_status="off", + type="climate", + val=val, + protected=0, + zone="Living room", + power=0.0, + power_unit=WATT, + ), + } + + freezer.tick(SCAN_INTERVAL) + async_fire_time_changed(hass) + await hass.async_block_till_done(wait_background_tasks=True) + + assert (state := hass.states.get(ENTITY_ID)) + assert state.state == mode + assert state.attributes[ATTR_TEMPERATURE] == temp + + +async def test_climate_data_update_bad_data( + hass: HomeAssistant, + freezer: FrozenDateTimeFactory, + mock_serial_bridge: AsyncMock, + mock_serial_bridge_config_entry: MockConfigEntry, +) -> None: + """Test climate data update.""" + await setup_integration(hass, mock_serial_bridge_config_entry) + + assert (state := hass.states.get(ENTITY_ID)) + assert state.state == HVACMode.HEAT + assert state.attributes[ATTR_TEMPERATURE] == 5.0 + + mock_serial_bridge.get_all_devices.return_value[CLIMATE] = { + 0: ComelitSerialBridgeObject( + index=0, + name="Climate0", + status=0, + human_status="off", + type="climate", + val="bad_data", + protected=0, + zone="Living room", + power=0.0, + power_unit=WATT, + ), + } + + freezer.tick(SCAN_INTERVAL) + async_fire_time_changed(hass) + await hass.async_block_till_done(wait_background_tasks=True) + + assert (state := hass.states.get(ENTITY_ID)) + assert state.state == HVACMode.HEAT + assert state.attributes[ATTR_TEMPERATURE] == 5.0 + + +async def test_climate_set_temperature( + hass: HomeAssistant, + mock_serial_bridge: AsyncMock, + mock_serial_bridge_config_entry: MockConfigEntry, +) -> None: + """Test climate set temperature service.""" + + await setup_integration(hass, mock_serial_bridge_config_entry) + + assert (state := hass.states.get(ENTITY_ID)) + assert state.state == HVACMode.HEAT + assert state.attributes[ATTR_TEMPERATURE] == 5.0 + + # Test set temperature + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_TEMPERATURE, + {ATTR_ENTITY_ID: ENTITY_ID, ATTR_TEMPERATURE: 23}, + blocking=True, + ) + mock_serial_bridge.set_clima_status.assert_called() + + assert (state := hass.states.get(ENTITY_ID)) + assert state.state == HVACMode.HEAT + assert state.attributes[ATTR_TEMPERATURE] == 23.0 + + +async def test_climate_set_temperature_when_off( + hass: HomeAssistant, + mock_serial_bridge: AsyncMock, + mock_serial_bridge_config_entry: MockConfigEntry, +) -> None: + """Test climate set temperature service when off.""" + + await setup_integration(hass, mock_serial_bridge_config_entry) + + assert (state := hass.states.get(ENTITY_ID)) + assert state.state == HVACMode.HEAT + assert state.attributes[ATTR_TEMPERATURE] == 5.0 + + # Switch climate off + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_HVAC_MODE, + {ATTR_ENTITY_ID: ENTITY_ID, ATTR_HVAC_MODE: HVACMode.OFF}, + blocking=True, + ) + mock_serial_bridge.set_clima_status.assert_called() + + assert (state := hass.states.get(ENTITY_ID)) + assert state.state == HVACMode.OFF + + # Test set temperature + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_TEMPERATURE, + {ATTR_ENTITY_ID: ENTITY_ID, ATTR_TEMPERATURE: 23}, + blocking=True, + ) + mock_serial_bridge.set_clima_status.assert_called() + + assert (state := hass.states.get(ENTITY_ID)) + assert state.state == HVACMode.OFF + + +async def test_climate_hvac_mode( + hass: HomeAssistant, + mock_serial_bridge: AsyncMock, + mock_serial_bridge_config_entry: MockConfigEntry, +) -> None: + """Test climate hvac mode service.""" + + await setup_integration(hass, mock_serial_bridge_config_entry) + + assert (state := hass.states.get(ENTITY_ID)) + assert state.state == HVACMode.HEAT + assert state.attributes[ATTR_TEMPERATURE] == 5.0 + + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_HVAC_MODE, + {ATTR_ENTITY_ID: ENTITY_ID, ATTR_HVAC_MODE: HVACMode.OFF}, + blocking=True, + ) + mock_serial_bridge.set_clima_status.assert_called() + + assert (state := hass.states.get(ENTITY_ID)) + assert state.state == HVACMode.OFF + + +async def test_climate_hvac_mode_when_off( + hass: HomeAssistant, + mock_serial_bridge: AsyncMock, + mock_serial_bridge_config_entry: MockConfigEntry, +) -> None: + """Test climate hvac mode service when off.""" + + await setup_integration(hass, mock_serial_bridge_config_entry) + + assert (state := hass.states.get(ENTITY_ID)) + assert state.state == HVACMode.HEAT + assert state.attributes[ATTR_TEMPERATURE] == 5.0 + + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_HVAC_MODE, + {ATTR_ENTITY_ID: ENTITY_ID, ATTR_HVAC_MODE: HVACMode.OFF}, + blocking=True, + ) + mock_serial_bridge.set_clima_status.assert_called() + + assert (state := hass.states.get(ENTITY_ID)) + assert state.state == HVACMode.OFF + + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_HVAC_MODE, + {ATTR_ENTITY_ID: ENTITY_ID, ATTR_HVAC_MODE: HVACMode.AUTO}, + blocking=True, + ) + mock_serial_bridge.set_clima_status.assert_called() + + assert (state := hass.states.get(ENTITY_ID)) + assert state.state == HVACMode.AUTO From c6c2309deef44e128c5709a8728ba5bbd3665f40 Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Sun, 30 Mar 2025 15:21:13 +0200 Subject: [PATCH 057/505] Replace "Idle" with common state in `zha` (#141825) --- homeassistant/components/zha/strings.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/zha/strings.json b/homeassistant/components/zha/strings.json index a35dd50df54..79cb05c3a0e 100644 --- a/homeassistant/components/zha/strings.json +++ b/homeassistant/components/zha/strings.json @@ -1487,7 +1487,7 @@ "adaptation_run_status": { "name": "Adaptation run status", "state": { - "nothing": "Idle", + "nothing": "[%key:common::state::idle%]", "something": "State" }, "state_attributes": { From 4734a82f99c13c1853f26a6d73496ee4d506c0e1 Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Sun, 30 Mar 2025 16:00:11 +0200 Subject: [PATCH 058/505] Replace "Off" with common state in `airgradient` (#141829) * Replace "Off" with common state in `airgradient` Also reference the name for CO2 from the `sensor` integration. * Replace indirect with direct references --- homeassistant/components/airgradient/strings.json | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/airgradient/strings.json b/homeassistant/components/airgradient/strings.json index 2d9b6be529d..cef4db57358 100644 --- a/homeassistant/components/airgradient/strings.json +++ b/homeassistant/components/airgradient/strings.json @@ -68,8 +68,8 @@ "led_bar_mode": { "name": "LED bar mode", "state": { - "off": "Off", - "co2": "Carbon dioxide", + "off": "[%key:common::state::off%]", + "co2": "[%key:component::sensor::entity_component::carbon_dioxide::name%]", "pm": "Particulate matter" } }, @@ -143,8 +143,8 @@ "led_bar_mode": { "name": "[%key:component::airgradient::entity::select::led_bar_mode::name%]", "state": { - "off": "[%key:component::airgradient::entity::select::led_bar_mode::state::off%]", - "co2": "[%key:component::airgradient::entity::select::led_bar_mode::state::co2%]", + "off": "[%key:common::state::off%]", + "co2": "[%key:component::sensor::entity_component::carbon_dioxide::name%]", "pm": "[%key:component::airgradient::entity::select::led_bar_mode::state::pm%]" } }, From 5ac6096e08ba147c9a2230ccaa5cc890434e3d03 Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Sun, 30 Mar 2025 16:00:50 +0200 Subject: [PATCH 059/505] Replace "Off" with common state in `osoenergy` (#141830) --- homeassistant/components/osoenergy/strings.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/osoenergy/strings.json b/homeassistant/components/osoenergy/strings.json index 7e10168d941..ef7e2abb89b 100644 --- a/homeassistant/components/osoenergy/strings.json +++ b/homeassistant/components/osoenergy/strings.json @@ -60,7 +60,7 @@ "ffr": "Fast frequency reserve", "legionella": "Legionella", "manual": "Manual", - "off": "Off", + "off": "[%key:common::state::off%]", "powersave": "Power save", "voltage": "Voltage" } @@ -70,7 +70,7 @@ "state": { "advanced": "Advanced", "gridcompany": "Grid company", - "off": "Off", + "off": "[%key:common::state::off%]", "oso": "OSO", "smartcompany": "Smart company" } From dccaa2dd2da1938d08d71d73eb0ec0c1d4ee1c13 Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Sun, 30 Mar 2025 16:01:15 +0200 Subject: [PATCH 060/505] Replace "Off" with common state in `sleepiq` (#141831) --- homeassistant/components/sleepiq/strings.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/sleepiq/strings.json b/homeassistant/components/sleepiq/strings.json index bdafbfb6c77..60f6026304b 100644 --- a/homeassistant/components/sleepiq/strings.json +++ b/homeassistant/components/sleepiq/strings.json @@ -28,7 +28,7 @@ "select": { "foot_warmer_temp": { "state": { - "off": "Off", + "off": "[%key:common::state::off%]", "low": "Low", "medium": "Medium", "high": "High" From 4103ef71c91010d49917733752bc4596e81c1f7a Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Sun, 30 Mar 2025 16:02:09 +0200 Subject: [PATCH 061/505] Replace "Off" with common state in `wyoming` (#141832) --- homeassistant/components/wyoming/strings.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/wyoming/strings.json b/homeassistant/components/wyoming/strings.json index 4a1a4c3a246..4480b00d867 100644 --- a/homeassistant/components/wyoming/strings.json +++ b/homeassistant/components/wyoming/strings.json @@ -40,7 +40,7 @@ "noise_suppression_level": { "name": "Noise suppression level", "state": { - "off": "Off", + "off": "[%key:common::state::off%]", "low": "Low", "medium": "Medium", "high": "High", From 4463e4c42b1b530ab74de374ed6329f693b7ecde Mon Sep 17 00:00:00 2001 From: Allen Porter Date: Sun, 30 Mar 2025 07:04:28 -0700 Subject: [PATCH 062/505] Move roborock unique id to be based on roborock userid instead of email (#141337) * Move roborock unique id to be based on roborock userid instead of email * Remove unnecessary data update * Update tests * Add tests coverage for removal of config entry * Use config entry migration * Remove unused fixtues * Remove unnecessary logging --- homeassistant/components/roborock/__init__.py | 25 ++++++++ .../components/roborock/config_flow.py | 12 ++-- .../components/roborock/strings.json | 3 +- tests/components/roborock/conftest.py | 43 +++++++++---- tests/components/roborock/mock_data.py | 3 +- tests/components/roborock/test_config_flow.py | 63 ++++++++++++++++++- tests/components/roborock/test_coordinator.py | 7 +++ tests/components/roborock/test_init.py | 33 +++++++++- 8 files changed, 166 insertions(+), 23 deletions(-) diff --git a/homeassistant/components/roborock/__init__.py b/homeassistant/components/roborock/__init__.py index 8140b58b86c..81b412c6770 100644 --- a/homeassistant/components/roborock/__init__.py +++ b/homeassistant/components/roborock/__init__.py @@ -164,6 +164,31 @@ async def async_setup_entry(hass: HomeAssistant, entry: RoborockConfigEntry) -> return True +async def async_migrate_entry(hass: HomeAssistant, entry: RoborockConfigEntry) -> bool: + """Migrate old configuration entries to the new format.""" + _LOGGER.debug( + "Migrating configuration from version %s.%s", + entry.version, + entry.minor_version, + ) + if entry.version > 1: + # Downgrade from future version + return False + + # 1->2: Migrate from unique id as email address to unique id as rruid + if entry.minor_version == 1: + user_data = UserData.from_dict(entry.data[CONF_USER_DATA]) + _LOGGER.debug("Updating unique id to %s", user_data.rruid) + hass.config_entries.async_update_entry( + entry, + unique_id=user_data.rruid, + version=1, + minor_version=2, + ) + + return True + + def build_setup_functions( hass: HomeAssistant, entry: RoborockConfigEntry, diff --git a/homeassistant/components/roborock/config_flow.py b/homeassistant/components/roborock/config_flow.py index 886bebea9b6..62943e0dcc9 100644 --- a/homeassistant/components/roborock/config_flow.py +++ b/homeassistant/components/roborock/config_flow.py @@ -48,6 +48,7 @@ class RoborockFlowHandler(ConfigFlow, domain=DOMAIN): """Handle a config flow for Roborock.""" VERSION = 1 + MINOR_VERSION = 2 def __init__(self) -> None: """Initialize the config flow.""" @@ -62,8 +63,6 @@ class RoborockFlowHandler(ConfigFlow, domain=DOMAIN): if user_input is not None: username = user_input[CONF_USERNAME] - await self.async_set_unique_id(username.lower()) - self._abort_if_unique_id_configured(error="already_configured_account") self._username = username _LOGGER.debug("Requesting code for Roborock account") self._client = RoborockApiClient( @@ -111,7 +110,7 @@ class RoborockFlowHandler(ConfigFlow, domain=DOMAIN): code = user_input[CONF_ENTRY_CODE] _LOGGER.debug("Logging into Roborock account using email provided code") try: - login_data = await self._client.code_login(code) + user_data = await self._client.code_login(code) except RoborockInvalidCode: errors["base"] = "invalid_code" except RoborockException: @@ -121,17 +120,20 @@ class RoborockFlowHandler(ConfigFlow, domain=DOMAIN): _LOGGER.exception("Unexpected exception") errors["base"] = "unknown" else: + await self.async_set_unique_id(user_data.rruid) if self.source == SOURCE_REAUTH: + self._abort_if_unique_id_mismatch(reason="wrong_account") reauth_entry = self._get_reauth_entry() self.hass.config_entries.async_update_entry( reauth_entry, data={ **reauth_entry.data, - CONF_USER_DATA: login_data.as_dict(), + CONF_USER_DATA: user_data.as_dict(), }, ) return self.async_abort(reason="reauth_successful") - return self._create_entry(self._client, self._username, login_data) + self._abort_if_unique_id_configured(error="already_configured_account") + return self._create_entry(self._client, self._username, user_data) return self.async_show_form( step_id="code", diff --git a/homeassistant/components/roborock/strings.json b/homeassistant/components/roborock/strings.json index 78d4fa80590..4546856ec8b 100644 --- a/homeassistant/components/roborock/strings.json +++ b/homeassistant/components/roborock/strings.json @@ -35,7 +35,8 @@ }, "abort": { "already_configured_account": "[%key:common::config_flow::abort::already_configured_account%]", - "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]" + "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]", + "wrong_account": "Wrong account: Please authenticate with the right account." } }, "options": { diff --git a/tests/components/roborock/conftest.py b/tests/components/roborock/conftest.py index 1ec2b00263f..d807e35710b 100644 --- a/tests/components/roborock/conftest.py +++ b/tests/components/roborock/conftest.py @@ -28,6 +28,7 @@ from .mock_data import ( MULTI_MAP_LIST, NETWORK_INFO, PROP, + ROBOROCK_RRUID, SCENES, USER_DATA, USER_EMAIL, @@ -188,18 +189,28 @@ def bypass_api_fixture_v1_only(bypass_api_fixture) -> None: yield +@pytest.fixture(name="config_entry_data") +def config_entry_data_fixture() -> dict[str, Any]: + """Fixture that returns the unique id for the config entry.""" + return { + CONF_USERNAME: USER_EMAIL, + CONF_USER_DATA: USER_DATA.as_dict(), + CONF_BASE_URL: BASE_URL, + } + + @pytest.fixture -def mock_roborock_entry(hass: HomeAssistant) -> MockConfigEntry: +def mock_roborock_entry( + hass: HomeAssistant, config_entry_data: dict[str, Any] +) -> MockConfigEntry: """Create a Roborock Entry that has not been setup.""" mock_entry = MockConfigEntry( domain=DOMAIN, title=USER_EMAIL, - data={ - CONF_USERNAME: USER_EMAIL, - CONF_USER_DATA: USER_DATA.as_dict(), - CONF_BASE_URL: BASE_URL, - }, - unique_id=USER_EMAIL, + data=config_entry_data, + unique_id=ROBOROCK_RRUID, + version=1, + minor_version=2, ) mock_entry.add_to_hass(hass) return mock_entry @@ -211,18 +222,26 @@ def mock_platforms() -> list[Platform]: return [] +@pytest.fixture(autouse=True) +async def mock_patforms_fixture( + hass: HomeAssistant, + platforms: list[Platform], +) -> Generator[None]: + """Set up the Roborock platform.""" + with patch("homeassistant.components.roborock.PLATFORMS", platforms): + yield + + @pytest.fixture async def setup_entry( hass: HomeAssistant, bypass_api_fixture, mock_roborock_entry: MockConfigEntry, - platforms: list[Platform], ) -> Generator[MockConfigEntry]: """Set up the Roborock platform.""" - with patch("homeassistant.components.roborock.PLATFORMS", platforms): - await hass.config_entries.async_setup(mock_roborock_entry.entry_id) - await hass.async_block_till_done() - yield mock_roborock_entry + await hass.config_entries.async_setup(mock_roborock_entry.entry_id) + await hass.async_block_till_done() + return mock_roborock_entry @pytest.fixture(autouse=True, name="storage_path") diff --git a/tests/components/roborock/mock_data.py b/tests/components/roborock/mock_data.py index 82b51e67f8d..cf4f167ef7f 100644 --- a/tests/components/roborock/mock_data.py +++ b/tests/components/roborock/mock_data.py @@ -28,6 +28,7 @@ USER_EMAIL = "user@domain.com" BASE_URL = "https://usiot.roborock.com" +ROBOROCK_RRUID = "roboborock-userid-abc-123" USER_DATA = UserData.from_dict( { "tuyaname": "abc123", @@ -35,7 +36,7 @@ USER_DATA = UserData.from_dict( "uid": 123456, "tokentype": "", "token": "abc123", - "rruid": "abc123", + "rruid": ROBOROCK_RRUID, "region": "us", "countrycode": "1", "country": "US", diff --git a/tests/components/roborock/test_config_flow.py b/tests/components/roborock/test_config_flow.py index 441974dc15d..7958f17a696 100644 --- a/tests/components/roborock/test_config_flow.py +++ b/tests/components/roborock/test_config_flow.py @@ -16,12 +16,12 @@ from vacuum_map_parser_base.config.drawable import Drawable from homeassistant import config_entries from homeassistant.components.roborock.const import CONF_ENTRY_CODE, DOMAIN, DRAWABLES -from homeassistant.const import CONF_USERNAME +from homeassistant.const import CONF_USERNAME, Platform from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo -from .mock_data import MOCK_CONFIG, NETWORK_INFO, USER_DATA, USER_EMAIL +from .mock_data import MOCK_CONFIG, NETWORK_INFO, ROBOROCK_RRUID, USER_DATA, USER_EMAIL from tests.common import MockConfigEntry @@ -64,6 +64,7 @@ async def test_config_flow_success( ) assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["context"]["unique_id"] == ROBOROCK_RRUID assert result["title"] == USER_EMAIL assert result["data"] == MOCK_CONFIG assert result["result"] @@ -128,6 +129,7 @@ async def test_config_flow_failures_request_code( ) assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["context"]["unique_id"] == ROBOROCK_RRUID assert result["title"] == USER_EMAIL assert result["data"] == MOCK_CONFIG assert result["result"] @@ -189,6 +191,7 @@ async def test_config_flow_failures_code_login( ) assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["context"]["unique_id"] == ROBOROCK_RRUID assert result["title"] == USER_EMAIL assert result["data"] == MOCK_CONFIG assert result["result"] @@ -256,6 +259,7 @@ async def test_reauth_flow( ) assert result["type"] is FlowResultType.ABORT assert result["reason"] == "reauth_successful" + assert mock_roborock_entry.unique_id == ROBOROCK_RRUID assert mock_roborock_entry.data["user_data"]["rriot"]["s"] == "new_password_hash" @@ -264,7 +268,8 @@ async def test_account_already_configured( bypass_api_fixture, mock_roborock_entry: MockConfigEntry, ) -> None: - """Handle the config flow and make sure it succeeds.""" + """Ensure the same account cannot be setup twice.""" + assert mock_roborock_entry.unique_id == ROBOROCK_RRUID with patch( "homeassistant.components.roborock.async_setup_entry", return_value=True ): @@ -280,10 +285,59 @@ async def test_account_already_configured( result["flow_id"], {CONF_USERNAME: USER_EMAIL} ) + assert result["step_id"] == "code" + assert result["type"] is FlowResultType.FORM + with patch( + "homeassistant.components.roborock.config_flow.RoborockApiClient.code_login", + return_value=USER_DATA, + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={CONF_ENTRY_CODE: "123456"} + ) assert result["type"] is FlowResultType.ABORT assert result["reason"] == "already_configured_account" +async def test_reauth_wrong_account( + hass: HomeAssistant, + bypass_api_fixture, + mock_roborock_entry: MockConfigEntry, +) -> None: + """Ensure that reauthentication must use the same account.""" + + # Start reauth + result = mock_roborock_entry.async_start_reauth(hass) + await hass.async_block_till_done() + flows = hass.config_entries.flow.async_progress() + assert len(flows) == 1 + [result] = flows + assert result["step_id"] == "reauth_confirm" + + with patch( + "homeassistant.components.roborock.async_setup_entry", return_value=True + ): + with patch( + "homeassistant.components.roborock.config_flow.RoborockApiClient.request_code" + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {CONF_USERNAME: USER_EMAIL} + ) + + assert result["step_id"] == "code" + assert result["type"] is FlowResultType.FORM + new_user_data = deepcopy(USER_DATA) + new_user_data.rruid = "new_rruid" + with patch( + "homeassistant.components.roborock.config_flow.RoborockApiClient.code_login", + return_value=new_user_data, + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={CONF_ENTRY_CODE: "123456"} + ) + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "wrong_account" + + async def test_discovery_not_setup( hass: HomeAssistant, bypass_api_fixture, @@ -322,11 +376,13 @@ async def test_discovery_not_setup( ) assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["context"]["unique_id"] == ROBOROCK_RRUID assert result["title"] == USER_EMAIL assert result["data"] == MOCK_CONFIG assert result["result"] +@pytest.mark.parametrize("platforms", [[Platform.SENSOR]]) async def test_discovery_already_setup( hass: HomeAssistant, bypass_api_fixture, @@ -346,3 +402,4 @@ async def test_discovery_already_setup( ) assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" diff --git a/tests/components/roborock/test_coordinator.py b/tests/components/roborock/test_coordinator.py index 94976ba92f5..dec4e0a62d4 100644 --- a/tests/components/roborock/test_coordinator.py +++ b/tests/components/roborock/test_coordinator.py @@ -13,6 +13,7 @@ from homeassistant.components.roborock.const import ( V1_LOCAL_IN_CLEANING_INTERVAL, V1_LOCAL_NOT_CLEANING_INTERVAL, ) +from homeassistant.const import Platform from homeassistant.core import HomeAssistant from homeassistant.util import dt as dt_util @@ -21,6 +22,12 @@ from .mock_data import PROP from tests.common import MockConfigEntry, async_fire_time_changed +@pytest.fixture +def platforms() -> list[Platform]: + """Fixture to set platforms used in the test.""" + return [Platform.SENSOR] + + @pytest.mark.parametrize( ("interval", "in_cleaning"), [ diff --git a/tests/components/roborock/test_init.py b/tests/components/roborock/test_init.py index 983e3d083f4..a1bcfc462e4 100644 --- a/tests/components/roborock/test_init.py +++ b/tests/components/roborock/test_init.py @@ -3,6 +3,7 @@ from copy import deepcopy from http import HTTPStatus import pathlib +from typing import Any from unittest.mock import patch import pytest @@ -20,7 +21,13 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import DeviceRegistry from homeassistant.setup import async_setup_component -from .mock_data import HOME_DATA, NETWORK_INFO, NETWORK_INFO_2 +from .mock_data import ( + HOME_DATA, + NETWORK_INFO, + NETWORK_INFO_2, + ROBOROCK_RRUID, + USER_EMAIL, +) from tests.common import MockConfigEntry from tests.typing import ClientSessionGenerator @@ -300,6 +307,7 @@ async def test_no_user_agreement( assert mock_roborock_entry.error_reason_translation_key == "no_user_agreement" +@pytest.mark.parametrize("platforms", [[Platform.SENSOR]]) async def test_stale_device( hass: HomeAssistant, bypass_api_fixture, @@ -341,6 +349,7 @@ async def test_stale_device( # therefore not deleted. +@pytest.mark.parametrize("platforms", [[Platform.SENSOR]]) async def test_no_stale_device( hass: HomeAssistant, bypass_api_fixture, @@ -369,3 +378,25 @@ async def test_no_stale_device( mock_roborock_entry.entry_id ) assert len(new_devices) == 6 # 2 for each robot, 1 for A01, 1 for Zeo + + +async def test_migrate_config_entry_unique_id( + hass: HomeAssistant, + bypass_api_fixture, + config_entry_data: dict[str, Any], +) -> None: + """Test migrating the config entry unique id.""" + config_entry = MockConfigEntry( + domain=DOMAIN, + unique_id=USER_EMAIL, + data=config_entry_data, + version=1, + minor_version=1, + ) + config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + assert len(hass.config_entries.async_entries(DOMAIN)) == 1 + assert config_entry.state is ConfigEntryState.LOADED + assert config_entry.unique_id == ROBOROCK_RRUID From 5a1aeff85c33ace4b40c8a6310ec04264fd24a92 Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Sun, 30 Mar 2025 16:10:17 +0200 Subject: [PATCH 063/505] Replace "On" and "Off" with common states in `rfxtrx` (#141835) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Also fix sentence-casing on other "… on" and "… off" states. --- homeassistant/components/rfxtrx/strings.json | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/rfxtrx/strings.json b/homeassistant/components/rfxtrx/strings.json index db4efad5bb4..d0a61540a53 100644 --- a/homeassistant/components/rfxtrx/strings.json +++ b/homeassistant/components/rfxtrx/strings.json @@ -105,15 +105,15 @@ "sound_15": "Sound 15", "down": "Down", "up": "Up", - "all_off": "All Off", - "all_on": "All On", + "all_off": "All off", + "all_on": "All on", "scene": "Scene", - "off": "Off", - "on": "On", + "off": "[%key:common::state::off%]", + "on": "[%key:common::state::on%]", "dim": "Dim", "bright": "Bright", - "all_group_off": "All/group Off", - "all_group_on": "All/group On", + "all_group_off": "All/group off", + "all_group_on": "All/group on", "chime": "Chime", "illegal_command": "Illegal command", "set_level": "Set level", From acbee815bef769476ee686c85ec8c06f9a869a8c Mon Sep 17 00:00:00 2001 From: Marlon Date: Sun, 30 Mar 2025 16:11:22 +0200 Subject: [PATCH 064/505] Update apsystems library to support battery inverter (#140086) Co-authored-by: Franck Nijhof --- homeassistant/components/apsystems/coordinator.py | 2 ++ homeassistant/components/apsystems/manifest.json | 2 +- homeassistant/components/apsystems/switch.py | 2 ++ requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/apsystems/conftest.py | 1 + 6 files changed, 8 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/apsystems/coordinator.py b/homeassistant/components/apsystems/coordinator.py index ca423055176..f7f1039b8a4 100644 --- a/homeassistant/components/apsystems/coordinator.py +++ b/homeassistant/components/apsystems/coordinator.py @@ -43,6 +43,7 @@ class ApSystemsDataCoordinator(DataUpdateCoordinator[ApSystemsSensorData]): config_entry: ApSystemsConfigEntry device_version: str + battery_system: bool def __init__( self, @@ -68,6 +69,7 @@ class ApSystemsDataCoordinator(DataUpdateCoordinator[ApSystemsSensorData]): self.api.max_power = device_info.maxPower self.api.min_power = device_info.minPower self.device_version = device_info.devVer + self.battery_system = device_info.isBatterySystem async def _async_update_data(self) -> ApSystemsSensorData: try: diff --git a/homeassistant/components/apsystems/manifest.json b/homeassistant/components/apsystems/manifest.json index a58530b05e2..934a155c500 100644 --- a/homeassistant/components/apsystems/manifest.json +++ b/homeassistant/components/apsystems/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/apsystems", "integration_type": "device", "iot_class": "local_polling", - "requirements": ["apsystems-ez1==2.4.0"] + "requirements": ["apsystems-ez1==2.5.0"] } diff --git a/homeassistant/components/apsystems/switch.py b/homeassistant/components/apsystems/switch.py index e1017f95448..5451f2885fe 100644 --- a/homeassistant/components/apsystems/switch.py +++ b/homeassistant/components/apsystems/switch.py @@ -36,6 +36,8 @@ class ApSystemsInverterSwitch(ApSystemsEntity, SwitchEntity): super().__init__(data) self._api = data.coordinator.api self._attr_unique_id = f"{data.device_id}_inverter_status" + if data.coordinator.battery_system: + self._attr_available = False async def async_update(self) -> None: """Update switch status and availability.""" diff --git a/requirements_all.txt b/requirements_all.txt index 2e3ef15d2b2..b6d8d3c3e63 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -491,7 +491,7 @@ apprise==1.9.1 aprslib==0.7.2 # homeassistant.components.apsystems -apsystems-ez1==2.4.0 +apsystems-ez1==2.5.0 # homeassistant.components.aqualogic aqualogic==2.6 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index e4b0d5dde2c..c4813981784 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -464,7 +464,7 @@ apprise==1.9.1 aprslib==0.7.2 # homeassistant.components.apsystems -apsystems-ez1==2.4.0 +apsystems-ez1==2.5.0 # homeassistant.components.aranet aranet4==2.5.1 diff --git a/tests/components/apsystems/conftest.py b/tests/components/apsystems/conftest.py index 92af6885c0b..d1c97e991a8 100644 --- a/tests/components/apsystems/conftest.py +++ b/tests/components/apsystems/conftest.py @@ -43,6 +43,7 @@ def mock_apsystems() -> Generator[MagicMock]: ipAddr="127.0.01", minPower=0, maxPower=1000, + isBatterySystem=False, ) mock_api.get_output_data.return_value = ReturnOutputData( p1=2.0, From b3564b6cff8bb9775a9cdf0f9457f2161bb796da Mon Sep 17 00:00:00 2001 From: Michal Schwarz Date: Sun, 30 Mar 2025 16:14:56 +0200 Subject: [PATCH 065/505] Fix order of palettes, presets and playlists in WLED integration (#132207) * Fix order of palettes, presets and playlists in WLED integration * fix tests: update palette items order --------- Co-authored-by: Franck Nijhof --- homeassistant/components/wled/select.py | 21 +- .../wled/snapshots/test_select.ambr | 228 +++++++++--------- 2 files changed, 126 insertions(+), 123 deletions(-) diff --git a/homeassistant/components/wled/select.py b/homeassistant/components/wled/select.py index e340c323151..76837652ae5 100644 --- a/homeassistant/components/wled/select.py +++ b/homeassistant/components/wled/select.py @@ -79,9 +79,10 @@ class WLEDPresetSelect(WLEDEntity, SelectEntity): super().__init__(coordinator=coordinator) self._attr_unique_id = f"{coordinator.data.info.mac_address}_preset" - self._attr_options = [ - preset.name for preset in self.coordinator.data.presets.values() - ] + sorted_values = sorted( + coordinator.data.presets.values(), key=lambda preset: preset.name + ) + self._attr_options = [preset.name for preset in sorted_values] @property def available(self) -> bool: @@ -115,9 +116,10 @@ class WLEDPlaylistSelect(WLEDEntity, SelectEntity): super().__init__(coordinator=coordinator) self._attr_unique_id = f"{coordinator.data.info.mac_address}_playlist" - self._attr_options = [ - playlist.name for playlist in self.coordinator.data.playlists.values() - ] + sorted_values = sorted( + coordinator.data.playlists.values(), key=lambda playlist: playlist.name + ) + self._attr_options = [playlist.name for playlist in sorted_values] @property def available(self) -> bool: @@ -159,9 +161,10 @@ class WLEDPaletteSelect(WLEDEntity, SelectEntity): self._attr_translation_placeholders = {"segment": str(segment)} self._attr_unique_id = f"{coordinator.data.info.mac_address}_palette_{segment}" - self._attr_options = [ - palette.name for palette in self.coordinator.data.palettes.values() - ] + sorted_values = sorted( + coordinator.data.palettes.values(), key=lambda palette: palette.name + ) + self._attr_options = [palette.name for palette in sorted_values] self._segment = segment @property diff --git a/tests/components/wled/snapshots/test_select.ambr b/tests/components/wled/snapshots/test_select.ambr index ca3b0a5dc6e..d3f8fbcc21d 100644 --- a/tests/components/wled/snapshots/test_select.ambr +++ b/tests/components/wled/snapshots/test_select.ambr @@ -99,77 +99,77 @@ 'attributes': ReadOnlyDict({ 'friendly_name': 'WLED RGB Light Segment 1 color palette', 'options': list([ - 'Default', - '* Random Cycle', '* Color 1', - '* Colors 1&2', '* Color Gradient', + '* Colors 1&2', '* Colors Only', - 'Party', - 'Cloud', - 'Lava', - 'Ocean', - 'Forest', - 'Rainbow', - 'Rainbow Bands', - 'Sunset', - 'Rivendell', - 'Breeze', - 'Red & Blue', - 'Yellowout', + '* Random Cycle', 'Analogous', - 'Splash', - 'Pastel', - 'Sunset 2', - 'Beach', - 'Vintage', - 'Departure', - 'Landscape', - 'Beech', - 'Sherbet', - 'Hult', - 'Hult 64', - 'Drywet', - 'Jul', - 'Grintage', - 'Rewhi', - 'Tertiary', - 'Fire', - 'Icefire', - 'Cyane', - 'Light Pink', - 'Autumn', - 'Magenta', - 'Magred', - 'Yelmag', - 'Yelblu', - 'Orange & Teal', - 'Tiamat', 'April Night', - 'Orangery', - 'C9', - 'Sakura', - 'Aurora', + 'Aqua Flash', 'Atlantica', + 'Aurora', + 'Aurora 2', + 'Autumn', + 'Beach', + 'Beech', + 'Blink Red', + 'Breeze', + 'C9', 'C9 2', 'C9 New', - 'Temperature', - 'Aurora 2', - 'Retro Clown', 'Candy', - 'Toxy Reaf', + 'Candy2', + 'Cloud', + 'Cyane', + 'Default', + 'Departure', + 'Drywet', 'Fairy Reaf', - 'Semi Blue', - 'Pink Candy', - 'Red Reaf', - 'Aqua Flash', - 'Yelblu Hot', + 'Fire', + 'Forest', + 'Grintage', + 'Hult', + 'Hult 64', + 'Icefire', + 'Jul', + 'Landscape', + 'Lava', + 'Light Pink', 'Lite Light', + 'Magenta', + 'Magred', + 'Ocean', + 'Orange & Teal', + 'Orangery', + 'Party', + 'Pastel', + 'Pink Candy', + 'Rainbow', + 'Rainbow Bands', + 'Red & Blue', 'Red Flash', - 'Blink Red', + 'Red Reaf', 'Red Shift', 'Red Tide', - 'Candy2', + 'Retro Clown', + 'Rewhi', + 'Rivendell', + 'Sakura', + 'Semi Blue', + 'Sherbet', + 'Splash', + 'Sunset', + 'Sunset 2', + 'Temperature', + 'Tertiary', + 'Tiamat', + 'Toxy Reaf', + 'Vintage', + 'Yelblu', + 'Yelblu Hot', + 'Yellowout', + 'Yelmag', ]), }), 'context': , @@ -187,77 +187,77 @@ 'area_id': None, 'capabilities': dict({ 'options': list([ - 'Default', - '* Random Cycle', '* Color 1', - '* Colors 1&2', '* Color Gradient', + '* Colors 1&2', '* Colors Only', - 'Party', - 'Cloud', - 'Lava', - 'Ocean', - 'Forest', - 'Rainbow', - 'Rainbow Bands', - 'Sunset', - 'Rivendell', - 'Breeze', - 'Red & Blue', - 'Yellowout', + '* Random Cycle', 'Analogous', - 'Splash', - 'Pastel', - 'Sunset 2', - 'Beach', - 'Vintage', - 'Departure', - 'Landscape', - 'Beech', - 'Sherbet', - 'Hult', - 'Hult 64', - 'Drywet', - 'Jul', - 'Grintage', - 'Rewhi', - 'Tertiary', - 'Fire', - 'Icefire', - 'Cyane', - 'Light Pink', - 'Autumn', - 'Magenta', - 'Magred', - 'Yelmag', - 'Yelblu', - 'Orange & Teal', - 'Tiamat', 'April Night', - 'Orangery', - 'C9', - 'Sakura', - 'Aurora', + 'Aqua Flash', 'Atlantica', + 'Aurora', + 'Aurora 2', + 'Autumn', + 'Beach', + 'Beech', + 'Blink Red', + 'Breeze', + 'C9', 'C9 2', 'C9 New', - 'Temperature', - 'Aurora 2', - 'Retro Clown', 'Candy', - 'Toxy Reaf', + 'Candy2', + 'Cloud', + 'Cyane', + 'Default', + 'Departure', + 'Drywet', 'Fairy Reaf', - 'Semi Blue', - 'Pink Candy', - 'Red Reaf', - 'Aqua Flash', - 'Yelblu Hot', + 'Fire', + 'Forest', + 'Grintage', + 'Hult', + 'Hult 64', + 'Icefire', + 'Jul', + 'Landscape', + 'Lava', + 'Light Pink', 'Lite Light', + 'Magenta', + 'Magred', + 'Ocean', + 'Orange & Teal', + 'Orangery', + 'Party', + 'Pastel', + 'Pink Candy', + 'Rainbow', + 'Rainbow Bands', + 'Red & Blue', 'Red Flash', - 'Blink Red', + 'Red Reaf', 'Red Shift', 'Red Tide', - 'Candy2', + 'Retro Clown', + 'Rewhi', + 'Rivendell', + 'Sakura', + 'Semi Blue', + 'Sherbet', + 'Splash', + 'Sunset', + 'Sunset 2', + 'Temperature', + 'Tertiary', + 'Tiamat', + 'Toxy Reaf', + 'Vintage', + 'Yelblu', + 'Yelblu Hot', + 'Yellowout', + 'Yelmag', ]), }), 'config_entry_id': , From ec20e41836bfc68ffc993373d9726ef95d37aefc Mon Sep 17 00:00:00 2001 From: Mauricio Bonani Date: Sun, 30 Mar 2025 10:26:44 -0400 Subject: [PATCH 066/505] Improve the readability of status messages in NUT (#141335) Improve the readability of status messages --- homeassistant/components/nut/sensor.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/nut/sensor.py b/homeassistant/components/nut/sensor.py index 5bf7958e39e..1781615b0f9 100644 --- a/homeassistant/components/nut/sensor.py +++ b/homeassistant/components/nut/sensor.py @@ -1097,6 +1097,6 @@ class NUTSensor(NUTBaseEntity, SensorEntity): def _format_display_state(status: dict[str, str]) -> str: """Return UPS display state.""" try: - return " ".join(STATE_TYPES[state] for state in status[KEY_STATUS].split()) + return ", ".join(STATE_TYPES[state] for state in status[KEY_STATUS].split()) except KeyError: return STATE_UNKNOWN From 86be626c691eb58a521687f4a0d194d4b10b3af6 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Sun, 30 Mar 2025 10:53:49 -0400 Subject: [PATCH 067/505] Migrate ESPHome to use token instead of media source ID for legacy Assist Pipelines (#139665) Migrate legacy ESPHome devices to use TTS token Co-authored-by: Franck Nijhof --- .../components/esphome/assist_satellite.py | 22 +-- .../esphome/test_assist_satellite.py | 159 +++++++----------- 2 files changed, 75 insertions(+), 106 deletions(-) diff --git a/homeassistant/components/esphome/assist_satellite.py b/homeassistant/components/esphome/assist_satellite.py index a129a7723dd..9d92b5fcb92 100644 --- a/homeassistant/components/esphome/assist_satellite.py +++ b/homeassistant/components/esphome/assist_satellite.py @@ -310,12 +310,13 @@ class EsphomeAssistSatellite( self.entry_data.api_version ) ) - if feature_flags & VoiceAssistantFeature.SPEAKER: - media_id = tts_output["media_id"] + if feature_flags & VoiceAssistantFeature.SPEAKER and ( + stream := tts.async_get_stream(self.hass, tts_output["token"]) + ): self._tts_streaming_task = ( self.config_entry.async_create_background_task( self.hass, - self._stream_tts_audio(media_id), + self._stream_tts_audio(stream), "esphome_voice_assistant_tts", ) ) @@ -564,7 +565,7 @@ class EsphomeAssistSatellite( async def _stream_tts_audio( self, - media_id: str, + tts_result: tts.ResultStream, sample_rate: int = 16000, sample_width: int = 2, sample_channels: int = 1, @@ -579,15 +580,14 @@ class EsphomeAssistSatellite( if not self._is_running: return - extension, data = await tts.async_get_media_source_audio( - self.hass, - media_id, - ) - - if extension != "wav": - _LOGGER.error("Only WAV audio can be streamed, got %s", extension) + if tts_result.extension != "wav": + _LOGGER.error( + "Only WAV audio can be streamed, got %s", tts_result.extension + ) return + data = b"".join([chunk async for chunk in tts_result.async_stream_result()]) + with io.BytesIO(data) as wav_io, wave.open(wav_io, "rb") as wav_file: if ( (wav_file.getframerate() != sample_rate) diff --git a/tests/components/esphome/test_assist_satellite.py b/tests/components/esphome/test_assist_satellite.py index 5f433a6c0ed..2254d24c9ac 100644 --- a/tests/components/esphome/test_assist_satellite.py +++ b/tests/components/esphome/test_assist_satellite.py @@ -58,6 +58,7 @@ from homeassistant.helpers import ( intent as intent_helper, ) from homeassistant.helpers.entity_component import EntityComponent +from homeassistant.helpers.network import get_url from .conftest import MockESPHomeDevice @@ -133,8 +134,6 @@ async def test_pipeline_api_audio( ) -> None: """Test a complete pipeline run with API audio (over the TCP connection).""" conversation_id = "test-conversation-id" - media_url = "http://test.url" - media_id = "test-media-id" mock_device: MockESPHomeDevice = await mock_esphome_device( mock_client=mock_client, @@ -328,15 +327,22 @@ async def test_pipeline_api_audio( assert satellite.state == AssistSatelliteState.RESPONDING # Should return mock_wav audio + mock_tts_result_stream = MockResultStream(hass, "wav", mock_wav) event_callback( PipelineEvent( type=PipelineEventType.TTS_END, - data={"tts_output": {"url": media_url, "media_id": media_id}}, + data={ + "tts_output": { + "media_id": "test-media-id", + "url": mock_tts_result_stream.url, + "token": mock_tts_result_stream.token, + } + }, ) ) assert mock_client.send_voice_assistant_event.call_args_list[-1].args == ( VoiceAssistantEventType.VOICE_ASSISTANT_TTS_END, - {"url": media_url}, + {"url": get_url(hass) + mock_tts_result_stream.url}, ) event_callback(PipelineEvent(type=PipelineEventType.RUN_END)) @@ -355,12 +361,6 @@ async def test_pipeline_api_audio( original_handle_pipeline_finished() pipeline_finished.set() - async def async_get_media_source_audio( - hass: HomeAssistant, - media_source_id: str, - ) -> tuple[str, bytes]: - return ("wav", mock_wav) - tts_finished = asyncio.Event() original_tts_response_finished = satellite.tts_response_finished @@ -373,10 +373,6 @@ async def test_pipeline_api_audio( "homeassistant.components.assist_satellite.entity.async_pipeline_from_audio_stream", new=async_pipeline_from_audio_stream, ), - patch( - "homeassistant.components.tts.async_get_media_source_audio", - new=async_get_media_source_audio, - ), patch.object(satellite, "handle_pipeline_finished", handle_pipeline_finished), patch.object(satellite, "_stream_tts_audio", _stream_tts_audio), patch.object(satellite, "tts_response_finished", tts_response_finished), @@ -434,8 +430,6 @@ async def test_pipeline_udp_audio( mainly focused on the UDP server. """ conversation_id = "test-conversation-id" - media_url = "http://test.url" - media_id = "test-media-id" mock_device: MockESPHomeDevice = await mock_esphome_device( mock_client=mock_client, @@ -522,10 +516,17 @@ async def test_pipeline_udp_audio( ) # Should return mock_wav audio + mock_tts_result_stream = MockResultStream(hass, "wav", mock_wav) event_callback( PipelineEvent( type=PipelineEventType.TTS_END, - data={"tts_output": {"url": media_url, "media_id": media_id}}, + data={ + "tts_output": { + "media_id": "test-media-id", + "url": mock_tts_result_stream.url, + "token": mock_tts_result_stream.token, + } + }, ) ) @@ -538,12 +539,6 @@ async def test_pipeline_udp_audio( original_handle_pipeline_finished() pipeline_finished.set() - async def async_get_media_source_audio( - hass: HomeAssistant, - media_source_id: str, - ) -> tuple[str, bytes]: - return ("wav", mock_wav) - tts_finished = asyncio.Event() original_tts_response_finished = satellite.tts_response_finished @@ -567,10 +562,6 @@ async def test_pipeline_udp_audio( "homeassistant.components.assist_satellite.entity.async_pipeline_from_audio_stream", new=async_pipeline_from_audio_stream, ), - patch( - "homeassistant.components.tts.async_get_media_source_audio", - new=async_get_media_source_audio, - ), patch.object(satellite, "handle_pipeline_finished", handle_pipeline_finished), patch.object(satellite, "tts_response_finished", tts_response_finished), ): @@ -652,8 +643,6 @@ async def test_pipeline_media_player( mainly focused on tts_response_finished getting automatically called. """ conversation_id = "test-conversation-id" - media_url = "http://test.url" - media_id = "test-media-id" mock_device: MockESPHomeDevice = await mock_esphome_device( mock_client=mock_client, @@ -733,10 +722,17 @@ async def test_pipeline_media_player( ) # Should return mock_wav audio + mock_tts_result_stream = MockResultStream(hass, "wav", mock_wav) event_callback( PipelineEvent( type=PipelineEventType.TTS_END, - data={"tts_output": {"url": media_url, "media_id": media_id}}, + data={ + "tts_output": { + "media_id": "test-media-id", + "url": mock_tts_result_stream.url, + "token": mock_tts_result_stream.token, + } + }, ) ) @@ -749,12 +745,6 @@ async def test_pipeline_media_player( original_handle_pipeline_finished() pipeline_finished.set() - async def async_get_media_source_audio( - hass: HomeAssistant, - media_source_id: str, - ) -> tuple[str, bytes]: - return ("wav", mock_wav) - tts_finished = asyncio.Event() original_tts_response_finished = satellite.tts_response_finished @@ -767,10 +757,6 @@ async def test_pipeline_media_player( "homeassistant.components.assist_satellite.entity.async_pipeline_from_audio_stream", new=async_pipeline_from_audio_stream, ), - patch( - "homeassistant.components.tts.async_get_media_source_audio", - new=async_get_media_source_audio, - ), patch.object(satellite, "handle_pipeline_finished", handle_pipeline_finished), patch.object(satellite, "tts_response_finished", tts_response_finished), ): @@ -944,80 +930,63 @@ async def test_streaming_tts_errors( # Should not stream if not running satellite._is_running = False - await satellite._stream_tts_audio("test-media-id") + await satellite._stream_tts_audio(MockResultStream(hass, "wav", mock_wav)) mock_client.send_voice_assistant_audio.assert_not_called() satellite._is_running = True # Should only stream WAV - async def get_mp3( - hass: HomeAssistant, - media_source_id: str, - ) -> tuple[str, bytes]: - return ("mp3", b"") - - with patch( - "homeassistant.components.tts.async_get_media_source_audio", new=get_mp3 - ): - await satellite._stream_tts_audio("test-media-id") - mock_client.send_voice_assistant_audio.assert_not_called() + await satellite._stream_tts_audio(MockResultStream(hass, "mp3", b"")) + mock_client.send_voice_assistant_audio.assert_not_called() # Needs to be the correct sample rate, etc. - async def get_bad_wav( - hass: HomeAssistant, - media_source_id: str, - ) -> tuple[str, bytes]: - with io.BytesIO() as wav_io: - with wave.open(wav_io, "wb") as wav_file: - wav_file.setframerate(48000) - wav_file.setsampwidth(2) - wav_file.setnchannels(1) - wav_file.writeframes(b"test-wav") + with io.BytesIO() as wav_io: + with wave.open(wav_io, "wb") as wav_file: + wav_file.setframerate(48000) + wav_file.setsampwidth(2) + wav_file.setnchannels(1) + wav_file.writeframes(b"test-wav") - return ("wav", wav_io.getvalue()) + mock_tts_result_stream = MockResultStream(hass, "wav", wav_io.getvalue()) - with patch( - "homeassistant.components.tts.async_get_media_source_audio", new=get_bad_wav - ): - await satellite._stream_tts_audio("test-media-id") - mock_client.send_voice_assistant_audio.assert_not_called() + await satellite._stream_tts_audio(mock_tts_result_stream) + mock_client.send_voice_assistant_audio.assert_not_called() # Check that TTS_STREAM_* events still get sent after cancel media_fetched = asyncio.Event() - async def get_slow_wav( - hass: HomeAssistant, - media_source_id: str, - ) -> tuple[str, bytes]: + mock_tts_result_stream = MockResultStream(hass, "wav", b"") + + async def async_stream_result_slowly(): media_fetched.set() await asyncio.sleep(1) - return ("wav", mock_wav) + yield mock_wav + + mock_tts_result_stream.async_stream_result = async_stream_result_slowly mock_client.send_voice_assistant_event.reset_mock() - with patch( - "homeassistant.components.tts.async_get_media_source_audio", new=get_slow_wav - ): - task = asyncio.create_task(satellite._stream_tts_audio("test-media-id")) - async with asyncio.timeout(1): - # Wait for media to be fetched - await media_fetched.wait() - # Cancel task - task.cancel() - await task + task = asyncio.create_task(satellite._stream_tts_audio(mock_tts_result_stream)) + async with asyncio.timeout(1): + # Wait for media to be fetched + await media_fetched.wait() - # No audio should have gone out - mock_client.send_voice_assistant_audio.assert_not_called() - assert len(mock_client.send_voice_assistant_event.call_args_list) == 2 + # Cancel task + task.cancel() + await task - # The TTS_STREAM_* events should have gone out - assert mock_client.send_voice_assistant_event.call_args_list[-2].args == ( - VoiceAssistantEventType.VOICE_ASSISTANT_TTS_STREAM_START, - {}, - ) - assert mock_client.send_voice_assistant_event.call_args_list[-1].args == ( - VoiceAssistantEventType.VOICE_ASSISTANT_TTS_STREAM_END, - {}, - ) + # No audio should have gone out + mock_client.send_voice_assistant_audio.assert_not_called() + assert len(mock_client.send_voice_assistant_event.call_args_list) == 2 + + # The TTS_STREAM_* events should have gone out + assert mock_client.send_voice_assistant_event.call_args_list[-2].args == ( + VoiceAssistantEventType.VOICE_ASSISTANT_TTS_STREAM_START, + {}, + ) + assert mock_client.send_voice_assistant_event.call_args_list[-1].args == ( + VoiceAssistantEventType.VOICE_ASSISTANT_TTS_STREAM_END, + {}, + ) async def test_tts_format_from_media_player( From 12eb071e8a8584703c403fcb86985f2465d1da01 Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Sun, 30 Mar 2025 17:31:12 +0200 Subject: [PATCH 068/505] Replace "Off" with common state in `plugwise` (#141828) --- homeassistant/components/plugwise/strings.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/plugwise/strings.json b/homeassistant/components/plugwise/strings.json index d16b38df992..99d501a79b5 100644 --- a/homeassistant/components/plugwise/strings.json +++ b/homeassistant/components/plugwise/strings.json @@ -139,7 +139,7 @@ "select_schedule": { "name": "Thermostat schedule", "state": { - "off": "Off" + "off": "[%key:common::state::off%]" } } }, From 7232d36494a9b057d81781d8d3326d4ee4fa0ab2 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Sun, 30 Mar 2025 17:44:48 +0200 Subject: [PATCH 069/505] Fix hardcoded UoM for total power sensor for Tuya zndb devices (#141822) --- homeassistant/components/tuya/sensor.py | 1 - 1 file changed, 1 deletion(-) diff --git a/homeassistant/components/tuya/sensor.py b/homeassistant/components/tuya/sensor.py index 29bdffe1c28..9e40bda5d4d 100644 --- a/homeassistant/components/tuya/sensor.py +++ b/homeassistant/components/tuya/sensor.py @@ -832,7 +832,6 @@ SENSORS: dict[str, tuple[TuyaSensorEntityDescription, ...]] = { translation_key="total_power", device_class=SensorDeviceClass.POWER, state_class=SensorStateClass.MEASUREMENT, - native_unit_of_measurement=UnitOfPower.KILO_WATT, subkey="power", ), TuyaSensorEntityDescription( From 963ea6141c559be6c5c582b2c907b99c2adaea11 Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Sun, 30 Mar 2025 17:46:03 +0200 Subject: [PATCH 070/505] Fix the entity category for max throughput sensors in AVM Fritz!Box Tools (#141838) correct the entity category for max throughput sensors --- homeassistant/components/fritz/sensor.py | 4 ++-- tests/components/fritz/snapshots/test_sensor.ambr | 8 ++++---- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/fritz/sensor.py b/homeassistant/components/fritz/sensor.py index bcee590460f..88de9ebdefc 100644 --- a/homeassistant/components/fritz/sensor.py +++ b/homeassistant/components/fritz/sensor.py @@ -193,7 +193,6 @@ SENSOR_TYPES: tuple[FritzSensorEntityDescription, ...] = ( translation_key="max_kb_s_sent", native_unit_of_measurement=UnitOfDataRate.KILOBITS_PER_SECOND, device_class=SensorDeviceClass.DATA_RATE, - entity_category=EntityCategory.DIAGNOSTIC, value_fn=_retrieve_max_kb_s_sent_state, ), FritzSensorEntityDescription( @@ -201,7 +200,6 @@ SENSOR_TYPES: tuple[FritzSensorEntityDescription, ...] = ( translation_key="max_kb_s_received", native_unit_of_measurement=UnitOfDataRate.KILOBITS_PER_SECOND, device_class=SensorDeviceClass.DATA_RATE, - entity_category=EntityCategory.DIAGNOSTIC, value_fn=_retrieve_max_kb_s_received_state, ), FritzSensorEntityDescription( @@ -225,6 +223,7 @@ SENSOR_TYPES: tuple[FritzSensorEntityDescription, ...] = ( translation_key="link_kb_s_sent", native_unit_of_measurement=UnitOfDataRate.KILOBITS_PER_SECOND, device_class=SensorDeviceClass.DATA_RATE, + entity_category=EntityCategory.DIAGNOSTIC, value_fn=_retrieve_link_kb_s_sent_state, ), FritzSensorEntityDescription( @@ -232,6 +231,7 @@ SENSOR_TYPES: tuple[FritzSensorEntityDescription, ...] = ( translation_key="link_kb_s_received", native_unit_of_measurement=UnitOfDataRate.KILOBITS_PER_SECOND, device_class=SensorDeviceClass.DATA_RATE, + entity_category=EntityCategory.DIAGNOSTIC, value_fn=_retrieve_link_kb_s_received_state, ), FritzSensorEntityDescription( diff --git a/tests/components/fritz/snapshots/test_sensor.ambr b/tests/components/fritz/snapshots/test_sensor.ambr index 5ff0e448b15..ffede386099 100644 --- a/tests/components/fritz/snapshots/test_sensor.ambr +++ b/tests/components/fritz/snapshots/test_sensor.ambr @@ -453,7 +453,7 @@ 'device_id': , 'disabled_by': None, 'domain': 'sensor', - 'entity_category': None, + 'entity_category': , 'entity_id': 'sensor.mock_title_link_download_throughput', 'has_entity_name': True, 'hidden_by': None, @@ -598,7 +598,7 @@ 'device_id': , 'disabled_by': None, 'domain': 'sensor', - 'entity_category': None, + 'entity_category': , 'entity_id': 'sensor.mock_title_link_upload_throughput', 'has_entity_name': True, 'hidden_by': None, @@ -647,7 +647,7 @@ 'device_id': , 'disabled_by': None, 'domain': 'sensor', - 'entity_category': , + 'entity_category': None, 'entity_id': 'sensor.mock_title_max_connection_download_throughput', 'has_entity_name': True, 'hidden_by': None, @@ -696,7 +696,7 @@ 'device_id': , 'disabled_by': None, 'domain': 'sensor', - 'entity_category': , + 'entity_category': None, 'entity_id': 'sensor.mock_title_max_connection_upload_throughput', 'has_entity_name': True, 'hidden_by': None, From b06de7a6876c85a57293c2b9370166a1a11a7c84 Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Sun, 30 Mar 2025 18:50:58 +0200 Subject: [PATCH 071/505] Replace "Off" and references with common state in `teslemetry` (#141841) --- homeassistant/components/teslemetry/strings.json | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/homeassistant/components/teslemetry/strings.json b/homeassistant/components/teslemetry/strings.json index 76c51f006fa..c4013800294 100644 --- a/homeassistant/components/teslemetry/strings.json +++ b/homeassistant/components/teslemetry/strings.json @@ -265,7 +265,7 @@ "high": "High", "low": "Low", "medium": "Medium", - "off": "Off" + "off": "[%key:common::state::off%]" } }, "climate_state_seat_heater_rear_center": { @@ -274,7 +274,7 @@ "high": "[%key:component::teslemetry::entity::select::climate_state_seat_heater_left::state::high%]", "low": "[%key:component::teslemetry::entity::select::climate_state_seat_heater_left::state::low%]", "medium": "[%key:component::teslemetry::entity::select::climate_state_seat_heater_left::state::medium%]", - "off": "[%key:component::teslemetry::entity::select::climate_state_seat_heater_left::state::off%]" + "off": "[%key:common::state::off%]" } }, "climate_state_seat_heater_rear_left": { @@ -283,7 +283,7 @@ "high": "[%key:component::teslemetry::entity::select::climate_state_seat_heater_left::state::high%]", "low": "[%key:component::teslemetry::entity::select::climate_state_seat_heater_left::state::low%]", "medium": "[%key:component::teslemetry::entity::select::climate_state_seat_heater_left::state::medium%]", - "off": "[%key:component::teslemetry::entity::select::climate_state_seat_heater_left::state::off%]" + "off": "[%key:common::state::off%]" } }, "climate_state_seat_heater_rear_right": { @@ -292,7 +292,7 @@ "high": "[%key:component::teslemetry::entity::select::climate_state_seat_heater_left::state::high%]", "low": "[%key:component::teslemetry::entity::select::climate_state_seat_heater_left::state::low%]", "medium": "[%key:component::teslemetry::entity::select::climate_state_seat_heater_left::state::medium%]", - "off": "[%key:component::teslemetry::entity::select::climate_state_seat_heater_left::state::off%]" + "off": "[%key:common::state::off%]" } }, "climate_state_seat_heater_right": { @@ -301,7 +301,7 @@ "high": "[%key:component::teslemetry::entity::select::climate_state_seat_heater_left::state::high%]", "low": "[%key:component::teslemetry::entity::select::climate_state_seat_heater_left::state::low%]", "medium": "[%key:component::teslemetry::entity::select::climate_state_seat_heater_left::state::medium%]", - "off": "[%key:component::teslemetry::entity::select::climate_state_seat_heater_left::state::off%]" + "off": "[%key:common::state::off%]" } }, "climate_state_seat_heater_third_row_left": { @@ -310,7 +310,7 @@ "high": "[%key:component::teslemetry::entity::select::climate_state_seat_heater_left::state::high%]", "low": "[%key:component::teslemetry::entity::select::climate_state_seat_heater_left::state::low%]", "medium": "[%key:component::teslemetry::entity::select::climate_state_seat_heater_left::state::medium%]", - "off": "[%key:component::teslemetry::entity::select::climate_state_seat_heater_left::state::off%]" + "off": "[%key:common::state::off%]" } }, "climate_state_seat_heater_third_row_right": { @@ -319,7 +319,7 @@ "high": "[%key:component::teslemetry::entity::select::climate_state_seat_heater_left::state::high%]", "low": "[%key:component::teslemetry::entity::select::climate_state_seat_heater_left::state::low%]", "medium": "[%key:component::teslemetry::entity::select::climate_state_seat_heater_left::state::medium%]", - "off": "[%key:component::teslemetry::entity::select::climate_state_seat_heater_left::state::off%]" + "off": "[%key:common::state::off%]" } }, "climate_state_steering_wheel_heat_level": { @@ -327,7 +327,7 @@ "state": { "high": "[%key:component::teslemetry::entity::select::climate_state_seat_heater_left::state::high%]", "low": "[%key:component::teslemetry::entity::select::climate_state_seat_heater_left::state::low%]", - "off": "[%key:component::teslemetry::entity::select::climate_state_seat_heater_left::state::off%]" + "off": "[%key:common::state::off%]" } }, "components_customer_preferred_export_rule": { From 68d1a3c0a225d476be0965f94215aa5d3953a78b Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Sun, 30 Mar 2025 19:06:28 +0200 Subject: [PATCH 072/505] Replace "Off" and references with common state in `tesla_fleet` (#141840) --- .../components/tesla_fleet/strings.json | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/homeassistant/components/tesla_fleet/strings.json b/homeassistant/components/tesla_fleet/strings.json index 31e88e4348e..c5a03e183e4 100644 --- a/homeassistant/components/tesla_fleet/strings.json +++ b/homeassistant/components/tesla_fleet/strings.json @@ -209,7 +209,7 @@ "high": "High", "low": "Low", "medium": "Medium", - "off": "Off" + "off": "[%key:common::state::off%]" } }, "climate_state_seat_heater_rear_center": { @@ -218,7 +218,7 @@ "high": "[%key:component::tesla_fleet::entity::select::climate_state_seat_heater_left::state::high%]", "low": "[%key:component::tesla_fleet::entity::select::climate_state_seat_heater_left::state::low%]", "medium": "[%key:component::tesla_fleet::entity::select::climate_state_seat_heater_left::state::medium%]", - "off": "[%key:component::tesla_fleet::entity::select::climate_state_seat_heater_left::state::off%]" + "off": "[%key:common::state::off%]" } }, "climate_state_seat_heater_rear_left": { @@ -227,7 +227,7 @@ "high": "[%key:component::tesla_fleet::entity::select::climate_state_seat_heater_left::state::high%]", "low": "[%key:component::tesla_fleet::entity::select::climate_state_seat_heater_left::state::low%]", "medium": "[%key:component::tesla_fleet::entity::select::climate_state_seat_heater_left::state::medium%]", - "off": "[%key:component::tesla_fleet::entity::select::climate_state_seat_heater_left::state::off%]" + "off": "[%key:common::state::off%]" } }, "climate_state_seat_heater_rear_right": { @@ -236,7 +236,7 @@ "high": "[%key:component::tesla_fleet::entity::select::climate_state_seat_heater_left::state::high%]", "low": "[%key:component::tesla_fleet::entity::select::climate_state_seat_heater_left::state::low%]", "medium": "[%key:component::tesla_fleet::entity::select::climate_state_seat_heater_left::state::medium%]", - "off": "[%key:component::tesla_fleet::entity::select::climate_state_seat_heater_left::state::off%]" + "off": "[%key:common::state::off%]" } }, "climate_state_seat_heater_right": { @@ -245,7 +245,7 @@ "high": "[%key:component::tesla_fleet::entity::select::climate_state_seat_heater_left::state::high%]", "low": "[%key:component::tesla_fleet::entity::select::climate_state_seat_heater_left::state::low%]", "medium": "[%key:component::tesla_fleet::entity::select::climate_state_seat_heater_left::state::medium%]", - "off": "[%key:component::tesla_fleet::entity::select::climate_state_seat_heater_left::state::off%]" + "off": "[%key:common::state::off%]" } }, "climate_state_seat_heater_third_row_left": { @@ -254,7 +254,7 @@ "high": "[%key:component::tesla_fleet::entity::select::climate_state_seat_heater_left::state::high%]", "low": "[%key:component::tesla_fleet::entity::select::climate_state_seat_heater_left::state::low%]", "medium": "[%key:component::tesla_fleet::entity::select::climate_state_seat_heater_left::state::medium%]", - "off": "[%key:component::tesla_fleet::entity::select::climate_state_seat_heater_left::state::off%]" + "off": "[%key:common::state::off%]" } }, "climate_state_seat_heater_third_row_right": { @@ -263,7 +263,7 @@ "high": "[%key:component::tesla_fleet::entity::select::climate_state_seat_heater_left::state::high%]", "low": "[%key:component::tesla_fleet::entity::select::climate_state_seat_heater_left::state::low%]", "medium": "[%key:component::tesla_fleet::entity::select::climate_state_seat_heater_left::state::medium%]", - "off": "[%key:component::tesla_fleet::entity::select::climate_state_seat_heater_left::state::off%]" + "off": "[%key:common::state::off%]" } }, "climate_state_steering_wheel_heat_level": { @@ -271,7 +271,7 @@ "state": { "high": "[%key:component::tesla_fleet::entity::select::climate_state_seat_heater_left::state::high%]", "low": "[%key:component::tesla_fleet::entity::select::climate_state_seat_heater_left::state::low%]", - "off": "[%key:component::tesla_fleet::entity::select::climate_state_seat_heater_left::state::off%]" + "off": "[%key:common::state::off%]" } }, "components_customer_preferred_export_rule": { From 3d49000c75a7fe8e446deda44a73adfdfdc125b1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lucas=20Mind=C3=AAllo=20de=20Andrade?= Date: Sun, 30 Mar 2025 14:11:09 -0300 Subject: [PATCH 073/505] Remove sunweg integration (#124230) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * chore(sunweg): remove sunweg integration * Update homeassistant/components/sunweg/strings.json Co-authored-by: Abílio Costa * Update homeassistant/components/sunweg/manifest.json Co-authored-by: Abílio Costa * feat: added async remove entry * Clean setup_entry; add tests --------- Co-authored-by: Abílio Costa Co-authored-by: abmantis --- CODEOWNERS | 2 - homeassistant/components/sunweg/__init__.py | 204 ++------------ .../components/sunweg/config_flow.py | 122 +------- homeassistant/components/sunweg/const.py | 25 -- homeassistant/components/sunweg/manifest.json | 6 +- .../components/sunweg/sensor/__init__.py | 178 ------------ .../components/sunweg/sensor/inverter.py | 70 ----- .../components/sunweg/sensor/phase.py | 27 -- .../sensor/sensor_entity_description.py | 24 -- .../components/sunweg/sensor/string.py | 27 -- .../components/sunweg/sensor/total.py | 50 ---- homeassistant/components/sunweg/strings.json | 35 +-- pyproject.toml | 10 +- requirements_all.txt | 3 - requirements_test_all.txt | 3 - .../fixtures/current_data.json | 1 - tests/components/sunweg/__init__.py | 2 +- tests/components/sunweg/common.py | 22 -- tests/components/sunweg/conftest.py | 90 ------ tests/components/sunweg/test_config_flow.py | 223 --------------- tests/components/sunweg/test_init.py | 266 +++++------------- 21 files changed, 105 insertions(+), 1285 deletions(-) delete mode 100644 homeassistant/components/sunweg/const.py delete mode 100644 homeassistant/components/sunweg/sensor/__init__.py delete mode 100644 homeassistant/components/sunweg/sensor/inverter.py delete mode 100644 homeassistant/components/sunweg/sensor/phase.py delete mode 100644 homeassistant/components/sunweg/sensor/sensor_entity_description.py delete mode 100644 homeassistant/components/sunweg/sensor/string.py delete mode 100644 homeassistant/components/sunweg/sensor/total.py delete mode 100644 tests/components/sunweg/common.py delete mode 100644 tests/components/sunweg/conftest.py delete mode 100644 tests/components/sunweg/test_config_flow.py diff --git a/CODEOWNERS b/CODEOWNERS index 9a8d8b2fc64..8afd3bab028 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -1480,8 +1480,6 @@ build.json @home-assistant/supervisor /tests/components/suez_water/ @ooii @jb101010-2 /homeassistant/components/sun/ @Swamp-Ig /tests/components/sun/ @Swamp-Ig -/homeassistant/components/sunweg/ @rokam -/tests/components/sunweg/ @rokam /homeassistant/components/supla/ @mwegrzynek /homeassistant/components/surepetcare/ @benleb @danielhiversen /tests/components/surepetcare/ @benleb @danielhiversen diff --git a/homeassistant/components/sunweg/__init__.py b/homeassistant/components/sunweg/__init__.py index 86da0a247b1..0dfed0e6bb3 100644 --- a/homeassistant/components/sunweg/__init__.py +++ b/homeassistant/components/sunweg/__init__.py @@ -1,197 +1,39 @@ """The Sun WEG inverter sensor integration.""" -import datetime -import json -import logging - -from sunweg.api import APIHelper -from sunweg.plant import Plant - -from homeassistant import config_entries from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_PASSWORD, CONF_USERNAME from homeassistant.core import HomeAssistant -from homeassistant.exceptions import ConfigEntryAuthFailed -from homeassistant.helpers.typing import StateType, UndefinedType -from homeassistant.util import Throttle +from homeassistant.helpers import issue_registry as ir -from .const import CONF_PLANT_ID, DOMAIN, PLATFORMS, DeviceType - -SCAN_INTERVAL = datetime.timedelta(minutes=5) - -_LOGGER = logging.getLogger(__name__) +DOMAIN = "sunweg" -async def async_setup_entry( - hass: HomeAssistant, entry: config_entries.ConfigEntry -) -> bool: +async def async_setup_entry(hass: HomeAssistant, _: ConfigEntry) -> bool: """Load the saved entities.""" - api = APIHelper(entry.data[CONF_USERNAME], entry.data[CONF_PASSWORD]) - if not await hass.async_add_executor_job(api.authenticate): - raise ConfigEntryAuthFailed("Username or Password may be incorrect!") - hass.data.setdefault(DOMAIN, {})[entry.entry_id] = SunWEGData( - api, entry.data[CONF_PLANT_ID] + ir.async_create_issue( + hass, + DOMAIN, + DOMAIN, + is_fixable=False, + severity=ir.IssueSeverity.ERROR, + translation_key="integration_removed", + translation_placeholders={ + "issue": "https://github.com/rokam/sunweg/issues/13", + "entries": "/config/integrations/integration/sunweg", + }, ) - await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + return True async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Unload a config entry.""" - hass.data[DOMAIN].pop(entry.entry_id) - if len(hass.data[DOMAIN]) == 0: - hass.data.pop(DOMAIN) - return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) + return True -class SunWEGData: - """The class for handling data retrieval.""" - - def __init__( - self, - api: APIHelper, - plant_id: int, - ) -> None: - """Initialize the probe.""" - - self.api = api - self.plant_id = plant_id - self.data: Plant = None - self.previous_values: dict = {} - - @Throttle(SCAN_INTERVAL) - def update(self) -> None: - """Update probe data.""" - _LOGGER.debug("Updating data for plant %s", self.plant_id) - try: - self.data = self.api.plant(self.plant_id) - for inverter in self.data.inverters: - self.api.complete_inverter(inverter) - except json.decoder.JSONDecodeError: - _LOGGER.error("Unable to fetch data from SunWEG server") - _LOGGER.debug("Finished updating data for plant %s", self.plant_id) - - def get_api_value( - self, - variable: str, - device_type: DeviceType, - inverter_id: int = 0, - deep_name: str | None = None, - ): - """Retrieve from a Plant the desired variable value.""" - if device_type == DeviceType.TOTAL: - return self.data.__dict__.get(variable) - - inverter_list = [i for i in self.data.inverters if i.id == inverter_id] - if len(inverter_list) == 0: - return None - inverter = inverter_list[0] - - if device_type == DeviceType.INVERTER: - return inverter.__dict__.get(variable) - if device_type == DeviceType.PHASE: - for phase in inverter.phases: - if phase.name == deep_name: - return phase.__dict__.get(variable) - elif device_type == DeviceType.STRING: - for mppt in inverter.mppts: - for string in mppt.strings: - if string.name == deep_name: - return string.__dict__.get(variable) - return None - - def get_data( - self, - *, - api_variable_key: str, - api_variable_unit: str | None, - deep_name: str | None, - device_type: DeviceType, - inverter_id: int, - name: str | UndefinedType | None, - native_unit_of_measurement: str | None, - never_resets: bool, - previous_value_drop_threshold: float | None, - ) -> tuple[StateType | datetime.datetime, str | None]: - """Get the data.""" - _LOGGER.debug( - "Data request for: %s", - name, - ) - variable = api_variable_key - previous_unit = native_unit_of_measurement - api_value = self.get_api_value(variable, device_type, inverter_id, deep_name) - previous_value = self.previous_values.get(variable) - return_value = api_value - if api_variable_unit is not None: - native_unit_of_measurement = self.get_api_value( - api_variable_unit, - device_type, - inverter_id, - deep_name, - ) - - # If we have a 'drop threshold' specified, then check it and correct if needed - if ( - previous_value_drop_threshold is not None - and previous_value is not None - and api_value is not None - and previous_unit == native_unit_of_measurement - ): - _LOGGER.debug( - ( - "%s - Drop threshold specified (%s), checking for drop... API" - " Value: %s, Previous Value: %s" - ), - name, - previous_value_drop_threshold, - api_value, - previous_value, - ) - diff = float(api_value) - float(previous_value) - - # Check if the value has dropped (negative value i.e. < 0) and it has only - # dropped by a small amount, if so, use the previous value. - # Note - The energy dashboard takes care of drops within 10% - # of the current value, however if the value is low e.g. 0.2 - # and drops by 0.1 it classes as a reset. - if -(previous_value_drop_threshold) <= diff < 0: - _LOGGER.debug( - ( - "Diff is negative, but only by a small amount therefore not a" - " nightly reset, using previous value (%s) instead of api value" - " (%s)" - ), - previous_value, - api_value, - ) - return_value = previous_value - else: - _LOGGER.debug("%s - No drop detected, using API value", name) - - # Lifetime total values should always be increasing, they will never reset, - # however the API sometimes returns 0 values when the clock turns to 00:00 - # local time in that scenario we should just return the previous value - # Scenarios: - # 1 - System has a genuine 0 value when it it first commissioned: - # - will return 0 until a non-zero value is registered - # 2 - System has been running fine but temporarily resets to 0 briefly - # at midnight: - # - will return the previous value - # 3 - HA is restarted during the midnight 'outage' - Not handled: - # - Previous value will not exist meaning 0 will be returned - # - This is an edge case that would be better handled by looking - # up the previous value of the entity from the recorder - if never_resets and api_value == 0 and previous_value: - _LOGGER.debug( - ( - "API value is 0, but this value should never reset, returning" - " previous value (%s) instead" - ), - previous_value, - ) - return_value = previous_value - - self.previous_values[variable] = return_value - - return (return_value, native_unit_of_measurement) +async def async_remove_entry(hass: HomeAssistant, entry: ConfigEntry) -> None: + """Remove a config entry.""" + if not hass.config_entries.async_loaded_entries(DOMAIN): + ir.async_delete_issue(hass, DOMAIN, DOMAIN) + # Remove any remaining disabled or ignored entries + for _entry in hass.config_entries.async_entries(DOMAIN): + hass.async_create_task(hass.config_entries.async_remove(_entry.entry_id)) diff --git a/homeassistant/components/sunweg/config_flow.py b/homeassistant/components/sunweg/config_flow.py index 24df8c02f55..42535a9ef58 100644 --- a/homeassistant/components/sunweg/config_flow.py +++ b/homeassistant/components/sunweg/config_flow.py @@ -1,129 +1,11 @@ """Config flow for Sun WEG integration.""" -from collections.abc import Mapping -from typing import Any +from homeassistant.config_entries import ConfigFlow -from sunweg.api import APIHelper, SunWegApiError -import voluptuous as vol - -from homeassistant.config_entries import ConfigFlow, ConfigFlowResult -from homeassistant.const import CONF_NAME, CONF_PASSWORD, CONF_USERNAME -from homeassistant.core import callback - -from .const import CONF_PLANT_ID, DOMAIN +from . import DOMAIN class SunWEGConfigFlow(ConfigFlow, domain=DOMAIN): """Config flow class.""" VERSION = 1 - - def __init__(self) -> None: - """Initialise sun weg server flow.""" - self.api: APIHelper = None - self.data: dict[str, Any] = {} - - @callback - def _async_show_user_form(self, step_id: str, errors=None) -> ConfigFlowResult: - """Show the form to the user.""" - default_username = "" - if CONF_USERNAME in self.data: - default_username = self.data[CONF_USERNAME] - data_schema = vol.Schema( - { - vol.Required(CONF_USERNAME, default=default_username): str, - vol.Required(CONF_PASSWORD): str, - } - ) - - return self.async_show_form( - step_id=step_id, data_schema=data_schema, errors=errors - ) - - def _set_auth_data( - self, step: str, username: str, password: str - ) -> ConfigFlowResult | None: - """Set username and password.""" - if self.api: - # Set username and password - self.api.username = username - self.api.password = password - else: - # Initialise the library with the username & password - self.api = APIHelper(username, password) - - try: - if not self.api.authenticate(): - return self._async_show_user_form(step, {"base": "invalid_auth"}) - except SunWegApiError: - return self._async_show_user_form(step, {"base": "timeout_connect"}) - - return None - - async def async_step_user(self, user_input=None) -> ConfigFlowResult: - """Handle the start of the config flow.""" - if not user_input: - return self._async_show_user_form("user") - - # Store authentication info - self.data = user_input - - conf_result = await self.hass.async_add_executor_job( - self._set_auth_data, - "user", - user_input[CONF_USERNAME], - user_input[CONF_PASSWORD], - ) - - return await self.async_step_plant() if conf_result is None else conf_result - - async def async_step_plant(self, user_input=None) -> ConfigFlowResult: - """Handle adding a "plant" to Home Assistant.""" - plant_list = await self.hass.async_add_executor_job(self.api.listPlants) - - if len(plant_list) == 0: - return self.async_abort(reason="no_plants") - - plants = {plant.id: plant.name for plant in plant_list} - - if user_input is None and len(plant_list) > 1: - data_schema = vol.Schema({vol.Required(CONF_PLANT_ID): vol.In(plants)}) - - return self.async_show_form(step_id="plant", data_schema=data_schema) - - if user_input is None and len(plant_list) == 1: - user_input = {CONF_PLANT_ID: plant_list[0].id} - - user_input[CONF_NAME] = plants[user_input[CONF_PLANT_ID]] - await self.async_set_unique_id(user_input[CONF_PLANT_ID]) - self._abort_if_unique_id_configured() - self.data.update(user_input) - return self.async_create_entry(title=self.data[CONF_NAME], data=self.data) - - async def async_step_reauth( - self, entry_data: Mapping[str, Any] - ) -> ConfigFlowResult: - """Handle reauthorization request from SunWEG.""" - self.data.update(entry_data) - return await self.async_step_reauth_confirm() - - async def async_step_reauth_confirm( - self, user_input: dict[str, Any] | None = None - ) -> ConfigFlowResult: - """Handle reauthorization flow.""" - if user_input is None: - return self._async_show_user_form("reauth_confirm") - - self.data.update(user_input) - conf_result = await self.hass.async_add_executor_job( - self._set_auth_data, - "reauth_confirm", - user_input[CONF_USERNAME], - user_input[CONF_PASSWORD], - ) - if conf_result is not None: - return conf_result - - return self.async_update_reload_and_abort( - self._get_reauth_entry(), data=self.data - ) diff --git a/homeassistant/components/sunweg/const.py b/homeassistant/components/sunweg/const.py deleted file mode 100644 index 11d24352962..00000000000 --- a/homeassistant/components/sunweg/const.py +++ /dev/null @@ -1,25 +0,0 @@ -"""Define constants for the Sun WEG component.""" - -from enum import Enum - -from homeassistant.const import Platform - - -class DeviceType(Enum): - """Device Type Enum.""" - - TOTAL = 1 - INVERTER = 2 - PHASE = 3 - STRING = 4 - - -CONF_PLANT_ID = "plant_id" - -DEFAULT_PLANT_ID = 0 - -DEFAULT_NAME = "Sun WEG" - -DOMAIN = "sunweg" - -PLATFORMS = [Platform.SENSOR] diff --git a/homeassistant/components/sunweg/manifest.json b/homeassistant/components/sunweg/manifest.json index 3ebe9ef8cb4..3e5c669f37f 100644 --- a/homeassistant/components/sunweg/manifest.json +++ b/homeassistant/components/sunweg/manifest.json @@ -1,10 +1,10 @@ { "domain": "sunweg", "name": "Sun WEG", - "codeowners": ["@rokam"], + "codeowners": [], "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/sunweg", "iot_class": "cloud_polling", - "loggers": ["sunweg"], - "requirements": ["sunweg==3.0.2"] + "loggers": [], + "requirements": [] } diff --git a/homeassistant/components/sunweg/sensor/__init__.py b/homeassistant/components/sunweg/sensor/__init__.py deleted file mode 100644 index f71d992bea9..00000000000 --- a/homeassistant/components/sunweg/sensor/__init__.py +++ /dev/null @@ -1,178 +0,0 @@ -"""Read status of SunWEG inverters.""" - -from __future__ import annotations - -import logging -from types import MappingProxyType -from typing import Any - -from sunweg.api import APIHelper -from sunweg.device import Inverter -from sunweg.plant import Plant - -from homeassistant.components.sensor import SensorEntity -from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_NAME -from homeassistant.core import HomeAssistant -from homeassistant.helpers.device_registry import DeviceInfo -from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback - -from .. import SunWEGData -from ..const import CONF_PLANT_ID, DEFAULT_PLANT_ID, DOMAIN, DeviceType -from .inverter import INVERTER_SENSOR_TYPES -from .phase import PHASE_SENSOR_TYPES -from .sensor_entity_description import SunWEGSensorEntityDescription -from .string import STRING_SENSOR_TYPES -from .total import TOTAL_SENSOR_TYPES - -_LOGGER = logging.getLogger(__name__) - - -def get_device_list( - api: APIHelper, config: MappingProxyType[str, Any] -) -> tuple[list[Inverter], int]: - """Retrieve the device list for the selected plant.""" - plant_id = int(config[CONF_PLANT_ID]) - - if plant_id == DEFAULT_PLANT_ID: - plant_info: list[Plant] = api.listPlants() - plant_id = plant_info[0].id - - devices: list[Inverter] = [] - # Get a list of devices for specified plant to add sensors for. - for inverter in api.plant(plant_id).inverters: - api.complete_inverter(inverter) - devices.append(inverter) - return (devices, plant_id) - - -async def async_setup_entry( - hass: HomeAssistant, - config_entry: ConfigEntry, - async_add_entities: AddConfigEntryEntitiesCallback, -) -> None: - """Set up the SunWEG sensor.""" - name = config_entry.data[CONF_NAME] - - probe: SunWEGData = hass.data[DOMAIN][config_entry.entry_id] - - devices, plant_id = await hass.async_add_executor_job( - get_device_list, probe.api, config_entry.data - ) - - entities = [ - SunWEGInverter( - probe, - name=f"{name} Total", - unique_id=f"{plant_id}-{description.key}", - description=description, - device_type=DeviceType.TOTAL, - ) - for description in TOTAL_SENSOR_TYPES - ] - - # Add sensors for each device in the specified plant. - entities.extend( - [ - SunWEGInverter( - probe, - name=f"{device.name}", - unique_id=f"{device.sn}-{description.key}", - description=description, - device_type=DeviceType.INVERTER, - inverter_id=device.id, - ) - for device in devices - for description in INVERTER_SENSOR_TYPES - ] - ) - - entities.extend( - [ - SunWEGInverter( - probe, - name=f"{device.name} {phase.name}", - unique_id=f"{device.sn}-{phase.name}-{description.key}", - description=description, - inverter_id=device.id, - device_type=DeviceType.PHASE, - deep_name=phase.name, - ) - for device in devices - for phase in device.phases - for description in PHASE_SENSOR_TYPES - ] - ) - - entities.extend( - [ - SunWEGInverter( - probe, - name=f"{device.name} {string.name}", - unique_id=f"{device.sn}-{string.name}-{description.key}", - description=description, - inverter_id=device.id, - device_type=DeviceType.STRING, - deep_name=string.name, - ) - for device in devices - for mppt in device.mppts - for string in mppt.strings - for description in STRING_SENSOR_TYPES - ] - ) - - async_add_entities(entities, True) - - -class SunWEGInverter(SensorEntity): - """Representation of a SunWEG Sensor.""" - - entity_description: SunWEGSensorEntityDescription - - def __init__( - self, - probe: SunWEGData, - name: str, - unique_id: str, - description: SunWEGSensorEntityDescription, - device_type: DeviceType, - inverter_id: int = 0, - deep_name: str | None = None, - ) -> None: - """Initialize a sensor.""" - self.probe = probe - self.entity_description = description - self.device_type = device_type - self.inverter_id = inverter_id - self.deep_name = deep_name - - self._attr_name = f"{name} {description.name}" - self._attr_unique_id = unique_id - self._attr_icon = ( - description.icon if description.icon is not None else "mdi:solar-power" - ) - - self._attr_device_info = DeviceInfo( - identifiers={(DOMAIN, str(probe.plant_id))}, - manufacturer="SunWEG", - name=name, - ) - - def update(self) -> None: - """Get the latest data from the Sun WEG API and updates the state.""" - self.probe.update() - ( - self._attr_native_value, - self._attr_native_unit_of_measurement, - ) = self.probe.get_data( - api_variable_key=self.entity_description.api_variable_key, - api_variable_unit=self.entity_description.api_variable_unit, - deep_name=self.deep_name, - device_type=self.device_type, - inverter_id=self.inverter_id, - name=self.entity_description.name, - native_unit_of_measurement=self.native_unit_of_measurement, - never_resets=self.entity_description.never_resets, - previous_value_drop_threshold=self.entity_description.previous_value_drop_threshold, - ) diff --git a/homeassistant/components/sunweg/sensor/inverter.py b/homeassistant/components/sunweg/sensor/inverter.py deleted file mode 100644 index 1010488b38a..00000000000 --- a/homeassistant/components/sunweg/sensor/inverter.py +++ /dev/null @@ -1,70 +0,0 @@ -"""SunWEG Sensor definitions for the Inverter type.""" - -from __future__ import annotations - -from homeassistant.components.sensor import SensorDeviceClass, SensorStateClass -from homeassistant.const import ( - UnitOfEnergy, - UnitOfFrequency, - UnitOfPower, - UnitOfTemperature, -) - -from .sensor_entity_description import SunWEGSensorEntityDescription - -INVERTER_SENSOR_TYPES: tuple[SunWEGSensorEntityDescription, ...] = ( - SunWEGSensorEntityDescription( - key="inverter_energy_today", - name="Energy today", - api_variable_key="_today_energy", - api_variable_unit="_today_energy_metric", - native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, - device_class=SensorDeviceClass.ENERGY, - state_class=SensorStateClass.TOTAL_INCREASING, - suggested_display_precision=1, - ), - SunWEGSensorEntityDescription( - key="inverter_energy_total", - name="Lifetime energy output", - api_variable_key="_total_energy", - api_variable_unit="_total_energy_metric", - native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, - device_class=SensorDeviceClass.ENERGY, - suggested_display_precision=1, - state_class=SensorStateClass.TOTAL, - never_resets=True, - ), - SunWEGSensorEntityDescription( - key="inverter_frequency", - name="AC frequency", - api_variable_key="_frequency", - native_unit_of_measurement=UnitOfFrequency.HERTZ, - device_class=SensorDeviceClass.FREQUENCY, - suggested_display_precision=1, - ), - SunWEGSensorEntityDescription( - key="inverter_current_wattage", - name="Output power", - api_variable_key="_power", - api_variable_unit="_power_metric", - native_unit_of_measurement=UnitOfPower.WATT, - device_class=SensorDeviceClass.POWER, - state_class=SensorStateClass.MEASUREMENT, - suggested_display_precision=1, - ), - SunWEGSensorEntityDescription( - key="inverter_temperature", - name="Temperature", - api_variable_key="_temperature", - native_unit_of_measurement=UnitOfTemperature.CELSIUS, - device_class=SensorDeviceClass.TEMPERATURE, - icon="mdi:temperature-celsius", - suggested_display_precision=1, - ), - SunWEGSensorEntityDescription( - key="inverter_power_factor", - name="Power Factor", - api_variable_key="_power_factor", - suggested_display_precision=1, - ), -) diff --git a/homeassistant/components/sunweg/sensor/phase.py b/homeassistant/components/sunweg/sensor/phase.py deleted file mode 100644 index d9db6c7c714..00000000000 --- a/homeassistant/components/sunweg/sensor/phase.py +++ /dev/null @@ -1,27 +0,0 @@ -"""SunWEG Sensor definitions for the Phase type.""" - -from __future__ import annotations - -from homeassistant.components.sensor import SensorDeviceClass -from homeassistant.const import UnitOfElectricCurrent, UnitOfElectricPotential - -from .sensor_entity_description import SunWEGSensorEntityDescription - -PHASE_SENSOR_TYPES: tuple[SunWEGSensorEntityDescription, ...] = ( - SunWEGSensorEntityDescription( - key="voltage", - name="Voltage", - api_variable_key="_voltage", - native_unit_of_measurement=UnitOfElectricPotential.VOLT, - device_class=SensorDeviceClass.VOLTAGE, - suggested_display_precision=2, - ), - SunWEGSensorEntityDescription( - key="amperage", - name="Amperage", - api_variable_key="_amperage", - native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, - device_class=SensorDeviceClass.CURRENT, - suggested_display_precision=1, - ), -) diff --git a/homeassistant/components/sunweg/sensor/sensor_entity_description.py b/homeassistant/components/sunweg/sensor/sensor_entity_description.py deleted file mode 100644 index 8c792ab617f..00000000000 --- a/homeassistant/components/sunweg/sensor/sensor_entity_description.py +++ /dev/null @@ -1,24 +0,0 @@ -"""Sensor Entity Description for the SunWEG integration.""" - -from __future__ import annotations - -from dataclasses import dataclass - -from homeassistant.components.sensor import SensorEntityDescription - - -@dataclass(frozen=True) -class SunWEGRequiredKeysMixin: - """Mixin for required keys.""" - - api_variable_key: str - - -@dataclass(frozen=True) -class SunWEGSensorEntityDescription(SensorEntityDescription, SunWEGRequiredKeysMixin): - """Describes SunWEG sensor entity.""" - - api_variable_unit: str | None = None - previous_value_drop_threshold: float | None = None - never_resets: bool = False - icon: str | None = None diff --git a/homeassistant/components/sunweg/sensor/string.py b/homeassistant/components/sunweg/sensor/string.py deleted file mode 100644 index ec59da5d20d..00000000000 --- a/homeassistant/components/sunweg/sensor/string.py +++ /dev/null @@ -1,27 +0,0 @@ -"""SunWEG Sensor definitions for the String type.""" - -from __future__ import annotations - -from homeassistant.components.sensor import SensorDeviceClass -from homeassistant.const import UnitOfElectricCurrent, UnitOfElectricPotential - -from .sensor_entity_description import SunWEGSensorEntityDescription - -STRING_SENSOR_TYPES: tuple[SunWEGSensorEntityDescription, ...] = ( - SunWEGSensorEntityDescription( - key="voltage", - name="Voltage", - api_variable_key="_voltage", - native_unit_of_measurement=UnitOfElectricPotential.VOLT, - device_class=SensorDeviceClass.VOLTAGE, - suggested_display_precision=2, - ), - SunWEGSensorEntityDescription( - key="amperage", - name="Amperage", - api_variable_key="_amperage", - native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, - device_class=SensorDeviceClass.CURRENT, - suggested_display_precision=1, - ), -) diff --git a/homeassistant/components/sunweg/sensor/total.py b/homeassistant/components/sunweg/sensor/total.py deleted file mode 100644 index 2b94446a165..00000000000 --- a/homeassistant/components/sunweg/sensor/total.py +++ /dev/null @@ -1,50 +0,0 @@ -"""SunWEG Sensor definitions for Totals.""" - -from __future__ import annotations - -from homeassistant.components.sensor import SensorDeviceClass, SensorStateClass -from homeassistant.const import UnitOfEnergy, UnitOfPower - -from .sensor_entity_description import SunWEGSensorEntityDescription - -TOTAL_SENSOR_TYPES: tuple[SunWEGSensorEntityDescription, ...] = ( - SunWEGSensorEntityDescription( - key="total_money_total", - name="Money lifetime", - api_variable_key="_saving", - icon="mdi:cash", - native_unit_of_measurement="R$", - suggested_display_precision=2, - ), - SunWEGSensorEntityDescription( - key="total_energy_today", - name="Energy Today", - api_variable_key="_today_energy", - api_variable_unit="_today_energy_metric", - native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, - device_class=SensorDeviceClass.ENERGY, - state_class=SensorStateClass.TOTAL_INCREASING, - ), - SunWEGSensorEntityDescription( - key="total_output_power", - name="Output Power", - api_variable_key="_total_power", - native_unit_of_measurement=UnitOfPower.KILO_WATT, - device_class=SensorDeviceClass.POWER, - ), - SunWEGSensorEntityDescription( - key="total_energy_output", - name="Lifetime energy output", - api_variable_key="_total_energy", - native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, - device_class=SensorDeviceClass.ENERGY, - state_class=SensorStateClass.TOTAL, - never_resets=True, - ), - SunWEGSensorEntityDescription( - key="last_update", - name="Last Update", - api_variable_key="_last_update", - device_class=SensorDeviceClass.DATE, - ), -) diff --git a/homeassistant/components/sunweg/strings.json b/homeassistant/components/sunweg/strings.json index 9ab7be053b1..75abf5d9271 100644 --- a/homeassistant/components/sunweg/strings.json +++ b/homeassistant/components/sunweg/strings.json @@ -1,35 +1,8 @@ { - "config": { - "abort": { - "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", - "no_plants": "No plants have been found on this account", - "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]" - }, - "error": { - "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", - "timeout_connect": "[%key:common::config_flow::error::timeout_connect%]" - }, - "step": { - "plant": { - "data": { - "plant_id": "Plant" - }, - "title": "Select your plant" - }, - "user": { - "data": { - "password": "[%key:common::config_flow::data::password%]", - "username": "[%key:common::config_flow::data::username%]" - }, - "title": "Enter your Sun WEG information" - }, - "reauth_confirm": { - "data": { - "password": "[%key:common::config_flow::data::password%]", - "username": "[%key:common::config_flow::data::username%]" - }, - "title": "[%key:common::config_flow::title::reauth%]" - } + "issues": { + "integration_removed": { + "title": "The SunWEG integration has been removed", + "description": "The SunWEG integration has been removed from Home Assistant.\n\nThe library that Home Assistant uses to connect with SunWEG services, [doesn't work as expected anymore, demanding daily token renew]({issue}).\n\nTo resolve this issue, please remove the (now defunct) integration entries from your Home Assistant setup. [Click here to see your existing SunWEG integration entries]({entries})." } } } diff --git a/pyproject.toml b/pyproject.toml index 50fd8770f0d..a542ac26f20 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -461,12 +461,10 @@ filterwarnings = [ # Modify app state for testing "ignore:Changing state of started or joined application is deprecated:DeprecationWarning:tests.components.http.test_ban", - # -- Tests - # Ignore custom pytest marks - "ignore:Unknown pytest.mark.disable_autouse_fixture:pytest.PytestUnknownMarkWarning:tests.components.met", - "ignore:Unknown pytest.mark.dataset:pytest.PytestUnknownMarkWarning:tests.components.screenlogic", - # https://github.com/rokam/sunweg/blob/3.1.0/sunweg/plant.py#L96 - v3.1.0 - 2024-10-02 - "ignore:The '(kwh_per_kwp|performance_rate)' property is deprecated and will return 0:DeprecationWarning:tests.components.sunweg.test_init", + # -- Tests + # Ignore custom pytest marks + "ignore:Unknown pytest.mark.disable_autouse_fixture:pytest.PytestUnknownMarkWarning:tests.components.met", + "ignore:Unknown pytest.mark.dataset:pytest.PytestUnknownMarkWarning:tests.components.screenlogic", # -- design choice 3rd party # https://github.com/gwww/elkm1/blob/2.2.10/elkm1_lib/util.py#L8-L19 diff --git a/requirements_all.txt b/requirements_all.txt index b6d8d3c3e63..356066b937c 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2830,9 +2830,6 @@ stringcase==1.2.0 # homeassistant.components.subaru subarulink==0.7.13 -# homeassistant.components.sunweg -sunweg==3.0.2 - # homeassistant.components.surepetcare surepy==0.9.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index c4813981784..7c030c3e6da 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2289,9 +2289,6 @@ stringcase==1.2.0 # homeassistant.components.subaru subarulink==0.7.13 -# homeassistant.components.sunweg -sunweg==3.0.2 - # homeassistant.components.surepetcare surepy==0.9.0 diff --git a/tests/components/analytics_insights/fixtures/current_data.json b/tests/components/analytics_insights/fixtures/current_data.json index c652a8c0154..ff1baca49ed 100644 --- a/tests/components/analytics_insights/fixtures/current_data.json +++ b/tests/components/analytics_insights/fixtures/current_data.json @@ -1050,7 +1050,6 @@ "melnor": 42, "plaato": 45, "freedompro": 26, - "sunweg": 3, "logi_circle": 18, "proxy": 16, "statsd": 4, diff --git a/tests/components/sunweg/__init__.py b/tests/components/sunweg/__init__.py index 1453483a3fd..d9dac10eeb6 100644 --- a/tests/components/sunweg/__init__.py +++ b/tests/components/sunweg/__init__.py @@ -1 +1 @@ -"""Tests for the sunweg component.""" +"""Tests for the Sun WEG integration.""" diff --git a/tests/components/sunweg/common.py b/tests/components/sunweg/common.py deleted file mode 100644 index 096113f6609..00000000000 --- a/tests/components/sunweg/common.py +++ /dev/null @@ -1,22 +0,0 @@ -"""Common functions needed to setup tests for Sun WEG.""" - -from homeassistant.components.sunweg.const import CONF_PLANT_ID, DOMAIN -from homeassistant.const import CONF_NAME, CONF_PASSWORD, CONF_USERNAME - -from tests.common import MockConfigEntry - -SUNWEG_USER_INPUT = { - CONF_USERNAME: "username", - CONF_PASSWORD: "password", -} - -SUNWEG_MOCK_ENTRY = MockConfigEntry( - domain=DOMAIN, - unique_id=0, - data={ - CONF_USERNAME: "user@email.com", - CONF_PASSWORD: "password", - CONF_PLANT_ID: 0, - CONF_NAME: "Name", - }, -) diff --git a/tests/components/sunweg/conftest.py b/tests/components/sunweg/conftest.py deleted file mode 100644 index db94b9cc5c8..00000000000 --- a/tests/components/sunweg/conftest.py +++ /dev/null @@ -1,90 +0,0 @@ -"""Conftest for SunWEG tests.""" - -from datetime import datetime - -import pytest -from sunweg.device import MPPT, Inverter, Phase, String -from sunweg.plant import Plant - - -@pytest.fixture -def string_fixture() -> String: - """Define String fixture.""" - return String("STR1", 450.3, 23.4, 0) - - -@pytest.fixture -def mppt_fixture(string_fixture) -> MPPT: - """Define MPPT fixture.""" - mppt = MPPT("mppt") - mppt.strings.append(string_fixture) - return mppt - - -@pytest.fixture -def phase_fixture() -> Phase: - """Define Phase fixture.""" - return Phase("PhaseA", 120.0, 3.2, 0, 0) - - -@pytest.fixture -def inverter_fixture(phase_fixture, mppt_fixture) -> Inverter: - """Define inverter fixture.""" - inverter = Inverter( - 21255, - "INVERSOR01", - "J63T233018RE074", - 23.2, - 0.0, - 0.0, - "MWh", - 0, - "kWh", - 0.0, - 1, - 0, - "kW", - ) - inverter.phases.append(phase_fixture) - inverter.mppts.append(mppt_fixture) - return inverter - - -@pytest.fixture -def plant_fixture(inverter_fixture) -> Plant: - """Define Plant fixture.""" - plant = Plant( - 123456, - "Plant #123", - 29.5, - 0.5, - 0, - 12.786912, - 24.0, - "kWh", - 332.2, - 0.012296, - datetime(2023, 2, 16, 14, 22, 37), - ) - plant.inverters.append(inverter_fixture) - return plant - - -@pytest.fixture -def plant_fixture_alternative(inverter_fixture) -> Plant: - """Define Plant fixture.""" - plant = Plant( - 123456, - "Plant #123", - 29.5, - 0.5, - 0, - 12.786912, - 24.0, - "kWh", - 332.2, - 0.012296, - None, - ) - plant.inverters.append(inverter_fixture) - return plant diff --git a/tests/components/sunweg/test_config_flow.py b/tests/components/sunweg/test_config_flow.py deleted file mode 100644 index 8103003d7fb..00000000000 --- a/tests/components/sunweg/test_config_flow.py +++ /dev/null @@ -1,223 +0,0 @@ -"""Tests for the Sun WEG server config flow.""" - -from unittest.mock import patch - -from sunweg.api import APIHelper, SunWegApiError - -from homeassistant import config_entries -from homeassistant.components.sunweg.const import CONF_PLANT_ID, DOMAIN -from homeassistant.const import CONF_PASSWORD, CONF_USERNAME -from homeassistant.core import HomeAssistant -from homeassistant.data_entry_flow import FlowResultType - -from .common import SUNWEG_MOCK_ENTRY, SUNWEG_USER_INPUT - -from tests.common import MockConfigEntry - - -async def test_show_authenticate_form(hass: HomeAssistant) -> None: - """Test that the setup form is served.""" - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_USER} - ) - - assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "user" - - -async def test_incorrect_login(hass: HomeAssistant) -> None: - """Test that it shows the appropriate error when an incorrect username/password/server is entered.""" - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_USER} - ) - - with patch.object(APIHelper, "authenticate", return_value=False): - result = await hass.config_entries.flow.async_configure( - result["flow_id"], SUNWEG_USER_INPUT - ) - - assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "user" - assert result["errors"] == {"base": "invalid_auth"} - - -async def test_server_unavailable(hass: HomeAssistant) -> None: - """Test when the SunWEG server don't respond.""" - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_USER} - ) - - with patch.object( - APIHelper, "authenticate", side_effect=SunWegApiError("Internal Server Error") - ): - result = await hass.config_entries.flow.async_configure( - result["flow_id"], SUNWEG_USER_INPUT - ) - - assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "user" - assert result["errors"] == {"base": "timeout_connect"} - - -async def test_reauth(hass: HomeAssistant, plant_fixture, inverter_fixture) -> None: - """Test reauth flow.""" - mock_entry = SUNWEG_MOCK_ENTRY - mock_entry.add_to_hass(hass) - - entries = hass.config_entries.async_entries() - assert len(entries) == 1 - assert entries[0].data[CONF_USERNAME] == SUNWEG_MOCK_ENTRY.data[CONF_USERNAME] - assert entries[0].data[CONF_PASSWORD] == SUNWEG_MOCK_ENTRY.data[CONF_PASSWORD] - - result = await mock_entry.start_reauth_flow(hass) - - assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "reauth_confirm" - - with patch.object(APIHelper, "authenticate", return_value=False): - result = await hass.config_entries.flow.async_configure( - result["flow_id"], - user_input=SUNWEG_USER_INPUT, - ) - - assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "reauth_confirm" - assert result["errors"] == {"base": "invalid_auth"} - - with patch.object( - APIHelper, "authenticate", side_effect=SunWegApiError("Internal Server Error") - ): - result = await hass.config_entries.flow.async_configure( - result["flow_id"], - user_input=SUNWEG_USER_INPUT, - ) - - assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "reauth_confirm" - assert result["errors"] == {"base": "timeout_connect"} - - with ( - patch.object(APIHelper, "authenticate", return_value=True), - patch.object(APIHelper, "listPlants", return_value=[plant_fixture]), - patch.object(APIHelper, "plant", return_value=plant_fixture), - patch.object(APIHelper, "inverter", return_value=inverter_fixture), - patch.object(APIHelper, "complete_inverter"), - ): - result = await hass.config_entries.flow.async_configure( - result["flow_id"], - user_input=SUNWEG_USER_INPUT, - ) - await hass.async_block_till_done() - - assert result["type"] is FlowResultType.ABORT - assert result["reason"] == "reauth_successful" - - entries = hass.config_entries.async_entries() - - assert len(entries) == 1 - assert entries[0].data[CONF_USERNAME] == SUNWEG_USER_INPUT[CONF_USERNAME] - assert entries[0].data[CONF_PASSWORD] == SUNWEG_USER_INPUT[CONF_PASSWORD] - - -async def test_no_plants_on_account(hass: HomeAssistant) -> None: - """Test registering an integration with wrong auth then with no plants available.""" - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_USER} - ) - - with patch.object(APIHelper, "authenticate", return_value=False): - result = await hass.config_entries.flow.async_configure( - result["flow_id"], SUNWEG_USER_INPUT - ) - - assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "user" - assert result["errors"] == {"base": "invalid_auth"} - - with ( - patch.object(APIHelper, "authenticate", return_value=True), - patch.object(APIHelper, "listPlants", return_value=[]), - ): - result = await hass.config_entries.flow.async_configure( - result["flow_id"], SUNWEG_USER_INPUT - ) - - assert result["type"] is FlowResultType.ABORT - assert result["reason"] == "no_plants" - - -async def test_multiple_plant_ids(hass: HomeAssistant, plant_fixture) -> None: - """Test registering an integration and finishing flow with an selected plant_id.""" - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_USER} - ) - - with ( - patch.object(APIHelper, "authenticate", return_value=True), - patch.object( - APIHelper, "listPlants", return_value=[plant_fixture, plant_fixture] - ), - ): - result = await hass.config_entries.flow.async_configure( - result["flow_id"], SUNWEG_USER_INPUT - ) - assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "plant" - - result = await hass.config_entries.flow.async_configure( - result["flow_id"], {CONF_PLANT_ID: 123456} - ) - await hass.async_block_till_done() - - assert result["type"] is FlowResultType.CREATE_ENTRY - assert result["data"][CONF_USERNAME] == SUNWEG_USER_INPUT[CONF_USERNAME] - assert result["data"][CONF_PASSWORD] == SUNWEG_USER_INPUT[CONF_PASSWORD] - assert result["data"][CONF_PLANT_ID] == 123456 - - -async def test_one_plant_on_account(hass: HomeAssistant, plant_fixture) -> None: - """Test registering an integration and finishing flow with current plant_id.""" - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_USER} - ) - - with ( - patch.object(APIHelper, "authenticate", return_value=True), - patch.object( - APIHelper, - "listPlants", - return_value=[plant_fixture], - ), - ): - result = await hass.config_entries.flow.async_configure( - result["flow_id"], SUNWEG_USER_INPUT - ) - - assert result["type"] is FlowResultType.CREATE_ENTRY - assert result["data"][CONF_USERNAME] == SUNWEG_USER_INPUT[CONF_USERNAME] - assert result["data"][CONF_PASSWORD] == SUNWEG_USER_INPUT[CONF_PASSWORD] - assert result["data"][CONF_PLANT_ID] == 123456 - - -async def test_existing_plant_configured(hass: HomeAssistant, plant_fixture) -> None: - """Test entering an existing plant_id.""" - entry = MockConfigEntry(domain=DOMAIN, unique_id=123456) - entry.add_to_hass(hass) - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_USER} - ) - - with ( - patch.object(APIHelper, "authenticate", return_value=True), - patch.object( - APIHelper, - "listPlants", - return_value=[plant_fixture], - ), - ): - result = await hass.config_entries.flow.async_configure( - result["flow_id"], SUNWEG_USER_INPUT - ) - - assert result["type"] is FlowResultType.ABORT - assert result["reason"] == "already_configured" diff --git a/tests/components/sunweg/test_init.py b/tests/components/sunweg/test_init.py index 6cbe38a128b..964b48aebcb 100644 --- a/tests/components/sunweg/test_init.py +++ b/tests/components/sunweg/test_init.py @@ -1,209 +1,79 @@ -"""Tests for the Sun WEG init.""" +"""Tests for the Sun WEG integration.""" -import json -from unittest.mock import MagicMock, patch - -from sunweg.api import APIHelper, SunWegApiError - -from homeassistant.components.sunweg import SunWEGData -from homeassistant.components.sunweg.const import DOMAIN, DeviceType -from homeassistant.components.sunweg.sensor.sensor_entity_description import ( - SunWEGSensorEntityDescription, +from homeassistant.components.sunweg import DOMAIN +from homeassistant.config_entries import ( + SOURCE_IGNORE, + ConfigEntryDisabler, + ConfigEntryState, ) -from homeassistant.config_entries import ConfigEntryState from homeassistant.core import HomeAssistant -from homeassistant.setup import async_setup_component +from homeassistant.helpers import issue_registry as ir -from .common import SUNWEG_MOCK_ENTRY +from tests.common import MockConfigEntry -async def test_methods(hass: HomeAssistant, plant_fixture, inverter_fixture) -> None: - """Test methods.""" - mock_entry = SUNWEG_MOCK_ENTRY - mock_entry.add_to_hass(hass) - - with ( - patch.object(APIHelper, "authenticate", return_value=True), - patch.object(APIHelper, "listPlants", return_value=[plant_fixture]), - patch.object(APIHelper, "plant", return_value=plant_fixture), - patch.object(APIHelper, "inverter", return_value=inverter_fixture), - patch.object(APIHelper, "complete_inverter"), - ): - assert await async_setup_component(hass, DOMAIN, mock_entry.data) - await hass.async_block_till_done() - assert await hass.config_entries.async_unload(mock_entry.entry_id) - - -async def test_setup_wrongpass(hass: HomeAssistant) -> None: - """Test setup with wrong pass.""" - mock_entry = SUNWEG_MOCK_ENTRY - mock_entry.add_to_hass(hass) - with patch.object(APIHelper, "authenticate", return_value=False): - assert await async_setup_component(hass, DOMAIN, mock_entry.data) - await hass.async_block_till_done() - - -async def test_setup_error_500(hass: HomeAssistant) -> None: - """Test setup with wrong pass.""" - mock_entry = SUNWEG_MOCK_ENTRY - mock_entry.add_to_hass(hass) - with patch.object( - APIHelper, "authenticate", side_effect=SunWegApiError("Error 500") - ): - assert await async_setup_component(hass, DOMAIN, mock_entry.data) - await hass.async_block_till_done() - - -async def test_sunwegdata_update_exception() -> None: - """Test SunWEGData exception on update.""" - api = MagicMock() - api.plant = MagicMock(side_effect=json.decoder.JSONDecodeError("Message", "Doc", 1)) - data = SunWEGData(api, 0) - data.update() - assert data.data is None - - -async def test_sunwegdata_update_success(plant_fixture) -> None: - """Test SunWEGData success on update.""" - api = MagicMock() - api.plant = MagicMock(return_value=plant_fixture) - api.complete_inverter = MagicMock() - data = SunWEGData(api, 0) - data.update() - assert data.data.id == plant_fixture.id - assert data.data.name == plant_fixture.name - assert data.data.kwh_per_kwp == plant_fixture.kwh_per_kwp - assert data.data.last_update == plant_fixture.last_update - assert data.data.performance_rate == plant_fixture.performance_rate - assert data.data.saving == plant_fixture.saving - assert len(data.data.inverters) == 1 - - -async def test_sunwegdata_update_success_alternative(plant_fixture_alternative) -> None: - """Test SunWEGData success on update.""" - api = MagicMock() - api.plant = MagicMock(return_value=plant_fixture_alternative) - api.complete_inverter = MagicMock() - data = SunWEGData(api, 0) - data.update() - assert data.data.id == plant_fixture_alternative.id - assert data.data.name == plant_fixture_alternative.name - assert data.data.kwh_per_kwp == plant_fixture_alternative.kwh_per_kwp - assert data.data.last_update == plant_fixture_alternative.last_update - assert data.data.performance_rate == plant_fixture_alternative.performance_rate - assert data.data.saving == plant_fixture_alternative.saving - assert len(data.data.inverters) == 1 - - -async def test_sunwegdata_get_api_value_none(plant_fixture) -> None: - """Test SunWEGData none return on get_api_value.""" - api = MagicMock() - data = SunWEGData(api, 123456) - data.data = plant_fixture - assert data.get_api_value("variable", DeviceType.INVERTER, 0, "deep_name") is None - assert data.get_api_value("variable", DeviceType.STRING, 21255, "deep_name") is None - - -async def test_sunwegdata_get_data_drop_threshold() -> None: - """Test SunWEGData get_data with drop threshold.""" - api = MagicMock() - data = SunWEGData(api, 123456) - data.get_api_value = MagicMock() - entity_description = SunWEGSensorEntityDescription( - api_variable_key="variable", key="key", previous_value_drop_threshold=0.1 +async def test_sunweg_repair_issue( + hass: HomeAssistant, issue_registry: ir.IssueRegistry +) -> None: + """Test the Sun WEG configuration entry loading/unloading handles the repair.""" + config_entry_1 = MockConfigEntry( + title="Example 1", + domain=DOMAIN, ) - data.get_api_value.return_value = 3.0 - assert data.get_data( - api_variable_key=entity_description.api_variable_key, - api_variable_unit=entity_description.api_variable_unit, - deep_name=None, - device_type=DeviceType.TOTAL, - inverter_id=0, - name=entity_description.name, - native_unit_of_measurement=entity_description.native_unit_of_measurement, - never_resets=entity_description.never_resets, - previous_value_drop_threshold=entity_description.previous_value_drop_threshold, - ) == (3.0, None) - data.get_api_value.return_value = 2.91 - assert data.get_data( - api_variable_key=entity_description.api_variable_key, - api_variable_unit=entity_description.api_variable_unit, - deep_name=None, - device_type=DeviceType.TOTAL, - inverter_id=0, - name=entity_description.name, - native_unit_of_measurement=entity_description.native_unit_of_measurement, - never_resets=entity_description.never_resets, - previous_value_drop_threshold=entity_description.previous_value_drop_threshold, - ) == (3.0, None) - data.get_api_value.return_value = 2.8 - assert data.get_data( - api_variable_key=entity_description.api_variable_key, - api_variable_unit=entity_description.api_variable_unit, - deep_name=None, - device_type=DeviceType.TOTAL, - inverter_id=0, - name=entity_description.name, - native_unit_of_measurement=entity_description.native_unit_of_measurement, - never_resets=entity_description.never_resets, - previous_value_drop_threshold=entity_description.previous_value_drop_threshold, - ) == (2.8, None) + config_entry_1.add_to_hass(hass) + await hass.config_entries.async_setup(config_entry_1.entry_id) + await hass.async_block_till_done() + assert config_entry_1.state is ConfigEntryState.LOADED - -async def test_sunwegdata_get_data_never_reset() -> None: - """Test SunWEGData get_data with never reset.""" - api = MagicMock() - data = SunWEGData(api, 123456) - data.get_api_value = MagicMock() - entity_description = SunWEGSensorEntityDescription( - api_variable_key="variable", key="key", never_resets=True + # Add a second one + config_entry_2 = MockConfigEntry( + title="Example 2", + domain=DOMAIN, ) - data.get_api_value.return_value = 3.0 - assert data.get_data( - api_variable_key=entity_description.api_variable_key, - api_variable_unit=entity_description.api_variable_unit, - deep_name=None, - device_type=DeviceType.TOTAL, - inverter_id=0, - name=entity_description.name, - native_unit_of_measurement=entity_description.native_unit_of_measurement, - never_resets=entity_description.never_resets, - previous_value_drop_threshold=entity_description.previous_value_drop_threshold, - ) == (3.0, None) - data.get_api_value.return_value = 0 - assert data.get_data( - api_variable_key=entity_description.api_variable_key, - api_variable_unit=entity_description.api_variable_unit, - deep_name=None, - device_type=DeviceType.TOTAL, - inverter_id=0, - name=entity_description.name, - native_unit_of_measurement=entity_description.native_unit_of_measurement, - never_resets=entity_description.never_resets, - previous_value_drop_threshold=entity_description.previous_value_drop_threshold, - ) == (3.0, None) - data.get_api_value.return_value = 2.8 - assert data.get_data( - api_variable_key=entity_description.api_variable_key, - api_variable_unit=entity_description.api_variable_unit, - deep_name=None, - device_type=DeviceType.TOTAL, - inverter_id=0, - name=entity_description.name, - native_unit_of_measurement=entity_description.native_unit_of_measurement, - never_resets=entity_description.never_resets, - previous_value_drop_threshold=entity_description.previous_value_drop_threshold, - ) == (2.8, None) + config_entry_2.add_to_hass(hass) + await hass.config_entries.async_setup(config_entry_2.entry_id) + await hass.async_block_till_done() + assert config_entry_2.state is ConfigEntryState.LOADED + assert issue_registry.async_get_issue(DOMAIN, DOMAIN) -async def test_reauth_started(hass: HomeAssistant) -> None: - """Test reauth flow started.""" - mock_entry = SUNWEG_MOCK_ENTRY - mock_entry.add_to_hass(hass) - with patch.object(APIHelper, "authenticate", return_value=False): - await async_setup_component(hass, DOMAIN, {}) - await hass.async_block_till_done() - assert mock_entry.state is ConfigEntryState.SETUP_ERROR - flows = hass.config_entries.flow.async_progress() - assert len(flows) == 1 - assert flows[0]["step_id"] == "reauth_confirm" + # Add an ignored entry + config_entry_3 = MockConfigEntry( + source=SOURCE_IGNORE, + domain=DOMAIN, + ) + config_entry_3.add_to_hass(hass) + await hass.config_entries.async_setup(config_entry_3.entry_id) + await hass.async_block_till_done() + + assert config_entry_3.state is ConfigEntryState.NOT_LOADED + + # Add a disabled entry + config_entry_4 = MockConfigEntry( + disabled_by=ConfigEntryDisabler.USER, + domain=DOMAIN, + ) + config_entry_4.add_to_hass(hass) + await hass.config_entries.async_setup(config_entry_4.entry_id) + await hass.async_block_till_done() + + assert config_entry_4.state is ConfigEntryState.NOT_LOADED + + # Remove the first one + await hass.config_entries.async_remove(config_entry_1.entry_id) + await hass.async_block_till_done() + + assert config_entry_1.state is ConfigEntryState.NOT_LOADED + assert config_entry_2.state is ConfigEntryState.LOADED + assert issue_registry.async_get_issue(DOMAIN, DOMAIN) + + # Remove the second one + await hass.config_entries.async_remove(config_entry_2.entry_id) + await hass.async_block_till_done() + + assert config_entry_1.state is ConfigEntryState.NOT_LOADED + assert config_entry_2.state is ConfigEntryState.NOT_LOADED + assert issue_registry.async_get_issue(DOMAIN, DOMAIN) is None + + # Check the ignored and disabled entries are removed + assert not hass.config_entries.async_entries(DOMAIN) From 97a0b9272e5ff040d79be42f1df73eb1da76a9ac Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Sun, 30 Mar 2025 19:42:39 +0200 Subject: [PATCH 074/505] Resolve state mismatches in `wolflink` (#141846) --- homeassistant/components/wolflink/strings.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/wolflink/strings.json b/homeassistant/components/wolflink/strings.json index b1c332984a1..1f1eb5e310d 100644 --- a/homeassistant/components/wolflink/strings.json +++ b/homeassistant/components/wolflink/strings.json @@ -28,9 +28,9 @@ "sensor": { "state": { "state": { - "ein": "[%key:common::state::enabled%]", - "deaktiviert": "Inactive", - "aus": "[%key:common::state::disabled%]", + "ein": "[%key:common::state::on%]", + "deaktiviert": "[%key:common::state::disabled%]", + "aus": "[%key:common::state::off%]", "standby": "[%key:common::state::standby%]", "auto": "Auto", "permanent": "Permanent", From 663d0691a780c25c9fa93ec4fae16bcc08966609 Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Sun, 30 Mar 2025 19:49:41 +0200 Subject: [PATCH 075/505] Move setup messages from info to debug level (#141834) move info to debug level --- homeassistant/setup.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/setup.py b/homeassistant/setup.py index 9572136559a..334e3a9e074 100644 --- a/homeassistant/setup.py +++ b/homeassistant/setup.py @@ -387,7 +387,7 @@ async def _async_setup_component( }, ) - _LOGGER.info("Setting up %s", domain) + _LOGGER.debug("Setting up %s", domain) with async_start_setup(hass, integration=domain, phase=SetupPhases.SETUP): if hasattr(component, "PLATFORM_SCHEMA"): @@ -783,7 +783,7 @@ def async_start_setup( # platforms, but we only care about the longest time. group_setup_times[phase] = max(group_setup_times[phase], time_taken) if group is None: - _LOGGER.info( + _LOGGER.debug( "Setup of domain %s took %.2f seconds", integration, time_taken ) elif _LOGGER.isEnabledFor(logging.DEBUG): From 933f42258844b6e71fef633fe6216a27069dc119 Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Sun, 30 Mar 2025 20:00:18 +0200 Subject: [PATCH 076/505] Replace "Disabled" with common state in `lamarzocco` (#141848) --- homeassistant/components/lamarzocco/strings.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/lamarzocco/strings.json b/homeassistant/components/lamarzocco/strings.json index 04853b8d0ca..f087856dbed 100644 --- a/homeassistant/components/lamarzocco/strings.json +++ b/homeassistant/components/lamarzocco/strings.json @@ -146,7 +146,7 @@ "prebrew_infusion_select": { "name": "Prebrew/-infusion mode", "state": { - "disabled": "Disabled", + "disabled": "[%key:common::state::disabled%]", "prebrew": "Prebrew", "prebrew_enabled": "Prebrew", "preinfusion": "Preinfusion" From dc16494332ac2cd1b9631444b0301a3371e0957f Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Sun, 30 Mar 2025 20:12:15 +0200 Subject: [PATCH 077/505] Replace "Disabled" with common state in `schlage`, fix sentence-case (#141849) Replace "Disabled" with common state in `lamarzocco`, fix sentence-case - replace "Disabled" with with common state reference - fix sentence-casing of "Auto-lock" --- homeassistant/components/schlage/strings.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/schlage/strings.json b/homeassistant/components/schlage/strings.json index 56e72c2d2c0..42bd51de9d0 100644 --- a/homeassistant/components/schlage/strings.json +++ b/homeassistant/components/schlage/strings.json @@ -33,9 +33,9 @@ }, "select": { "auto_lock_time": { - "name": "Auto-Lock time", + "name": "Auto-lock time", "state": { - "0": "Disabled", + "0": "[%key:common::state::disabled%]", "15": "15 seconds", "30": "30 seconds", "60": "1 minute", From 95679294846226159527dce7c60f4f59a9c94ea1 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Sun, 30 Mar 2025 20:12:42 +0200 Subject: [PATCH 078/505] Update pvo to v2.2.1 (#141847) --- homeassistant/components/pvoutput/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/pvoutput/manifest.json b/homeassistant/components/pvoutput/manifest.json index 9dbdad53bcb..dee5f9cda6e 100644 --- a/homeassistant/components/pvoutput/manifest.json +++ b/homeassistant/components/pvoutput/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/pvoutput", "integration_type": "device", "iot_class": "cloud_polling", - "requirements": ["pvo==2.2.0"] + "requirements": ["pvo==2.2.1"] } diff --git a/requirements_all.txt b/requirements_all.txt index 356066b937c..59064077c19 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1722,7 +1722,7 @@ pushbullet.py==0.11.0 pushover_complete==1.1.1 # homeassistant.components.pvoutput -pvo==2.2.0 +pvo==2.2.1 # homeassistant.components.aosmith py-aosmith==1.0.12 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 7c030c3e6da..ea200276394 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1421,7 +1421,7 @@ pushbullet.py==0.11.0 pushover_complete==1.1.1 # homeassistant.components.pvoutput -pvo==2.2.0 +pvo==2.2.1 # homeassistant.components.aosmith py-aosmith==1.0.12 From da190ec96f4f5527abd11e2507ad614ba0c06702 Mon Sep 17 00:00:00 2001 From: Bouwe Westerdijk <11290930+bouwew@users.noreply.github.com> Date: Sun, 30 Mar 2025 20:24:13 +0200 Subject: [PATCH 079/505] Bump plugwise to v1.7.3 (#141843) --- homeassistant/components/plugwise/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/plugwise/manifest.json b/homeassistant/components/plugwise/manifest.json index 87878980f2d..3f812c1a63b 100644 --- a/homeassistant/components/plugwise/manifest.json +++ b/homeassistant/components/plugwise/manifest.json @@ -8,6 +8,6 @@ "iot_class": "local_polling", "loggers": ["plugwise"], "quality_scale": "platinum", - "requirements": ["plugwise==1.7.2"], + "requirements": ["plugwise==1.7.3"], "zeroconf": ["_plugwise._tcp.local."] } diff --git a/requirements_all.txt b/requirements_all.txt index 59064077c19..82b48131216 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1675,7 +1675,7 @@ plexauth==0.0.6 plexwebsocket==0.0.14 # homeassistant.components.plugwise -plugwise==1.7.2 +plugwise==1.7.3 # homeassistant.components.plum_lightpad plumlightpad==0.0.11 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index ea200276394..7e942673898 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1386,7 +1386,7 @@ plexauth==0.0.6 plexwebsocket==0.0.14 # homeassistant.components.plugwise -plugwise==1.7.2 +plugwise==1.7.3 # homeassistant.components.plum_lightpad plumlightpad==0.0.11 From 506d485c0d077b4978e6791baa132ac83178c80e Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Sun, 30 Mar 2025 20:31:08 +0200 Subject: [PATCH 080/505] Ensure EcoNet operation modes are unique (#141689) --- homeassistant/components/econet/water_heater.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/econet/water_heater.py b/homeassistant/components/econet/water_heater.py index fb74ae8b4a5..f93ad7f8872 100644 --- a/homeassistant/components/econet/water_heater.py +++ b/homeassistant/components/econet/water_heater.py @@ -91,15 +91,15 @@ class EcoNetWaterHeater(EcoNetEntity[WaterHeater], WaterHeaterEntity): def operation_list(self) -> list[str]: """List of available operation modes.""" econet_modes = self.water_heater.modes - op_list = [] + operation_modes = set() for mode in econet_modes: if ( mode is not WaterHeaterOperationMode.UNKNOWN and mode is not WaterHeaterOperationMode.VACATION ): ha_mode = ECONET_STATE_TO_HA[mode] - op_list.append(ha_mode) - return op_list + operation_modes.add(ha_mode) + return list(operation_modes) @property def supported_features(self) -> WaterHeaterEntityFeature: From 5106548f2c9aafd66e5ca56f94c5a1da2b0916c3 Mon Sep 17 00:00:00 2001 From: Eli Sand Date: Sun, 30 Mar 2025 14:43:13 -0400 Subject: [PATCH 081/505] Fix generic_thermostat so it doesn't turn on when current temp is within target temp range (#138209) * Don't turn on thermostat if temp is equal to target temp. * Update strings to reflect logic change. * Fix logic and add zero tolerance tests. * Include tests for cool mode * Removed unnecessary async_block_till_done calls --- .../components/generic_thermostat/climate.py | 14 ++++-- .../generic_thermostat/strings.json | 2 +- .../generic_thermostat/test_climate.py | 46 +++++++++++++++++++ 3 files changed, 57 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/generic_thermostat/climate.py b/homeassistant/components/generic_thermostat/climate.py index 190caa58b3f..185040f02c9 100644 --- a/homeassistant/components/generic_thermostat/climate.py +++ b/homeassistant/components/generic_thermostat/climate.py @@ -539,10 +539,14 @@ class GenericThermostat(ClimateEntity, RestoreEntity): return assert self._cur_temp is not None and self._target_temp is not None - too_cold = self._target_temp >= self._cur_temp + self._cold_tolerance - too_hot = self._cur_temp >= self._target_temp + self._hot_tolerance + + min_temp = self._target_temp - self._cold_tolerance + max_temp = self._target_temp + self._hot_tolerance + if self._is_device_active: - if (self.ac_mode and too_cold) or (not self.ac_mode and too_hot): + if (self.ac_mode and self._cur_temp <= min_temp) or ( + not self.ac_mode and self._cur_temp >= max_temp + ): _LOGGER.debug("Turning off heater %s", self.heater_entity_id) await self._async_heater_turn_off() elif time is not None: @@ -552,7 +556,9 @@ class GenericThermostat(ClimateEntity, RestoreEntity): self.heater_entity_id, ) await self._async_heater_turn_on() - elif (self.ac_mode and too_hot) or (not self.ac_mode and too_cold): + elif (self.ac_mode and self._cur_temp > max_temp) or ( + not self.ac_mode and self._cur_temp < min_temp + ): _LOGGER.debug("Turning on heater %s", self.heater_entity_id) await self._async_heater_turn_on() elif time is not None: diff --git a/homeassistant/components/generic_thermostat/strings.json b/homeassistant/components/generic_thermostat/strings.json index 58280e99543..9b88d590eea 100644 --- a/homeassistant/components/generic_thermostat/strings.json +++ b/homeassistant/components/generic_thermostat/strings.json @@ -21,7 +21,7 @@ "heater": "Switch entity used to cool or heat depending on A/C mode.", "target_sensor": "Temperature sensor that reflects the current temperature.", "min_cycle_duration": "Set a minimum amount of time that the switch specified must be in its current state prior to being switched either off or on.", - "cold_tolerance": "Minimum amount of difference between the temperature read by the temperature sensor the target temperature that must change prior to being switched on. For example, if the target temperature is 25 and the tolerance is 0.5 the heater will start when the sensor equals or goes below 24.5.", + "cold_tolerance": "Minimum amount of difference between the temperature read by the temperature sensor the target temperature that must change prior to being switched on. For example, if the target temperature is 25 and the tolerance is 0.5 the heater will start when the sensor goes below 24.5.", "hot_tolerance": "Minimum amount of difference between the temperature read by the temperature sensor the target temperature that must change prior to being switched off. For example, if the target temperature is 25 and the tolerance is 0.5 the heater will stop when the sensor equals or goes above 25.5." } }, diff --git a/tests/components/generic_thermostat/test_climate.py b/tests/components/generic_thermostat/test_climate.py index 7e2e92f025b..65be83bad20 100644 --- a/tests/components/generic_thermostat/test_climate.py +++ b/tests/components/generic_thermostat/test_climate.py @@ -1119,6 +1119,52 @@ async def test_precision(hass: HomeAssistant) -> None: assert state.attributes.get("target_temp_step") == 0.1 +@pytest.fixture( + params=[ + HVACMode.HEAT, + HVACMode.COOL, + ] +) +async def setup_comp_10(hass: HomeAssistant, request: pytest.FixtureRequest) -> None: + """Initialize components.""" + assert await async_setup_component( + hass, + CLIMATE_DOMAIN, + { + "climate": { + "platform": "generic_thermostat", + "name": "test", + "cold_tolerance": 0, + "hot_tolerance": 0, + "target_temp": 25, + "heater": ENT_SWITCH, + "target_sensor": ENT_SENSOR, + "initial_hvac_mode": request.param, + } + }, + ) + await hass.async_block_till_done() + + +@pytest.mark.usefixtures("setup_comp_10") +async def test_zero_tolerances(hass: HomeAssistant) -> None: + """Test that having a zero tolerance doesn't cause the switch to flip-flop.""" + + # if the switch is off, it should remain off + calls = _setup_switch(hass, False) + _setup_sensor(hass, 25) + await hass.async_block_till_done() + await common.async_set_temperature(hass, 25) + assert len(calls) == 0 + + # if the switch is on, it should turn off + calls = _setup_switch(hass, True) + _setup_sensor(hass, 25) + await hass.async_block_till_done() + await common.async_set_temperature(hass, 25) + assert len(calls) == 1 + + async def test_custom_setup_params(hass: HomeAssistant) -> None: """Test the setup with custom parameters.""" result = await async_setup_component( From 9c869fa701d181a1bc27101c2c5623d7d5d246d4 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Sun, 30 Mar 2025 20:58:40 +0200 Subject: [PATCH 082/505] Add a coordinator to Point (#126775) * Add a coordinator to Point * Fix * Fix * Fix * Fix * Fix * Fix --- homeassistant/components/point/__init__.py | 110 ++---------------- .../components/point/alarm_control_panel.py | 47 +++----- .../components/point/binary_sensor.py | 72 +++++------- homeassistant/components/point/coordinator.py | 70 +++++++++++ homeassistant/components/point/entity.py | 77 ++++-------- homeassistant/components/point/sensor.py | 53 +++++---- 6 files changed, 179 insertions(+), 250 deletions(-) create mode 100644 homeassistant/components/point/coordinator.py diff --git a/homeassistant/components/point/__init__.py b/homeassistant/components/point/__init__.py index e446606f191..0f90bd75c9d 100644 --- a/homeassistant/components/point/__init__.py +++ b/homeassistant/components/point/__init__.py @@ -1,7 +1,5 @@ """Support for Minut Point.""" -import asyncio -from dataclasses import dataclass from http import HTTPStatus import logging @@ -29,26 +27,18 @@ from homeassistant.helpers import ( config_validation as cv, ) from homeassistant.helpers.dispatcher import async_dispatcher_send -from homeassistant.helpers.event import async_track_time_interval from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue from homeassistant.helpers.typing import ConfigType from . import api -from .const import ( - CONF_WEBHOOK_URL, - DOMAIN, - EVENT_RECEIVED, - POINT_DISCOVERY_NEW, - SCAN_INTERVAL, - SIGNAL_UPDATE_ENTITY, - SIGNAL_WEBHOOK, -) +from .const import CONF_WEBHOOK_URL, DOMAIN, EVENT_RECEIVED, SIGNAL_WEBHOOK +from .coordinator import PointDataUpdateCoordinator _LOGGER = logging.getLogger(__name__) PLATFORMS = [Platform.BINARY_SENSOR, Platform.SENSOR] -type PointConfigEntry = ConfigEntry[PointData] +type PointConfigEntry = ConfigEntry[PointDataUpdateCoordinator] CONFIG_SCHEMA = vol.Schema( { @@ -131,9 +121,11 @@ async def async_setup_entry(hass: HomeAssistant, entry: PointConfigEntry) -> boo point_session = PointSession(auth) - client = MinutPointClient(hass, entry, point_session) - hass.async_create_task(client.update()) - entry.runtime_data = PointData(client) + coordinator = PointDataUpdateCoordinator(hass, point_session) + + await coordinator.async_config_entry_first_refresh() + + entry.runtime_data = coordinator await async_setup_webhook(hass, entry, point_session) await hass.config_entries.async_forward_entry_setups( @@ -176,7 +168,7 @@ async def async_unload_entry(hass: HomeAssistant, entry: PointConfigEntry) -> bo if unload_ok := await hass.config_entries.async_unload_platforms( entry, [*PLATFORMS, Platform.ALARM_CONTROL_PANEL] ): - session: PointSession = entry.runtime_data.client + session = entry.runtime_data.point if CONF_WEBHOOK_ID in entry.data: webhook.async_unregister(hass, entry.data[CONF_WEBHOOK_ID]) await session.remove_webhook() @@ -197,87 +189,3 @@ async def handle_webhook( data["webhook_id"] = webhook_id async_dispatcher_send(hass, SIGNAL_WEBHOOK, data, data.get("hook_id")) hass.bus.async_fire(EVENT_RECEIVED, data) - - -class MinutPointClient: - """Get the latest data and update the states.""" - - def __init__( - self, hass: HomeAssistant, config_entry: ConfigEntry, session: PointSession - ) -> None: - """Initialize the Minut data object.""" - self._known_devices: set[str] = set() - self._known_homes: set[str] = set() - self._hass = hass - self._config_entry = config_entry - self._is_available = True - self._client = session - - async_track_time_interval(self._hass, self.update, SCAN_INTERVAL) - - async def update(self, *args): - """Periodically poll the cloud for current state.""" - await self._sync() - - async def _sync(self): - """Update local list of devices.""" - if not await self._client.update(): - self._is_available = False - _LOGGER.warning("Device is unavailable") - async_dispatcher_send(self._hass, SIGNAL_UPDATE_ENTITY) - return - - self._is_available = True - for home_id in self._client.homes: - if home_id not in self._known_homes: - async_dispatcher_send( - self._hass, - POINT_DISCOVERY_NEW.format(Platform.ALARM_CONTROL_PANEL), - home_id, - ) - self._known_homes.add(home_id) - for device in self._client.devices: - if device.device_id not in self._known_devices: - for platform in PLATFORMS: - async_dispatcher_send( - self._hass, - POINT_DISCOVERY_NEW.format(platform), - device.device_id, - ) - self._known_devices.add(device.device_id) - async_dispatcher_send(self._hass, SIGNAL_UPDATE_ENTITY) - - def device(self, device_id): - """Return device representation.""" - return self._client.device(device_id) - - def is_available(self, device_id): - """Return device availability.""" - if not self._is_available: - return False - return device_id in self._client.device_ids - - async def remove_webhook(self): - """Remove the session webhook.""" - return await self._client.remove_webhook() - - @property - def homes(self): - """Return known homes.""" - return self._client.homes - - async def async_alarm_disarm(self, home_id): - """Send alarm disarm command.""" - return await self._client.alarm_disarm(home_id) - - async def async_alarm_arm(self, home_id): - """Send alarm arm command.""" - return await self._client.alarm_arm(home_id) - - -@dataclass -class PointData: - """Point Data.""" - - client: MinutPointClient - entry_lock: asyncio.Lock = asyncio.Lock() diff --git a/homeassistant/components/point/alarm_control_panel.py b/homeassistant/components/point/alarm_control_panel.py index 0f501d2ee09..fa56bf70546 100644 --- a/homeassistant/components/point/alarm_control_panel.py +++ b/homeassistant/components/point/alarm_control_panel.py @@ -2,23 +2,22 @@ from __future__ import annotations -from collections.abc import Callable import logging +from pypoint import PointSession + from homeassistant.components.alarm_control_panel import ( - DOMAIN as ALARM_CONTROL_PANEL_DOMAIN, AlarmControlPanelEntity, AlarmControlPanelEntityFeature, AlarmControlPanelState, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from . import MinutPointClient -from .const import DOMAIN as POINT_DOMAIN, POINT_DISCOVERY_NEW, SIGNAL_WEBHOOK +from . import PointConfigEntry +from .const import DOMAIN as POINT_DOMAIN, SIGNAL_WEBHOOK _LOGGER = logging.getLogger(__name__) @@ -32,21 +31,20 @@ EVENT_MAP = { async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: PointConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up a Point's alarm_control_panel based on a config entry.""" + coordinator = config_entry.runtime_data - async def async_discover_home(home_id): + def async_discover_home(home_id: str) -> None: """Discover and add a discovered home.""" - client = config_entry.runtime_data.client - async_add_entities([MinutPointAlarmControl(client, home_id)], True) + async_add_entities([MinutPointAlarmControl(coordinator.point, home_id)]) - async_dispatcher_connect( - hass, - POINT_DISCOVERY_NEW.format(ALARM_CONTROL_PANEL_DOMAIN, POINT_DOMAIN), - async_discover_home, - ) + coordinator.new_home_callback = async_discover_home + + for home_id in coordinator.point.homes: + async_discover_home(home_id) class MinutPointAlarmControl(AlarmControlPanelEntity): @@ -55,12 +53,11 @@ class MinutPointAlarmControl(AlarmControlPanelEntity): _attr_supported_features = AlarmControlPanelEntityFeature.ARM_AWAY _attr_code_arm_required = False - def __init__(self, point_client: MinutPointClient, home_id: str) -> None: + def __init__(self, point: PointSession, home_id: str) -> None: """Initialize the entity.""" - self._client = point_client + self._client = point self._home_id = home_id - self._async_unsub_hook_dispatcher_connect: Callable[[], None] | None = None - self._home = point_client.homes[self._home_id] + self._home = point.homes[self._home_id] self._attr_name = self._home["name"] self._attr_unique_id = f"point.{home_id}" @@ -73,16 +70,10 @@ class MinutPointAlarmControl(AlarmControlPanelEntity): async def async_added_to_hass(self) -> None: """Call when entity is added to HOme Assistant.""" await super().async_added_to_hass() - self._async_unsub_hook_dispatcher_connect = async_dispatcher_connect( - self.hass, SIGNAL_WEBHOOK, self._webhook_event + self.async_on_remove( + async_dispatcher_connect(self.hass, SIGNAL_WEBHOOK, self._webhook_event) ) - async def async_will_remove_from_hass(self) -> None: - """Disconnect dispatcher listener when removed.""" - await super().async_will_remove_from_hass() - if self._async_unsub_hook_dispatcher_connect: - self._async_unsub_hook_dispatcher_connect() - @callback def _webhook_event(self, data, webhook): """Process new event from the webhook.""" @@ -107,12 +98,12 @@ class MinutPointAlarmControl(AlarmControlPanelEntity): async def async_alarm_disarm(self, code: str | None = None) -> None: """Send disarm command.""" - status = await self._client.async_alarm_disarm(self._home_id) + status = await self._client.alarm_disarm(self._home_id) if status: self._home["alarm_status"] = "off" async def async_alarm_arm_away(self, code: str | None = None) -> None: """Send arm away command.""" - status = await self._client.async_alarm_arm(self._home_id) + status = await self._client.alarm_arm(self._home_id) if status: self._home["alarm_status"] = "on" diff --git a/homeassistant/components/point/binary_sensor.py b/homeassistant/components/point/binary_sensor.py index c9338cb63f2..17fe40b9654 100644 --- a/homeassistant/components/point/binary_sensor.py +++ b/homeassistant/components/point/binary_sensor.py @@ -3,26 +3,27 @@ from __future__ import annotations import logging +from typing import Any from pypoint import EVENTS from homeassistant.components.binary_sensor import ( - DOMAIN as BINARY_SENSOR_DOMAIN, BinarySensorDeviceClass, BinarySensorEntity, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from .const import DOMAIN as POINT_DOMAIN, POINT_DISCOVERY_NEW, SIGNAL_WEBHOOK +from . import PointConfigEntry +from .const import SIGNAL_WEBHOOK +from .coordinator import PointDataUpdateCoordinator from .entity import MinutPointEntity _LOGGER = logging.getLogger(__name__) -DEVICES = { +DEVICES: dict[str, Any] = { "alarm": {"icon": "mdi:alarm-bell"}, "battery": {"device_class": BinarySensorDeviceClass.BATTERY}, "button_press": {"icon": "mdi:gesture-tap-button"}, @@ -42,69 +43,60 @@ DEVICES = { async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: PointConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up a Point's binary sensors based on a config entry.""" - async def async_discover_sensor(device_id): + coordinator = config_entry.runtime_data + + def async_discover_sensor(device_id: str) -> None: """Discover and add a discovered sensor.""" - client = config_entry.runtime_data.client async_add_entities( - ( - MinutPointBinarySensor(client, device_id, device_name) - for device_name in DEVICES - if device_name in EVENTS - ), - True, + MinutPointBinarySensor(coordinator, device_id, device_name) + for device_name in DEVICES + if device_name in EVENTS ) - async_dispatcher_connect( - hass, - POINT_DISCOVERY_NEW.format(BINARY_SENSOR_DOMAIN, POINT_DOMAIN), - async_discover_sensor, + coordinator.new_device_callbacks.append(async_discover_sensor) + + async_add_entities( + MinutPointBinarySensor(coordinator, device_id, device_name) + for device_name in DEVICES + if device_name in EVENTS + for device_id in coordinator.point.device_ids ) class MinutPointBinarySensor(MinutPointEntity, BinarySensorEntity): """The platform class required by Home Assistant.""" - def __init__(self, point_client, device_id, device_name): + def __init__( + self, coordinator: PointDataUpdateCoordinator, device_id: str, key: str + ) -> None: """Initialize the binary sensor.""" - super().__init__( - point_client, - device_id, - DEVICES[device_name].get("device_class", device_name), - ) - self._device_name = device_name - self._async_unsub_hook_dispatcher_connect = None - self._events = EVENTS[device_name] - self._attr_unique_id = f"point.{device_id}-{device_name}" - self._attr_icon = DEVICES[self._device_name].get("icon") + self._attr_device_class = DEVICES[key].get("device_class", key) + super().__init__(coordinator, device_id) + self._device_name = key + self._events = EVENTS[key] + self._attr_unique_id = f"point.{device_id}-{key}" + self._attr_icon = DEVICES[key].get("icon") async def async_added_to_hass(self) -> None: """Call when entity is added to HOme Assistant.""" await super().async_added_to_hass() - self._async_unsub_hook_dispatcher_connect = async_dispatcher_connect( - self.hass, SIGNAL_WEBHOOK, self._webhook_event + self.async_on_remove( + async_dispatcher_connect(self.hass, SIGNAL_WEBHOOK, self._webhook_event) ) - async def async_will_remove_from_hass(self) -> None: - """Disconnect dispatcher listener when removed.""" - await super().async_will_remove_from_hass() - if self._async_unsub_hook_dispatcher_connect: - self._async_unsub_hook_dispatcher_connect() - - async def _update_callback(self): + def _handle_coordinator_update(self) -> None: """Update the value of the sensor.""" - if not self.is_updated: - return if self.device_class == BinarySensorDeviceClass.CONNECTIVITY: # connectivity is the other way around. self._attr_is_on = self._events[0] not in self.device.ongoing_events else: self._attr_is_on = self._events[0] in self.device.ongoing_events - self.async_write_ha_state() + super()._handle_coordinator_update() @callback def _webhook_event(self, data, webhook): diff --git a/homeassistant/components/point/coordinator.py b/homeassistant/components/point/coordinator.py new file mode 100644 index 00000000000..c0cb4e27646 --- /dev/null +++ b/homeassistant/components/point/coordinator.py @@ -0,0 +1,70 @@ +"""Define a data update coordinator for Point.""" + +from collections.abc import Callable +from datetime import datetime +import logging +from typing import Any + +from pypoint import PointSession +from tempora.utc import fromtimestamp + +from homeassistant.core import HomeAssistant +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed +from homeassistant.util.dt import parse_datetime + +from .const import DOMAIN, SCAN_INTERVAL + +_LOGGER = logging.getLogger(__name__) + + +class PointDataUpdateCoordinator(DataUpdateCoordinator[dict[str, dict[str, Any]]]): + """Class to manage fetching Point data from the API.""" + + def __init__(self, hass: HomeAssistant, point: PointSession) -> None: + """Initialize.""" + super().__init__( + hass, + _LOGGER, + name=DOMAIN, + update_interval=SCAN_INTERVAL, + ) + self.point = point + self.device_updates: dict[str, datetime] = {} + self._known_devices: set[str] = set() + self._known_homes: set[str] = set() + self.new_home_callback: Callable[[str], None] | None = None + self.new_device_callbacks: list[Callable[[str], None]] = [] + self.data: dict[str, dict[str, Any]] = {} + + async def _async_update_data(self) -> dict[str, dict[str, Any]]: + if not await self.point.update(): + raise UpdateFailed("Failed to fetch data from Point") + + if new_homes := set(self.point.homes) - self._known_homes: + _LOGGER.debug("Found new homes: %s", new_homes) + for home_id in new_homes: + if self.new_home_callback: + self.new_home_callback(home_id) + self._known_homes.update(new_homes) + + device_ids = {device.device_id for device in self.point.devices} + if new_devices := device_ids - self._known_devices: + _LOGGER.debug("Found new devices: %s", new_devices) + for device_id in new_devices: + for callback in self.new_device_callbacks: + callback(device_id) + self._known_devices.update(new_devices) + + for device in self.point.devices: + last_updated = parse_datetime(device.last_update) + if ( + not last_updated + or device.device_id not in self.device_updates + or self.device_updates[device.device_id] < last_updated + ): + self.device_updates[device.device_id] = last_updated or fromtimestamp(0) + self.data[device.device_id] = { + k: await device.sensor(k) + for k in ("temperature", "humidity", "sound_pressure") + } + return self.data diff --git a/homeassistant/components/point/entity.py b/homeassistant/components/point/entity.py index 5c52e81e6f7..39af7867e97 100644 --- a/homeassistant/components/point/entity.py +++ b/homeassistant/components/point/entity.py @@ -2,31 +2,27 @@ import logging +from pypoint import Device, PointSession + from homeassistant.helpers import device_registry as dr from homeassistant.helpers.device_registry import DeviceInfo -from homeassistant.helpers.dispatcher import async_dispatcher_connect -from homeassistant.helpers.entity import Entity -from homeassistant.util.dt import as_local, parse_datetime, utc_from_timestamp +from homeassistant.helpers.update_coordinator import CoordinatorEntity +from homeassistant.util.dt import as_local -from .const import DOMAIN, SIGNAL_UPDATE_ENTITY +from .const import DOMAIN +from .coordinator import PointDataUpdateCoordinator _LOGGER = logging.getLogger(__name__) -class MinutPointEntity(Entity): +class MinutPointEntity(CoordinatorEntity[PointDataUpdateCoordinator]): """Base Entity used by the sensors.""" - _attr_should_poll = False - - def __init__(self, point_client, device_id, device_class) -> None: + def __init__(self, coordinator: PointDataUpdateCoordinator, device_id: str) -> None: """Initialize the entity.""" - self._async_unsub_dispatcher_connect = None - self._client = point_client - self._id = device_id + super().__init__(coordinator) + self.device_id = device_id self._name = self.device.name - self._attr_device_class = device_class - self._updated = utc_from_timestamp(0) - self._attr_unique_id = f"point.{device_id}-{device_class}" device = self.device.device self._attr_device_info = DeviceInfo( connections={(dr.CONNECTION_NETWORK_MAC, device["device_mac"])}, @@ -37,59 +33,32 @@ class MinutPointEntity(Entity): sw_version=device["firmware"]["installed"], via_device=(DOMAIN, device["home"]), ) - if device_class: - self._attr_name = f"{self._name} {device_class.capitalize()}" - - def __str__(self) -> str: - """Return string representation of device.""" - return f"MinutPoint {self.name}" - - async def async_added_to_hass(self): - """Call when entity is added to hass.""" - _LOGGER.debug("Created device %s", self) - self._async_unsub_dispatcher_connect = async_dispatcher_connect( - self.hass, SIGNAL_UPDATE_ENTITY, self._update_callback - ) - await self._update_callback() - - async def async_will_remove_from_hass(self) -> None: - """Disconnect dispatcher listener when removed.""" - if self._async_unsub_dispatcher_connect: - self._async_unsub_dispatcher_connect() + if self.device_class: + self._attr_name = f"{self._name} {self.device_class.capitalize()}" async def _update_callback(self): """Update the value of the sensor.""" + @property + def client(self) -> PointSession: + """Return the client object.""" + return self.coordinator.point + @property def available(self) -> bool: """Return true if device is not offline.""" - return self._client.is_available(self.device_id) + return super().available and self.device_id in self.client.device_ids @property - def device(self): + def device(self) -> Device: """Return the representation of the device.""" - return self._client.device(self.device_id) - - @property - def device_id(self): - """Return the id of the device.""" - return self._id + return self.client.device(self.device_id) @property def extra_state_attributes(self): """Return status of device.""" attrs = self.device.device_status - attrs["last_heard_from"] = as_local(self.last_update).strftime( - "%Y-%m-%d %H:%M:%S" - ) + attrs["last_heard_from"] = as_local( + self.coordinator.device_updates[self.device_id] + ).strftime("%Y-%m-%d %H:%M:%S") return attrs - - @property - def is_updated(self): - """Return true if sensor have been updated.""" - return self.last_update > self._updated - - @property - def last_update(self): - """Return the last_update time for the device.""" - return parse_datetime(self.device.last_update) diff --git a/homeassistant/components/point/sensor.py b/homeassistant/components/point/sensor.py index c959d09d606..246536d86ab 100644 --- a/homeassistant/components/point/sensor.py +++ b/homeassistant/components/point/sensor.py @@ -5,19 +5,17 @@ from __future__ import annotations import logging from homeassistant.components.sensor import ( - DOMAIN as SENSOR_DOMAIN, SensorDeviceClass, SensorEntity, SensorEntityDescription, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import PERCENTAGE, UnitOfSoundPressure, UnitOfTemperature from homeassistant.core import HomeAssistant -from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from homeassistant.util.dt import parse_datetime +from homeassistant.helpers.typing import StateType -from .const import DOMAIN as POINT_DOMAIN, POINT_DISCOVERY_NEW +from . import PointConfigEntry +from .coordinator import PointDataUpdateCoordinator from .entity import MinutPointEntity _LOGGER = logging.getLogger(__name__) @@ -37,7 +35,7 @@ SENSOR_TYPES: tuple[SensorEntityDescription, ...] = ( native_unit_of_measurement=PERCENTAGE, ), SensorEntityDescription( - key="sound", + key="sound_pressure", suggested_display_precision=1, device_class=SensorDeviceClass.SOUND_PRESSURE, native_unit_of_measurement=UnitOfSoundPressure.WEIGHTED_DECIBEL_A, @@ -47,26 +45,26 @@ SENSOR_TYPES: tuple[SensorEntityDescription, ...] = ( async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: PointConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up a Point's sensors based on a config entry.""" - async def async_discover_sensor(device_id): + coordinator = config_entry.runtime_data + + def async_discover_sensor(device_id: str) -> None: """Discover and add a discovered sensor.""" - client = config_entry.runtime_data.client async_add_entities( - [ - MinutPointSensor(client, device_id, description) - for description in SENSOR_TYPES - ], - True, + MinutPointSensor(coordinator, device_id, description) + for description in SENSOR_TYPES ) - async_dispatcher_connect( - hass, - POINT_DISCOVERY_NEW.format(SENSOR_DOMAIN, POINT_DOMAIN), - async_discover_sensor, + coordinator.new_device_callbacks.append(async_discover_sensor) + + async_add_entities( + MinutPointSensor(coordinator, device_id, description) + for device_id in coordinator.data + for description in SENSOR_TYPES ) @@ -74,16 +72,17 @@ class MinutPointSensor(MinutPointEntity, SensorEntity): """The platform class required by Home Assistant.""" def __init__( - self, point_client, device_id, description: SensorEntityDescription + self, + coordinator: PointDataUpdateCoordinator, + device_id: str, + description: SensorEntityDescription, ) -> None: """Initialize the sensor.""" - super().__init__(point_client, device_id, description.device_class) self.entity_description = description + super().__init__(coordinator, device_id) + self._attr_unique_id = f"point.{device_id}-{description.key}" - async def _update_callback(self): - """Update the value of the sensor.""" - _LOGGER.debug("Update sensor value for %s", self) - if self.is_updated: - self._attr_native_value = await self.device.sensor(self.device_class) - self._updated = parse_datetime(self.device.last_update) - self.async_write_ha_state() + @property + def native_value(self) -> StateType: + """Return the state of the sensor.""" + return self.coordinator.data[self.device_id].get(self.entity_description.key) From aaea30bee0c8d928deb8e32fdd3673856a830d28 Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Sun, 30 Mar 2025 21:01:03 +0200 Subject: [PATCH 083/505] Replace "Off" in selector of `media_player` with common state (#141853) --- homeassistant/components/media_player/strings.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/media_player/strings.json b/homeassistant/components/media_player/strings.json index 87b5ec692af..03106b431d7 100644 --- a/homeassistant/components/media_player/strings.json +++ b/homeassistant/components/media_player/strings.json @@ -344,7 +344,7 @@ }, "repeat": { "options": { - "off": "Off", + "off": "[%key:common::state::off%]", "all": "Repeat all", "one": "Repeat one" } From ea9437eab2a6b95064da9aced09de828b017240c Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Sun, 30 Mar 2025 21:02:54 +0200 Subject: [PATCH 084/505] Use common state for "Off" in `climate` selector (#141850) * Use common states for "Away" and "Off" in `climate` * Revert common state for "Away" Four other integrations are referencing this instead of the common state. Needs to be addressed first. --- homeassistant/components/climate/strings.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/climate/strings.json b/homeassistant/components/climate/strings.json index 6d8b2c5449d..609eee71139 100644 --- a/homeassistant/components/climate/strings.json +++ b/homeassistant/components/climate/strings.json @@ -257,7 +257,7 @@ "selector": { "hvac_mode": { "options": { - "off": "Off", + "off": "[%key:common::state::off%]", "auto": "Auto", "cool": "Cool", "dry": "Dry", From 02397a8d2de2fab94712b5c846d05b569a4c6dd4 Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Sun, 30 Mar 2025 21:03:46 +0200 Subject: [PATCH 085/505] Replace "Off" state in selectors of `home_connect` with common state (#141857) * Replace "Off" state in selectors of `home_connect` with common state * Replace internal with common references --- homeassistant/components/home_connect/strings.json | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/home_connect/strings.json b/homeassistant/components/home_connect/strings.json index 5072a4d49a7..ad7f67968f5 100644 --- a/homeassistant/components/home_connect/strings.json +++ b/homeassistant/components/home_connect/strings.json @@ -511,7 +511,7 @@ }, "spin_speed": { "options": { - "laundry_care_washer_enum_type_spin_speed_off": "Off", + "laundry_care_washer_enum_type_spin_speed_off": "[%key:common::state::off%]", "laundry_care_washer_enum_type_spin_speed_r_p_m_400": "400 rpm", "laundry_care_washer_enum_type_spin_speed_r_p_m_600": "600 rpm", "laundry_care_washer_enum_type_spin_speed_r_p_m_700": "700 rpm", @@ -521,7 +521,7 @@ "laundry_care_washer_enum_type_spin_speed_r_p_m_1200": "1200 rpm", "laundry_care_washer_enum_type_spin_speed_r_p_m_1400": "1400 rpm", "laundry_care_washer_enum_type_spin_speed_r_p_m_1600": "1600 rpm", - "laundry_care_washer_enum_type_spin_speed_ul_off": "Off", + "laundry_care_washer_enum_type_spin_speed_ul_off": "[%key:common::state::off%]", "laundry_care_washer_enum_type_spin_speed_ul_low": "Low", "laundry_care_washer_enum_type_spin_speed_ul_medium": "Medium", "laundry_care_washer_enum_type_spin_speed_ul_high": "High" @@ -529,7 +529,7 @@ }, "vario_perfect": { "options": { - "laundry_care_common_enum_type_vario_perfect_off": "Off", + "laundry_care_common_enum_type_vario_perfect_off": "[%key:common::state::off%]", "laundry_care_common_enum_type_vario_perfect_eco_perfect": "Eco perfect", "laundry_care_common_enum_type_vario_perfect_speed_perfect": "Speed perfect" } @@ -1494,7 +1494,7 @@ "spin_speed": { "name": "[%key:component::home_connect::services::set_program_and_options::fields::laundry_care_washer_option_spin_speed::name%]", "state": { - "laundry_care_washer_enum_type_spin_speed_off": "[%key:component::home_connect::selector::spin_speed::options::laundry_care_washer_enum_type_spin_speed_off%]", + "laundry_care_washer_enum_type_spin_speed_off": "[%key:common::state::off%]", "laundry_care_washer_enum_type_spin_speed_r_p_m_400": "[%key:component::home_connect::selector::spin_speed::options::laundry_care_washer_enum_type_spin_speed_r_p_m_400%]", "laundry_care_washer_enum_type_spin_speed_r_p_m_600": "[%key:component::home_connect::selector::spin_speed::options::laundry_care_washer_enum_type_spin_speed_r_p_m_600%]", "laundry_care_washer_enum_type_spin_speed_r_p_m_700": "[%key:component::home_connect::selector::spin_speed::options::laundry_care_washer_enum_type_spin_speed_r_p_m_700%]", @@ -1504,7 +1504,7 @@ "laundry_care_washer_enum_type_spin_speed_r_p_m_1200": "[%key:component::home_connect::selector::spin_speed::options::laundry_care_washer_enum_type_spin_speed_r_p_m_1200%]", "laundry_care_washer_enum_type_spin_speed_r_p_m_1400": "[%key:component::home_connect::selector::spin_speed::options::laundry_care_washer_enum_type_spin_speed_r_p_m_1400%]", "laundry_care_washer_enum_type_spin_speed_r_p_m_1600": "[%key:component::home_connect::selector::spin_speed::options::laundry_care_washer_enum_type_spin_speed_r_p_m_1600%]", - "laundry_care_washer_enum_type_spin_speed_ul_off": "[%key:component::home_connect::selector::spin_speed::options::laundry_care_washer_enum_type_spin_speed_ul_off%]", + "laundry_care_washer_enum_type_spin_speed_ul_off": "[%key:common::state::off%]", "laundry_care_washer_enum_type_spin_speed_ul_low": "[%key:component::home_connect::selector::spin_speed::options::laundry_care_washer_enum_type_spin_speed_ul_low%]", "laundry_care_washer_enum_type_spin_speed_ul_medium": "[%key:component::home_connect::selector::spin_speed::options::laundry_care_washer_enum_type_spin_speed_ul_medium%]", "laundry_care_washer_enum_type_spin_speed_ul_high": "[%key:component::home_connect::selector::spin_speed::options::laundry_care_washer_enum_type_spin_speed_ul_high%]" @@ -1513,7 +1513,7 @@ "vario_perfect": { "name": "[%key:component::home_connect::services::set_program_and_options::fields::laundry_care_washer_option_vario_perfect::name%]", "state": { - "laundry_care_common_enum_type_vario_perfect_off": "[%key:component::home_connect::selector::vario_perfect::options::laundry_care_common_enum_type_vario_perfect_off%]", + "laundry_care_common_enum_type_vario_perfect_off": "[%key:common::state::off%]", "laundry_care_common_enum_type_vario_perfect_eco_perfect": "[%key:component::home_connect::selector::vario_perfect::options::laundry_care_common_enum_type_vario_perfect_eco_perfect%]", "laundry_care_common_enum_type_vario_perfect_speed_perfect": "[%key:component::home_connect::selector::vario_perfect::options::laundry_care_common_enum_type_vario_perfect_speed_perfect%]" } From b5e1f7e03eba2ef79289ae707577e30d1d415d0b Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 30 Mar 2025 09:18:30 -1000 Subject: [PATCH 086/505] Cleanup some typing in isy994 (#141859) Now that pyisy is mostly typed there were some obvious issues. We are still a long way away from being able to add py.typed to pyisy, but we can now see some obvious things in an IDE --- homeassistant/components/isy994/__init__.py | 4 ++-- homeassistant/components/isy994/entity.py | 1 + homeassistant/components/isy994/services.py | 3 ++- homeassistant/components/isy994/switch.py | 2 +- 4 files changed, 6 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/isy994/__init__.py b/homeassistant/components/isy994/__init__.py index e387196ba94..1e227b08206 100644 --- a/homeassistant/components/isy994/__init__.py +++ b/homeassistant/components/isy994/__init__.py @@ -227,9 +227,9 @@ async def async_unload_entry( """Unload a config entry.""" unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) - isy_data = hass.data[DOMAIN][entry.entry_id] + isy_data: IsyData = hass.data[DOMAIN][entry.entry_id] - isy: ISY = isy_data.root + isy = isy_data.root _LOGGER.debug("ISY Stopping Event Stream and automatic updates") isy.websocket.stop() diff --git a/homeassistant/components/isy994/entity.py b/homeassistant/components/isy994/entity.py index 1da727fdee8..d170854396c 100644 --- a/homeassistant/components/isy994/entity.py +++ b/homeassistant/components/isy994/entity.py @@ -181,6 +181,7 @@ class ISYProgramEntity(ISYEntity): _actions: Program _status: Program + _node: Program def __init__(self, name: str, status: Program, actions: Program = None) -> None: """Initialize the ISY program-based entity.""" diff --git a/homeassistant/components/isy994/services.py b/homeassistant/components/isy994/services.py index 6546aec6efa..24cfa9aefb1 100644 --- a/homeassistant/components/isy994/services.py +++ b/homeassistant/components/isy994/services.py @@ -21,6 +21,7 @@ from homeassistant.helpers.service import entity_service_call from homeassistant.helpers.typing import VolDictType from .const import _LOGGER, DOMAIN +from .models import IsyData # Common Services for All Platforms: SERVICE_SEND_PROGRAM_COMMAND = "send_program_command" @@ -149,7 +150,7 @@ def async_setup_services(hass: HomeAssistant) -> None: isy_name = service.data.get(CONF_ISY) for config_entry_id in hass.data[DOMAIN]: - isy_data = hass.data[DOMAIN][config_entry_id] + isy_data: IsyData = hass.data[DOMAIN][config_entry_id] isy = isy_data.root if isy_name and isy_name != isy.conf["name"]: continue diff --git a/homeassistant/components/isy994/switch.py b/homeassistant/components/isy994/switch.py index 946feddcd10..d5c8a23cbea 100644 --- a/homeassistant/components/isy994/switch.py +++ b/homeassistant/components/isy994/switch.py @@ -157,7 +157,7 @@ class ISYEnableSwitchEntity(ISYAuxControlEntity, SwitchEntity): device_info=device_info, ) self._attr_name = description.name # Override super - self._change_handler: EventListener = None + self._change_handler: EventListener | None = None # pylint: disable-next=hass-missing-super-call async def async_added_to_hass(self) -> None: From 302eea74186f5deb95b4eb38539666423049967d Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 30 Mar 2025 09:29:51 -1000 Subject: [PATCH 087/505] Bump PyISY to 3.4.0 (#141851) * Bump PyISY to 3.3.0 changelog: https://github.com/automicus/PyISY/compare/v3.2.0...v3.3.0 * Apply suggestions from code review --------- Co-authored-by: Shay Levy --- homeassistant/components/isy994/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/isy994/manifest.json b/homeassistant/components/isy994/manifest.json index ab0367f3db4..5cd3bb73a89 100644 --- a/homeassistant/components/isy994/manifest.json +++ b/homeassistant/components/isy994/manifest.json @@ -24,7 +24,7 @@ "integration_type": "hub", "iot_class": "local_push", "loggers": ["pyisy"], - "requirements": ["pyisy==3.2.0"], + "requirements": ["pyisy==3.4.0"], "ssdp": [ { "manufacturer": "Universal Devices Inc.", diff --git a/requirements_all.txt b/requirements_all.txt index 82b48131216..d78bcc3e8eb 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2047,7 +2047,7 @@ pyiskra==0.1.15 pyiss==1.0.1 # homeassistant.components.isy994 -pyisy==3.2.0 +pyisy==3.4.0 # homeassistant.components.itach pyitachip2ir==0.0.7 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 7e942673898..3391c5bfa23 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1668,7 +1668,7 @@ pyiskra==0.1.15 pyiss==1.0.1 # homeassistant.components.isy994 -pyisy==3.2.0 +pyisy==3.4.0 # homeassistant.components.ituran pyituran==0.1.4 From 0f9f090db210640902e1ec3e321eb9e4b0fdb1e5 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Sun, 30 Mar 2025 21:34:49 +0200 Subject: [PATCH 088/505] Bump pySmartThings to 3.0.1 (#141722) --- homeassistant/components/smartthings/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/smartthings/manifest.json b/homeassistant/components/smartthings/manifest.json index 49de0c79ce7..2af3e5c193b 100644 --- a/homeassistant/components/smartthings/manifest.json +++ b/homeassistant/components/smartthings/manifest.json @@ -30,5 +30,5 @@ "iot_class": "cloud_push", "loggers": ["pysmartthings"], "quality_scale": "bronze", - "requirements": ["pysmartthings==3.0.0"] + "requirements": ["pysmartthings==3.0.1"] } diff --git a/requirements_all.txt b/requirements_all.txt index d78bcc3e8eb..cb8f73deeb6 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2319,7 +2319,7 @@ pysma==0.7.5 pysmappee==0.2.29 # homeassistant.components.smartthings -pysmartthings==3.0.0 +pysmartthings==3.0.1 # homeassistant.components.smarty pysmarty2==0.10.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 3391c5bfa23..779ce1cccdf 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1889,7 +1889,7 @@ pysma==0.7.5 pysmappee==0.2.29 # homeassistant.components.smartthings -pysmartthings==3.0.0 +pysmartthings==3.0.1 # homeassistant.components.smarty pysmarty2==0.10.2 From cf786b3b046321fffd060cc4aaa2d42b23f3b9d9 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 30 Mar 2025 10:15:19 -1000 Subject: [PATCH 089/505] Bump google_cloud deps (#141861) speech: https://github.com/googleapis/google-cloud-python/compare/google-cloud-speech-v2.27.0...google-cloud-speech-v2.31.1 texttospeech: https://github.com/googleapis/google-cloud-python/compare/google-cloud-texttospeech-v2.17.2...google-cloud-texttospeech-v2.25.1 --- homeassistant/components/google_cloud/manifest.json | 4 ++-- requirements_all.txt | 4 ++-- requirements_test_all.txt | 4 ++-- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/google_cloud/manifest.json b/homeassistant/components/google_cloud/manifest.json index 3e08b6254db..3e6371cbe23 100644 --- a/homeassistant/components/google_cloud/manifest.json +++ b/homeassistant/components/google_cloud/manifest.json @@ -8,7 +8,7 @@ "integration_type": "service", "iot_class": "cloud_push", "requirements": [ - "google-cloud-texttospeech==2.17.2", - "google-cloud-speech==2.27.0" + "google-cloud-texttospeech==2.25.1", + "google-cloud-speech==2.31.1" ] } diff --git a/requirements_all.txt b/requirements_all.txt index cb8f73deeb6..df321a5f112 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1035,10 +1035,10 @@ google-api-python-client==2.71.0 google-cloud-pubsub==2.29.0 # homeassistant.components.google_cloud -google-cloud-speech==2.27.0 +google-cloud-speech==2.31.1 # homeassistant.components.google_cloud -google-cloud-texttospeech==2.17.2 +google-cloud-texttospeech==2.25.1 # homeassistant.components.google_generative_ai_conversation google-genai==1.7.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 779ce1cccdf..4b8df3aa1a8 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -886,10 +886,10 @@ google-api-python-client==2.71.0 google-cloud-pubsub==2.29.0 # homeassistant.components.google_cloud -google-cloud-speech==2.27.0 +google-cloud-speech==2.31.1 # homeassistant.components.google_cloud -google-cloud-texttospeech==2.17.2 +google-cloud-texttospeech==2.25.1 # homeassistant.components.google_generative_ai_conversation google-genai==1.7.0 From 5bfe034b4dcad0ab2d20136f230e859c457dd517 Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Sun, 30 Mar 2025 22:17:51 +0200 Subject: [PATCH 090/505] Replace "Country" with common and pollutant labels with `sensor` strings (#141863) * Replace "Country" with common and pollutant labels with `sensor` strings * Fix copy & paste error for "ozone" --- homeassistant/components/airvisual/strings.json | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/homeassistant/components/airvisual/strings.json b/homeassistant/components/airvisual/strings.json index 7a5f8b1d5c7..9d53be4dee7 100644 --- a/homeassistant/components/airvisual/strings.json +++ b/homeassistant/components/airvisual/strings.json @@ -16,8 +16,8 @@ "data": { "api_key": "[%key:common::config_flow::data::api_key%]", "city": "City", - "country": "Country", - "state": "State" + "state": "State", + "country": "[%key:common::config_flow::data::country%]" } }, "reauth_confirm": { @@ -56,12 +56,12 @@ "sensor": { "pollutant_label": { "state": { - "co": "Carbon monoxide", - "n2": "Nitrogen dioxide", - "o3": "Ozone", - "p1": "PM10", - "p2": "PM2.5", - "s2": "Sulfur dioxide" + "co": "[%key:component::sensor::entity_component::carbon_monoxide::name%]", + "n2": "[%key:component::sensor::entity_component::nitrogen_dioxide::name%]", + "o3": "[%key:component::sensor::entity_component::ozone::name%]", + "p1": "[%key:component::sensor::entity_component::pm10::name%]", + "p2": "[%key:component::sensor::entity_component::pm25::name%]", + "s2": "[%key:component::sensor::entity_component::sulphur_dioxide::name%]" } }, "pollutant_level": { From 0d511c697c09dc91c8c51d5fd731df1768bf8827 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 30 Mar 2025 10:20:24 -1000 Subject: [PATCH 091/505] Improve performance of as_compressed_state (#141800) We have to build all of these at startup. Its a lot faster to compare floats instead of datetime objects. Since we already have to fetch last_changed_timestamp, use it to compare with last_updated_timestamp since we already know we will have last_updated_timestamp --- homeassistant/core.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/homeassistant/core.py b/homeassistant/core.py index 46ae499e2ca..ec251832dba 100644 --- a/homeassistant/core.py +++ b/homeassistant/core.py @@ -1935,13 +1935,14 @@ class State: # to avoid callers outside of this module # from misusing it by mistake. context = state_context._as_dict # noqa: SLF001 + last_changed_timestamp = self.last_changed_timestamp compressed_state: CompressedState = { COMPRESSED_STATE_STATE: self.state, COMPRESSED_STATE_ATTRIBUTES: self.attributes, COMPRESSED_STATE_CONTEXT: context, - COMPRESSED_STATE_LAST_CHANGED: self.last_changed_timestamp, + COMPRESSED_STATE_LAST_CHANGED: last_changed_timestamp, } - if self.last_changed != self.last_updated: + if last_changed_timestamp != self.last_updated_timestamp: compressed_state[COMPRESSED_STATE_LAST_UPDATED] = ( self.last_updated_timestamp ) From 936b0b32ed745f0676b087359b294ca97cf515a7 Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Sun, 30 Mar 2025 22:30:08 +0200 Subject: [PATCH 092/505] Replace "Home" and "Away" in `drop_connect` with common strings (#141864) --- homeassistant/components/drop_connect/strings.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/drop_connect/strings.json b/homeassistant/components/drop_connect/strings.json index 93df4dc3310..6093f2e8100 100644 --- a/homeassistant/components/drop_connect/strings.json +++ b/homeassistant/components/drop_connect/strings.json @@ -38,8 +38,8 @@ "protect_mode": { "name": "Protect mode", "state": { - "away": "Away", - "home": "Home", + "away": "[%key:common::state::not_home%]", + "home": "[%key:common::state::home%]", "schedule": "Schedule" } } From 85d2e3d006be81b29adf1ac03c0c156cc395cc49 Mon Sep 17 00:00:00 2001 From: John Karabudak Date: Sun, 30 Mar 2025 18:00:40 -0230 Subject: [PATCH 093/505] Fix LLM to speed up prefill (#141156) * fix: two minor LLM changes to speed up prefill - moved the current date/time to the end of the prompt - started sorting all entities by last_changed * addressed PR comments * fixed tests * reduced scope of try/catch in LLM prompt * addressed more PR comments * fixed Anthropic test * addressed another PR comment * fixed remainder of tests --- .../components/conversation/chat_log.py | 73 ++++++++++++------- homeassistant/helpers/llm.py | 3 +- .../snapshots/test_conversation.ambr | 2 +- .../snapshots/test_conversation.ambr | 6 +- tests/helpers/test_llm.py | 54 +++++++------- 5 files changed, 79 insertions(+), 59 deletions(-) diff --git a/homeassistant/components/conversation/chat_log.py b/homeassistant/components/conversation/chat_log.py index cb7b8dd22f7..9ffcc7fc0d5 100644 --- a/homeassistant/components/conversation/chat_log.py +++ b/homeassistant/components/conversation/chat_log.py @@ -354,6 +354,35 @@ class ChatLog: if self.delta_listener: self.delta_listener(self, asdict(tool_result)) + async def _async_expand_prompt_template( + self, + llm_context: llm.LLMContext, + prompt: str, + language: str, + user_name: str | None = None, + ) -> str: + try: + return template.Template(prompt, self.hass).async_render( + { + "ha_name": self.hass.config.location_name, + "user_name": user_name, + "llm_context": llm_context, + }, + parse_result=False, + ) + except TemplateError as err: + LOGGER.error("Error rendering prompt: %s", err) + intent_response = intent.IntentResponse(language=language) + intent_response.async_set_error( + intent.IntentResponseErrorCode.UNKNOWN, + "Sorry, I had a problem with my template", + ) + raise ConverseError( + "Error rendering prompt", + conversation_id=self.conversation_id, + response=intent_response, + ) from err + async def async_update_llm_data( self, conversing_domain: str, @@ -409,38 +438,28 @@ class ChatLog: ): user_name = user.name - try: - prompt_parts = [ - template.Template( - llm.BASE_PROMPT - + (user_llm_prompt or llm.DEFAULT_INSTRUCTIONS_PROMPT), - self.hass, - ).async_render( - { - "ha_name": self.hass.config.location_name, - "user_name": user_name, - "llm_context": llm_context, - }, - parse_result=False, - ) - ] - - except TemplateError as err: - LOGGER.error("Error rendering prompt: %s", err) - intent_response = intent.IntentResponse(language=user_input.language) - intent_response.async_set_error( - intent.IntentResponseErrorCode.UNKNOWN, - "Sorry, I had a problem with my template", + prompt_parts = [] + prompt_parts.append( + await self._async_expand_prompt_template( + llm_context, + (user_llm_prompt or llm.DEFAULT_INSTRUCTIONS_PROMPT), + user_input.language, + user_name, ) - raise ConverseError( - "Error rendering prompt", - conversation_id=self.conversation_id, - response=intent_response, - ) from err + ) if llm_api: prompt_parts.append(llm_api.api_prompt) + prompt_parts.append( + await self._async_expand_prompt_template( + llm_context, + llm.BASE_PROMPT, + user_input.language, + user_name, + ) + ) + if extra_system_prompt := ( # Take new system prompt if one was given user_input.extra_system_prompt or self.extra_system_prompt diff --git a/homeassistant/helpers/llm.py b/homeassistant/helpers/llm.py index 7f6fe22ec70..aa6b3dc2cbf 100644 --- a/homeassistant/helpers/llm.py +++ b/homeassistant/helpers/llm.py @@ -9,6 +9,7 @@ from datetime import timedelta from decimal import Decimal from enum import Enum from functools import cache, partial +from operator import attrgetter from typing import Any, cast import slugify as unicode_slug @@ -496,7 +497,7 @@ def _get_exposed_entities( CALENDAR_DOMAIN: {}, } - for state in hass.states.async_all(): + for state in sorted(hass.states.async_all(), key=attrgetter("name")): if not async_should_expose(hass, assistant, state.entity_id): continue diff --git a/tests/components/anthropic/snapshots/test_conversation.ambr b/tests/components/anthropic/snapshots/test_conversation.ambr index c0ed986f002..ea4ce5a980d 100644 --- a/tests/components/anthropic/snapshots/test_conversation.ambr +++ b/tests/components/anthropic/snapshots/test_conversation.ambr @@ -3,11 +3,11 @@ list([ dict({ 'content': ''' - Current time is 16:00:00. Today's date is 2024-06-03. You are a voice assistant for Home Assistant. Answer questions about the world truthfully. Answer in plain text. Keep it simple and to the point. Only if the user wants to control a device, tell them to expose entities to their voice assistant in Home Assistant. + Current time is 16:00:00. Today's date is 2024-06-03. ''', 'role': 'system', }), diff --git a/tests/components/google_generative_ai_conversation/snapshots/test_conversation.ambr b/tests/components/google_generative_ai_conversation/snapshots/test_conversation.ambr index ec98bdd6529..2376bf51cdc 100644 --- a/tests/components/google_generative_ai_conversation/snapshots/test_conversation.ambr +++ b/tests/components/google_generative_ai_conversation/snapshots/test_conversation.ambr @@ -6,7 +6,7 @@ tuple( ), dict({ - 'config': GenerateContentConfig(http_options=None, system_instruction="Current time is 05:00:00. Today's date is 2024-05-24.\nYou are a voice assistant for Home Assistant.\nAnswer questions about the world truthfully.\nAnswer in plain text. Keep it simple and to the point.\nOnly if the user wants to control a device, tell them to expose entities to their voice assistant in Home Assistant.", temperature=1.0, top_p=0.95, top_k=64.0, candidate_count=None, max_output_tokens=150, stop_sequences=None, response_logprobs=None, logprobs=None, presence_penalty=None, frequency_penalty=None, seed=None, response_mime_type=None, response_schema=None, routing_config=None, safety_settings=[SafetySetting(method=None, category=, threshold=), SafetySetting(method=None, category=, threshold=), SafetySetting(method=None, category=, threshold=), SafetySetting(method=None, category=, threshold=)], tools=[Tool(function_declarations=[FunctionDeclaration(response=None, description='Test function', name='test_tool', parameters=Schema(example=None, pattern=None, default=None, max_length=None, title=None, min_length=None, min_properties=None, max_properties=None, any_of=None, description=None, enum=None, format=None, items=None, max_items=None, maximum=None, min_items=None, minimum=None, nullable=None, properties={'param1': Schema(example=None, pattern=None, default=None, max_length=None, title=None, min_length=None, min_properties=None, max_properties=None, any_of=None, description='Test parameters', enum=None, format=None, items=Schema(example=None, pattern=None, default=None, max_length=None, title=None, min_length=None, min_properties=None, max_properties=None, any_of=None, description=None, enum=None, format=None, items=None, max_items=None, maximum=None, min_items=None, minimum=None, nullable=None, properties=None, property_ordering=None, required=None, type=), max_items=None, maximum=None, min_items=None, minimum=None, nullable=None, properties=None, property_ordering=None, required=None, type=), 'param2': Schema(example=None, pattern=None, default=None, max_length=None, title=None, min_length=None, min_properties=None, max_properties=None, any_of=None, description=None, enum=None, format=None, items=None, max_items=None, maximum=None, min_items=None, minimum=None, nullable=None, properties=None, property_ordering=None, required=None, type=None), 'param3': Schema(example=None, pattern=None, default=None, max_length=None, title=None, min_length=None, min_properties=None, max_properties=None, any_of=None, description=None, enum=None, format=None, items=None, max_items=None, maximum=None, min_items=None, minimum=None, nullable=None, properties={'json': Schema(example=None, pattern=None, default=None, max_length=None, title=None, min_length=None, min_properties=None, max_properties=None, any_of=None, description=None, enum=None, format=None, items=None, max_items=None, maximum=None, min_items=None, minimum=None, nullable=None, properties=None, property_ordering=None, required=None, type=)}, property_ordering=None, required=[], type=)}, property_ordering=None, required=[], type=))], retrieval=None, google_search=None, google_search_retrieval=None, code_execution=None)], tool_config=None, labels=None, cached_content=None, response_modalities=None, media_resolution=None, speech_config=None, audio_timestamp=None, automatic_function_calling=AutomaticFunctionCallingConfig(disable=True, maximum_remote_calls=None, ignore_call_history=None), thinking_config=None), + 'config': GenerateContentConfig(http_options=None, system_instruction="You are a voice assistant for Home Assistant.\nAnswer questions about the world truthfully.\nAnswer in plain text. Keep it simple and to the point.\nOnly if the user wants to control a device, tell them to expose entities to their voice assistant in Home Assistant.\nCurrent time is 05:00:00. Today's date is 2024-05-24.", temperature=1.0, top_p=0.95, top_k=64.0, candidate_count=None, max_output_tokens=150, stop_sequences=None, response_logprobs=None, logprobs=None, presence_penalty=None, frequency_penalty=None, seed=None, response_mime_type=None, response_schema=None, routing_config=None, safety_settings=[SafetySetting(method=None, category=, threshold=), SafetySetting(method=None, category=, threshold=), SafetySetting(method=None, category=, threshold=), SafetySetting(method=None, category=, threshold=)], tools=[Tool(function_declarations=[FunctionDeclaration(response=None, description='Test function', name='test_tool', parameters=Schema(example=None, pattern=None, default=None, max_length=None, title=None, min_length=None, min_properties=None, max_properties=None, any_of=None, description=None, enum=None, format=None, items=None, max_items=None, maximum=None, min_items=None, minimum=None, nullable=None, properties={'param1': Schema(example=None, pattern=None, default=None, max_length=None, title=None, min_length=None, min_properties=None, max_properties=None, any_of=None, description='Test parameters', enum=None, format=None, items=Schema(example=None, pattern=None, default=None, max_length=None, title=None, min_length=None, min_properties=None, max_properties=None, any_of=None, description=None, enum=None, format=None, items=None, max_items=None, maximum=None, min_items=None, minimum=None, nullable=None, properties=None, property_ordering=None, required=None, type=), max_items=None, maximum=None, min_items=None, minimum=None, nullable=None, properties=None, property_ordering=None, required=None, type=), 'param2': Schema(example=None, pattern=None, default=None, max_length=None, title=None, min_length=None, min_properties=None, max_properties=None, any_of=None, description=None, enum=None, format=None, items=None, max_items=None, maximum=None, min_items=None, minimum=None, nullable=None, properties=None, property_ordering=None, required=None, type=None), 'param3': Schema(example=None, pattern=None, default=None, max_length=None, title=None, min_length=None, min_properties=None, max_properties=None, any_of=None, description=None, enum=None, format=None, items=None, max_items=None, maximum=None, min_items=None, minimum=None, nullable=None, properties={'json': Schema(example=None, pattern=None, default=None, max_length=None, title=None, min_length=None, min_properties=None, max_properties=None, any_of=None, description=None, enum=None, format=None, items=None, max_items=None, maximum=None, min_items=None, minimum=None, nullable=None, properties=None, property_ordering=None, required=None, type=)}, property_ordering=None, required=[], type=)}, property_ordering=None, required=[], type=))], retrieval=None, google_search=None, google_search_retrieval=None, code_execution=None)], tool_config=None, labels=None, cached_content=None, response_modalities=None, media_resolution=None, speech_config=None, audio_timestamp=None, automatic_function_calling=AutomaticFunctionCallingConfig(disable=True, maximum_remote_calls=None, ignore_call_history=None), thinking_config=None), 'history': list([ ]), 'model': 'models/gemini-2.0-flash', @@ -39,7 +39,7 @@ tuple( ), dict({ - 'config': GenerateContentConfig(http_options=None, system_instruction="Current time is 05:00:00. Today's date is 2024-05-24.\nYou are a voice assistant for Home Assistant.\nAnswer questions about the world truthfully.\nAnswer in plain text. Keep it simple and to the point.\nOnly if the user wants to control a device, tell them to expose entities to their voice assistant in Home Assistant.", temperature=1.0, top_p=0.95, top_k=64.0, candidate_count=None, max_output_tokens=150, stop_sequences=None, response_logprobs=None, logprobs=None, presence_penalty=None, frequency_penalty=None, seed=None, response_mime_type=None, response_schema=None, routing_config=None, safety_settings=[SafetySetting(method=None, category=, threshold=), SafetySetting(method=None, category=, threshold=), SafetySetting(method=None, category=, threshold=), SafetySetting(method=None, category=, threshold=)], tools=[Tool(function_declarations=[FunctionDeclaration(response=None, description='Test function', name='test_tool', parameters=None)], retrieval=None, google_search=None, google_search_retrieval=None, code_execution=None)], tool_config=None, labels=None, cached_content=None, response_modalities=None, media_resolution=None, speech_config=None, audio_timestamp=None, automatic_function_calling=AutomaticFunctionCallingConfig(disable=True, maximum_remote_calls=None, ignore_call_history=None), thinking_config=None), + 'config': GenerateContentConfig(http_options=None, system_instruction="You are a voice assistant for Home Assistant.\nAnswer questions about the world truthfully.\nAnswer in plain text. Keep it simple and to the point.\nOnly if the user wants to control a device, tell them to expose entities to their voice assistant in Home Assistant.\nCurrent time is 05:00:00. Today's date is 2024-05-24.", temperature=1.0, top_p=0.95, top_k=64.0, candidate_count=None, max_output_tokens=150, stop_sequences=None, response_logprobs=None, logprobs=None, presence_penalty=None, frequency_penalty=None, seed=None, response_mime_type=None, response_schema=None, routing_config=None, safety_settings=[SafetySetting(method=None, category=, threshold=), SafetySetting(method=None, category=, threshold=), SafetySetting(method=None, category=, threshold=), SafetySetting(method=None, category=, threshold=)], tools=[Tool(function_declarations=[FunctionDeclaration(response=None, description='Test function', name='test_tool', parameters=None)], retrieval=None, google_search=None, google_search_retrieval=None, code_execution=None)], tool_config=None, labels=None, cached_content=None, response_modalities=None, media_resolution=None, speech_config=None, audio_timestamp=None, automatic_function_calling=AutomaticFunctionCallingConfig(disable=True, maximum_remote_calls=None, ignore_call_history=None), thinking_config=None), 'history': list([ ]), 'model': 'models/gemini-2.0-flash', @@ -72,7 +72,7 @@ tuple( ), dict({ - 'config': GenerateContentConfig(http_options=None, system_instruction="Current time is 05:00:00. Today's date is 2024-05-24.\nYou are a voice assistant for Home Assistant.\nAnswer questions about the world truthfully.\nAnswer in plain text. Keep it simple and to the point.\nOnly if the user wants to control a device, tell them to expose entities to their voice assistant in Home Assistant.", temperature=1.0, top_p=0.95, top_k=64.0, candidate_count=None, max_output_tokens=150, stop_sequences=None, response_logprobs=None, logprobs=None, presence_penalty=None, frequency_penalty=None, seed=None, response_mime_type=None, response_schema=None, routing_config=None, safety_settings=[SafetySetting(method=None, category=, threshold=), SafetySetting(method=None, category=, threshold=), SafetySetting(method=None, category=, threshold=), SafetySetting(method=None, category=, threshold=)], tools=[Tool(function_declarations=[FunctionDeclaration(response=None, description='Test function', name='test_tool', parameters=Schema(example=None, pattern=None, default=None, max_length=None, title=None, min_length=None, min_properties=None, max_properties=None, any_of=None, description=None, enum=None, format=None, items=None, max_items=None, maximum=None, min_items=None, minimum=None, nullable=None, properties={'param1': Schema(example=None, pattern=None, default=None, max_length=None, title=None, min_length=None, min_properties=None, max_properties=None, any_of=None, description='Test parameters', enum=None, format=None, items=Schema(example=None, pattern=None, default=None, max_length=None, title=None, min_length=None, min_properties=None, max_properties=None, any_of=None, description=None, enum=None, format=None, items=None, max_items=None, maximum=None, min_items=None, minimum=None, nullable=None, properties=None, property_ordering=None, required=None, type=), max_items=None, maximum=None, min_items=None, minimum=None, nullable=None, properties=None, property_ordering=None, required=None, type=), 'param2': Schema(example=None, pattern=None, default=None, max_length=None, title=None, min_length=None, min_properties=None, max_properties=None, any_of=None, description=None, enum=None, format=None, items=None, max_items=None, maximum=None, min_items=None, minimum=None, nullable=None, properties=None, property_ordering=None, required=None, type=None), 'param3': Schema(example=None, pattern=None, default=None, max_length=None, title=None, min_length=None, min_properties=None, max_properties=None, any_of=None, description=None, enum=None, format=None, items=None, max_items=None, maximum=None, min_items=None, minimum=None, nullable=None, properties={'json': Schema(example=None, pattern=None, default=None, max_length=None, title=None, min_length=None, min_properties=None, max_properties=None, any_of=None, description=None, enum=None, format=None, items=None, max_items=None, maximum=None, min_items=None, minimum=None, nullable=None, properties=None, property_ordering=None, required=None, type=)}, property_ordering=None, required=[], type=)}, property_ordering=None, required=[], type=))], retrieval=None, google_search=None, google_search_retrieval=None, code_execution=None), Tool(function_declarations=None, retrieval=None, google_search=GoogleSearch(), google_search_retrieval=None, code_execution=None)], tool_config=None, labels=None, cached_content=None, response_modalities=None, media_resolution=None, speech_config=None, audio_timestamp=None, automatic_function_calling=AutomaticFunctionCallingConfig(disable=True, maximum_remote_calls=None, ignore_call_history=None), thinking_config=None), + 'config': GenerateContentConfig(http_options=None, system_instruction="You are a voice assistant for Home Assistant.\nAnswer questions about the world truthfully.\nAnswer in plain text. Keep it simple and to the point.\nOnly if the user wants to control a device, tell them to expose entities to their voice assistant in Home Assistant.\nCurrent time is 05:00:00. Today's date is 2024-05-24.", temperature=1.0, top_p=0.95, top_k=64.0, candidate_count=None, max_output_tokens=150, stop_sequences=None, response_logprobs=None, logprobs=None, presence_penalty=None, frequency_penalty=None, seed=None, response_mime_type=None, response_schema=None, routing_config=None, safety_settings=[SafetySetting(method=None, category=, threshold=), SafetySetting(method=None, category=, threshold=), SafetySetting(method=None, category=, threshold=), SafetySetting(method=None, category=, threshold=)], tools=[Tool(function_declarations=[FunctionDeclaration(response=None, description='Test function', name='test_tool', parameters=Schema(example=None, pattern=None, default=None, max_length=None, title=None, min_length=None, min_properties=None, max_properties=None, any_of=None, description=None, enum=None, format=None, items=None, max_items=None, maximum=None, min_items=None, minimum=None, nullable=None, properties={'param1': Schema(example=None, pattern=None, default=None, max_length=None, title=None, min_length=None, min_properties=None, max_properties=None, any_of=None, description='Test parameters', enum=None, format=None, items=Schema(example=None, pattern=None, default=None, max_length=None, title=None, min_length=None, min_properties=None, max_properties=None, any_of=None, description=None, enum=None, format=None, items=None, max_items=None, maximum=None, min_items=None, minimum=None, nullable=None, properties=None, property_ordering=None, required=None, type=), max_items=None, maximum=None, min_items=None, minimum=None, nullable=None, properties=None, property_ordering=None, required=None, type=), 'param2': Schema(example=None, pattern=None, default=None, max_length=None, title=None, min_length=None, min_properties=None, max_properties=None, any_of=None, description=None, enum=None, format=None, items=None, max_items=None, maximum=None, min_items=None, minimum=None, nullable=None, properties=None, property_ordering=None, required=None, type=None), 'param3': Schema(example=None, pattern=None, default=None, max_length=None, title=None, min_length=None, min_properties=None, max_properties=None, any_of=None, description=None, enum=None, format=None, items=None, max_items=None, maximum=None, min_items=None, minimum=None, nullable=None, properties={'json': Schema(example=None, pattern=None, default=None, max_length=None, title=None, min_length=None, min_properties=None, max_properties=None, any_of=None, description=None, enum=None, format=None, items=None, max_items=None, maximum=None, min_items=None, minimum=None, nullable=None, properties=None, property_ordering=None, required=None, type=)}, property_ordering=None, required=[], type=)}, property_ordering=None, required=[], type=))], retrieval=None, google_search=None, google_search_retrieval=None, code_execution=None), Tool(function_declarations=None, retrieval=None, google_search=GoogleSearch(), google_search_retrieval=None, code_execution=None)], tool_config=None, labels=None, cached_content=None, response_modalities=None, media_resolution=None, speech_config=None, audio_timestamp=None, automatic_function_calling=AutomaticFunctionCallingConfig(disable=True, maximum_remote_calls=None, ignore_call_history=None), thinking_config=None), 'history': list([ ]), 'model': 'models/gemini-2.0-flash', diff --git a/tests/helpers/test_llm.py b/tests/helpers/test_llm.py index 19ada407550..26c357c4b0a 100644 --- a/tests/helpers/test_llm.py +++ b/tests/helpers/test_llm.py @@ -576,6 +576,10 @@ async def test_assist_api_prompt( ) ) exposed_entities_prompt = """An overview of the areas and the devices in this smart home: +- names: '1' + domain: light + state: unavailable + areas: Test Area 2 - names: Kitchen domain: light state: 'on' @@ -590,18 +594,6 @@ async def test_assist_api_prompt( domain: light state: unavailable areas: Test Area, Alternative name -- names: Test Service - domain: light - state: unavailable - areas: Test Area, Alternative name -- names: Test Service - domain: light - state: unavailable - areas: Test Area, Alternative name -- names: Test Service - domain: light - state: unavailable - areas: Test Area, Alternative name - names: Test Device 2 domain: light state: unavailable @@ -614,16 +606,27 @@ async def test_assist_api_prompt( domain: light state: unavailable areas: Test Area 2 -- names: Unnamed Device +- names: Test Service domain: light state: unavailable - areas: Test Area 2 -- names: '1' + areas: Test Area, Alternative name +- names: Test Service + domain: light + state: unavailable + areas: Test Area, Alternative name +- names: Test Service + domain: light + state: unavailable + areas: Test Area, Alternative name +- names: Unnamed Device domain: light state: unavailable areas: Test Area 2 """ stateless_exposed_entities_prompt = """An overview of the areas and the devices in this smart home: +- names: '1' + domain: light + areas: Test Area 2 - names: Kitchen domain: light - names: Living Room @@ -632,15 +635,6 @@ async def test_assist_api_prompt( - names: Test Device, my test light domain: light areas: Test Area, Alternative name -- names: Test Service - domain: light - areas: Test Area, Alternative name -- names: Test Service - domain: light - areas: Test Area, Alternative name -- names: Test Service - domain: light - areas: Test Area, Alternative name - names: Test Device 2 domain: light areas: Test Area 2 @@ -650,10 +644,16 @@ async def test_assist_api_prompt( - names: Test Device 4 domain: light areas: Test Area 2 -- names: Unnamed Device +- names: Test Service domain: light - areas: Test Area 2 -- names: '1' + areas: Test Area, Alternative name +- names: Test Service + domain: light + areas: Test Area, Alternative name +- names: Test Service + domain: light + areas: Test Area, Alternative name +- names: Unnamed Device domain: light areas: Test Area 2 """ From e81a08916a851b5e502dcbdb59301732750e134f Mon Sep 17 00:00:00 2001 From: tdfountain <174762217+tdfountain@users.noreply.github.com> Date: Sun, 30 Mar 2025 13:34:45 -0700 Subject: [PATCH 094/505] Remove scan interval option from NUT (#141845) Remove scan interval option and test case, migrate config and add migration test case --- homeassistant/components/nut/__init__.py | 14 +++---- homeassistant/components/nut/config_flow.py | 41 ++------------------ homeassistant/components/nut/const.py | 2 - homeassistant/components/nut/strings.json | 9 ----- tests/components/nut/test_config_flow.py | 43 --------------------- tests/components/nut/test_init.py | 27 +++++++++++++ 6 files changed, 36 insertions(+), 100 deletions(-) diff --git a/homeassistant/components/nut/__init__.py b/homeassistant/components/nut/__init__.py index dc260dffe96..bec388db9b1 100644 --- a/homeassistant/components/nut/__init__.py +++ b/homeassistant/components/nut/__init__.py @@ -25,12 +25,7 @@ from homeassistant.exceptions import ConfigEntryAuthFailed, HomeAssistantError from homeassistant.helpers import device_registry as dr from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed -from .const import ( - DEFAULT_SCAN_INTERVAL, - DOMAIN, - INTEGRATION_SUPPORTED_COMMANDS, - PLATFORMS, -) +from .const import DOMAIN, INTEGRATION_SUPPORTED_COMMANDS, PLATFORMS NUT_FAKE_SERIAL = ["unknown", "blank"] @@ -68,7 +63,10 @@ async def async_setup_entry(hass: HomeAssistant, entry: NutConfigEntry) -> bool: alias = config.get(CONF_ALIAS) username = config.get(CONF_USERNAME) password = config.get(CONF_PASSWORD) - scan_interval = entry.options.get(CONF_SCAN_INTERVAL, DEFAULT_SCAN_INTERVAL) + if CONF_SCAN_INTERVAL in entry.options: + current_options = {**entry.options} + current_options.pop(CONF_SCAN_INTERVAL) + hass.config_entries.async_update_entry(entry, options=current_options) data = PyNUTData(host, port, alias, username, password) @@ -101,7 +99,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: NutConfigEntry) -> bool: config_entry=entry, name="NUT resource status", update_method=async_update_data, - update_interval=timedelta(seconds=scan_interval), + update_interval=timedelta(seconds=60), always_update=False, ) diff --git a/homeassistant/components/nut/config_flow.py b/homeassistant/components/nut/config_flow.py index b1b44966d14..d1bbd209626 100644 --- a/homeassistant/components/nut/config_flow.py +++ b/homeassistant/components/nut/config_flow.py @@ -9,27 +9,21 @@ from typing import Any from aionut import NUTError, NUTLoginError import voluptuous as vol -from homeassistant.config_entries import ( - ConfigEntry, - ConfigFlow, - ConfigFlowResult, - OptionsFlow, -) +from homeassistant.config_entries import ConfigEntry, ConfigFlow, ConfigFlowResult from homeassistant.const import ( CONF_ALIAS, CONF_BASE, CONF_HOST, CONF_PASSWORD, CONF_PORT, - CONF_SCAN_INTERVAL, CONF_USERNAME, ) -from homeassistant.core import HomeAssistant, callback +from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import AbortFlow from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo from . import PyNUTData -from .const import DEFAULT_HOST, DEFAULT_PORT, DEFAULT_SCAN_INTERVAL, DOMAIN +from .const import DEFAULT_HOST, DEFAULT_PORT, DOMAIN _LOGGER = logging.getLogger(__name__) @@ -230,32 +224,3 @@ class NutConfigFlow(ConfigFlow, domain=DOMAIN): data_schema=vol.Schema(AUTH_SCHEMA), errors=errors, ) - - @staticmethod - @callback - def async_get_options_flow(config_entry: ConfigEntry) -> OptionsFlow: - """Get the options flow for this handler.""" - return OptionsFlowHandler() - - -class OptionsFlowHandler(OptionsFlow): - """Handle a option flow for nut.""" - - async def async_step_init( - self, user_input: dict[str, Any] | None = None - ) -> ConfigFlowResult: - """Handle options flow.""" - if user_input is not None: - return self.async_create_entry(title="", data=user_input) - - scan_interval = self.config_entry.options.get( - CONF_SCAN_INTERVAL, DEFAULT_SCAN_INTERVAL - ) - - base_schema = { - vol.Optional(CONF_SCAN_INTERVAL, default=scan_interval): vol.All( - vol.Coerce(int), vol.Clamp(min=10, max=300) - ) - } - - return self.async_show_form(step_id="init", data_schema=vol.Schema(base_schema)) diff --git a/homeassistant/components/nut/const.py b/homeassistant/components/nut/const.py index d741d8e95f9..175e971a12a 100644 --- a/homeassistant/components/nut/const.py +++ b/homeassistant/components/nut/const.py @@ -19,8 +19,6 @@ DEFAULT_PORT = 3493 KEY_STATUS = "ups.status" KEY_STATUS_DISPLAY = "ups.status.display" -DEFAULT_SCAN_INTERVAL = 60 - STATE_TYPES = { "OL": "Online", "OB": "On Battery", diff --git a/homeassistant/components/nut/strings.json b/homeassistant/components/nut/strings.json index 56952778753..a7231b22235 100644 --- a/homeassistant/components/nut/strings.json +++ b/homeassistant/components/nut/strings.json @@ -38,15 +38,6 @@ "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]" } }, - "options": { - "step": { - "init": { - "data": { - "scan_interval": "Scan Interval (seconds)" - } - } - } - }, "device_automation": { "action_type": { "beeper_disable": "Disable UPS beeper/buzzer", diff --git a/tests/components/nut/test_config_flow.py b/tests/components/nut/test_config_flow.py index ed9c87f2f90..e9bee23c4ce 100644 --- a/tests/components/nut/test_config_flow.py +++ b/tests/components/nut/test_config_flow.py @@ -14,7 +14,6 @@ from homeassistant.const import ( CONF_PASSWORD, CONF_PORT, CONF_RESOURCES, - CONF_SCAN_INTERVAL, CONF_USERNAME, ) from homeassistant.core import HomeAssistant @@ -573,45 +572,3 @@ async def test_abort_if_already_setup_alias(hass: HomeAssistant) -> None: assert result3["type"] is FlowResultType.ABORT assert result3["reason"] == "already_configured" - - -async def test_options_flow(hass: HomeAssistant) -> None: - """Test config flow options.""" - - config_entry = MockConfigEntry( - domain=DOMAIN, - unique_id="abcde12345", - data=VALID_CONFIG, - ) - config_entry.add_to_hass(hass) - - with patch("homeassistant.components.nut.async_setup_entry", return_value=True): - result = await hass.config_entries.options.async_init(config_entry.entry_id) - - assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "init" - - result = await hass.config_entries.options.async_configure( - result["flow_id"], user_input={} - ) - - assert result["type"] is FlowResultType.CREATE_ENTRY - assert config_entry.options == { - CONF_SCAN_INTERVAL: 60, - } - - with patch("homeassistant.components.nut.async_setup_entry", return_value=True): - result2 = await hass.config_entries.options.async_init(config_entry.entry_id) - - assert result2["type"] is FlowResultType.FORM - assert result2["step_id"] == "init" - - result2 = await hass.config_entries.options.async_configure( - result2["flow_id"], - user_input={CONF_SCAN_INTERVAL: 12}, - ) - - assert result2["type"] is FlowResultType.CREATE_ENTRY - assert config_entry.options == { - CONF_SCAN_INTERVAL: 12, - } diff --git a/tests/components/nut/test_init.py b/tests/components/nut/test_init.py index 4f11ffb5bb0..b3cf23bddcc 100644 --- a/tests/components/nut/test_init.py +++ b/tests/components/nut/test_init.py @@ -12,6 +12,7 @@ from homeassistant.const import ( CONF_HOST, CONF_PASSWORD, CONF_PORT, + CONF_SCAN_INTERVAL, CONF_USERNAME, STATE_UNAVAILABLE, ) @@ -23,6 +24,32 @@ from .util import _get_mock_nutclient, async_init_integration from tests.common import MockConfigEntry +async def test_config_entry_migrations(hass: HomeAssistant) -> None: + """Test that config entries were migrated.""" + mock_pynut = _get_mock_nutclient( + list_vars={"battery.voltage": "voltage"}, + list_ups={"ups1": "UPS 1"}, + ) + + with patch( + "homeassistant.components.nut.AIONUTClient", + return_value=mock_pynut, + ): + entry = MockConfigEntry( + domain=DOMAIN, + data={ + CONF_HOST: "1.1.1.1", + CONF_PORT: 123, + }, + options={CONF_SCAN_INTERVAL: 30}, + ) + entry.add_to_hass(hass) + + assert await hass.config_entries.async_setup(entry.entry_id) + + assert CONF_SCAN_INTERVAL not in entry.options + + async def test_async_setup_entry(hass: HomeAssistant) -> None: """Test a successful setup entry.""" entry = MockConfigEntry( From f0464564453719a865de6e60d90631a9d2a1f579 Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Sun, 30 Mar 2025 22:36:46 +0200 Subject: [PATCH 095/505] Replace "Home" and "Away" in `opentherm_gw` with common strings (#141867) --- homeassistant/components/opentherm_gw/strings.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/opentherm_gw/strings.json b/homeassistant/components/opentherm_gw/strings.json index cc57a7d9e0c..ae1a1eb9276 100644 --- a/homeassistant/components/opentherm_gw/strings.json +++ b/homeassistant/components/opentherm_gw/strings.json @@ -172,8 +172,8 @@ "vcc": "Vcc (5V)", "led_e": "LED E", "led_f": "LED F", - "home": "Home", - "away": "Away", + "home": "[%key:common::state::home%]", + "away": "[%key:common::state::not_home%]", "ds1820": "DS1820", "dhw_block": "Block hot water" } From 6c3e85fd5e591f2754652cc8697595cd8f1a61ca Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Sun, 30 Mar 2025 22:44:48 +0200 Subject: [PATCH 096/505] Replace "Home" and "Away" in reolink with common strings (#141869) --- homeassistant/components/reolink/strings.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/reolink/strings.json b/homeassistant/components/reolink/strings.json index 9a6db7b5d67..a884b3ed431 100644 --- a/homeassistant/components/reolink/strings.json +++ b/homeassistant/components/reolink/strings.json @@ -842,8 +842,8 @@ "state": { "off": "[%key:common::state::off%]", "disarm": "Disarmed", - "home": "Home", - "away": "Away" + "home": "[%key:common::state::home%]", + "away": "[%key:common::state::not_home%]" } } }, From 5057343b6ad25b30f23bb3a3b44adf813361d98c Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Sun, 30 Mar 2025 22:49:52 +0200 Subject: [PATCH 097/505] Replace "Home" and "Away" in `vallox` with common strings (#141870) --- homeassistant/components/vallox/strings.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/vallox/strings.json b/homeassistant/components/vallox/strings.json index f00206826d3..2a074cf2015 100644 --- a/homeassistant/components/vallox/strings.json +++ b/homeassistant/components/vallox/strings.json @@ -152,8 +152,8 @@ "selector": { "profile": { "options": { - "home": "Home", - "away": "Away", + "home": "[%key:common::state::home%]", + "away": "[%key:common::state::not_home%]", "boost": "Boost", "fireplace": "Fireplace", "extra": "Extra" From 3ab2cd3fb7c29f11fc09506cd62834a0f27b882b Mon Sep 17 00:00:00 2001 From: tdfountain <174762217+tdfountain@users.noreply.github.com> Date: Sun, 30 Mar 2025 14:21:11 -0700 Subject: [PATCH 098/505] Set device connection MAC address for networked devices in NUT (#141856) * Set device connection MAC address for networked devices * Change variable name for consistency --- homeassistant/components/nut/__init__.py | 18 +++++++++++++++++- 1 file changed, 17 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/nut/__init__.py b/homeassistant/components/nut/__init__.py index bec388db9b1..e9d6f41f8d8 100644 --- a/homeassistant/components/nut/__init__.py +++ b/homeassistant/components/nut/__init__.py @@ -23,6 +23,7 @@ from homeassistant.const import ( from homeassistant.core import Event, HomeAssistant, callback from homeassistant.exceptions import ConfigEntryAuthFailed, HomeAssistantError from homeassistant.helpers import device_registry as dr +from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC, format_mac from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from .const import DOMAIN, INTEGRATION_SUPPORTED_COMMANDS, PLATFORMS @@ -153,10 +154,15 @@ async def async_setup_entry(hass: HomeAssistant, entry: NutConfigEntry) -> bool: coordinator, data, unique_id, user_available_commands ) + connections: set[tuple[str, str]] | None = None + if data.device_info.mac_address is not None: + connections = {(CONNECTION_NETWORK_MAC, data.device_info.mac_address)} + device_registry = dr.async_get(hass) device_registry.async_get_or_create( config_entry_id=entry.entry_id, identifiers={(DOMAIN, unique_id)}, + connections=connections, name=data.name.title(), manufacturer=data.device_info.manufacturer, model=data.device_info.model, @@ -244,6 +250,7 @@ class NUTDeviceInfo: model_id: str | None = None firmware: str | None = None serial: str | None = None + mac_address: str | None = None device_location: str | None = None @@ -307,9 +314,18 @@ class PyNUTData: model_id: str | None = self._status.get("device.part") firmware = _firmware_from_status(self._status) serial = _serial_from_status(self._status) + mac_address: str | None = self._status.get("device.macaddr") + if mac_address is not None: + mac_address = format_mac(mac_address.rstrip().replace(" ", ":")) device_location: str | None = self._status.get("device.location") return NUTDeviceInfo( - manufacturer, model, model_id, firmware, serial, device_location + manufacturer, + model, + model_id, + firmware, + serial, + mac_address, + device_location, ) async def _async_get_status(self) -> dict[str, str]: From 1c16fb8e424c7653464fc93196e77681b079f477 Mon Sep 17 00:00:00 2001 From: tdfountain <174762217+tdfountain@users.noreply.github.com> Date: Sun, 30 Mar 2025 14:41:56 -0700 Subject: [PATCH 099/505] Set and check unique id of config in NUT (#141783) * Set and check unique id in config * Update homeassistant/components/nut/config_flow.py Set unique ID and abort only if value is defined Co-authored-by: J. Nick Koston * Add duplicate ID test case for multiple devices * Add unique ID check to config flow step for UPS * Update homeassistant/components/nut/__init__.py Fix to only set config_entries unique ID if not None Co-authored-by: J. Nick Koston * Remove duplicate config flow call --------- Co-authored-by: J. Nick Koston Co-authored-by: J. Nick Koston --- homeassistant/components/nut/__init__.py | 3 + homeassistant/components/nut/config_flow.py | 14 ++- tests/components/nut/test_config_flow.py | 100 +++++++++++++++++++- 3 files changed, 114 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/nut/__init__.py b/homeassistant/components/nut/__init__.py index e9d6f41f8d8..3c67b28196a 100644 --- a/homeassistant/components/nut/__init__.py +++ b/homeassistant/components/nut/__init__.py @@ -121,6 +121,9 @@ async def async_setup_entry(hass: HomeAssistant, entry: NutConfigEntry) -> bool: if unique_id is None: unique_id = entry.entry_id + elif entry.unique_id is None: + hass.config_entries.async_update_entry(entry, unique_id=unique_id) + if username is not None and password is not None: # Dynamically add outlet integration commands additional_integration_commands = set() diff --git a/homeassistant/components/nut/config_flow.py b/homeassistant/components/nut/config_flow.py index d1bbd209626..5996c1c0087 100644 --- a/homeassistant/components/nut/config_flow.py +++ b/homeassistant/components/nut/config_flow.py @@ -22,7 +22,7 @@ from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import AbortFlow from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo -from . import PyNUTData +from . import PyNUTData, _unique_id_from_status from .const import DEFAULT_HOST, DEFAULT_PORT, DOMAIN _LOGGER = logging.getLogger(__name__) @@ -119,6 +119,11 @@ class NutConfigFlow(ConfigFlow, domain=DOMAIN): if self._host_port_alias_already_configured(nut_config): return self.async_abort(reason="already_configured") + + if unique_id := _unique_id_from_status(info["available_resources"]): + await self.async_set_unique_id(unique_id) + self._abort_if_unique_id_configured() + title = _format_host_port_alias(nut_config) return self.async_create_entry(title=title, data=nut_config) @@ -141,8 +146,13 @@ class NutConfigFlow(ConfigFlow, domain=DOMAIN): self.nut_config.update(user_input) if self._host_port_alias_already_configured(nut_config): return self.async_abort(reason="already_configured") - _, errors, placeholders = await self._async_validate_or_error(nut_config) + + info, errors, placeholders = await self._async_validate_or_error(nut_config) if not errors: + if unique_id := _unique_id_from_status(info["available_resources"]): + await self.async_set_unique_id(unique_id) + self._abort_if_unique_id_configured() + title = _format_host_port_alias(nut_config) return self.async_create_entry(title=title, data=nut_config) diff --git a/tests/components/nut/test_config_flow.py b/tests/components/nut/test_config_flow.py index e9bee23c4ce..6237ad341b4 100644 --- a/tests/components/nut/test_config_flow.py +++ b/tests/components/nut/test_config_flow.py @@ -20,7 +20,7 @@ from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo -from .util import _get_mock_nutclient +from .util import _get_mock_nutclient, async_init_integration from tests.common import MockConfigEntry @@ -524,6 +524,104 @@ async def test_abort_if_already_setup(hass: HomeAssistant) -> None: assert result2["reason"] == "already_configured" +async def test_abort_duplicate_unique_ids(hass: HomeAssistant) -> None: + """Test we abort if unique_id is already setup.""" + + list_vars = { + "device.mfr": "Some manufacturer", + "device.model": "Some model", + "device.serial": "0000-1", + } + await async_init_integration( + hass, + list_ups={"ups1": "UPS 1"}, + list_vars=list_vars, + ) + + mock_pynut = _get_mock_nutclient(list_ups={"ups2": "UPS 2"}, list_vars=list_vars) + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + with patch( + "homeassistant.components.nut.AIONUTClient", + return_value=mock_pynut, + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_HOST: "1.1.1.1", + CONF_PORT: 2222, + }, + ) + await hass.async_block_till_done() + + assert result2["type"] is FlowResultType.ABORT + assert result2["reason"] == "already_configured" + + +async def test_abort_multiple_ups_duplicate_unique_ids(hass: HomeAssistant) -> None: + """Test we abort on multiple devices if unique_id is already setup.""" + + list_vars = { + "device.mfr": "Some manufacturer", + "device.model": "Some model", + "device.serial": "0000-1", + } + + mock_pynut = _get_mock_nutclient( + list_ups={"ups2": "UPS 2", "ups3": "UPS 3"}, list_vars=list_vars + ) + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {} + + with patch( + "homeassistant.components.nut.AIONUTClient", + return_value=mock_pynut, + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_HOST: "1.1.1.1", + CONF_PORT: 2222, + }, + ) + await hass.async_block_till_done() + + assert result2["step_id"] == "ups" + assert result2["type"] is FlowResultType.FORM + + await async_init_integration( + hass, + list_ups={"ups1": "UPS 1"}, + list_vars=list_vars, + ) + + with ( + patch( + "homeassistant.components.nut.AIONUTClient", + return_value=mock_pynut, + ), + patch( + "homeassistant.components.nut.async_setup_entry", + return_value=True, + ), + ): + result3 = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_ALIAS: "ups2"}, + ) + await hass.async_block_till_done() + + assert result3["type"] is FlowResultType.ABORT + assert result3["reason"] == "already_configured" + + async def test_abort_if_already_setup_alias(hass: HomeAssistant) -> None: """Test we abort if component is already setup with same alias.""" config_entry = MockConfigEntry( From 7336178e03a80be11f54eadd6833b9a2a40bae30 Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Mon, 31 Mar 2025 00:00:48 +0200 Subject: [PATCH 100/505] Fix test RuntimeWarnings for hassio (#141883) --- tests/components/hassio/test_websocket_api.py | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/components/hassio/test_websocket_api.py b/tests/components/hassio/test_websocket_api.py index b695cc1794a..6334fb096a2 100644 --- a/tests/components/hassio/test_websocket_api.py +++ b/tests/components/hassio/test_websocket_api.py @@ -42,6 +42,7 @@ def mock_all( aioclient_mock: AiohttpClientMocker, supervisor_is_connected: AsyncMock, resolution_info: AsyncMock, + addon_info: AsyncMock, ) -> None: """Mock all setup requests.""" aioclient_mock.post("http://127.0.0.1/homeassistant/options", json={"result": "ok"}) From 704d7a037cb793de41132b9f2b02a1c09d0c8638 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 30 Mar 2025 15:14:17 -1000 Subject: [PATCH 101/505] Bump aioesphomeapi to 29.8.0 (#141888) changelog: https://github.com/esphome/aioesphomeapi/compare/v29.7.0...v29.8.0 --- homeassistant/components/esphome/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/esphome/manifest.json b/homeassistant/components/esphome/manifest.json index 075185dffbb..954968f5e2c 100644 --- a/homeassistant/components/esphome/manifest.json +++ b/homeassistant/components/esphome/manifest.json @@ -16,7 +16,7 @@ "loggers": ["aioesphomeapi", "noiseprotocol", "bleak_esphome"], "mqtt": ["esphome/discover/#"], "requirements": [ - "aioesphomeapi==29.7.0", + "aioesphomeapi==29.8.0", "esphome-dashboard-api==1.2.3", "bleak-esphome==2.12.0" ], diff --git a/requirements_all.txt b/requirements_all.txt index df321a5f112..c1826880c99 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -243,7 +243,7 @@ aioelectricitymaps==0.4.0 aioemonitor==1.0.5 # homeassistant.components.esphome -aioesphomeapi==29.7.0 +aioesphomeapi==29.8.0 # homeassistant.components.flo aioflo==2021.11.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 4b8df3aa1a8..60532be192a 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -231,7 +231,7 @@ aioelectricitymaps==0.4.0 aioemonitor==1.0.5 # homeassistant.components.esphome -aioesphomeapi==29.7.0 +aioesphomeapi==29.8.0 # homeassistant.components.flo aioflo==2021.11.0 From 018651ff1dd0b50b227c1c47dad48d2c62a69bf0 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 30 Mar 2025 15:22:47 -1000 Subject: [PATCH 102/505] Improve handling of empty iterable in async_add_entities (#141889) * Improve handling of empty iterable in async_add_entities We had two checks here because we were doing an empty iterable check. If its a list we can check it directly but if its not we need to convert it to a list to know if its empty. * tweaks * tasks never used --- homeassistant/helpers/entity_platform.py | 58 ++++++++++++------------ 1 file changed, 29 insertions(+), 29 deletions(-) diff --git a/homeassistant/helpers/entity_platform.py b/homeassistant/helpers/entity_platform.py index 11a9786f86e..2ca331a185b 100644 --- a/homeassistant/helpers/entity_platform.py +++ b/homeassistant/helpers/entity_platform.py @@ -573,9 +573,9 @@ class EntityPlatform: async def _async_add_and_update_entities( self, - coros: list[Coroutine[Any, Any, None]], entities: list[Entity], timeout: float, + config_subentry_id: str | None, ) -> None: """Add entities for a single platform and update them. @@ -585,10 +585,21 @@ class EntityPlatform: event loop and will finish faster if we run them concurrently. """ results: list[BaseException | None] | None = None - tasks = [create_eager_task(coro, loop=self.hass.loop) for coro in coros] + entity_registry = ent_reg.async_get(self.hass) try: async with self.hass.timeout.async_timeout(timeout, self.domain): - results = await asyncio.gather(*tasks, return_exceptions=True) + results = await asyncio.gather( + *( + create_eager_task( + self._async_add_entity( + entity, True, entity_registry, config_subentry_id + ), + loop=self.hass.loop, + ) + for entity in entities + ), + return_exceptions=True, + ) except TimeoutError: self.logger.warning( "Timed out adding entities for domain %s with platform %s after %ds", @@ -615,9 +626,9 @@ class EntityPlatform: async def _async_add_entities( self, - coros: list[Coroutine[Any, Any, None]], entities: list[Entity], timeout: float, + config_subentry_id: str | None, ) -> None: """Add entities for a single platform without updating. @@ -626,13 +637,15 @@ class EntityPlatform: to the event loop so we can await the coros directly without scheduling them as tasks. """ + entity_registry = ent_reg.async_get(self.hass) try: async with self.hass.timeout.async_timeout(timeout, self.domain): - for idx, coro in enumerate(coros): + for entity in entities: try: - await coro + await self._async_add_entity( + entity, False, entity_registry, config_subentry_id + ) except Exception as ex: - entity = entities[idx] self.logger.exception( "Error adding entity %s for domain %s with platform %s", entity.entity_id, @@ -670,33 +683,20 @@ class EntityPlatform: f"entry {self.config_entry.entry_id if self.config_entry else None}" ) + entities: list[Entity] = ( + new_entities if type(new_entities) is list else list(new_entities) + ) # handle empty list from component/platform - if not new_entities: # type: ignore[truthy-iterable] + if not entities: return - hass = self.hass - entity_registry = ent_reg.async_get(hass) - coros: list[Coroutine[Any, Any, None]] = [] - entities: list[Entity] = [] - for entity in new_entities: - coros.append( - self._async_add_entity( - entity, update_before_add, entity_registry, config_subentry_id - ) - ) - entities.append(entity) - - # No entities for processing - if not coros: - return - - timeout = max(SLOW_ADD_ENTITY_MAX_WAIT * len(coros), SLOW_ADD_MIN_TIMEOUT) + timeout = max(SLOW_ADD_ENTITY_MAX_WAIT * len(entities), SLOW_ADD_MIN_TIMEOUT) if update_before_add: - add_func = self._async_add_and_update_entities + await self._async_add_and_update_entities( + entities, timeout, config_subentry_id + ) else: - add_func = self._async_add_entities - - await add_func(coros, entities, timeout) + await self._async_add_entities(entities, timeout, config_subentry_id) if ( (self.config_entry and self.config_entry.pref_disable_polling) From f043404cd9bd13f83fcd3b5e7f67d5d3d0be83d5 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 30 Mar 2025 15:23:54 -1000 Subject: [PATCH 103/505] Fix duplicate call to async_write_ha_state when adding elkm1 entities (#141890) When an entity is added state is always written in add_to_platform_finish: https://github.com/home-assistant/core/blob/7336178e03a80be11f54eadd6833b9a2a40bae30/homeassistant/helpers/entity.py#L1384 We should not do it in async_added_to_hass as well --- homeassistant/components/elkm1/entity.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/elkm1/entity.py b/homeassistant/components/elkm1/entity.py index d9967d93967..ce717578eae 100644 --- a/homeassistant/components/elkm1/entity.py +++ b/homeassistant/components/elkm1/entity.py @@ -100,7 +100,11 @@ class ElkEntity(Entity): return {"index": self._element.index + 1} def _element_changed(self, element: Element, changeset: dict[str, Any]) -> None: - pass + """Handle changes to the element. + + This method is called when the element changes. It should be + overridden by subclasses to handle the changes. + """ @callback def _element_callback(self, element: Element, changeset: dict[str, Any]) -> None: @@ -111,7 +115,7 @@ class ElkEntity(Entity): async def async_added_to_hass(self) -> None: """Register callback for ElkM1 changes and update entity state.""" self._element.add_callback(self._element_callback) - self._element_callback(self._element, {}) + self._element_changed(self._element, {}) @property def device_info(self) -> DeviceInfo: From 1639163c2eba4af6fe21f4b1e876667031b73f44 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 30 Mar 2025 15:25:24 -1000 Subject: [PATCH 104/505] Handle encryption being disabled on an ESPHome device (#141887) fixes #121442 --- .../components/esphome/config_flow.py | 15 ++++++++++ homeassistant/components/esphome/manager.py | 2 ++ homeassistant/components/esphome/strings.json | 3 ++ tests/components/esphome/test_config_flow.py | 30 +++++++++++++++++++ 4 files changed, 50 insertions(+) diff --git a/homeassistant/components/esphome/config_flow.py b/homeassistant/components/esphome/config_flow.py index 955a93cd2b7..686d77d9b34 100644 --- a/homeassistant/components/esphome/config_flow.py +++ b/homeassistant/components/esphome/config_flow.py @@ -128,8 +128,23 @@ class EsphomeFlowHandler(ConfigFlow, domain=DOMAIN): self._password = "" return await self._async_authenticate_or_add() + if error is None and entry_data.get(CONF_NOISE_PSK): + return await self.async_step_reauth_encryption_removed_confirm() return await self.async_step_reauth_confirm() + async def async_step_reauth_encryption_removed_confirm( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle reauthorization flow when encryption was removed.""" + if user_input is not None: + self._noise_psk = None + return self._async_get_entry() + + return self.async_show_form( + step_id="reauth_encryption_removed_confirm", + description_placeholders={"name": self._name}, + ) + async def async_step_reauth_confirm( self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: diff --git a/homeassistant/components/esphome/manager.py b/homeassistant/components/esphome/manager.py index 0a47fb66815..7ce96a0f510 100644 --- a/homeassistant/components/esphome/manager.py +++ b/homeassistant/components/esphome/manager.py @@ -13,6 +13,7 @@ from aioesphomeapi import ( APIConnectionError, APIVersion, DeviceInfo as EsphomeDeviceInfo, + EncryptionHelloAPIError, EntityInfo, HomeassistantServiceCall, InvalidAuthAPIError, @@ -570,6 +571,7 @@ class ESPHomeManager: if isinstance( err, ( + EncryptionHelloAPIError, RequiresEncryptionAPIError, InvalidEncryptionKeyAPIError, InvalidAuthAPIError, diff --git a/homeassistant/components/esphome/strings.json b/homeassistant/components/esphome/strings.json index c6916a3636d..437b9ac2098 100644 --- a/homeassistant/components/esphome/strings.json +++ b/homeassistant/components/esphome/strings.json @@ -43,6 +43,9 @@ }, "description": "The ESPHome device {name} enabled transport encryption or changed the encryption key. Please enter the updated key. You can find it in the ESPHome Dashboard or in your device configuration." }, + "reauth_encryption_removed_confirm": { + "description": "The ESPHome device {name} disabled transport encryption. Please confirm that you want to remove the encryption key and allow unencrypted connections." + }, "discovery_confirm": { "description": "Do you want to add the ESPHome node `{name}` to Home Assistant?", "title": "Discovered ESPHome node" diff --git a/tests/components/esphome/test_config_flow.py b/tests/components/esphome/test_config_flow.py index afca6f76b43..d48a1f40482 100644 --- a/tests/components/esphome/test_config_flow.py +++ b/tests/components/esphome/test_config_flow.py @@ -1047,6 +1047,36 @@ async def test_reauth_confirm_invalid_with_unique_id( assert entry.data[CONF_NOISE_PSK] == VALID_NOISE_PSK +@pytest.mark.usefixtures("mock_zeroconf") +async def test_reauth_encryption_key_removed( + hass: HomeAssistant, mock_client, mock_setup_entry: None +) -> None: + """Test reauth when the encryption key was removed.""" + entry = MockConfigEntry( + domain=DOMAIN, + data={ + CONF_HOST: "127.0.0.1", + CONF_PORT: 6053, + CONF_PASSWORD: "", + CONF_NOISE_PSK: VALID_NOISE_PSK, + }, + unique_id="test", + ) + entry.add_to_hass(hass) + + result = await entry.start_reauth_flow(hass) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "reauth_encryption_removed_confirm" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={} + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reauth_successful" + assert entry.data[CONF_NOISE_PSK] == "" + + async def test_discovery_dhcp_updates_host( hass: HomeAssistant, mock_client: APIClient, mock_setup_entry: None ) -> None: From 0c4cb27fe94129fe65db11362a6f73cda90c6cc3 Mon Sep 17 00:00:00 2001 From: Allen Porter Date: Sun, 30 Mar 2025 20:14:52 -0700 Subject: [PATCH 105/505] Add OAuth support for Model Context Protocol (mcp) integration (#141874) * Add authentication support for Model Context Protocol (mcp) integration * Update homeassistant/components/mcp/application_credentials.py Co-authored-by: Paulus Schoutsen * Handle MCP servers with ports --------- Co-authored-by: Paulus Schoutsen --- homeassistant/components/mcp/__init__.py | 45 +- .../components/mcp/application_credentials.py | 35 ++ homeassistant/components/mcp/config_flow.py | 213 +++++++- homeassistant/components/mcp/const.py | 4 + homeassistant/components/mcp/coordinator.py | 44 +- homeassistant/components/mcp/manifest.json | 1 + .../components/mcp/quality_scale.yaml | 4 +- homeassistant/components/mcp/strings.json | 17 +- .../generated/application_credentials.py | 1 + tests/components/mcp/conftest.py | 66 ++- tests/components/mcp/test_config_flow.py | 512 ++++++++++++++++-- tests/components/mcp/test_init.py | 38 +- 12 files changed, 904 insertions(+), 76 deletions(-) create mode 100644 homeassistant/components/mcp/application_credentials.py diff --git a/homeassistant/components/mcp/__init__.py b/homeassistant/components/mcp/__init__.py index 41b6a260d9f..a2a148dffd5 100644 --- a/homeassistant/components/mcp/__init__.py +++ b/homeassistant/components/mcp/__init__.py @@ -3,12 +3,15 @@ from __future__ import annotations from dataclasses import dataclass +from typing import cast +from homeassistant.components.application_credentials import AuthorizationServer from homeassistant.core import HomeAssistant -from homeassistant.helpers import llm +from homeassistant.helpers import config_entry_oauth2_flow, llm -from .const import DOMAIN -from .coordinator import ModelContextProtocolCoordinator +from .application_credentials import authorization_server_context +from .const import CONF_ACCESS_TOKEN, CONF_AUTHORIZATION_URL, CONF_TOKEN_URL, DOMAIN +from .coordinator import ModelContextProtocolCoordinator, TokenManager from .types import ModelContextProtocolConfigEntry __all__ = [ @@ -20,11 +23,45 @@ __all__ = [ API_PROMPT = "The following tools are available from a remote server named {name}." +async def async_get_config_entry_implementation( + hass: HomeAssistant, entry: ModelContextProtocolConfigEntry +) -> config_entry_oauth2_flow.AbstractOAuth2Implementation | None: + """OAuth implementation for the config entry.""" + if "auth_implementation" not in entry.data: + return None + with authorization_server_context( + AuthorizationServer( + authorize_url=entry.data[CONF_AUTHORIZATION_URL], + token_url=entry.data[CONF_TOKEN_URL], + ) + ): + return await config_entry_oauth2_flow.async_get_config_entry_implementation( + hass, entry + ) + + +async def _create_token_manager( + hass: HomeAssistant, entry: ModelContextProtocolConfigEntry +) -> TokenManager | None: + """Create a OAuth token manager for the config entry if the server requires authentication.""" + if not (implementation := await async_get_config_entry_implementation(hass, entry)): + return None + + session = config_entry_oauth2_flow.OAuth2Session(hass, entry, implementation) + + async def token_manager() -> str: + await session.async_ensure_token_valid() + return cast(str, session.token[CONF_ACCESS_TOKEN]) + + return token_manager + + async def async_setup_entry( hass: HomeAssistant, entry: ModelContextProtocolConfigEntry ) -> bool: """Set up Model Context Protocol from a config entry.""" - coordinator = ModelContextProtocolCoordinator(hass, entry) + token_manager = await _create_token_manager(hass, entry) + coordinator = ModelContextProtocolCoordinator(hass, entry, token_manager) await coordinator.async_config_entry_first_refresh() unsub = llm.async_register_api( diff --git a/homeassistant/components/mcp/application_credentials.py b/homeassistant/components/mcp/application_credentials.py new file mode 100644 index 00000000000..9b8bed894e4 --- /dev/null +++ b/homeassistant/components/mcp/application_credentials.py @@ -0,0 +1,35 @@ +"""Application credentials platform for Model Context Protocol.""" + +from __future__ import annotations + +from collections.abc import Generator +from contextlib import contextmanager +import contextvars + +from homeassistant.components.application_credentials import AuthorizationServer +from homeassistant.core import HomeAssistant + +CONF_ACTIVE_AUTHORIZATION_SERVER = "active_authorization_server" + +_mcp_context: contextvars.ContextVar[AuthorizationServer] = contextvars.ContextVar( + "mcp_authorization_server_context" +) + + +@contextmanager +def authorization_server_context( + authorization_server: AuthorizationServer, +) -> Generator[None]: + """Context manager for setting the active authorization server.""" + token = _mcp_context.set(authorization_server) + try: + yield + finally: + _mcp_context.reset(token) + + +async def async_get_authorization_server(hass: HomeAssistant) -> AuthorizationServer: + """Return authorization server, for the default auth implementation.""" + if _mcp_context.get() is None: + raise RuntimeError("No MCP authorization server set in context") + return _mcp_context.get() diff --git a/homeassistant/components/mcp/config_flow.py b/homeassistant/components/mcp/config_flow.py index 92e0052c665..0f34962f7ee 100644 --- a/homeassistant/components/mcp/config_flow.py +++ b/homeassistant/components/mcp/config_flow.py @@ -2,20 +2,29 @@ from __future__ import annotations +from collections.abc import Mapping import logging -from typing import Any +from typing import Any, cast import httpx import voluptuous as vol +from yarl import URL -from homeassistant.config_entries import ConfigFlow, ConfigFlowResult -from homeassistant.const import CONF_URL +from homeassistant.components.application_credentials import AuthorizationServer +from homeassistant.config_entries import SOURCE_REAUTH, ConfigFlowResult +from homeassistant.const import CONF_TOKEN, CONF_URL from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import config_validation as cv +from homeassistant.helpers.config_entry_oauth2_flow import ( + AbstractOAuth2FlowHandler, + async_get_implementations, +) -from .const import DOMAIN -from .coordinator import mcp_client +from . import async_get_config_entry_implementation +from .application_credentials import authorization_server_context +from .const import CONF_ACCESS_TOKEN, CONF_AUTHORIZATION_URL, CONF_TOKEN_URL, DOMAIN +from .coordinator import TokenManager, mcp_client _LOGGER = logging.getLogger(__name__) @@ -25,8 +34,62 @@ STEP_USER_DATA_SCHEMA = vol.Schema( } ) +# OAuth server discovery endpoint for rfc8414 +OAUTH_DISCOVERY_ENDPOINT = ".well-known/oauth-authorization-server" +MCP_DISCOVERY_HEADERS = { + "MCP-Protocol-Version": "2025-03-26", +} -async def validate_input(hass: HomeAssistant, data: dict[str, Any]) -> dict[str, Any]: + +async def async_discover_oauth_config( + hass: HomeAssistant, mcp_server_url: str +) -> AuthorizationServer: + """Discover the OAuth configuration for the MCP server. + + This implements the functionality in the MCP spec for discovery. If the MCP server URL + is https://api.example.com/v1/mcp, then: + - The authorization base URL is https://api.example.com + - The metadata endpoint MUST be at https://api.example.com/.well-known/oauth-authorization-server + - For servers that do not implement OAuth 2.0 Authorization Server Metadata, the client uses + default paths relative to the authorization base URL. + """ + parsed_url = URL(mcp_server_url) + discovery_endpoint = str(parsed_url.with_path(OAUTH_DISCOVERY_ENDPOINT)) + try: + async with httpx.AsyncClient(headers=MCP_DISCOVERY_HEADERS) as client: + response = await client.get(discovery_endpoint) + response.raise_for_status() + except httpx.TimeoutException as error: + _LOGGER.info("Timeout connecting to MCP server: %s", error) + raise TimeoutConnectError from error + except httpx.HTTPStatusError as error: + if error.response.status_code == 404: + _LOGGER.info("Authorization Server Metadata not found, using default paths") + return AuthorizationServer( + authorize_url=str(parsed_url.with_path("/authorize")), + token_url=str(parsed_url.with_path("/token")), + ) + raise CannotConnect from error + except httpx.HTTPError as error: + _LOGGER.info("Cannot discover OAuth configuration: %s", error) + raise CannotConnect from error + + data = response.json() + authorize_url = data["authorization_endpoint"] + token_url = data["token_endpoint"] + if authorize_url.startswith("/"): + authorize_url = str(parsed_url.with_path(authorize_url)) + if token_url.startswith("/"): + token_url = str(parsed_url.with_path(token_url)) + return AuthorizationServer( + authorize_url=authorize_url, + token_url=token_url, + ) + + +async def validate_input( + hass: HomeAssistant, data: dict[str, Any], token_manager: TokenManager | None = None +) -> dict[str, Any]: """Validate the user input and connect to the MCP server.""" url = data[CONF_URL] try: @@ -34,7 +97,7 @@ async def validate_input(hass: HomeAssistant, data: dict[str, Any]) -> dict[str, except vol.Invalid as error: raise InvalidUrl from error try: - async with mcp_client(url) as session: + async with mcp_client(url, token_manager=token_manager) as session: response = await session.initialize() except httpx.TimeoutException as error: _LOGGER.info("Timeout connecting to MCP server: %s", error) @@ -56,10 +119,17 @@ async def validate_input(hass: HomeAssistant, data: dict[str, Any]) -> dict[str, return {"title": response.serverInfo.name} -class ModelContextProtocolConfigFlow(ConfigFlow, domain=DOMAIN): +class ModelContextProtocolConfigFlow(AbstractOAuth2FlowHandler, domain=DOMAIN): """Handle a config flow for Model Context Protocol.""" VERSION = 1 + DOMAIN = DOMAIN + logger = _LOGGER + + def __init__(self) -> None: + """Initialize the config flow.""" + super().__init__() + self.data: dict[str, Any] = {} async def async_step_user( self, user_input: dict[str, Any] | None = None @@ -76,7 +146,8 @@ class ModelContextProtocolConfigFlow(ConfigFlow, domain=DOMAIN): except CannotConnect: errors["base"] = "cannot_connect" except InvalidAuth: - return self.async_abort(reason="invalid_auth") + self.data[CONF_URL] = user_input[CONF_URL] + return await self.async_step_auth_discovery() except MissingCapabilities: return self.async_abort(reason="missing_capabilities") except Exception: @@ -90,6 +161,130 @@ class ModelContextProtocolConfigFlow(ConfigFlow, domain=DOMAIN): step_id="user", data_schema=STEP_USER_DATA_SCHEMA, errors=errors ) + async def async_step_auth_discovery( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle the OAuth server discovery step. + + Since this OAuth server requires authentication, this step will attempt + to find the OAuth medata then run the OAuth authentication flow. + """ + try: + authorization_server = await async_discover_oauth_config( + self.hass, self.data[CONF_URL] + ) + except TimeoutConnectError: + return self.async_abort(reason="timeout_connect") + except CannotConnect: + return self.async_abort(reason="cannot_connect") + except Exception: + _LOGGER.exception("Unexpected exception") + return self.async_abort(reason="unknown") + else: + _LOGGER.info("OAuth configuration: %s", authorization_server) + self.data.update( + { + CONF_AUTHORIZATION_URL: authorization_server.authorize_url, + CONF_TOKEN_URL: authorization_server.token_url, + } + ) + return await self.async_step_credentials_choice() + + def authorization_server(self) -> AuthorizationServer: + """Return the authorization server provided by the MCP server.""" + return AuthorizationServer( + self.data[CONF_AUTHORIZATION_URL], + self.data[CONF_TOKEN_URL], + ) + + async def async_step_credentials_choice( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Step to ask they user if they would like to add credentials. + + This is needed since we can't automatically assume existing credentials + should be used given they may be for another existing server. + """ + with authorization_server_context(self.authorization_server()): + if not await async_get_implementations(self.hass, self.DOMAIN): + return await self.async_step_new_credentials() + return self.async_show_menu( + step_id="credentials_choice", + menu_options=["pick_implementation", "new_credentials"], + ) + + async def async_step_new_credentials( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Step to take the frontend flow to enter new credentials.""" + return self.async_abort(reason="missing_credentials") + + async def async_step_pick_implementation( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle the pick implementation step. + + This exists to dynamically set application credentials Authorization Server + based on the values form the OAuth discovery step. + """ + with authorization_server_context(self.authorization_server()): + return await super().async_step_pick_implementation(user_input) + + async def async_oauth_create_entry(self, data: dict) -> ConfigFlowResult: + """Create an entry for the flow. + + Ok to override if you want to fetch extra info or even add another step. + """ + config_entry_data = { + **self.data, + **data, + } + + async def token_manager() -> str: + return cast(str, data[CONF_TOKEN][CONF_ACCESS_TOKEN]) + + try: + info = await validate_input(self.hass, config_entry_data, token_manager) + except TimeoutConnectError: + return self.async_abort(reason="timeout_connect") + except CannotConnect: + return self.async_abort(reason="cannot_connect") + except MissingCapabilities: + return self.async_abort(reason="missing_capabilities") + except Exception: + _LOGGER.exception("Unexpected exception") + return self.async_abort(reason="unknown") + + # Unique id based on the application credentials OAuth Client ID + if self.source == SOURCE_REAUTH: + return self.async_update_reload_and_abort( + self._get_reauth_entry(), data=config_entry_data + ) + await self.async_set_unique_id(config_entry_data["auth_implementation"]) + return self.async_create_entry( + title=info["title"], + data=config_entry_data, + ) + + async def async_step_reauth( + self, entry_data: Mapping[str, Any] + ) -> ConfigFlowResult: + """Perform reauth upon an API authentication error.""" + return await self.async_step_reauth_confirm() + + async def async_step_reauth_confirm( + self, user_input: Mapping[str, Any] | None = None + ) -> ConfigFlowResult: + """Confirm reauth dialog.""" + if user_input is None: + return self.async_show_form(step_id="reauth_confirm") + config_entry = self._get_reauth_entry() + self.data = {**config_entry.data} + self.flow_impl = await async_get_config_entry_implementation( # type: ignore[assignment] + self.hass, config_entry + ) + return await self.async_step_auth() + class InvalidUrl(HomeAssistantError): """Error to indicate the URL format is invalid.""" diff --git a/homeassistant/components/mcp/const.py b/homeassistant/components/mcp/const.py index 675b2d7031c..13f63b02c73 100644 --- a/homeassistant/components/mcp/const.py +++ b/homeassistant/components/mcp/const.py @@ -1,3 +1,7 @@ """Constants for the Model Context Protocol integration.""" DOMAIN = "mcp" + +CONF_ACCESS_TOKEN = "access_token" +CONF_AUTHORIZATION_URL = "authorization_url" +CONF_TOKEN_URL = "token_url" diff --git a/homeassistant/components/mcp/coordinator.py b/homeassistant/components/mcp/coordinator.py index 6e66036c548..f560875292f 100644 --- a/homeassistant/components/mcp/coordinator.py +++ b/homeassistant/components/mcp/coordinator.py @@ -1,7 +1,7 @@ """Types for the Model Context Protocol integration.""" import asyncio -from collections.abc import AsyncGenerator +from collections.abc import AsyncGenerator, Awaitable, Callable from contextlib import asynccontextmanager import datetime import logging @@ -15,7 +15,7 @@ from voluptuous_openapi import convert_to_voluptuous from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_URL from homeassistant.core import HomeAssistant -from homeassistant.exceptions import HomeAssistantError +from homeassistant.exceptions import ConfigEntryAuthFailed, HomeAssistantError from homeassistant.helpers import llm from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from homeassistant.util.json import JsonObjectType @@ -27,16 +27,28 @@ _LOGGER = logging.getLogger(__name__) UPDATE_INTERVAL = datetime.timedelta(minutes=30) TIMEOUT = 10 +TokenManager = Callable[[], Awaitable[str]] + @asynccontextmanager -async def mcp_client(url: str) -> AsyncGenerator[ClientSession]: +async def mcp_client( + url: str, + token_manager: TokenManager | None = None, +) -> AsyncGenerator[ClientSession]: """Create a server-sent event MCP client. This is an asynccontext manager that exists to wrap other async context managers so that the coordinator has a single object to manage. """ + headers: dict[str, str] = {} + if token_manager is not None: + token = await token_manager() + headers["Authorization"] = f"Bearer {token}" try: - async with sse_client(url=url) as streams, ClientSession(*streams) as session: + async with ( + sse_client(url=url, headers=headers) as streams, + ClientSession(*streams) as session, + ): await session.initialize() yield session except ExceptionGroup as err: @@ -53,12 +65,14 @@ class ModelContextProtocolTool(llm.Tool): description: str | None, parameters: vol.Schema, server_url: str, + token_manager: TokenManager | None = None, ) -> None: """Initialize the tool.""" self.name = name self.description = description self.parameters = parameters self.server_url = server_url + self.token_manager = token_manager async def async_call( self, @@ -69,7 +83,7 @@ class ModelContextProtocolTool(llm.Tool): """Call the tool.""" try: async with asyncio.timeout(TIMEOUT): - async with mcp_client(self.server_url) as session: + async with mcp_client(self.server_url, self.token_manager) as session: result = await session.call_tool( tool_input.tool_name, tool_input.tool_args ) @@ -87,7 +101,12 @@ class ModelContextProtocolCoordinator(DataUpdateCoordinator[list[llm.Tool]]): config_entry: ConfigEntry - def __init__(self, hass: HomeAssistant, config_entry: ConfigEntry) -> None: + def __init__( + self, + hass: HomeAssistant, + config_entry: ConfigEntry, + token_manager: TokenManager | None = None, + ) -> None: """Initialize ModelContextProtocolCoordinator.""" super().__init__( hass, @@ -96,6 +115,7 @@ class ModelContextProtocolCoordinator(DataUpdateCoordinator[list[llm.Tool]]): config_entry=config_entry, update_interval=UPDATE_INTERVAL, ) + self.token_manager = token_manager async def _async_update_data(self) -> list[llm.Tool]: """Fetch data from API endpoint. @@ -105,11 +125,20 @@ class ModelContextProtocolCoordinator(DataUpdateCoordinator[list[llm.Tool]]): """ try: async with asyncio.timeout(TIMEOUT): - async with mcp_client(self.config_entry.data[CONF_URL]) as session: + async with mcp_client( + self.config_entry.data[CONF_URL], self.token_manager + ) as session: result = await session.list_tools() except TimeoutError as error: _LOGGER.debug("Timeout when listing tools: %s", error) raise UpdateFailed(f"Timeout when listing tools: {error}") from error + except httpx.HTTPStatusError as error: + _LOGGER.debug("Error communicating with API: %s", error) + if error.response.status_code == 401 and self.token_manager is not None: + raise ConfigEntryAuthFailed( + "The MCP server requires authentication" + ) from error + raise UpdateFailed(f"Error communicating with API: {error}") from error except httpx.HTTPError as err: _LOGGER.debug("Error communicating with API: %s", err) raise UpdateFailed(f"Error communicating with API: {err}") from err @@ -129,6 +158,7 @@ class ModelContextProtocolCoordinator(DataUpdateCoordinator[list[llm.Tool]]): tool.description, parameters, self.config_entry.data[CONF_URL], + self.token_manager, ) ) return tools diff --git a/homeassistant/components/mcp/manifest.json b/homeassistant/components/mcp/manifest.json index 9cd1e2899a6..7ff64d29aa4 100644 --- a/homeassistant/components/mcp/manifest.json +++ b/homeassistant/components/mcp/manifest.json @@ -3,6 +3,7 @@ "name": "Model Context Protocol", "codeowners": ["@allenporter"], "config_flow": true, + "dependencies": ["application_credentials"], "documentation": "https://www.home-assistant.io/integrations/mcp", "iot_class": "local_polling", "quality_scale": "silver", diff --git a/homeassistant/components/mcp/quality_scale.yaml b/homeassistant/components/mcp/quality_scale.yaml index 76afdf5860d..f22343c8d0e 100644 --- a/homeassistant/components/mcp/quality_scale.yaml +++ b/homeassistant/components/mcp/quality_scale.yaml @@ -44,9 +44,7 @@ rules: parallel-updates: status: exempt comment: Integration does not have platforms. - reauthentication-flow: - status: exempt - comment: Integration does not support authentication. + reauthentication-flow: done test-coverage: done # Gold diff --git a/homeassistant/components/mcp/strings.json b/homeassistant/components/mcp/strings.json index 97a75fc6f85..2b59d4ffa51 100644 --- a/homeassistant/components/mcp/strings.json +++ b/homeassistant/components/mcp/strings.json @@ -8,6 +8,15 @@ "data_description": { "url": "The remote MCP server URL for the SSE endpoint, for example http://example/sse" } + }, + "pick_implementation": { + "title": "[%key:common::config_flow::title::oauth2_pick_implementation%]", + "data": { + "implementation": "Credentials" + }, + "data_description": { + "implementation": "The credentials to use for the OAuth2 flow" + } } }, "error": { @@ -17,9 +26,15 @@ "invalid_url": "Must be a valid MCP server URL e.g. https://example.com/sse" }, "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", "missing_capabilities": "The MCP server does not support a required capability (Tools)", - "already_configured": "[%key:common::config_flow::abort::already_configured_device%]" + "missing_credentials": "[%key:common::config_flow::abort::oauth2_missing_credentials%]", + "reauth_account_mismatch": "The authenticated user does not match the MCP Server user that needed re-authentication.", + "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]", + "timeout_connect": "[%key:common::config_flow::error::timeout_connect%]", + "unknown": "[%key:common::config_flow::error::unknown%]" } } } diff --git a/homeassistant/generated/application_credentials.py b/homeassistant/generated/application_credentials.py index 68c6de405e6..eaa4c657b56 100644 --- a/homeassistant/generated/application_credentials.py +++ b/homeassistant/generated/application_credentials.py @@ -19,6 +19,7 @@ APPLICATION_CREDENTIALS = [ "iotty", "lametric", "lyric", + "mcp", "microbees", "monzo", "myuplink", diff --git a/tests/components/mcp/conftest.py b/tests/components/mcp/conftest.py index d86603a12ed..b6d6958d3d9 100644 --- a/tests/components/mcp/conftest.py +++ b/tests/components/mcp/conftest.py @@ -1,17 +1,34 @@ """Common fixtures for the Model Context Protocol tests.""" from collections.abc import Generator +import datetime from unittest.mock import AsyncMock, patch import pytest -from homeassistant.components.mcp.const import DOMAIN -from homeassistant.const import CONF_URL +from homeassistant.components.application_credentials import ( + ClientCredential, + async_import_client_credential, +) +from homeassistant.components.mcp.const import ( + CONF_ACCESS_TOKEN, + CONF_AUTHORIZATION_URL, + CONF_TOKEN_URL, + DOMAIN, +) +from homeassistant.const import CONF_TOKEN, CONF_URL from homeassistant.core import HomeAssistant +from homeassistant.setup import async_setup_component from tests.common import MockConfigEntry TEST_API_NAME = "Memory Server" +MCP_SERVER_URL = "http://1.1.1.1:8080/sse" +CLIENT_ID = "test-client-id" +CLIENT_SECRET = "test-client-secret" +AUTH_DOMAIN = "some-auth-domain" +OAUTH_AUTHORIZE_URL = "https://example-auth-server.com/authorize-path" +OAUTH_TOKEN_URL = "https://example-auth-server.com/token-path" @pytest.fixture @@ -29,6 +46,7 @@ def mock_mcp_client() -> Generator[AsyncMock]: with ( patch("homeassistant.components.mcp.coordinator.sse_client"), patch("homeassistant.components.mcp.coordinator.ClientSession") as mock_session, + patch("homeassistant.components.mcp.coordinator.TIMEOUT", 1), ): yield mock_session.return_value.__aenter__ @@ -43,3 +61,47 @@ def mock_config_entry(hass: HomeAssistant) -> MockConfigEntry: ) config_entry.add_to_hass(hass) return config_entry + + +@pytest.fixture(name="credential") +async def mock_credential(hass: HomeAssistant) -> None: + """Fixture that provides the ClientCredential for the test.""" + assert await async_setup_component(hass, "application_credentials", {}) + await async_import_client_credential( + hass, + DOMAIN, + ClientCredential(CLIENT_ID, CLIENT_SECRET), + AUTH_DOMAIN, + ) + + +@pytest.fixture(name="config_entry_token_expiration") +def mock_config_entry_token_expiration() -> datetime.datetime: + """Fixture to mock the token expiration.""" + return datetime.datetime.now(datetime.UTC) + datetime.timedelta(days=1) + + +@pytest.fixture(name="config_entry_with_auth") +def mock_config_entry_with_auth( + hass: HomeAssistant, + config_entry_token_expiration: datetime.datetime, +) -> MockConfigEntry: + """Fixture to load the integration with authentication.""" + config_entry = MockConfigEntry( + domain=DOMAIN, + unique_id=AUTH_DOMAIN, + data={ + "auth_implementation": AUTH_DOMAIN, + CONF_URL: MCP_SERVER_URL, + CONF_AUTHORIZATION_URL: OAUTH_AUTHORIZE_URL, + CONF_TOKEN_URL: OAUTH_TOKEN_URL, + CONF_TOKEN: { + CONF_ACCESS_TOKEN: "test-access-token", + "refresh_token": "test-refresh-token", + "expires_at": config_entry_token_expiration.timestamp(), + }, + }, + title=TEST_API_NAME, + ) + config_entry.add_to_hass(hass) + return config_entry diff --git a/tests/components/mcp/test_config_flow.py b/tests/components/mcp/test_config_flow.py index 29733e653a6..426b3267195 100644 --- a/tests/components/mcp/test_config_flow.py +++ b/tests/components/mcp/test_config_flow.py @@ -1,20 +1,70 @@ """Test the Model Context Protocol config flow.""" +import json from typing import Any from unittest.mock import AsyncMock, Mock import httpx import pytest +import respx from homeassistant import config_entries -from homeassistant.components.mcp.const import DOMAIN -from homeassistant.const import CONF_URL +from homeassistant.components.mcp.const import ( + CONF_AUTHORIZATION_URL, + CONF_TOKEN_URL, + DOMAIN, +) +from homeassistant.const import CONF_TOKEN, CONF_URL from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType +from homeassistant.helpers import config_entry_oauth2_flow -from .conftest import TEST_API_NAME +from .conftest import ( + AUTH_DOMAIN, + CLIENT_ID, + MCP_SERVER_URL, + OAUTH_AUTHORIZE_URL, + OAUTH_TOKEN_URL, + TEST_API_NAME, +) from tests.common import MockConfigEntry +from tests.test_util.aiohttp import AiohttpClientMocker +from tests.typing import ClientSessionGenerator + +MCP_SERVER_BASE_URL = "http://1.1.1.1:8080" +OAUTH_DISCOVERY_ENDPOINT = ( + f"{MCP_SERVER_BASE_URL}/.well-known/oauth-authorization-server" +) +OAUTH_SERVER_METADATA_RESPONSE = httpx.Response( + status_code=200, + text=json.dumps( + { + "authorization_endpoint": OAUTH_AUTHORIZE_URL, + "token_endpoint": OAUTH_TOKEN_URL, + } + ), +) +CALLBACK_PATH = "/auth/external/callback" +OAUTH_CALLBACK_URL = f"https://example.com{CALLBACK_PATH}" +OAUTH_CODE = "abcd" +OAUTH_TOKEN_PAYLOAD = { + "refresh_token": "mock-refresh-token", + "access_token": "mock-access-token", + "type": "Bearer", + "expires_in": 60, +} + + +def encode_state(hass: HomeAssistant, flow_id: str) -> str: + """Encode the OAuth JWT.""" + return config_entry_oauth2_flow._encode_jwt( + hass, + { + "flow_id": flow_id, + "redirect_uri": OAUTH_CALLBACK_URL, + }, + ) async def test_form( @@ -34,15 +84,19 @@ async def test_form( result = await hass.config_entries.flow.async_configure( result["flow_id"], { - CONF_URL: "http://1.1.1.1/sse", + CONF_URL: MCP_SERVER_URL, }, ) assert result["type"] is FlowResultType.CREATE_ENTRY assert result["title"] == TEST_API_NAME assert result["data"] == { - CONF_URL: "http://1.1.1.1/sse", + CONF_URL: MCP_SERVER_URL, } + # Config entry does not have a unique id + assert result["result"] + assert result["result"].unique_id is None + assert len(mock_setup_entry.mock_calls) == 1 @@ -73,7 +127,7 @@ async def test_form_mcp_client_error( result = await hass.config_entries.flow.async_configure( result["flow_id"], { - CONF_URL: "http://1.1.1.1/sse", + CONF_URL: MCP_SERVER_URL, }, ) @@ -89,50 +143,18 @@ async def test_form_mcp_client_error( result = await hass.config_entries.flow.async_configure( result["flow_id"], { - CONF_URL: "http://1.1.1.1/sse", + CONF_URL: MCP_SERVER_URL, }, ) assert result["type"] is FlowResultType.CREATE_ENTRY assert result["title"] == TEST_API_NAME assert result["data"] == { - CONF_URL: "http://1.1.1.1/sse", + CONF_URL: MCP_SERVER_URL, } assert len(mock_setup_entry.mock_calls) == 1 -@pytest.mark.parametrize( - ("side_effect", "expected_error"), - [ - ( - httpx.HTTPStatusError("", request=None, response=httpx.Response(401)), - "invalid_auth", - ), - ], -) -async def test_form_mcp_client_error_abort( - hass: HomeAssistant, - mock_setup_entry: AsyncMock, - mock_mcp_client: Mock, - side_effect: Exception, - expected_error: str, -) -> None: - """Test we handle different client library errors that end with an abort.""" - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_USER} - ) - mock_mcp_client.side_effect = side_effect - result = await hass.config_entries.flow.async_configure( - result["flow_id"], - { - CONF_URL: "http://1.1.1.1/sse", - }, - ) - - assert result["type"] is FlowResultType.ABORT - assert result["reason"] == expected_error - - @pytest.mark.parametrize( "user_input", [ @@ -165,14 +187,14 @@ async def test_input_form_validation_error( result = await hass.config_entries.flow.async_configure( result["flow_id"], { - CONF_URL: "http://1.1.1.1/sse", + CONF_URL: MCP_SERVER_URL, }, ) assert result["type"] is FlowResultType.CREATE_ENTRY assert result["title"] == TEST_API_NAME assert result["data"] == { - CONF_URL: "http://1.1.1.1/sse", + CONF_URL: MCP_SERVER_URL, } assert len(mock_setup_entry.mock_calls) == 1 @@ -183,7 +205,7 @@ async def test_unique_url( """Test that the same url cannot be configured twice.""" config_entry = MockConfigEntry( domain=DOMAIN, - data={CONF_URL: "http://1.1.1.1/sse"}, + data={CONF_URL: MCP_SERVER_URL}, title=TEST_API_NAME, ) config_entry.add_to_hass(hass) @@ -201,7 +223,7 @@ async def test_unique_url( result = await hass.config_entries.flow.async_configure( result["flow_id"], { - CONF_URL: "http://1.1.1.1/sse", + CONF_URL: MCP_SERVER_URL, }, ) @@ -226,9 +248,409 @@ async def test_server_missing_capbilities( result = await hass.config_entries.flow.async_configure( result["flow_id"], { - CONF_URL: "http://1.1.1.1/sse", + CONF_URL: MCP_SERVER_URL, }, ) assert result["type"] is FlowResultType.ABORT assert result["reason"] == "missing_capabilities" + + +@respx.mock +async def test_oauth_discovery_flow_without_credentials( + hass: HomeAssistant, + mock_setup_entry: AsyncMock, + mock_mcp_client: Mock, +) -> None: + """Test for an OAuth discoveryflow for an MCP server where the user has not yet entered credentials.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + # MCP Server returns 401 indicating the client needs to authenticate + mock_mcp_client.side_effect = httpx.HTTPStatusError( + "Authentication required", request=None, response=httpx.Response(401) + ) + # Prepare the OAuth Server metadata + respx.get(OAUTH_DISCOVERY_ENDPOINT).mock( + return_value=OAUTH_SERVER_METADATA_RESPONSE + ) + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_URL: MCP_SERVER_URL, + }, + ) + + # The config flow will abort and the user will be taken to the application credentials UI + # to enter their credentials. + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "missing_credentials" + + +async def perform_oauth_flow( + hass: HomeAssistant, + aioclient_mock: AiohttpClientMocker, + hass_client_no_auth: ClientSessionGenerator, + result: config_entries.ConfigFlowResult, + authorize_url: str = OAUTH_AUTHORIZE_URL, + token_url: str = OAUTH_TOKEN_URL, +) -> config_entries.ConfigFlowResult: + """Perform the common steps of the OAuth flow. + + Expects to be called from the step where the user selects credentials. + """ + state = config_entry_oauth2_flow._encode_jwt( + hass, + { + "flow_id": result["flow_id"], + "redirect_uri": OAUTH_CALLBACK_URL, + }, + ) + assert result["url"] == ( + f"{authorize_url}?response_type=code&client_id={CLIENT_ID}" + f"&redirect_uri={OAUTH_CALLBACK_URL}" + f"&state={state}" + ) + + client = await hass_client_no_auth() + resp = await client.get(f"{CALLBACK_PATH}?code={OAUTH_CODE}&state={state}") + assert resp.status == 200 + assert resp.headers["content-type"] == "text/html; charset=utf-8" + + aioclient_mock.post( + token_url, + json=OAUTH_TOKEN_PAYLOAD, + ) + + return result + + +@pytest.mark.parametrize( + ("oauth_server_metadata_response", "expected_authorize_url", "expected_token_url"), + [ + (OAUTH_SERVER_METADATA_RESPONSE, OAUTH_AUTHORIZE_URL, OAUTH_TOKEN_URL), + ( + httpx.Response( + status_code=200, + text=json.dumps( + { + "authorization_endpoint": "/authorize-path", + "token_endpoint": "/token-path", + } + ), + ), + f"{MCP_SERVER_BASE_URL}/authorize-path", + f"{MCP_SERVER_BASE_URL}/token-path", + ), + ( + httpx.Response(status_code=404), + f"{MCP_SERVER_BASE_URL}/authorize", + f"{MCP_SERVER_BASE_URL}/token", + ), + ], + ids=( + "discovery", + "relative_paths", + "no_discovery_metadata", + ), +) +@pytest.mark.usefixtures("current_request_with_host") +@respx.mock +async def test_authentication_flow( + hass: HomeAssistant, + mock_setup_entry: AsyncMock, + mock_mcp_client: Mock, + credential: None, + aioclient_mock: AiohttpClientMocker, + hass_client_no_auth: ClientSessionGenerator, + oauth_server_metadata_response: httpx.Response, + expected_authorize_url: str, + expected_token_url: str, +) -> None: + """Test for an OAuth authentication flow for an MCP server.""" + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + # MCP Server returns 401 indicating the client needs to authenticate + mock_mcp_client.side_effect = httpx.HTTPStatusError( + "Authentication required", request=None, response=httpx.Response(401) + ) + # Prepare the OAuth Server metadata + respx.get(OAUTH_DISCOVERY_ENDPOINT).mock( + return_value=oauth_server_metadata_response + ) + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_URL: MCP_SERVER_URL, + }, + ) + assert result["type"] is FlowResultType.MENU + assert result["step_id"] == "credentials_choice" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + "next_step_id": "pick_implementation", + }, + ) + assert result["type"] is FlowResultType.EXTERNAL_STEP + result = await perform_oauth_flow( + hass, + aioclient_mock, + hass_client_no_auth, + result, + authorize_url=expected_authorize_url, + token_url=expected_token_url, + ) + + # Client now accepts credentials + mock_mcp_client.side_effect = None + response = Mock() + response.serverInfo.name = TEST_API_NAME + mock_mcp_client.return_value.initialize.return_value = response + + result = await hass.config_entries.flow.async_configure(result["flow_id"]) + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == TEST_API_NAME + data = result["data"] + token = data.pop(CONF_TOKEN) + assert data == { + "auth_implementation": AUTH_DOMAIN, + CONF_URL: MCP_SERVER_URL, + CONF_AUTHORIZATION_URL: expected_authorize_url, + CONF_TOKEN_URL: expected_token_url, + } + assert token + token.pop("expires_at") + assert token == OAUTH_TOKEN_PAYLOAD + + assert len(mock_setup_entry.mock_calls) == 1 + + +@pytest.mark.parametrize( + ("side_effect", "expected_error"), + [ + (httpx.TimeoutException("Some timeout"), "timeout_connect"), + ( + httpx.HTTPStatusError("", request=None, response=httpx.Response(500)), + "cannot_connect", + ), + (httpx.HTTPError("Some HTTP error"), "cannot_connect"), + (Exception, "unknown"), + ], +) +@pytest.mark.usefixtures("current_request_with_host") +@respx.mock +async def test_oauth_discovery_failure( + hass: HomeAssistant, + mock_setup_entry: AsyncMock, + mock_mcp_client: Mock, + credential: None, + aioclient_mock: AiohttpClientMocker, + hass_client_no_auth: ClientSessionGenerator, + side_effect: Exception, + expected_error: str, +) -> None: + """Test for an OAuth authentication flow for an MCP server.""" + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + # MCP Server returns 401 indicating the client needs to authenticate + mock_mcp_client.side_effect = httpx.HTTPStatusError( + "Authentication required", request=None, response=httpx.Response(401) + ) + # Prepare the OAuth Server metadata + respx.get(OAUTH_DISCOVERY_ENDPOINT).mock(side_effect=side_effect) + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_URL: MCP_SERVER_URL, + }, + ) + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == expected_error + + +@pytest.mark.parametrize( + ("side_effect", "expected_error"), + [ + (httpx.TimeoutException("Some timeout"), "timeout_connect"), + ( + httpx.HTTPStatusError("", request=None, response=httpx.Response(500)), + "cannot_connect", + ), + (httpx.HTTPError("Some HTTP error"), "cannot_connect"), + (Exception, "unknown"), + ], +) +@pytest.mark.usefixtures("current_request_with_host") +@respx.mock +async def test_authentication_flow_server_failure_abort( + hass: HomeAssistant, + mock_setup_entry: AsyncMock, + mock_mcp_client: Mock, + credential: None, + aioclient_mock: AiohttpClientMocker, + hass_client_no_auth: ClientSessionGenerator, + side_effect: Exception, + expected_error: str, +) -> None: + """Test for an OAuth authentication flow for an MCP server.""" + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + # MCP Server returns 401 indicating the client needs to authenticate + mock_mcp_client.side_effect = httpx.HTTPStatusError( + "Authentication required", request=None, response=httpx.Response(401) + ) + # Prepare the OAuth Server metadata + respx.get(OAUTH_DISCOVERY_ENDPOINT).mock( + return_value=OAUTH_SERVER_METADATA_RESPONSE + ) + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_URL: MCP_SERVER_URL, + }, + ) + assert result["type"] is FlowResultType.MENU + assert result["step_id"] == "credentials_choice" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + "next_step_id": "pick_implementation", + }, + ) + assert result["type"] is FlowResultType.EXTERNAL_STEP + result = await perform_oauth_flow( + hass, + aioclient_mock, + hass_client_no_auth, + result, + ) + + # Client fails with an error + mock_mcp_client.side_effect = side_effect + + result = await hass.config_entries.flow.async_configure(result["flow_id"]) + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == expected_error + + +@pytest.mark.usefixtures("current_request_with_host") +@respx.mock +async def test_authentication_flow_server_missing_tool_capabilities( + hass: HomeAssistant, + mock_setup_entry: AsyncMock, + mock_mcp_client: Mock, + credential: None, + aioclient_mock: AiohttpClientMocker, + hass_client_no_auth: ClientSessionGenerator, +) -> None: + """Test for an OAuth authentication flow for an MCP server.""" + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + # MCP Server returns 401 indicating the client needs to authenticate + mock_mcp_client.side_effect = httpx.HTTPStatusError( + "Authentication required", request=None, response=httpx.Response(401) + ) + # Prepare the OAuth Server metadata + respx.get(OAUTH_DISCOVERY_ENDPOINT).mock( + return_value=OAUTH_SERVER_METADATA_RESPONSE + ) + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_URL: MCP_SERVER_URL, + }, + ) + assert result["type"] is FlowResultType.MENU + assert result["step_id"] == "credentials_choice" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + "next_step_id": "pick_implementation", + }, + ) + assert result["type"] is FlowResultType.EXTERNAL_STEP + result = await perform_oauth_flow( + hass, + aioclient_mock, + hass_client_no_auth, + result, + ) + + # Client can now authenticate + mock_mcp_client.side_effect = None + + response = Mock() + response.serverInfo.name = TEST_API_NAME + response.capabilities.tools = None + mock_mcp_client.return_value.initialize.return_value = response + + result = await hass.config_entries.flow.async_configure(result["flow_id"]) + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "missing_capabilities" + + +@pytest.mark.usefixtures("current_request_with_host") +@respx.mock +async def test_reauth_flow( + hass: HomeAssistant, + mock_setup_entry: AsyncMock, + mock_mcp_client: Mock, + credential: None, + config_entry_with_auth: MockConfigEntry, + aioclient_mock: AiohttpClientMocker, + hass_client_no_auth: ClientSessionGenerator, +) -> None: + """Test for an OAuth authentication flow for an MCP server.""" + config_entry_with_auth.async_start_reauth(hass) + await hass.async_block_till_done() + + flows = hass.config_entries.flow.async_progress() + assert len(flows) == 1 + result = flows[0] + assert result["step_id"] == "reauth_confirm" + + result = await hass.config_entries.flow.async_configure(result["flow_id"], {}) + + result = await perform_oauth_flow(hass, aioclient_mock, hass_client_no_auth, result) + + # Verify we can connect to the server + response = Mock() + response.serverInfo.name = TEST_API_NAME + mock_mcp_client.return_value.initialize.return_value = response + + result = await hass.config_entries.flow.async_configure(result["flow_id"]) + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reauth_successful" + + assert config_entry_with_auth.unique_id == AUTH_DOMAIN + assert config_entry_with_auth.title == TEST_API_NAME + data = {**config_entry_with_auth.data} + token = data.pop(CONF_TOKEN) + assert data == { + "auth_implementation": AUTH_DOMAIN, + CONF_URL: MCP_SERVER_URL, + CONF_AUTHORIZATION_URL: OAUTH_AUTHORIZE_URL, + CONF_TOKEN_URL: OAUTH_TOKEN_URL, + } + assert token + token.pop("expires_at") + assert token == OAUTH_TOKEN_PAYLOAD + + assert len(mock_setup_entry.mock_calls) == 1 diff --git a/tests/components/mcp/test_init.py b/tests/components/mcp/test_init.py index 460df2c5785..045fb99e181 100644 --- a/tests/components/mcp/test_init.py +++ b/tests/components/mcp/test_init.py @@ -76,17 +76,45 @@ async def test_init( assert config_entry.state is ConfigEntryState.NOT_LOADED +@pytest.mark.parametrize( + ("side_effect"), + [ + (httpx.TimeoutException("Some timeout")), + (httpx.HTTPStatusError("", request=None, response=httpx.Response(500))), + (httpx.HTTPStatusError("", request=None, response=httpx.Response(401))), + (httpx.HTTPError("Some HTTP error")), + ], +) async def test_mcp_server_failure( - hass: HomeAssistant, config_entry: MockConfigEntry, mock_mcp_client: Mock + hass: HomeAssistant, + config_entry: MockConfigEntry, + mock_mcp_client: Mock, + side_effect: Exception, ) -> None: """Test the integration fails to setup if the server fails initialization.""" + mock_mcp_client.side_effect = side_effect + + await hass.config_entries.async_setup(config_entry.entry_id) + assert config_entry.state is ConfigEntryState.SETUP_RETRY + + +async def test_mcp_server_authentication_failure( + hass: HomeAssistant, + credential: None, + config_entry_with_auth: MockConfigEntry, + mock_mcp_client: Mock, +) -> None: + """Test the integration fails to setup if the server fails authentication.""" mock_mcp_client.side_effect = httpx.HTTPStatusError( - "", request=None, response=httpx.Response(500) + "Authentication required", request=None, response=httpx.Response(401) ) - with patch("homeassistant.components.mcp.coordinator.TIMEOUT", 1): - await hass.config_entries.async_setup(config_entry.entry_id) - assert config_entry.state is ConfigEntryState.SETUP_RETRY + await hass.config_entries.async_setup(config_entry_with_auth.entry_id) + assert config_entry_with_auth.state is ConfigEntryState.SETUP_ERROR + + flows = hass.config_entries.flow.async_progress() + assert len(flows) == 1 + assert flows[0]["step_id"] == "reauth_confirm" async def test_list_tools_failure( From e88b3217411dd88ab614a67d636dfac2c22e2887 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Sun, 30 Mar 2025 23:31:45 -0400 Subject: [PATCH 106/505] Ensure user always has first turn for Google Gen AI (#141893) --- .../conversation.py | 9 ++++ .../test_conversation.py | 45 +++++++++++++++++++ 2 files changed, 54 insertions(+) diff --git a/homeassistant/components/google_generative_ai_conversation/conversation.py b/homeassistant/components/google_generative_ai_conversation/conversation.py index 5460f48f20e..7c19c5445a7 100644 --- a/homeassistant/components/google_generative_ai_conversation/conversation.py +++ b/homeassistant/components/google_generative_ai_conversation/conversation.py @@ -356,6 +356,15 @@ class GoogleGenerativeAIConversationEntity( messages.append(_convert_content(chat_content)) + # The SDK requires the first message to be a user message + # This is not the case if user used `start_conversation` + # Workaround from https://github.com/googleapis/python-genai/issues/529#issuecomment-2740964537 + if messages and messages[0].role != "user": + messages.insert( + 0, + Content(role="user", parts=[Part.from_text(text=" ")]), + ) + if tool_results: messages.append(_create_google_tool_response_content(tool_results)) generateContentConfig = GenerateContentConfig( diff --git a/tests/components/google_generative_ai_conversation/test_conversation.py b/tests/components/google_generative_ai_conversation/test_conversation.py index a2b238b9399..9c4ecc4f9a4 100644 --- a/tests/components/google_generative_ai_conversation/test_conversation.py +++ b/tests/components/google_generative_ai_conversation/test_conversation.py @@ -715,3 +715,48 @@ async def test_empty_content_in_chat_history( assert actual_history[0].parts[0].text == first_input assert actual_history[1].parts[0].text == " " + + +@pytest.mark.usefixtures("mock_init_component") +async def test_history_always_user_first_turn( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + snapshot: SnapshotAssertion, +) -> None: + """Test that the user is always first in the chat history.""" + with ( + chat_session.async_get_chat_session(hass) as session, + async_get_chat_log(hass, session) as chat_log, + ): + chat_log.async_add_assistant_content_without_tools( + conversation.AssistantContent( + agent_id="conversation.google_generative_ai_conversation", + content="Garage door left open, do you want to close it?", + ) + ) + + with patch("google.genai.chats.AsyncChats.create") as mock_create: + mock_chat = AsyncMock() + mock_create.return_value.send_message = mock_chat + chat_response = Mock(prompt_feedback=None) + mock_chat.return_value = chat_response + chat_response.candidates = [Mock(content=Mock(parts=[]))] + + await conversation.async_converse( + hass, + "hello", + chat_log.conversation_id, + Context(), + agent_id="conversation.google_generative_ai_conversation", + ) + + _, kwargs = mock_create.call_args + actual_history = kwargs.get("history") + + assert actual_history[0].parts[0].text == " " + assert actual_history[0].role == "user" + assert ( + actual_history[1].parts[0].text + == "Garage door left open, do you want to close it?" + ) + assert actual_history[1].role == "model" From 0be881bca633ea9a449dcfa9291536f6bfe8f5bc Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Mon, 31 Mar 2025 07:24:02 +0200 Subject: [PATCH 107/505] Fix test RuntimeWarnings for homeassistant_hardware (#141884) --- tests/components/homeassistant_hardware/test_config_flow.py | 2 ++ .../homeassistant_hardware/test_config_flow_failures.py | 1 + 2 files changed, 3 insertions(+) diff --git a/tests/components/homeassistant_hardware/test_config_flow.py b/tests/components/homeassistant_hardware/test_config_flow.py index 32c5a381233..9b7ae3e6f63 100644 --- a/tests/components/homeassistant_hardware/test_config_flow.py +++ b/tests/components/homeassistant_hardware/test_config_flow.py @@ -558,6 +558,7 @@ async def test_config_flow_zigbee_not_hassio(hass: HomeAssistant) -> None: assert zha_flow["step_id"] == "confirm" +@pytest.mark.usefixtures("addon_store_info") async def test_options_flow_zigbee_to_thread(hass: HomeAssistant) -> None: """Test the options flow, migrating Zigbee to Thread.""" config_entry = MockConfigEntry( @@ -649,6 +650,7 @@ async def test_options_flow_zigbee_to_thread(hass: HomeAssistant) -> None: assert config_entry.data["firmware"] == "spinel" +@pytest.mark.usefixtures("addon_store_info") async def test_options_flow_thread_to_zigbee(hass: HomeAssistant) -> None: """Test the options flow, migrating Thread to Zigbee.""" config_entry = MockConfigEntry( diff --git a/tests/components/homeassistant_hardware/test_config_flow_failures.py b/tests/components/homeassistant_hardware/test_config_flow_failures.py index fb38704ae61..251c4743bfe 100644 --- a/tests/components/homeassistant_hardware/test_config_flow_failures.py +++ b/tests/components/homeassistant_hardware/test_config_flow_failures.py @@ -660,6 +660,7 @@ async def test_options_flow_zigbee_to_thread_zha_configured( "ignore_translations_for_mock_domains", ["test_firmware_domain"], ) +@pytest.mark.usefixtures("addon_store_info") async def test_options_flow_thread_to_zigbee_otbr_configured( hass: HomeAssistant, ) -> None: From 15e03957a9daed77517d34c3b6a2e26aeb89c73f Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Mon, 31 Mar 2025 07:25:19 +0200 Subject: [PATCH 108/505] Replace "Away" in `generic_thermostat` with common string (#141880) --- homeassistant/components/generic_thermostat/strings.json | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/generic_thermostat/strings.json b/homeassistant/components/generic_thermostat/strings.json index 9b88d590eea..735e0b0f9e6 100644 --- a/homeassistant/components/generic_thermostat/strings.json +++ b/homeassistant/components/generic_thermostat/strings.json @@ -28,10 +28,10 @@ "presets": { "title": "Temperature presets", "data": { - "away_temp": "[%key:component::climate::entity_component::_::state_attributes::preset_mode::state::away%]", + "home_temp": "[%key:common::state::home%]", + "away_temp": "[%key:common::state::not_home%]", "comfort_temp": "[%key:component::climate::entity_component::_::state_attributes::preset_mode::state::comfort%]", "eco_temp": "[%key:component::climate::entity_component::_::state_attributes::preset_mode::state::eco%]", - "home_temp": "[%key:common::state::home%]", "sleep_temp": "[%key:component::climate::entity_component::_::state_attributes::preset_mode::state::sleep%]", "activity_temp": "[%key:component::climate::entity_component::_::state_attributes::preset_mode::state::activity%]" } @@ -63,10 +63,10 @@ "presets": { "title": "[%key:component::generic_thermostat::config::step::presets::title%]", "data": { - "away_temp": "[%key:component::climate::entity_component::_::state_attributes::preset_mode::state::away%]", + "home_temp": "[%key:common::state::home%]", + "away_temp": "[%key:common::state::not_home%]", "comfort_temp": "[%key:component::climate::entity_component::_::state_attributes::preset_mode::state::comfort%]", "eco_temp": "[%key:component::climate::entity_component::_::state_attributes::preset_mode::state::eco%]", - "home_temp": "[%key:common::state::home%]", "sleep_temp": "[%key:component::climate::entity_component::_::state_attributes::preset_mode::state::sleep%]", "activity_temp": "[%key:component::climate::entity_component::_::state_attributes::preset_mode::state::activity%]" } From ffc4fa1c2a699d5642d7ac4409c3469d8fb2083a Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Mon, 31 Mar 2025 07:29:17 +0200 Subject: [PATCH 109/505] Replace "Away" in `humidifier` with common string (#141872) --- homeassistant/components/humidifier/strings.json | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/humidifier/strings.json b/homeassistant/components/humidifier/strings.json index 436f7df8312..abd9ca5757b 100644 --- a/homeassistant/components/humidifier/strings.json +++ b/homeassistant/components/humidifier/strings.json @@ -63,14 +63,14 @@ "name": "Mode", "state": { "normal": "Normal", - "eco": "Eco", - "away": "Away", + "home": "[%key:common::state::home%]", + "away": "[%key:common::state::not_home%]", + "auto": "Auto", + "baby": "Baby", "boost": "Boost", "comfort": "Comfort", - "home": "[%key:common::state::home%]", - "sleep": "Sleep", - "auto": "Auto", - "baby": "Baby" + "eco": "Eco", + "sleep": "Sleep" } } } From 0b91aa920216c4e03ba5546a3fab126749efdcf0 Mon Sep 17 00:00:00 2001 From: Noah Husby <32528627+noahhusby@users.noreply.github.com> Date: Mon, 31 Mar 2025 01:32:14 -0400 Subject: [PATCH 110/505] Bump aiorussound to 4.5.0 (#141892) --- homeassistant/components/russound_rio/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/russound_rio/manifest.json b/homeassistant/components/russound_rio/manifest.json index f91406e8a4b..acedbaf0573 100644 --- a/homeassistant/components/russound_rio/manifest.json +++ b/homeassistant/components/russound_rio/manifest.json @@ -7,6 +7,6 @@ "iot_class": "local_push", "loggers": ["aiorussound"], "quality_scale": "silver", - "requirements": ["aiorussound==4.4.0"], + "requirements": ["aiorussound==4.5.0"], "zeroconf": ["_rio._tcp.local."] } diff --git a/requirements_all.txt b/requirements_all.txt index c1826880c99..e48a7f3936b 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -362,7 +362,7 @@ aioridwell==2024.01.0 aioruckus==0.42 # homeassistant.components.russound_rio -aiorussound==4.4.0 +aiorussound==4.5.0 # homeassistant.components.ruuvi_gateway aioruuvigateway==0.1.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 60532be192a..05e34442d3c 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -344,7 +344,7 @@ aioridwell==2024.01.0 aioruckus==0.42 # homeassistant.components.russound_rio -aiorussound==4.4.0 +aiorussound==4.5.0 # homeassistant.components.ruuvi_gateway aioruuvigateway==0.1.0 From 03366038ce5d76dcb68093eb7d916965071a9c30 Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Mon, 31 Mar 2025 07:35:03 +0200 Subject: [PATCH 111/505] Define "Away" state in `plugwise` using common string (#141875) --- homeassistant/components/plugwise/strings.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/plugwise/strings.json b/homeassistant/components/plugwise/strings.json index 99d501a79b5..96f5366bb2a 100644 --- a/homeassistant/components/plugwise/strings.json +++ b/homeassistant/components/plugwise/strings.json @@ -85,7 +85,7 @@ "preset_mode": { "state": { "asleep": "Night", - "away": "[%key:component::climate::entity_component::_::state_attributes::preset_mode::state::away%]", + "away": "[%key:common::state::not_home%]", "home": "[%key:common::state::home%]", "no_frost": "Anti-frost", "vacation": "Vacation" From 92ac396d192a8f9ef7f31c76366a46af843f8385 Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Mon, 31 Mar 2025 08:44:42 +0200 Subject: [PATCH 112/505] Use common state for "Away" in `honeywell` (#141894) --- homeassistant/components/honeywell/strings.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/honeywell/strings.json b/homeassistant/components/honeywell/strings.json index 2538e7101a1..ca152b99ccf 100644 --- a/homeassistant/components/honeywell/strings.json +++ b/homeassistant/components/honeywell/strings.json @@ -55,7 +55,7 @@ "preset_mode": { "state": { "hold": "Hold", - "away": "[%key:component::climate::entity_component::_::state_attributes::preset_mode::state::away%]", + "away": "[%key:common::state::not_home%]", "none": "[%key:component::climate::entity_component::_::state_attributes::preset_mode::state::none%]" } } From ee4bf165b55da49c7d5db9b439b6fe8b6bd43821 Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Mon, 31 Mar 2025 08:45:19 +0200 Subject: [PATCH 113/505] Use common state for "Away" in `nobo_hub` (#141895) --- homeassistant/components/nobo_hub/strings.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/nobo_hub/strings.json b/homeassistant/components/nobo_hub/strings.json index 1059934e896..5d1b8350edf 100644 --- a/homeassistant/components/nobo_hub/strings.json +++ b/homeassistant/components/nobo_hub/strings.json @@ -46,7 +46,7 @@ "global_override": { "name": "Global override", "state": { - "away": "[%key:component::climate::entity_component::_::state_attributes::preset_mode::state::away%]", + "away": "[%key:common::state::not_home%]", "comfort": "[%key:component::climate::entity_component::_::state_attributes::preset_mode::state::comfort%]", "eco": "[%key:component::climate::entity_component::_::state_attributes::preset_mode::state::eco%]", "none": "[%key:component::climate::entity_component::_::state_attributes::preset_mode::state::none%]" From c662b94d06922ba2c90884d18a955edb87bd6dfb Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Mon, 31 Mar 2025 09:56:10 +0200 Subject: [PATCH 114/505] Replace "Away" in `climate` with common state string, matching "Home" (#141897) * Replace "Away" in `climate` with common state string Also reordered the states a bit to group the two presence-based options at the top and order the rest alphabetically. * Prettier --- homeassistant/components/climate/strings.json | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/climate/strings.json b/homeassistant/components/climate/strings.json index 609eee71139..4682419d1e9 100644 --- a/homeassistant/components/climate/strings.json +++ b/homeassistant/components/climate/strings.json @@ -98,13 +98,13 @@ "name": "Preset", "state": { "none": "None", - "eco": "Eco", - "away": "Away", + "home": "[%key:common::state::home%]", + "away": "[%key:common::state::not_home%]", + "activity": "Activity", "boost": "Boost", "comfort": "Comfort", - "home": "[%key:common::state::home%]", - "sleep": "Sleep", - "activity": "Activity" + "eco": "Eco", + "sleep": "Sleep" } }, "preset_modes": { From f247183e11398b8f2c0f0f0f6db6dc58445521e9 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 30 Mar 2025 22:11:13 -1000 Subject: [PATCH 115/505] Bump SQLAlchemy to 2.0.40 (#141898) changelog: https://docs.sqlalchemy.org/en/20/changelog/changelog_20.html#change-2.0.40 --- homeassistant/components/recorder/manifest.json | 2 +- homeassistant/components/sql/manifest.json | 2 +- homeassistant/package_constraints.txt | 2 +- pyproject.toml | 2 +- requirements.txt | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 7 files changed, 7 insertions(+), 7 deletions(-) diff --git a/homeassistant/components/recorder/manifest.json b/homeassistant/components/recorder/manifest.json index f5336e2a85b..82fdeaca045 100644 --- a/homeassistant/components/recorder/manifest.json +++ b/homeassistant/components/recorder/manifest.json @@ -7,7 +7,7 @@ "iot_class": "local_push", "quality_scale": "internal", "requirements": [ - "SQLAlchemy==2.0.39", + "SQLAlchemy==2.0.40", "fnv-hash-fast==1.4.0", "psutil-home-assistant==0.0.1" ] diff --git a/homeassistant/components/sql/manifest.json b/homeassistant/components/sql/manifest.json index 37b5dc2b647..e6a45390120 100644 --- a/homeassistant/components/sql/manifest.json +++ b/homeassistant/components/sql/manifest.json @@ -6,5 +6,5 @@ "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/sql", "iot_class": "local_polling", - "requirements": ["SQLAlchemy==2.0.39", "sqlparse==0.5.0"] + "requirements": ["SQLAlchemy==2.0.40", "sqlparse==0.5.0"] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index eff2b89e0e8..3cccab5fca9 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -62,7 +62,7 @@ PyTurboJPEG==1.7.5 PyYAML==6.0.2 requests==2.32.3 securetar==2025.2.1 -SQLAlchemy==2.0.39 +SQLAlchemy==2.0.40 standard-aifc==3.13.0 standard-telnetlib==3.13.0 typing-extensions>=4.13.0,<5.0 diff --git a/pyproject.toml b/pyproject.toml index a542ac26f20..8900eab74be 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -68,7 +68,7 @@ dependencies = [ "PyYAML==6.0.2", "requests==2.32.3", "securetar==2025.2.1", - "SQLAlchemy==2.0.39", + "SQLAlchemy==2.0.40", "standard-aifc==3.13.0", "standard-telnetlib==3.13.0", "typing-extensions>=4.13.0,<5.0", diff --git a/requirements.txt b/requirements.txt index b13ef7b02e5..736736e8f20 100644 --- a/requirements.txt +++ b/requirements.txt @@ -40,7 +40,7 @@ python-slugify==8.0.4 PyYAML==6.0.2 requests==2.32.3 securetar==2025.2.1 -SQLAlchemy==2.0.39 +SQLAlchemy==2.0.40 standard-aifc==3.13.0 standard-telnetlib==3.13.0 typing-extensions>=4.13.0,<5.0 diff --git a/requirements_all.txt b/requirements_all.txt index e48a7f3936b..b75a6f50a2a 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -116,7 +116,7 @@ RtmAPI==0.7.2 # homeassistant.components.recorder # homeassistant.components.sql -SQLAlchemy==2.0.39 +SQLAlchemy==2.0.40 # homeassistant.components.tami4 Tami4EdgeAPI==3.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 05e34442d3c..3e55eed72d9 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -110,7 +110,7 @@ RtmAPI==0.7.2 # homeassistant.components.recorder # homeassistant.components.sql -SQLAlchemy==2.0.39 +SQLAlchemy==2.0.40 # homeassistant.components.tami4 Tami4EdgeAPI==3.0 From 0488012c7709234887f0c907dcba57ab18e87839 Mon Sep 17 00:00:00 2001 From: elmurato <1382097+elmurato@users.noreply.github.com> Date: Mon, 31 Mar 2025 10:23:40 +0200 Subject: [PATCH 116/505] Add sensor platform to Pterodactyl (#141428) * Add sensor platform * Correct CPU Limit state attribute translation * Remove calculated util entitites, add usage and limit entities * Use suggested_unit_of_measurement instead of converters * Start only first word of sensor names in upper case, improve suggested units of sensors * Simplify update of native_value, set uptime as timestamp * Add paranthesis around multi-line lambda --- .../components/pterodactyl/__init__.py | 2 +- homeassistant/components/pterodactyl/api.py | 22 ++- .../components/pterodactyl/icons.json | 33 ++++ .../components/pterodactyl/sensor.py | 183 ++++++++++++++++++ .../components/pterodactyl/strings.json | 29 +++ 5 files changed, 260 insertions(+), 9 deletions(-) create mode 100644 homeassistant/components/pterodactyl/icons.json create mode 100644 homeassistant/components/pterodactyl/sensor.py diff --git a/homeassistant/components/pterodactyl/__init__.py b/homeassistant/components/pterodactyl/__init__.py index 33b3cc7576f..5712c1bdd58 100644 --- a/homeassistant/components/pterodactyl/__init__.py +++ b/homeassistant/components/pterodactyl/__init__.py @@ -7,7 +7,7 @@ from homeassistant.core import HomeAssistant from .coordinator import PterodactylConfigEntry, PterodactylCoordinator -_PLATFORMS: list[Platform] = [Platform.BINARY_SENSOR] +_PLATFORMS: list[Platform] = [Platform.BINARY_SENSOR, Platform.SENSOR] async def async_setup_entry(hass: HomeAssistant, entry: PterodactylConfigEntry) -> bool: diff --git a/homeassistant/components/pterodactyl/api.py b/homeassistant/components/pterodactyl/api.py index 38cb9809652..aadb3261db0 100644 --- a/homeassistant/components/pterodactyl/api.py +++ b/homeassistant/components/pterodactyl/api.py @@ -32,11 +32,14 @@ class PterodactylData: uuid: str identifier: str state: str - memory_utilization: int cpu_utilization: float - disk_utilization: int - network_rx_utilization: int - network_tx_utilization: int + cpu_limit: int + disk_usage: int + disk_limit: int + memory_usage: int + memory_limit: int + network_inbound: int + network_outbound: int uptime: int @@ -108,10 +111,13 @@ class PterodactylAPI: identifier=identifier, state=utilization["current_state"], cpu_utilization=utilization["resources"]["cpu_absolute"], - memory_utilization=utilization["resources"]["memory_bytes"], - disk_utilization=utilization["resources"]["disk_bytes"], - network_rx_utilization=utilization["resources"]["network_rx_bytes"], - network_tx_utilization=utilization["resources"]["network_tx_bytes"], + cpu_limit=server["limits"]["cpu"], + memory_usage=utilization["resources"]["memory_bytes"], + memory_limit=server["limits"]["memory"], + disk_usage=utilization["resources"]["disk_bytes"], + disk_limit=server["limits"]["disk"], + network_inbound=utilization["resources"]["network_rx_bytes"], + network_outbound=utilization["resources"]["network_tx_bytes"], uptime=utilization["resources"]["uptime"], ) diff --git a/homeassistant/components/pterodactyl/icons.json b/homeassistant/components/pterodactyl/icons.json new file mode 100644 index 00000000000..245bdd7dbe5 --- /dev/null +++ b/homeassistant/components/pterodactyl/icons.json @@ -0,0 +1,33 @@ +{ + "entity": { + "sensor": { + "cpu_utilization": { + "default": "mdi:cpu-64-bit" + }, + "cpu_limit": { + "default": "mdi:cpu-64-bit" + }, + "memory_usage": { + "default": "mdi:memory" + }, + "memory_limit": { + "default": "mdi:memory" + }, + "disk_usage": { + "default": "mdi:harddisk" + }, + "disk_limit": { + "default": "mdi:harddisk" + }, + "network_inbound": { + "default": "mdi:download" + }, + "network_outbound": { + "default": "mdi:upload" + }, + "uptime": { + "default": "mdi:timer" + } + } + } +} diff --git a/homeassistant/components/pterodactyl/sensor.py b/homeassistant/components/pterodactyl/sensor.py new file mode 100644 index 00000000000..646b429cd08 --- /dev/null +++ b/homeassistant/components/pterodactyl/sensor.py @@ -0,0 +1,183 @@ +"""Sensor platform of the Pterodactyl integration.""" + +from __future__ import annotations + +from collections.abc import Callable +from dataclasses import dataclass +from datetime import datetime, timedelta + +from homeassistant.components.sensor import ( + SensorDeviceClass, + SensorEntity, + SensorEntityDescription, + SensorStateClass, +) +from homeassistant.const import PERCENTAGE, EntityCategory, UnitOfInformation +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback +from homeassistant.helpers.typing import StateType +from homeassistant.util import dt as dt_util + +from .coordinator import PterodactylConfigEntry, PterodactylCoordinator, PterodactylData +from .entity import PterodactylEntity + +KEY_CPU_UTILIZATION = "cpu_utilization" +KEY_CPU_LIMIT = "cpu_limit" +KEY_MEMORY_USAGE = "memory_usage" +KEY_MEMORY_LIMIT = "memory_limit" +KEY_DISK_USAGE = "disk_usage" +KEY_DISK_LIMIT = "disk_limit" +KEY_NETWORK_INBOUND = "network_inbound" +KEY_NETWORK_OUTBOUND = "network_outbound" +KEY_UPTIME = "uptime" + +# Coordinator is used to centralize the data updates. +PARALLEL_UPDATES = 0 + + +@dataclass(frozen=True, kw_only=True) +class PterodactylSensorEntityDescription(SensorEntityDescription): + """Class describing Pterodactyl sensor entities.""" + + value_fn: Callable[[PterodactylData], StateType | datetime] + + +SENSOR_DESCRIPTIONS = [ + PterodactylSensorEntityDescription( + key=KEY_CPU_UTILIZATION, + translation_key=KEY_CPU_UTILIZATION, + value_fn=lambda data: data.cpu_utilization, + entity_category=EntityCategory.DIAGNOSTIC, + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=PERCENTAGE, + suggested_display_precision=0, + ), + PterodactylSensorEntityDescription( + key=KEY_CPU_LIMIT, + translation_key=KEY_CPU_LIMIT, + value_fn=lambda data: data.cpu_limit, + entity_category=EntityCategory.DIAGNOSTIC, + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=PERCENTAGE, + suggested_display_precision=0, + entity_registry_enabled_default=False, + ), + PterodactylSensorEntityDescription( + key=KEY_MEMORY_USAGE, + translation_key=KEY_MEMORY_USAGE, + value_fn=lambda data: data.memory_usage, + entity_category=EntityCategory.DIAGNOSTIC, + device_class=SensorDeviceClass.DATA_SIZE, + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=UnitOfInformation.BYTES, + suggested_unit_of_measurement=UnitOfInformation.GIGABYTES, + suggested_display_precision=1, + ), + PterodactylSensorEntityDescription( + key=KEY_MEMORY_LIMIT, + translation_key=KEY_MEMORY_LIMIT, + value_fn=lambda data: data.memory_limit, + device_class=SensorDeviceClass.DATA_SIZE, + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, + native_unit_of_measurement=UnitOfInformation.MEGABYTES, + suggested_unit_of_measurement=UnitOfInformation.GIGABYTES, + suggested_display_precision=1, + entity_registry_enabled_default=False, + ), + PterodactylSensorEntityDescription( + key=KEY_DISK_USAGE, + translation_key=KEY_DISK_USAGE, + value_fn=lambda data: data.disk_usage, + device_class=SensorDeviceClass.DATA_SIZE, + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, + native_unit_of_measurement=UnitOfInformation.BYTES, + suggested_unit_of_measurement=UnitOfInformation.GIGABYTES, + suggested_display_precision=1, + ), + PterodactylSensorEntityDescription( + key=KEY_DISK_LIMIT, + translation_key=KEY_DISK_LIMIT, + value_fn=lambda data: data.disk_limit, + entity_category=EntityCategory.DIAGNOSTIC, + device_class=SensorDeviceClass.DATA_SIZE, + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=UnitOfInformation.MEGABYTES, + suggested_unit_of_measurement=UnitOfInformation.GIGABYTES, + suggested_display_precision=1, + entity_registry_enabled_default=False, + ), + PterodactylSensorEntityDescription( + key=KEY_NETWORK_INBOUND, + translation_key=KEY_NETWORK_INBOUND, + value_fn=lambda data: data.network_inbound, + entity_category=EntityCategory.DIAGNOSTIC, + device_class=SensorDeviceClass.DATA_SIZE, + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=UnitOfInformation.BYTES, + suggested_unit_of_measurement=UnitOfInformation.GIGABYTES, + suggested_display_precision=1, + entity_registry_enabled_default=False, + ), + PterodactylSensorEntityDescription( + key=KEY_NETWORK_OUTBOUND, + translation_key=KEY_NETWORK_OUTBOUND, + value_fn=lambda data: data.network_outbound, + entity_category=EntityCategory.DIAGNOSTIC, + device_class=SensorDeviceClass.DATA_SIZE, + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=UnitOfInformation.BYTES, + suggested_unit_of_measurement=UnitOfInformation.GIGABYTES, + suggested_display_precision=1, + entity_registry_enabled_default=False, + ), + PterodactylSensorEntityDescription( + key=KEY_UPTIME, + translation_key=KEY_UPTIME, + value_fn=( + lambda data: dt_util.utcnow() - timedelta(milliseconds=data.uptime) + if data.uptime > 0 + else None + ), + device_class=SensorDeviceClass.TIMESTAMP, + ), +] + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: PterodactylConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up the Pterodactyl sensor platform.""" + coordinator = config_entry.runtime_data + + async_add_entities( + PterodactylSensorEntity(coordinator, identifier, description, config_entry) + for identifier in coordinator.api.identifiers + for description in SENSOR_DESCRIPTIONS + ) + + +class PterodactylSensorEntity(PterodactylEntity, SensorEntity): + """Representation of a Pterodactyl sensor base entity.""" + + entity_description: PterodactylSensorEntityDescription + + def __init__( + self, + coordinator: PterodactylCoordinator, + identifier: str, + description: PterodactylSensorEntityDescription, + config_entry: PterodactylConfigEntry, + ) -> None: + """Initialize sensor base entity.""" + super().__init__(coordinator, identifier, config_entry) + self.entity_description = description + self._attr_unique_id = f"{self.game_server_data.uuid}_{description.key}" + + @property + def native_value(self) -> StateType | datetime: + """Return native value of sensor.""" + return self.entity_description.value_fn(self.game_server_data) diff --git a/homeassistant/components/pterodactyl/strings.json b/homeassistant/components/pterodactyl/strings.json index a875c72ccd8..9f1feef388c 100644 --- a/homeassistant/components/pterodactyl/strings.json +++ b/homeassistant/components/pterodactyl/strings.json @@ -25,6 +25,35 @@ "status": { "name": "Status" } + }, + "sensor": { + "cpu_utilization": { + "name": "CPU utilization" + }, + "cpu_limit": { + "name": "CPU limit" + }, + "memory_usage": { + "name": "Memory usage" + }, + "memory_limit": { + "name": "Memory limit" + }, + "disk_usage": { + "name": "Disk usage" + }, + "disk_limit": { + "name": "Disk limit" + }, + "network_inbound": { + "name": "Network inbound" + }, + "network_outbound": { + "name": "Network outbound" + }, + "uptime": { + "name": "Uptime" + } } } } From c0e8f1474568b366cebcd274ac12fbfb3277d352 Mon Sep 17 00:00:00 2001 From: pglab-electronics <89299919+pglab-electronics@users.noreply.github.com> Date: Mon, 31 Mar 2025 10:25:48 +0200 Subject: [PATCH 117/505] Update support to external library pypglab to version 0.0.5 (#141876) update support to external library pypglab to version 0.0.5 --- homeassistant/components/pglab/__init__.py | 2 +- homeassistant/components/pglab/coordinator.py | 4 +- homeassistant/components/pglab/discovery.py | 2 +- homeassistant/components/pglab/entity.py | 2 +- homeassistant/components/pglab/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/pglab/test_common.py | 50 ++++++ tests/components/pglab/test_cover.py | 93 +++-------- tests/components/pglab/test_discovery.py | 87 +++------- tests/components/pglab/test_sensor.py | 33 +--- tests/components/pglab/test_switch.py | 148 +++++------------- 12 files changed, 142 insertions(+), 285 deletions(-) create mode 100644 tests/components/pglab/test_common.py diff --git a/homeassistant/components/pglab/__init__.py b/homeassistant/components/pglab/__init__.py index 8bce7be26e8..a490f476f83 100644 --- a/homeassistant/components/pglab/__init__.py +++ b/homeassistant/components/pglab/__init__.py @@ -5,7 +5,7 @@ from __future__ import annotations from pypglab.mqtt import ( Client as PyPGLabMqttClient, Sub_State as PyPGLabSubState, - Subcribe_CallBack as PyPGLabSubscribeCallBack, + Subscribe_CallBack as PyPGLabSubscribeCallBack, ) from homeassistant.components import mqtt diff --git a/homeassistant/components/pglab/coordinator.py b/homeassistant/components/pglab/coordinator.py index 53c5dbc3b58..b703f368eb1 100644 --- a/homeassistant/components/pglab/coordinator.py +++ b/homeassistant/components/pglab/coordinator.py @@ -7,7 +7,7 @@ from typing import TYPE_CHECKING, Any from pypglab.const import SENSOR_REBOOT_TIME, SENSOR_TEMPERATURE, SENSOR_VOLTAGE from pypglab.device import Device as PyPGLabDevice -from pypglab.sensor import Sensor as PyPGLabSensors +from pypglab.sensor import StatusSensor as PyPGLabSensors from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.update_coordinator import DataUpdateCoordinator @@ -31,7 +31,7 @@ class PGLabSensorsCoordinator(DataUpdateCoordinator[dict[str, Any]]): """Initialize.""" # get a reference of PG Lab device internal sensors state - self._sensors: PyPGLabSensors = pglab_device.sensors + self._sensors: PyPGLabSensors = pglab_device.status_sensor super().__init__( hass, diff --git a/homeassistant/components/pglab/discovery.py b/homeassistant/components/pglab/discovery.py index c1d8653c17b..c83ea4466fa 100644 --- a/homeassistant/components/pglab/discovery.py +++ b/homeassistant/components/pglab/discovery.py @@ -220,7 +220,7 @@ class PGLabDiscovery: configuration_url=f"http://{pglab_device.ip}/", connections={(CONNECTION_NETWORK_MAC, pglab_device.mac)}, identifiers={(DOMAIN, pglab_device.id)}, - manufacturer=pglab_device.manufactor, + manufacturer=pglab_device.manufacturer, model=pglab_device.type, name=pglab_device.name, sw_version=pglab_device.firmware_version, diff --git a/homeassistant/components/pglab/entity.py b/homeassistant/components/pglab/entity.py index 59a4e28de89..c0a02f4f835 100644 --- a/homeassistant/components/pglab/entity.py +++ b/homeassistant/components/pglab/entity.py @@ -37,7 +37,7 @@ class PGLabBaseEntity(Entity): sw_version=pglab_device.firmware_version, hw_version=pglab_device.hardware_version, model=pglab_device.type, - manufacturer=pglab_device.manufactor, + manufacturer=pglab_device.manufacturer, configuration_url=f"http://{pglab_device.ip}/", connections={(CONNECTION_NETWORK_MAC, pglab_device.mac)}, ) diff --git a/homeassistant/components/pglab/manifest.json b/homeassistant/components/pglab/manifest.json index 7f7d596be77..c8dca6c6229 100644 --- a/homeassistant/components/pglab/manifest.json +++ b/homeassistant/components/pglab/manifest.json @@ -9,6 +9,6 @@ "loggers": ["pglab"], "mqtt": ["pglab/discovery/#"], "quality_scale": "bronze", - "requirements": ["pypglab==0.0.3"], + "requirements": ["pypglab==0.0.5"], "single_config_entry": true } diff --git a/requirements_all.txt b/requirements_all.txt index b75a6f50a2a..d5eca20886e 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2223,7 +2223,7 @@ pypca==0.0.7 pypck==0.8.5 # homeassistant.components.pglab -pypglab==0.0.3 +pypglab==0.0.5 # homeassistant.components.pjlink pypjlink2==1.2.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 3e55eed72d9..e53b7311682 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1814,7 +1814,7 @@ pypalazzetti==0.1.19 pypck==0.8.5 # homeassistant.components.pglab -pypglab==0.0.3 +pypglab==0.0.5 # homeassistant.components.pjlink pypjlink2==1.2.1 diff --git a/tests/components/pglab/test_common.py b/tests/components/pglab/test_common.py new file mode 100644 index 00000000000..0ff3271d5d6 --- /dev/null +++ b/tests/components/pglab/test_common.py @@ -0,0 +1,50 @@ +"""Common code for PG LAB Electronics tests.""" + +import json + +from homeassistant.core import HomeAssistant + +from tests.common import async_fire_mqtt_message + + +def get_device_discovery_payload( + number_of_shutters: int, + number_of_boards: int, + device_name: str = "test", +) -> dict[str, any]: + """Return the device discovery payload.""" + + # be sure the number of shutters and boards are in the correct range + assert 0 <= number_of_boards <= 8 + assert 0 <= number_of_shutters <= (number_of_boards * 4) + + # define the number of E-RELAY boards connected to E-BOARD + boards = "1" * number_of_boards + "0" * (8 - number_of_boards) + + return { + "ip": "192.168.1.16", + "mac": "80:34:28:1B:18:5A", + "name": device_name, + "hw": "1.0.7", + "fw": "1.0.0", + "type": "E-BOARD", + "id": "E-BOARD-DD53AC85", + "manufacturer": "PG LAB Electronics", + "params": {"shutters": number_of_shutters, "boards": boards}, + } + + +async def send_discovery_message( + hass: HomeAssistant, + payload: dict[str, any] | None, +) -> None: + """Send the discovery message to make E-BOARD device discoverable.""" + + topic = "pglab/discovery/E-BOARD-DD53AC85/config" + + async_fire_mqtt_message( + hass, + topic, + json.dumps(payload if payload is not None else ""), + ) + await hass.async_block_till_done() diff --git a/tests/components/pglab/test_cover.py b/tests/components/pglab/test_cover.py index ea4c7a7213e..aa92e2da433 100644 --- a/tests/components/pglab/test_cover.py +++ b/tests/components/pglab/test_cover.py @@ -1,7 +1,5 @@ """The tests for the PG LAB Electronics cover.""" -import json - from homeassistant.components import cover from homeassistant.components.cover import ( DOMAIN as COVER_DOMAIN, @@ -19,6 +17,8 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant +from .test_common import get_device_discovery_payload, send_discovery_message + from tests.common import async_fire_mqtt_message from tests.typing import MqttMockHAClient @@ -43,25 +43,13 @@ async def test_cover_features( hass: HomeAssistant, mqtt_mock: MqttMockHAClient, setup_pglab ) -> None: """Test cover features.""" - topic = "pglab/discovery/E-Board-DD53AC85/config" - payload = { - "ip": "192.168.1.16", - "mac": "80:34:28:1B:18:5A", - "name": "test", - "hw": "1.0.7", - "fw": "1.0.0", - "type": "E-Board", - "id": "E-Board-DD53AC85", - "manufacturer": "PG LAB Electronics", - "params": {"shutters": 4, "boards": "10000000"}, - } - async_fire_mqtt_message( - hass, - topic, - json.dumps(payload), + payload = get_device_discovery_payload( + number_of_shutters=4, + number_of_boards=1, ) - await hass.async_block_till_done() + + await send_discovery_message(hass, payload) assert len(hass.states.async_all("cover")) == 4 @@ -75,25 +63,13 @@ async def test_cover_availability( hass: HomeAssistant, mqtt_mock: MqttMockHAClient, setup_pglab ) -> None: """Check if covers are properly created.""" - topic = "pglab/discovery/E-Board-DD53AC85/config" - payload = { - "ip": "192.168.1.16", - "mac": "80:34:28:1B:18:5A", - "name": "test", - "hw": "1.0.7", - "fw": "1.0.0", - "type": "E-Board", - "id": "E-Board-DD53AC85", - "manufacturer": "PG LAB Electronics", - "params": {"shutters": 6, "boards": "11000000"}, - } - async_fire_mqtt_message( - hass, - topic, - json.dumps(payload), + payload = get_device_discovery_payload( + number_of_shutters=6, + number_of_boards=2, ) - await hass.async_block_till_done() + + await send_discovery_message(hass, payload) # We are creating 6 covers using two E-RELAY devices connected to E-BOARD. # Now we are going to check if all covers are created and their state is unknown. @@ -111,25 +87,12 @@ async def test_cover_change_state_via_mqtt( hass: HomeAssistant, mqtt_mock: MqttMockHAClient, setup_pglab ) -> None: """Test state update via MQTT.""" - topic = "pglab/discovery/E-Board-DD53AC85/config" - payload = { - "ip": "192.168.1.16", - "mac": "80:34:28:1B:18:5A", - "name": "test", - "hw": "1.0.7", - "fw": "1.0.0", - "type": "E-Board", - "id": "E-Board-DD53AC85", - "manufacturer": "PG LAB Electronics", - "params": {"shutters": 2, "boards": "10000000"}, - } - - async_fire_mqtt_message( - hass, - topic, - json.dumps(payload), + payload = get_device_discovery_payload( + number_of_shutters=2, + number_of_boards=1, ) - await hass.async_block_till_done() + + await send_discovery_message(hass, payload) # Check initial state is unknown cover = hass.states.get("cover.test_shutter_0") @@ -165,25 +128,13 @@ async def test_cover_mqtt_state_by_calling_service( hass: HomeAssistant, mqtt_mock: MqttMockHAClient, setup_pglab ) -> None: """Calling service to OPEN/CLOSE cover and check mqtt state.""" - topic = "pglab/discovery/E-Board-DD53AC85/config" - payload = { - "ip": "192.168.1.16", - "mac": "80:34:28:1B:18:5A", - "name": "test", - "hw": "1.0.7", - "fw": "1.0.0", - "type": "E-Board", - "id": "E-Board-DD53AC85", - "manufacturer": "PG LAB Electronics", - "params": {"shutters": 2, "boards": "10000000"}, - } - async_fire_mqtt_message( - hass, - topic, - json.dumps(payload), + payload = get_device_discovery_payload( + number_of_shutters=2, + number_of_boards=1, ) - await hass.async_block_till_done() + + await send_discovery_message(hass, payload) cover = hass.states.get("cover.test_shutter_0") assert cover.state == STATE_UNKNOWN diff --git a/tests/components/pglab/test_discovery.py b/tests/components/pglab/test_discovery.py index 65716236277..df897264163 100644 --- a/tests/components/pglab/test_discovery.py +++ b/tests/components/pglab/test_discovery.py @@ -1,13 +1,12 @@ """The tests for the PG LAB Electronics discovery device.""" -import json - from syrupy.assertion import SnapshotAssertion from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr -from tests.common import async_fire_mqtt_message +from .test_common import get_device_discovery_payload, send_discovery_message + from tests.typing import MqttMockHAClient @@ -19,25 +18,13 @@ async def test_device_discover( setup_pglab, ) -> None: """Test setting up a device.""" - topic = "pglab/discovery/E-Board-DD53AC85/config" - payload = { - "ip": "192.168.1.16", - "mac": "80:34:28:1B:18:5A", - "name": "test", - "hw": "1.0.7", - "fw": "1.0.0", - "type": "E-Board", - "id": "E-Board-DD53AC85", - "manufacturer": "PG LAB Electronics", - "params": {"shutters": 0, "boards": "11000000"}, - } - async_fire_mqtt_message( - hass, - topic, - json.dumps(payload), + payload = get_device_discovery_payload( + number_of_shutters=0, + number_of_boards=2, ) - await hass.async_block_till_done() + + await send_discovery_message(hass, payload) # Verify device and registry entries are created device_entry = device_reg.async_get_device( @@ -60,25 +47,12 @@ async def test_device_update( snapshot: SnapshotAssertion, ) -> None: """Test update a device.""" - topic = "pglab/discovery/E-Board-DD53AC85/config" - payload = { - "ip": "192.168.1.16", - "mac": "80:34:28:1B:18:5A", - "name": "test", - "hw": "1.0.7", - "fw": "1.0.0", - "type": "E-Board", - "id": "E-Board-DD53AC85", - "manufacturer": "PG LAB Electronics", - "params": {"shutters": 0, "boards": "11000000"}, - } - - async_fire_mqtt_message( - hass, - topic, - json.dumps(payload), + payload = get_device_discovery_payload( + number_of_shutters=0, + number_of_boards=2, ) - await hass.async_block_till_done() + + await send_discovery_message(hass, payload) # Verify device is created device_entry = device_reg.async_get_device( @@ -90,12 +64,7 @@ async def test_device_update( payload["fw"] = "1.0.1" payload["hw"] = "1.0.8" - async_fire_mqtt_message( - hass, - topic, - json.dumps(payload), - ) - await hass.async_block_till_done() + await send_discovery_message(hass, payload) # Verify device is created device_entry = device_reg.async_get_device( @@ -114,25 +83,12 @@ async def test_device_remove( setup_pglab, ) -> None: """Test remove a device.""" - topic = "pglab/discovery/E-Board-DD53AC85/config" - payload = { - "ip": "192.168.1.16", - "mac": "80:34:28:1B:18:5A", - "name": "test", - "hw": "1.0.7", - "fw": "1.0.0", - "type": "E-Board", - "id": "E-Board-DD53AC85", - "manufacturer": "PG LAB Electronics", - "params": {"shutters": 0, "boards": "11000000"}, - } - - async_fire_mqtt_message( - hass, - topic, - json.dumps(payload), + payload = get_device_discovery_payload( + number_of_shutters=0, + number_of_boards=2, ) - await hass.async_block_till_done() + + await send_discovery_message(hass, payload) # Verify device is created device_entry = device_reg.async_get_device( @@ -140,12 +96,7 @@ async def test_device_remove( ) assert device_entry is not None - async_fire_mqtt_message( - hass, - topic, - "", - ) - await hass.async_block_till_done() + await send_discovery_message(hass, None) # Verify device entry is removed device_entry = device_reg.async_get_device( diff --git a/tests/components/pglab/test_sensor.py b/tests/components/pglab/test_sensor.py index ff20d1452a4..75932dd036c 100644 --- a/tests/components/pglab/test_sensor.py +++ b/tests/components/pglab/test_sensor.py @@ -8,34 +8,12 @@ from syrupy import SnapshotAssertion from homeassistant.core import HomeAssistant +from .test_common import get_device_discovery_payload, send_discovery_message + from tests.common import async_fire_mqtt_message from tests.typing import MqttMockHAClient -async def send_discovery_message(hass: HomeAssistant) -> None: - """Send mqtt discovery message.""" - - topic = "pglab/discovery/E-Board-DD53AC85/config" - payload = { - "ip": "192.168.1.16", - "mac": "80:34:28:1B:18:5A", - "name": "test", - "hw": "1.0.7", - "fw": "1.0.0", - "type": "E-Board", - "id": "E-Board-DD53AC85", - "manufacturer": "PG LAB Electronics", - "params": {"shutters": 0, "boards": "00000000"}, - } - - async_fire_mqtt_message( - hass, - topic, - json.dumps(payload), - ) - await hass.async_block_till_done() - - @freeze_time("2024-02-26 01:21:34") @pytest.mark.parametrize( "sensor_suffix", @@ -55,7 +33,12 @@ async def test_sensors( """Check if sensors are properly created and updated.""" # send the discovery message to make E-BOARD device discoverable - await send_discovery_message(hass) + payload = get_device_discovery_payload( + number_of_shutters=0, + number_of_boards=0, + ) + + await send_discovery_message(hass, payload) # check initial sensors state state = hass.states.get(f"sensor.test_{sensor_suffix}") diff --git a/tests/components/pglab/test_switch.py b/tests/components/pglab/test_switch.py index fef445f80f3..0f1a2e4bb04 100644 --- a/tests/components/pglab/test_switch.py +++ b/tests/components/pglab/test_switch.py @@ -1,7 +1,6 @@ """The tests for the PG LAB Electronics switch.""" from datetime import timedelta -import json from homeassistant import config_entries from homeassistant.components.switch import ( @@ -20,6 +19,8 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er from homeassistant.util import dt as dt_util +from .test_common import get_device_discovery_payload, send_discovery_message + from tests.common import async_fire_mqtt_message, async_fire_time_changed from tests.typing import MqttMockHAClient @@ -38,25 +39,13 @@ async def test_available_relay( hass: HomeAssistant, mqtt_mock: MqttMockHAClient, setup_pglab ) -> None: """Check if relay are properly created when two E-Relay boards are connected.""" - topic = "pglab/discovery/E-Board-DD53AC85/config" - payload = { - "ip": "192.168.1.16", - "mac": "80:34:28:1B:18:5A", - "name": "test", - "hw": "1.0.7", - "fw": "1.0.0", - "type": "E-Board", - "id": "E-Board-DD53AC85", - "manufacturer": "PG LAB Electronics", - "params": {"shutters": 0, "boards": "11000000"}, - } - async_fire_mqtt_message( - hass, - topic, - json.dumps(payload), + payload = get_device_discovery_payload( + number_of_shutters=0, + number_of_boards=2, ) - await hass.async_block_till_done() + + await send_discovery_message(hass, payload) for i in range(16): state = hass.states.get(f"switch.test_relay_{i}") @@ -68,25 +57,13 @@ async def test_change_state_via_mqtt( hass: HomeAssistant, mqtt_mock: MqttMockHAClient, setup_pglab ) -> None: """Test state update via MQTT.""" - topic = "pglab/discovery/E-Board-DD53AC85/config" - payload = { - "ip": "192.168.1.16", - "mac": "80:34:28:1B:18:5A", - "name": "test", - "hw": "1.0.7", - "fw": "1.0.0", - "type": "E-Board", - "id": "E-Board-DD53AC85", - "manufacturer": "PG LAB Electronics", - "params": {"shutters": 0, "boards": "10000000"}, - } - async_fire_mqtt_message( - hass, - topic, - json.dumps(payload), + payload = get_device_discovery_payload( + number_of_shutters=0, + number_of_boards=1, ) - await hass.async_block_till_done() + + await send_discovery_message(hass, payload) # Simulate response from the device state = hass.states.get("switch.test_relay_0") @@ -123,25 +100,13 @@ async def test_mqtt_state_by_calling_service( hass: HomeAssistant, mqtt_mock: MqttMockHAClient, setup_pglab ) -> None: """Calling service to turn ON/OFF relay and check mqtt state.""" - topic = "pglab/discovery/E-Board-DD53AC85/config" - payload = { - "ip": "192.168.1.16", - "mac": "80:34:28:1B:18:5A", - "name": "test", - "hw": "1.0.7", - "fw": "1.0.0", - "type": "E-Board", - "id": "E-Board-DD53AC85", - "manufacturer": "PG LAB Electronics", - "params": {"shutters": 0, "boards": "10000000"}, - } - async_fire_mqtt_message( - hass, - topic, - json.dumps(payload), + payload = get_device_discovery_payload( + number_of_shutters=0, + number_of_boards=1, ) - await hass.async_block_till_done() + + await send_discovery_message(hass, payload) # Turn relay ON await call_service(hass, "switch.test_relay_0", SERVICE_TURN_ON) @@ -177,26 +142,13 @@ async def test_discovery_update( ) -> None: """Update discovery message and check if relay are property updated.""" - # publish the first discovery message - topic = "pglab/discovery/E-Board-DD53AC85/config" - payload = { - "ip": "192.168.1.16", - "mac": "80:34:28:1B:18:5A", - "name": "first_test", - "hw": "1.0.7", - "fw": "1.0.0", - "type": "E-Board", - "id": "E-Board-DD53AC85", - "manufacturer": "PG LAB Electronics", - "params": {"shutters": 0, "boards": "10000000"}, - } - - async_fire_mqtt_message( - hass, - topic, - json.dumps(payload), + payload = get_device_discovery_payload( + device_name="first_test", + number_of_shutters=0, + number_of_boards=1, ) - await hass.async_block_till_done() + + await send_discovery_message(hass, payload) # test the available relay in the first configuration for i in range(8): @@ -206,25 +158,13 @@ async def test_discovery_update( # prepare a new message ... the same device but renamed # and with different relay configuration - topic = "pglab/discovery/E-Board-DD53AC85/config" - payload = { - "ip": "192.168.1.16", - "mac": "80:34:28:1B:18:5A", - "name": "second_test", - "hw": "1.0.7", - "fw": "1.0.0", - "type": "E-Board", - "id": "E-Board-DD53AC85", - "manufacturer": "PG LAB Electronics", - "params": {"shutters": 0, "boards": "11000000"}, - } - - async_fire_mqtt_message( - hass, - topic, - json.dumps(payload), + payload = get_device_discovery_payload( + device_name="second_test", + number_of_shutters=0, + number_of_boards=2, ) - await hass.async_block_till_done() + + await send_discovery_message(hass, payload) # be sure that old relay are been removed for i in range(8): @@ -245,25 +185,12 @@ async def test_disable_entity_state_change_via_mqtt( ) -> None: """Test state update via MQTT of disable entity.""" - topic = "pglab/discovery/E-Board-DD53AC85/config" - payload = { - "ip": "192.168.1.16", - "mac": "80:34:28:1B:18:5A", - "name": "test", - "hw": "1.0.7", - "fw": "1.0.0", - "type": "E-Board", - "id": "E-Board-DD53AC85", - "manufacturer": "PG LAB Electronics", - "params": {"shutters": 0, "boards": "10000000"}, - } - - async_fire_mqtt_message( - hass, - topic, - json.dumps(payload), + payload = get_device_discovery_payload( + number_of_shutters=0, + number_of_boards=1, ) - await hass.async_block_till_done() + + await send_discovery_message(hass, payload) # Be sure that the entity relay_0 is available state = hass.states.get("switch.test_relay_0") @@ -298,12 +225,7 @@ async def test_disable_entity_state_change_via_mqtt( await hass.async_block_till_done() # Re-send the discovery message - async_fire_mqtt_message( - hass, - topic, - json.dumps(payload), - ) - await hass.async_block_till_done() + await send_discovery_message(hass, payload) # Be sure that the state is not changed state = hass.states.get("switch.test_relay_0") From f6308368b0a680483ad856ddb4ea28f21bc3ae60 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Mon, 31 Mar 2025 10:43:57 +0200 Subject: [PATCH 118/505] Test behavior of statistic_during_period when circular mean is undefined (#141554) * Test behavior of statistic_during_period when circular mean is undefined * Improve comment --- .../components/recorder/test_websocket_api.py | 46 +++++++++++++------ 1 file changed, 32 insertions(+), 14 deletions(-) diff --git a/tests/components/recorder/test_websocket_api.py b/tests/components/recorder/test_websocket_api.py index a4e4fe45db1..2460de994ec 100644 --- a/tests/components/recorder/test_websocket_api.py +++ b/tests/components/recorder/test_websocket_api.py @@ -698,17 +698,33 @@ def _circular_mean(values: Iterable[StatisticData]) -> dict[str, float]: } -def _circular_mean_approx(values: Iterable[StatisticData]) -> ApproxBase: - return pytest.approx(_circular_mean(values)["mean"]) +def _circular_mean_approx( + values: Iterable[StatisticData], tolerance: float | None = None +) -> ApproxBase: + return pytest.approx(_circular_mean(values)["mean"], abs=tolerance) @pytest.mark.freeze_time(datetime.datetime(2022, 10, 21, 7, 25, tzinfo=datetime.UTC)) @pytest.mark.usefixtures("recorder_mock") @pytest.mark.parametrize("offset", [0, 1, 2]) +@pytest.mark.parametrize( + ("step_size", "tolerance"), + [ + (123.456, 1e-4), + # In this case the angles are uniformly distributed and the mean is undefined. + # This edge case is not handled by the current implementation, but the test + # checks the behavior is consistent. + # We could consider returning None in this case, or returning also an estimate + # of the variance. + (120, 10), + ], +) async def test_statistic_during_period_circular_mean( hass: HomeAssistant, hass_ws_client: WebSocketGenerator, offset: int, + step_size: float, + tolerance: float, ) -> None: """Test statistic_during_period.""" now = dt_util.utcnow() @@ -724,7 +740,7 @@ async def test_statistic_during_period_circular_mean( imported_stats_5min: list[StatisticData] = [ { "start": (start + timedelta(minutes=5 * i)), - "mean": (123.456 * i) % 360, + "mean": (step_size * i) % 360, "mean_weight": 1, } for i in range(39) @@ -807,7 +823,7 @@ async def test_statistic_during_period_circular_mean( response = await client.receive_json() assert response["success"] assert response["result"] == { - "mean": _circular_mean_approx(imported_stats_5min), + "mean": _circular_mean_approx(imported_stats_5min, tolerance), "max": None, "min": None, "change": None, @@ -835,7 +851,7 @@ async def test_statistic_during_period_circular_mean( response = await client.receive_json() assert response["success"] assert response["result"] == { - "mean": _circular_mean_approx(imported_stats_5min), + "mean": _circular_mean_approx(imported_stats_5min, tolerance), "max": None, "min": None, "change": None, @@ -863,7 +879,7 @@ async def test_statistic_during_period_circular_mean( response = await client.receive_json() assert response["success"] assert response["result"] == { - "mean": _circular_mean_approx(imported_stats_5min), + "mean": _circular_mean_approx(imported_stats_5min, tolerance), "max": None, "min": None, "change": None, @@ -887,7 +903,7 @@ async def test_statistic_during_period_circular_mean( response = await client.receive_json() assert response["success"] assert response["result"] == { - "mean": _circular_mean_approx(imported_stats_5min[26:]), + "mean": _circular_mean_approx(imported_stats_5min[26:], tolerance), "max": None, "min": None, "change": None, @@ -910,7 +926,7 @@ async def test_statistic_during_period_circular_mean( response = await client.receive_json() assert response["success"] assert response["result"] == { - "mean": _circular_mean_approx(imported_stats_5min[26:]), + "mean": _circular_mean_approx(imported_stats_5min[26:], tolerance), "max": None, "min": None, "change": None, @@ -934,7 +950,7 @@ async def test_statistic_during_period_circular_mean( response = await client.receive_json() assert response["success"] assert response["result"] == { - "mean": _circular_mean_approx(imported_stats_5min[:26]), + "mean": _circular_mean_approx(imported_stats_5min[:26], tolerance), "max": None, "min": None, "change": None, @@ -964,7 +980,7 @@ async def test_statistic_during_period_circular_mean( response = await client.receive_json() assert response["success"] assert response["result"] == { - "mean": _circular_mean_approx(imported_stats_5min[26:32]), + "mean": _circular_mean_approx(imported_stats_5min[26:32], tolerance), "max": None, "min": None, "change": None, @@ -986,7 +1002,7 @@ async def test_statistic_during_period_circular_mean( response = await client.receive_json() assert response["success"] assert response["result"] == { - "mean": _circular_mean_approx(imported_stats_5min[24 - offset :]), + "mean": _circular_mean_approx(imported_stats_5min[24 - offset :], tolerance), "max": None, "min": None, "change": None, @@ -1005,7 +1021,7 @@ async def test_statistic_during_period_circular_mean( response = await client.receive_json() assert response["success"] assert response["result"] == { - "mean": _circular_mean_approx(imported_stats_5min[24 - offset :]), + "mean": _circular_mean_approx(imported_stats_5min[24 - offset :], tolerance), "max": None, "min": None, "change": None, @@ -1027,7 +1043,9 @@ async def test_statistic_during_period_circular_mean( slice_start = 24 - offset slice_end = 36 - offset assert response["result"] == { - "mean": _circular_mean_approx(imported_stats_5min[slice_start:slice_end]), + "mean": _circular_mean_approx( + imported_stats_5min[slice_start:slice_end], tolerance + ), "max": None, "min": None, "change": None, @@ -1044,7 +1062,7 @@ async def test_statistic_during_period_circular_mean( response = await client.receive_json() assert response["success"] assert response["result"] == { - "mean": _circular_mean_approx(imported_stats_5min), + "mean": _circular_mean_approx(imported_stats_5min, tolerance), } From 6aeb7f36f6ef569b958d950b4fae65a1d83419c5 Mon Sep 17 00:00:00 2001 From: Thomas55555 <59625598+Thomas55555@users.noreply.github.com> Date: Mon, 31 Mar 2025 11:40:14 +0200 Subject: [PATCH 119/505] Handle 403 error in remote calendar (#141839) * Handle 403 error in remote calendar * tests --- .../components/remote_calendar/config_flow.py | 8 ++++++++ .../components/remote_calendar/strings.json | 1 + .../remote_calendar/test_config_flow.py | 15 ++++++++++++--- 3 files changed, 21 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/remote_calendar/config_flow.py b/homeassistant/components/remote_calendar/config_flow.py index 1ceeb7a3937..cc9f45e2767 100644 --- a/homeassistant/components/remote_calendar/config_flow.py +++ b/homeassistant/components/remote_calendar/config_flow.py @@ -1,5 +1,6 @@ """Config flow for Remote Calendar integration.""" +from http import HTTPStatus import logging from typing import Any @@ -50,6 +51,13 @@ class RemoteCalendarConfigFlow(ConfigFlow, domain=DOMAIN): client = get_async_client(self.hass) try: res = await client.get(user_input[CONF_URL], follow_redirects=True) + if res.status_code == HTTPStatus.FORBIDDEN: + errors["base"] = "forbidden" + return self.async_show_form( + step_id="user", + data_schema=STEP_USER_DATA_SCHEMA, + errors=errors, + ) res.raise_for_status() except (HTTPError, InvalidURL) as err: errors["base"] = "cannot_connect" diff --git a/homeassistant/components/remote_calendar/strings.json b/homeassistant/components/remote_calendar/strings.json index 1ad62821818..fff2d4abbb3 100644 --- a/homeassistant/components/remote_calendar/strings.json +++ b/homeassistant/components/remote_calendar/strings.json @@ -19,6 +19,7 @@ }, "error": { "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "forbidden": "The server understood the request but refuses to authorize it.", "invalid_ics_file": "[%key:component::local_calendar::config::error::invalid_ics_file%]" } }, diff --git a/tests/components/remote_calendar/test_config_flow.py b/tests/components/remote_calendar/test_config_flow.py index 9eb9cb40134..9aff1594db3 100644 --- a/tests/components/remote_calendar/test_config_flow.py +++ b/tests/components/remote_calendar/test_config_flow.py @@ -165,8 +165,17 @@ async def test_unsupported_inputs( ## and then the exception isn't raised anymore. +@pytest.mark.parametrize( + ("http_status", "error"), + [ + (401, "cannot_connect"), + (403, "forbidden"), + ], +) @respx.mock -async def test_form_http_status_error(hass: HomeAssistant, ics_content: str) -> None: +async def test_form_http_status_error( + hass: HomeAssistant, ics_content: str, http_status: int, error: str +) -> None: """Test we http status.""" result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_USER} @@ -174,7 +183,7 @@ async def test_form_http_status_error(hass: HomeAssistant, ics_content: str) -> assert result["type"] is FlowResultType.FORM respx.get(CALENDER_URL).mock( return_value=Response( - status_code=403, + status_code=http_status, ) ) @@ -186,7 +195,7 @@ async def test_form_http_status_error(hass: HomeAssistant, ics_content: str) -> }, ) assert result2["type"] is FlowResultType.FORM - assert result2["errors"] == {"base": "cannot_connect"} + assert result2["errors"] == {"base": error} respx.get(CALENDER_URL).mock( return_value=Response( status_code=200, From d5ab86edbfe792591ec9c669e184cfd8ecd59389 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Mon, 31 Mar 2025 11:41:52 +0200 Subject: [PATCH 120/505] Fix SmartThings climate entity missing off HAVC mode (#141700) * Fix smartthing climate entity missing off HAVC mode: * Fix tests * Fix test --------- Co-authored-by: Joostlek --- homeassistant/components/smartthings/climate.py | 2 +- tests/components/smartthings/snapshots/test_climate.ambr | 8 ++++++++ tests/components/smartthings/test_climate.py | 6 +++--- 3 files changed, 12 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/smartthings/climate.py b/homeassistant/components/smartthings/climate.py index e20f191352f..9f94293d863 100644 --- a/homeassistant/components/smartthings/climate.py +++ b/homeassistant/components/smartthings/climate.py @@ -281,7 +281,7 @@ class SmartThingsThermostat(SmartThingsEntity, ClimateEntity): return [ state for mode in supported_thermostat_modes - if (state := AC_MODE_TO_STATE.get(mode)) is not None + if (state := MODE_TO_STATE.get(mode)) is not None ] @property diff --git a/tests/components/smartthings/snapshots/test_climate.ambr b/tests/components/smartthings/snapshots/test_climate.ambr index 10e9dbd5489..17e25421fec 100644 --- a/tests/components/smartthings/snapshots/test_climate.ambr +++ b/tests/components/smartthings/snapshots/test_climate.ambr @@ -70,6 +70,7 @@ 'area_id': None, 'capabilities': dict({ 'hvac_modes': list([ + , , ]), 'max_temp': 35, @@ -109,6 +110,7 @@ 'current_temperature': 23.9, 'friendly_name': 'Radiator Thermostat II [+M] Wohnzimmer', 'hvac_modes': list([ + , , ]), 'max_temp': 35, @@ -431,6 +433,7 @@ 'auto', ]), 'hvac_modes': list([ + , , , ]), @@ -478,6 +481,7 @@ 'friendly_name': 'Main Floor', 'hvac_action': , 'hvac_modes': list([ + , , , ]), @@ -628,6 +632,7 @@ 'area_id': None, 'capabilities': dict({ 'hvac_modes': list([ + , , ]), 'max_temp': 35, @@ -668,6 +673,7 @@ 'friendly_name': 'Hall thermostat', 'hvac_action': , 'hvac_modes': list([ + , , ]), 'max_temp': 35, @@ -695,6 +701,7 @@ 'on', ]), 'hvac_modes': list([ + , ]), 'max_temp': 35.0, 'min_temp': 7.0, @@ -738,6 +745,7 @@ 'friendly_name': 'asd', 'hvac_action': , 'hvac_modes': list([ + , ]), 'max_temp': 35.0, 'min_temp': 7.0, diff --git a/tests/components/smartthings/test_climate.py b/tests/components/smartthings/test_climate.py index 380c4072860..75b864598bd 100644 --- a/tests/components/smartthings/test_climate.py +++ b/tests/components/smartthings/test_climate.py @@ -817,10 +817,10 @@ async def test_updating_humidity( ( Capability.THERMOSTAT_MODE, Attribute.SUPPORTED_THERMOSTAT_MODES, - ["coolClean", "dryClean"], + ["rush hour", "heat"], ATTR_HVAC_MODES, - [], - [HVACMode.COOL, HVACMode.DRY], + [HVACMode.AUTO], + [HVACMode.AUTO, HVACMode.HEAT], ), ], ids=[ From 560c719b0f2ef95c50556ce7b2dda86b4b180dd0 Mon Sep 17 00:00:00 2001 From: Retha Runolfsson <137745329+zerzhang@users.noreply.github.com> Date: Mon, 31 Mar 2025 17:42:31 +0800 Subject: [PATCH 121/505] Add switchbot cover unit tests (#140265) * add cover unit tests * Add unit test for SwitchBot cover * fix: use mock_restore_cache to mock the last state * modify unit tests * modify scripts as suggest * improve readability * adjust patch target per review comments * adjust patch target per review comments --------- Co-authored-by: J. Nick Koston --- homeassistant/components/switchbot/cover.py | 2 +- tests/components/switchbot/__init__.py | 67 ++++ tests/components/switchbot/conftest.py | 19 ++ tests/components/switchbot/test_cover.py | 327 ++++++++++++++++++++ 4 files changed, 414 insertions(+), 1 deletion(-) create mode 100644 tests/components/switchbot/test_cover.py diff --git a/homeassistant/components/switchbot/cover.py b/homeassistant/components/switchbot/cover.py index 3ef0f5625c2..5a9613ab2a2 100644 --- a/homeassistant/components/switchbot/cover.py +++ b/homeassistant/components/switchbot/cover.py @@ -154,7 +154,7 @@ class SwitchBotBlindTiltEntity(SwitchbotEntity, CoverEntity, RestoreEntity): ATTR_CURRENT_TILT_POSITION ) self._last_run_success = last_state.attributes.get("last_run_success") - if (_tilt := self._attr_current_cover_position) is not None: + if (_tilt := self._attr_current_cover_tilt_position) is not None: self._attr_is_closed = (_tilt < self.CLOSED_DOWN_THRESHOLD) or ( _tilt > self.CLOSED_UP_THRESHOLD ) diff --git a/tests/components/switchbot/__init__.py b/tests/components/switchbot/__init__.py index d123c93a873..715073aa891 100644 --- a/tests/components/switchbot/__init__.py +++ b/tests/components/switchbot/__init__.py @@ -319,3 +319,70 @@ WOHUB2_SERVICE_INFO = BluetoothServiceInfoBleak( connectable=True, tx_power=-127, ) + + +WOCURTAIN3_SERVICE_INFO = BluetoothServiceInfoBleak( + name="WoCurtain3", + address="AA:BB:CC:DD:EE:FF", + manufacturer_data={2409: b"\xcf;Zwu\x0c\x19\x0b\x00\x11D\x006"}, + service_data={"00000d00-0000-1000-8000-00805f9b34fb": b"{\xc06\x00\x11D"}, + service_uuids=["cba20d00-224d-11e6-9fb8-0002a5d5c51b"], + rssi=-60, + source="local", + advertisement=generate_advertisement_data( + local_name="WoCurtain3", + manufacturer_data={2409: b"\xcf;Zwu\x0c\x19\x0b\x00\x11D\x006"}, + service_data={"00000d00-0000-1000-8000-00805f9b34fb": b"{\xc06\x00\x11D"}, + service_uuids=["cba20d00-224d-11e6-9fb8-0002a5d5c51b"], + ), + device=generate_ble_device("AA:BB:CC:DD:EE:FF", "WoCurtain3"), + time=0, + connectable=True, + tx_power=-127, +) + + +WOBLINDTILT_SERVICE_INFO = BluetoothServiceInfoBleak( + name="WoBlindTilt", + address="AA:BB:CC:DD:EE:FF", + manufacturer_data={2409: b"\xfbgA`\x98\xe8\x1d%2\x11\x84"}, + service_data={"00000d00-0000-1000-8000-00805f9b34fb": b"x\x00*"}, + service_uuids=["cba20d00-224d-11e6-9fb8-0002a5d5c51b"], + rssi=-60, + source="local", + advertisement=generate_advertisement_data( + local_name="WoBlindTilt", + manufacturer_data={2409: b"\xfbgA`\x98\xe8\x1d%2\x11\x84"}, + service_data={"00000d00-0000-1000-8000-00805f9b34fb": b"x\x00*"}, + service_uuids=["cba20d00-224d-11e6-9fb8-0002a5d5c51b"], + ), + device=generate_ble_device("AA:BB:CC:DD:EE:FF", "WoBlindTilt"), + time=0, + connectable=True, + tx_power=-127, +) + + +def make_advertisement( + address: str, manufacturer_data: bytes, service_data: bytes +) -> BluetoothServiceInfoBleak: + """Make a dummy advertisement.""" + return BluetoothServiceInfoBleak( + name="Test Device", + address=address, + manufacturer_data={2409: manufacturer_data}, + service_data={"00000d00-0000-1000-8000-00805f9b34fb": service_data}, + service_uuids=["cba20d00-224d-11e6-9fb8-0002a5d5c51b"], + rssi=-60, + source="local", + advertisement=generate_advertisement_data( + local_name="Test Device", + manufacturer_data={2409: manufacturer_data}, + service_data={"00000d00-0000-1000-8000-00805f9b34fb": service_data}, + service_uuids=["cba20d00-224d-11e6-9fb8-0002a5d5c51b"], + ), + device=generate_ble_device(address, "Test Device"), + time=0, + connectable=True, + tx_power=-127, + ) diff --git a/tests/components/switchbot/conftest.py b/tests/components/switchbot/conftest.py index 44f68a1c8ae..aff94626a68 100644 --- a/tests/components/switchbot/conftest.py +++ b/tests/components/switchbot/conftest.py @@ -2,7 +2,26 @@ import pytest +from homeassistant.components.switchbot.const import DOMAIN +from homeassistant.const import CONF_ADDRESS, CONF_NAME, CONF_SENSOR_TYPE + +from tests.common import MockConfigEntry + @pytest.fixture(autouse=True) def mock_bluetooth(enable_bluetooth: None) -> None: """Auto mock bluetooth.""" + + +@pytest.fixture +def mock_entry_factory(): + """Fixture to create a MockConfigEntry with a customizable sensor type.""" + return lambda sensor_type="curtain": MockConfigEntry( + domain=DOMAIN, + data={ + CONF_ADDRESS: "aa:bb:cc:dd:ee:ff", + CONF_NAME: "test-name", + CONF_SENSOR_TYPE: sensor_type, + }, + unique_id="aabbccddeeff", + ) diff --git a/tests/components/switchbot/test_cover.py b/tests/components/switchbot/test_cover.py new file mode 100644 index 00000000000..8810963f63d --- /dev/null +++ b/tests/components/switchbot/test_cover.py @@ -0,0 +1,327 @@ +"""Test the switchbot covers.""" + +from collections.abc import Callable +from unittest.mock import AsyncMock, patch + +from homeassistant.components.cover import ( + ATTR_CURRENT_POSITION, + ATTR_CURRENT_TILT_POSITION, + ATTR_POSITION, + ATTR_TILT_POSITION, + DOMAIN as COVER_DOMAIN, + CoverState, +) +from homeassistant.const import ( + ATTR_ENTITY_ID, + SERVICE_CLOSE_COVER, + SERVICE_CLOSE_COVER_TILT, + SERVICE_OPEN_COVER, + SERVICE_OPEN_COVER_TILT, + SERVICE_SET_COVER_POSITION, + SERVICE_SET_COVER_TILT_POSITION, + SERVICE_STOP_COVER, + SERVICE_STOP_COVER_TILT, +) +from homeassistant.core import HomeAssistant, State + +from . import WOBLINDTILT_SERVICE_INFO, WOCURTAIN3_SERVICE_INFO, make_advertisement + +from tests.common import MockConfigEntry, mock_restore_cache +from tests.components.bluetooth import inject_bluetooth_service_info + + +async def test_curtain3_setup( + hass: HomeAssistant, mock_entry_factory: Callable[[str], MockConfigEntry] +) -> None: + """Test setting up the Curtain3.""" + inject_bluetooth_service_info(hass, WOCURTAIN3_SERVICE_INFO) + + entry = mock_entry_factory(sensor_type="curtain") + + entity_id = "cover.test_name" + mock_restore_cache( + hass, + [ + State( + entity_id, + CoverState.OPEN, + {ATTR_CURRENT_POSITION: 50}, + ) + ], + ) + + entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + state = hass.states.get(entity_id) + assert state.state == CoverState.OPEN + assert state.attributes[ATTR_CURRENT_POSITION] == 50 + + +async def test_curtain3_controlling( + hass: HomeAssistant, mock_entry_factory: Callable[[str], MockConfigEntry] +) -> None: + """Test Curtain3 controlling.""" + inject_bluetooth_service_info(hass, WOCURTAIN3_SERVICE_INFO) + + entry = mock_entry_factory(sensor_type="curtain") + entry.add_to_hass(hass) + + with ( + patch( + "homeassistant.components.switchbot.cover.switchbot.SwitchbotCurtain.open", + new=AsyncMock(return_value=True), + ) as mock_open, + patch( + "homeassistant.components.switchbot.cover.switchbot.SwitchbotCurtain.close", + new=AsyncMock(return_value=True), + ) as mock_close, + patch( + "homeassistant.components.switchbot.cover.switchbot.SwitchbotCurtain.stop", + new=AsyncMock(return_value=True), + ) as mock_stop, + patch( + "homeassistant.components.switchbot.cover.switchbot.SwitchbotCurtain.set_position", + new=AsyncMock(return_value=True), + ) as mock_set_position, + ): + assert await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + entity_id = "cover.test_name" + address = "AA:BB:CC:DD:EE:FF" + service_data = b"{\xc06\x00\x11D" + + # Test open + manufacturer_data = b"\xcf;Zwu\x0c\x19\x0b\x05\x11D\x006" + await hass.services.async_call( + COVER_DOMAIN, SERVICE_OPEN_COVER, {ATTR_ENTITY_ID: entity_id}, blocking=True + ) + inject_bluetooth_service_info( + hass, make_advertisement(address, manufacturer_data, service_data) + ) + await hass.async_block_till_done() + + mock_open.assert_awaited_once() + state = hass.states.get(entity_id) + assert state.state == CoverState.OPEN + assert state.attributes[ATTR_CURRENT_POSITION] == 95 + + # Test close + manufacturer_data = b"\xcf;Zwu\x0c\x19\x0b\x58\x11D\x006" + await hass.services.async_call( + COVER_DOMAIN, + SERVICE_CLOSE_COVER, + {ATTR_ENTITY_ID: entity_id}, + blocking=True, + ) + inject_bluetooth_service_info( + hass, make_advertisement(address, manufacturer_data, service_data) + ) + await hass.async_block_till_done() + + mock_close.assert_awaited_once() + state = hass.states.get(entity_id) + assert state.state == CoverState.CLOSED + assert state.attributes[ATTR_CURRENT_POSITION] == 12 + + # Test stop + manufacturer_data = b"\xcf;Zwu\x0c\x19\x0b\x3c\x11D\x006" + await hass.services.async_call( + COVER_DOMAIN, SERVICE_STOP_COVER, {ATTR_ENTITY_ID: entity_id}, blocking=True + ) + inject_bluetooth_service_info( + hass, make_advertisement(address, manufacturer_data, service_data) + ) + await hass.async_block_till_done() + + mock_stop.assert_awaited_once() + state = hass.states.get(entity_id) + assert state.state == CoverState.OPEN + assert state.attributes[ATTR_CURRENT_POSITION] == 40 + + # Test set position + manufacturer_data = b"\xcf;Zwu\x0c\x19\x0b(\x11D\x006" + await hass.services.async_call( + COVER_DOMAIN, + SERVICE_SET_COVER_POSITION, + {ATTR_ENTITY_ID: entity_id, ATTR_POSITION: 50}, + blocking=True, + ) + inject_bluetooth_service_info( + hass, make_advertisement(address, manufacturer_data, service_data) + ) + await hass.async_block_till_done() + + mock_set_position.assert_awaited_once() + state = hass.states.get(entity_id) + assert state.state == CoverState.OPEN + assert state.attributes[ATTR_CURRENT_POSITION] == 60 + + +async def test_blindtilt_setup( + hass: HomeAssistant, mock_entry_factory: Callable[[str], MockConfigEntry] +) -> None: + """Test setting up the blindtilt.""" + inject_bluetooth_service_info(hass, WOBLINDTILT_SERVICE_INFO) + + entry = mock_entry_factory(sensor_type="blind_tilt") + entity_id = "cover.test_name" + mock_restore_cache( + hass, + [ + State( + entity_id, + CoverState.OPEN, + {ATTR_CURRENT_TILT_POSITION: 40}, + ) + ], + ) + + entry.add_to_hass(hass) + with patch( + "homeassistant.components.switchbot.cover.switchbot.SwitchbotBlindTilt.update", + new=AsyncMock(return_value=True), + ): + assert await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + state = hass.states.get(entity_id) + assert state.state == CoverState.OPEN + assert state.attributes[ATTR_CURRENT_TILT_POSITION] == 40 + + +async def test_blindtilt_controlling( + hass: HomeAssistant, mock_entry_factory: Callable[[str], MockConfigEntry] +) -> None: + """Test blindtilt controlling.""" + inject_bluetooth_service_info(hass, WOBLINDTILT_SERVICE_INFO) + + entry = mock_entry_factory(sensor_type="blind_tilt") + entry.add_to_hass(hass) + info = { + "motionDirection": { + "opening": False, + "closing": False, + "up": False, + "down": False, + }, + } + with ( + patch( + "homeassistant.components.switchbot.cover.switchbot.SwitchbotBlindTilt.get_basic_info", + new=AsyncMock(return_value=info), + ), + patch( + "homeassistant.components.switchbot.cover.switchbot.SwitchbotBlindTilt.open", + new=AsyncMock(return_value=True), + ) as mock_open, + patch( + "homeassistant.components.switchbot.cover.switchbot.SwitchbotBlindTilt.close", + new=AsyncMock(return_value=True), + ) as mock_close, + patch( + "homeassistant.components.switchbot.cover.switchbot.SwitchbotBlindTilt.stop", + new=AsyncMock(return_value=True), + ) as mock_stop, + patch( + "homeassistant.components.switchbot.cover.switchbot.SwitchbotBlindTilt.set_position", + new=AsyncMock(return_value=True), + ) as mock_set_position, + ): + assert await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + entity_id = "cover.test_name" + address = "AA:BB:CC:DD:EE:FF" + service_data = b"x\x00*" + + # Test open + manufacturer_data = b"\xfbgA`\x98\xe8\x1d%F\x12\x85" + await hass.services.async_call( + COVER_DOMAIN, + SERVICE_OPEN_COVER_TILT, + {ATTR_ENTITY_ID: entity_id}, + blocking=True, + ) + with patch( + "homeassistant.components.switchbot.cover.switchbot.SwitchbotBlindTilt.get_basic_info", + return_value=info, + ): + inject_bluetooth_service_info( + hass, make_advertisement(address, manufacturer_data, service_data) + ) + await hass.async_block_till_done() + + mock_open.assert_awaited_once() + + state = hass.states.get(entity_id) + assert state.state == CoverState.OPEN + assert state.attributes[ATTR_CURRENT_TILT_POSITION] == 70 + + # Test close + manufacturer_data = b"\xfbgA`\x98\xe8\x1d%\x0f\x12\x85" + await hass.services.async_call( + COVER_DOMAIN, + SERVICE_CLOSE_COVER_TILT, + {ATTR_ENTITY_ID: entity_id}, + blocking=True, + ) + with patch( + "homeassistant.components.switchbot.cover.switchbot.SwitchbotBlindTilt.get_basic_info", + return_value=info, + ): + inject_bluetooth_service_info( + hass, make_advertisement(address, manufacturer_data, service_data) + ) + await hass.async_block_till_done() + + mock_close.assert_awaited_once() + state = hass.states.get(entity_id) + assert state.state == CoverState.CLOSED + assert state.attributes[ATTR_CURRENT_TILT_POSITION] == 15 + + # Test stop + manufacturer_data = b"\xfbgA`\x98\xe8\x1d%\n\x12\x85" + await hass.services.async_call( + COVER_DOMAIN, + SERVICE_STOP_COVER_TILT, + {ATTR_ENTITY_ID: entity_id}, + blocking=True, + ) + with patch( + "homeassistant.components.switchbot.cover.switchbot.SwitchbotBlindTilt.get_basic_info", + return_value=info, + ): + inject_bluetooth_service_info( + hass, make_advertisement(address, manufacturer_data, service_data) + ) + await hass.async_block_till_done() + + mock_stop.assert_awaited_once() + state = hass.states.get(entity_id) + assert state.state == CoverState.CLOSED + assert state.attributes[ATTR_CURRENT_TILT_POSITION] == 10 + + # Test set position + manufacturer_data = b"\xfbgA`\x98\xe8\x1d%2\x12\x85" + await hass.services.async_call( + COVER_DOMAIN, + SERVICE_SET_COVER_TILT_POSITION, + {ATTR_ENTITY_ID: entity_id, ATTR_TILT_POSITION: 50}, + blocking=True, + ) + with patch( + "homeassistant.components.switchbot.cover.switchbot.SwitchbotBlindTilt.get_basic_info", + return_value=info, + ): + inject_bluetooth_service_info( + hass, make_advertisement(address, manufacturer_data, service_data) + ) + await hass.async_block_till_done() + + mock_set_position.assert_awaited_once() + state = hass.states.get(entity_id) + assert state.state == CoverState.OPEN + assert state.attributes[ATTR_CURRENT_TILT_POSITION] == 50 From 778a2891ce084e0bbd3541cba3879c29453a8e15 Mon Sep 17 00:00:00 2001 From: Dan Raper Date: Mon, 31 Mar 2025 10:44:01 +0100 Subject: [PATCH 122/505] Bump ohmepy to 1.5.1 (#141879) * Bump ohmepy to 1.5.1 * Fix types for ohmepy version change --- homeassistant/components/ohme/button.py | 5 +++-- homeassistant/components/ohme/manifest.json | 2 +- homeassistant/components/ohme/number.py | 9 +++++---- homeassistant/components/ohme/select.py | 4 ++-- homeassistant/components/ohme/sensor.py | 4 ++-- homeassistant/components/ohme/services.py | 2 +- homeassistant/components/ohme/time.py | 5 +++-- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/ohme/test_services.py | 12 +++++++----- 10 files changed, 26 insertions(+), 21 deletions(-) diff --git a/homeassistant/components/ohme/button.py b/homeassistant/components/ohme/button.py index 6e942215c0f..41782ea4a2d 100644 --- a/homeassistant/components/ohme/button.py +++ b/homeassistant/components/ohme/button.py @@ -2,8 +2,9 @@ from __future__ import annotations -from collections.abc import Awaitable, Callable +from collections.abc import Callable, Coroutine from dataclasses import dataclass +from typing import Any from ohme import ApiException, ChargerStatus, OhmeApiClient @@ -23,7 +24,7 @@ PARALLEL_UPDATES = 1 class OhmeButtonDescription(OhmeEntityDescription, ButtonEntityDescription): """Class describing Ohme button entities.""" - press_fn: Callable[[OhmeApiClient], Awaitable[None]] + press_fn: Callable[[OhmeApiClient], Coroutine[Any, Any, bool]] BUTTON_DESCRIPTIONS = [ diff --git a/homeassistant/components/ohme/manifest.json b/homeassistant/components/ohme/manifest.json index f0021808d92..30a55360ce2 100644 --- a/homeassistant/components/ohme/manifest.json +++ b/homeassistant/components/ohme/manifest.json @@ -7,5 +7,5 @@ "integration_type": "device", "iot_class": "cloud_polling", "quality_scale": "silver", - "requirements": ["ohme==1.4.1"] + "requirements": ["ohme==1.5.1"] } diff --git a/homeassistant/components/ohme/number.py b/homeassistant/components/ohme/number.py index 0c71bab009f..f412c658085 100644 --- a/homeassistant/components/ohme/number.py +++ b/homeassistant/components/ohme/number.py @@ -1,7 +1,8 @@ """Platform for number.""" -from collections.abc import Awaitable, Callable +from collections.abc import Callable, Coroutine from dataclasses import dataclass +from typing import Any from ohme import ApiException, OhmeApiClient @@ -22,7 +23,7 @@ PARALLEL_UPDATES = 1 class OhmeNumberDescription(OhmeEntityDescription, NumberEntityDescription): """Class describing Ohme number entities.""" - set_fn: Callable[[OhmeApiClient, float], Awaitable[None]] + set_fn: Callable[[OhmeApiClient, float], Coroutine[Any, Any, bool]] value_fn: Callable[[OhmeApiClient], float] @@ -31,7 +32,7 @@ NUMBER_DESCRIPTION = [ key="target_percentage", translation_key="target_percentage", value_fn=lambda client: client.target_soc, - set_fn=lambda client, value: client.async_set_target(target_percent=value), + set_fn=lambda client, value: client.async_set_target(target_percent=int(value)), native_min_value=0, native_max_value=100, native_step=1, @@ -42,7 +43,7 @@ NUMBER_DESCRIPTION = [ translation_key="preconditioning_duration", value_fn=lambda client: client.preconditioning, set_fn=lambda client, value: client.async_set_target( - pre_condition_length=value + pre_condition_length=int(value) ), native_min_value=0, native_max_value=60, diff --git a/homeassistant/components/ohme/select.py b/homeassistant/components/ohme/select.py index f065afeb176..d8d9c52c3b6 100644 --- a/homeassistant/components/ohme/select.py +++ b/homeassistant/components/ohme/select.py @@ -2,7 +2,7 @@ from __future__ import annotations -from collections.abc import Awaitable, Callable +from collections.abc import Callable, Coroutine from dataclasses import dataclass from typing import Any, Final @@ -24,7 +24,7 @@ PARALLEL_UPDATES = 1 class OhmeSelectDescription(OhmeEntityDescription, SelectEntityDescription): """Class to describe an Ohme select entity.""" - select_fn: Callable[[OhmeApiClient, Any], Awaitable[None]] + select_fn: Callable[[OhmeApiClient, Any], Coroutine[Any, Any, bool | None]] options: list[str] | None = None options_fn: Callable[[OhmeApiClient], list[str]] | None = None current_option_fn: Callable[[OhmeApiClient], str | None] diff --git a/homeassistant/components/ohme/sensor.py b/homeassistant/components/ohme/sensor.py index 6b9e1e9c5a7..7047e33749f 100644 --- a/homeassistant/components/ohme/sensor.py +++ b/homeassistant/components/ohme/sensor.py @@ -34,7 +34,7 @@ PARALLEL_UPDATES = 0 class OhmeSensorDescription(OhmeEntityDescription, SensorEntityDescription): """Class describing Ohme sensor entities.""" - value_fn: Callable[[OhmeApiClient], str | int | float] + value_fn: Callable[[OhmeApiClient], str | int | float | None] SENSOR_CHARGE_SESSION = [ @@ -130,6 +130,6 @@ class OhmeSensor(OhmeEntity, SensorEntity): entity_description: OhmeSensorDescription @property - def native_value(self) -> str | int | float: + def native_value(self) -> str | int | float | None: """Return the state of the sensor.""" return self.entity_description.value_fn(self.coordinator.client) diff --git a/homeassistant/components/ohme/services.py b/homeassistant/components/ohme/services.py index be044f01740..249fb1abdab 100644 --- a/homeassistant/components/ohme/services.py +++ b/homeassistant/components/ohme/services.py @@ -78,7 +78,7 @@ def async_setup_services(hass: HomeAssistant) -> None: """List of charge slots.""" client = __get_client(service_call) - return {"slots": client.slots} + return {"slots": [slot.to_dict() for slot in client.slots]} async def set_price_cap( service_call: ServiceCall, diff --git a/homeassistant/components/ohme/time.py b/homeassistant/components/ohme/time.py index 264b2afd41a..a0b1edb594a 100644 --- a/homeassistant/components/ohme/time.py +++ b/homeassistant/components/ohme/time.py @@ -1,8 +1,9 @@ """Platform for time.""" -from collections.abc import Awaitable, Callable +from collections.abc import Callable, Coroutine from dataclasses import dataclass from datetime import time +from typing import Any from ohme import ApiException, OhmeApiClient @@ -22,7 +23,7 @@ PARALLEL_UPDATES = 1 class OhmeTimeDescription(OhmeEntityDescription, TimeEntityDescription): """Class describing Ohme time entities.""" - set_fn: Callable[[OhmeApiClient, time], Awaitable[None]] + set_fn: Callable[[OhmeApiClient, time], Coroutine[Any, Any, bool]] value_fn: Callable[[OhmeApiClient], time] diff --git a/requirements_all.txt b/requirements_all.txt index d5eca20886e..bf01275a876 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1559,7 +1559,7 @@ odp-amsterdam==6.0.2 oemthermostat==1.1.1 # homeassistant.components.ohme -ohme==1.4.1 +ohme==1.5.1 # homeassistant.components.ollama ollama==0.4.7 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index e53b7311682..4e382193217 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1305,7 +1305,7 @@ objgraph==3.5.0 odp-amsterdam==6.0.2 # homeassistant.components.ohme -ohme==1.4.1 +ohme==1.5.1 # homeassistant.components.ollama ollama==0.4.7 diff --git a/tests/components/ohme/test_services.py b/tests/components/ohme/test_services.py index 2513635c1c2..c228ddcd9a7 100644 --- a/tests/components/ohme/test_services.py +++ b/tests/components/ohme/test_services.py @@ -1,7 +1,9 @@ """Tests for services.""" +from datetime import datetime from unittest.mock import AsyncMock, MagicMock +from ohme import ChargeSlot import pytest from syrupy.assertion import SnapshotAssertion @@ -30,11 +32,11 @@ async def test_list_charge_slots( await setup_integration(hass, mock_config_entry) mock_client.slots = [ - { - "start": "2024-12-30T04:00:00+00:00", - "end": "2024-12-30T04:30:39+00:00", - "energy": 2.042, - } + ChargeSlot( + datetime.fromisoformat("2024-12-30T04:00:00+00:00"), + datetime.fromisoformat("2024-12-30T04:30:39+00:00"), + 2.042, + ) ] assert snapshot == await hass.services.async_call( From c91a1d0fceff30b70c6127613517345272699551 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Mon, 31 Mar 2025 12:20:06 +0200 Subject: [PATCH 123/505] Fix SmartThings being able to understand incomplete DRLC (#141907) --- .../components/smartthings/climate.py | 14 +- tests/components/smartthings/conftest.py | 1 + .../device_status/da_ac_rac_000003.json | 585 ++++++++++++++++++ .../fixtures/devices/da_ac_rac_000003.json | 217 +++++++ .../smartthings/snapshots/test_climate.ambr | 106 ++++ .../smartthings/snapshots/test_init.ambr | 33 + .../smartthings/snapshots/test_sensor.ambr | 429 +++++++++++++ 7 files changed, 1379 insertions(+), 6 deletions(-) create mode 100644 tests/components/smartthings/fixtures/device_status/da_ac_rac_000003.json create mode 100644 tests/components/smartthings/fixtures/devices/da_ac_rac_000003.json diff --git a/homeassistant/components/smartthings/climate.py b/homeassistant/components/smartthings/climate.py index 9f94293d863..49499732c24 100644 --- a/homeassistant/components/smartthings/climate.py +++ b/homeassistant/components/smartthings/climate.py @@ -466,12 +466,14 @@ class SmartThingsAirConditioner(SmartThingsEntity, ClimateEntity): Capability.DEMAND_RESPONSE_LOAD_CONTROL, Attribute.DEMAND_RESPONSE_LOAD_CONTROL_STATUS, ) - return { - "drlc_status_duration": drlc_status["duration"], - "drlc_status_level": drlc_status["drlcLevel"], - "drlc_status_start": drlc_status["start"], - "drlc_status_override": drlc_status["override"], - } + res = {} + for key in ("duration", "start", "override", "drlcLevel"): + if key in drlc_status: + dict_key = {"drlcLevel": "drlc_status_level"}.get( + key, f"drlc_status_{key}" + ) + res[dict_key] = drlc_status[key] + return res @property def fan_mode(self) -> str: diff --git a/tests/components/smartthings/conftest.py b/tests/components/smartthings/conftest.py index ef6b6f29011..5744adfef07 100644 --- a/tests/components/smartthings/conftest.py +++ b/tests/components/smartthings/conftest.py @@ -93,6 +93,7 @@ def mock_smartthings() -> Generator[AsyncMock]: params=[ "da_ac_airsensor_01001", "da_ac_rac_000001", + "da_ac_rac_000003", "da_ac_rac_100001", "da_ac_rac_01001", "multipurpose_sensor", diff --git a/tests/components/smartthings/fixtures/device_status/da_ac_rac_000003.json b/tests/components/smartthings/fixtures/device_status/da_ac_rac_000003.json new file mode 100644 index 00000000000..98434aa2c5a --- /dev/null +++ b/tests/components/smartthings/fixtures/device_status/da_ac_rac_000003.json @@ -0,0 +1,585 @@ +{ + "components": { + "main": { + "relativeHumidityMeasurement": { + "humidity": { + "value": 48, + "unit": "%", + "timestamp": "2025-03-27T05:12:16.158Z" + } + }, + "custom.airConditionerOdorController": { + "airConditionerOdorControllerProgress": { + "value": null + }, + "airConditionerOdorControllerState": { + "value": null + } + }, + "custom.thermostatSetpointControl": { + "minimumSetpoint": { + "value": 16, + "unit": "C", + "timestamp": "2025-03-13T09:29:37.008Z" + }, + "maximumSetpoint": { + "value": 30, + "unit": "C", + "timestamp": "2024-06-21T13:45:16.785Z" + } + }, + "airConditionerMode": { + "availableAcModes": { + "value": null + }, + "supportedAcModes": { + "value": ["cool", "dry", "wind", "auto"], + "timestamp": "2024-06-21T13:45:16.785Z" + }, + "airConditionerMode": { + "value": "cool", + "timestamp": "2025-03-13T09:29:36.789Z" + } + }, + "custom.spiMode": { + "spiMode": { + "value": "off", + "timestamp": "2025-02-08T08:54:15.661Z" + } + }, + "samsungce.deviceIdentification": { + "micomAssayCode": { + "value": null + }, + "modelName": { + "value": null + }, + "serialNumber": { + "value": null + }, + "serialNumberExtra": { + "value": null + }, + "modelClassificationCode": { + "value": null + }, + "description": { + "value": null + }, + "releaseYear": { + "value": null + }, + "binaryId": { + "value": "ARTIK051_PRAC_20K", + "timestamp": "2025-03-27T05:12:15.284Z" + } + }, + "airQualitySensor": { + "airQuality": { + "value": null + } + }, + "custom.airConditionerOptionalMode": { + "supportedAcOptionalMode": { + "value": [ + "off", + "sleep", + "quiet", + "smart", + "speed", + "windFree", + "windFreeSleep" + ], + "timestamp": "2024-06-21T13:45:16.785Z" + }, + "acOptionalMode": { + "value": "off", + "timestamp": "2025-03-26T12:20:41.095Z" + } + }, + "switch": { + "switch": { + "value": "on", + "timestamp": "2025-03-27T05:41:42.291Z" + } + }, + "custom.airConditionerTropicalNightMode": { + "acTropicalNightModeLevel": { + "value": 0, + "timestamp": "2025-02-08T08:54:15.789Z" + } + }, + "ocf": { + "st": { + "value": null + }, + "mndt": { + "value": null + }, + "mnfv": { + "value": "ARTIK051_PRAC_20K_11230313", + "timestamp": "2024-06-21T13:58:04.085Z" + }, + "mnhw": { + "value": "ARTIK051", + "timestamp": "2024-06-21T13:51:35.294Z" + }, + "di": { + "value": "c76d6f38-1b7f-13dd-37b5-db18d5272783", + "timestamp": "2024-06-21T13:45:16.329Z" + }, + "mnsl": { + "value": "http://www.samsung.com", + "timestamp": "2024-06-21T13:51:35.980Z" + }, + "dmv": { + "value": "res.1.1.0,sh.1.1.0", + "timestamp": "2024-06-21T13:58:04.698Z" + }, + "n": { + "value": "Samsung Room A/C", + "timestamp": "2024-06-21T13:58:04.085Z" + }, + "mnmo": { + "value": "ARTIK051_PRAC_20K|10256941|60010534001411014600003200800000", + "timestamp": "2024-06-21T13:45:16.329Z" + }, + "vid": { + "value": "DA-AC-RAC-000003", + "timestamp": "2024-06-21T13:45:16.329Z" + }, + "mnmn": { + "value": "Samsung Electronics", + "timestamp": "2024-06-21T13:45:16.329Z" + }, + "mnml": { + "value": "http://www.samsung.com", + "timestamp": "2024-06-21T13:45:16.329Z" + }, + "mnpv": { + "value": "DAWIT 2.0", + "timestamp": "2024-06-21T13:51:35.294Z" + }, + "mnos": { + "value": "TizenRT 1.0 + IPv6", + "timestamp": "2024-06-21T13:51:35.294Z" + }, + "pi": { + "value": "c76d6f38-1b7f-13dd-37b5-db18d5272783", + "timestamp": "2024-06-21T13:45:16.329Z" + }, + "icv": { + "value": "core.1.1.0", + "timestamp": "2024-06-21T13:45:16.329Z" + } + }, + "airConditionerFanMode": { + "fanMode": { + "value": "low", + "timestamp": "2025-03-26T12:20:41.393Z" + }, + "supportedAcFanModes": { + "value": ["auto", "low", "medium", "high", "turbo"], + "timestamp": "2024-06-21T13:45:16.785Z" + }, + "availableAcFanModes": { + "value": null + } + }, + "samsungce.dustFilterAlarm": { + "alarmThreshold": { + "value": 500, + "unit": "Hour", + "timestamp": "2025-02-08T08:54:15.473Z" + }, + "supportedAlarmThresholds": { + "value": [180, 300, 500, 700], + "unit": "Hour", + "timestamp": "2025-02-08T08:54:15.473Z" + } + }, + "custom.electricHepaFilter": { + "electricHepaFilterCapacity": { + "value": null + }, + "electricHepaFilterUsageStep": { + "value": null + }, + "electricHepaFilterLastResetDate": { + "value": null + }, + "electricHepaFilterStatus": { + "value": null + }, + "electricHepaFilterUsage": { + "value": null + }, + "electricHepaFilterResetType": { + "value": null + } + }, + "custom.disabledCapabilities": { + "disabledCapabilities": { + "value": [ + "remoteControlStatus", + "airQualitySensor", + "dustSensor", + "odorSensor", + "veryFineDustSensor", + "custom.spiMode", + "custom.deodorFilter", + "custom.electricHepaFilter", + "custom.periodicSensing", + "custom.doNotDisturbMode", + "custom.airConditionerOdorController", + "samsungce.individualControlLock" + ], + "timestamp": "2025-02-08T08:54:15.355Z" + } + }, + "custom.ocfResourceVersion": { + "ocfResourceUpdatedTime": { + "value": null + }, + "ocfResourceVersion": { + "value": null + } + }, + "samsungce.driverVersion": { + "versionNumber": { + "value": 24040101, + "timestamp": "2024-06-21T13:45:16.348Z" + } + }, + "fanOscillationMode": { + "supportedFanOscillationModes": { + "value": ["fixed", "all", "vertical", "horizontal"], + "timestamp": "2025-02-08T08:54:15.797Z" + }, + "availableFanOscillationModes": { + "value": null + }, + "fanOscillationMode": { + "value": "fixed", + "timestamp": "2025-02-25T15:40:11.773Z" + } + }, + "temperatureMeasurement": { + "temperatureRange": { + "value": null + }, + "temperature": { + "value": 26, + "unit": "C", + "timestamp": "2025-03-26T14:19:08.047Z" + } + }, + "dustSensor": { + "dustLevel": { + "value": null + }, + "fineDustLevel": { + "value": null + } + }, + "custom.deviceReportStateConfiguration": { + "reportStateRealtimePeriod": { + "value": "disabled", + "timestamp": "2025-02-08T08:54:15.726Z" + }, + "reportStateRealtime": { + "value": { + "state": "enabled", + "duration": 10, + "unit": "minute" + }, + "timestamp": "2025-03-24T08:28:07.030Z" + }, + "reportStatePeriod": { + "value": "enabled", + "timestamp": "2025-02-08T08:54:15.726Z" + } + }, + "custom.periodicSensing": { + "automaticExecutionSetting": { + "value": null + }, + "automaticExecutionMode": { + "value": null + }, + "supportedAutomaticExecutionSetting": { + "value": null + }, + "supportedAutomaticExecutionMode": { + "value": null + }, + "periodicSensing": { + "value": null + }, + "periodicSensingInterval": { + "value": null + }, + "lastSensingTime": { + "value": null + }, + "lastSensingLevel": { + "value": null + }, + "periodicSensingStatus": { + "value": null + } + }, + "thermostatCoolingSetpoint": { + "coolingSetpointRange": { + "value": null + }, + "coolingSetpoint": { + "value": 24, + "unit": "C", + "timestamp": "2025-03-26T12:20:41.346Z" + } + }, + "demandResponseLoadControl": { + "drlcStatus": { + "value": { + "drlcType": 1, + "duration": 0, + "override": false + }, + "timestamp": "2025-03-24T04:56:36.855Z" + } + }, + "audioVolume": { + "volume": { + "value": 100, + "unit": "%", + "timestamp": "2025-02-08T08:54:15.789Z" + } + }, + "powerConsumptionReport": { + "powerConsumption": { + "value": { + "energy": 602171, + "deltaEnergy": 0, + "power": 0, + "powerEnergy": 0.0, + "persistedEnergy": 602171, + "energySaved": 0, + "persistedSavedEnergy": 0, + "start": "2025-03-27T05:29:22Z", + "end": "2025-03-27T05:40:02Z" + }, + "timestamp": "2025-03-27T05:40:02.686Z" + } + }, + "custom.autoCleaningMode": { + "supportedAutoCleaningModes": { + "value": null + }, + "timedCleanDuration": { + "value": null + }, + "operatingState": { + "value": null + }, + "timedCleanDurationRange": { + "value": null + }, + "supportedOperatingStates": { + "value": null + }, + "progress": { + "value": null + }, + "autoCleaningMode": { + "value": "off", + "timestamp": "2025-03-15T05:30:11.075Z" + } + }, + "samsungce.individualControlLock": { + "lockState": { + "value": null + } + }, + "refresh": {}, + "execute": { + "data": { + "value": null + } + }, + "samsungce.selfCheck": { + "result": { + "value": null + }, + "supportedActions": { + "value": ["start"], + "timestamp": "2024-06-21T13:45:16.348Z" + }, + "progress": { + "value": null + }, + "errors": { + "value": [], + "timestamp": "2025-02-08T08:54:15.048Z" + }, + "status": { + "value": null + } + }, + "custom.dustFilter": { + "dustFilterUsageStep": { + "value": 1, + "timestamp": "2025-02-08T08:54:15.473Z" + }, + "dustFilterUsage": { + "value": 69, + "timestamp": "2025-03-26T10:57:41.097Z" + }, + "dustFilterLastResetDate": { + "value": null + }, + "dustFilterStatus": { + "value": "normal", + "timestamp": "2025-02-08T08:54:15.473Z" + }, + "dustFilterCapacity": { + "value": 500, + "unit": "Hour", + "timestamp": "2025-02-08T08:54:15.473Z" + }, + "dustFilterResetType": { + "value": ["replaceable", "washable"], + "timestamp": "2025-02-08T08:54:15.473Z" + } + }, + "odorSensor": { + "odorLevel": { + "value": null + } + }, + "remoteControlStatus": { + "remoteControlEnabled": { + "value": null + } + }, + "custom.deodorFilter": { + "deodorFilterCapacity": { + "value": null + }, + "deodorFilterLastResetDate": { + "value": null + }, + "deodorFilterStatus": { + "value": null + }, + "deodorFilterResetType": { + "value": null + }, + "deodorFilterUsage": { + "value": null + }, + "deodorFilterUsageStep": { + "value": null + } + }, + "custom.energyType": { + "energyType": { + "value": "1.0", + "timestamp": "2024-06-21T13:45:16.785Z" + }, + "energySavingSupport": { + "value": true, + "timestamp": "2024-06-21T13:58:08.419Z" + }, + "drMaxDuration": { + "value": 99999999, + "unit": "min", + "timestamp": "2024-06-21T13:51:39.304Z" + }, + "energySavingLevel": { + "value": null + }, + "energySavingInfo": { + "value": null + }, + "supportedEnergySavingLevels": { + "value": null + }, + "energySavingOperation": { + "value": false, + "timestamp": "2025-02-08T08:54:16.767Z" + }, + "notificationTemplateID": { + "value": null + }, + "energySavingOperationSupport": { + "value": false, + "timestamp": "2025-03-24T04:56:36.855Z" + } + }, + "samsungce.softwareUpdate": { + "targetModule": { + "value": {}, + "timestamp": "2025-02-08T08:54:16.685Z" + }, + "otnDUID": { + "value": "MTCPH4AI4MTYO", + "timestamp": "2025-02-08T08:54:15.626Z" + }, + "lastUpdatedDate": { + "value": null + }, + "availableModules": { + "value": [], + "timestamp": "2025-02-08T08:54:15.626Z" + }, + "newVersionAvailable": { + "value": false, + "timestamp": "2025-02-08T08:54:15.626Z" + }, + "operatingState": { + "value": null + }, + "progress": { + "value": null + } + }, + "veryFineDustSensor": { + "veryFineDustLevel": { + "value": null + } + }, + "custom.veryFineDustFilter": { + "veryFineDustFilterStatus": { + "value": null + }, + "veryFineDustFilterResetType": { + "value": null + }, + "veryFineDustFilterUsage": { + "value": null + }, + "veryFineDustFilterLastResetDate": { + "value": null + }, + "veryFineDustFilterUsageStep": { + "value": null + }, + "veryFineDustFilterCapacity": { + "value": null + } + }, + "custom.doNotDisturbMode": { + "doNotDisturb": { + "value": null + }, + "startTime": { + "value": null + }, + "endTime": { + "value": null + } + } + } + } +} diff --git a/tests/components/smartthings/fixtures/devices/da_ac_rac_000003.json b/tests/components/smartthings/fixtures/devices/da_ac_rac_000003.json new file mode 100644 index 00000000000..44dafc213f0 --- /dev/null +++ b/tests/components/smartthings/fixtures/devices/da_ac_rac_000003.json @@ -0,0 +1,217 @@ +{ + "items": [ + { + "deviceId": "c76d6f38-1b7f-13dd-37b5-db18d5272783", + "name": "Samsung Room A/C", + "label": "Office AirFree", + "manufacturerName": "Samsung Electronics", + "presentationId": "DA-AC-RAC-000003", + "deviceManufacturerCode": "Samsung Electronics", + "locationId": "403cd42e-f692-416c-91fd-1883c00e3262", + "ownerId": "dd474e5c-59c0-4bea-a319-ff5287fd3373", + "roomId": "dffe353e-b3c5-4a97-8a8a-797ccc649fab", + "deviceTypeName": "Samsung OCF Air Conditioner", + "components": [ + { + "id": "main", + "label": "main", + "capabilities": [ + { + "id": "ocf", + "version": 1 + }, + { + "id": "switch", + "version": 1 + }, + { + "id": "airConditionerMode", + "version": 1 + }, + { + "id": "airConditionerFanMode", + "version": 1 + }, + { + "id": "fanOscillationMode", + "version": 1 + }, + { + "id": "temperatureMeasurement", + "version": 1 + }, + { + "id": "thermostatCoolingSetpoint", + "version": 1 + }, + { + "id": "relativeHumidityMeasurement", + "version": 1 + }, + { + "id": "airQualitySensor", + "version": 1 + }, + { + "id": "odorSensor", + "version": 1 + }, + { + "id": "dustSensor", + "version": 1 + }, + { + "id": "veryFineDustSensor", + "version": 1 + }, + { + "id": "audioVolume", + "version": 1 + }, + { + "id": "remoteControlStatus", + "version": 1 + }, + { + "id": "powerConsumptionReport", + "version": 1 + }, + { + "id": "demandResponseLoadControl", + "version": 1 + }, + { + "id": "refresh", + "version": 1 + }, + { + "id": "execute", + "version": 1 + }, + { + "id": "custom.spiMode", + "version": 1 + }, + { + "id": "custom.thermostatSetpointControl", + "version": 1 + }, + { + "id": "custom.airConditionerOptionalMode", + "version": 1 + }, + { + "id": "custom.airConditionerTropicalNightMode", + "version": 1 + }, + { + "id": "custom.autoCleaningMode", + "version": 1 + }, + { + "id": "custom.deviceReportStateConfiguration", + "version": 1 + }, + { + "id": "custom.energyType", + "version": 1 + }, + { + "id": "custom.dustFilter", + "version": 1 + }, + { + "id": "custom.veryFineDustFilter", + "version": 1 + }, + { + "id": "custom.deodorFilter", + "version": 1 + }, + { + "id": "custom.electricHepaFilter", + "version": 1 + }, + { + "id": "custom.doNotDisturbMode", + "version": 1 + }, + { + "id": "custom.periodicSensing", + "version": 1 + }, + { + "id": "custom.airConditionerOdorController", + "version": 1 + }, + { + "id": "custom.ocfResourceVersion", + "version": 1 + }, + { + "id": "custom.disabledCapabilities", + "version": 1 + }, + { + "id": "samsungce.deviceIdentification", + "version": 1 + }, + { + "id": "samsungce.dustFilterAlarm", + "version": 1 + }, + { + "id": "samsungce.driverVersion", + "version": 1 + }, + { + "id": "samsungce.softwareUpdate", + "version": 1 + }, + { + "id": "samsungce.selfCheck", + "version": 1 + }, + { + "id": "samsungce.individualControlLock", + "version": 1 + } + ], + "categories": [ + { + "name": "AirConditioner", + "categoryType": "manufacturer" + } + ] + } + ], + "createTime": "2024-06-21T13:45:16.238Z", + "profile": { + "id": "cedae6e3-1ec9-37e3-9aba-f717518156b8" + }, + "ocf": { + "ocfDeviceType": "oic.d.airconditioner", + "name": "Samsung Room A/C", + "specVersion": "core.1.1.0", + "verticalDomainSpecVersion": "1.2.1", + "manufacturerName": "Samsung Electronics", + "modelNumber": "ARTIK051_PRAC_20K|10256941|60010534001411014600003200800000", + "platformVersion": "DAWIT 2.0", + "platformOS": "TizenRT 1.0 + IPv6", + "hwVersion": "ARTIK051", + "firmwareVersion": "ARTIK051_PRAC_20K_11230313", + "vendorId": "DA-AC-RAC-000003", + "vendorResourceClientServerVersion": "ARTIK051 Release 2.211222.1", + "lastSignupTime": "2024-06-21T13:45:08.592221Z", + "transferCandidate": false, + "additionalAuthCodeRequired": false + }, + "type": "OCF", + "restrictionTier": 0, + "allowed": null, + "executionContext": "CLOUD", + "relationships": [] + } + ], + "_links": {} +} diff --git a/tests/components/smartthings/snapshots/test_climate.ambr b/tests/components/smartthings/snapshots/test_climate.ambr index 17e25421fec..19cfe971d7f 100644 --- a/tests/components/smartthings/snapshots/test_climate.ambr +++ b/tests/components/smartthings/snapshots/test_climate.ambr @@ -228,6 +228,112 @@ 'state': 'off', }) # --- +# name: test_all_entities[da_ac_rac_000003][climate.office_airfree-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'fan_modes': list([ + 'auto', + 'low', + 'medium', + 'high', + 'turbo', + ]), + 'hvac_modes': list([ + , + , + , + , + , + ]), + 'max_temp': 35, + 'min_temp': 7, + 'preset_modes': list([ + 'windFree', + ]), + 'swing_modes': list([ + 'off', + 'both', + 'vertical', + 'horizontal', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'climate', + 'entity_category': None, + 'entity_id': 'climate.office_airfree', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': None, + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': , + 'translation_key': None, + 'unique_id': 'c76d6f38-1b7f-13dd-37b5-db18d5272783_main', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[da_ac_rac_000003][climate.office_airfree-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'current_temperature': 26, + 'drlc_status_duration': 0, + 'drlc_status_override': False, + 'fan_mode': 'low', + 'fan_modes': list([ + 'auto', + 'low', + 'medium', + 'high', + 'turbo', + ]), + 'friendly_name': 'Office AirFree', + 'hvac_modes': list([ + , + , + , + , + , + ]), + 'max_temp': 35, + 'min_temp': 7, + 'preset_mode': None, + 'preset_modes': list([ + 'windFree', + ]), + 'supported_features': , + 'swing_mode': 'off', + 'swing_modes': list([ + 'off', + 'both', + 'vertical', + 'horizontal', + ]), + 'temperature': 24, + }), + 'context': , + 'entity_id': 'climate.office_airfree', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'cool', + }) +# --- # name: test_all_entities[da_ac_rac_01001][climate.aire_dormitorio_principal-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ diff --git a/tests/components/smartthings/snapshots/test_init.ambr b/tests/components/smartthings/snapshots/test_init.ambr index 6a402182b82..052d15bd1ae 100644 --- a/tests/components/smartthings/snapshots/test_init.ambr +++ b/tests/components/smartthings/snapshots/test_init.ambr @@ -365,6 +365,39 @@ 'via_device_id': None, }) # --- +# name: test_devices[da_ac_rac_000003] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'config_entries_subentries': , + 'configuration_url': 'https://account.smartthings.com', + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': 'ARTIK051', + 'id': , + 'identifiers': set({ + tuple( + 'smartthings', + 'c76d6f38-1b7f-13dd-37b5-db18d5272783', + ), + }), + 'is_new': False, + 'labels': set({ + }), + 'manufacturer': 'Samsung Electronics', + 'model': 'ARTIK051_PRAC_20K', + 'model_id': None, + 'name': 'Office AirFree', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'suggested_area': None, + 'sw_version': 'ARTIK051_PRAC_20K_11230313', + 'via_device_id': None, + }) +# --- # name: test_devices[da_ac_rac_01001] DeviceRegistryEntrySnapshot({ 'area_id': 'theater', diff --git a/tests/components/smartthings/snapshots/test_sensor.ambr b/tests/components/smartthings/snapshots/test_sensor.ambr index 416a3d15947..73bbc96bc85 100644 --- a/tests/components/smartthings/snapshots/test_sensor.ambr +++ b/tests/components/smartthings/snapshots/test_sensor.ambr @@ -1513,6 +1513,435 @@ 'state': '100', }) # --- +# name: test_all_entities[da_ac_rac_000003][sensor.office_airfree_energy-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.office_airfree_energy', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Energy', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'c76d6f38-1b7f-13dd-37b5-db18d5272783_main_powerConsumptionReport_powerConsumption_energy_meter', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[da_ac_rac_000003][sensor.office_airfree_energy-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Office AirFree Energy', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.office_airfree_energy', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '602.171', + }) +# --- +# name: test_all_entities[da_ac_rac_000003][sensor.office_airfree_energy_difference-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.office_airfree_energy_difference', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Energy difference', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'energy_difference', + 'unique_id': 'c76d6f38-1b7f-13dd-37b5-db18d5272783_main_powerConsumptionReport_powerConsumption_deltaEnergy_meter', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[da_ac_rac_000003][sensor.office_airfree_energy_difference-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Office AirFree Energy difference', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.office_airfree_energy_difference', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0', + }) +# --- +# name: test_all_entities[da_ac_rac_000003][sensor.office_airfree_energy_saved-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.office_airfree_energy_saved', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Energy saved', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'energy_saved', + 'unique_id': 'c76d6f38-1b7f-13dd-37b5-db18d5272783_main_powerConsumptionReport_powerConsumption_energySaved_meter', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[da_ac_rac_000003][sensor.office_airfree_energy_saved-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Office AirFree Energy saved', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.office_airfree_energy_saved', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0', + }) +# --- +# name: test_all_entities[da_ac_rac_000003][sensor.office_airfree_humidity-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.office_airfree_humidity', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Humidity', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'c76d6f38-1b7f-13dd-37b5-db18d5272783_main_relativeHumidityMeasurement_humidity_humidity', + 'unit_of_measurement': '%', + }) +# --- +# name: test_all_entities[da_ac_rac_000003][sensor.office_airfree_humidity-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'humidity', + 'friendly_name': 'Office AirFree Humidity', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.office_airfree_humidity', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '48', + }) +# --- +# name: test_all_entities[da_ac_rac_000003][sensor.office_airfree_power-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.office_airfree_power', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Power', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'c76d6f38-1b7f-13dd-37b5-db18d5272783_main_powerConsumptionReport_powerConsumption_power_meter', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[da_ac_rac_000003][sensor.office_airfree_power-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'Office AirFree Power', + 'power_consumption_end': '2025-03-27T05:40:02Z', + 'power_consumption_start': '2025-03-27T05:29:22Z', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.office_airfree_power', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0', + }) +# --- +# name: test_all_entities[da_ac_rac_000003][sensor.office_airfree_power_energy-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.office_airfree_power_energy', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Power energy', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'power_energy', + 'unique_id': 'c76d6f38-1b7f-13dd-37b5-db18d5272783_main_powerConsumptionReport_powerConsumption_powerEnergy_meter', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[da_ac_rac_000003][sensor.office_airfree_power_energy-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Office AirFree Power energy', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.office_airfree_power_energy', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0', + }) +# --- +# name: test_all_entities[da_ac_rac_000003][sensor.office_airfree_temperature-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.office_airfree_temperature', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Temperature', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'c76d6f38-1b7f-13dd-37b5-db18d5272783_main_temperatureMeasurement_temperature_temperature', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[da_ac_rac_000003][sensor.office_airfree_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'Office AirFree Temperature', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.office_airfree_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '26', + }) +# --- +# name: test_all_entities[da_ac_rac_000003][sensor.office_airfree_volume-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.office_airfree_volume', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Volume', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'audio_volume', + 'unique_id': 'c76d6f38-1b7f-13dd-37b5-db18d5272783_main_audioVolume_volume_volume', + 'unit_of_measurement': '%', + }) +# --- +# name: test_all_entities[da_ac_rac_000003][sensor.office_airfree_volume-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Office AirFree Volume', + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.office_airfree_volume', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '100', + }) +# --- # name: test_all_entities[da_ac_rac_01001][sensor.aire_dormitorio_principal_energy-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ From 86622cd29d91f3825648bdc3ffe5e9f18898795c Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Mon, 31 Mar 2025 12:30:20 +0200 Subject: [PATCH 124/505] Remove unnecessary imports of http integration (#141899) * Remove unnecessary imports of http integration * Check reason for test failures * Revert "Check reason for test failures" This reverts commit 5ccf356ab029402ab87e00dc00eeb4798a0f6658. * Update tests --- homeassistant/components/plex/config_flow.py | 3 +-- homeassistant/helpers/config_entry_oauth2_flow.py | 2 +- homeassistant/helpers/network.py | 2 +- tests/components/http/test_auth.py | 3 +-- tests/components/http/test_ban.py | 2 +- tests/components/http/test_cors.py | 3 +-- tests/components/plex/test_config_flow.py | 2 +- tests/conftest.py | 2 +- tests/helpers/test_network.py | 14 +++++++------- tests/test_test_fixtures.py | 2 +- 10 files changed, 16 insertions(+), 19 deletions(-) diff --git a/homeassistant/components/plex/config_flow.py b/homeassistant/components/plex/config_flow.py index 3c9f35b20a4..48459a81860 100644 --- a/homeassistant/components/plex/config_flow.py +++ b/homeassistant/components/plex/config_flow.py @@ -14,7 +14,6 @@ from plexauth import PlexAuth import requests.exceptions import voluptuous as vol -from homeassistant.components import http from homeassistant.components.http import KEY_HASS, HomeAssistantView from homeassistant.components.media_player import DOMAIN as MP_DOMAIN from homeassistant.config_entries import ( @@ -36,7 +35,7 @@ from homeassistant.const import ( CONF_VERIFY_SSL, ) from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers import config_validation as cv, discovery_flow +from homeassistant.helpers import config_validation as cv, discovery_flow, http from homeassistant.helpers.aiohttp_client import async_get_clientsession from .const import ( diff --git a/homeassistant/helpers/config_entry_oauth2_flow.py b/homeassistant/helpers/config_entry_oauth2_flow.py index 84728978ede..1cff90031c2 100644 --- a/homeassistant/helpers/config_entry_oauth2_flow.py +++ b/homeassistant/helpers/config_entry_oauth2_flow.py @@ -27,11 +27,11 @@ import voluptuous as vol from yarl import URL from homeassistant import config_entries -from homeassistant.components import http from homeassistant.core import HomeAssistant, callback from homeassistant.loader import async_get_application_credentials from homeassistant.util.hass_dict import HassKey +from . import http from .aiohttp_client import async_get_clientsession from .network import NoURLAvailableError diff --git a/homeassistant/helpers/network.py b/homeassistant/helpers/network.py index e39cc2de547..67c4448724e 100644 --- a/homeassistant/helpers/network.py +++ b/homeassistant/helpers/network.py @@ -10,12 +10,12 @@ from aiohttp import hdrs from hass_nabucasa import remote import yarl -from homeassistant.components import http from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.loader import bind_hass from homeassistant.util.network import is_ip_address, is_loopback, normalize_url +from . import http from .hassio import is_hassio TYPE_URL_INTERNAL = "internal_url" diff --git a/tests/components/http/test_auth.py b/tests/components/http/test_auth.py index e31e630807e..8bf2e66a286 100644 --- a/tests/components/http/test_auth.py +++ b/tests/components/http/test_auth.py @@ -18,7 +18,6 @@ from homeassistant.auth.models import User from homeassistant.auth.providers import trusted_networks from homeassistant.auth.providers.homeassistant import HassAuthProvider from homeassistant.components import websocket_api -from homeassistant.components.http import KEY_HASS from homeassistant.components.http.auth import ( CONTENT_USER_NAME, DATA_SIGN_SECRET, @@ -28,13 +27,13 @@ from homeassistant.components.http.auth import ( async_sign_path, async_user_not_allowed_do_auth, ) -from homeassistant.components.http.const import KEY_AUTHENTICATED from homeassistant.components.http.forwarded import async_setup_forwarded from homeassistant.components.http.request_context import ( current_request, setup_request_context, ) from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.http import KEY_AUTHENTICATED, KEY_HASS from homeassistant.setup import async_setup_component from . import HTTP_HEADER_HA_AUTH diff --git a/tests/components/http/test_ban.py b/tests/components/http/test_ban.py index 59011de0cfd..51d3e4ed992 100644 --- a/tests/components/http/test_ban.py +++ b/tests/components/http/test_ban.py @@ -11,7 +11,6 @@ from aiohttp.web_middlewares import middleware import pytest from homeassistant.components import http -from homeassistant.components.http import KEY_AUTHENTICATED, KEY_HASS from homeassistant.components.http.ban import ( IP_BANS_FILE, KEY_BAN_MANAGER, @@ -22,6 +21,7 @@ from homeassistant.components.http.ban import ( from homeassistant.components.http.view import request_handler_factory from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers.http import KEY_AUTHENTICATED, KEY_HASS from homeassistant.setup import async_setup_component from tests.common import async_get_persistent_notifications diff --git a/tests/components/http/test_cors.py b/tests/components/http/test_cors.py index c0256abb25d..b637220ac6d 100644 --- a/tests/components/http/test_cors.py +++ b/tests/components/http/test_cors.py @@ -18,9 +18,8 @@ from aiohttp.test_utils import TestClient import pytest from homeassistant.components.http.cors import setup_cors -from homeassistant.components.http.view import HomeAssistantView from homeassistant.core import HomeAssistant -from homeassistant.helpers.http import KEY_ALLOW_CONFIGURED_CORS +from homeassistant.helpers.http import KEY_ALLOW_CONFIGURED_CORS, HomeAssistantView from homeassistant.setup import async_setup_component from . import HTTP_HEADER_HA_AUTH diff --git a/tests/components/plex/test_config_flow.py b/tests/components/plex/test_config_flow.py index 42dcf449168..2644f0f21c6 100644 --- a/tests/components/plex/test_config_flow.py +++ b/tests/components/plex/test_config_flow.py @@ -856,7 +856,7 @@ async def test_client_header_issues(hass: HomeAssistant) -> None: patch("plexauth.PlexAuth.initiate_auth"), patch("plexauth.PlexAuth.token", return_value=None), patch( - "homeassistant.components.http.current_request.get", + "homeassistant.helpers.http.current_request.get", return_value=MockRequest(), ), pytest.raises( diff --git a/tests/conftest.py b/tests/conftest.py index 65e3518956e..dd3fd44f3ea 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -852,7 +852,7 @@ def hass_client_no_auth( @pytest.fixture def current_request() -> Generator[MagicMock]: """Mock current request.""" - with patch("homeassistant.components.http.current_request") as mock_request_context: + with patch("homeassistant.helpers.http.current_request") as mock_request_context: mocked_request = make_mocked_request( "GET", "/some/request", diff --git a/tests/helpers/test_network.py b/tests/helpers/test_network.py index 3064b215f2f..46d84ea768d 100644 --- a/tests/helpers/test_network.py +++ b/tests/helpers/test_network.py @@ -538,7 +538,7 @@ async def test_get_url(hass: HomeAssistant) -> None: "homeassistant.helpers.network._get_request_host", return_value="example.com", ), - patch("homeassistant.components.http.current_request"), + patch("homeassistant.helpers.http.current_request"), ): assert get_url(hass, require_current_request=True) == "https://example.com" assert ( @@ -554,7 +554,7 @@ async def test_get_url(hass: HomeAssistant) -> None: "homeassistant.helpers.network._get_request_host", return_value="example.local", ), - patch("homeassistant.components.http.current_request"), + patch("homeassistant.helpers.http.current_request"), ): assert get_url(hass, require_current_request=True) == "http://example.local" @@ -592,7 +592,7 @@ async def test_get_request_host_with_port(hass: HomeAssistant) -> None: with pytest.raises(NoURLAvailableError): _get_request_host() - with patch("homeassistant.components.http.current_request") as mock_request_context: + with patch("homeassistant.helpers.http.current_request") as mock_request_context: mock_request = Mock() mock_request.headers = CIMultiDictProxy( CIMultiDict({hdrs.HOST: "example.com:8123"}) @@ -609,7 +609,7 @@ async def test_get_request_host_without_port(hass: HomeAssistant) -> None: with pytest.raises(NoURLAvailableError): _get_request_host() - with patch("homeassistant.components.http.current_request") as mock_request_context: + with patch("homeassistant.helpers.http.current_request") as mock_request_context: mock_request = Mock() mock_request.headers = CIMultiDictProxy(CIMultiDict({hdrs.HOST: "example.com"})) mock_request.url = URL("http://example.com/test/request") @@ -624,7 +624,7 @@ async def test_get_request_ipv6_address(hass: HomeAssistant) -> None: with pytest.raises(NoURLAvailableError): _get_request_host() - with patch("homeassistant.components.http.current_request") as mock_request_context: + with patch("homeassistant.helpers.http.current_request") as mock_request_context: mock_request = Mock() mock_request.headers = CIMultiDictProxy(CIMultiDict({hdrs.HOST: "[::1]:8123"})) mock_request.url = URL("http://[::1]:8123/test/request") @@ -639,7 +639,7 @@ async def test_get_request_ipv6_address_without_port(hass: HomeAssistant) -> Non with pytest.raises(NoURLAvailableError): _get_request_host() - with patch("homeassistant.components.http.current_request") as mock_request_context: + with patch("homeassistant.helpers.http.current_request") as mock_request_context: mock_request = Mock() mock_request.headers = CIMultiDictProxy(CIMultiDict({hdrs.HOST: "[::1]"})) mock_request.url = URL("http://[::1]/test/request") @@ -654,7 +654,7 @@ async def test_get_request_host_no_host_header(hass: HomeAssistant) -> None: with pytest.raises(NoURLAvailableError): _get_request_host() - with patch("homeassistant.components.http.current_request") as mock_request_context: + with patch("homeassistant.helpers.http.current_request") as mock_request_context: mock_request = Mock() mock_request.headers = CIMultiDictProxy(CIMultiDict()) mock_request.url = URL("/test/request") diff --git a/tests/test_test_fixtures.py b/tests/test_test_fixtures.py index 0b8fd20a7c0..0bada601a3b 100644 --- a/tests/test_test_fixtures.py +++ b/tests/test_test_fixtures.py @@ -9,9 +9,9 @@ from aiohttp import web import pytest import pytest_socket -from homeassistant.components.http import HomeAssistantView from homeassistant.core import HomeAssistant, async_get_hass from homeassistant.helpers import translation +from homeassistant.helpers.http import HomeAssistantView from homeassistant.setup import async_setup_component from .common import MockModule, mock_integration From 46a8325556dde5f14224f06bdf09ea7e0fd2ef6c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ab=C3=ADlio=20Costa?= Date: Mon, 31 Mar 2025 11:32:30 +0100 Subject: [PATCH 125/505] Simplify Energy cost sensor update method (#138961) --- homeassistant/components/energy/sensor.py | 112 +++++++++++++--------- 1 file changed, 67 insertions(+), 45 deletions(-) diff --git a/homeassistant/components/energy/sensor.py b/homeassistant/components/energy/sensor.py index eec92c32f98..062601eb4c5 100644 --- a/homeassistant/components/energy/sensor.py +++ b/homeassistant/components/energy/sensor.py @@ -25,6 +25,7 @@ from homeassistant.core import ( split_entity_id, valid_entity_id, ) +from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import entity_registry as er from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.event import async_track_state_change_event @@ -122,6 +123,10 @@ SOURCE_ADAPTERS: Final = ( ) +class EntityNotFoundError(HomeAssistantError): + """When a referenced entity was not found.""" + + class SensorManager: """Class to handle creation/removal of sensor data.""" @@ -311,43 +316,25 @@ class EnergyCostSensor(SensorEntity): except ValueError: return - # Determine energy price - if self._config["entity_energy_price"] is not None: - energy_price_state = self.hass.states.get( - self._config["entity_energy_price"] + try: + energy_price, energy_price_unit = self._get_energy_price( + valid_units, default_price_unit ) - - if energy_price_state is None: - return - - try: - energy_price = float(energy_price_state.state) - except ValueError: - if self._last_energy_sensor_state is None: - # Initialize as it's the first time all required entities except - # price are in place. This means that the cost will update the first - # time the energy is updated after the price entity is in place. - self._reset(energy_state) - return - - energy_price_unit: str | None = energy_price_state.attributes.get( - ATTR_UNIT_OF_MEASUREMENT, "" - ).partition("/")[2] - - # For backwards compatibility we don't validate the unit of the price - # If it is not valid, we assume it's our default price unit. - if energy_price_unit not in valid_units: - energy_price_unit = default_price_unit - - else: - energy_price = cast(float, self._config["number_energy_price"]) - energy_price_unit = default_price_unit + except EntityNotFoundError: + return + except ValueError: + energy_price = None if self._last_energy_sensor_state is None: - # Initialize as it's the first time all required entities are in place. + # Initialize as it's the first time all required entities are in place or + # only the price is missing. In the later case, cost will update the first + # time the energy is updated after the price entity is in place. self._reset(energy_state) return + if energy_price is None: + return + energy_unit: str | None = energy_state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) if energy_unit is None or energy_unit not in valid_units: @@ -383,20 +370,9 @@ class EnergyCostSensor(SensorEntity): old_energy_value = float(self._last_energy_sensor_state.state) cur_value = cast(float, self._attr_native_value) - if energy_price_unit is None: - converted_energy_price = energy_price - else: - converter: Callable[[float, str, str], float] - if energy_unit in VALID_ENERGY_UNITS: - converter = unit_conversion.EnergyConverter.convert - else: - converter = unit_conversion.VolumeConverter.convert - - converted_energy_price = converter( - energy_price, - energy_unit, - energy_price_unit, - ) + converted_energy_price = self._convert_energy_price( + energy_price, energy_price_unit, energy_unit + ) self._attr_native_value = ( cur_value + (energy - old_energy_value) * converted_energy_price @@ -404,6 +380,52 @@ class EnergyCostSensor(SensorEntity): self._last_energy_sensor_state = energy_state + def _get_energy_price( + self, valid_units: set[str], default_unit: str | None + ) -> tuple[float, str | None]: + """Get the energy price. + + Raises: + EntityNotFoundError: When the energy price entity is not found. + ValueError: When the entity state is not a valid float. + + """ + + if self._config["entity_energy_price"] is None: + return cast(float, self._config["number_energy_price"]), default_unit + + energy_price_state = self.hass.states.get(self._config["entity_energy_price"]) + if energy_price_state is None: + raise EntityNotFoundError + + energy_price = float(energy_price_state.state) + + energy_price_unit: str | None = energy_price_state.attributes.get( + ATTR_UNIT_OF_MEASUREMENT, "" + ).partition("/")[2] + + # For backwards compatibility we don't validate the unit of the price + # If it is not valid, we assume it's our default price unit. + if energy_price_unit not in valid_units: + energy_price_unit = default_unit + + return energy_price, energy_price_unit + + def _convert_energy_price( + self, energy_price: float, energy_price_unit: str | None, energy_unit: str + ) -> float: + """Convert the energy price to the correct unit.""" + if energy_price_unit is None: + return energy_price + + converter: Callable[[float, str, str], float] + if energy_unit in VALID_ENERGY_UNITS: + converter = unit_conversion.EnergyConverter.convert + else: + converter = unit_conversion.VolumeConverter.convert + + return converter(energy_price, energy_unit, energy_price_unit) + async def async_added_to_hass(self) -> None: """Register callbacks.""" energy_state = self.hass.states.get(self._config[self._adapter.stat_energy_key]) From 314834b4eb4e216897469fdc7b6527dee1a63489 Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Mon, 31 Mar 2025 12:36:31 +0200 Subject: [PATCH 126/505] Use more common state strings in `lektrico` (#141906) --- homeassistant/components/lektrico/strings.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/lektrico/strings.json b/homeassistant/components/lektrico/strings.json index eb0203e0661..eb223b4758b 100644 --- a/homeassistant/components/lektrico/strings.json +++ b/homeassistant/components/lektrico/strings.json @@ -87,11 +87,11 @@ "state": { "available": "Available", "charging": "[%key:common::state::charging%]", - "connected": "Connected", + "connected": "[%key:common::state::connected%]", "error": "Error", - "locked": "Locked", + "locked": "[%key:common::state::locked%]", "need_auth": "Waiting for authentication", - "paused": "Paused", + "paused": "[%key:common::state::paused%]", "paused_by_scheduler": "Paused by scheduler", "updating_firmware": "Updating firmware" } From fba11d8016ff36698495de834964ed64eab8c727 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Mon, 31 Mar 2025 12:36:46 +0200 Subject: [PATCH 127/505] Don't create SmartThings entities for disabled components (#141909) --- .../components/smartthings/__init__.py | 29 +- tests/components/smartthings/conftest.py | 1 + .../device_status/da_ref_normal_01011.json | 933 ++++++++++++++++++ .../fixtures/devices/da_ref_normal_01011.json | 521 ++++++++++ .../snapshots/test_binary_sensor.ambr | 144 +++ .../smartthings/snapshots/test_init.ambr | 33 + .../smartthings/snapshots/test_sensor.ambr | 277 ++++++ 7 files changed, 1933 insertions(+), 5 deletions(-) create mode 100644 tests/components/smartthings/fixtures/device_status/da_ref_normal_01011.json create mode 100644 tests/components/smartthings/fixtures/devices/da_ref_normal_01011.json diff --git a/homeassistant/components/smartthings/__init__.py b/homeassistant/components/smartthings/__init__.py index 346d5e66b42..c8ca1a819e0 100644 --- a/homeassistant/components/smartthings/__init__.py +++ b/homeassistant/components/smartthings/__init__.py @@ -478,7 +478,27 @@ def process_status(status: dict[str, ComponentStatus]) -> dict[str, ComponentSta if (main_component := status.get(MAIN)) is None: return status if ( - disabled_capabilities_capability := main_component.get( + disabled_components_capability := main_component.get( + Capability.CUSTOM_DISABLED_COMPONENTS + ) + ) is not None: + disabled_components = cast( + list[str], + disabled_components_capability[Attribute.DISABLED_COMPONENTS].value, + ) + if disabled_components is not None: + for component in disabled_components: + if component in status: + del status[component] + for component_status in status.values(): + process_component_status(component_status) + return status + + +def process_component_status(status: ComponentStatus) -> None: + """Remove disabled capabilities from component status.""" + if ( + disabled_capabilities_capability := status.get( Capability.CUSTOM_DISABLED_CAPABILITIES ) ) is not None: @@ -488,9 +508,8 @@ def process_status(status: dict[str, ComponentStatus]) -> dict[str, ComponentSta ) if disabled_capabilities is not None: for capability in disabled_capabilities: - if capability in main_component and ( + if capability in status and ( capability not in KEEP_CAPABILITY_QUIRK - or not KEEP_CAPABILITY_QUIRK[capability](main_component[capability]) + or not KEEP_CAPABILITY_QUIRK[capability](status[capability]) ): - del main_component[capability] - return status + del status[capability] diff --git a/tests/components/smartthings/conftest.py b/tests/components/smartthings/conftest.py index 5744adfef07..277c327744f 100644 --- a/tests/components/smartthings/conftest.py +++ b/tests/components/smartthings/conftest.py @@ -106,6 +106,7 @@ def mock_smartthings() -> Generator[AsyncMock]: "ge_in_wall_smart_dimmer", "centralite", "da_ref_normal_000001", + "da_ref_normal_01011", "vd_network_audio_002s", "vd_sensor_light_2023", "iphone", diff --git a/tests/components/smartthings/fixtures/device_status/da_ref_normal_01011.json b/tests/components/smartthings/fixtures/device_status/da_ref_normal_01011.json new file mode 100644 index 00000000000..350a0ee14bb --- /dev/null +++ b/tests/components/smartthings/fixtures/device_status/da_ref_normal_01011.json @@ -0,0 +1,933 @@ +{ + "components": { + "pantry-01": { + "samsungce.foodDefrost": { + "supportedOptions": { + "value": null + }, + "foodType": { + "value": null + }, + "weight": { + "value": null + }, + "operationTime": { + "value": null + }, + "remainingTime": { + "value": null + } + }, + "samsungce.fridgePantryInfo": { + "name": { + "value": null + } + }, + "custom.disabledCapabilities": { + "disabledCapabilities": { + "value": null + } + }, + "samsungce.meatAging": { + "zoneInfo": { + "value": null + }, + "supportedMeatTypes": { + "value": null + }, + "supportedAgingMethods": { + "value": null + }, + "status": { + "value": null + } + }, + "samsungce.fridgePantryMode": { + "mode": { + "value": null + }, + "supportedModes": { + "value": null + } + } + }, + "pantry-02": { + "samsungce.foodDefrost": { + "supportedOptions": { + "value": null + }, + "foodType": { + "value": null + }, + "weight": { + "value": null + }, + "operationTime": { + "value": null + }, + "remainingTime": { + "value": null + } + }, + "samsungce.fridgePantryInfo": { + "name": { + "value": null + } + }, + "custom.disabledCapabilities": { + "disabledCapabilities": { + "value": null + } + }, + "samsungce.meatAging": { + "zoneInfo": { + "value": null + }, + "supportedMeatTypes": { + "value": null + }, + "supportedAgingMethods": { + "value": null + }, + "status": { + "value": null + } + }, + "samsungce.fridgePantryMode": { + "mode": { + "value": null + }, + "supportedModes": { + "value": null + } + } + }, + "icemaker": { + "custom.disabledCapabilities": { + "disabledCapabilities": { + "value": null + } + }, + "switch": { + "switch": { + "value": null + } + } + }, + "onedoor": { + "custom.fridgeMode": { + "fridgeModeValue": { + "value": null + }, + "fridgeMode": { + "value": null + }, + "supportedFridgeModes": { + "value": null + } + }, + "contactSensor": { + "contact": { + "value": null + } + }, + "samsungce.unavailableCapabilities": { + "unavailableCommands": { + "value": [], + "timestamp": "2024-12-01T18:22:19.337Z" + } + }, + "custom.disabledCapabilities": { + "disabledCapabilities": { + "value": ["samsungce.freezerConvertMode", "custom.fridgeMode"], + "timestamp": "2024-12-01T18:22:20.155Z" + } + }, + "samsungce.temperatureSetting": { + "supportedDesiredTemperatures": { + "value": null + }, + "desiredTemperature": { + "value": null + } + }, + "temperatureMeasurement": { + "temperatureRange": { + "value": null + }, + "temperature": { + "value": null + } + }, + "custom.thermostatSetpointControl": { + "minimumSetpoint": { + "value": null + }, + "maximumSetpoint": { + "value": null + } + }, + "samsungce.freezerConvertMode": { + "supportedFreezerConvertModes": { + "value": null + }, + "freezerConvertMode": { + "value": null + } + }, + "thermostatCoolingSetpoint": { + "coolingSetpointRange": { + "value": null + }, + "coolingSetpoint": { + "value": null + } + } + }, + "scale-10": { + "samsungce.connectionState": { + "connectionState": { + "value": null + } + }, + "custom.disabledCapabilities": { + "disabledCapabilities": { + "value": null + } + }, + "samsungce.weightMeasurement": { + "weight": { + "value": null + } + }, + "samsungce.weightMeasurementCalibration": {} + }, + "scale-11": { + "custom.disabledCapabilities": { + "disabledCapabilities": { + "value": null + } + }, + "samsungce.weightMeasurement": { + "weight": { + "value": null + } + } + }, + "cooler": { + "custom.fridgeMode": { + "fridgeModeValue": { + "value": null + }, + "fridgeMode": { + "value": null + }, + "supportedFridgeModes": { + "value": null + } + }, + "contactSensor": { + "contact": { + "value": "closed", + "timestamp": "2025-03-30T18:36:45.151Z" + } + }, + "samsungce.unavailableCapabilities": { + "unavailableCommands": { + "value": [], + "timestamp": "2024-12-01T18:22:19.337Z" + } + }, + "custom.disabledCapabilities": { + "disabledCapabilities": { + "value": ["custom.fridgeMode", "samsungce.temperatureSetting"], + "timestamp": "2024-12-01T18:22:22.081Z" + } + }, + "samsungce.temperatureSetting": { + "supportedDesiredTemperatures": { + "value": null + }, + "desiredTemperature": { + "value": null + } + }, + "temperatureMeasurement": { + "temperatureRange": { + "value": null + }, + "temperature": { + "value": 6, + "unit": "C", + "timestamp": "2025-03-30T17:41:42.863Z" + } + }, + "custom.thermostatSetpointControl": { + "minimumSetpoint": { + "value": 1, + "unit": "C", + "timestamp": "2024-12-01T18:22:19.337Z" + }, + "maximumSetpoint": { + "value": 7, + "unit": "C", + "timestamp": "2024-12-01T18:22:19.337Z" + } + }, + "thermostatCoolingSetpoint": { + "coolingSetpointRange": { + "value": { + "minimum": 1, + "maximum": 7, + "step": 1 + }, + "unit": "C", + "timestamp": "2024-12-01T18:22:19.337Z" + }, + "coolingSetpoint": { + "value": 6, + "unit": "C", + "timestamp": "2025-03-30T17:33:48.530Z" + } + } + }, + "freezer": { + "custom.fridgeMode": { + "fridgeModeValue": { + "value": null + }, + "fridgeMode": { + "value": null + }, + "supportedFridgeModes": { + "value": null + } + }, + "contactSensor": { + "contact": { + "value": "closed", + "timestamp": "2024-12-01T18:22:19.331Z" + } + }, + "samsungce.unavailableCapabilities": { + "unavailableCommands": { + "value": [], + "timestamp": "2024-12-01T18:22:19.337Z" + } + }, + "custom.disabledCapabilities": { + "disabledCapabilities": { + "value": [ + "custom.fridgeMode", + "samsungce.temperatureSetting", + "samsungce.freezerConvertMode" + ], + "timestamp": "2024-12-01T18:22:22.081Z" + } + }, + "samsungce.temperatureSetting": { + "supportedDesiredTemperatures": { + "value": null + }, + "desiredTemperature": { + "value": null + } + }, + "temperatureMeasurement": { + "temperatureRange": { + "value": null + }, + "temperature": { + "value": -17, + "unit": "C", + "timestamp": "2025-03-30T17:35:48.599Z" + } + }, + "custom.thermostatSetpointControl": { + "minimumSetpoint": { + "value": -23, + "unit": "C", + "timestamp": "2024-12-01T18:22:19.337Z" + }, + "maximumSetpoint": { + "value": -15, + "unit": "C", + "timestamp": "2024-12-01T18:22:19.337Z" + } + }, + "samsungce.freezerConvertMode": { + "supportedFreezerConvertModes": { + "value": null + }, + "freezerConvertMode": { + "value": null + } + }, + "thermostatCoolingSetpoint": { + "coolingSetpointRange": { + "value": { + "minimum": -23, + "maximum": -15, + "step": 1 + }, + "unit": "C", + "timestamp": "2024-12-01T18:22:19.337Z" + }, + "coolingSetpoint": { + "value": -17, + "unit": "C", + "timestamp": "2025-03-30T17:32:34.710Z" + } + } + }, + "main": { + "contactSensor": { + "contact": { + "value": "closed", + "timestamp": "2025-03-30T18:36:45.151Z" + } + }, + "samsungce.fridgeWelcomeLighting": { + "detectionProximity": { + "value": null + }, + "supportedDetectionProximities": { + "value": null + }, + "status": { + "value": null + } + }, + "samsungce.viewInside": { + "supportedFocusAreas": { + "value": null + }, + "contents": { + "value": null + }, + "lastUpdatedTime": { + "value": null + } + }, + "samsungce.deviceIdentification": { + "micomAssayCode": { + "value": null + }, + "modelName": { + "value": null + }, + "serialNumber": { + "value": null + }, + "serialNumberExtra": { + "value": null + }, + "modelClassificationCode": { + "value": null + }, + "description": { + "value": null + }, + "releaseYear": { + "value": null + }, + "binaryId": { + "value": "TP1X_REF_21K", + "timestamp": "2025-03-23T21:53:15.900Z" + } + }, + "samsungce.quickControl": { + "version": { + "value": "1.0", + "timestamp": "2025-02-12T21:52:01.494Z" + } + }, + "custom.fridgeMode": { + "fridgeModeValue": { + "value": null + }, + "fridgeMode": { + "value": null + }, + "supportedFridgeModes": { + "value": null + } + }, + "ocf": { + "st": { + "value": null + }, + "mndt": { + "value": null + }, + "mnfv": { + "value": "A-RFWW-TP1-22-REV1_20241030", + "timestamp": "2025-02-12T21:51:58.927Z" + }, + "mnhw": { + "value": "Realtek", + "timestamp": "2025-02-12T21:51:58.927Z" + }, + "di": { + "value": "5758b2ec-563e-f39b-ec39-208e54aabf60", + "timestamp": "2025-02-12T21:51:58.927Z" + }, + "mnsl": { + "value": "http://www.samsung.com", + "timestamp": "2025-02-12T21:51:58.927Z" + }, + "dmv": { + "value": "1.2.1", + "timestamp": "2025-02-12T21:51:58.927Z" + }, + "n": { + "value": "Samsung-Refrigerator", + "timestamp": "2025-02-12T21:51:58.927Z" + }, + "mnmo": { + "value": "TP1X_REF_21K|00156941|00050126001611304100000030010000", + "timestamp": "2025-02-12T21:51:58.927Z" + }, + "vid": { + "value": "DA-REF-NORMAL-01011", + "timestamp": "2025-02-12T21:51:58.927Z" + }, + "mnmn": { + "value": "Samsung Electronics", + "timestamp": "2025-02-12T21:51:58.927Z" + }, + "mnml": { + "value": "http://www.samsung.com", + "timestamp": "2025-02-12T21:51:58.927Z" + }, + "mnpv": { + "value": "DAWIT 2.0", + "timestamp": "2025-02-12T21:51:58.927Z" + }, + "mnos": { + "value": "TizenRT 3.1", + "timestamp": "2025-02-12T21:51:58.927Z" + }, + "pi": { + "value": "5758b2ec-563e-f39b-ec39-208e54aabf60", + "timestamp": "2025-02-12T21:51:58.927Z" + }, + "icv": { + "value": "core.1.1.0", + "timestamp": "2025-02-12T21:51:58.927Z" + } + }, + "samsungce.fridgeVacationMode": { + "vacationMode": { + "value": "off", + "timestamp": "2024-12-01T18:22:19.337Z" + } + }, + "custom.disabledCapabilities": { + "disabledCapabilities": { + "value": [ + "temperatureMeasurement", + "thermostatCoolingSetpoint", + "custom.fridgeMode", + "custom.deodorFilter", + "custom.waterFilter", + "custom.dustFilter", + "samsungce.viewInside", + "samsungce.fridgeWelcomeLighting", + "samsungce.sabbathMode" + ], + "timestamp": "2025-02-12T21:52:01.494Z" + } + }, + "samsungce.driverVersion": { + "versionNumber": { + "value": 24090102, + "timestamp": "2024-12-01T18:22:19.337Z" + } + }, + "sec.diagnosticsInformation": { + "logType": { + "value": ["errCode", "dump"], + "timestamp": "2024-12-01T18:22:19.337Z" + }, + "endpoint": { + "value": "SSM", + "timestamp": "2024-12-01T18:22:19.337Z" + }, + "minVersion": { + "value": "3.0", + "timestamp": "2025-02-12T21:52:00.460Z" + }, + "signinPermission": { + "value": null + }, + "setupId": { + "value": "RB0", + "timestamp": "2024-12-01T18:22:19.337Z" + }, + "protocolType": { + "value": "ble_ocf", + "timestamp": "2025-02-12T21:52:00.460Z" + }, + "tsId": { + "value": "DA01", + "timestamp": "2025-02-12T21:52:00.460Z" + }, + "mnId": { + "value": "0AJT", + "timestamp": "2024-12-01T18:22:19.337Z" + }, + "dumpType": { + "value": "file", + "timestamp": "2024-12-01T18:22:19.337Z" + } + }, + "temperatureMeasurement": { + "temperatureRange": { + "value": null + }, + "temperature": { + "value": null + } + }, + "custom.deviceReportStateConfiguration": { + "reportStateRealtimePeriod": { + "value": null + }, + "reportStateRealtime": { + "value": { + "state": "disabled" + }, + "timestamp": "2024-12-01T18:22:19.337Z" + }, + "reportStatePeriod": { + "value": "enabled", + "timestamp": "2024-12-01T18:22:19.337Z" + } + }, + "thermostatCoolingSetpoint": { + "coolingSetpointRange": { + "value": null + }, + "coolingSetpoint": { + "value": null + } + }, + "custom.disabledComponents": { + "disabledComponents": { + "value": [ + "icemaker", + "icemaker-02", + "icemaker-03", + "pantry-01", + "pantry-02", + "scale-10", + "scale-11", + "cvroom", + "onedoor" + ], + "timestamp": "2024-12-01T18:22:19.337Z" + } + }, + "demandResponseLoadControl": { + "drlcStatus": { + "value": { + "drlcType": 1, + "drlcLevel": 0, + "duration": 0, + "override": false + }, + "timestamp": "2024-12-01T18:22:19.337Z" + } + }, + "samsungce.sabbathMode": { + "supportedActions": { + "value": null + }, + "status": { + "value": null + } + }, + "powerConsumptionReport": { + "powerConsumption": { + "value": { + "energy": 66571, + "deltaEnergy": 19, + "power": 61, + "powerEnergy": 18.91178222020467, + "persistedEnergy": 0, + "energySaved": 0, + "persistedSavedEnergy": 0, + "start": "2025-03-30T18:21:37Z", + "end": "2025-03-30T18:38:18Z" + }, + "timestamp": "2025-03-30T18:38:18.219Z" + } + }, + "refresh": {}, + "execute": { + "data": { + "value": null + } + }, + "sec.wifiConfiguration": { + "autoReconnection": { + "value": true, + "timestamp": "2024-12-01T18:22:19.337Z" + }, + "minVersion": { + "value": "1.0", + "timestamp": "2024-12-01T18:22:19.337Z" + }, + "supportedWiFiFreq": { + "value": ["2.4G"], + "timestamp": "2024-12-01T18:22:19.331Z" + }, + "supportedAuthType": { + "value": ["OPEN", "WEP", "WPA-PSK", "WPA2-PSK", "SAE"], + "timestamp": "2024-12-01T18:22:19.331Z" + }, + "protocolType": { + "value": ["helper_hotspot"], + "timestamp": "2024-12-01T18:22:19.331Z" + } + }, + "samsungce.selfCheck": { + "result": { + "value": "passed", + "timestamp": "2024-12-01T18:22:19.337Z" + }, + "supportedActions": { + "value": ["start"], + "timestamp": "2024-12-01T18:22:19.337Z" + }, + "progress": { + "value": null + }, + "errors": { + "value": [], + "timestamp": "2024-12-01T18:22:19.337Z" + }, + "status": { + "value": "ready", + "timestamp": "2024-12-01T18:22:19.337Z" + } + }, + "custom.dustFilter": { + "dustFilterUsageStep": { + "value": null + }, + "dustFilterUsage": { + "value": null + }, + "dustFilterLastResetDate": { + "value": null + }, + "dustFilterStatus": { + "value": null + }, + "dustFilterCapacity": { + "value": null + }, + "dustFilterResetType": { + "value": null + } + }, + "refrigeration": { + "defrost": { + "value": null + }, + "rapidCooling": { + "value": "off", + "timestamp": "2024-12-01T18:22:19.337Z" + }, + "rapidFreezing": { + "value": "off", + "timestamp": "2024-12-01T18:22:19.337Z" + } + }, + "custom.deodorFilter": { + "deodorFilterCapacity": { + "value": null + }, + "deodorFilterLastResetDate": { + "value": null + }, + "deodorFilterStatus": { + "value": null + }, + "deodorFilterResetType": { + "value": null + }, + "deodorFilterUsage": { + "value": null + }, + "deodorFilterUsageStep": { + "value": null + } + }, + "samsungce.powerCool": { + "activated": { + "value": false, + "timestamp": "2024-12-01T18:22:19.337Z" + } + }, + "custom.energyType": { + "energyType": { + "value": "2.0", + "timestamp": "2024-12-01T18:22:19.337Z" + }, + "energySavingSupport": { + "value": true, + "timestamp": "2025-03-06T23:10:37.429Z" + }, + "drMaxDuration": { + "value": 99999999, + "unit": "min", + "timestamp": "2024-12-01T18:22:20.756Z" + }, + "energySavingLevel": { + "value": 1, + "timestamp": "2024-12-01T18:22:19.337Z" + }, + "energySavingInfo": { + "value": null + }, + "supportedEnergySavingLevels": { + "value": [1, 2], + "timestamp": "2024-12-01T18:22:19.337Z" + }, + "energySavingOperation": { + "value": false, + "timestamp": "2024-12-01T18:22:19.337Z" + }, + "notificationTemplateID": { + "value": null + }, + "energySavingOperationSupport": { + "value": true, + "timestamp": "2024-12-01T18:22:19.337Z" + } + }, + "samsungce.softwareUpdate": { + "targetModule": { + "value": {}, + "timestamp": "2024-12-01T18:55:10.062Z" + }, + "otnDUID": { + "value": "MTCB2ZD4B6BT4", + "timestamp": "2024-12-01T18:22:19.337Z" + }, + "lastUpdatedDate": { + "value": null + }, + "availableModules": { + "value": [], + "timestamp": "2024-12-01T18:22:19.337Z" + }, + "newVersionAvailable": { + "value": false, + "timestamp": "2024-12-01T18:22:19.337Z" + }, + "operatingState": { + "value": "none", + "timestamp": "2024-12-01T18:28:40.492Z" + }, + "progress": { + "value": 0, + "unit": "%", + "timestamp": "2024-12-01T18:43:42.645Z" + } + }, + "samsungce.powerFreeze": { + "activated": { + "value": false, + "timestamp": "2024-12-01T18:22:19.337Z" + } + }, + "custom.waterFilter": { + "waterFilterUsageStep": { + "value": null + }, + "waterFilterResetType": { + "value": null + }, + "waterFilterCapacity": { + "value": null + }, + "waterFilterLastResetDate": { + "value": null + }, + "waterFilterUsage": { + "value": null + }, + "waterFilterStatus": { + "value": null + } + } + }, + "cvroom": { + "custom.fridgeMode": { + "fridgeModeValue": { + "value": null + }, + "fridgeMode": { + "value": null + }, + "supportedFridgeModes": { + "value": null + } + }, + "contactSensor": { + "contact": { + "value": null + } + }, + "custom.disabledCapabilities": { + "disabledCapabilities": { + "value": null + } + }, + "temperatureMeasurement": { + "temperatureRange": { + "value": null + }, + "temperature": { + "value": null + } + }, + "thermostatCoolingSetpoint": { + "coolingSetpointRange": { + "value": null + }, + "coolingSetpoint": { + "value": null + } + } + }, + "icemaker-02": { + "custom.disabledCapabilities": { + "disabledCapabilities": { + "value": null + } + }, + "switch": { + "switch": { + "value": null + } + } + }, + "icemaker-03": { + "custom.disabledCapabilities": { + "disabledCapabilities": { + "value": null + } + }, + "switch": { + "switch": { + "value": null + } + } + } + } +} diff --git a/tests/components/smartthings/fixtures/devices/da_ref_normal_01011.json b/tests/components/smartthings/fixtures/devices/da_ref_normal_01011.json new file mode 100644 index 00000000000..9be5db0bda9 --- /dev/null +++ b/tests/components/smartthings/fixtures/devices/da_ref_normal_01011.json @@ -0,0 +1,521 @@ +{ + "items": [ + { + "deviceId": "5758b2ec-563e-f39b-ec39-208e54aabf60", + "name": "Samsung-Refrigerator", + "label": "Frigo", + "manufacturerName": "Samsung Electronics", + "presentationId": "DA-REF-NORMAL-01011", + "deviceManufacturerCode": "Samsung Electronics", + "locationId": "d91ee683-be36-4124-9200-c0030253fbc2", + "ownerId": "60b5179d-607f-f754-a648-6e1e21aeeb31", + "roomId": "c4f98377-534d-422f-b061-a4f3e281ddf5", + "deviceTypeName": "Samsung OCF Refrigerator", + "components": [ + { + "id": "main", + "label": "main", + "capabilities": [ + { + "id": "contactSensor", + "version": 1 + }, + { + "id": "execute", + "version": 1 + }, + { + "id": "ocf", + "version": 1 + }, + { + "id": "powerConsumptionReport", + "version": 1 + }, + { + "id": "demandResponseLoadControl", + "version": 1 + }, + { + "id": "refresh", + "version": 1 + }, + { + "id": "refrigeration", + "version": 1 + }, + { + "id": "temperatureMeasurement", + "version": 1 + }, + { + "id": "thermostatCoolingSetpoint", + "version": 1 + }, + { + "id": "custom.deodorFilter", + "version": 1 + }, + { + "id": "custom.dustFilter", + "version": 1 + }, + { + "id": "custom.deviceReportStateConfiguration", + "version": 1 + }, + { + "id": "custom.energyType", + "version": 1 + }, + { + "id": "custom.fridgeMode", + "version": 1 + }, + { + "id": "custom.disabledCapabilities", + "version": 1 + }, + { + "id": "custom.disabledComponents", + "version": 1 + }, + { + "id": "custom.waterFilter", + "version": 1 + }, + { + "id": "samsungce.softwareUpdate", + "version": 1 + }, + { + "id": "samsungce.deviceIdentification", + "version": 1 + }, + { + "id": "samsungce.driverVersion", + "version": 1 + }, + { + "id": "samsungce.fridgeVacationMode", + "version": 1 + }, + { + "id": "samsungce.powerCool", + "version": 1 + }, + { + "id": "samsungce.powerFreeze", + "version": 1 + }, + { + "id": "samsungce.sabbathMode", + "version": 1 + }, + { + "id": "samsungce.selfCheck", + "version": 1 + }, + { + "id": "samsungce.viewInside", + "version": 1 + }, + { + "id": "samsungce.fridgeWelcomeLighting", + "version": 1 + }, + { + "id": "samsungce.quickControl", + "version": 1 + }, + { + "id": "sec.diagnosticsInformation", + "version": 1 + }, + { + "id": "sec.wifiConfiguration", + "version": 1 + } + ], + "categories": [ + { + "name": "Refrigerator", + "categoryType": "manufacturer" + } + ] + }, + { + "id": "freezer", + "label": "freezer", + "capabilities": [ + { + "id": "contactSensor", + "version": 1 + }, + { + "id": "temperatureMeasurement", + "version": 1 + }, + { + "id": "thermostatCoolingSetpoint", + "version": 1 + }, + { + "id": "custom.disabledCapabilities", + "version": 1 + }, + { + "id": "custom.fridgeMode", + "version": 1 + }, + { + "id": "custom.thermostatSetpointControl", + "version": 1 + }, + { + "id": "samsungce.temperatureSetting", + "version": 1 + }, + { + "id": "samsungce.freezerConvertMode", + "version": 1 + }, + { + "id": "samsungce.unavailableCapabilities", + "version": 1 + } + ], + "categories": [ + { + "name": "Other", + "categoryType": "manufacturer" + } + ] + }, + { + "id": "cooler", + "label": "cooler", + "capabilities": [ + { + "id": "contactSensor", + "version": 1 + }, + { + "id": "temperatureMeasurement", + "version": 1 + }, + { + "id": "thermostatCoolingSetpoint", + "version": 1 + }, + { + "id": "custom.disabledCapabilities", + "version": 1 + }, + { + "id": "custom.fridgeMode", + "version": 1 + }, + { + "id": "custom.thermostatSetpointControl", + "version": 1 + }, + { + "id": "samsungce.temperatureSetting", + "version": 1 + }, + { + "id": "samsungce.unavailableCapabilities", + "version": 1 + } + ], + "categories": [ + { + "name": "Other", + "categoryType": "manufacturer" + } + ] + }, + { + "id": "cvroom", + "label": "cvroom", + "capabilities": [ + { + "id": "contactSensor", + "version": 1 + }, + { + "id": "temperatureMeasurement", + "version": 1 + }, + { + "id": "thermostatCoolingSetpoint", + "version": 1 + }, + { + "id": "custom.disabledCapabilities", + "version": 1 + }, + { + "id": "custom.fridgeMode", + "version": 1 + } + ], + "categories": [ + { + "name": "Other", + "categoryType": "manufacturer" + } + ] + }, + { + "id": "onedoor", + "label": "onedoor", + "capabilities": [ + { + "id": "contactSensor", + "version": 1 + }, + { + "id": "temperatureMeasurement", + "version": 1 + }, + { + "id": "thermostatCoolingSetpoint", + "version": 1 + }, + { + "id": "custom.disabledCapabilities", + "version": 1 + }, + { + "id": "custom.fridgeMode", + "version": 1 + }, + { + "id": "custom.thermostatSetpointControl", + "version": 1 + }, + { + "id": "samsungce.temperatureSetting", + "version": 1 + }, + { + "id": "samsungce.freezerConvertMode", + "version": 1 + }, + { + "id": "samsungce.unavailableCapabilities", + "version": 1 + } + ], + "categories": [ + { + "name": "Other", + "categoryType": "manufacturer" + } + ] + }, + { + "id": "icemaker", + "label": "icemaker", + "capabilities": [ + { + "id": "switch", + "version": 1 + }, + { + "id": "custom.disabledCapabilities", + "version": 1 + } + ], + "categories": [ + { + "name": "Other", + "categoryType": "manufacturer" + } + ] + }, + { + "id": "icemaker-02", + "label": "icemaker-02", + "capabilities": [ + { + "id": "switch", + "version": 1 + }, + { + "id": "custom.disabledCapabilities", + "version": 1 + } + ], + "categories": [ + { + "name": "Other", + "categoryType": "manufacturer" + } + ] + }, + { + "id": "icemaker-03", + "label": "icemaker-03", + "capabilities": [ + { + "id": "switch", + "version": 1 + }, + { + "id": "custom.disabledCapabilities", + "version": 1 + } + ], + "categories": [ + { + "name": "Other", + "categoryType": "manufacturer" + } + ] + }, + { + "id": "scale-10", + "label": "scale-10", + "capabilities": [ + { + "id": "samsungce.weightMeasurement", + "version": 1 + }, + { + "id": "samsungce.weightMeasurementCalibration", + "version": 1 + }, + { + "id": "samsungce.connectionState", + "version": 1 + }, + { + "id": "custom.disabledCapabilities", + "version": 1 + } + ], + "categories": [ + { + "name": "Other", + "categoryType": "manufacturer" + } + ] + }, + { + "id": "scale-11", + "label": "scale-11", + "capabilities": [ + { + "id": "samsungce.weightMeasurement", + "version": 1 + }, + { + "id": "custom.disabledCapabilities", + "version": 1 + } + ], + "categories": [ + { + "name": "Other", + "categoryType": "manufacturer" + } + ] + }, + { + "id": "pantry-01", + "label": "pantry-01", + "capabilities": [ + { + "id": "samsungce.fridgePantryInfo", + "version": 1 + }, + { + "id": "samsungce.fridgePantryMode", + "version": 1 + }, + { + "id": "samsungce.meatAging", + "version": 1 + }, + { + "id": "samsungce.foodDefrost", + "version": 1 + }, + { + "id": "custom.disabledCapabilities", + "version": 1 + } + ], + "categories": [ + { + "name": "Other", + "categoryType": "manufacturer" + } + ] + }, + { + "id": "pantry-02", + "label": "pantry-02", + "capabilities": [ + { + "id": "samsungce.fridgePantryInfo", + "version": 1 + }, + { + "id": "samsungce.fridgePantryMode", + "version": 1 + }, + { + "id": "samsungce.meatAging", + "version": 1 + }, + { + "id": "samsungce.foodDefrost", + "version": 1 + }, + { + "id": "custom.disabledCapabilities", + "version": 1 + } + ], + "categories": [ + { + "name": "Other", + "categoryType": "manufacturer" + } + ] + } + ], + "createTime": "2024-12-01T18:22:14.880Z", + "profile": { + "id": "37c7b355-bdaa-371b-b246-dbdf2a7f9c84" + }, + "ocf": { + "ocfDeviceType": "oic.d.refrigerator", + "name": "Samsung-Refrigerator", + "specVersion": "core.1.1.0", + "verticalDomainSpecVersion": "1.2.1", + "manufacturerName": "Samsung Electronics", + "modelNumber": "TP1X_REF_21K|00156941|00050126001611304100000030010000", + "platformVersion": "DAWIT 2.0", + "platformOS": "TizenRT 3.1", + "hwVersion": "Realtek", + "firmwareVersion": "A-RFWW-TP1-22-REV1_20241030", + "vendorId": "DA-REF-NORMAL-01011", + "vendorResourceClientServerVersion": "Realtek Release 3.1.240221", + "lastSignupTime": "2024-12-01T18:22:14.807976528Z", + "transferCandidate": false, + "additionalAuthCodeRequired": false + }, + "type": "OCF", + "restrictionTier": 0, + "allowed": null, + "executionContext": "CLOUD", + "relationships": [] + } + ], + "_links": {} +} diff --git a/tests/components/smartthings/snapshots/test_binary_sensor.ambr b/tests/components/smartthings/snapshots/test_binary_sensor.ambr index d6a5ac6a4e7..d41c36aea64 100644 --- a/tests/components/smartthings/snapshots/test_binary_sensor.ambr +++ b/tests/components/smartthings/snapshots/test_binary_sensor.ambr @@ -809,6 +809,150 @@ 'state': 'off', }) # --- +# name: test_all_entities[da_ref_normal_01011][binary_sensor.frigo_cooler_door-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.frigo_cooler_door', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Cooler door', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'cooler_door', + 'unique_id': '5758b2ec-563e-f39b-ec39-208e54aabf60_cooler_contactSensor_contact_contact', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[da_ref_normal_01011][binary_sensor.frigo_cooler_door-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'door', + 'friendly_name': 'Frigo Cooler door', + }), + 'context': , + 'entity_id': 'binary_sensor.frigo_cooler_door', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_all_entities[da_ref_normal_01011][binary_sensor.frigo_door-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.frigo_door', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Door', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '5758b2ec-563e-f39b-ec39-208e54aabf60_main_contactSensor_contact_contact', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[da_ref_normal_01011][binary_sensor.frigo_door-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'door', + 'friendly_name': 'Frigo Door', + }), + 'context': , + 'entity_id': 'binary_sensor.frigo_door', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_all_entities[da_ref_normal_01011][binary_sensor.frigo_freezer_door-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.frigo_freezer_door', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Freezer door', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'freezer_door', + 'unique_id': '5758b2ec-563e-f39b-ec39-208e54aabf60_freezer_contactSensor_contact_contact', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[da_ref_normal_01011][binary_sensor.frigo_freezer_door-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'door', + 'friendly_name': 'Frigo Freezer door', + }), + 'context': , + 'entity_id': 'binary_sensor.frigo_freezer_door', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- # name: test_all_entities[da_wm_dw_000001][binary_sensor.dishwasher_child_lock-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ diff --git a/tests/components/smartthings/snapshots/test_init.ambr b/tests/components/smartthings/snapshots/test_init.ambr index 052d15bd1ae..8ec97af7d84 100644 --- a/tests/components/smartthings/snapshots/test_init.ambr +++ b/tests/components/smartthings/snapshots/test_init.ambr @@ -629,6 +629,39 @@ 'via_device_id': None, }) # --- +# name: test_devices[da_ref_normal_01011] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'config_entries_subentries': , + 'configuration_url': 'https://account.smartthings.com', + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': 'Realtek', + 'id': , + 'identifiers': set({ + tuple( + 'smartthings', + '5758b2ec-563e-f39b-ec39-208e54aabf60', + ), + }), + 'is_new': False, + 'labels': set({ + }), + 'manufacturer': 'Samsung Electronics', + 'model': 'TP1X_REF_21K', + 'model_id': None, + 'name': 'Frigo', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'suggested_area': None, + 'sw_version': 'A-RFWW-TP1-22-REV1_20241030', + 'via_device_id': None, + }) +# --- # name: test_devices[da_rvc_normal_000001] DeviceRegistryEntrySnapshot({ 'area_id': 'theater', diff --git a/tests/components/smartthings/snapshots/test_sensor.ambr b/tests/components/smartthings/snapshots/test_sensor.ambr index 73bbc96bc85..7be10ebac91 100644 --- a/tests/components/smartthings/snapshots/test_sensor.ambr +++ b/tests/components/smartthings/snapshots/test_sensor.ambr @@ -4049,6 +4049,283 @@ 'state': '0.0135559777781698', }) # --- +# name: test_all_entities[da_ref_normal_01011][sensor.frigo_energy-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.frigo_energy', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Energy', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '5758b2ec-563e-f39b-ec39-208e54aabf60_main_powerConsumptionReport_powerConsumption_energy_meter', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[da_ref_normal_01011][sensor.frigo_energy-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Frigo Energy', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.frigo_energy', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '66.571', + }) +# --- +# name: test_all_entities[da_ref_normal_01011][sensor.frigo_energy_difference-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.frigo_energy_difference', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Energy difference', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'energy_difference', + 'unique_id': '5758b2ec-563e-f39b-ec39-208e54aabf60_main_powerConsumptionReport_powerConsumption_deltaEnergy_meter', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[da_ref_normal_01011][sensor.frigo_energy_difference-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Frigo Energy difference', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.frigo_energy_difference', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.019', + }) +# --- +# name: test_all_entities[da_ref_normal_01011][sensor.frigo_energy_saved-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.frigo_energy_saved', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Energy saved', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'energy_saved', + 'unique_id': '5758b2ec-563e-f39b-ec39-208e54aabf60_main_powerConsumptionReport_powerConsumption_energySaved_meter', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[da_ref_normal_01011][sensor.frigo_energy_saved-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Frigo Energy saved', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.frigo_energy_saved', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0', + }) +# --- +# name: test_all_entities[da_ref_normal_01011][sensor.frigo_power-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.frigo_power', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Power', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '5758b2ec-563e-f39b-ec39-208e54aabf60_main_powerConsumptionReport_powerConsumption_power_meter', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[da_ref_normal_01011][sensor.frigo_power-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'Frigo Power', + 'power_consumption_end': '2025-03-30T18:38:18Z', + 'power_consumption_start': '2025-03-30T18:21:37Z', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.frigo_power', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '61', + }) +# --- +# name: test_all_entities[da_ref_normal_01011][sensor.frigo_power_energy-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.frigo_power_energy', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Power energy', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'power_energy', + 'unique_id': '5758b2ec-563e-f39b-ec39-208e54aabf60_main_powerConsumptionReport_powerConsumption_powerEnergy_meter', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[da_ref_normal_01011][sensor.frigo_power_energy-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Frigo Power energy', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.frigo_power_energy', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0189117822202047', + }) +# --- # name: test_all_entities[da_rvc_normal_000001][sensor.robot_vacuum_battery-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ From 33b6d0a45f1aabd5c1776bca458904a0ef3b45b6 Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Mon, 31 Mar 2025 13:13:48 +0200 Subject: [PATCH 128/505] Replace "Connected" and "Disconnected" with common states (#141913) --- homeassistant/components/qbittorrent/strings.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/qbittorrent/strings.json b/homeassistant/components/qbittorrent/strings.json index ee613eb96c2..ef2f45bbc28 100644 --- a/homeassistant/components/qbittorrent/strings.json +++ b/homeassistant/components/qbittorrent/strings.json @@ -53,9 +53,9 @@ "connection_status": { "name": "Connection status", "state": { - "connected": "Connected", + "connected": "[%key:common::state::connected%]", "firewalled": "Firewalled", - "disconnected": "Disconnected" + "disconnected": "[%key:common::state::disconnected%]" } }, "active_torrents": { From 05a5b8cdf049e298b807082928e7ac6e2f27a85b Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Mon, 31 Mar 2025 13:17:46 +0200 Subject: [PATCH 129/505] Replace "Connected" and "Disconnected" with common states (#141912) --- homeassistant/components/motionblinds_ble/strings.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/motionblinds_ble/strings.json b/homeassistant/components/motionblinds_ble/strings.json index ec1fb080854..cc7cbbd69e2 100644 --- a/homeassistant/components/motionblinds_ble/strings.json +++ b/homeassistant/components/motionblinds_ble/strings.json @@ -72,8 +72,8 @@ "connection": { "name": "Connection status", "state": { - "connected": "Connected", - "disconnected": "Disconnected", + "connected": "[%key:common::state::connected%]", + "disconnected": "[%key:common::state::disconnected%]", "connecting": "Connecting", "disconnecting": "Disconnecting" } From d669dd45cf7a820599d9af19bd00ea7a54ef19fa Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Mon, 31 Mar 2025 13:18:12 +0200 Subject: [PATCH 130/505] Use common state for "Paused" and "Unplugged" / "Plugged in" from `binary sensor` (#141908) Use common state for "Paused" and "Unplugged" / "Plugged" from `binary sensor` --- homeassistant/components/ohme/strings.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/ohme/strings.json b/homeassistant/components/ohme/strings.json index 4a2170babeb..fa19adbede8 100644 --- a/homeassistant/components/ohme/strings.json +++ b/homeassistant/components/ohme/strings.json @@ -89,7 +89,7 @@ "state": { "smart_charge": "Smart charge", "max_charge": "Max charge", - "paused": "Paused" + "paused": "[%key:common::state::paused%]" } }, "vehicle": { @@ -100,8 +100,8 @@ "status": { "name": "Status", "state": { - "unplugged": "Unplugged", - "plugged_in": "Plugged in", + "unplugged": "[%key:component::binary_sensor::entity_component::plug::state::off%]", + "plugged_in": "[%key:component::binary_sensor::entity_component::plug::state::on%]", "charging": "[%key:common::state::charging%]", "paused": "[%key:common::state::paused%]", "pending_approval": "Pending approval", From 58af3545f49ba083d7bb5b49512cd028af10187f Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Mon, 31 Mar 2025 13:18:44 +0200 Subject: [PATCH 131/505] Correct further sensor categorizations in AVM Fritz!Box tools (#141911) mark margin and attenuation as diagnostic and disable them by default --- homeassistant/components/fritz/sensor.py | 8 ++++++++ tests/components/fritz/snapshots/test_sensor.ambr | 8 ++++---- 2 files changed, 12 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/fritz/sensor.py b/homeassistant/components/fritz/sensor.py index 88de9ebdefc..243b3b5eb4c 100644 --- a/homeassistant/components/fritz/sensor.py +++ b/homeassistant/components/fritz/sensor.py @@ -238,6 +238,8 @@ SENSOR_TYPES: tuple[FritzSensorEntityDescription, ...] = ( key="link_noise_margin_sent", translation_key="link_noise_margin_sent", native_unit_of_measurement=SIGNAL_STRENGTH_DECIBELS, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, value_fn=_retrieve_link_noise_margin_sent_state, is_suitable=lambda info: info.wan_enabled and info.connection == DSL_CONNECTION, ), @@ -245,6 +247,8 @@ SENSOR_TYPES: tuple[FritzSensorEntityDescription, ...] = ( key="link_noise_margin_received", translation_key="link_noise_margin_received", native_unit_of_measurement=SIGNAL_STRENGTH_DECIBELS, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, value_fn=_retrieve_link_noise_margin_received_state, is_suitable=lambda info: info.wan_enabled and info.connection == DSL_CONNECTION, ), @@ -252,6 +256,8 @@ SENSOR_TYPES: tuple[FritzSensorEntityDescription, ...] = ( key="link_attenuation_sent", translation_key="link_attenuation_sent", native_unit_of_measurement=SIGNAL_STRENGTH_DECIBELS, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, value_fn=_retrieve_link_attenuation_sent_state, is_suitable=lambda info: info.wan_enabled and info.connection == DSL_CONNECTION, ), @@ -259,6 +265,8 @@ SENSOR_TYPES: tuple[FritzSensorEntityDescription, ...] = ( key="link_attenuation_received", translation_key="link_attenuation_received", native_unit_of_measurement=SIGNAL_STRENGTH_DECIBELS, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, value_fn=_retrieve_link_attenuation_received_state, is_suitable=lambda info: info.wan_enabled and info.connection == DSL_CONNECTION, ), diff --git a/tests/components/fritz/snapshots/test_sensor.ambr b/tests/components/fritz/snapshots/test_sensor.ambr index ffede386099..ffdd3d23f50 100644 --- a/tests/components/fritz/snapshots/test_sensor.ambr +++ b/tests/components/fritz/snapshots/test_sensor.ambr @@ -357,7 +357,7 @@ 'device_id': , 'disabled_by': None, 'domain': 'sensor', - 'entity_category': None, + 'entity_category': , 'entity_id': 'sensor.mock_title_link_download_noise_margin', 'has_entity_name': True, 'hidden_by': None, @@ -405,7 +405,7 @@ 'device_id': , 'disabled_by': None, 'domain': 'sensor', - 'entity_category': None, + 'entity_category': , 'entity_id': 'sensor.mock_title_link_download_power_attenuation', 'has_entity_name': True, 'hidden_by': None, @@ -502,7 +502,7 @@ 'device_id': , 'disabled_by': None, 'domain': 'sensor', - 'entity_category': None, + 'entity_category': , 'entity_id': 'sensor.mock_title_link_upload_noise_margin', 'has_entity_name': True, 'hidden_by': None, @@ -550,7 +550,7 @@ 'device_id': , 'disabled_by': None, 'domain': 'sensor', - 'entity_category': None, + 'entity_category': , 'entity_id': 'sensor.mock_title_link_upload_power_attenuation', 'has_entity_name': True, 'hidden_by': None, From c888502671c04bc7d77cfb8e590cc91d864efd5e Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Mon, 31 Mar 2025 08:41:13 -0400 Subject: [PATCH 132/505] Add quality scale summary generator (#141780) * Add quality scale summary generator * Remove executable bit * Split out virtual --- script/quality_scale_summary.py | 89 +++++++++++++++++++++++++++++++++ 1 file changed, 89 insertions(+) create mode 100644 script/quality_scale_summary.py diff --git a/script/quality_scale_summary.py b/script/quality_scale_summary.py new file mode 100644 index 00000000000..b93eab81451 --- /dev/null +++ b/script/quality_scale_summary.py @@ -0,0 +1,89 @@ +"""Generate a summary of integration quality scales. + +Run with python3 -m script.quality_scale_summary +Data collected at https://docs.google.com/spreadsheets/d/1xEiwovRJyPohAv8S4ad2LAB-0A38s1HWmzHng8v-4NI +""" + +import csv +from pathlib import Path +import sys + +from homeassistant.const import __version__ as current_version +from homeassistant.util.json import load_json + +COMPONENTS_DIR = Path("homeassistant/components") + + +def generate_quality_scale_summary() -> list[str, int]: + """Generate a summary of integration quality scales.""" + quality_scales = { + "virtual": 0, + "unknown": 0, + "legacy": 0, + "internal": 0, + "bronze": 0, + "silver": 0, + "gold": 0, + "platinum": 0, + } + + for manifest_path in COMPONENTS_DIR.glob("*/manifest.json"): + manifest = load_json(manifest_path) + + if manifest.get("integration_type") == "virtual": + quality_scales["virtual"] += 1 + elif quality_scale := manifest.get("quality_scale"): + quality_scales[quality_scale] += 1 + else: + quality_scales["unknown"] += 1 + + return quality_scales + + +def output_csv(quality_scales: dict[str, int], print_header: bool) -> None: + """Output the quality scale summary as CSV.""" + writer = csv.writer(sys.stdout) + if print_header: + writer.writerow( + [ + "Version", + "Total", + "Virtual", + "Unknown", + "Legacy", + "Internal", + "Bronze", + "Silver", + "Gold", + "Platinum", + ] + ) + + # Calculate total + total = sum(quality_scales.values()) + + # Write the summary + writer.writerow( + [ + current_version, + total, + quality_scales["virtual"], + quality_scales["unknown"], + quality_scales["legacy"], + quality_scales["internal"], + quality_scales["bronze"], + quality_scales["silver"], + quality_scales["gold"], + quality_scales["platinum"], + ] + ) + + +def main() -> None: + """Run the script.""" + quality_scales = generate_quality_scale_summary() + output_csv(quality_scales, "--header" in sys.argv) + + +if __name__ == "__main__": + main() From 1c0768dd7806196a8e907b03d5e45bdb1459ae1b Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Mon, 31 Mar 2025 14:42:07 +0200 Subject: [PATCH 133/505] Replace "Disconnected" with common string in `teslemetry` (#141914) Replaced "Disconnected" with common string in `teslemetry` --- homeassistant/components/teslemetry/strings.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/teslemetry/strings.json b/homeassistant/components/teslemetry/strings.json index c4013800294..69a99fa52f3 100644 --- a/homeassistant/components/teslemetry/strings.json +++ b/homeassistant/components/teslemetry/strings.json @@ -534,7 +534,7 @@ "vin": { "name": "Vehicle", "state": { - "disconnected": "Disconnected" + "disconnected": "[%key:common::state::disconnected%]" } }, "vpp_backup_reserve_percent": { From 6e6f10c0853dc998eb48b3243058be36a7b602f8 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Mon, 31 Mar 2025 14:42:58 +0200 Subject: [PATCH 134/505] Don't create persistent notification when starting discovery flow (#141546) Co-authored-by: Martin Hjelmare Co-authored-by: Franck Nijhof --- homeassistant/config_entries.py | 23 --------- tests/test_config_entries.py | 86 --------------------------------- 2 files changed, 109 deletions(-) diff --git a/homeassistant/config_entries.py b/homeassistant/config_entries.py index d3e681ecca1..81df30210e1 100644 --- a/homeassistant/config_entries.py +++ b/homeassistant/config_entries.py @@ -30,7 +30,6 @@ from propcache.api import cached_property import voluptuous as vol from . import data_entry_flow, loader -from .components import persistent_notification from .const import ( CONF_NAME, EVENT_HOMEASSISTANT_STARTED, @@ -178,7 +177,6 @@ class ConfigEntryState(Enum): DEFAULT_DISCOVERY_UNIQUE_ID = "default_discovery_unique_id" -DISCOVERY_NOTIFICATION_ID = "config_entry_discovery" DISCOVERY_SOURCES = { SOURCE_BLUETOOTH, SOURCE_DHCP, @@ -1385,14 +1383,6 @@ class ConfigEntriesFlowManager( await asyncio.wait(current.values()) - @callback - def _async_has_other_discovery_flows(self, flow_id: str) -> bool: - """Check if there are any other discovery flows in progress.""" - for flow in self._progress.values(): - if flow.flow_id != flow_id and flow.context["source"] in DISCOVERY_SOURCES: - return True - return False - async def async_init( self, handler: str, @@ -1527,10 +1517,6 @@ class ConfigEntriesFlowManager( # init to be done. self._set_pending_import_done(flow) - # Remove notification if no other discovery config entries in progress - if not self._async_has_other_discovery_flows(flow.flow_id): - persistent_notification.async_dismiss(self.hass, DISCOVERY_NOTIFICATION_ID) - # Clean up issue if this is a reauth flow if flow.context["source"] == SOURCE_REAUTH: if (entry_id := flow.context.get("entry_id")) is not None and ( @@ -1719,15 +1705,6 @@ class ConfigEntriesFlowManager( # async_fire_internal is used here because this is only # called from the Debouncer so we know the usage is safe self.hass.bus.async_fire_internal(EVENT_FLOW_DISCOVERED) - persistent_notification.async_create( - self.hass, - title="New devices discovered", - message=( - "We have discovered new devices on your network. " - "[Check it out](/config/integrations)." - ), - notification_id=DISCOVERY_NOTIFICATION_ID, - ) @callback def async_has_matching_discovery_flow( diff --git a/tests/test_config_entries.py b/tests/test_config_entries.py index e3b80ecc03f..6147102f68f 100644 --- a/tests/test_config_entries.py +++ b/tests/test_config_entries.py @@ -57,7 +57,6 @@ from .common import ( MockPlatform, async_capture_events, async_fire_time_changed, - async_get_persistent_notifications, flush_store, mock_config_flow, mock_integration, @@ -1368,59 +1367,6 @@ async def test_async_forward_entry_setup_deprecated( ) in caplog.text -async def test_discovery_notification( - hass: HomeAssistant, manager: config_entries.ConfigEntries -) -> None: - """Test that we create/dismiss a notification when source is discovery.""" - mock_integration(hass, MockModule("test")) - mock_platform(hass, "test.config_flow", None) - - class TestFlow(config_entries.ConfigFlow): - """Test flow.""" - - VERSION = 5 - - async def async_step_discovery(self, discovery_info): - """Test discovery step.""" - return self.async_show_form(step_id="discovery_confirm") - - async def async_step_discovery_confirm(self, discovery_info): - """Test discovery confirm step.""" - return self.async_create_entry(title="Test Title", data={"token": "abcd"}) - - with mock_config_flow("test", TestFlow): - notifications = async_get_persistent_notifications(hass) - assert "config_entry_discovery" not in notifications - - # Start first discovery flow to assert that discovery notification fires - flow1 = await hass.config_entries.flow.async_init( - "test", context={"source": config_entries.SOURCE_DISCOVERY} - ) - await hass.async_block_till_done() - notifications = async_get_persistent_notifications(hass) - assert "config_entry_discovery" in notifications - - # Start a second discovery flow so we can finish the first and assert that - # the discovery notification persists until the second one is complete - flow2 = await hass.config_entries.flow.async_init( - "test", context={"source": config_entries.SOURCE_DISCOVERY} - ) - - flow1 = await hass.config_entries.flow.async_configure(flow1["flow_id"], {}) - assert flow1["type"] == data_entry_flow.FlowResultType.CREATE_ENTRY - - await hass.async_block_till_done() - notifications = async_get_persistent_notifications(hass) - assert "config_entry_discovery" in notifications - - flow2 = await hass.config_entries.flow.async_configure(flow2["flow_id"], {}) - assert flow2["type"] == data_entry_flow.FlowResultType.CREATE_ENTRY - - await hass.async_block_till_done() - notifications = async_get_persistent_notifications(hass) - assert "config_entry_discovery" not in notifications - - async def test_reauth_issue( hass: HomeAssistant, manager: config_entries.ConfigEntries, @@ -1467,30 +1413,6 @@ async def test_reauth_issue( assert len(issue_registry.issues) == 0 -async def test_discovery_notification_not_created(hass: HomeAssistant) -> None: - """Test that we not create a notification when discovery is aborted.""" - mock_integration(hass, MockModule("test")) - mock_platform(hass, "test.config_flow", None) - - class TestFlow(config_entries.ConfigFlow): - """Test flow.""" - - VERSION = 5 - - async def async_step_discovery(self, discovery_info): - """Test discovery step.""" - return self.async_abort(reason="test") - - with mock_config_flow("test", TestFlow): - await hass.config_entries.flow.async_init( - "test", context={"source": config_entries.SOURCE_DISCOVERY} - ) - - await hass.async_block_till_done() - state = hass.states.get("persistent_notification.config_entry_discovery") - assert state is None - - async def test_loading_default_config(hass: HomeAssistant) -> None: """Test loading the default config.""" manager = config_entries.ConfigEntries(hass, {}) @@ -4188,10 +4110,6 @@ async def test_partial_flows_hidden( # While it's blocked it shouldn't be visible or trigger discovery notifications assert len(hass.config_entries.flow.async_progress()) == 0 - await hass.async_block_till_done() - notifications = async_get_persistent_notifications(hass) - assert "config_entry_discovery" not in notifications - # Let the flow init complete pause_discovery.set() @@ -4201,10 +4119,6 @@ async def test_partial_flows_hidden( assert result["type"] == data_entry_flow.FlowResultType.FORM assert len(hass.config_entries.flow.async_progress()) == 1 - await hass.async_block_till_done() - notifications = async_get_persistent_notifications(hass) - assert "config_entry_discovery" in notifications - async def test_async_setup_init_entry( hass: HomeAssistant, manager: config_entries.ConfigEntries From 8abf822d924249dac2cfa8ba5a21fba5c431a476 Mon Sep 17 00:00:00 2001 From: Josef Zweck Date: Mon, 31 Mar 2025 15:29:17 +0200 Subject: [PATCH 135/505] Add None check to azure_storage (#141922) --- homeassistant/components/azure_storage/backup.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/azure_storage/backup.py b/homeassistant/components/azure_storage/backup.py index 4d897126d3d..4a9254213dc 100644 --- a/homeassistant/components/azure_storage/backup.py +++ b/homeassistant/components/azure_storage/backup.py @@ -175,7 +175,8 @@ class AzureStorageBackupAgent(BackupAgent): """Find a blob by backup id.""" async for blob in self._client.list_blobs(include="metadata"): if ( - backup_id == blob.metadata.get("backup_id", "") + blob.metadata is not None + and backup_id == blob.metadata.get("backup_id", "") and blob.metadata.get("metadata_version") == METADATA_VERSION ): return blob From 64994277b1de98e864db96b030935b5acbfe67b1 Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Mon, 31 Mar 2025 16:23:14 +0200 Subject: [PATCH 136/505] Fix spelling of "QR code" and improve grammar in `tuya` (#141929) * Fix spelling of "QR code" in `tuya` Remove the wrong hyphen. * Add "the" to the sentence to improve the grammar --- homeassistant/components/tuya/strings.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/tuya/strings.json b/homeassistant/components/tuya/strings.json index 83847d32fb5..c86e60c22ef 100644 --- a/homeassistant/components/tuya/strings.json +++ b/homeassistant/components/tuya/strings.json @@ -14,7 +14,7 @@ } }, "scan": { - "description": "Use Smart Life app or Tuya Smart app to scan the following QR-code to complete the login.\n\nContinue to the next step once you have completed this step in the app." + "description": "Use the Smart Life app or Tuya Smart app to scan the following QR code to complete the login.\n\nContinue to the next step once you have completed this step in the app." } }, "error": { From 94884d33db821464596ad33f0e0dc1d738e793e7 Mon Sep 17 00:00:00 2001 From: elmurato <1382097+elmurato@users.noreply.github.com> Date: Mon, 31 Mar 2025 17:53:08 +0200 Subject: [PATCH 137/505] Add button platform to Pterodactyl (#141910) * Add button platform to Pterodactyl * Fix parameter order of send_power_action, remove _attr_has_entity_name from button * Rename PterodactylCommands to PterodactylCommand --- .../components/pterodactyl/__init__.py | 2 +- homeassistant/components/pterodactyl/api.py | 27 +++++ .../components/pterodactyl/button.py | 98 +++++++++++++++++++ .../components/pterodactyl/icons.json | 14 +++ .../components/pterodactyl/strings.json | 14 +++ 5 files changed, 154 insertions(+), 1 deletion(-) create mode 100644 homeassistant/components/pterodactyl/button.py diff --git a/homeassistant/components/pterodactyl/__init__.py b/homeassistant/components/pterodactyl/__init__.py index 5712c1bdd58..c0e23b271d1 100644 --- a/homeassistant/components/pterodactyl/__init__.py +++ b/homeassistant/components/pterodactyl/__init__.py @@ -7,7 +7,7 @@ from homeassistant.core import HomeAssistant from .coordinator import PterodactylConfigEntry, PterodactylCoordinator -_PLATFORMS: list[Platform] = [Platform.BINARY_SENSOR, Platform.SENSOR] +_PLATFORMS: list[Platform] = [Platform.BINARY_SENSOR, Platform.BUTTON, Platform.SENSOR] async def async_setup_entry(hass: HomeAssistant, entry: PterodactylConfigEntry) -> bool: diff --git a/homeassistant/components/pterodactyl/api.py b/homeassistant/components/pterodactyl/api.py index aadb3261db0..a60962ecf51 100644 --- a/homeassistant/components/pterodactyl/api.py +++ b/homeassistant/components/pterodactyl/api.py @@ -1,6 +1,7 @@ """API module of the Pterodactyl integration.""" from dataclasses import dataclass +from enum import StrEnum import logging from pydactyl import PterodactylClient @@ -43,6 +44,15 @@ class PterodactylData: uptime: int +class PterodactylCommand(StrEnum): + """Command enum for the Pterodactyl server.""" + + START_SERVER = "start" + STOP_SERVER = "stop" + RESTART_SERVER = "restart" + FORCE_STOP_SERVER = "kill" + + class PterodactylAPI: """Wrapper for Pterodactyl's API.""" @@ -124,3 +134,20 @@ class PterodactylAPI: _LOGGER.debug("%s", data[identifier]) return data + + async def async_send_command( + self, identifier: str, command: PterodactylCommand + ) -> None: + """Send a command to the Pterodactyl server.""" + try: + await self.hass.async_add_executor_job( + self.pterodactyl.client.servers.send_power_action, # type: ignore[union-attr] + identifier, + command, + ) + except ( + PydactylError, + BadRequestError, + PterodactylApiError, + ) as error: + raise PterodactylConnectionError(error) from error diff --git a/homeassistant/components/pterodactyl/button.py b/homeassistant/components/pterodactyl/button.py new file mode 100644 index 00000000000..a1201f3ced5 --- /dev/null +++ b/homeassistant/components/pterodactyl/button.py @@ -0,0 +1,98 @@ +"""Button platform for the Pterodactyl integration.""" + +from __future__ import annotations + +from dataclasses import dataclass + +from homeassistant.components.button import ButtonEntity, ButtonEntityDescription +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from .api import PterodactylCommand, PterodactylConnectionError +from .coordinator import PterodactylConfigEntry, PterodactylCoordinator +from .entity import PterodactylEntity + +KEY_START_SERVER = "start_server" +KEY_STOP_SERVER = "stop_server" +KEY_RESTART_SERVER = "restart_server" +KEY_FORCE_STOP_SERVER = "force_stop_server" + +# Coordinator is used to centralize the data updates. +PARALLEL_UPDATES = 0 + + +@dataclass(frozen=True, kw_only=True) +class PterodactylButtonEntityDescription(ButtonEntityDescription): + """Class describing Pterodactyl button entities.""" + + command: PterodactylCommand + + +BUTTON_DESCRIPTIONS = [ + PterodactylButtonEntityDescription( + key=KEY_START_SERVER, + translation_key=KEY_START_SERVER, + command=PterodactylCommand.START_SERVER, + ), + PterodactylButtonEntityDescription( + key=KEY_STOP_SERVER, + translation_key=KEY_STOP_SERVER, + command=PterodactylCommand.STOP_SERVER, + ), + PterodactylButtonEntityDescription( + key=KEY_RESTART_SERVER, + translation_key=KEY_RESTART_SERVER, + command=PterodactylCommand.RESTART_SERVER, + ), + PterodactylButtonEntityDescription( + key=KEY_FORCE_STOP_SERVER, + translation_key=KEY_FORCE_STOP_SERVER, + command=PterodactylCommand.FORCE_STOP_SERVER, + entity_registry_enabled_default=False, + ), +] + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: PterodactylConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up the Pterodactyl button platform.""" + coordinator = config_entry.runtime_data + + async_add_entities( + PterodactylButtonEntity(coordinator, identifier, description, config_entry) + for identifier in coordinator.api.identifiers + for description in BUTTON_DESCRIPTIONS + ) + + +class PterodactylButtonEntity(PterodactylEntity, ButtonEntity): + """Representation of a Pterodactyl button entity.""" + + entity_description: PterodactylButtonEntityDescription + + def __init__( + self, + coordinator: PterodactylCoordinator, + identifier: str, + description: PterodactylButtonEntityDescription, + config_entry: PterodactylConfigEntry, + ) -> None: + """Initialize the button entity.""" + super().__init__(coordinator, identifier, config_entry) + self.entity_description = description + self._attr_unique_id = f"{self.game_server_data.uuid}_{description.key}" + + async def async_press(self) -> None: + """Handle the button press.""" + try: + await self.coordinator.api.async_send_command( + self.identifier, self.entity_description.command + ) + except PterodactylConnectionError as err: + raise HomeAssistantError( + f"Failed to send action '{self.entity_description.key}'" + ) from err diff --git a/homeassistant/components/pterodactyl/icons.json b/homeassistant/components/pterodactyl/icons.json index 245bdd7dbe5..265a8dcadda 100644 --- a/homeassistant/components/pterodactyl/icons.json +++ b/homeassistant/components/pterodactyl/icons.json @@ -1,5 +1,19 @@ { "entity": { + "button": { + "start_server": { + "default": "mdi:play" + }, + "stop_server": { + "default": "mdi:stop" + }, + "restart_server": { + "default": "mdi:refresh" + }, + "force_stop_server": { + "default": "mdi:flash-alert" + } + }, "sensor": { "cpu_utilization": { "default": "mdi:cpu-64-bit" diff --git a/homeassistant/components/pterodactyl/strings.json b/homeassistant/components/pterodactyl/strings.json index 9f1feef388c..97b33566f39 100644 --- a/homeassistant/components/pterodactyl/strings.json +++ b/homeassistant/components/pterodactyl/strings.json @@ -26,6 +26,20 @@ "name": "Status" } }, + "button": { + "start_server": { + "name": "Start server" + }, + "stop_server": { + "name": "Stop server" + }, + "restart_server": { + "name": "Restart server" + }, + "force_stop_server": { + "name": "Force stop server" + } + }, "sensor": { "cpu_utilization": { "name": "CPU utilization" From ac723161c1c4c47474d264bd8f47b3017180fc46 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 31 Mar 2025 06:16:33 -1000 Subject: [PATCH 138/505] Bump grpcio to 1.71.0 (#141881) --- homeassistant/package_constraints.txt | 6 +++--- script/gen_requirements_all.py | 6 +++--- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 3cccab5fca9..b90371fdb67 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -87,9 +87,9 @@ httplib2>=0.19.0 # gRPC is an implicit dependency that we want to make explicit so we manage # upgrades intentionally. It is a large package to build from source and we # want to ensure we have wheels built. -grpcio==1.67.1 -grpcio-status==1.67.1 -grpcio-reflection==1.67.1 +grpcio==1.71.0 +grpcio-status==1.71.0 +grpcio-reflection==1.71.0 # This is a old unmaintained library and is replaced with pycryptodome pycrypto==1000000000.0.0 diff --git a/script/gen_requirements_all.py b/script/gen_requirements_all.py index 1be6286d30c..8cbb423966d 100755 --- a/script/gen_requirements_all.py +++ b/script/gen_requirements_all.py @@ -117,9 +117,9 @@ httplib2>=0.19.0 # gRPC is an implicit dependency that we want to make explicit so we manage # upgrades intentionally. It is a large package to build from source and we # want to ensure we have wheels built. -grpcio==1.67.1 -grpcio-status==1.67.1 -grpcio-reflection==1.67.1 +grpcio==1.71.0 +grpcio-status==1.71.0 +grpcio-reflection==1.71.0 # This is a old unmaintained library and is replaced with pycryptodome pycrypto==1000000000.0.0 From 4071eb76c72a08045f74b424f7ddb78ea1f33ec3 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Mon, 31 Mar 2025 18:33:45 +0200 Subject: [PATCH 139/505] Revert PR 136314 (Cleanup map references in lovelace) (#141928) * Revert PR 136314 (Cleanup map references in lovelace) * Update homeassistant/components/lovelace/__init__.py Co-authored-by: Martin Hjelmare * Fix dashboard creation * Update homeassistant/components/lovelace/__init__.py Co-authored-by: Martin Hjelmare --------- Co-authored-by: Martin Hjelmare --- homeassistant/components/lovelace/__init__.py | 32 +++++++++- tests/components/lovelace/test_init.py | 58 +++++++++++++++++++ 2 files changed, 89 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/lovelace/__init__.py b/homeassistant/components/lovelace/__init__.py index 4d8472da9a2..c0262f42f6c 100644 --- a/homeassistant/components/lovelace/__init__.py +++ b/homeassistant/components/lovelace/__init__.py @@ -6,7 +6,7 @@ from typing import Any import voluptuous as vol -from homeassistant.components import frontend, websocket_api +from homeassistant.components import frontend, onboarding, websocket_api from homeassistant.config import ( async_hass_config_yaml, async_process_component_and_handle_errors, @@ -17,6 +17,7 @@ from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import collection, config_validation as cv from homeassistant.helpers.frame import report_usage from homeassistant.helpers.service import async_register_admin_service +from homeassistant.helpers.translation import async_get_translations from homeassistant.helpers.typing import ConfigType from homeassistant.loader import async_get_integration from homeassistant.util import slugify @@ -282,6 +283,13 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: STORAGE_DASHBOARD_UPDATE_FIELDS, ).async_setup(hass) + def create_map_dashboard() -> None: + """Create a map dashboard.""" + hass.async_create_task(_create_map_dashboard(hass, dashboards_collection)) + + if not onboarding.async_is_onboarded(hass): + onboarding.async_add_listener(hass, create_map_dashboard) + return True @@ -323,3 +331,25 @@ def _register_panel( kwargs["sidebar_icon"] = config.get(CONF_ICON, DEFAULT_ICON) frontend.async_register_built_in_panel(hass, DOMAIN, **kwargs) + + +async def _create_map_dashboard( + hass: HomeAssistant, dashboards_collection: dashboard.DashboardsCollection +) -> None: + """Create a map dashboard.""" + translations = await async_get_translations( + hass, hass.config.language, "dashboard", {onboarding.DOMAIN} + ) + title = translations["component.onboarding.dashboard.map.title"] + + await dashboards_collection.async_create_item( + { + CONF_ALLOW_SINGLE_WORD: True, + CONF_ICON: "mdi:map", + CONF_TITLE: title, + CONF_URL_PATH: "map", + } + ) + + map_store = hass.data[LOVELACE_DATA].dashboards["map"] + await map_store.async_save({"strategy": {"type": "map"}}) diff --git a/tests/components/lovelace/test_init.py b/tests/components/lovelace/test_init.py index f35f7369f93..4c7cc96504b 100644 --- a/tests/components/lovelace/test_init.py +++ b/tests/components/lovelace/test_init.py @@ -13,6 +13,16 @@ from homeassistant.setup import async_setup_component from tests.typing import WebSocketGenerator +@pytest.fixture +def mock_onboarding_not_done() -> Generator[MagicMock]: + """Mock that Home Assistant is currently onboarding.""" + with patch( + "homeassistant.components.onboarding.async_is_onboarded", + return_value=False, + ) as mock_onboarding: + yield mock_onboarding + + @pytest.fixture def mock_onboarding_done() -> Generator[MagicMock]: """Mock that Home Assistant is currently onboarding.""" @@ -23,6 +33,15 @@ def mock_onboarding_done() -> Generator[MagicMock]: yield mock_onboarding +@pytest.fixture +def mock_add_onboarding_listener() -> Generator[MagicMock]: + """Mock that Home Assistant is currently onboarding.""" + with patch( + "homeassistant.components.onboarding.async_add_listener", + ) as mock_add_onboarding_listener: + yield mock_add_onboarding_listener + + async def test_create_dashboards_when_onboarded( hass: HomeAssistant, hass_ws_client: WebSocketGenerator, @@ -41,6 +60,45 @@ async def test_create_dashboards_when_onboarded( assert response["result"] == [] +async def test_create_dashboards_when_not_onboarded( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + hass_storage: dict[str, Any], + mock_add_onboarding_listener, + mock_onboarding_not_done, +) -> None: + """Test we automatically create dashboards when not onboarded.""" + client = await hass_ws_client(hass) + + assert await async_setup_component(hass, "lovelace", {}) + + # Call onboarding listener + mock_add_onboarding_listener.mock_calls[0][1][1]() + await hass.async_block_till_done() + + # List dashboards + await client.send_json_auto_id({"type": "lovelace/dashboards/list"}) + response = await client.receive_json() + assert response["success"] + assert response["result"] == [ + { + "icon": "mdi:map", + "id": "map", + "mode": "storage", + "require_admin": False, + "show_in_sidebar": True, + "title": "Map", + "url_path": "map", + } + ] + + # List map dashboard config + await client.send_json_auto_id({"type": "lovelace/config", "url_path": "map"}) + response = await client.receive_json() + assert response["success"] + assert response["result"] == {"strategy": {"type": "map"}} + + @pytest.mark.parametrize("integration_frame_path", ["custom_components/my_integration"]) @pytest.mark.usefixtures("mock_integration_frame") async def test_hass_data_compatibility( From ef989160af7c785e74b01ff938ca69a2ab70bfac Mon Sep 17 00:00:00 2001 From: Jan-Philipp Benecke Date: Mon, 31 Mar 2025 19:08:21 +0200 Subject: [PATCH 140/505] Bump aiowebdav2 to 0.4.5 (#141934) --- homeassistant/components/webdav/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/webdav/manifest.json b/homeassistant/components/webdav/manifest.json index 260c569b72b..63d093745d1 100644 --- a/homeassistant/components/webdav/manifest.json +++ b/homeassistant/components/webdav/manifest.json @@ -8,5 +8,5 @@ "iot_class": "cloud_polling", "loggers": ["aiowebdav2"], "quality_scale": "bronze", - "requirements": ["aiowebdav2==0.4.4"] + "requirements": ["aiowebdav2==0.4.5"] } diff --git a/requirements_all.txt b/requirements_all.txt index bf01275a876..25937c18b3d 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -422,7 +422,7 @@ aiowaqi==3.1.0 aiowatttime==0.1.1 # homeassistant.components.webdav -aiowebdav2==0.4.4 +aiowebdav2==0.4.5 # homeassistant.components.webostv aiowebostv==0.7.3 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 4e382193217..49800dbc973 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -404,7 +404,7 @@ aiowaqi==3.1.0 aiowatttime==0.1.1 # homeassistant.components.webdav -aiowebdav2==0.4.4 +aiowebdav2==0.4.5 # homeassistant.components.webostv aiowebostv==0.7.3 From 28dbf6e3dcd36d6bcfd96c39d31e1fc0c38966c3 Mon Sep 17 00:00:00 2001 From: Michael Hansen Date: Mon, 31 Mar 2025 13:29:07 -0500 Subject: [PATCH 141/505] Add preannounce boolean for announce/start conversation (#141930) * Add preannounce boolean * Fix disabling preannounce in wizard * Fix casing * Fix type of preannounce_media_id * Adjust description of preannounce_media_id --- .../components/assist_satellite/__init__.py | 6 ++++-- .../components/assist_satellite/entity.py | 20 ++++++++++++------- .../components/assist_satellite/services.yaml | 10 ++++++++++ .../components/assist_satellite/strings.json | 16 +++++++++++---- .../assist_satellite/websocket_api.py | 2 +- .../assist_satellite/test_entity.py | 12 +++++------ .../esphome/test_assist_satellite.py | 8 ++++---- 7 files changed, 50 insertions(+), 24 deletions(-) diff --git a/homeassistant/components/assist_satellite/__init__.py b/homeassistant/components/assist_satellite/__init__.py index bc2157b10b2..3338f223bc9 100644 --- a/homeassistant/components/assist_satellite/__init__.py +++ b/homeassistant/components/assist_satellite/__init__.py @@ -60,7 +60,8 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: { vol.Optional("message"): str, vol.Optional("media_id"): str, - vol.Optional("preannounce_media_id"): vol.Any(str, None), + vol.Optional("preannounce"): bool, + vol.Optional("preannounce_media_id"): str, } ), cv.has_at_least_one_key("message", "media_id"), @@ -75,7 +76,8 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: { vol.Optional("start_message"): str, vol.Optional("start_media_id"): str, - vol.Optional("preannounce_media_id"): vol.Any(str, None), + vol.Optional("preannounce"): bool, + vol.Optional("preannounce_media_id"): str, vol.Optional("extra_system_prompt"): str, } ), diff --git a/homeassistant/components/assist_satellite/entity.py b/homeassistant/components/assist_satellite/entity.py index 7b4c1b92d8c..dc20c7650d7 100644 --- a/homeassistant/components/assist_satellite/entity.py +++ b/homeassistant/components/assist_satellite/entity.py @@ -180,7 +180,8 @@ class AssistSatelliteEntity(entity.Entity): self, message: str | None = None, media_id: str | None = None, - preannounce_media_id: str | None = PREANNOUNCE_URL, + preannounce: bool = True, + preannounce_media_id: str = PREANNOUNCE_URL, ) -> None: """Play and show an announcement on the satellite. @@ -190,8 +191,8 @@ class AssistSatelliteEntity(entity.Entity): If media_id is provided, it is played directly. It is possible to omit the message and the satellite will not show any text. + If preannounce is True, a sound is played before the announcement. If preannounce_media_id is provided, it overrides the default sound. - If preannounce_media_id is None, no sound is played. Calls async_announce with message and media id. """ @@ -201,7 +202,9 @@ class AssistSatelliteEntity(entity.Entity): message = "" announcement = await self._resolve_announcement_media_id( - message, media_id, preannounce_media_id + message, + media_id, + preannounce_media_id=preannounce_media_id if preannounce else None, ) if self._is_announcing: @@ -229,7 +232,8 @@ class AssistSatelliteEntity(entity.Entity): start_message: str | None = None, start_media_id: str | None = None, extra_system_prompt: str | None = None, - preannounce_media_id: str | None = PREANNOUNCE_URL, + preannounce: bool = True, + preannounce_media_id: str = PREANNOUNCE_URL, ) -> None: """Start a conversation from the satellite. @@ -239,8 +243,8 @@ class AssistSatelliteEntity(entity.Entity): If start_media_id is provided, it is played directly. It is possible to omit the message and the satellite will not show any text. - If preannounce_media_id is provided, it is played before the announcement. - If preannounce_media_id is None, no sound is played. + If preannounce is True, a sound is played before the start message or media. + If preannounce_media_id is provided, it overrides the default sound. Calls async_start_conversation. """ @@ -257,7 +261,9 @@ class AssistSatelliteEntity(entity.Entity): start_message = "" announcement = await self._resolve_announcement_media_id( - start_message, start_media_id, preannounce_media_id + start_message, + start_media_id, + preannounce_media_id=preannounce_media_id if preannounce else None, ) if self._is_announcing: diff --git a/homeassistant/components/assist_satellite/services.yaml b/homeassistant/components/assist_satellite/services.yaml index 7d334d6a8db..d88710c4c4e 100644 --- a/homeassistant/components/assist_satellite/services.yaml +++ b/homeassistant/components/assist_satellite/services.yaml @@ -15,6 +15,11 @@ announce: required: false selector: text: + preannounce: + required: false + default: true + selector: + boolean: preannounce_media_id: required: false selector: @@ -40,6 +45,11 @@ start_conversation: required: false selector: text: + preannounce: + required: false + default: true + selector: + boolean: preannounce_media_id: required: false selector: diff --git a/homeassistant/components/assist_satellite/strings.json b/homeassistant/components/assist_satellite/strings.json index 2bb61516bca..b69711c7106 100644 --- a/homeassistant/components/assist_satellite/strings.json +++ b/homeassistant/components/assist_satellite/strings.json @@ -24,9 +24,13 @@ "name": "Media ID", "description": "The media ID to announce instead of using text-to-speech." }, + "preannounce": { + "name": "Preannounce", + "description": "Play a sound before the announcement." + }, "preannounce_media_id": { - "name": "Preannounce Media ID", - "description": "The media ID to play before the announcement." + "name": "Preannounce media ID", + "description": "Custom media ID to play before the announcement." } } }, @@ -46,9 +50,13 @@ "name": "Extra system prompt", "description": "Provide background information to the AI about the request." }, + "preannounce": { + "name": "Preannounce", + "description": "Play a sound before the start message or media." + }, "preannounce_media_id": { - "name": "Preannounce Media ID", - "description": "The media ID to play before the start message or media." + "name": "Preannounce media ID", + "description": "Custom media ID to play before the start message or media." } } } diff --git a/homeassistant/components/assist_satellite/websocket_api.py b/homeassistant/components/assist_satellite/websocket_api.py index 0a95880706a..6f8b3d723ad 100644 --- a/homeassistant/components/assist_satellite/websocket_api.py +++ b/homeassistant/components/assist_satellite/websocket_api.py @@ -199,7 +199,7 @@ async def websocket_test_connection( hass.async_create_background_task( satellite.async_internal_announce( media_id=f"{CONNECTION_TEST_URL_BASE}/{connection_id}", - preannounce_media_id=None, + preannounce=False, ), f"assist_satellite_connection_test_{msg['entity_id']}", ) diff --git a/tests/components/assist_satellite/test_entity.py b/tests/components/assist_satellite/test_entity.py index 2b1cc78943f..8050b23f5ff 100644 --- a/tests/components/assist_satellite/test_entity.py +++ b/tests/components/assist_satellite/test_entity.py @@ -186,7 +186,7 @@ async def test_new_pipeline_cancels_pipeline( ("service_data", "expected_params"), [ ( - {"message": "Hello", "preannounce_media_id": None}, + {"message": "Hello", "preannounce": False}, AssistSatelliteAnnouncement( message="Hello", media_id="http://10.10.10.10:8123/api/tts_proxy/test-token", @@ -199,7 +199,7 @@ async def test_new_pipeline_cancels_pipeline( { "message": "Hello", "media_id": "media-source://given", - "preannounce_media_id": None, + "preannounce": False, }, AssistSatelliteAnnouncement( message="Hello", @@ -210,7 +210,7 @@ async def test_new_pipeline_cancels_pipeline( ), ), ( - {"media_id": "http://example.com/bla.mp3", "preannounce_media_id": None}, + {"media_id": "http://example.com/bla.mp3", "preannounce": False}, AssistSatelliteAnnouncement( message="", media_id="http://example.com/bla.mp3", @@ -541,7 +541,7 @@ async def test_vad_sensitivity_entity_not_found( { "start_message": "Hello", "extra_system_prompt": "Better system prompt", - "preannounce_media_id": None, + "preannounce": False, }, ( "mock-conversation-id", @@ -559,7 +559,7 @@ async def test_vad_sensitivity_entity_not_found( { "start_message": "Hello", "start_media_id": "media-source://given", - "preannounce_media_id": None, + "preannounce": False, }, ( "mock-conversation-id", @@ -576,7 +576,7 @@ async def test_vad_sensitivity_entity_not_found( ( { "start_media_id": "http://example.com/given.mp3", - "preannounce_media_id": None, + "preannounce": False, }, ( "mock-conversation-id", diff --git a/tests/components/esphome/test_assist_satellite.py b/tests/components/esphome/test_assist_satellite.py index 2254d24c9ac..3f6db1dd9c9 100644 --- a/tests/components/esphome/test_assist_satellite.py +++ b/tests/components/esphome/test_assist_satellite.py @@ -1221,7 +1221,7 @@ async def test_announce_message( { "entity_id": satellite.entity_id, "message": "test-text", - "preannounce_media_id": None, + "preannounce": False, }, blocking=True, ) @@ -1311,7 +1311,7 @@ async def test_announce_media_id( { "entity_id": satellite.entity_id, "media_id": "https://www.home-assistant.io/resolved.mp3", - "preannounce_media_id": None, + "preannounce": False, }, blocking=True, ) @@ -1522,7 +1522,7 @@ async def test_start_conversation_message( { "entity_id": satellite.entity_id, "start_message": "test-text", - "preannounce_media_id": None, + "preannounce": False, }, blocking=True, ) @@ -1631,7 +1631,7 @@ async def test_start_conversation_media_id( { "entity_id": satellite.entity_id, "start_media_id": "https://www.home-assistant.io/resolved.mp3", - "preannounce_media_id": None, + "preannounce": False, }, blocking=True, ) From 1978e94aaacba92457c328a127cf4b0eb67d0eef Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ab=C3=ADlio=20Costa?= Date: Mon, 31 Mar 2025 19:32:24 +0100 Subject: [PATCH 142/505] Fix Whirlpool sensor icon definition (#141937) --- homeassistant/components/whirlpool/sensor.py | 19 ++++++------------- 1 file changed, 6 insertions(+), 13 deletions(-) diff --git a/homeassistant/components/whirlpool/sensor.py b/homeassistant/components/whirlpool/sensor.py index d0d13a128e2..3d38883b901 100644 --- a/homeassistant/components/whirlpool/sensor.py +++ b/homeassistant/components/whirlpool/sensor.py @@ -1,7 +1,5 @@ """The Washer/Dryer Sensor for Whirlpool Appliances.""" -from __future__ import annotations - from collections.abc import Callable from dataclasses import dataclass from datetime import datetime, timedelta @@ -65,8 +63,6 @@ CYCLE_FUNC = [ ] DOOR_OPEN = "door_open" -ICON_D = "mdi:tumble-dryer" -ICON_W = "mdi:washing-machine" _LOGGER = logging.getLogger(__name__) SCAN_INTERVAL = timedelta(minutes=5) @@ -124,6 +120,7 @@ SENSOR_TIMER: tuple[SensorEntityDescription] = ( key="timeremaining", translation_key="end_time", device_class=SensorDeviceClass.TIMESTAMP, + icon="mdi:progress-clock", ), ) @@ -161,10 +158,11 @@ class WasherDryerClass(SensorEntity): """Initialize the washer sensor.""" self._wd: WasherDryer = washer_dryer - if washer_dryer.name == "dryer": - self._attr_icon = ICON_D - else: - self._attr_icon = ICON_W + self._attr_icon = ( + "mdi:tumble-dryer" + if "dryer" in washer_dryer.appliance_info.data_model.lower() + else "mdi:washing-machine" + ) self.entity_description: WhirlpoolSensorEntityDescription = description self._attr_device_info = DeviceInfo( @@ -205,11 +203,6 @@ class WasherDryerTimeClass(RestoreSensor): """Initialize the washer sensor.""" self._wd: WasherDryer = washer_dryer - if washer_dryer.name == "dryer": - self._attr_icon = ICON_D - else: - self._attr_icon = ICON_W - self.entity_description: SensorEntityDescription = description self._running: bool | None = None self._attr_device_info = DeviceInfo( From a904df5bc24186474a1eb49ef22afc520cf8ff02 Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Mon, 31 Mar 2025 21:03:13 +0200 Subject: [PATCH 143/505] Add common module to ProxymoxVE integration (#141941) add common module --- .../components/proxmoxve/__init__.py | 79 +---------------- homeassistant/components/proxmoxve/common.py | 88 +++++++++++++++++++ 2 files changed, 89 insertions(+), 78 deletions(-) create mode 100644 homeassistant/components/proxmoxve/common.py diff --git a/homeassistant/components/proxmoxve/__init__.py b/homeassistant/components/proxmoxve/__init__.py index 0db6ea28652..11fa530f47b 100644 --- a/homeassistant/components/proxmoxve/__init__.py +++ b/homeassistant/components/proxmoxve/__init__.py @@ -6,7 +6,6 @@ from datetime import timedelta from typing import Any from proxmoxer import AuthenticationError, ProxmoxAPI -from proxmoxer.core import ResourceException import requests.exceptions from requests.exceptions import ConnectTimeout, SSLError import voluptuous as vol @@ -25,6 +24,7 @@ from homeassistant.helpers.discovery import async_load_platform from homeassistant.helpers.typing import ConfigType from homeassistant.helpers.update_coordinator import DataUpdateCoordinator +from .common import ProxmoxClient, call_api_container_vm, parse_api_container_vm from .const import ( _LOGGER, CONF_CONTAINERS, @@ -219,80 +219,3 @@ def create_coordinator_container_vm( update_method=async_update_data, update_interval=timedelta(seconds=UPDATE_INTERVAL), ) - - -def parse_api_container_vm(status: dict[str, Any]) -> dict[str, Any]: - """Get the container or vm api data and return it formatted in a dictionary. - - It is implemented in this way to allow for more data to be added for sensors - in the future. - """ - - return {"status": status["status"], "name": status["name"]} - - -def call_api_container_vm( - proxmox: ProxmoxAPI, - node_name: str, - vm_id: int, - machine_type: int, -) -> dict[str, Any] | None: - """Make proper api calls.""" - status = None - - try: - if machine_type == TYPE_VM: - status = proxmox.nodes(node_name).qemu(vm_id).status.current.get() - elif machine_type == TYPE_CONTAINER: - status = proxmox.nodes(node_name).lxc(vm_id).status.current.get() - except (ResourceException, requests.exceptions.ConnectionError): - return None - - return status - - -class ProxmoxClient: - """A wrapper for the proxmoxer ProxmoxAPI client.""" - - _proxmox: ProxmoxAPI - - def __init__( - self, - host: str, - port: int, - user: str, - realm: str, - password: str, - verify_ssl: bool, - ) -> None: - """Initialize the ProxmoxClient.""" - - self._host = host - self._port = port - self._user = user - self._realm = realm - self._password = password - self._verify_ssl = verify_ssl - - def build_client(self) -> None: - """Construct the ProxmoxAPI client. - - Allows inserting the realm within the `user` value. - """ - - if "@" in self._user: - user_id = self._user - else: - user_id = f"{self._user}@{self._realm}" - - self._proxmox = ProxmoxAPI( - self._host, - port=self._port, - user=user_id, - password=self._password, - verify_ssl=self._verify_ssl, - ) - - def get_api_client(self) -> ProxmoxAPI: - """Return the ProxmoxAPI client.""" - return self._proxmox diff --git a/homeassistant/components/proxmoxve/common.py b/homeassistant/components/proxmoxve/common.py new file mode 100644 index 00000000000..4173377377c --- /dev/null +++ b/homeassistant/components/proxmoxve/common.py @@ -0,0 +1,88 @@ +"""Commons for Proxmox VE integration.""" + +from __future__ import annotations + +from typing import Any + +from proxmoxer import ProxmoxAPI +from proxmoxer.core import ResourceException +import requests.exceptions + +from .const import TYPE_CONTAINER, TYPE_VM + + +class ProxmoxClient: + """A wrapper for the proxmoxer ProxmoxAPI client.""" + + _proxmox: ProxmoxAPI + + def __init__( + self, + host: str, + port: int, + user: str, + realm: str, + password: str, + verify_ssl: bool, + ) -> None: + """Initialize the ProxmoxClient.""" + + self._host = host + self._port = port + self._user = user + self._realm = realm + self._password = password + self._verify_ssl = verify_ssl + + def build_client(self) -> None: + """Construct the ProxmoxAPI client. + + Allows inserting the realm within the `user` value. + """ + + if "@" in self._user: + user_id = self._user + else: + user_id = f"{self._user}@{self._realm}" + + self._proxmox = ProxmoxAPI( + self._host, + port=self._port, + user=user_id, + password=self._password, + verify_ssl=self._verify_ssl, + ) + + def get_api_client(self) -> ProxmoxAPI: + """Return the ProxmoxAPI client.""" + return self._proxmox + + +def parse_api_container_vm(status: dict[str, Any]) -> dict[str, Any]: + """Get the container or vm api data and return it formatted in a dictionary. + + It is implemented in this way to allow for more data to be added for sensors + in the future. + """ + + return {"status": status["status"], "name": status["name"]} + + +def call_api_container_vm( + proxmox: ProxmoxAPI, + node_name: str, + vm_id: int, + machine_type: int, +) -> dict[str, Any] | None: + """Make proper api calls.""" + status = None + + try: + if machine_type == TYPE_VM: + status = proxmox.nodes(node_name).qemu(vm_id).status.current.get() + elif machine_type == TYPE_CONTAINER: + status = proxmox.nodes(node_name).lxc(vm_id).status.current.get() + except (ResourceException, requests.exceptions.ConnectionError): + return None + + return status From c5f75bc135eba018441eb47980b5d6ac27338563 Mon Sep 17 00:00:00 2001 From: Jan-Philipp Benecke Date: Mon, 31 Mar 2025 21:10:14 +0200 Subject: [PATCH 144/505] Import function instead of relying on `hass.component` in watergate (#141945) --- homeassistant/components/watergate/__init__.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/watergate/__init__.py b/homeassistant/components/watergate/__init__.py index fd591215d8b..4f075a57228 100644 --- a/homeassistant/components/watergate/__init__.py +++ b/homeassistant/components/watergate/__init__.py @@ -15,6 +15,7 @@ from homeassistant.components.webhook import ( Response, async_generate_url, async_register, + async_unregister, ) from homeassistant.const import CONF_IP_ADDRESS, CONF_WEBHOOK_ID, Platform from homeassistant.core import HomeAssistant @@ -75,7 +76,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: WatergateConfigEntry) -> async def async_unload_entry(hass: HomeAssistant, entry: WatergateConfigEntry) -> bool: """Unload a config entry.""" webhook_id = entry.data[CONF_WEBHOOK_ID] - hass.components.webhook.async_unregister(webhook_id) + async_unregister(hass, webhook_id) return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) From b758dc202f55b1ded8ed4605442046a2a6f9648c Mon Sep 17 00:00:00 2001 From: puddly <32534428+puddly@users.noreply.github.com> Date: Mon, 31 Mar 2025 15:10:24 -0400 Subject: [PATCH 145/505] Reload the ZBT-1 integration on USB state changes (#141287) * Reload the config entry when the ZBT-1 is unplugged * Register the USB event handler globally to react better to re-plugs * Fix existing unit tests * Add an empty `CONFIG_SCHEMA` * Add a unit test * Fix unit tests * Fix unit tests for Linux * Address most review comments * Address remaining review comments --- .../homeassistant_sky_connect/__init__.py | 48 ++++++- .../homeassistant_sky_connect/conftest.py | 10 ++ .../homeassistant_sky_connect/test_init.py | 131 +++++++++++++++++- 3 files changed, 186 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/homeassistant_sky_connect/__init__.py b/homeassistant/components/homeassistant_sky_connect/__init__.py index e8b8c3bb433..1770e902b0f 100644 --- a/homeassistant/components/homeassistant_sky_connect/__init__.py +++ b/homeassistant/components/homeassistant_sky_connect/__init__.py @@ -3,19 +3,63 @@ from __future__ import annotations import logging +import os.path from homeassistant.components.homeassistant_hardware.util import guess_firmware_info +from homeassistant.components.usb import USBDevice, async_register_port_event_callback from homeassistant.config_entries import ConfigEntry -from homeassistant.core import HomeAssistant +from homeassistant.core import HomeAssistant, callback +from homeassistant.exceptions import ConfigEntryNotReady +from homeassistant.helpers import config_validation as cv +from homeassistant.helpers.typing import ConfigType -from .const import DESCRIPTION, DEVICE, FIRMWARE, FIRMWARE_VERSION, PRODUCT +from .const import DESCRIPTION, DEVICE, DOMAIN, FIRMWARE, FIRMWARE_VERSION, PRODUCT _LOGGER = logging.getLogger(__name__) +CONFIG_SCHEMA = cv.empty_config_schema(DOMAIN) + + +async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: + """Set up the ZBT-1 integration.""" + + @callback + def async_port_event_callback( + added: set[USBDevice], removed: set[USBDevice] + ) -> None: + """Handle USB port events.""" + current_entries_by_path = { + entry.data[DEVICE]: entry + for entry in hass.config_entries.async_entries(DOMAIN) + } + + for device in added | removed: + path = device.device + entry = current_entries_by_path.get(path) + + if entry is not None: + _LOGGER.debug( + "Device %r has changed state, reloading config entry %s", + path, + entry, + ) + hass.config_entries.async_schedule_reload(entry.entry_id) + + async_register_port_event_callback(hass, async_port_event_callback) + + return True + async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up a Home Assistant SkyConnect config entry.""" + + # Postpone loading the config entry if the device is missing + device_path = entry.data[DEVICE] + if not await hass.async_add_executor_job(os.path.exists, device_path): + raise ConfigEntryNotReady + await hass.config_entries.async_forward_entry_setups(entry, ["update"]) + return True diff --git a/tests/components/homeassistant_sky_connect/conftest.py b/tests/components/homeassistant_sky_connect/conftest.py index c5bfa4bd609..89ec292d879 100644 --- a/tests/components/homeassistant_sky_connect/conftest.py +++ b/tests/components/homeassistant_sky_connect/conftest.py @@ -47,3 +47,13 @@ def mock_zha_get_last_network_settings() -> Generator[None]: AsyncMock(return_value=None), ): yield + + +@pytest.fixture(autouse=True) +def mock_usb_path_exists() -> Generator[None]: + """Mock os.path.exists to allow the ZBT-1 integration to load.""" + with patch( + "homeassistant.components.homeassistant_sky_connect.os.path.exists", + return_value=True, + ): + yield diff --git a/tests/components/homeassistant_sky_connect/test_init.py b/tests/components/homeassistant_sky_connect/test_init.py index c467a9e0d60..f38ac158e71 100644 --- a/tests/components/homeassistant_sky_connect/test_init.py +++ b/tests/components/homeassistant_sky_connect/test_init.py @@ -1,15 +1,28 @@ """Test the Home Assistant SkyConnect integration.""" +from datetime import timedelta from unittest.mock import patch +import pytest + from homeassistant.components.homeassistant_hardware.util import ( ApplicationType, FirmwareInfo, ) from homeassistant.components.homeassistant_sky_connect.const import DOMAIN +from homeassistant.components.usb import USBDevice +from homeassistant.config_entries import ConfigEntryState +from homeassistant.const import EVENT_HOMEASSISTANT_STARTED from homeassistant.core import HomeAssistant +from homeassistant.setup import async_setup_component +from homeassistant.util import dt as dt_util -from tests.common import MockConfigEntry +from tests.common import MockConfigEntry, async_fire_time_changed +from tests.components.usb import ( + async_request_scan, + force_usb_polling_watcher, # noqa: F401 + patch_scanned_serial_ports, +) async def test_config_entry_migration_v2(hass: HomeAssistant) -> None: @@ -58,3 +71,119 @@ async def test_config_entry_migration_v2(hass: HomeAssistant) -> None: } await hass.config_entries.async_unload(config_entry.entry_id) + + +async def test_setup_fails_on_missing_usb_port(hass: HomeAssistant) -> None: + """Test setup failing when the USB port is missing.""" + + config_entry = MockConfigEntry( + domain=DOMAIN, + unique_id="some_unique_id", + data={ + "description": "SkyConnect v1.0", + "device": "/dev/serial/by-id/usb-Nabu_Casa_SkyConnect_v1.0_9e2adbd75b8beb119fe564a0f320645d-if00-port0", + "vid": "10C4", + "pid": "EA60", + "serial_number": "3c0ed67c628beb11b1cd64a0f320645d", + "manufacturer": "Nabu Casa", + "product": "SkyConnect v1.0", + "firmware": "ezsp", + "firmware_version": "7.4.4.0", + }, + version=1, + minor_version=3, + ) + + config_entry.add_to_hass(hass) + + # Set up the config entry + with patch( + "homeassistant.components.homeassistant_sky_connect.os.path.exists" + ) as mock_exists: + mock_exists.return_value = False + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + # Failed to set up, the device is missing + assert config_entry.state is ConfigEntryState.SETUP_RETRY + + mock_exists.return_value = True + async_fire_time_changed(hass, dt_util.now() + timedelta(seconds=30)) + await hass.async_block_till_done(wait_background_tasks=True) + + # Now it's ready + assert config_entry.state is ConfigEntryState.LOADED + + +@pytest.mark.usefixtures("force_usb_polling_watcher") +async def test_usb_device_reactivity(hass: HomeAssistant) -> None: + """Test setting up USB monitoring.""" + assert await async_setup_component(hass, "usb", {"usb": {}}) + + await hass.async_block_till_done() + hass.bus.async_fire(EVENT_HOMEASSISTANT_STARTED) + await hass.async_block_till_done() + + config_entry = MockConfigEntry( + domain=DOMAIN, + unique_id="some_unique_id", + data={ + "description": "SkyConnect v1.0", + "device": "/dev/serial/by-id/usb-Nabu_Casa_SkyConnect_v1.0_9e2adbd75b8beb119fe564a0f320645d-if00-port0", + "vid": "10C4", + "pid": "EA60", + "serial_number": "3c0ed67c628beb11b1cd64a0f320645d", + "manufacturer": "Nabu Casa", + "product": "SkyConnect v1.0", + "firmware": "ezsp", + "firmware_version": "7.4.4.0", + }, + version=1, + minor_version=3, + ) + + config_entry.add_to_hass(hass) + + with patch( + "homeassistant.components.homeassistant_sky_connect.os.path.exists" + ) as mock_exists: + mock_exists.return_value = False + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + # Failed to set up, the device is missing + assert config_entry.state is ConfigEntryState.SETUP_RETRY + + # Now we make it available but do not wait + mock_exists.return_value = True + + with patch_scanned_serial_ports( + return_value=[ + USBDevice( + device="/dev/serial/by-id/usb-Nabu_Casa_SkyConnect_v1.0_9e2adbd75b8beb119fe564a0f320645d-if00-port0", + vid="10C4", + pid="EA60", + serial_number="3c0ed67c628beb11b1cd64a0f320645d", + manufacturer="Nabu Casa", + description="SkyConnect v1.0", + ) + ], + ): + await async_request_scan(hass) + + # It loads immediately + await hass.async_block_till_done(wait_background_tasks=True) + assert config_entry.state is ConfigEntryState.LOADED + + # Wait for a bit for the USB scan debouncer to cool off + async_fire_time_changed(hass, dt_util.now() + timedelta(minutes=5)) + + # Unplug the stick + mock_exists.return_value = False + + with patch_scanned_serial_ports(return_value=[]): + await async_request_scan(hass) + + # The integration has reloaded and is now in a failed state + await hass.async_block_till_done(wait_background_tasks=True) + assert config_entry.state is ConfigEntryState.SETUP_RETRY From 09e5fbb25839df2b2736c2200d695520fb7aa0d3 Mon Sep 17 00:00:00 2001 From: Bram Kragten Date: Mon, 31 Mar 2025 21:23:48 +0200 Subject: [PATCH 146/505] Update frontend to 20250331.0 (#141943) --- homeassistant/components/frontend/manifest.json | 2 +- homeassistant/package_constraints.txt | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/frontend/manifest.json b/homeassistant/components/frontend/manifest.json index 884436ad4db..ef974177947 100644 --- a/homeassistant/components/frontend/manifest.json +++ b/homeassistant/components/frontend/manifest.json @@ -20,5 +20,5 @@ "documentation": "https://www.home-assistant.io/integrations/frontend", "integration_type": "system", "quality_scale": "internal", - "requirements": ["home-assistant-frontend==20250328.0"] + "requirements": ["home-assistant-frontend==20250331.0"] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index b90371fdb67..9c1172aedc9 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -38,7 +38,7 @@ habluetooth==3.37.0 hass-nabucasa==0.94.0 hassil==2.2.3 home-assistant-bluetooth==1.13.1 -home-assistant-frontend==20250328.0 +home-assistant-frontend==20250331.0 home-assistant-intents==2025.3.28 httpx==0.28.1 ifaddr==0.2.0 diff --git a/requirements_all.txt b/requirements_all.txt index 25937c18b3d..cd273b8e3f0 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1157,7 +1157,7 @@ hole==0.8.0 holidays==0.69 # homeassistant.components.frontend -home-assistant-frontend==20250328.0 +home-assistant-frontend==20250331.0 # homeassistant.components.conversation home-assistant-intents==2025.3.28 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 49800dbc973..13a37c7e3a7 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -984,7 +984,7 @@ hole==0.8.0 holidays==0.69 # homeassistant.components.frontend -home-assistant-frontend==20250328.0 +home-assistant-frontend==20250331.0 # homeassistant.components.conversation home-assistant-intents==2025.3.28 From b3379e19213240d8e820eaa8f53339a69fa20e61 Mon Sep 17 00:00:00 2001 From: Steven Looman Date: Mon, 31 Mar 2025 21:35:21 +0200 Subject: [PATCH 147/505] Bump async-upnp-client to 0.44.0 (#141946) --- homeassistant/components/dlna_dmr/manifest.json | 2 +- homeassistant/components/dlna_dms/manifest.json | 2 +- homeassistant/components/samsungtv/manifest.json | 2 +- homeassistant/components/ssdp/manifest.json | 2 +- homeassistant/components/upnp/manifest.json | 2 +- homeassistant/components/yeelight/manifest.json | 2 +- homeassistant/package_constraints.txt | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 9 files changed, 9 insertions(+), 9 deletions(-) diff --git a/homeassistant/components/dlna_dmr/manifest.json b/homeassistant/components/dlna_dmr/manifest.json index 82541476a02..119d1d31d52 100644 --- a/homeassistant/components/dlna_dmr/manifest.json +++ b/homeassistant/components/dlna_dmr/manifest.json @@ -8,7 +8,7 @@ "documentation": "https://www.home-assistant.io/integrations/dlna_dmr", "iot_class": "local_push", "loggers": ["async_upnp_client"], - "requirements": ["async-upnp-client==0.43.0", "getmac==0.9.5"], + "requirements": ["async-upnp-client==0.44.0", "getmac==0.9.5"], "ssdp": [ { "deviceType": "urn:schemas-upnp-org:device:MediaRenderer:1", diff --git a/homeassistant/components/dlna_dms/manifest.json b/homeassistant/components/dlna_dms/manifest.json index 17fc3dc27e8..0289d5100d6 100644 --- a/homeassistant/components/dlna_dms/manifest.json +++ b/homeassistant/components/dlna_dms/manifest.json @@ -7,7 +7,7 @@ "dependencies": ["ssdp"], "documentation": "https://www.home-assistant.io/integrations/dlna_dms", "iot_class": "local_polling", - "requirements": ["async-upnp-client==0.43.0"], + "requirements": ["async-upnp-client==0.44.0"], "ssdp": [ { "deviceType": "urn:schemas-upnp-org:device:MediaServer:1", diff --git a/homeassistant/components/samsungtv/manifest.json b/homeassistant/components/samsungtv/manifest.json index 6a30efd64f8..5bb69e7f121 100644 --- a/homeassistant/components/samsungtv/manifest.json +++ b/homeassistant/components/samsungtv/manifest.json @@ -39,7 +39,7 @@ "samsungctl[websocket]==0.7.1", "samsungtvws[async,encrypted]==2.7.2", "wakeonlan==2.1.0", - "async-upnp-client==0.43.0" + "async-upnp-client==0.44.0" ], "ssdp": [ { diff --git a/homeassistant/components/ssdp/manifest.json b/homeassistant/components/ssdp/manifest.json index 6e1fba8c3a3..93943b0a9ea 100644 --- a/homeassistant/components/ssdp/manifest.json +++ b/homeassistant/components/ssdp/manifest.json @@ -8,5 +8,5 @@ "iot_class": "local_push", "loggers": ["async_upnp_client"], "quality_scale": "internal", - "requirements": ["async-upnp-client==0.43.0"] + "requirements": ["async-upnp-client==0.44.0"] } diff --git a/homeassistant/components/upnp/manifest.json b/homeassistant/components/upnp/manifest.json index df4daa8782c..62ee4ede7d9 100644 --- a/homeassistant/components/upnp/manifest.json +++ b/homeassistant/components/upnp/manifest.json @@ -8,7 +8,7 @@ "integration_type": "device", "iot_class": "local_polling", "loggers": ["async_upnp_client"], - "requirements": ["async-upnp-client==0.43.0", "getmac==0.9.5"], + "requirements": ["async-upnp-client==0.44.0", "getmac==0.9.5"], "ssdp": [ { "st": "urn:schemas-upnp-org:device:InternetGatewayDevice:1" diff --git a/homeassistant/components/yeelight/manifest.json b/homeassistant/components/yeelight/manifest.json index cf7bc9c9035..07970cb25ca 100644 --- a/homeassistant/components/yeelight/manifest.json +++ b/homeassistant/components/yeelight/manifest.json @@ -16,7 +16,7 @@ }, "iot_class": "local_push", "loggers": ["async_upnp_client", "yeelight"], - "requirements": ["yeelight==0.7.16", "async-upnp-client==0.43.0"], + "requirements": ["yeelight==0.7.16", "async-upnp-client==0.44.0"], "zeroconf": [ { "type": "_miio._udp.local.", diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 9c1172aedc9..23555c16a2a 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -13,7 +13,7 @@ aiozoneinfo==0.2.3 annotatedyaml==0.4.5 astral==2.2 async-interrupt==1.2.2 -async-upnp-client==0.43.0 +async-upnp-client==0.44.0 atomicwrites-homeassistant==1.4.1 attrs==25.1.0 audioop-lts==0.2.1 diff --git a/requirements_all.txt b/requirements_all.txt index cd273b8e3f0..794cda27366 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -514,7 +514,7 @@ asmog==0.0.6 # homeassistant.components.ssdp # homeassistant.components.upnp # homeassistant.components.yeelight -async-upnp-client==0.43.0 +async-upnp-client==0.44.0 # homeassistant.components.arve asyncarve==0.1.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 13a37c7e3a7..f65756040b0 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -478,7 +478,7 @@ arcam-fmj==1.8.1 # homeassistant.components.ssdp # homeassistant.components.upnp # homeassistant.components.yeelight -async-upnp-client==0.43.0 +async-upnp-client==0.44.0 # homeassistant.components.arve asyncarve==0.1.1 From 4a4458ec5bef7e94009865cef22b988ae1602c1a Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Mon, 31 Mar 2025 22:02:22 +0200 Subject: [PATCH 148/505] Replace "Open" with common state in `comelit` (#141949) --- homeassistant/components/comelit/strings.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/comelit/strings.json b/homeassistant/components/comelit/strings.json index 496d62655a9..a738c837d1b 100644 --- a/homeassistant/components/comelit/strings.json +++ b/homeassistant/components/comelit/strings.json @@ -42,9 +42,9 @@ "sensor": { "zone_status": { "state": { + "open": "[%key:common::state::open%]", "alarm": "Alarm", "armed": "Armed", - "open": "Open", "excluded": "Excluded", "faulty": "Faulty", "inhibited": "Inhibited", From 363b88407c1d4bcf23543ece497f4995c946a602 Mon Sep 17 00:00:00 2001 From: Ben Jones Date: Tue, 1 Apr 2025 10:16:22 +1300 Subject: [PATCH 149/505] Handle empty or missing state values for MQTT light entities using 'template' schema (#141177) * check for empty or missing values when processing state messages for MQTT light entities using 'template' schema * normalise warning logs * add tests (one is still failing and I can't work out why) * fix test * improve test coverage after PR review * improve test coverage after PR review --- .../components/mqtt/light/schema_template.py | 159 ++++++++++++------ tests/components/mqtt/test_light_template.py | 106 ++++++++++++ 2 files changed, 217 insertions(+), 48 deletions(-) diff --git a/homeassistant/components/mqtt/light/schema_template.py b/homeassistant/components/mqtt/light/schema_template.py index 595f072416b..f561f15fb51 100644 --- a/homeassistant/components/mqtt/light/schema_template.py +++ b/homeassistant/components/mqtt/light/schema_template.py @@ -62,6 +62,7 @@ from ..entity import MqttEntity from ..models import ( MqttCommandTemplate, MqttValueTemplate, + PayloadSentinel, PublishPayloadType, ReceiveMessage, ) @@ -126,7 +127,9 @@ class MqttLightTemplate(MqttEntity, LightEntity, RestoreEntity): _command_templates: dict[ str, Callable[[PublishPayloadType, TemplateVarsType], PublishPayloadType] ] - _value_templates: dict[str, Callable[[ReceivePayloadType], ReceivePayloadType]] + _value_templates: dict[ + str, Callable[[ReceivePayloadType, ReceivePayloadType], ReceivePayloadType] + ] _fixed_color_mode: ColorMode | str | None _topics: dict[str, str | None] @@ -203,73 +206,133 @@ class MqttLightTemplate(MqttEntity, LightEntity, RestoreEntity): @callback def _state_received(self, msg: ReceiveMessage) -> None: """Handle new MQTT messages.""" - state = self._value_templates[CONF_STATE_TEMPLATE](msg.payload) - if state == STATE_ON: + state_value = self._value_templates[CONF_STATE_TEMPLATE]( + msg.payload, + PayloadSentinel.NONE, + ) + if not state_value: + _LOGGER.debug( + "Ignoring message from '%s' with empty state value", msg.topic + ) + elif state_value == STATE_ON: self._attr_is_on = True - elif state == STATE_OFF: + elif state_value == STATE_OFF: self._attr_is_on = False - elif state == PAYLOAD_NONE: + elif state_value == PAYLOAD_NONE: self._attr_is_on = None else: - _LOGGER.warning("Invalid state value received") + _LOGGER.warning( + "Invalid state value '%s' received from %s", + state_value, + msg.topic, + ) if CONF_BRIGHTNESS_TEMPLATE in self._config: - try: - if brightness := int( - self._value_templates[CONF_BRIGHTNESS_TEMPLATE](msg.payload) - ): - self._attr_brightness = brightness - else: - _LOGGER.debug( - "Ignoring zero brightness value for entity %s", - self.entity_id, + brightness_value = self._value_templates[CONF_BRIGHTNESS_TEMPLATE]( + msg.payload, + PayloadSentinel.NONE, + ) + if not brightness_value: + _LOGGER.debug( + "Ignoring message from '%s' with empty brightness value", + msg.topic, + ) + else: + try: + if brightness := int(brightness_value): + self._attr_brightness = brightness + else: + _LOGGER.debug( + "Ignoring zero brightness value for entity %s", + self.entity_id, + ) + except ValueError: + _LOGGER.warning( + "Invalid brightness value '%s' received from %s", + brightness_value, + msg.topic, ) - except ValueError: - _LOGGER.warning("Invalid brightness value received from %s", msg.topic) - if CONF_COLOR_TEMP_TEMPLATE in self._config: - try: - color_temp = self._value_templates[CONF_COLOR_TEMP_TEMPLATE]( - msg.payload + color_temp_value = self._value_templates[CONF_COLOR_TEMP_TEMPLATE]( + msg.payload, + PayloadSentinel.NONE, + ) + if not color_temp_value: + _LOGGER.debug( + "Ignoring message from '%s' with empty color temperature value", + msg.topic, ) - self._attr_color_temp_kelvin = ( - int(color_temp) - if self._color_temp_kelvin - else color_util.color_temperature_mired_to_kelvin(int(color_temp)) - if color_temp != "None" - else None - ) - except ValueError: - _LOGGER.warning("Invalid color temperature value received") + else: + try: + self._attr_color_temp_kelvin = ( + int(color_temp_value) + if self._color_temp_kelvin + else color_util.color_temperature_mired_to_kelvin( + int(color_temp_value) + ) + if color_temp_value != "None" + else None + ) + except ValueError: + _LOGGER.warning( + "Invalid color temperature value '%s' received from %s", + color_temp_value, + msg.topic, + ) if ( CONF_RED_TEMPLATE in self._config and CONF_GREEN_TEMPLATE in self._config and CONF_BLUE_TEMPLATE in self._config ): - try: - red = self._value_templates[CONF_RED_TEMPLATE](msg.payload) - green = self._value_templates[CONF_GREEN_TEMPLATE](msg.payload) - blue = self._value_templates[CONF_BLUE_TEMPLATE](msg.payload) - if red == "None" and green == "None" and blue == "None": - self._attr_hs_color = None - else: - self._attr_hs_color = color_util.color_RGB_to_hs( - int(red), int(green), int(blue) - ) + red_value = self._value_templates[CONF_RED_TEMPLATE]( + msg.payload, + PayloadSentinel.NONE, + ) + green_value = self._value_templates[CONF_GREEN_TEMPLATE]( + msg.payload, + PayloadSentinel.NONE, + ) + blue_value = self._value_templates[CONF_BLUE_TEMPLATE]( + msg.payload, + PayloadSentinel.NONE, + ) + if not red_value or not green_value or not blue_value: + _LOGGER.debug( + "Ignoring message from '%s' with empty color value", msg.topic + ) + elif red_value == "None" and green_value == "None" and blue_value == "None": + self._attr_hs_color = None self._update_color_mode() - except ValueError: - _LOGGER.warning("Invalid color value received") + else: + try: + self._attr_hs_color = color_util.color_RGB_to_hs( + int(red_value), int(green_value), int(blue_value) + ) + self._update_color_mode() + except ValueError: + _LOGGER.warning("Invalid color value received from %s", msg.topic) if CONF_EFFECT_TEMPLATE in self._config: - effect = str(self._value_templates[CONF_EFFECT_TEMPLATE](msg.payload)) - if ( - effect_list := self._config[CONF_EFFECT_LIST] - ) and effect in effect_list: - self._attr_effect = effect + effect_value = self._value_templates[CONF_EFFECT_TEMPLATE]( + msg.payload, + PayloadSentinel.NONE, + ) + if not effect_value: + _LOGGER.debug( + "Ignoring message from '%s' with empty effect value", msg.topic + ) + elif (effect_list := self._config[CONF_EFFECT_LIST]) and str( + effect_value + ) in effect_list: + self._attr_effect = str(effect_value) else: - _LOGGER.warning("Unsupported effect value received") + _LOGGER.warning( + "Unsupported effect value '%s' received from %s", + effect_value, + msg.topic, + ) @callback def _prepare_subscribe_topics(self) -> None: diff --git a/tests/components/mqtt/test_light_template.py b/tests/components/mqtt/test_light_template.py index b3a1c11c2b6..e2cc801e97d 100644 --- a/tests/components/mqtt/test_light_template.py +++ b/tests/components/mqtt/test_light_template.py @@ -1545,3 +1545,109 @@ async def test_rgb_value_template_fails( "TypeError: unsupported operand type(s) for *: 'NoneType' and 'int' rendering template" in caplog.text ) + + +@pytest.mark.parametrize( + "hass_config", + [ + help_custom_config( + light.DOMAIN, + DEFAULT_CONFIG, + ( + { + "effect_list": ["rainbow", "colorloop"], + "state_topic": "test-topic", + "state_template": "{{ value_json.state }}", + "brightness_template": "{{ value_json.brightness }}", + "color_temp_template": "{{ value_json.color_temp }}", + "red_template": "{{ value_json.color.red }}", + "green_template": "{{ value_json.color.green }}", + "blue_template": "{{ value_json.color.blue }}", + "effect_template": "{{ value_json.effect }}", + }, + ), + ) + ], +) +async def test_state_templates_ignore_missing_values( + hass: HomeAssistant, + mqtt_mock_entry: MqttMockHAClientGenerator, +) -> None: + """Test that rendering of MQTT value template ignores missing values.""" + await mqtt_mock_entry() + + # turn on the light + async_fire_mqtt_message(hass, "test-topic", '{"state": "on"}') + state = hass.states.get("light.test") + assert state.state == STATE_ON + assert state.attributes.get("rgb_color") is None + assert state.attributes.get("brightness") is None + assert state.attributes.get("color_temp_kelvin") is None + assert state.attributes.get("effect") is None + + # update brightness and color temperature (with no state) + async_fire_mqtt_message( + hass, "test-topic", '{"brightness": 255, "color_temp": 145}' + ) + state = hass.states.get("light.test") + assert state.state == STATE_ON + assert state.attributes.get("rgb_color") == ( + 246, + 244, + 255, + ) # temp converted to color + assert state.attributes.get("brightness") == 255 + assert state.attributes.get("color_temp_kelvin") == 6896 + assert state.attributes.get("effect") is None + assert state.attributes.get("xy_color") == (0.317, 0.317) # temp converted to color + assert state.attributes.get("hs_color") == ( + 251.249, + 4.253, + ) # temp converted to color + + # update color + async_fire_mqtt_message( + hass, "test-topic", '{"color": {"red": 255, "green": 128, "blue": 64}}' + ) + state = hass.states.get("light.test") + assert state.state == STATE_ON + assert state.attributes.get("rgb_color") == (255, 128, 64) + assert state.attributes.get("brightness") == 255 + assert state.attributes.get("color_temp_kelvin") is None # rgb color has priority + assert state.attributes.get("effect") is None + + # update brightness + async_fire_mqtt_message(hass, "test-topic", '{"brightness": 128}') + state = hass.states.get("light.test") + assert state.state == STATE_ON + assert state.attributes.get("rgb_color") == (255, 128, 64) + assert state.attributes.get("brightness") == 128 + assert state.attributes.get("color_temp_kelvin") is None # rgb color has priority + assert state.attributes.get("effect") is None + + # update effect + async_fire_mqtt_message(hass, "test-topic", '{"effect": "rainbow"}') + state = hass.states.get("light.test") + assert state.state == STATE_ON + assert state.attributes.get("rgb_color") == (255, 128, 64) + assert state.attributes.get("brightness") == 128 + assert state.attributes.get("color_temp_kelvin") is None # rgb color has priority + assert state.attributes.get("effect") == "rainbow" + + # invalid effect + async_fire_mqtt_message(hass, "test-topic", '{"effect": "invalid"}') + state = hass.states.get("light.test") + assert state.state == STATE_ON + assert state.attributes.get("rgb_color") == (255, 128, 64) + assert state.attributes.get("brightness") == 128 + assert state.attributes.get("color_temp_kelvin") is None # rgb color has priority + assert state.attributes.get("effect") == "rainbow" + + # turn off the light + async_fire_mqtt_message(hass, "test-topic", '{"state": "off"}') + state = hass.states.get("light.test") + assert state.state == STATE_OFF + assert state.attributes.get("rgb_color") is None + assert state.attributes.get("brightness") is None + assert state.attributes.get("color_temp_kelvin") is None + assert state.attributes.get("effect") is None From 0abaaa0a06d79a6af3fafb36879d512868d6f28f Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 31 Mar 2025 11:23:02 -1000 Subject: [PATCH 150/505] Bump pydantic to 2.11.1 (#141951) --- homeassistant/package_constraints.txt | 2 +- requirements_test.txt | 2 +- script/gen_requirements_all.py | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 23555c16a2a..846ade07eaa 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -129,7 +129,7 @@ multidict>=6.0.2 backoff>=2.0 # ensure pydantic version does not float since it might have breaking changes -pydantic==2.10.6 +pydantic==2.11.1 # Required for Python 3.12.4 compatibility (#119223). mashumaro>=3.13.1 diff --git a/requirements_test.txt b/requirements_test.txt index c7bb9b11b87..f7b04f0a6bd 100644 --- a/requirements_test.txt +++ b/requirements_test.txt @@ -14,7 +14,7 @@ license-expression==30.4.1 mock-open==1.4.0 mypy-dev==1.16.0a7 pre-commit==4.0.0 -pydantic==2.10.6 +pydantic==2.11.1 pylint==3.3.6 pylint-per-file-ignores==1.4.0 pipdeptree==2.25.1 diff --git a/script/gen_requirements_all.py b/script/gen_requirements_all.py index 8cbb423966d..11698f01e45 100755 --- a/script/gen_requirements_all.py +++ b/script/gen_requirements_all.py @@ -159,7 +159,7 @@ multidict>=6.0.2 backoff>=2.0 # ensure pydantic version does not float since it might have breaking changes -pydantic==2.10.6 +pydantic==2.11.1 # Required for Python 3.12.4 compatibility (#119223). mashumaro>=3.13.1 From a09213bce87eefb64ebb5b90e0bcfed988c4d1a3 Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Mon, 31 Mar 2025 23:23:25 +0200 Subject: [PATCH 151/505] Replace "Start" and "Disable" with common actions in `hassio` (#141953) --- homeassistant/components/hassio/strings.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/hassio/strings.json b/homeassistant/components/hassio/strings.json index a543dbc7f89..cc7cfdd5f2c 100644 --- a/homeassistant/components/hassio/strings.json +++ b/homeassistant/components/hassio/strings.json @@ -24,8 +24,8 @@ "fix_menu": { "description": "Add-on {addon} is set to start at boot but failed to start. Usually this occurs when the configuration is incorrect or the same port is used in multiple add-ons. Check the configuration as well as logs for {addon} and Supervisor.\n\nUse Start to try again or Disable to turn off the start at boot option.", "menu_options": { - "addon_execute_start": "Start", - "addon_disable_boot": "Disable" + "addon_execute_start": "[%key:common::action::start%]", + "addon_disable_boot": "[%key:common::action::disable%]" } } }, From a722912e05c64564e5d95d66a796f3490c496a71 Mon Sep 17 00:00:00 2001 From: Manu <4445816+tr4nt0r@users.noreply.github.com> Date: Tue, 1 Apr 2025 05:43:24 +0200 Subject: [PATCH 152/505] Add translations for flash options in `light.turn_on` action (#141950) --- homeassistant/components/light/services.yaml | 7 +++---- homeassistant/components/light/strings.json | 6 ++++++ 2 files changed, 9 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/light/services.yaml b/homeassistant/components/light/services.yaml index 2a1fbd11afd..c59d9e22483 100644 --- a/homeassistant/components/light/services.yaml +++ b/homeassistant/components/light/services.yaml @@ -293,11 +293,10 @@ turn_on: - light.LightEntityFeature.FLASH selector: select: + translation_key: flash options: - - label: "Long" - value: "long" - - label: "Short" - value: "short" + - long + - short turn_off: target: diff --git a/homeassistant/components/light/strings.json b/homeassistant/components/light/strings.json index 4a3b98ded46..d4b709f65aa 100644 --- a/homeassistant/components/light/strings.json +++ b/homeassistant/components/light/strings.json @@ -283,6 +283,12 @@ "yellow": "Yellow", "yellowgreen": "Yellow green" } + }, + "flash": { + "options": { + "short": "Short", + "long": "Long" + } } }, "services": { From aa7694e81c68d01ef35b00edc8dce262eb688ab6 Mon Sep 17 00:00:00 2001 From: Steven Stallion Date: Tue, 1 Apr 2025 00:29:09 -0500 Subject: [PATCH 153/505] Bump sensorpush-api to 2.1.2 (#141965) --- homeassistant/components/sensorpush_cloud/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/sensorpush_cloud/manifest.json b/homeassistant/components/sensorpush_cloud/manifest.json index ad817251fa1..6fd6513ad2d 100644 --- a/homeassistant/components/sensorpush_cloud/manifest.json +++ b/homeassistant/components/sensorpush_cloud/manifest.json @@ -7,5 +7,5 @@ "iot_class": "cloud_polling", "loggers": ["sensorpush_api", "sensorpush_ha"], "quality_scale": "bronze", - "requirements": ["sensorpush-api==2.1.1", "sensorpush-ha==1.3.2"] + "requirements": ["sensorpush-api==2.1.2", "sensorpush-ha==1.3.2"] } diff --git a/requirements_all.txt b/requirements_all.txt index 794cda27366..e8d1e5ea77f 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2712,7 +2712,7 @@ sensirion-ble==0.1.1 sensorpro-ble==0.5.3 # homeassistant.components.sensorpush_cloud -sensorpush-api==2.1.1 +sensorpush-api==2.1.2 # homeassistant.components.sensorpush sensorpush-ble==1.7.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index f65756040b0..77a70929022 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2189,7 +2189,7 @@ sensirion-ble==0.1.1 sensorpro-ble==0.5.3 # homeassistant.components.sensorpush_cloud -sensorpush-api==2.1.1 +sensorpush-api==2.1.2 # homeassistant.components.sensorpush sensorpush-ble==1.7.1 From def50b255d7fb8a96f330a4ba362d302d5a2f30e Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 31 Mar 2025 20:31:25 -1000 Subject: [PATCH 154/505] Bump aiohttp to 3.11.15 (#141967) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit changelog: https://github.com/aio-libs/aiohttp/compare/v3.11.14...v3.11.15 fixes #141855 fixes #141146 --- homeassistant/package_constraints.txt | 2 +- pyproject.toml | 2 +- requirements.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 846ade07eaa..3465b24fb2a 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -6,7 +6,7 @@ aiodns==3.2.0 aiohasupervisor==0.3.0 aiohttp-asyncmdnsresolver==0.1.1 aiohttp-fast-zlib==0.2.3 -aiohttp==3.11.14 +aiohttp==3.11.15 aiohttp_cors==0.7.0 aiousbwatcher==1.1.1 aiozoneinfo==0.2.3 diff --git a/pyproject.toml b/pyproject.toml index 8900eab74be..b90738bdbef 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -28,7 +28,7 @@ dependencies = [ # change behavior based on presence of supervisor. Deprecated with #127228 # Lib can be removed with 2025.11 "aiohasupervisor==0.3.0", - "aiohttp==3.11.14", + "aiohttp==3.11.15", "aiohttp_cors==0.7.0", "aiohttp-fast-zlib==0.2.3", "aiohttp-asyncmdnsresolver==0.1.1", diff --git a/requirements.txt b/requirements.txt index 736736e8f20..bd3722b3617 100644 --- a/requirements.txt +++ b/requirements.txt @@ -5,7 +5,7 @@ # Home Assistant Core aiodns==3.2.0 aiohasupervisor==0.3.0 -aiohttp==3.11.14 +aiohttp==3.11.15 aiohttp_cors==0.7.0 aiohttp-fast-zlib==0.2.3 aiohttp-asyncmdnsresolver==0.1.1 From 9c3b9eee2a12ab6ef6f6569da6dacff0d77af3a9 Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Tue, 1 Apr 2025 08:52:31 +0200 Subject: [PATCH 155/505] Replace "a entity" with "an entity" in `isy994` user strings (#141972) --- homeassistant/components/isy994/strings.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/isy994/strings.json b/homeassistant/components/isy994/strings.json index 8872226daba..6594c030f08 100644 --- a/homeassistant/components/isy994/strings.json +++ b/homeassistant/components/isy994/strings.json @@ -90,7 +90,7 @@ }, "get_zwave_parameter": { "name": "Get Z-Wave Parameter", - "description": "Requests a Z-Wave device parameter via the ISY. The parameter value will be returned as a entity extra state attribute with the name \"ZW_#\" where \"#\" is the parameter number.", + "description": "Requests a Z-Wave device parameter via the ISY. The parameter value will be returned as an entity extra state attribute with the name \"ZW_#\" where \"#\" is the parameter number.", "fields": { "parameter": { "name": "Parameter", @@ -100,7 +100,7 @@ }, "set_zwave_parameter": { "name": "Set Z-Wave parameter", - "description": "Updates a Z-Wave device parameter via the ISY. The parameter value will also be returned as a entity extra state attribute with the name \"ZW_#\" where \"#\" is the parameter number.", + "description": "Updates a Z-Wave device parameter via the ISY. The parameter value will also be returned as an entity extra state attribute with the name \"ZW_#\" where \"#\" is the parameter number.", "fields": { "parameter": { "name": "[%key:component::isy994::services::get_zwave_parameter::fields::parameter::name%]", From 28c38e92d4c38cdd513a0ac0e53655ad20a32502 Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Tue, 1 Apr 2025 09:28:41 +0200 Subject: [PATCH 156/505] Fix typo "certificartes" in `fully_kiosk` (#141979) --- homeassistant/components/fully_kiosk/strings.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/fully_kiosk/strings.json b/homeassistant/components/fully_kiosk/strings.json index 5841456c034..fdfdf7910ae 100644 --- a/homeassistant/components/fully_kiosk/strings.json +++ b/homeassistant/components/fully_kiosk/strings.json @@ -2,7 +2,7 @@ "common": { "data_description_password": "The Remote Admin password from the Fully Kiosk Browser app settings.", "data_description_ssl": "Is the Fully Kiosk app configured to require SSL for the connection?", - "data_description_verify_ssl": "Should SSL certificartes be verified? This should be off for self-signed certificates." + "data_description_verify_ssl": "Should SSL certificates be verified? This should be off for self-signed certificates." }, "config": { "step": { From 3155c1cd4fe677a2baead4769d3d3d0f835d7aff Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Tue, 1 Apr 2025 11:01:13 +0200 Subject: [PATCH 157/505] Add tests for renault QuotaLimitException (#141985) --- tests/components/renault/conftest.py | 176 +++++++++--------------- tests/components/renault/test_sensor.py | 82 ++++++++++- 2 files changed, 144 insertions(+), 114 deletions(-) diff --git a/tests/components/renault/conftest.py b/tests/components/renault/conftest.py index 9be41eb7ba0..dd3c4896264 100644 --- a/tests/components/renault/conftest.py +++ b/tests/components/renault/conftest.py @@ -1,9 +1,8 @@ """Provide common Renault fixtures.""" -from collections.abc import Generator, Iterator +from collections.abc import AsyncGenerator, Generator import contextlib from types import MappingProxyType -from typing import Any from unittest.mock import AsyncMock, patch import pytest @@ -51,7 +50,7 @@ def get_config_entry(hass: HomeAssistant) -> ConfigEntry: @pytest.fixture(name="patch_renault_account") -async def patch_renault_account(hass: HomeAssistant) -> RenaultAccount: +async def patch_renault_account(hass: HomeAssistant) -> AsyncGenerator[RenaultAccount]: """Create a Renault account.""" renault_account = RenaultAccount( MOCK_ACCOUNT_ID, @@ -68,7 +67,7 @@ async def patch_renault_account(hass: HomeAssistant) -> RenaultAccount: @pytest.fixture(name="patch_get_vehicles") -def patch_get_vehicles(vehicle_type: str): +def patch_get_vehicles(vehicle_type: str) -> Generator[None]: """Mock fixtures.""" with patch( "renault_api.renault_account.RenaultAccount.get_vehicles", @@ -123,149 +122,100 @@ def _get_fixtures(vehicle_type: str) -> MappingProxyType: } +@contextlib.contextmanager +def patch_get_vehicle_data() -> Generator[dict[str, AsyncMock]]: + """Mock get_vehicle_data methods.""" + with ( + patch( + "renault_api.renault_vehicle.RenaultVehicle.get_battery_status" + ) as get_battery_status, + patch( + "renault_api.renault_vehicle.RenaultVehicle.get_charge_mode" + ) as get_charge_mode, + patch("renault_api.renault_vehicle.RenaultVehicle.get_cockpit") as get_cockpit, + patch( + "renault_api.renault_vehicle.RenaultVehicle.get_hvac_status" + ) as get_hvac_status, + patch( + "renault_api.renault_vehicle.RenaultVehicle.get_location" + ) as get_location, + patch( + "renault_api.renault_vehicle.RenaultVehicle.get_lock_status" + ) as get_lock_status, + patch( + "renault_api.renault_vehicle.RenaultVehicle.get_res_state" + ) as get_res_state, + ): + yield { + "battery_status": get_battery_status, + "charge_mode": get_charge_mode, + "cockpit": get_cockpit, + "hvac_status": get_hvac_status, + "location": get_location, + "lock_status": get_lock_status, + "res_state": get_res_state, + } + + @pytest.fixture(name="fixtures_with_data") -def patch_fixtures_with_data(vehicle_type: str): +def patch_fixtures_with_data(vehicle_type: str) -> Generator[dict[str, AsyncMock]]: """Mock fixtures.""" mock_fixtures = _get_fixtures(vehicle_type) - with ( - patch( - "renault_api.renault_vehicle.RenaultVehicle.get_battery_status", - return_value=mock_fixtures["battery_status"], - ), - patch( - "renault_api.renault_vehicle.RenaultVehicle.get_charge_mode", - return_value=mock_fixtures["charge_mode"], - ), - patch( - "renault_api.renault_vehicle.RenaultVehicle.get_cockpit", - return_value=mock_fixtures["cockpit"], - ), - patch( - "renault_api.renault_vehicle.RenaultVehicle.get_hvac_status", - return_value=mock_fixtures["hvac_status"], - ), - patch( - "renault_api.renault_vehicle.RenaultVehicle.get_location", - return_value=mock_fixtures["location"], - ), - patch( - "renault_api.renault_vehicle.RenaultVehicle.get_lock_status", - return_value=mock_fixtures["lock_status"], - ), - patch( - "renault_api.renault_vehicle.RenaultVehicle.get_res_state", - return_value=mock_fixtures["res_state"], - ), - ): - yield + with patch_get_vehicle_data() as patches: + for key, value in patches.items(): + value.return_value = mock_fixtures[key] + yield patches @pytest.fixture(name="fixtures_with_no_data") -def patch_fixtures_with_no_data(): +def patch_fixtures_with_no_data() -> Generator[dict[str, AsyncMock]]: """Mock fixtures.""" mock_fixtures = _get_fixtures("") - with ( - patch( - "renault_api.renault_vehicle.RenaultVehicle.get_battery_status", - return_value=mock_fixtures["battery_status"], - ), - patch( - "renault_api.renault_vehicle.RenaultVehicle.get_charge_mode", - return_value=mock_fixtures["charge_mode"], - ), - patch( - "renault_api.renault_vehicle.RenaultVehicle.get_cockpit", - return_value=mock_fixtures["cockpit"], - ), - patch( - "renault_api.renault_vehicle.RenaultVehicle.get_hvac_status", - return_value=mock_fixtures["hvac_status"], - ), - patch( - "renault_api.renault_vehicle.RenaultVehicle.get_location", - return_value=mock_fixtures["location"], - ), - patch( - "renault_api.renault_vehicle.RenaultVehicle.get_lock_status", - return_value=mock_fixtures["lock_status"], - ), - patch( - "renault_api.renault_vehicle.RenaultVehicle.get_res_state", - return_value=mock_fixtures["res_state"], - ), - ): - yield - - -@contextlib.contextmanager -def _patch_fixtures_with_side_effect(side_effect: Any) -> Iterator[None]: - """Mock fixtures.""" - with ( - patch( - "renault_api.renault_vehicle.RenaultVehicle.get_battery_status", - side_effect=side_effect, - ), - patch( - "renault_api.renault_vehicle.RenaultVehicle.get_charge_mode", - side_effect=side_effect, - ), - patch( - "renault_api.renault_vehicle.RenaultVehicle.get_cockpit", - side_effect=side_effect, - ), - patch( - "renault_api.renault_vehicle.RenaultVehicle.get_hvac_status", - side_effect=side_effect, - ), - patch( - "renault_api.renault_vehicle.RenaultVehicle.get_location", - side_effect=side_effect, - ), - patch( - "renault_api.renault_vehicle.RenaultVehicle.get_lock_status", - side_effect=side_effect, - ), - patch( - "renault_api.renault_vehicle.RenaultVehicle.get_res_state", - side_effect=side_effect, - ), - ): - yield + with patch_get_vehicle_data() as patches: + for key, value in patches.items(): + value.return_value = mock_fixtures[key] + yield patches @pytest.fixture(name="fixtures_with_access_denied_exception") -def patch_fixtures_with_access_denied_exception(): +def patch_fixtures_with_access_denied_exception() -> Generator[dict[str, AsyncMock]]: """Mock fixtures.""" access_denied_exception = exceptions.AccessDeniedException( "err.func.403", "Access is denied for this resource", ) - with _patch_fixtures_with_side_effect(access_denied_exception): - yield + with patch_get_vehicle_data() as patches: + for value in patches.values(): + value.side_effect = access_denied_exception + yield patches @pytest.fixture(name="fixtures_with_invalid_upstream_exception") -def patch_fixtures_with_invalid_upstream_exception(): +def patch_fixtures_with_invalid_upstream_exception() -> Generator[dict[str, AsyncMock]]: """Mock fixtures.""" invalid_upstream_exception = exceptions.InvalidUpstreamException( "err.tech.500", "Invalid response from the upstream server (The request sent to the GDC is erroneous) ; 502 Bad Gateway", ) - with _patch_fixtures_with_side_effect(invalid_upstream_exception): - yield + with patch_get_vehicle_data() as patches: + for value in patches.values(): + value.side_effect = invalid_upstream_exception + yield patches @pytest.fixture(name="fixtures_with_not_supported_exception") -def patch_fixtures_with_not_supported_exception(): +def patch_fixtures_with_not_supported_exception() -> Generator[dict[str, AsyncMock]]: """Mock fixtures.""" not_supported_exception = exceptions.NotSupportedException( "err.tech.501", "This feature is not technically supported by this gateway", ) - with _patch_fixtures_with_side_effect(not_supported_exception): - yield + with patch_get_vehicle_data() as patches: + for value in patches.values(): + value.side_effect = not_supported_exception + yield patches diff --git a/tests/components/renault/test_sensor.py b/tests/components/renault/test_sensor.py index d69ab5c0b7f..fb5fc205a7b 100644 --- a/tests/components/renault/test_sensor.py +++ b/tests/components/renault/test_sensor.py @@ -1,19 +1,25 @@ """Tests for Renault sensors.""" from collections.abc import Generator +import datetime from unittest.mock import patch +from freezegun.api import FrozenDateTimeFactory import pytest +from renault_api.kamereon.exceptions import QuotaLimitException from syrupy.assertion import SnapshotAssertion from homeassistant.config_entries import ConfigEntry -from homeassistant.const import Platform +from homeassistant.const import STATE_UNAVAILABLE, Platform from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr, entity_registry as er from . import check_device_registry, check_entities_unavailable +from .conftest import _get_fixtures, patch_get_vehicle_data from .const import MOCK_VEHICLES +from tests.common import async_fire_time_changed + pytestmark = pytest.mark.usefixtures("patch_renault_account", "patch_get_vehicles") @@ -150,3 +156,77 @@ async def test_sensor_not_supported( check_device_registry(device_registry, mock_vehicle["expected_device"]) assert len(entity_registry.entities) == 0 + + +@pytest.mark.parametrize("vehicle_type", ["zoe_40"], indirect=True) +async def test_sensor_throttling_during_setup( + hass: HomeAssistant, + config_entry: ConfigEntry, + vehicle_type: str, + freezer: FrozenDateTimeFactory, +) -> None: + """Test for Renault sensors with a throttling error during setup.""" + mock_fixtures = _get_fixtures(vehicle_type) + with patch_get_vehicle_data() as patches: + for key, get_data_mock in patches.items(): + get_data_mock.return_value = mock_fixtures[key] + get_data_mock.side_effect = QuotaLimitException( + "err.func.wired.overloaded", "You have reached your quota limit" + ) + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + # Initial state + entity_id = "sensor.reg_number_battery" + assert hass.states.get(entity_id).state == STATE_UNAVAILABLE + + # Test QuotaLimitException recovery, with new battery level + for get_data_mock in patches.values(): + get_data_mock.side_effect = None + patches["battery_status"].return_value.batteryLevel = 55 + freezer.tick(datetime.timedelta(minutes=10)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + assert hass.states.get(entity_id).state == "55" + + +@pytest.mark.parametrize("vehicle_type", ["zoe_40"], indirect=True) +async def test_sensor_throttling_after_init( + hass: HomeAssistant, + config_entry: ConfigEntry, + vehicle_type: str, + freezer: FrozenDateTimeFactory, +) -> None: + """Test for Renault sensors with a throttling error during setup.""" + mock_fixtures = _get_fixtures(vehicle_type) + with patch_get_vehicle_data() as patches: + for key, get_data_mock in patches.items(): + get_data_mock.return_value = mock_fixtures[key] + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + # Initial state + entity_id = "sensor.reg_number_battery" + assert hass.states.get(entity_id).state == "60" + + # Test QuotaLimitException state + for get_data_mock in patches.values(): + get_data_mock.side_effect = QuotaLimitException( + "err.func.wired.overloaded", "You have reached your quota limit" + ) + freezer.tick(datetime.timedelta(minutes=10)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + assert hass.states.get(entity_id).state == STATE_UNAVAILABLE + + # Test QuotaLimitException recovery, with new battery level + for get_data_mock in patches.values(): + get_data_mock.side_effect = None + patches["battery_status"].return_value.batteryLevel = 55 + freezer.tick(datetime.timedelta(minutes=10)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + assert hass.states.get(entity_id).state == "55" From 7a9836064d6615d8542c7bb8882bf591445e984b Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Tue, 1 Apr 2025 11:14:41 +0200 Subject: [PATCH 158/505] Replace "A entity" with "An entity" in `modbus` (#141973) * Replace "A entity" with "An entity" in `modbus` * Fix wrong commas --- homeassistant/components/modbus/strings.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/modbus/strings.json b/homeassistant/components/modbus/strings.json index 347549dc837..7d1578558b0 100644 --- a/homeassistant/components/modbus/strings.json +++ b/homeassistant/components/modbus/strings.json @@ -88,11 +88,11 @@ }, "duplicate_entity_entry": { "title": "Modbus {sub_1} address {sub_2} is duplicate, second entry not loaded.", - "description": "An address can only be associated with one entity, Please correct the entry in your configuration.yaml file and restart Home Assistant to fix this issue." + "description": "An address can only be associated with one entity. Please correct the entry in your configuration.yaml file and restart Home Assistant to fix this issue." }, "duplicate_entity_name": { "title": "Modbus {sub_1} is duplicate, second entry not loaded.", - "description": "A entity name must be unique, Please correct the entry in your configuration.yaml file and restart Home Assistant to fix this issue." + "description": "An entity name must be unique. Please correct the entry in your configuration.yaml file and restart Home Assistant to fix this issue." }, "no_entities": { "title": "Modbus {sub_1} contain no entities, entry not loaded.", From cbcd1929dd8e593e9fc647096aa5afae8df391e3 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Tue, 1 Apr 2025 05:37:59 -0400 Subject: [PATCH 159/505] Move Z-Wave JS smoke, CO, CO2, Heat, Water problem entities to diagnostic (#129922) * Move Z-Wave JS smoke, CO, CO2, Heat, Water problem entities to diagnostic * Update link + states * Specify problem class explicitly instead of catch-all * Heat alarm test is not a problem * Also split out smoke alarm * Document mapping rule * add tests * format * update test * review comments * remove idle state from doc as it is ignored --------- Co-authored-by: Petar Petrov --- .../components/zwave_js/binary_sensor.py | 99 +- tests/components/zwave_js/conftest.py | 20 + .../fixtures/zcombo_smoke_co_alarm_state.json | 854 ++++++++++++++++++ .../components/zwave_js/test_binary_sensor.py | 138 +++ 4 files changed, 1101 insertions(+), 10 deletions(-) create mode 100644 tests/components/zwave_js/fixtures/zcombo_smoke_co_alarm_state.json diff --git a/homeassistant/components/zwave_js/binary_sensor.py b/homeassistant/components/zwave_js/binary_sensor.py index d07846c8dcc..1439aa0ca0f 100644 --- a/homeassistant/components/zwave_js/binary_sensor.py +++ b/homeassistant/components/zwave_js/binary_sensor.py @@ -67,7 +67,45 @@ class PropertyZWaveJSEntityDescription(BinarySensorEntityDescription): # Mappings for Notification sensors -# https://github.com/zwave-js/node-zwave-js/blob/master/packages/config/config/notifications.json +# https://github.com/zwave-js/specs/blob/master/Registries/Notification%20Command%20Class%2C%20list%20of%20assigned%20Notifications.xlsx +# +# Mapping rules: +# The catch all description should not have a device class and be marked as diagnostic. +# +# The following notifications have been moved to diagnostic: +# Smoke Alarm +# - Alarm silenced +# - Replacement required +# - Replacement required, End-of-life +# - Maintenance required, planned periodic inspection +# - Maintenance required, dust in device +# CO Alarm +# - Carbon monoxide test +# - Replacement required +# - Replacement required, End-of-life +# - Alarm silenced +# - Maintenance required, planned periodic inspection +# CO2 Alarm +# - Carbon dioxide test +# - Replacement required +# - Replacement required, End-of-life +# - Alarm silenced +# - Maintenance required, planned periodic inspection +# Heat Alarm +# - Rapid temperature rise (location provided) +# - Rapid temperature rise +# - Rapid temperature fall (location provided) +# - Rapid temperature fall +# - Heat alarm test +# - Alarm silenced +# - Replacement required, End-of-life +# - Maintenance required, dust in device +# - Maintenance required, planned periodic inspection + +# Water Alarm +# - Replace water filter +# - Sump pump failure + NOTIFICATION_SENSOR_MAPPINGS: tuple[NotificationZWaveJSEntityDescription, ...] = ( NotificationZWaveJSEntityDescription( # NotificationType 1: Smoke Alarm - State Id's 1 and 2 - Smoke detected @@ -75,10 +113,17 @@ NOTIFICATION_SENSOR_MAPPINGS: tuple[NotificationZWaveJSEntityDescription, ...] = states=("1", "2"), device_class=BinarySensorDeviceClass.SMOKE, ), + NotificationZWaveJSEntityDescription( + # NotificationType 1: Smoke Alarm - State Id's 4, 5, 7, 8 + key=NOTIFICATION_SMOKE_ALARM, + states=("4", "5", "7", "8"), + device_class=BinarySensorDeviceClass.PROBLEM, + entity_category=EntityCategory.DIAGNOSTIC, + ), NotificationZWaveJSEntityDescription( # NotificationType 1: Smoke Alarm - All other State Id's key=NOTIFICATION_SMOKE_ALARM, - device_class=BinarySensorDeviceClass.PROBLEM, + entity_category=EntityCategory.DIAGNOSTIC, ), NotificationZWaveJSEntityDescription( # NotificationType 2: Carbon Monoxide - State Id's 1 and 2 @@ -86,10 +131,17 @@ NOTIFICATION_SENSOR_MAPPINGS: tuple[NotificationZWaveJSEntityDescription, ...] = states=("1", "2"), device_class=BinarySensorDeviceClass.CO, ), + NotificationZWaveJSEntityDescription( + # NotificationType 2: Carbon Monoxide - State Id 4, 5, 7 + key=NOTIFICATION_CARBON_MONOOXIDE, + states=("4", "5", "7"), + device_class=BinarySensorDeviceClass.PROBLEM, + entity_category=EntityCategory.DIAGNOSTIC, + ), NotificationZWaveJSEntityDescription( # NotificationType 2: Carbon Monoxide - All other State Id's key=NOTIFICATION_CARBON_MONOOXIDE, - device_class=BinarySensorDeviceClass.PROBLEM, + entity_category=EntityCategory.DIAGNOSTIC, ), NotificationZWaveJSEntityDescription( # NotificationType 3: Carbon Dioxide - State Id's 1 and 2 @@ -97,10 +149,17 @@ NOTIFICATION_SENSOR_MAPPINGS: tuple[NotificationZWaveJSEntityDescription, ...] = states=("1", "2"), device_class=BinarySensorDeviceClass.GAS, ), + NotificationZWaveJSEntityDescription( + # NotificationType 3: Carbon Dioxide - State Id's 4, 5, 7 + key=NOTIFICATION_CARBON_DIOXIDE, + states=("4", "5", "7"), + device_class=BinarySensorDeviceClass.PROBLEM, + entity_category=EntityCategory.DIAGNOSTIC, + ), NotificationZWaveJSEntityDescription( # NotificationType 3: Carbon Dioxide - All other State Id's key=NOTIFICATION_CARBON_DIOXIDE, - device_class=BinarySensorDeviceClass.PROBLEM, + entity_category=EntityCategory.DIAGNOSTIC, ), NotificationZWaveJSEntityDescription( # NotificationType 4: Heat - State Id's 1, 2, 5, 6 (heat/underheat) @@ -109,20 +168,34 @@ NOTIFICATION_SENSOR_MAPPINGS: tuple[NotificationZWaveJSEntityDescription, ...] = device_class=BinarySensorDeviceClass.HEAT, ), NotificationZWaveJSEntityDescription( - # NotificationType 4: Heat - All other State Id's + # NotificationType 4: Heat - State ID's 8, A, B key=NOTIFICATION_HEAT, + states=("8", "10", "11"), device_class=BinarySensorDeviceClass.PROBLEM, + entity_category=EntityCategory.DIAGNOSTIC, ), NotificationZWaveJSEntityDescription( - # NotificationType 5: Water - State Id's 1, 2, 3, 4 + # NotificationType 4: Heat - All other State Id's + key=NOTIFICATION_HEAT, + entity_category=EntityCategory.DIAGNOSTIC, + ), + NotificationZWaveJSEntityDescription( + # NotificationType 5: Water - State Id's 1, 2, 3, 4, 6, 7, 8, 9, 0A key=NOTIFICATION_WATER, - states=("1", "2", "3", "4"), + states=("1", "2", "3", "4", "6", "7", "8", "9", "10"), device_class=BinarySensorDeviceClass.MOISTURE, ), + NotificationZWaveJSEntityDescription( + # NotificationType 5: Water - State Id's B + key=NOTIFICATION_WATER, + states=("11",), + device_class=BinarySensorDeviceClass.PROBLEM, + entity_category=EntityCategory.DIAGNOSTIC, + ), NotificationZWaveJSEntityDescription( # NotificationType 5: Water - All other State Id's key=NOTIFICATION_WATER, - device_class=BinarySensorDeviceClass.PROBLEM, + entity_category=EntityCategory.DIAGNOSTIC, ), NotificationZWaveJSEntityDescription( # NotificationType 6: Access Control - State Id's 1, 2, 3, 4 (Lock) @@ -214,16 +287,22 @@ NOTIFICATION_SENSOR_MAPPINGS: tuple[NotificationZWaveJSEntityDescription, ...] = device_class=BinarySensorDeviceClass.SOUND, ), NotificationZWaveJSEntityDescription( - # NotificationType 18: Gas + # NotificationType 18: Gas - State Id's 1, 2, 3, 4 key=NOTIFICATION_GAS, states=("1", "2", "3", "4"), device_class=BinarySensorDeviceClass.GAS, ), NotificationZWaveJSEntityDescription( - # NotificationType 18: Gas + # NotificationType 18: Gas - State Id 6 key=NOTIFICATION_GAS, states=("6",), device_class=BinarySensorDeviceClass.PROBLEM, + entity_category=EntityCategory.DIAGNOSTIC, + ), + NotificationZWaveJSEntityDescription( + # NotificationType 18: Gas - All other State Id's + key=NOTIFICATION_GAS, + entity_category=EntityCategory.DIAGNOSTIC, ), ) diff --git a/tests/components/zwave_js/conftest.py b/tests/components/zwave_js/conftest.py index ce7b0e0109e..e4e757ad363 100644 --- a/tests/components/zwave_js/conftest.py +++ b/tests/components/zwave_js/conftest.py @@ -509,6 +509,15 @@ def aeotec_smart_switch_7_state_fixture() -> NodeDataType: ) +@pytest.fixture(name="zcombo_smoke_co_alarm_state") +def zcombo_smoke_co_alarm_state_fixture() -> NodeDataType: + """Load node with fixture data for ZCombo-G Smoke/CO Alarm.""" + return cast( + NodeDataType, + load_json_object_fixture("zcombo_smoke_co_alarm_state.json", DOMAIN), + ) + + # model fixtures @@ -554,6 +563,7 @@ def mock_client_fixture( client.connect = AsyncMock(side_effect=connect) client.listen = AsyncMock(side_effect=listen) client.disconnect = AsyncMock(side_effect=disconnect) + client.disable_server_logging = MagicMock() client.driver = Driver( client, copy.deepcopy(controller_state), copy.deepcopy(log_config_state) ) @@ -1252,3 +1262,13 @@ def aeotec_smart_switch_7_fixture( node = Node(client, aeotec_smart_switch_7_state) client.driver.controller.nodes[node.node_id] = node return node + + +@pytest.fixture(name="zcombo_smoke_co_alarm") +def zcombo_smoke_co_alarm_fixture( + client: MagicMock, zcombo_smoke_co_alarm_state: NodeDataType +) -> Node: + """Load node for ZCombo-G Smoke/CO Alarm.""" + node = Node(client, zcombo_smoke_co_alarm_state) + client.driver.controller.nodes[node.node_id] = node + return node diff --git a/tests/components/zwave_js/fixtures/zcombo_smoke_co_alarm_state.json b/tests/components/zwave_js/fixtures/zcombo_smoke_co_alarm_state.json new file mode 100644 index 00000000000..c7417859f1c --- /dev/null +++ b/tests/components/zwave_js/fixtures/zcombo_smoke_co_alarm_state.json @@ -0,0 +1,854 @@ +{ + "nodeId": 3, + "index": 0, + "installerIcon": 3073, + "userIcon": 3073, + "status": 1, + "ready": true, + "isListening": false, + "isRouting": true, + "isSecure": true, + "manufacturerId": 312, + "productId": 3, + "productType": 1, + "firmwareVersion": "11.0.0", + "zwavePlusVersion": 1, + "deviceConfig": { + "filename": "/data/db/devices/0x0138/zcombo-g.json", + "isEmbedded": true, + "manufacturer": "First Alert (BRK Brands Inc)", + "manufacturerId": 312, + "label": "ZCOMBO", + "description": "ZCombo-G Smoke/CO Alarm", + "devices": [ + { + "productType": 1, + "productId": 3 + } + ], + "firmwareVersion": { + "min": "0.0", + "max": "255.255" + }, + "preferred": false, + "associations": {}, + "paramInformation": { + "_map": {} + }, + "metadata": { + "wakeup": "WAKEUP\n1. Slide battery door open and then closed with the batteries inserted.", + "inclusion": "ADD\n1. Slide battery door open.\n2. Insert batteries checking the correct orientation.\n3. Press and hold the test button. Keep it held down as you slide the battery drawer closed. You may then release the button.\nNOTE: Use only your finger or thumb on the test button. The use of any other instrument is strictly prohibited", + "exclusion": "REMOVE\n1. Slide battery door open.\n2. Remove and re-insert batteries checking the correct orientation.\n3. Press and hold the test button. Keep it held down as you slide the battery drawer closed. You may then release the button.\nNOTE: Use only your finger or thumb on the test button. The use of any other instrument is strictly prohibited", + "reset": "RESET DEVICE\nIf the device is powered up with the test button held down for 10+ seconds, the device will reset all Z-Wave settings and leave the network.\nUpon completion of the Reset operation, the LED will glow and the horn will sound for ~1 second.\nPlease use this procedure only when the network primary controller is missing or otherwise inoperable", + "manual": "https://products.z-wavealliance.org/ProductManual/File?folder=&filename=product_documents/3886/User_Manual_M08-0456-173833_D2.pdf" + } + }, + "label": "ZCOMBO", + "interviewAttempts": 0, + "isFrequentListening": false, + "maxDataRate": 100000, + "supportedDataRates": [40000, 100000], + "protocolVersion": 3, + "supportsBeaming": true, + "supportsSecurity": false, + "nodeType": 1, + "zwavePlusNodeType": 0, + "zwavePlusRoleType": 6, + "deviceClass": { + "basic": { + "key": 4, + "label": "Routing End Node" + }, + "generic": { + "key": 7, + "label": "Notification Sensor" + }, + "specific": { + "key": 1, + "label": "Notification Sensor" + } + }, + "interviewStage": "Complete", + "deviceDatabaseUrl": "https://devices.zwave-js.io/?jumpTo=0x0138:0x0001:0x0003:11.0.0", + "statistics": { + "commandsTX": 1, + "commandsRX": 4, + "commandsDroppedRX": 1, + "commandsDroppedTX": 0, + "timeoutResponse": 0, + "lwr": { + "protocolDataRate": 3, + "repeaters": [], + "rssi": -79, + "repeaterRSSI": [] + }, + "lastSeen": "2024-11-11T21:36:45.802Z", + "rtt": 28.9, + "rssi": -79 + }, + "highestSecurityClass": 1, + "isControllerNode": false, + "keepAwake": false, + "lastSeen": "2024-11-11T19:17:39.916Z", + "protocol": 0, + "values": [ + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 1, + "propertyName": "Supervision Report Timeout", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "ZCOMBO will send the message over Supervision Command Class and it will wait for the Supervision report from the Controller for the Supervision report timeout time.", + "label": "Supervision Report Timeout", + "default": 1500, + "min": 500, + "max": 5000, + "unit": "ms", + "valueSize": 2, + "format": 0, + "allowManualEntry": true, + "isFromConfig": true + }, + "value": 1500 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 2, + "propertyName": "Supervision Retry Count", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "If the Supervision report is not received within the Supervision report timeout time, the ZCOMBO will retry sending the message again. Upon exceeding the max retry, the ZCOMBO device will send the next message available in the queue.", + "label": "Supervision Retry Count", + "default": 1, + "min": 0, + "max": 5, + "valueSize": 2, + "format": 0, + "allowManualEntry": true, + "isFromConfig": true + }, + "value": 1 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 3, + "propertyName": "Supervision Wait Time", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "Before retrying the message, ZCOMBO will wait for the Supervision wait time. Actual wait time is calculated using the formula: Wait Time = Supervision wait time base-value + random-value + (attempt-count x 5 seconds). The random value will be between 100 and 1100 milliseconds.", + "label": "Supervision Wait Time", + "default": 5, + "min": 1, + "max": 60, + "unit": "seconds", + "valueSize": 2, + "format": 0, + "allowManualEntry": true, + "isFromConfig": true + }, + "value": 5 + }, + { + "endpoint": 0, + "commandClass": 113, + "commandClassName": "Notification", + "property": "Smoke Alarm", + "propertyKey": "Sensor status", + "propertyName": "Smoke Alarm", + "propertyKeyName": "Sensor status", + "ccVersion": 8, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "label": "Sensor status", + "ccSpecific": { + "notificationType": 1 + }, + "min": 0, + "max": 255, + "states": { + "0": "idle", + "2": "Smoke detected" + }, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 113, + "commandClassName": "Notification", + "property": "Smoke Alarm", + "propertyKey": "Alarm status", + "propertyName": "Smoke Alarm", + "propertyKeyName": "Alarm status", + "ccVersion": 8, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "label": "Alarm status", + "ccSpecific": { + "notificationType": 1 + }, + "min": 0, + "max": 255, + "states": { + "0": "idle", + "3": "Smoke alarm test", + "6": "Alarm silenced" + }, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 113, + "commandClassName": "Notification", + "property": "CO Alarm", + "propertyKey": "Sensor status", + "propertyName": "CO Alarm", + "propertyKeyName": "Sensor status", + "ccVersion": 8, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "label": "Sensor status", + "ccSpecific": { + "notificationType": 2 + }, + "min": 0, + "max": 255, + "states": { + "0": "idle", + "2": "Carbon monoxide detected" + }, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 113, + "commandClassName": "Notification", + "property": "CO Alarm", + "propertyKey": "Maintenance status", + "propertyName": "CO Alarm", + "propertyKeyName": "Maintenance status", + "ccVersion": 8, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "label": "Maintenance status", + "ccSpecific": { + "notificationType": 2 + }, + "min": 0, + "max": 255, + "states": { + "0": "idle", + "5": "Replacement required, End-of-life" + }, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 113, + "commandClassName": "Notification", + "property": "CO Alarm", + "propertyKey": "Alarm status", + "propertyName": "CO Alarm", + "propertyKeyName": "Alarm status", + "ccVersion": 8, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "label": "Alarm status", + "ccSpecific": { + "notificationType": 2 + }, + "min": 0, + "max": 255, + "states": { + "0": "idle", + "6": "Alarm silenced" + }, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 113, + "commandClassName": "Notification", + "property": "System", + "propertyKey": "Hardware status", + "propertyName": "System", + "propertyKeyName": "Hardware status", + "ccVersion": 8, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "label": "Hardware status", + "ccSpecific": { + "notificationType": 9 + }, + "min": 0, + "max": 255, + "states": { + "0": "idle", + "1": "System hardware failure" + }, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 113, + "commandClassName": "Notification", + "property": "alarmType", + "propertyName": "alarmType", + "ccVersion": 8, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "label": "Alarm Type", + "min": 0, + "max": 255, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 113, + "commandClassName": "Notification", + "property": "alarmLevel", + "propertyName": "alarmLevel", + "ccVersion": 8, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "label": "Alarm Level", + "min": 0, + "max": 255, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 114, + "commandClassName": "Manufacturer Specific", + "property": "manufacturerId", + "propertyName": "manufacturerId", + "ccVersion": 2, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "label": "Manufacturer ID", + "min": 0, + "max": 65535, + "stateful": true, + "secret": false + }, + "value": 312 + }, + { + "endpoint": 0, + "commandClass": 114, + "commandClassName": "Manufacturer Specific", + "property": "productType", + "propertyName": "productType", + "ccVersion": 2, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "label": "Product type", + "min": 0, + "max": 65535, + "stateful": true, + "secret": false + }, + "value": 1 + }, + { + "endpoint": 0, + "commandClass": 114, + "commandClassName": "Manufacturer Specific", + "property": "productId", + "propertyName": "productId", + "ccVersion": 2, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "label": "Product ID", + "min": 0, + "max": 65535, + "stateful": true, + "secret": false + }, + "value": 3 + }, + { + "endpoint": 0, + "commandClass": 128, + "commandClassName": "Battery", + "property": "level", + "propertyName": "level", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "label": "Battery level", + "min": 0, + "max": 100, + "unit": "%", + "stateful": true, + "secret": false + }, + "value": 92 + }, + { + "endpoint": 0, + "commandClass": 128, + "commandClassName": "Battery", + "property": "isLow", + "propertyName": "isLow", + "ccVersion": 1, + "metadata": { + "type": "boolean", + "readable": true, + "writeable": false, + "label": "Low battery level", + "stateful": true, + "secret": false + }, + "value": false + }, + { + "endpoint": 0, + "commandClass": 132, + "commandClassName": "Wake Up", + "property": "wakeUpInterval", + "propertyName": "wakeUpInterval", + "ccVersion": 2, + "metadata": { + "type": "number", + "default": 4200, + "readable": false, + "writeable": true, + "min": 4200, + "max": 4200, + "steps": 0, + "stateful": true, + "secret": false + }, + "value": 4200 + }, + { + "endpoint": 0, + "commandClass": 132, + "commandClassName": "Wake Up", + "property": "controllerNodeId", + "propertyName": "controllerNodeId", + "ccVersion": 2, + "metadata": { + "type": "any", + "readable": true, + "writeable": false, + "label": "Node ID of the controller", + "stateful": true, + "secret": false + }, + "value": 1 + }, + { + "endpoint": 0, + "commandClass": 134, + "commandClassName": "Version", + "property": "libraryType", + "propertyName": "libraryType", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "label": "Library type", + "states": { + "0": "Unknown", + "1": "Static Controller", + "2": "Controller", + "3": "Enhanced Slave", + "4": "Slave", + "5": "Installer", + "6": "Routing Slave", + "7": "Bridge Controller", + "8": "Device under Test", + "9": "N/A", + "10": "AV Remote", + "11": "AV Device" + }, + "stateful": true, + "secret": false + }, + "value": 6 + }, + { + "endpoint": 0, + "commandClass": 134, + "commandClassName": "Version", + "property": "protocolVersion", + "propertyName": "protocolVersion", + "ccVersion": 3, + "metadata": { + "type": "string", + "readable": true, + "writeable": false, + "label": "Z-Wave protocol version", + "stateful": true, + "secret": false + }, + "value": "6.7" + }, + { + "endpoint": 0, + "commandClass": 134, + "commandClassName": "Version", + "property": "firmwareVersions", + "propertyName": "firmwareVersions", + "ccVersion": 3, + "metadata": { + "type": "string[]", + "readable": true, + "writeable": false, + "label": "Z-Wave chip firmware versions", + "stateful": true, + "secret": false + }, + "value": ["11.0", "7.0"] + }, + { + "endpoint": 0, + "commandClass": 134, + "commandClassName": "Version", + "property": "hardwareVersion", + "propertyName": "hardwareVersion", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "label": "Z-Wave chip hardware version", + "stateful": true, + "secret": false + }, + "value": 2 + }, + { + "endpoint": 0, + "commandClass": 134, + "commandClassName": "Version", + "property": "sdkVersion", + "propertyName": "sdkVersion", + "ccVersion": 3, + "metadata": { + "type": "string", + "readable": true, + "writeable": false, + "label": "SDK version", + "stateful": true, + "secret": false + }, + "value": "6.81.6" + }, + { + "endpoint": 0, + "commandClass": 134, + "commandClassName": "Version", + "property": "applicationFrameworkAPIVersion", + "propertyName": "applicationFrameworkAPIVersion", + "ccVersion": 3, + "metadata": { + "type": "string", + "readable": true, + "writeable": false, + "label": "Z-Wave application framework API version", + "stateful": true, + "secret": false + }, + "value": "4.3.0" + }, + { + "endpoint": 0, + "commandClass": 134, + "commandClassName": "Version", + "property": "applicationFrameworkBuildNumber", + "propertyName": "applicationFrameworkBuildNumber", + "ccVersion": 3, + "metadata": { + "type": "string", + "readable": true, + "writeable": false, + "label": "Z-Wave application framework API build number", + "stateful": true, + "secret": false + }, + "value": 52445 + }, + { + "endpoint": 0, + "commandClass": 134, + "commandClassName": "Version", + "property": "hostInterfaceVersion", + "propertyName": "hostInterfaceVersion", + "ccVersion": 3, + "metadata": { + "type": "string", + "readable": true, + "writeable": false, + "label": "Serial API version", + "stateful": true, + "secret": false + }, + "value": "unused" + }, + { + "endpoint": 0, + "commandClass": 134, + "commandClassName": "Version", + "property": "hostInterfaceBuildNumber", + "propertyName": "hostInterfaceBuildNumber", + "ccVersion": 3, + "metadata": { + "type": "string", + "readable": true, + "writeable": false, + "label": "Serial API build number", + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 134, + "commandClassName": "Version", + "property": "zWaveProtocolVersion", + "propertyName": "zWaveProtocolVersion", + "ccVersion": 3, + "metadata": { + "type": "string", + "readable": true, + "writeable": false, + "label": "Z-Wave protocol version", + "stateful": true, + "secret": false + }, + "value": "6.7.0" + }, + { + "endpoint": 0, + "commandClass": 134, + "commandClassName": "Version", + "property": "zWaveProtocolBuildNumber", + "propertyName": "zWaveProtocolBuildNumber", + "ccVersion": 3, + "metadata": { + "type": "string", + "readable": true, + "writeable": false, + "label": "Z-Wave protocol build number", + "stateful": true, + "secret": false + }, + "value": 97 + }, + { + "endpoint": 0, + "commandClass": 134, + "commandClassName": "Version", + "property": "applicationVersion", + "propertyName": "applicationVersion", + "ccVersion": 3, + "metadata": { + "type": "string", + "readable": true, + "writeable": false, + "label": "Application version", + "stateful": true, + "secret": false + }, + "value": "11.0.0" + }, + { + "endpoint": 0, + "commandClass": 134, + "commandClassName": "Version", + "property": "applicationBuildNumber", + "propertyName": "applicationBuildNumber", + "ccVersion": 3, + "metadata": { + "type": "string", + "readable": true, + "writeable": false, + "label": "Application build number", + "stateful": true, + "secret": false + }, + "value": 0 + } + ], + "endpoints": [ + { + "nodeId": 3, + "index": 0, + "installerIcon": 3073, + "userIcon": 3073, + "deviceClass": { + "basic": { + "key": 4, + "label": "Routing End Node" + }, + "generic": { + "key": 7, + "label": "Notification Sensor" + }, + "specific": { + "key": 1, + "label": "Notification Sensor" + } + }, + "commandClasses": [ + { + "id": 94, + "name": "Z-Wave Plus Info", + "version": 2, + "isSecure": false + }, + { + "id": 133, + "name": "Association", + "version": 2, + "isSecure": true + }, + { + "id": 142, + "name": "Multi Channel Association", + "version": 3, + "isSecure": true + }, + { + "id": 89, + "name": "Association Group Information", + "version": 1, + "isSecure": true + }, + { + "id": 85, + "name": "Transport Service", + "version": 2, + "isSecure": false + }, + { + "id": 134, + "name": "Version", + "version": 3, + "isSecure": true + }, + { + "id": 114, + "name": "Manufacturer Specific", + "version": 2, + "isSecure": true + }, + { + "id": 90, + "name": "Device Reset Locally", + "version": 1, + "isSecure": true + }, + { + "id": 115, + "name": "Powerlevel", + "version": 1, + "isSecure": true + }, + { + "id": 128, + "name": "Battery", + "version": 1, + "isSecure": true + }, + { + "id": 159, + "name": "Security 2", + "version": 1, + "isSecure": true + }, + { + "id": 113, + "name": "Notification", + "version": 8, + "isSecure": true + }, + { + "id": 132, + "name": "Wake Up", + "version": 2, + "isSecure": true + }, + { + "id": 112, + "name": "Configuration", + "version": 1, + "isSecure": true + }, + { + "id": 108, + "name": "Supervision", + "version": 1, + "isSecure": false + } + ] + } + ] +} diff --git a/tests/components/zwave_js/test_binary_sensor.py b/tests/components/zwave_js/test_binary_sensor.py index 657dd337bf9..93ac52f9041 100644 --- a/tests/components/zwave_js/test_binary_sensor.py +++ b/tests/components/zwave_js/test_binary_sensor.py @@ -293,3 +293,141 @@ async def test_config_parameter_binary_sensor( state = hass.states.get(binary_sensor_entity_id) assert state assert state.state == STATE_OFF + + +async def test_smoke_co_notification_sensors( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + zcombo_smoke_co_alarm: Node, + integration: MockConfigEntry, +) -> None: + """Test smoke and CO notification sensors with diagnostic states.""" + # Test smoke alarm sensor + smoke_sensor = "binary_sensor.zcombo_g_smoke_co_alarm_smoke_detected" + state = hass.states.get(smoke_sensor) + assert state + assert state.state == STATE_OFF + assert state.attributes[ATTR_DEVICE_CLASS] == BinarySensorDeviceClass.SMOKE + entity_entry = entity_registry.async_get(smoke_sensor) + assert entity_entry + assert entity_entry.entity_category != EntityCategory.DIAGNOSTIC + + # Test smoke alarm diagnostic sensor + smoke_diagnostic = "binary_sensor.zcombo_g_smoke_co_alarm_smoke_alarm_test" + state = hass.states.get(smoke_diagnostic) + assert state + assert state.state == STATE_OFF + entity_entry = entity_registry.async_get(smoke_diagnostic) + assert entity_entry + assert entity_entry.entity_category == EntityCategory.DIAGNOSTIC + + # Test CO alarm sensor + co_sensor = "binary_sensor.zcombo_g_smoke_co_alarm_carbon_monoxide_detected" + state = hass.states.get(co_sensor) + assert state + assert state.state == STATE_OFF + assert state.attributes[ATTR_DEVICE_CLASS] == BinarySensorDeviceClass.CO + entity_entry = entity_registry.async_get(co_sensor) + assert entity_entry + assert entity_entry.entity_category != EntityCategory.DIAGNOSTIC + + # Test diagnostic entities + entity_ids = [ + "binary_sensor.zcombo_g_smoke_co_alarm_smoke_alarm_test", + "binary_sensor.zcombo_g_smoke_co_alarm_alarm_silenced", + "binary_sensor.zcombo_g_smoke_co_alarm_replacement_required_end_of_life", + "binary_sensor.zcombo_g_smoke_co_alarm_alarm_silenced_2", + "binary_sensor.zcombo_g_smoke_co_alarm_system_hardware_failure", + "binary_sensor.zcombo_g_smoke_co_alarm_low_battery_level", + ] + for entity_id in entity_ids: + entity_entry = entity_registry.async_get(entity_id) + assert entity_entry + assert entity_entry.entity_category == EntityCategory.DIAGNOSTIC + + # Test state updates for smoke alarm + event = Event( + type="value updated", + data={ + "source": "node", + "event": "value updated", + "nodeId": 3, + "args": { + "commandClassName": "Notification", + "commandClass": 113, + "endpoint": 0, + "property": "Smoke Alarm", + "propertyKey": "Sensor status", + "newValue": 2, + "prevValue": 0, + "propertyName": "Smoke Alarm", + "propertyKeyName": "Sensor status", + }, + }, + ) + zcombo_smoke_co_alarm.receive_event(event) + await hass.async_block_till_done() # Wait for state change to be processed + # Get a fresh state after the sleep + state = hass.states.get(smoke_sensor) + assert state is not None, "Smoke sensor state should not be None" + assert state.state == STATE_ON, ( + f"Expected smoke sensor state to be 'on', got '{state.state}'" + ) + + # Test state updates for CO alarm + event = Event( + type="value updated", + data={ + "source": "node", + "event": "value updated", + "nodeId": 3, + "args": { + "commandClassName": "Notification", + "commandClass": 113, + "endpoint": 0, + "property": "CO Alarm", + "propertyKey": "Sensor status", + "newValue": 2, + "prevValue": 0, + "propertyName": "CO Alarm", + "propertyKeyName": "Sensor status", + }, + }, + ) + zcombo_smoke_co_alarm.receive_event(event) + await hass.async_block_till_done() # Wait for state change to be processed + # Get a fresh state after the sleep + state = hass.states.get(co_sensor) + assert state is not None, "CO sensor state should not be None" + assert state.state == STATE_ON, ( + f"Expected CO sensor state to be 'on', got '{state.state}'" + ) + + # Test diagnostic state updates for smoke alarm + event = Event( + type="value updated", + data={ + "source": "node", + "event": "value updated", + "nodeId": 3, + "args": { + "commandClassName": "Notification", + "commandClass": 113, + "endpoint": 0, + "property": "Smoke Alarm", + "propertyKey": "Alarm status", + "newValue": 3, + "prevValue": 0, + "propertyName": "Smoke Alarm", + "propertyKeyName": "Alarm status", + }, + }, + ) + zcombo_smoke_co_alarm.receive_event(event) + await hass.async_block_till_done() # Wait for state change to be processed + # Get a fresh state after the sleep + state = hass.states.get(smoke_diagnostic) + assert state is not None, "Smoke diagnostic state should not be None" + assert state.state == STATE_ON, ( + f"Expected smoke diagnostic state to be 'on', got '{state.state}'" + ) From c15169635748b83d29718ccc4879f8f7f5060d3a Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Tue, 1 Apr 2025 11:38:48 +0200 Subject: [PATCH 160/505] Fix spelling in Reolink user-facing strings (#141971) Fix spelling in `reolink` user-facing string - replace three occurrences of "a" with proper "an" - replace "infra red" with "infrared" --- homeassistant/components/reolink/strings.json | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/reolink/strings.json b/homeassistant/components/reolink/strings.json index a884b3ed431..8bfea1c6910 100644 --- a/homeassistant/components/reolink/strings.json +++ b/homeassistant/components/reolink/strings.json @@ -15,7 +15,7 @@ "data_description": { "host": "The hostname or IP address of your Reolink device. For example: '192.168.1.25'.", "port": "The HTTP(s) port to connect to the Reolink device API. For HTTP normally: '80', for HTTPS normally '443'.", - "use_https": "Use a HTTPS (SSL) connection to the Reolink device.", + "use_https": "Use an HTTPS (SSL) connection to the Reolink device.", "baichuan_port": "The 'Basic Service Port' to connect to the Reolink device over TCP. Normally '9000' unless manually changed in the Reolink desktop client.", "username": "Username to login to the Reolink device itself. Not the Reolink cloud account.", "password": "Password to login to the Reolink device itself. Not the Reolink cloud account." @@ -66,7 +66,7 @@ "message": "Invalid input parameter: {err}" }, "api_error": { - "message": "The device responded with a error: {err}" + "message": "The device responded with an error: {err}" }, "invalid_content_type": { "message": "Received a different content type than expected: {err}" @@ -130,7 +130,7 @@ }, "firmware_update": { "title": "Reolink firmware update required", - "description": "\"{name}\" with model \"{model}\" and hardware version \"{hw_version}\" is running a old firmware version \"{current_firmware}\", while at least firmware version \"{required_firmware}\" is required for proper operation of the Reolink integration. The firmware can be updated by pressing \"install\" in the more info dialog of the update entity of \"{name}\" from within Home Assistant. Alternatively, the latest firmware can be downloaded from the [Reolink download center]({download_link})." + "description": "\"{name}\" with model \"{model}\" and hardware version \"{hw_version}\" is running an old firmware version \"{current_firmware}\", while at least firmware version \"{required_firmware}\" is required for proper operation of the Reolink integration. The firmware can be updated by pressing \"install\" in the more info dialog of the update entity of \"{name}\" from within Home Assistant. Alternatively, the latest firmware can be downloaded from the [Reolink download center]({download_link})." }, "hub_switch_deprecated": { "title": "Reolink Home Hub switches deprecated", @@ -893,7 +893,7 @@ }, "switch": { "ir_lights": { - "name": "Infra red lights in night mode" + "name": "Infrared lights in night mode" }, "record_audio": { "name": "Record audio" From 145e02769c7d58c9838178e4448a748c2e616b56 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Tue, 1 Apr 2025 12:54:24 +0200 Subject: [PATCH 161/505] Remove redundant type hint from core_config.py (#141989) --- homeassistant/core_config.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/homeassistant/core_config.py b/homeassistant/core_config.py index f080705fced..9cd232097a7 100644 --- a/homeassistant/core_config.py +++ b/homeassistant/core_config.py @@ -581,9 +581,7 @@ class Config: self.all_components: set[str] = set() # Set of loaded components - self.components: _ComponentSet = _ComponentSet( - self.top_level_components, self.all_components - ) + self.components = _ComponentSet(self.top_level_components, self.all_components) # API (HTTP) server configuration self.api: ApiConfig | None = None From fa9613a879482158300eeaa372e96cd2a619dbf1 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Tue, 1 Apr 2025 14:24:15 +0200 Subject: [PATCH 162/505] Unconditionally import turbojpeg from camera (#141995) --- homeassistant/components/camera/img_util.py | 9 +-------- 1 file changed, 1 insertion(+), 8 deletions(-) diff --git a/homeassistant/components/camera/img_util.py b/homeassistant/components/camera/img_util.py index bbe85bf82db..971e6804add 100644 --- a/homeassistant/components/camera/img_util.py +++ b/homeassistant/components/camera/img_util.py @@ -2,17 +2,10 @@ from __future__ import annotations -from contextlib import suppress import logging from typing import TYPE_CHECKING, Literal, cast -with suppress(Exception): - # TurboJPEG imports numpy which may or may not work so - # we have to guard the import here. We still want - # to import it at top level so it gets loaded - # in the import executor and not in the event loop. - from turbojpeg import TurboJPEG - +from turbojpeg import TurboJPEG if TYPE_CHECKING: from . import Image From 2427b77363fa3119d5441546b3505b2436d133f8 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Tue, 1 Apr 2025 14:31:49 +0200 Subject: [PATCH 163/505] Use send_json_auto_id in websocket_api tests (#141994) --- .../components/websocket_api/test_commands.py | 338 +++++++----------- 1 file changed, 123 insertions(+), 215 deletions(-) diff --git a/tests/components/websocket_api/test_commands.py b/tests/components/websocket_api/test_commands.py index c0114cde42b..f03673048c0 100644 --- a/tests/components/websocket_api/test_commands.py +++ b/tests/components/websocket_api/test_commands.py @@ -106,9 +106,8 @@ async def test_fire_event( hass.bus.async_listen_once("event_type_test", event_handler) - await websocket_client.send_json( + await websocket_client.send_json_auto_id( { - "id": 5, "type": "fire_event", "event_type": "event_type_test", "event_data": {"hello": "world"}, @@ -116,7 +115,6 @@ async def test_fire_event( ) msg = await websocket_client.receive_json() - assert msg["id"] == 5 assert msg["type"] == const.TYPE_RESULT assert msg["success"] @@ -137,16 +135,14 @@ async def test_fire_event_without_data( hass.bus.async_listen_once("event_type_test", event_handler) - await websocket_client.send_json( + await websocket_client.send_json_auto_id( { - "id": 5, "type": "fire_event", "event_type": "event_type_test", } ) msg = await websocket_client.receive_json() - assert msg["id"] == 5 assert msg["type"] == const.TYPE_RESULT assert msg["success"] @@ -162,9 +158,8 @@ async def test_call_service( """Test call service command.""" calls = async_mock_service(hass, "domain_test", "test_service") - await websocket_client.send_json( + await websocket_client.send_json_auto_id( { - "id": 5, "type": "call_service", "domain": "domain_test", "service": "test_service", @@ -173,7 +168,6 @@ async def test_call_service( ) msg = await websocket_client.receive_json() - assert msg["id"] == 5 assert msg["type"] == const.TYPE_RESULT assert msg["success"] @@ -191,9 +185,8 @@ async def test_return_response_error(hass: HomeAssistant, websocket_client) -> N hass.services.async_register( "domain_test", "test_service_with_no_response", lambda x: None ) - await websocket_client.send_json( + await websocket_client.send_json_auto_id( { - "id": 8, "type": "call_service", "domain": "domain_test", "service": "test_service_with_no_response", @@ -203,7 +196,6 @@ async def test_return_response_error(hass: HomeAssistant, websocket_client) -> N ) msg = await websocket_client.receive_json() - assert msg["id"] == 8 assert msg["type"] == const.TYPE_RESULT assert not msg["success"] assert msg["error"]["code"] == "service_validation_error" @@ -225,9 +217,8 @@ async def test_call_service_blocking( "homeassistant.core.ServiceRegistry.async_call", autospec=True ) as mock_call: mock_call.return_value = {"foo": "bar"} - await websocket_client.send_json( + await websocket_client.send_json_auto_id( { - "id": 4, "type": "call_service", "domain": "domain_test", "service": "test_service", @@ -237,7 +228,6 @@ async def test_call_service_blocking( ) msg = await websocket_client.receive_json() - assert msg["id"] == 4 assert msg["type"] == const.TYPE_RESULT assert msg["success"] assert msg["result"]["response"] == {"foo": "bar"} @@ -256,9 +246,8 @@ async def test_call_service_blocking( "homeassistant.core.ServiceRegistry.async_call", autospec=True ) as mock_call: mock_call.return_value = None - await websocket_client.send_json( + await websocket_client.send_json_auto_id( { - "id": 5, "type": "call_service", "domain": "domain_test", "service": "test_service", @@ -267,7 +256,6 @@ async def test_call_service_blocking( ) msg = await websocket_client.receive_json() - assert msg["id"] == 5 assert msg["type"] == const.TYPE_RESULT assert msg["success"] mock_call.assert_called_once_with( @@ -286,9 +274,8 @@ async def test_call_service_blocking( "homeassistant.core.ServiceRegistry.async_call", autospec=True ) as mock_call: mock_call.return_value = None - await websocket_client.send_json( + await websocket_client.send_json_auto_id( { - "id": 6, "type": "call_service", "domain": "homeassistant", "service": "test_service", @@ -296,7 +283,6 @@ async def test_call_service_blocking( ) msg = await websocket_client.receive_json() - assert msg["id"] == 6 assert msg["type"] == const.TYPE_RESULT assert msg["success"] mock_call.assert_called_once_with( @@ -315,9 +301,8 @@ async def test_call_service_blocking( "homeassistant.core.ServiceRegistry.async_call", autospec=True ) as mock_call: mock_call.return_value = None - await websocket_client.send_json( + await websocket_client.send_json_auto_id( { - "id": 7, "type": "call_service", "domain": "homeassistant", "service": "restart", @@ -325,7 +310,6 @@ async def test_call_service_blocking( ) msg = await websocket_client.receive_json() - assert msg["id"] == 7 assert msg["type"] == const.TYPE_RESULT assert msg["success"] mock_call.assert_called_once_with( @@ -346,9 +330,8 @@ async def test_call_service_target( """Test call service command with target.""" calls = async_mock_service(hass, "domain_test", "test_service") - await websocket_client.send_json( + await websocket_client.send_json_auto_id( { - "id": 5, "type": "call_service", "domain": "domain_test", "service": "test_service", @@ -361,7 +344,6 @@ async def test_call_service_target( ) msg = await websocket_client.receive_json() - assert msg["id"] == 5 assert msg["type"] == const.TYPE_RESULT assert msg["success"] @@ -382,9 +364,8 @@ async def test_call_service_target_template( hass: HomeAssistant, websocket_client ) -> None: """Test call service command with target does not allow template.""" - await websocket_client.send_json( + await websocket_client.send_json_auto_id( { - "id": 5, "type": "call_service", "domain": "domain_test", "service": "test_service", @@ -396,7 +377,6 @@ async def test_call_service_target_template( ) msg = await websocket_client.receive_json() - assert msg["id"] == 5 assert msg["type"] == const.TYPE_RESULT assert not msg["success"] assert msg["error"]["code"] == const.ERR_INVALID_FORMAT @@ -406,9 +386,8 @@ async def test_call_service_not_found( hass: HomeAssistant, websocket_client: MockHAClientWebSocket ) -> None: """Test call service command.""" - await websocket_client.send_json( + await websocket_client.send_json_auto_id( { - "id": 5, "type": "call_service", "domain": "domain_test", "service": "test_service", @@ -417,7 +396,6 @@ async def test_call_service_not_found( ) msg = await websocket_client.receive_json() - assert msg["id"] == 5 assert msg["type"] == const.TYPE_RESULT assert not msg["success"] assert msg["error"]["code"] == const.ERR_NOT_FOUND @@ -440,9 +418,8 @@ async def test_call_service_child_not_found( hass.services.async_register("domain_test", "test_service", serv_handler) - await websocket_client.send_json( + await websocket_client.send_json_auto_id( { - "id": 5, "type": "call_service", "domain": "domain_test", "service": "test_service", @@ -451,7 +428,6 @@ async def test_call_service_child_not_found( ) msg = await websocket_client.receive_json() - assert msg["id"] == 5 assert msg["type"] == const.TYPE_RESULT assert not msg["success"] assert msg["error"]["code"] == const.ERR_HOME_ASSISTANT_ERROR @@ -492,9 +468,8 @@ async def test_call_service_schema_validation_error( schema=service_schema, ) - await websocket_client.send_json( + await websocket_client.send_json_auto_id( { - "id": 5, "type": "call_service", "domain": "domain_test", "service": "test_service", @@ -502,14 +477,12 @@ async def test_call_service_schema_validation_error( } ) msg = await websocket_client.receive_json() - assert msg["id"] == 5 assert msg["type"] == const.TYPE_RESULT assert not msg["success"] assert msg["error"]["code"] == const.ERR_INVALID_FORMAT - await websocket_client.send_json( + await websocket_client.send_json_auto_id( { - "id": 6, "type": "call_service", "domain": "domain_test", "service": "test_service", @@ -517,14 +490,12 @@ async def test_call_service_schema_validation_error( } ) msg = await websocket_client.receive_json() - assert msg["id"] == 6 assert msg["type"] == const.TYPE_RESULT assert not msg["success"] assert msg["error"]["code"] == const.ERR_INVALID_FORMAT - await websocket_client.send_json( + await websocket_client.send_json_auto_id( { - "id": 7, "type": "call_service", "domain": "domain_test", "service": "test_service", @@ -532,7 +503,6 @@ async def test_call_service_schema_validation_error( } ) msg = await websocket_client.receive_json() - assert msg["id"] == 7 assert msg["type"] == const.TYPE_RESULT assert not msg["success"] assert msg["error"]["code"] == const.ERR_INVALID_FORMAT @@ -573,9 +543,8 @@ async def test_call_service_error( hass.services.async_register("domain_test", "unknown_error", unknown_error_call) - await websocket_client.send_json( + await websocket_client.send_json_auto_id( { - "id": 5, "type": "call_service", "domain": "domain_test", "service": "ha_error", @@ -583,7 +552,6 @@ async def test_call_service_error( ) msg = await websocket_client.receive_json() - assert msg["id"] == 5 assert msg["type"] == const.TYPE_RESULT assert msg["success"] is False assert msg["error"]["code"] == "home_assistant_error" @@ -592,9 +560,8 @@ async def test_call_service_error( assert msg["error"]["translation_key"] == "custom_error" assert msg["error"]["translation_domain"] == "test" - await websocket_client.send_json( + await websocket_client.send_json_auto_id( { - "id": 6, "type": "call_service", "domain": "domain_test", "service": "service_error", @@ -602,7 +569,6 @@ async def test_call_service_error( ) msg = await websocket_client.receive_json() - assert msg["id"] == 6 assert msg["type"] == const.TYPE_RESULT assert msg["success"] is False assert msg["error"]["code"] == "service_validation_error" @@ -611,9 +577,8 @@ async def test_call_service_error( assert msg["error"]["translation_key"] == "custom_error" assert msg["error"]["translation_domain"] == "test" - await websocket_client.send_json( + await websocket_client.send_json_auto_id( { - "id": 7, "type": "call_service", "domain": "domain_test", "service": "unknown_error", @@ -621,7 +586,6 @@ async def test_call_service_error( ) msg = await websocket_client.receive_json() - assert msg["id"] == 7 assert msg["type"] == const.TYPE_RESULT assert msg["success"] is False assert msg["error"]["code"] == "unknown_error" @@ -634,12 +598,12 @@ async def test_subscribe_unsubscribe_events( """Test subscribe/unsubscribe events command.""" init_count = sum(hass.bus.async_listeners().values()) - await websocket_client.send_json( - {"id": 5, "type": "subscribe_events", "event_type": "test_event"} + await websocket_client.send_json_auto_id( + {"type": "subscribe_events", "event_type": "test_event"} ) msg = await websocket_client.receive_json() - assert msg["id"] == 5 + subscription = msg["id"] assert msg["type"] == const.TYPE_RESULT assert msg["success"] @@ -653,7 +617,7 @@ async def test_subscribe_unsubscribe_events( async with asyncio.timeout(3): msg = await websocket_client.receive_json() - assert msg["id"] == 5 + assert msg["id"] == subscription assert msg["type"] == "event" event = msg["event"] @@ -661,12 +625,11 @@ async def test_subscribe_unsubscribe_events( assert event["data"] == {"hello": "world"} assert event["origin"] == "LOCAL" - await websocket_client.send_json( - {"id": 6, "type": "unsubscribe_events", "subscription": 5} + await websocket_client.send_json_auto_id( + {"type": "unsubscribe_events", "subscription": subscription} ) msg = await websocket_client.receive_json() - assert msg["id"] == 6 assert msg["type"] == const.TYPE_RESULT assert msg["success"] @@ -681,10 +644,9 @@ async def test_get_states( hass.states.async_set("greeting.hello", "world") hass.states.async_set("greeting.bye", "universe") - await websocket_client.send_json({"id": 5, "type": "get_states"}) + await websocket_client.send_json_auto_id({"type": "get_states"}) msg = await websocket_client.receive_json() - assert msg["id"] == 5 assert msg["type"] == const.TYPE_RESULT assert msg["success"] @@ -711,10 +673,9 @@ async def test_get_config( hass: HomeAssistant, websocket_client: MockHAClientWebSocket ) -> None: """Test get_config command.""" - await websocket_client.send_json({"id": 5, "type": "get_config"}) + await websocket_client.send_json_auto_id({"type": "get_config"}) msg = await websocket_client.receive_json() - assert msg["id"] == 5 assert msg["type"] == const.TYPE_RESULT assert msg["success"] @@ -737,10 +698,9 @@ async def test_get_config( async def test_ping(websocket_client: MockHAClientWebSocket) -> None: """Test get_panels command.""" - await websocket_client.send_json({"id": 5, "type": "ping"}) + await websocket_client.send_json_auto_id({"type": "ping"}) msg = await websocket_client.receive_json() - assert msg["id"] == 5 assert msg["type"] == "pong" @@ -792,8 +752,8 @@ async def test_subscribe_requires_admin( ) -> None: """Test subscribing events without being admin.""" hass_admin_user.groups = [] - await websocket_client.send_json( - {"id": 5, "type": "subscribe_events", "event_type": "test_event"} + await websocket_client.send_json_auto_id( + {"type": "subscribe_events", "event_type": "test_event"} ) msg = await websocket_client.receive_json() @@ -809,10 +769,9 @@ async def test_states_filters_visible( hass_admin_user.mock_policy({"entities": {"entity_ids": {"test.entity": True}}}) hass.states.async_set("test.entity", "hello") hass.states.async_set("test.not_visible_entity", "invisible") - await websocket_client.send_json({"id": 5, "type": "get_states"}) + await websocket_client.send_json_auto_id({"type": "get_states"}) msg = await websocket_client.receive_json() - assert msg["id"] == 5 assert msg["type"] == const.TYPE_RESULT assert msg["success"] @@ -828,13 +787,12 @@ async def test_get_states_not_allows_nan( hass.states.async_set("greeting.bad", "data", {"hello": float("NaN")}) hass.states.async_set("greeting.bye", "universe") - await websocket_client.send_json({"id": 5, "type": "get_states"}) + await websocket_client.send_json_auto_id({"type": "get_states"}) bad = dict(hass.states.get("greeting.bad").as_dict()) bad["attributes"] = dict(bad["attributes"]) bad["attributes"]["hello"] = None msg = await websocket_client.receive_json() - assert msg["id"] == 5 assert msg["type"] == const.TYPE_RESULT assert msg["success"] assert msg["result"] == [ @@ -852,22 +810,21 @@ async def test_subscribe_unsubscribe_events_whitelist( """Test subscribe/unsubscribe events on whitelist.""" hass_admin_user.groups = [] - await websocket_client.send_json( - {"id": 5, "type": "subscribe_events", "event_type": "not-in-whitelist"} + await websocket_client.send_json_auto_id( + {"type": "subscribe_events", "event_type": "not-in-whitelist"} ) msg = await websocket_client.receive_json() - assert msg["id"] == 5 assert msg["type"] == const.TYPE_RESULT assert not msg["success"] assert msg["error"]["code"] == "unauthorized" - await websocket_client.send_json( - {"id": 6, "type": "subscribe_events", "event_type": "themes_updated"} + await websocket_client.send_json_auto_id( + {"type": "subscribe_events", "event_type": "themes_updated"} ) msg = await websocket_client.receive_json() - assert msg["id"] == 6 + themes_updated_subscription = msg["id"] assert msg["type"] == const.TYPE_RESULT assert msg["success"] @@ -876,7 +833,7 @@ async def test_subscribe_unsubscribe_events_whitelist( async with asyncio.timeout(3): msg = await websocket_client.receive_json() - assert msg["id"] == 6 + assert msg["id"] == themes_updated_subscription assert msg["type"] == "event" event = msg["event"] assert event["event_type"] == "themes_updated" @@ -892,12 +849,12 @@ async def test_subscribe_unsubscribe_events_state_changed( hass_admin_user.groups = [] hass_admin_user.mock_policy({"entities": {"entity_ids": {"light.permitted": True}}}) - await websocket_client.send_json( - {"id": 7, "type": "subscribe_events", "event_type": "state_changed"} + await websocket_client.send_json_auto_id( + {"type": "subscribe_events", "event_type": "state_changed"} ) msg = await websocket_client.receive_json() - assert msg["id"] == 7 + subscription = msg["id"] assert msg["type"] == const.TYPE_RESULT assert msg["success"] @@ -905,7 +862,7 @@ async def test_subscribe_unsubscribe_events_state_changed( hass.states.async_set("light.permitted", "on") msg = await websocket_client.receive_json() - assert msg["id"] == 7 + assert msg["id"] == subscription assert msg["type"] == "event" assert msg["event"]["event_type"] == "state_changed" assert msg["event"]["data"]["entity_id"] == "light.permitted" @@ -949,15 +906,15 @@ async def test_subscribe_entities_with_unserializable_state( } ) - await websocket_client.send_json({"id": 7, "type": "subscribe_entities"}) + await websocket_client.send_json_auto_id({"type": "subscribe_entities"}) msg = await websocket_client.receive_json() - assert msg["id"] == 7 + subscription = msg["id"] assert msg["type"] == const.TYPE_RESULT assert msg["success"] msg = await websocket_client.receive_json() - assert msg["id"] == 7 + assert msg["id"] == subscription assert msg["type"] == "event" assert msg["event"] == { "a": { @@ -971,7 +928,7 @@ async def test_subscribe_entities_with_unserializable_state( } hass.states.async_set("light.permitted", "on", {"effect": "help"}) msg = await websocket_client.receive_json() - assert msg["id"] == 7 + assert msg["id"] == subscription assert msg["type"] == "event" assert msg["event"] == { "c": { @@ -988,7 +945,7 @@ async def test_subscribe_entities_with_unserializable_state( } hass.states.async_set("light.cannot_serialize", "on", {"effect": "help"}) msg = await websocket_client.receive_json() - assert msg["id"] == 7 + assert msg["id"] == subscription assert msg["type"] == "event" # Order does not matter msg["event"]["c"]["light.cannot_serialize"]["-"]["a"] = set( @@ -1022,7 +979,7 @@ async def test_subscribe_entities_with_unserializable_state( {"color": "red", "cannot_serialize": CannotSerializeMe()}, ) msg = await websocket_client.receive_json() - assert msg["id"] == 7 + assert msg["id"] == subscription assert msg["type"] == "result" assert msg["error"] == { "code": "unknown_error", @@ -1052,15 +1009,15 @@ async def test_subscribe_unsubscribe_entities( hass_admin_user.mock_policy({"entities": {"entity_ids": {"light.permitted": True}}}) assert not hass_admin_user.is_admin - await websocket_client.send_json({"id": 7, "type": "subscribe_entities"}) + await websocket_client.send_json_auto_id({"type": "subscribe_entities"}) msg = await websocket_client.receive_json() - assert msg["id"] == 7 + subscription = msg["id"] assert msg["type"] == const.TYPE_RESULT assert msg["success"] msg = await websocket_client.receive_json() - assert msg["id"] == 7 + assert msg["id"] == subscription assert msg["type"] == "event" assert isinstance(msg["event"]["a"]["light.permitted"]["c"], str) assert msg["event"] == { @@ -1083,7 +1040,7 @@ async def test_subscribe_unsubscribe_entities( hass.states.async_set("light.permitted", "on", {"effect": "help", "color": "blue"}) msg = await websocket_client.receive_json() - assert msg["id"] == 7 + assert msg["id"] == subscription assert msg["type"] == "event" assert msg["event"] == { "c": { @@ -1115,7 +1072,7 @@ async def test_subscribe_unsubscribe_entities( } msg = await websocket_client.receive_json() - assert msg["id"] == 7 + assert msg["id"] == subscription assert msg["type"] == "event" assert msg["event"] == { "c": { @@ -1148,7 +1105,7 @@ async def test_subscribe_unsubscribe_entities( } msg = await websocket_client.receive_json() - assert msg["id"] == 7 + assert msg["id"] == subscription assert msg["type"] == "event" assert msg["event"] == { "c": { @@ -1180,12 +1137,12 @@ async def test_subscribe_unsubscribe_entities( } msg = await websocket_client.receive_json() - assert msg["id"] == 7 + assert msg["id"] == subscription assert msg["type"] == "event" assert msg["event"] == {"r": ["light.permitted"]} msg = await websocket_client.receive_json() - assert msg["id"] == 7 + assert msg["id"] == subscription assert msg["type"] == "event" assert msg["event"] == { "a": { @@ -1219,17 +1176,17 @@ async def test_subscribe_unsubscribe_entities_specific_entities( } ) - await websocket_client.send_json( - {"id": 7, "type": "subscribe_entities", "entity_ids": ["light.permitted"]} + await websocket_client.send_json_auto_id( + {"type": "subscribe_entities", "entity_ids": ["light.permitted"]} ) msg = await websocket_client.receive_json() - assert msg["id"] == 7 + subscription = msg["id"] assert msg["type"] == const.TYPE_RESULT assert msg["success"] msg = await websocket_client.receive_json() - assert msg["id"] == 7 + assert msg["id"] == subscription assert msg["type"] == "event" assert isinstance(msg["event"]["a"]["light.permitted"]["c"], str) assert msg["event"] == { @@ -1247,7 +1204,7 @@ async def test_subscribe_unsubscribe_entities_specific_entities( hass.states.async_set("light.permitted", "on", {"color": "blue"}) msg = await websocket_client.receive_json() - assert msg["id"] == 7 + assert msg["id"] == subscription assert msg["type"] == "event" assert msg["event"] == { "c": { @@ -1271,17 +1228,17 @@ async def test_subscribe_unsubscribe_entities_with_filter( """Test subscribe/unsubscribe entities with an entity filter.""" hass.states.async_set("switch.not_included", "off") hass.states.async_set("light.include", "off") - await websocket_client.send_json( - {"id": 7, "type": "subscribe_entities", "include": {"domains": ["light"]}} + await websocket_client.send_json_auto_id( + {"type": "subscribe_entities", "include": {"domains": ["light"]}} ) msg = await websocket_client.receive_json() - assert msg["id"] == 7 + subscription = msg["id"] assert msg["type"] == const.TYPE_RESULT assert msg["success"] msg = await websocket_client.receive_json() - assert msg["id"] == 7 + assert msg["id"] == subscription assert msg["type"] == "event" assert msg["event"] == { "a": { @@ -1296,7 +1253,7 @@ async def test_subscribe_unsubscribe_entities_with_filter( hass.states.async_set("switch.not_included", "on") hass.states.async_set("light.include", "on") msg = await websocket_client.receive_json() - assert msg["id"] == 7 + assert msg["id"] == subscription assert msg["type"] == "event" assert msg["event"] == { "c": { @@ -1317,21 +1274,20 @@ async def test_render_template_renders_template( """Test simple template is rendered and updated.""" hass.states.async_set("light.test", "on") - await websocket_client.send_json( + await websocket_client.send_json_auto_id( { - "id": 5, "type": "render_template", "template": "State is: {{ states('light.test') }}", } ) msg = await websocket_client.receive_json() - assert msg["id"] == 5 + subscription = msg["id"] assert msg["type"] == const.TYPE_RESULT assert msg["success"] msg = await websocket_client.receive_json() - assert msg["id"] == 5 + assert msg["id"] == subscription assert msg["type"] == "event" event = msg["event"] assert event == { @@ -1346,7 +1302,7 @@ async def test_render_template_renders_template( hass.states.async_set("light.test", "off") msg = await websocket_client.receive_json() - assert msg["id"] == 5 + assert msg["id"] == subscription assert msg["type"] == "event" event = msg["event"] assert event == { @@ -1364,9 +1320,8 @@ async def test_render_template_with_timeout_and_variables( hass: HomeAssistant, websocket_client ) -> None: """Test a template with a timeout and variables renders without error.""" - await websocket_client.send_json( + await websocket_client.send_json_auto_id( { - "id": 5, "type": "render_template", "timeout": 10, "variables": {"test": {"value": "hello"}}, @@ -1375,12 +1330,12 @@ async def test_render_template_with_timeout_and_variables( ) msg = await websocket_client.receive_json() - assert msg["id"] == 5 + subscription = msg["id"] assert msg["type"] == const.TYPE_RESULT assert msg["success"] msg = await websocket_client.receive_json() - assert msg["id"] == 5 + assert msg["id"] == subscription assert msg["type"] == "event" event = msg["event"] assert event == { @@ -1400,21 +1355,20 @@ async def test_render_template_manual_entity_ids_no_longer_needed( """Test that updates to specified entity ids cause a template rerender.""" hass.states.async_set("light.test", "on") - await websocket_client.send_json( + await websocket_client.send_json_auto_id( { - "id": 5, "type": "render_template", "template": "State is: {{ states('light.test') }}", } ) msg = await websocket_client.receive_json() - assert msg["id"] == 5 + subscription = msg["id"] assert msg["type"] == const.TYPE_RESULT assert msg["success"] msg = await websocket_client.receive_json() - assert msg["id"] == 5 + assert msg["id"] == subscription assert msg["type"] == "event" event = msg["event"] assert event == { @@ -1429,7 +1383,7 @@ async def test_render_template_manual_entity_ids_no_longer_needed( hass.states.async_set("light.test", "off") msg = await websocket_client.receive_json() - assert msg["id"] == 5 + assert msg["id"] == subscription assert msg["type"] == "event" event = msg["event"] assert event == { @@ -1523,9 +1477,8 @@ async def test_render_template_with_error( ) -> None: """Test a template with an error.""" caplog.set_level(logging.INFO) - await websocket_client.send_json( + await websocket_client.send_json_auto_id( { - "id": 5, "type": "render_template", "template": template, "report_errors": True, @@ -1534,7 +1487,6 @@ async def test_render_template_with_error( for expected_event in expected_events: msg = await websocket_client.receive_json() - assert msg["id"] == 5 for key, value in expected_event.items(): assert msg[key] == value @@ -1596,9 +1548,8 @@ async def test_render_template_with_timeout_and_error( ) -> None: """Test a template with an error with a timeout.""" caplog.set_level(logging.INFO) - await websocket_client.send_json( + await websocket_client.send_json_auto_id( { - "id": 5, "type": "render_template", "template": template, "timeout": 5, @@ -1608,7 +1559,6 @@ async def test_render_template_with_timeout_and_error( for expected_event in expected_events: msg = await websocket_client.receive_json() - assert msg["id"] == 5 for key, value in expected_event.items(): assert msg[key] == value @@ -1666,9 +1616,8 @@ async def test_render_template_strict_with_timeout_and_error( In this test report_errors is enabled. """ caplog.set_level(logging.INFO) - await websocket_client.send_json( + await websocket_client.send_json_auto_id( { - "id": 5, "type": "render_template", "template": template, "timeout": 5, @@ -1679,7 +1628,6 @@ async def test_render_template_strict_with_timeout_and_error( for expected_event in expected_events: msg = await websocket_client.receive_json() - assert msg["id"] == 5 for key, value in expected_event.items(): assert msg[key] == value @@ -1729,9 +1677,8 @@ async def test_render_template_strict_with_timeout_and_error_2( In this test report_errors is disabled. """ caplog.set_level(logging.INFO) - await websocket_client.send_json( + await websocket_client.send_json_auto_id( { - "id": 5, "type": "render_template", "template": template, "timeout": 5, @@ -1741,7 +1688,6 @@ async def test_render_template_strict_with_timeout_and_error_2( for expected_event in expected_events: msg = await websocket_client.receive_json() - assert msg["id"] == 5 for key, value in expected_event.items(): assert msg[key] == value @@ -1815,9 +1761,8 @@ async def test_render_template_error_in_template_code( In this test report_errors is enabled. """ - await websocket_client.send_json( + await websocket_client.send_json_auto_id( { - "id": 5, "type": "render_template", "template": template, "report_errors": True, @@ -1826,7 +1771,6 @@ async def test_render_template_error_in_template_code( for expected_event in expected_events_1: msg = await websocket_client.receive_json() - assert msg["id"] == 5 for key, value in expected_event.items(): assert msg[key] == value @@ -1834,7 +1778,6 @@ async def test_render_template_error_in_template_code( for expected_event in expected_events_2: msg = await websocket_client.receive_json() - assert msg["id"] == 5 for key, value in expected_event.items(): assert msg[key] == value @@ -1882,13 +1825,12 @@ async def test_render_template_error_in_template_code_2( In this test report_errors is disabled. """ - await websocket_client.send_json( - {"id": 5, "type": "render_template", "template": template} + await websocket_client.send_json_auto_id( + {"type": "render_template", "template": template} ) for expected_event in expected_events_1: msg = await websocket_client.receive_json() - assert msg["id"] == 5 for key, value in expected_event.items(): assert msg[key] == value @@ -1896,7 +1838,6 @@ async def test_render_template_error_in_template_code_2( for expected_event in expected_events_2: msg = await websocket_client.receive_json() - assert msg["id"] == 5 for key, value in expected_event.items(): assert msg[key] == value @@ -1924,9 +1865,8 @@ async def test_render_template_with_delayed_error( {% endif %} """ - await websocket_client.send_json( + await websocket_client.send_json_auto_id( { - "id": 5, "type": "render_template", "template": template_str, "report_errors": True, @@ -1935,7 +1875,7 @@ async def test_render_template_with_delayed_error( await hass.async_block_till_done() msg = await websocket_client.receive_json() - assert msg["id"] == 5 + subscription = msg["id"] assert msg["type"] == const.TYPE_RESULT assert msg["success"] @@ -1943,7 +1883,7 @@ async def test_render_template_with_delayed_error( await hass.async_block_till_done() msg = await websocket_client.receive_json() - assert msg["id"] == 5 + assert msg["id"] == subscription assert msg["type"] == "event" event = msg["event"] assert event == { @@ -1957,13 +1897,13 @@ async def test_render_template_with_delayed_error( } msg = await websocket_client.receive_json() - assert msg["id"] == 5 + assert msg["id"] == subscription assert msg["type"] == "event" event = msg["event"] assert event["error"] == "'None' has no attribute 'state'" msg = await websocket_client.receive_json() - assert msg["id"] == 5 + assert msg["id"] == subscription assert msg["type"] == "event" event = msg["event"] assert event == { @@ -1994,9 +1934,8 @@ async def test_render_template_with_delayed_error_2( {% endif %} """ - await websocket_client.send_json( + await websocket_client.send_json_auto_id( { - "id": 5, "type": "render_template", "template": template_str, "report_errors": False, @@ -2005,7 +1944,7 @@ async def test_render_template_with_delayed_error_2( await hass.async_block_till_done() msg = await websocket_client.receive_json() - assert msg["id"] == 5 + subscription = msg["id"] assert msg["type"] == const.TYPE_RESULT assert msg["success"] @@ -2013,7 +1952,7 @@ async def test_render_template_with_delayed_error_2( await hass.async_block_till_done() msg = await websocket_client.receive_json() - assert msg["id"] == 5 + assert msg["id"] == subscription assert msg["type"] == "event" event = msg["event"] assert event == { @@ -2044,9 +1983,8 @@ async def test_render_template_with_timeout( {%- endfor %} """ - await websocket_client.send_json( + await websocket_client.send_json_auto_id( { - "id": 5, "type": "render_template", "timeout": 0.000001, "template": slow_template_str, @@ -2054,7 +1992,6 @@ async def test_render_template_with_timeout( ) msg = await websocket_client.receive_json() - assert msg["id"] == 5 assert msg["type"] == const.TYPE_RESULT assert not msg["success"] assert msg["error"]["code"] == const.ERR_TEMPLATE_ERROR @@ -2066,12 +2003,11 @@ async def test_render_template_returns_with_match_all( hass: HomeAssistant, websocket_client ) -> None: """Test that a template that would match with all entities still return success.""" - await websocket_client.send_json( - {"id": 5, "type": "render_template", "template": "State is: {{ 42 }}"} + await websocket_client.send_json_auto_id( + {"type": "render_template", "template": "State is: {{ 42 }}"} ) msg = await websocket_client.receive_json() - assert msg["id"] == 5 assert msg["type"] == const.TYPE_RESULT assert msg["success"] @@ -2083,10 +2019,9 @@ async def test_manifest_list( http = await async_get_integration(hass, "http") websocket_api = await async_get_integration(hass, "websocket_api") - await websocket_client.send_json({"id": 5, "type": "manifest/list"}) + await websocket_client.send_json_auto_id({"type": "manifest/list"}) msg = await websocket_client.receive_json() - assert msg["id"] == 5 assert msg["type"] == const.TYPE_RESULT assert msg["success"] assert sorted(msg["result"], key=lambda manifest: manifest["domain"]) == [ @@ -2101,13 +2036,12 @@ async def test_manifest_list_specific_integrations( """Test loading manifests for specific integrations.""" websocket_api = await async_get_integration(hass, "websocket_api") - await websocket_client.send_json( - {"id": 5, "type": "manifest/list", "integrations": ["hue", "websocket_api"]} + await websocket_client.send_json_auto_id( + {"type": "manifest/list", "integrations": ["hue", "websocket_api"]} ) hue = await async_get_integration(hass, "hue") msg = await websocket_client.receive_json() - assert msg["id"] == 5 assert msg["type"] == const.TYPE_RESULT assert msg["success"] assert sorted(msg["result"], key=lambda manifest: manifest["domain"]) == [ @@ -2122,23 +2056,21 @@ async def test_manifest_get( """Test getting a manifest.""" hue = await async_get_integration(hass, "hue") - await websocket_client.send_json( - {"id": 6, "type": "manifest/get", "integration": "hue"} + await websocket_client.send_json_auto_id( + {"type": "manifest/get", "integration": "hue"} ) msg = await websocket_client.receive_json() - assert msg["id"] == 6 assert msg["type"] == const.TYPE_RESULT assert msg["success"] assert msg["result"] == hue.manifest # Non existing - await websocket_client.send_json( - {"id": 7, "type": "manifest/get", "integration": "non_existing"} + await websocket_client.send_json_auto_id( + {"type": "manifest/get", "integration": "non_existing"} ) msg = await websocket_client.receive_json() - assert msg["id"] == 7 assert msg["type"] == const.TYPE_RESULT assert not msg["success"] assert msg["error"]["code"] == "not_found" @@ -2157,10 +2089,9 @@ async def test_entity_source_admin( ) # Fetch all - await websocket_client.send_json({"id": 6, "type": "entity/source"}) + await websocket_client.send_json_auto_id({"type": "entity/source"}) msg = await websocket_client.receive_json() - assert msg["id"] == 6 assert msg["type"] == const.TYPE_RESULT assert msg["success"] assert msg["result"] == { @@ -2175,10 +2106,9 @@ async def test_entity_source_admin( ) # Fetch all - await websocket_client.send_json({"id": 10, "type": "entity/source"}) + await websocket_client.send_json_auto_id({"type": "entity/source"}) msg = await websocket_client.receive_json() - assert msg["id"] == 10 assert msg["type"] == const.TYPE_RESULT assert msg["success"] assert msg["result"] == { @@ -2192,9 +2122,8 @@ async def test_subscribe_trigger( """Test subscribing to a trigger.""" init_count = sum(hass.bus.async_listeners().values()) - await websocket_client.send_json( + await websocket_client.send_json_auto_id( { - "id": 5, "type": "subscribe_trigger", "trigger": {"platform": "event", "event_type": "test_event"}, "variables": {"hello": "world"}, @@ -2202,7 +2131,6 @@ async def test_subscribe_trigger( ) msg = await websocket_client.receive_json() - assert msg["id"] == 5 assert msg["type"] == const.TYPE_RESULT assert msg["success"] @@ -2218,7 +2146,6 @@ async def test_subscribe_trigger( async with asyncio.timeout(3): msg = await websocket_client.receive_json() - assert msg["id"] == 5 assert msg["type"] == "event" assert msg["event"]["context"]["id"] == context.id assert msg["event"]["variables"]["trigger"]["platform"] == "event" @@ -2229,12 +2156,11 @@ async def test_subscribe_trigger( assert event["data"] == {"hello": "world"} assert event["origin"] == "LOCAL" - await websocket_client.send_json( - {"id": 6, "type": "unsubscribe_events", "subscription": 5} + await websocket_client.send_json_auto_id( + {"type": "unsubscribe_events", "subscription": msg["id"]} ) msg = await websocket_client.receive_json() - assert msg["id"] == 6 assert msg["type"] == const.TYPE_RESULT assert msg["success"] @@ -2248,9 +2174,8 @@ async def test_test_condition( """Test testing a condition.""" hass.states.async_set("hello.world", "paulus") - await websocket_client.send_json( + await websocket_client.send_json_auto_id( { - "id": 5, "type": "test_condition", "condition": { "condition": "state", @@ -2262,14 +2187,12 @@ async def test_test_condition( ) msg = await websocket_client.receive_json() - assert msg["id"] == 5 assert msg["type"] == const.TYPE_RESULT assert msg["success"] assert msg["result"]["result"] is True - await websocket_client.send_json( + await websocket_client.send_json_auto_id( { - "id": 6, "type": "test_condition", "condition": { "condition": "template", @@ -2280,14 +2203,12 @@ async def test_test_condition( ) msg = await websocket_client.receive_json() - assert msg["id"] == 6 assert msg["type"] == const.TYPE_RESULT assert msg["success"] assert msg["result"]["result"] is True - await websocket_client.send_json( + await websocket_client.send_json_auto_id( { - "id": 7, "type": "test_condition", "condition": { "condition": "template", @@ -2298,7 +2219,6 @@ async def test_test_condition( ) msg = await websocket_client.receive_json() - assert msg["id"] == 7 assert msg["type"] == const.TYPE_RESULT assert msg["success"] assert msg["result"]["result"] is False @@ -2312,9 +2232,8 @@ async def test_execute_script( hass, "domain_test", "test_service", response={"hello": "world"} ) - await websocket_client.send_json( + await websocket_client.send_json_auto_id( { - "id": 5, "type": "execute_script", "sequence": [ { @@ -2328,14 +2247,12 @@ async def test_execute_script( ) msg_no_var = await websocket_client.receive_json() - assert msg_no_var["id"] == 5 assert msg_no_var["type"] == const.TYPE_RESULT assert msg_no_var["success"] assert msg_no_var["result"]["response"] == {"hello": "world"} - await websocket_client.send_json( + await websocket_client.send_json_auto_id( { - "id": 6, "type": "execute_script", "sequence": { "service": "domain_test.test_service", @@ -2346,7 +2263,6 @@ async def test_execute_script( ) msg_var = await websocket_client.receive_json() - assert msg_var["id"] == 6 assert msg_var["type"] == const.TYPE_RESULT assert msg_var["success"] @@ -2403,9 +2319,8 @@ async def test_execute_script_err_localization( hass, "domain_test", "test_service", raise_exception=raise_exception ) - await websocket_client.send_json( + await websocket_client.send_json_auto_id( { - "id": 5, "type": "execute_script", "sequence": [ { @@ -2418,7 +2333,6 @@ async def test_execute_script_err_localization( ) msg = await websocket_client.receive_json() - assert msg["id"] == 5 assert msg["type"] == const.TYPE_RESULT assert msg["success"] is False assert msg["error"]["code"] == err_code @@ -2522,12 +2436,12 @@ async def test_subscribe_unsubscribe_bootstrap_integrations( hass_admin_user: MockUser, ) -> None: """Test subscribe/unsubscribe bootstrap_integrations.""" - await websocket_client.send_json( - {"id": 7, "type": "subscribe_bootstrap_integrations"} + await websocket_client.send_json_auto_id( + {"type": "subscribe_bootstrap_integrations"} ) msg = await websocket_client.receive_json() - assert msg["id"] == 7 + subscription = msg["id"] assert msg["type"] == const.TYPE_RESULT assert msg["success"] @@ -2535,7 +2449,7 @@ async def test_subscribe_unsubscribe_bootstrap_integrations( async_dispatcher_send(hass, SIGNAL_BOOTSTRAP_INTEGRATIONS, message) msg = await websocket_client.receive_json() - assert msg["id"] == 7 + assert msg["id"] == subscription assert msg["type"] == "event" assert msg["event"] == message @@ -2553,10 +2467,9 @@ async def test_integration_setup_info( "isy994": 12.8, }, ): - await websocket_client.send_json({"id": 7, "type": "integration/setup_info"}) + await websocket_client.send_json_auto_id({"type": "integration/setup_info"}) msg = await websocket_client.receive_json() - assert msg["id"] == 7 assert msg["type"] == const.TYPE_RESULT assert msg["success"] assert msg["result"] == [ @@ -2855,12 +2768,7 @@ async def test_integration_descriptions( assert await async_setup_component(hass, "config", {}) ws_client = await hass_ws_client(hass) - await ws_client.send_json( - { - "id": 1, - "type": "integration/descriptions", - } - ) + await ws_client.send_json_auto_id({"type": "integration/descriptions"}) response = await ws_client.receive_json() assert response["success"] @@ -2884,31 +2792,31 @@ async def test_subscribe_entities_chained_state_change( async_track_state_change_event(hass, ["light.permitted"], auto_off_listener) - await websocket_client.send_json({"id": 7, "type": "subscribe_entities"}) + await websocket_client.send_json_auto_id({"type": "subscribe_entities"}) data = await websocket_client.receive_str() msg = json_loads(data) - assert msg["id"] == 7 + subscription = msg["id"] assert msg["type"] == const.TYPE_RESULT assert msg["success"] data = await websocket_client.receive_str() msg = json_loads(data) - assert msg["id"] == 7 + assert msg["id"] == subscription assert msg["type"] == "event" assert msg["event"] == {"a": {}} hass.states.async_set("light.permitted", "on") data = await websocket_client.receive_str() msg = json_loads(data) - assert msg["id"] == 7 + assert msg["id"] == subscription assert msg["type"] == "event" assert msg["event"] == { "a": {"light.permitted": {"a": {}, "c": ANY, "lc": ANY, "s": "on"}} } data = await websocket_client.receive_str() msg = json_loads(data) - assert msg["id"] == 7 + assert msg["id"] == subscription assert msg["type"] == "event" assert msg["event"] == { "c": {"light.permitted": {"+": {"c": ANY, "lc": ANY, "s": "off"}}} From 50c12d44870d0394e5022567735f87ac7f0272c1 Mon Sep 17 00:00:00 2001 From: Simone Chemelli Date: Tue, 1 Apr 2025 14:39:44 +0200 Subject: [PATCH 164/505] Move Vodafone Station to platinum quality scale (#141406) --- homeassistant/components/vodafone_station/manifest.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/vodafone_station/manifest.json b/homeassistant/components/vodafone_station/manifest.json index 29cb3c070ab..a36af1466d6 100644 --- a/homeassistant/components/vodafone_station/manifest.json +++ b/homeassistant/components/vodafone_station/manifest.json @@ -7,6 +7,6 @@ "integration_type": "hub", "iot_class": "local_polling", "loggers": ["aiovodafone"], - "quality_scale": "silver", + "quality_scale": "platinum", "requirements": ["aiovodafone==0.6.1"] } From 32ee31b8c7a98045096f97835d1ae08c623257f7 Mon Sep 17 00:00:00 2001 From: Louis Christ Date: Tue, 1 Apr 2025 14:41:24 +0200 Subject: [PATCH 165/505] Use saved volume when selecting preset in bluesound integration (#141079) * Use load_preset to select preset as source * Add tests * Fix --------- Co-authored-by: Joostlek --- .../components/bluesound/media_player.py | 12 ++++---- tests/components/bluesound/conftest.py | 4 +-- .../components/bluesound/test_media_player.py | 28 +++++++++++++++++++ 3 files changed, 35 insertions(+), 9 deletions(-) diff --git a/homeassistant/components/bluesound/media_player.py b/homeassistant/components/bluesound/media_player.py index 135d1b5d27e..0addcc1daac 100644 --- a/homeassistant/components/bluesound/media_player.py +++ b/homeassistant/components/bluesound/media_player.py @@ -501,18 +501,16 @@ class BluesoundPlayer(CoordinatorEntity[BluesoundCoordinator], MediaPlayerEntity return # presets and inputs might have the same name; presets have priority - url: str | None = None for input_ in self._inputs: if input_.text == source: - url = input_.url + await self._player.play_url(input_.url) + return for preset in self._presets: if preset.name == source: - url = preset.url + await self._player.load_preset(preset.id) + return - if url is None: - raise ServiceValidationError(f"Source {source} not found") - - await self._player.play_url(url) + raise ServiceValidationError(f"Source {source} not found") async def async_clear_playlist(self) -> None: """Clear players playlist.""" diff --git a/tests/components/bluesound/conftest.py b/tests/components/bluesound/conftest.py index 717c9f61850..63597ed0532 100644 --- a/tests/components/bluesound/conftest.py +++ b/tests/components/bluesound/conftest.py @@ -102,8 +102,8 @@ class PlayerMockData: ) player.presets = AsyncMock( return_value=[ - Preset("preset1", "1", "url1", "image1", None), - Preset("preset2", "2", "url2", "image2", None), + Preset("preset1", 1, "url1", "image1", None), + Preset("preset2", 2, "url2", "image2", None), ] ) diff --git a/tests/components/bluesound/test_media_player.py b/tests/components/bluesound/test_media_player.py index ed537d0bc57..dcff33399f5 100644 --- a/tests/components/bluesound/test_media_player.py +++ b/tests/components/bluesound/test_media_player.py @@ -17,12 +17,14 @@ from homeassistant.components.bluesound.media_player import ( SERVICE_SET_TIMER, ) from homeassistant.components.media_player import ( + ATTR_INPUT_SOURCE, ATTR_MEDIA_VOLUME_LEVEL, DOMAIN as MEDIA_PLAYER_DOMAIN, SERVICE_MEDIA_NEXT_TRACK, SERVICE_MEDIA_PAUSE, SERVICE_MEDIA_PLAY, SERVICE_MEDIA_PREVIOUS_TRACK, + SERVICE_SELECT_SOURCE, SERVICE_VOLUME_DOWN, SERVICE_VOLUME_MUTE, SERVICE_VOLUME_SET, @@ -119,6 +121,32 @@ async def test_volume_down( player_mocks.player_data.player.volume.assert_called_once_with(level=9) +async def test_select_input_source( + hass: HomeAssistant, setup_config_entry: None, player_mocks: PlayerMocks +) -> None: + """Test the media player select input source.""" + await hass.services.async_call( + MEDIA_PLAYER_DOMAIN, + SERVICE_SELECT_SOURCE, + {ATTR_ENTITY_ID: "media_player.player_name1111", ATTR_INPUT_SOURCE: "input1"}, + ) + + player_mocks.player_data.player.play_url.assert_called_once_with("url1") + + +async def test_select_preset_source( + hass: HomeAssistant, setup_config_entry: None, player_mocks: PlayerMocks +) -> None: + """Test the media player select preset source.""" + await hass.services.async_call( + MEDIA_PLAYER_DOMAIN, + SERVICE_SELECT_SOURCE, + {ATTR_ENTITY_ID: "media_player.player_name1111", ATTR_INPUT_SOURCE: "preset1"}, + ) + + player_mocks.player_data.player.load_preset.assert_called_once_with(1) + + async def test_attributes_set( hass: HomeAssistant, setup_config_entry: None, From 7068986c14ed383de57a17b4be50abbaebca23e6 Mon Sep 17 00:00:00 2001 From: Dan Raper Date: Tue, 1 Apr 2025 14:48:45 +0100 Subject: [PATCH 166/505] Bump Ohme to platinum (#141762) * Bump version of ohmepy and fix types * Add strict typing to ohme * Inject websession for ohme * CI/code formatting fixes for ohme * Update mypy.ini for ohme * Fix typing in services for ohme * Bump ohme quality in manifest --- .strict-typing | 1 + homeassistant/components/ohme/__init__.py | 7 ++++++- homeassistant/components/ohme/manifest.json | 2 +- homeassistant/components/ohme/quality_scale.yaml | 6 +++--- homeassistant/components/ohme/services.py | 5 +++-- mypy.ini | 10 ++++++++++ 6 files changed, 24 insertions(+), 7 deletions(-) diff --git a/.strict-typing b/.strict-typing index e0c4e569f4b..3e8ad0ddbaf 100644 --- a/.strict-typing +++ b/.strict-typing @@ -364,6 +364,7 @@ homeassistant.components.notify.* homeassistant.components.notion.* homeassistant.components.number.* homeassistant.components.nut.* +homeassistant.components.ohme.* homeassistant.components.onboarding.* homeassistant.components.oncue.* homeassistant.components.onedrive.* diff --git a/homeassistant/components/ohme/__init__.py b/homeassistant/components/ohme/__init__.py index e3e252cbf8b..c304bfdf72d 100644 --- a/homeassistant/components/ohme/__init__.py +++ b/homeassistant/components/ohme/__init__.py @@ -6,6 +6,7 @@ from homeassistant.const import CONF_EMAIL, CONF_PASSWORD from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady from homeassistant.helpers import config_validation as cv +from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.typing import ConfigType from .const import DOMAIN, PLATFORMS @@ -31,7 +32,11 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: async def async_setup_entry(hass: HomeAssistant, entry: OhmeConfigEntry) -> bool: """Set up Ohme from a config entry.""" - client = OhmeApiClient(entry.data[CONF_EMAIL], entry.data[CONF_PASSWORD]) + client = OhmeApiClient( + email=entry.data[CONF_EMAIL], + password=entry.data[CONF_PASSWORD], + session=async_get_clientsession(hass), + ) try: await client.async_login() diff --git a/homeassistant/components/ohme/manifest.json b/homeassistant/components/ohme/manifest.json index 30a55360ce2..786c615d68a 100644 --- a/homeassistant/components/ohme/manifest.json +++ b/homeassistant/components/ohme/manifest.json @@ -6,6 +6,6 @@ "documentation": "https://www.home-assistant.io/integrations/ohme/", "integration_type": "device", "iot_class": "cloud_polling", - "quality_scale": "silver", + "quality_scale": "platinum", "requirements": ["ohme==1.5.1"] } diff --git a/homeassistant/components/ohme/quality_scale.yaml b/homeassistant/components/ohme/quality_scale.yaml index 12473a08edd..2f7aece5bb6 100644 --- a/homeassistant/components/ohme/quality_scale.yaml +++ b/homeassistant/components/ohme/quality_scale.yaml @@ -75,6 +75,6 @@ rules: comment: | Not supported by the API. Accounts and devices have a one-to-one relationship. # Platinum - async-dependency: todo - inject-websession: todo - strict-typing: todo + async-dependency: done + inject-websession: done + strict-typing: done diff --git a/homeassistant/components/ohme/services.py b/homeassistant/components/ohme/services.py index 249fb1abdab..8ed29aa373d 100644 --- a/homeassistant/components/ohme/services.py +++ b/homeassistant/components/ohme/services.py @@ -5,7 +5,7 @@ from typing import Final from ohme import OhmeApiClient import voluptuous as vol -from homeassistant.config_entries import ConfigEntry, ConfigEntryState +from homeassistant.config_entries import ConfigEntryState from homeassistant.core import ( HomeAssistant, ServiceCall, @@ -16,6 +16,7 @@ from homeassistant.exceptions import ServiceValidationError from homeassistant.helpers import selector from .const import DOMAIN +from .coordinator import OhmeConfigEntry ATTR_CONFIG_ENTRY: Final = "config_entry" ATTR_PRICE_CAP: Final = "price_cap" @@ -47,7 +48,7 @@ SERVICE_SET_PRICE_CAP_SCHEMA: Final = vol.Schema( def __get_client(call: ServiceCall) -> OhmeApiClient: """Get the client from the config entry.""" entry_id: str = call.data[ATTR_CONFIG_ENTRY] - entry: ConfigEntry | None = call.hass.config_entries.async_get_entry(entry_id) + entry: OhmeConfigEntry | None = call.hass.config_entries.async_get_entry(entry_id) if not entry: raise ServiceValidationError( diff --git a/mypy.ini b/mypy.ini index 9831a183ec4..685412e6e98 100644 --- a/mypy.ini +++ b/mypy.ini @@ -3396,6 +3396,16 @@ disallow_untyped_defs = true warn_return_any = true warn_unreachable = true +[mypy-homeassistant.components.ohme.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + [mypy-homeassistant.components.onboarding.*] check_untyped_defs = true disallow_incomplete_defs = true From aaafdee56fae5d5b1e5536322a45f13440ffa68f Mon Sep 17 00:00:00 2001 From: Simone Chemelli Date: Tue, 1 Apr 2025 16:05:46 +0200 Subject: [PATCH 167/505] Remove un-necessary wait for background tasks in Comelit tests (#142000) --- tests/components/comelit/test_climate.py | 4 ++-- tests/components/comelit/test_coordinator.py | 2 +- tests/components/comelit/test_sensor.py | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/tests/components/comelit/test_climate.py b/tests/components/comelit/test_climate.py index 44478d154f4..f9f28b4d675 100644 --- a/tests/components/comelit/test_climate.py +++ b/tests/components/comelit/test_climate.py @@ -112,7 +112,7 @@ async def test_climate_data_update( freezer.tick(SCAN_INTERVAL) async_fire_time_changed(hass) - await hass.async_block_till_done(wait_background_tasks=True) + await hass.async_block_till_done() assert (state := hass.states.get(ENTITY_ID)) assert state.state == mode @@ -149,7 +149,7 @@ async def test_climate_data_update_bad_data( freezer.tick(SCAN_INTERVAL) async_fire_time_changed(hass) - await hass.async_block_till_done(wait_background_tasks=True) + await hass.async_block_till_done() assert (state := hass.states.get(ENTITY_ID)) assert state.state == HVACMode.HEAT diff --git a/tests/components/comelit/test_coordinator.py b/tests/components/comelit/test_coordinator.py index a8ef82a7e89..49e3164e875 100644 --- a/tests/components/comelit/test_coordinator.py +++ b/tests/components/comelit/test_coordinator.py @@ -43,7 +43,7 @@ async def test_coordinator_data_update_fails( freezer.tick(SCAN_INTERVAL) async_fire_time_changed(hass) - await hass.async_block_till_done(wait_background_tasks=True) + await hass.async_block_till_done() assert (state := hass.states.get(entity_id)) assert state.state == STATE_UNAVAILABLE diff --git a/tests/components/comelit/test_sensor.py b/tests/components/comelit/test_sensor.py index 56409083165..8473158f662 100644 --- a/tests/components/comelit/test_sensor.py +++ b/tests/components/comelit/test_sensor.py @@ -84,7 +84,7 @@ async def test_sensor_state_unknown( freezer.tick(SCAN_INTERVAL) async_fire_time_changed(hass) - await hass.async_block_till_done(wait_background_tasks=True) + await hass.async_block_till_done() assert (state := hass.states.get(ENTITY_ID)) assert state.state == STATE_UNKNOWN From 78338f161f8dbfa2bde3bd10fed76f5539fdc8ad Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Tue, 1 Apr 2025 16:13:18 +0200 Subject: [PATCH 168/505] Add base class for onboarding views (#141970) --- homeassistant/components/onboarding/views.py | 79 +++++++++----------- 1 file changed, 34 insertions(+), 45 deletions(-) diff --git a/homeassistant/components/onboarding/views.py b/homeassistant/components/onboarding/views.py index f0638e72d94..52f63d91770 100644 --- a/homeassistant/components/onboarding/views.py +++ b/homeassistant/components/onboarding/views.py @@ -51,7 +51,7 @@ async def async_setup( hass: HomeAssistant, data: OnboardingStoreData, store: OnboardingStorage ) -> None: """Set up the onboarding view.""" - hass.http.register_view(OnboardingView(data, store)) + hass.http.register_view(OnboardingStatusView(data, store)) hass.http.register_view(InstallationTypeOnboardingView(data)) hass.http.register_view(UserOnboardingView(data, store)) hass.http.register_view(CoreConfigOnboardingView(data, store)) @@ -63,17 +63,30 @@ async def async_setup( setup_cloud_views(hass, data) -class OnboardingView(HomeAssistantView): - """Return the onboarding status.""" +class _BaseOnboardingView(HomeAssistantView): + """Base class for onboarding views.""" + + def __init__(self, data: OnboardingStoreData) -> None: + """Initialize the onboarding view.""" + self._data = data + + +class _NoAuthBaseOnboardingView(_BaseOnboardingView): + """Base class for unauthenticated onboarding views.""" requires_auth = False + + +class OnboardingStatusView(_NoAuthBaseOnboardingView): + """Return the onboarding status.""" + url = "/api/onboarding" name = "api:onboarding" def __init__(self, data: OnboardingStoreData, store: OnboardingStorage) -> None: """Initialize the onboarding view.""" + super().__init__(data) self._store = store - self._data = data async def get(self, request: web.Request) -> web.Response: """Return the onboarding status.""" @@ -82,17 +95,12 @@ class OnboardingView(HomeAssistantView): ) -class InstallationTypeOnboardingView(HomeAssistantView): +class InstallationTypeOnboardingView(_NoAuthBaseOnboardingView): """Return the installation type during onboarding.""" - requires_auth = False url = "/api/onboarding/installation_type" name = "api:onboarding:installation_type" - def __init__(self, data: OnboardingStoreData) -> None: - """Initialize the onboarding installation type view.""" - self._data = data - async def get(self, request: web.Request) -> web.Response: """Return the onboarding status.""" if self._data["done"]: @@ -103,15 +111,15 @@ class InstallationTypeOnboardingView(HomeAssistantView): return self.json({"installation_type": info["installation_type"]}) -class _BaseOnboardingView(HomeAssistantView): - """Base class for onboarding.""" +class _BaseOnboardingStepView(_BaseOnboardingView): + """Base class for an onboarding step.""" step: str def __init__(self, data: OnboardingStoreData, store: OnboardingStorage) -> None: """Initialize the onboarding view.""" + super().__init__(data) self._store = store - self._data = data self._lock = asyncio.Lock() @callback @@ -131,7 +139,7 @@ class _BaseOnboardingView(HomeAssistantView): listener() -class UserOnboardingView(_BaseOnboardingView): +class UserOnboardingView(_BaseOnboardingStepView): """View to handle create user onboarding step.""" url = "/api/onboarding/users" @@ -197,7 +205,7 @@ class UserOnboardingView(_BaseOnboardingView): return self.json({"auth_code": auth_code}) -class CoreConfigOnboardingView(_BaseOnboardingView): +class CoreConfigOnboardingView(_BaseOnboardingStepView): """View to finish core config onboarding step.""" url = "/api/onboarding/core_config" @@ -243,7 +251,7 @@ class CoreConfigOnboardingView(_BaseOnboardingView): return self.json({}) -class IntegrationOnboardingView(_BaseOnboardingView): +class IntegrationOnboardingView(_BaseOnboardingStepView): """View to finish integration onboarding step.""" url = "/api/onboarding/integration" @@ -290,7 +298,7 @@ class IntegrationOnboardingView(_BaseOnboardingView): return self.json({"auth_code": auth_code}) -class AnalyticsOnboardingView(_BaseOnboardingView): +class AnalyticsOnboardingView(_BaseOnboardingStepView): """View to finish analytics onboarding step.""" url = "/api/onboarding/analytics" @@ -312,17 +320,7 @@ class AnalyticsOnboardingView(_BaseOnboardingView): return self.json({}) -class BackupOnboardingView(HomeAssistantView): - """Backup onboarding view.""" - - requires_auth = False - - def __init__(self, data: OnboardingStoreData) -> None: - """Initialize the view.""" - self._data = data - - -def with_backup_manager[_ViewT: BackupOnboardingView, **_P]( +def with_backup_manager[_ViewT: _BaseOnboardingView, **_P]( func: Callable[ Concatenate[_ViewT, BackupManager, web.Request, _P], Coroutine[Any, Any, web.Response], @@ -354,7 +352,7 @@ def with_backup_manager[_ViewT: BackupOnboardingView, **_P]( return with_backup -class BackupInfoView(BackupOnboardingView): +class BackupInfoView(_NoAuthBaseOnboardingView): """Get backup info view.""" url = "/api/onboarding/backup/info" @@ -373,7 +371,7 @@ class BackupInfoView(BackupOnboardingView): ) -class RestoreBackupView(BackupOnboardingView): +class RestoreBackupView(_NoAuthBaseOnboardingView): """Restore backup view.""" url = "/api/onboarding/backup/restore" @@ -418,7 +416,7 @@ class RestoreBackupView(BackupOnboardingView): return web.Response(status=HTTPStatus.OK) -class UploadBackupView(BackupOnboardingView, backup_http.UploadBackupView): +class UploadBackupView(_NoAuthBaseOnboardingView, backup_http.UploadBackupView): """Upload backup view.""" url = "/api/onboarding/backup/upload" @@ -442,16 +440,7 @@ def setup_cloud_views(hass: HomeAssistant, data: OnboardingStoreData) -> None: # pylint: disable-next=import-outside-toplevel,hass-component-root-import from homeassistant.components.cloud.const import DATA_CLOUD - class CloudOnboardingView(HomeAssistantView): - """Cloud onboarding view.""" - - requires_auth = False - - def __init__(self, data: OnboardingStoreData) -> None: - """Initialize the view.""" - self._data = data - - def with_cloud[_ViewT: CloudOnboardingView, **_P]( + def with_cloud[_ViewT: _BaseOnboardingView, **_P]( func: Callable[ Concatenate[_ViewT, web.Request, _P], Coroutine[Any, Any, web.Response], @@ -486,7 +475,7 @@ def setup_cloud_views(hass: HomeAssistant, data: OnboardingStoreData) -> None: return _with_cloud class CloudForgotPasswordView( - CloudOnboardingView, cloud_http.CloudForgotPasswordView + _NoAuthBaseOnboardingView, cloud_http.CloudForgotPasswordView ): """View to start Forgot Password flow.""" @@ -498,7 +487,7 @@ def setup_cloud_views(hass: HomeAssistant, data: OnboardingStoreData) -> None: """Handle forgot password request.""" return await super()._post(request) - class CloudLoginView(CloudOnboardingView, cloud_http.CloudLoginView): + class CloudLoginView(_NoAuthBaseOnboardingView, cloud_http.CloudLoginView): """Login to Home Assistant Cloud.""" url = "/api/onboarding/cloud/login" @@ -509,7 +498,7 @@ def setup_cloud_views(hass: HomeAssistant, data: OnboardingStoreData) -> None: """Handle login request.""" return await super()._post(request) - class CloudLogoutView(CloudOnboardingView, cloud_http.CloudLogoutView): + class CloudLogoutView(_NoAuthBaseOnboardingView, cloud_http.CloudLogoutView): """Log out of the Home Assistant cloud.""" url = "/api/onboarding/cloud/logout" @@ -520,7 +509,7 @@ def setup_cloud_views(hass: HomeAssistant, data: OnboardingStoreData) -> None: """Handle logout request.""" return await super()._post(request) - class CloudStatusView(CloudOnboardingView): + class CloudStatusView(_NoAuthBaseOnboardingView): """Get cloud status view.""" url = "/api/onboarding/cloud/status" From c4f0d9d2fa4c1b63a9ce2c0170b46273516d611e Mon Sep 17 00:00:00 2001 From: Artur Pragacz <49985303+arturpragacz@users.noreply.github.com> Date: Tue, 1 Apr 2025 16:28:29 +0200 Subject: [PATCH 169/505] Always set up after dependencies if they are scheduled to be loaded (#141593) * Always setup after dependencies * Add comment --- homeassistant/setup.py | 23 +++++++++++------------ tests/test_setup.py | 23 +++++++++++++++++++++++ 2 files changed, 34 insertions(+), 12 deletions(-) diff --git a/homeassistant/setup.py b/homeassistant/setup.py index 334e3a9e074..7f037482f0d 100644 --- a/homeassistant/setup.py +++ b/homeassistant/setup.py @@ -213,17 +213,24 @@ async def _async_process_dependencies( if dep not in hass.config.components } - after_dependencies_tasks: dict[str, asyncio.Future[bool]] = {} to_be_loaded = hass.data.get(DATA_SETUP_DONE, {}) + # We don't want to just wait for the futures from `to_be_loaded` here. + # We want to ensure that our after_dependencies are always actually + # scheduled to be set up, as if for whatever reason they had not been, + # we would deadlock waiting for them here. for dep in integration.after_dependencies: if ( dep not in dependencies_tasks and dep in to_be_loaded and dep not in hass.config.components ): - after_dependencies_tasks[dep] = to_be_loaded[dep] + dependencies_tasks[dep] = setup_futures.get(dep) or create_eager_task( + async_setup_component(hass, dep, config), + name=f"setup {dep} as after dependency of {integration.domain}", + loop=hass.loop, + ) - if not dependencies_tasks and not after_dependencies_tasks: + if not dependencies_tasks: return [] if dependencies_tasks: @@ -232,17 +239,9 @@ async def _async_process_dependencies( integration.domain, dependencies_tasks.keys(), ) - if after_dependencies_tasks: - _LOGGER.debug( - "Dependency %s will wait for after dependencies %s", - integration.domain, - after_dependencies_tasks.keys(), - ) async with hass.timeout.async_freeze(integration.domain): - results = await asyncio.gather( - *dependencies_tasks.values(), *after_dependencies_tasks.values() - ) + results = await asyncio.gather(*dependencies_tasks.values()) failed = [ domain for idx, domain in enumerate(dependencies_tasks) if not results[idx] diff --git a/tests/test_setup.py b/tests/test_setup.py index bb221c7cb4c..1f0e668d4e2 100644 --- a/tests/test_setup.py +++ b/tests/test_setup.py @@ -458,6 +458,29 @@ async def test_set_domains_to_be_loaded(hass: HomeAssistant) -> None: assert not hass.data[setup.DATA_SETUP_DONE] +async def test_component_setup_after_dependencies(hass: HomeAssistant) -> None: + """Test that after dependencies are set up before the component.""" + mock_integration(hass, MockModule("dep")) + mock_integration( + hass, MockModule("comp", partial_manifest={"after_dependencies": ["dep"]}) + ) + mock_integration( + hass, MockModule("comp2", partial_manifest={"after_dependencies": ["dep"]}) + ) + + setup.async_set_domains_to_be_loaded(hass, {"comp"}) + + assert await setup.async_setup_component(hass, "comp", {}) + assert "comp" in hass.config.components + assert "dep" not in hass.config.components + + setup.async_set_domains_to_be_loaded(hass, {"comp2", "dep"}) + + assert await setup.async_setup_component(hass, "comp2", {}) + assert "comp2" in hass.config.components + assert "dep" in hass.config.components + + async def test_component_setup_with_validation_and_dependency( hass: HomeAssistant, ) -> None: From b9a0d553abecf870ee18887726a824dff771430c Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Tue, 1 Apr 2025 16:29:18 +0200 Subject: [PATCH 170/505] Fix import issues related to onboarding views (#141919) * Fix import issues related to onboarding views * Add ha-intents and numpy to pyproject.toml * Add more requirements to pyproject.toml * Add more requirements to pyproject.toml --- homeassistant/components/onboarding/views.py | 16 ++++++-- homeassistant/package_constraints.txt | 1 + pyproject.toml | 40 ++++++++++++++++++++ requirements.txt | 8 ++++ 4 files changed, 62 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/onboarding/views.py b/homeassistant/components/onboarding/views.py index 52f63d91770..978e16963d9 100644 --- a/homeassistant/components/onboarding/views.py +++ b/homeassistant/components/onboarding/views.py @@ -31,7 +31,7 @@ from homeassistant.helpers import area_registry as ar from homeassistant.helpers.backup import async_get_manager as async_get_backup_manager from homeassistant.helpers.system_info import async_get_system_info from homeassistant.helpers.translation import async_get_translations -from homeassistant.setup import async_setup_component +from homeassistant.setup import SetupPhases, async_pause_setup, async_setup_component if TYPE_CHECKING: from . import OnboardingData, OnboardingStorage, OnboardingStoreData @@ -60,7 +60,7 @@ async def async_setup( hass.http.register_view(BackupInfoView(data)) hass.http.register_view(RestoreBackupView(data)) hass.http.register_view(UploadBackupView(data)) - setup_cloud_views(hass, data) + await setup_cloud_views(hass, data) class _BaseOnboardingView(HomeAssistantView): @@ -428,9 +428,19 @@ class UploadBackupView(_NoAuthBaseOnboardingView, backup_http.UploadBackupView): return await self._post(request) -def setup_cloud_views(hass: HomeAssistant, data: OnboardingStoreData) -> None: +async def setup_cloud_views(hass: HomeAssistant, data: OnboardingStoreData) -> None: """Set up the cloud views.""" + with async_pause_setup(hass, SetupPhases.WAIT_IMPORT_PACKAGES): + # Import the cloud integration in an executor to avoid blocking the + # event loop. + def import_cloud() -> None: + """Import the cloud integration.""" + # pylint: disable-next=import-outside-toplevel + from homeassistant.components.cloud import http_api # noqa: F401 + + await hass.async_add_import_executor_job(import_cloud) + # The cloud integration is imported locally to avoid cloud being imported by # bootstrap.py and to avoid circular imports. diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 3465b24fb2a..ba28ff34157 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -45,6 +45,7 @@ ifaddr==0.2.0 Jinja2==3.1.6 lru-dict==1.3.0 mutagen==1.47.0 +numpy==2.2.2 orjson==3.10.16 packaging>=23.1 paho-mqtt==2.1.0 diff --git a/pyproject.toml b/pyproject.toml index b90738bdbef..b5ba5a0efd3 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -45,16 +45,41 @@ dependencies = [ "ciso8601==2.3.2", "cronsim==2.6", "fnv-hash-fast==1.4.0", + # ha-ffmpeg is indirectly imported from onboarding via the import chain + # onboarding->cloud->assist_pipeline->tts->ffmpeg. Onboarding needs + # to be setup in stage 0, but we don't want to also promote cloud with all its + # dependencies to stage 0. + "ha-ffmpeg==3.2.2", # hass-nabucasa is imported by helpers which don't depend on the cloud # integration "hass-nabucasa==0.94.0", + # hassil is indirectly imported from onboarding via the import chain + # onboarding->cloud->assist_pipeline->conversation->hassil. Onboarding needs + # to be setup in stage 0, but we don't want to also promote cloud with all its + # dependencies to stage 0. + "hassil==2.2.3", # When bumping httpx, please check the version pins of # httpcore, anyio, and h11 in gen_requirements_all "httpx==0.28.1", "home-assistant-bluetooth==1.13.1", + # home_assistant_intents is indirectly imported from onboarding via the import chain + # onboarding->cloud->assist_pipeline->conversation->home_assistant_intents. Onboarding needs + # to be setup in stage 0, but we don't want to also promote cloud with all its + # dependencies to stage 0. + "home-assistant-intents==2025.3.28", "ifaddr==0.2.0", "Jinja2==3.1.6", "lru-dict==1.3.0", + # mutagen is indirectly imported from onboarding via the import chain + # onboarding->cloud->assist_pipeline->tts->mutagen. Onboarding needs + # to be setup in stage 0, but we don't want to also promote cloud with all its + # dependencies to stage 0. + "mutagen==1.47.0", + # numpy is indirectly imported from onboarding via the import chain + # onboarding->cloud->alexa->camera->stream->numpy. Onboarding needs + # to be setup in stage 0, but we don't want to also promote cloud with all its + # dependencies to stage 0. + "numpy==2.2.2", "PyJWT==2.10.1", # PyJWT has loose dependency. We want the latest one. "cryptography==44.0.1", @@ -64,7 +89,22 @@ dependencies = [ "orjson==3.10.16", "packaging>=23.1", "psutil-home-assistant==0.0.1", + # pymicro_vad is indirectly imported from onboarding via the import chain + # onboarding->cloud->assist_pipeline->pymicro_vad. Onboarding needs + # to be setup in stage 0, but we don't want to also promote cloud with all its + # dependencies to stage 0. + "pymicro-vad==1.0.1", + # pyspeex-noise is indirectly imported from onboarding via the import chain + # onboarding->cloud->assist_pipeline->pyspeex_noise. Onboarding needs + # to be setup in stage 0, but we don't want to also promote cloud with all its + # dependencies to stage 0. + "pyspeex-noise==1.0.2", "python-slugify==8.0.4", + # PyTurboJPEG is indirectly imported from onboarding via the import chain + # onboarding->cloud->camera->pyturbojpeg. Onboarding needs + # to be setup in stage 0, but we don't want to also promote cloud with all its + # dependencies to stage 0. + "PyTurboJPEG==1.7.5", "PyYAML==6.0.2", "requests==2.32.3", "securetar==2025.2.1", diff --git a/requirements.txt b/requirements.txt index bd3722b3617..0ef5062201a 100644 --- a/requirements.txt +++ b/requirements.txt @@ -22,12 +22,17 @@ certifi>=2021.5.30 ciso8601==2.3.2 cronsim==2.6 fnv-hash-fast==1.4.0 +ha-ffmpeg==3.2.2 hass-nabucasa==0.94.0 +hassil==2.2.3 httpx==0.28.1 home-assistant-bluetooth==1.13.1 +home-assistant-intents==2025.3.28 ifaddr==0.2.0 Jinja2==3.1.6 lru-dict==1.3.0 +mutagen==1.47.0 +numpy==2.2.2 PyJWT==2.10.1 cryptography==44.0.1 Pillow==11.1.0 @@ -36,7 +41,10 @@ pyOpenSSL==25.0.0 orjson==3.10.16 packaging>=23.1 psutil-home-assistant==0.0.1 +pymicro-vad==1.0.1 +pyspeex-noise==1.0.2 python-slugify==8.0.4 +PyTurboJPEG==1.7.5 PyYAML==6.0.2 requests==2.32.3 securetar==2025.2.1 From 23b79b2f39de389c087fb9de0397700427cf1495 Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Tue, 1 Apr 2025 16:39:22 +0200 Subject: [PATCH 171/505] Capitalize app name in `deluge` description string (#142003) This should help fix / prevent some wrong translations like "impostazioni di diluvio" in Italian. --- homeassistant/components/deluge/strings.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/deluge/strings.json b/homeassistant/components/deluge/strings.json index 6adde8ef7df..ddea78b315f 100644 --- a/homeassistant/components/deluge/strings.json +++ b/homeassistant/components/deluge/strings.json @@ -2,7 +2,7 @@ "config": { "step": { "user": { - "description": "To be able to use this integration, you have to enable the following option in deluge settings: Daemon > Allow remote controls", + "description": "To be able to use this integration, you have to enable the following option in Deluge settings: Daemon > Allow remote controls", "data": { "host": "[%key:common::config_flow::data::host%]", "username": "[%key:common::config_flow::data::username%]", From da9b3dc68b004f80d0acba76cd6b17e6bc4336a8 Mon Sep 17 00:00:00 2001 From: tmenguy Date: Tue, 1 Apr 2025 17:14:21 +0200 Subject: [PATCH 172/505] Better throttling handling for the Renault API (#141667) * Added some better throttling handling for the Renault API, it fixes #106777 HA ticket * Added some better throttling handling for the Renault API, it fixes #106777 HA ticket, test fixing * Update homeassistant/components/renault/coordinator.py Co-authored-by: epenet <6771947+epenet@users.noreply.github.com> * Update homeassistant/components/renault/coordinator.py Co-authored-by: epenet <6771947+epenet@users.noreply.github.com> * Update homeassistant/components/renault/coordinator.py Co-authored-by: epenet <6771947+epenet@users.noreply.github.com> * Update homeassistant/components/renault/coordinator.py Co-authored-by: epenet <6771947+epenet@users.noreply.github.com> * Update homeassistant/components/renault/coordinator.py Co-authored-by: epenet <6771947+epenet@users.noreply.github.com> * Update homeassistant/components/renault/coordinator.py Co-authored-by: epenet <6771947+epenet@users.noreply.github.com> * Update homeassistant/components/renault/coordinator.py Co-authored-by: epenet <6771947+epenet@users.noreply.github.com> * Update homeassistant/components/renault/renault_hub.py Co-authored-by: epenet <6771947+epenet@users.noreply.github.com> * bigger testsuite for #106777 HA ticket * bigger testsuite for #106777 HA ticket * bigger testsuite for #106777 HA ticket * bigger testsuite for #106777 HA ticket * Adjust tests * Update homeassistant/components/renault/coordinator.py Co-authored-by: epenet <6771947+epenet@users.noreply.github.com> * Update homeassistant/components/renault/renault_hub.py Co-authored-by: epenet <6771947+epenet@users.noreply.github.com> * Update homeassistant/components/renault/renault_hub.py Co-authored-by: epenet <6771947+epenet@users.noreply.github.com> * Update tests/components/renault/test_sensor.py * Update tests/components/renault/test_sensor.py * Update tests/components/renault/test_sensor.py * requested changes #106777 HA ticket * Use unkown * Fix test * Fix test again * Reduce and fix * Use assumed_state * requested changes #106777 HA ticket --------- Co-authored-by: epenet <6771947+epenet@users.noreply.github.com> --- homeassistant/components/renault/const.py | 3 ++ .../components/renault/coordinator.py | 28 +++++++++++++++++++ homeassistant/components/renault/entity.py | 5 ++++ .../components/renault/renault_hub.py | 27 +++++++++++++++++- .../components/renault/renault_vehicle.py | 4 +++ tests/components/renault/test_sensor.py | 19 ++++++++++--- 6 files changed, 81 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/renault/const.py b/homeassistant/components/renault/const.py index 201a07c6783..05f8099b168 100644 --- a/homeassistant/components/renault/const.py +++ b/homeassistant/components/renault/const.py @@ -9,6 +9,9 @@ CONF_KAMEREON_ACCOUNT_ID = "kamereon_account_id" DEFAULT_SCAN_INTERVAL = 420 # 7 minutes +# If throttled time to pause the updates, in seconds +COOLING_UPDATES_SECONDS = 60 * 15 # 15 minutes + PLATFORMS = [ Platform.BINARY_SENSOR, Platform.BUTTON, diff --git a/homeassistant/components/renault/coordinator.py b/homeassistant/components/renault/coordinator.py index a90331730bc..c768c436133 100644 --- a/homeassistant/components/renault/coordinator.py +++ b/homeassistant/components/renault/coordinator.py @@ -12,6 +12,7 @@ from renault_api.kamereon.exceptions import ( AccessDeniedException, KamereonResponseException, NotSupportedException, + QuotaLimitException, ) from renault_api.kamereon.models import KamereonVehicleDataAttributes @@ -20,6 +21,7 @@ from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, Upda if TYPE_CHECKING: from . import RenaultConfigEntry + from .renault_hub import RenaultHub T = TypeVar("T", bound=KamereonVehicleDataAttributes) @@ -37,6 +39,7 @@ class RenaultDataUpdateCoordinator(DataUpdateCoordinator[T]): self, hass: HomeAssistant, config_entry: RenaultConfigEntry, + hub: RenaultHub, logger: logging.Logger, *, name: str, @@ -54,10 +57,24 @@ class RenaultDataUpdateCoordinator(DataUpdateCoordinator[T]): ) self.access_denied = False self.not_supported = False + self.assumed_state = False + self._has_already_worked = False + self._hub = hub async def _async_update_data(self) -> T: """Fetch the latest data from the source.""" + + if self._hub.is_throttled(): + if not self._has_already_worked: + raise UpdateFailed("Renault hub currently throttled: init skipped") + # we have been throttled and decided to cooldown + # so do not count this update as an error + # coordinator. last_update_success should still be ok + self.logger.debug("Renault hub currently throttled: scan skipped") + self.assumed_state = True + return self.data + try: async with _PARALLEL_SEMAPHORE: data = await self.update_method() @@ -70,6 +87,16 @@ class RenaultDataUpdateCoordinator(DataUpdateCoordinator[T]): self.access_denied = True raise UpdateFailed(f"This endpoint is denied: {err}") from err + except QuotaLimitException as err: + # The data we got is not bad per see, initiate cooldown for all coordinators + self._hub.set_throttled() + if self._has_already_worked: + self.assumed_state = True + self.logger.warning("Renault API throttled") + return self.data + + raise UpdateFailed(f"Renault API throttled: {err}") from err + except NotSupportedException as err: # Disable because the vehicle does not support this Renault endpoint. self.update_interval = None @@ -81,6 +108,7 @@ class RenaultDataUpdateCoordinator(DataUpdateCoordinator[T]): raise UpdateFailed(f"Error communicating with API: {err}") from err self._has_already_worked = True + self.assumed_state = False return data async def async_config_entry_first_refresh(self) -> None: diff --git a/homeassistant/components/renault/entity.py b/homeassistant/components/renault/entity.py index 7beb91e9603..81d81a18b7f 100644 --- a/homeassistant/components/renault/entity.py +++ b/homeassistant/components/renault/entity.py @@ -60,3 +60,8 @@ class RenaultDataEntity( def _get_data_attr(self, key: str) -> StateType: """Return the attribute value from the coordinator data.""" return cast(StateType, getattr(self.coordinator.data, key)) + + @property + def assumed_state(self) -> bool: + """Return True if unable to access real state of the entity.""" + return self.coordinator.assumed_state diff --git a/homeassistant/components/renault/renault_hub.py b/homeassistant/components/renault/renault_hub.py index b37390526cf..e5168fc81fd 100644 --- a/homeassistant/components/renault/renault_hub.py +++ b/homeassistant/components/renault/renault_hub.py @@ -27,7 +27,13 @@ from homeassistant.helpers.aiohttp_client import async_get_clientsession if TYPE_CHECKING: from . import RenaultConfigEntry -from .const import CONF_KAMEREON_ACCOUNT_ID, DEFAULT_SCAN_INTERVAL +from time import time + +from .const import ( + CONF_KAMEREON_ACCOUNT_ID, + COOLING_UPDATES_SECONDS, + DEFAULT_SCAN_INTERVAL, +) from .renault_vehicle import RenaultVehicleProxy LOGGER = logging.getLogger(__name__) @@ -45,6 +51,24 @@ class RenaultHub: self._account: RenaultAccount | None = None self._vehicles: dict[str, RenaultVehicleProxy] = {} + self._got_throttled_at_time: float | None = None + + def set_throttled(self) -> None: + """We got throttled, we need to adjust the rate limit.""" + if self._got_throttled_at_time is None: + self._got_throttled_at_time = time() + + def is_throttled(self) -> bool: + """Check if we are throttled.""" + if self._got_throttled_at_time is None: + return False + + if time() - self._got_throttled_at_time > COOLING_UPDATES_SECONDS: + self._got_throttled_at_time = None + return False + + return True + async def attempt_login(self, username: str, password: str) -> bool: """Attempt login to Renault servers.""" try: @@ -99,6 +123,7 @@ class RenaultHub: vehicle = RenaultVehicleProxy( hass=self._hass, config_entry=config_entry, + hub=self, vehicle=await renault_account.get_api_vehicle(vehicle_link.vin), details=vehicle_link.vehicleDetails, scan_interval=scan_interval, diff --git a/homeassistant/components/renault/renault_vehicle.py b/homeassistant/components/renault/renault_vehicle.py index 1cce0e4459f..1ab9bf0bd5a 100644 --- a/homeassistant/components/renault/renault_vehicle.py +++ b/homeassistant/components/renault/renault_vehicle.py @@ -20,6 +20,7 @@ from homeassistant.helpers.device_registry import DeviceInfo if TYPE_CHECKING: from . import RenaultConfigEntry + from .renault_hub import RenaultHub from .const import DOMAIN from .coordinator import RenaultDataUpdateCoordinator @@ -68,6 +69,7 @@ class RenaultVehicleProxy: self, hass: HomeAssistant, config_entry: RenaultConfigEntry, + hub: RenaultHub, vehicle: RenaultVehicle, details: models.KamereonVehicleDetails, scan_interval: timedelta, @@ -87,6 +89,7 @@ class RenaultVehicleProxy: self.coordinators: dict[str, RenaultDataUpdateCoordinator] = {} self.hvac_target_temperature = 21 self._scan_interval = scan_interval + self._hub = hub @property def details(self) -> models.KamereonVehicleDetails: @@ -104,6 +107,7 @@ class RenaultVehicleProxy: coord.key: RenaultDataUpdateCoordinator( self.hass, self.config_entry, + self._hub, LOGGER, name=f"{self.details.vin} {coord.key}", update_method=coord.update_method(self._vehicle), diff --git a/tests/components/renault/test_sensor.py b/tests/components/renault/test_sensor.py index fb5fc205a7b..bce50ec4fbf 100644 --- a/tests/components/renault/test_sensor.py +++ b/tests/components/renault/test_sensor.py @@ -10,7 +10,7 @@ from renault_api.kamereon.exceptions import QuotaLimitException from syrupy.assertion import SnapshotAssertion from homeassistant.config_entries import ConfigEntry -from homeassistant.const import STATE_UNAVAILABLE, Platform +from homeassistant.const import ATTR_ASSUMED_STATE, STATE_UNAVAILABLE, Platform from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr, entity_registry as er @@ -184,7 +184,7 @@ async def test_sensor_throttling_during_setup( for get_data_mock in patches.values(): get_data_mock.side_effect = None patches["battery_status"].return_value.batteryLevel = 55 - freezer.tick(datetime.timedelta(minutes=10)) + freezer.tick(datetime.timedelta(minutes=20)) async_fire_time_changed(hass) await hass.async_block_till_done() @@ -196,6 +196,7 @@ async def test_sensor_throttling_after_init( hass: HomeAssistant, config_entry: ConfigEntry, vehicle_type: str, + caplog: pytest.LogCaptureFixture, freezer: FrozenDateTimeFactory, ) -> None: """Test for Renault sensors with a throttling error during setup.""" @@ -209,8 +210,11 @@ async def test_sensor_throttling_after_init( # Initial state entity_id = "sensor.reg_number_battery" assert hass.states.get(entity_id).state == "60" + assert not hass.states.get(entity_id).attributes.get(ATTR_ASSUMED_STATE) + assert "Renault API throttled: scan skipped" not in caplog.text # Test QuotaLimitException state + caplog.clear() for get_data_mock in patches.values(): get_data_mock.side_effect = QuotaLimitException( "err.func.wired.overloaded", "You have reached your quota limit" @@ -219,14 +223,21 @@ async def test_sensor_throttling_after_init( async_fire_time_changed(hass) await hass.async_block_till_done() - assert hass.states.get(entity_id).state == STATE_UNAVAILABLE + assert hass.states.get(entity_id).state == "60" + assert hass.states.get(entity_id).attributes.get(ATTR_ASSUMED_STATE) + assert "Renault API throttled" in caplog.text + assert "Renault hub currently throttled: scan skipped" in caplog.text # Test QuotaLimitException recovery, with new battery level + caplog.clear() for get_data_mock in patches.values(): get_data_mock.side_effect = None patches["battery_status"].return_value.batteryLevel = 55 - freezer.tick(datetime.timedelta(minutes=10)) + freezer.tick(datetime.timedelta(minutes=20)) async_fire_time_changed(hass) await hass.async_block_till_done() assert hass.states.get(entity_id).state == "55" + assert not hass.states.get(entity_id).attributes.get(ATTR_ASSUMED_STATE) + assert "Renault API throttled" not in caplog.text + assert "Renault hub currently throttled: scan skipped" not in caplog.text From e0b030c8927673b6ee7f87494d81052cebb363f2 Mon Sep 17 00:00:00 2001 From: LG-ThinQ-Integration Date: Wed, 2 Apr 2025 00:14:39 +0900 Subject: [PATCH 173/505] Add select for dehumidifier's mode control (#140572) * Add select for dehumidifier * Add device_class POWER * Delete not related to select * Update homeassistant/components/lg_thinq/strings.json --------- Co-authored-by: yunseon.park Co-authored-by: Joost Lekkerkerker --- homeassistant/components/lg_thinq/icons.json | 3 +++ homeassistant/components/lg_thinq/select.py | 8 +++++++- homeassistant/components/lg_thinq/strings.json | 11 +++++++++++ 3 files changed, 21 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/lg_thinq/icons.json b/homeassistant/components/lg_thinq/icons.json index 787b50167c1..3b0baaaaf75 100644 --- a/homeassistant/components/lg_thinq/icons.json +++ b/homeassistant/components/lg_thinq/icons.json @@ -169,6 +169,9 @@ "current_job_mode": { "default": "mdi:format-list-bulleted" }, + "current_job_mode_dehumidifier": { + "default": "mdi:format-list-bulleted" + }, "operation_mode": { "default": "mdi:gesture-tap-button" }, diff --git a/homeassistant/components/lg_thinq/select.py b/homeassistant/components/lg_thinq/select.py index 929fa0b1d28..3f29ee9e5c8 100644 --- a/homeassistant/components/lg_thinq/select.py +++ b/homeassistant/components/lg_thinq/select.py @@ -98,7 +98,13 @@ DEVICE_TYPE_SELECT_MAP: dict[DeviceType, tuple[SelectEntityDescription, ...]] = AIR_FLOW_SELECT_DESC[ThinQProperty.WIND_STRENGTH], SELECT_DESC[ThinQProperty.CURRENT_JOB_MODE], ), - DeviceType.DEHUMIDIFIER: (AIR_FLOW_SELECT_DESC[ThinQProperty.WIND_STRENGTH],), + DeviceType.DEHUMIDIFIER: ( + AIR_FLOW_SELECT_DESC[ThinQProperty.WIND_STRENGTH], + SelectEntityDescription( + key=ThinQProperty.CURRENT_JOB_MODE, + translation_key="current_job_mode_dehumidifier", + ), + ), DeviceType.DISH_WASHER: ( OPERATION_SELECT_DESC[ThinQProperty.DISH_WASHER_OPERATION_MODE], ), diff --git a/homeassistant/components/lg_thinq/strings.json b/homeassistant/components/lg_thinq/strings.json index 09e3718af9b..bb3865254a3 100644 --- a/homeassistant/components/lg_thinq/strings.json +++ b/homeassistant/components/lg_thinq/strings.json @@ -928,6 +928,17 @@ "vacation": "Vacation" } }, + "current_job_mode_dehumidifier": { + "name": "[%key:component::lg_thinq::entity::sensor::current_job_mode::name%]", + "state": { + "air_clean": "[%key:component::lg_thinq::entity::sensor::current_job_mode::state::air_clean%]", + "clothes_dry": "[%key:component::lg_thinq::entity::sensor::current_job_mode::state::clothes_dry%]", + "intensive_dry": "[%key:component::lg_thinq::entity::sensor::current_job_mode::state::intensive_dry%]", + "quiet_humidity": "[%key:component::lg_thinq::entity::sensor::current_job_mode::state::quiet_humidity%]", + "rapid_humidity": "[%key:component::lg_thinq::entity::sensor::current_job_mode::state::rapid_humidity%]", + "smart_humidity": "[%key:component::lg_thinq::entity::sensor::current_job_mode::state::smart_humidity%]" + } + }, "operation_mode": { "name": "Operation", "state": { From 597540b61120a182ad08866a161fd106bf79d2f9 Mon Sep 17 00:00:00 2001 From: aaronburt <42388542+aaronburt@users.noreply.github.com> Date: Tue, 1 Apr 2025 16:17:34 +0100 Subject: [PATCH 174/505] Correct unit conversion for OneDrive quota display (#140337) * Correct unit conversion for OneDrive quota display * Convert OneDrive quota values from bytes to GiB in coordinator and update strings --- homeassistant/components/onedrive/coordinator.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/onedrive/coordinator.py b/homeassistant/components/onedrive/coordinator.py index 7b2dbaab87a..3eb7d762712 100644 --- a/homeassistant/components/onedrive/coordinator.py +++ b/homeassistant/components/onedrive/coordinator.py @@ -88,8 +88,8 @@ class OneDriveUpdateCoordinator(DataUpdateCoordinator[Drive]): ), translation_key=key, translation_placeholders={ - "total": str(drive.quota.total), - "used": str(drive.quota.used), + "total": f"{drive.quota.total / (1024**3):.2f}", + "used": f"{drive.quota.used / (1024**3):.2f}", }, ) return drive From 935db1308f4fbfcd10da1de55aba0f2ea0304038 Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Tue, 1 Apr 2025 18:07:19 +0200 Subject: [PATCH 175/505] Add common states for "Low", "Medium" and "High" (#141999) --- homeassistant/strings.json | 3 +++ 1 file changed, 3 insertions(+) diff --git a/homeassistant/strings.json b/homeassistant/strings.json index 13a6d1ef759..763d50e79d7 100644 --- a/homeassistant/strings.json +++ b/homeassistant/strings.json @@ -126,9 +126,12 @@ "discharging": "Discharging", "disconnected": "Disconnected", "enabled": "Enabled", + "high": "High", "home": "Home", "idle": "Idle", "locked": "Locked", + "low": "Low", + "medium": "Medium", "no": "No", "not_home": "Away", "off": "Off", From 426e9846d9d6a12bdfb599ea4557790c2397ce50 Mon Sep 17 00:00:00 2001 From: Markus Adrario Date: Tue, 1 Apr 2025 18:08:36 +0200 Subject: [PATCH 176/505] Add Homee climate platform (#141616) * Add climate platform * Add climate tests * Add service tests * Add snapshot test * Code optimazitions 1 * Add test for current preset mode. * code optimization 2 * code optimization 3 * small tweaks * another small tweak * Last minute changes * Update tests/components/homee/test_climate.py Co-authored-by: Joost Lekkerkerker * fix review comments * typo * more review fixes. * maybe final review fixes. --------- Co-authored-by: Joost Lekkerkerker --- homeassistant/components/homee/__init__.py | 1 + homeassistant/components/homee/climate.py | 200 +++++++++++++ homeassistant/components/homee/const.py | 3 + homeassistant/components/homee/icons.json | 11 + homeassistant/components/homee/strings.json | 11 + .../fixtures/thermostat_only_targettemp.json | 52 ++++ .../fixtures/thermostat_with_currenttemp.json | 77 +++++ .../thermostat_with_heating_mode.json | 127 ++++++++ .../fixtures/thermostat_with_preset.json | 98 +++++++ .../homee/snapshots/test_climate.ambr | 274 ++++++++++++++++++ tests/components/homee/test_climate.py | 270 +++++++++++++++++ 11 files changed, 1124 insertions(+) create mode 100644 homeassistant/components/homee/climate.py create mode 100644 tests/components/homee/fixtures/thermostat_only_targettemp.json create mode 100644 tests/components/homee/fixtures/thermostat_with_currenttemp.json create mode 100644 tests/components/homee/fixtures/thermostat_with_heating_mode.json create mode 100644 tests/components/homee/fixtures/thermostat_with_preset.json create mode 100644 tests/components/homee/snapshots/test_climate.ambr create mode 100644 tests/components/homee/test_climate.py diff --git a/homeassistant/components/homee/__init__.py b/homeassistant/components/homee/__init__.py index 9fd88ee40aa..fbd34743496 100644 --- a/homeassistant/components/homee/__init__.py +++ b/homeassistant/components/homee/__init__.py @@ -17,6 +17,7 @@ _LOGGER = logging.getLogger(__name__) PLATFORMS = [ Platform.BINARY_SENSOR, Platform.BUTTON, + Platform.CLIMATE, Platform.COVER, Platform.LIGHT, Platform.LOCK, diff --git a/homeassistant/components/homee/climate.py b/homeassistant/components/homee/climate.py new file mode 100644 index 00000000000..3411d31461c --- /dev/null +++ b/homeassistant/components/homee/climate.py @@ -0,0 +1,200 @@ +"""The Homee climate platform.""" + +from typing import Any + +from pyHomee.const import AttributeType, NodeProfile +from pyHomee.model import HomeeNode + +from homeassistant.components.climate import ( + ATTR_TEMPERATURE, + PRESET_BOOST, + PRESET_ECO, + PRESET_NONE, + ClimateEntity, + ClimateEntityFeature, + HVACAction, + HVACMode, +) +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from . import HomeeConfigEntry +from .const import CLIMATE_PROFILES, DOMAIN, HOMEE_UNIT_TO_HA_UNIT, PRESET_MANUAL +from .entity import HomeeNodeEntity + +PARALLEL_UPDATES = 0 + +ROOM_THERMOSTATS = { + NodeProfile.ROOM_THERMOSTAT, + NodeProfile.ROOM_THERMOSTAT_WITH_HUMIDITY_SENSOR, + NodeProfile.WIFI_ROOM_THERMOSTAT, +} + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: HomeeConfigEntry, + async_add_devices: AddConfigEntryEntitiesCallback, +) -> None: + """Add the Homee platform for the climate component.""" + + async_add_devices( + HomeeClimate(node, config_entry) + for node in config_entry.runtime_data.nodes + if node.profile in CLIMATE_PROFILES + ) + + +class HomeeClimate(HomeeNodeEntity, ClimateEntity): + """Representation of a Homee climate entity.""" + + _attr_name = None + _attr_translation_key = DOMAIN + + def __init__(self, node: HomeeNode, entry: HomeeConfigEntry) -> None: + """Initialize a Homee climate entity.""" + super().__init__(node, entry) + + ( + self._attr_supported_features, + self._attr_hvac_modes, + self._attr_preset_modes, + ) = get_climate_features(self._node) + + self._target_temp = self._node.get_attribute_by_type( + AttributeType.TARGET_TEMPERATURE + ) + assert self._target_temp is not None + self._attr_temperature_unit = str(HOMEE_UNIT_TO_HA_UNIT[self._target_temp.unit]) + self._attr_target_temperature_step = self._target_temp.step_value + self._attr_unique_id = f"{self._attr_unique_id}-{self._target_temp.id}" + + self._heating_mode = self._node.get_attribute_by_type( + AttributeType.HEATING_MODE + ) + self._temperature = self._node.get_attribute_by_type(AttributeType.TEMPERATURE) + self._valve_position = self._node.get_attribute_by_type( + AttributeType.CURRENT_VALVE_POSITION + ) + + @property + def hvac_mode(self) -> HVACMode: + """Return the hvac operation mode.""" + if ClimateEntityFeature.TURN_OFF in self.supported_features and ( + self._heating_mode is not None + ): + if self._heating_mode.current_value == 0: + return HVACMode.OFF + + return HVACMode.HEAT + + @property + def hvac_action(self) -> HVACAction: + """Return the hvac action.""" + if self._heating_mode is not None and self._heating_mode.current_value == 0: + return HVACAction.OFF + + if ( + self._valve_position is not None and self._valve_position.current_value == 0 + ) or ( + self._temperature is not None + and self._temperature.current_value >= self.target_temperature + ): + return HVACAction.IDLE + + return HVACAction.HEATING + + @property + def preset_mode(self) -> str: + """Return the present preset mode.""" + if ( + ClimateEntityFeature.PRESET_MODE in self.supported_features + and self._heating_mode is not None + and self._heating_mode.current_value > 0 + ): + assert self._attr_preset_modes is not None + return self._attr_preset_modes[int(self._heating_mode.current_value) - 1] + + return PRESET_NONE + + @property + def current_temperature(self) -> float | None: + """Return the current temperature.""" + if self._temperature is not None: + return self._temperature.current_value + return None + + @property + def target_temperature(self) -> float: + """Return the temperature we try to reach.""" + assert self._target_temp is not None + return self._target_temp.current_value + + @property + def min_temp(self) -> float: + """Return the lowest settable target temperature.""" + assert self._target_temp is not None + return self._target_temp.minimum + + @property + def max_temp(self) -> float: + """Return the lowest settable target temperature.""" + assert self._target_temp is not None + return self._target_temp.maximum + + async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None: + """Set new target hvac mode.""" + # Currently only HEAT and OFF are supported. + assert self._heating_mode is not None + await self.async_set_homee_value( + self._heating_mode, float(hvac_mode == HVACMode.HEAT) + ) + + async def async_set_preset_mode(self, preset_mode: str) -> None: + """Set new target preset mode.""" + assert self._heating_mode is not None and self._attr_preset_modes is not None + await self.async_set_homee_value( + self._heating_mode, self._attr_preset_modes.index(preset_mode) + 1 + ) + + async def async_set_temperature(self, **kwargs: Any) -> None: + """Set new target temperature.""" + assert self._target_temp is not None + if ATTR_TEMPERATURE in kwargs: + await self.async_set_homee_value( + self._target_temp, kwargs[ATTR_TEMPERATURE] + ) + + async def async_turn_on(self) -> None: + """Turn the entity on.""" + assert self._heating_mode is not None + await self.async_set_homee_value(self._heating_mode, 1) + + async def async_turn_off(self) -> None: + """Turn the entity on.""" + assert self._heating_mode is not None + await self.async_set_homee_value(self._heating_mode, 0) + + +def get_climate_features( + node: HomeeNode, +) -> tuple[ClimateEntityFeature, list[HVACMode], list[str] | None]: + """Determine supported climate features of a node based on the available attributes.""" + features = ClimateEntityFeature.TARGET_TEMPERATURE + hvac_modes = [HVACMode.HEAT] + preset_modes: list[str] = [] + + if ( + attribute := node.get_attribute_by_type(AttributeType.HEATING_MODE) + ) is not None: + features |= ClimateEntityFeature.TURN_ON | ClimateEntityFeature.TURN_OFF + hvac_modes.append(HVACMode.OFF) + + if attribute.maximum > 1: + # Node supports more modes than off and heating. + features |= ClimateEntityFeature.PRESET_MODE + preset_modes.extend([PRESET_ECO, PRESET_BOOST, PRESET_MANUAL]) + + if len(preset_modes) > 0: + preset_modes.insert(0, PRESET_NONE) + return (features, hvac_modes, preset_modes if len(preset_modes) > 0 else None) diff --git a/homeassistant/components/homee/const.py b/homeassistant/components/homee/const.py index 2c614d3f5eb..468fb2d49ac 100644 --- a/homeassistant/components/homee/const.py +++ b/homeassistant/components/homee/const.py @@ -95,3 +95,6 @@ LIGHT_PROFILES = [ NodeProfile.WIFI_DIMMABLE_LIGHT, NodeProfile.WIFI_ON_OFF_DIMMABLE_METERING_SWITCH, ] + +# Climate Presets +PRESET_MANUAL = "manual" diff --git a/homeassistant/components/homee/icons.json b/homeassistant/components/homee/icons.json index b4ad8871568..d6d327a32c5 100644 --- a/homeassistant/components/homee/icons.json +++ b/homeassistant/components/homee/icons.json @@ -1,5 +1,16 @@ { "entity": { + "climate": { + "homee": { + "state_attributes": { + "preset_mode": { + "state": { + "manual": "mdi:hand-back-left" + } + } + } + } + }, "sensor": { "brightness": { "default": "mdi:brightness-5" diff --git a/homeassistant/components/homee/strings.json b/homeassistant/components/homee/strings.json index 3dbbdcd2004..623a4e93895 100644 --- a/homeassistant/components/homee/strings.json +++ b/homeassistant/components/homee/strings.json @@ -131,6 +131,17 @@ "name": "Ventilate" } }, + "climate": { + "homee": { + "state_attributes": { + "preset_mode": { + "state": { + "manual": "Manual" + } + } + } + } + }, "light": { "light_instance": { "name": "Light {instance}" diff --git a/tests/components/homee/fixtures/thermostat_only_targettemp.json b/tests/components/homee/fixtures/thermostat_only_targettemp.json new file mode 100644 index 00000000000..4bdbaa0df78 --- /dev/null +++ b/tests/components/homee/fixtures/thermostat_only_targettemp.json @@ -0,0 +1,52 @@ +{ + "id": 1, + "name": "Test Thermostat 1", + "profile": 3003, + "image": "default", + "favorite": 0, + "order": 32, + "protocol": 1, + "routing": 0, + "state": 1, + "state_changed": 1712840187, + "added": 1655274291, + "history": 1, + "cube_type": 1, + "note": "", + "services": 7, + "phonetic_name": "", + "owner": 2, + "security": 0, + "attributes": [ + { + "id": 1, + "node_id": 1, + "instance": 0, + "minimum": 12, + "maximum": 28, + "current_value": 20.0, + "target_value": 13.0, + "last_value": 12.0, + "unit": "°C", + "step_value": 0.1, + "editable": 1, + "type": 6, + "state": 2, + "last_changed": 1713695529, + "changed_by": 3, + "changed_by_id": 0, + "based_on": 1, + "data": "", + "name": "", + "options": { + "automations": ["step"], + "history": { + "day": 35, + "week": 5, + "month": 1, + "stepped": true + } + } + } + ] +} diff --git a/tests/components/homee/fixtures/thermostat_with_currenttemp.json b/tests/components/homee/fixtures/thermostat_with_currenttemp.json new file mode 100644 index 00000000000..9685034f178 --- /dev/null +++ b/tests/components/homee/fixtures/thermostat_with_currenttemp.json @@ -0,0 +1,77 @@ +{ + "id": 2, + "name": "Test Thermostat 2", + "profile": 3003, + "image": "default", + "favorite": 0, + "order": 32, + "protocol": 1, + "routing": 0, + "state": 1, + "state_changed": 1712840187, + "added": 1655274291, + "history": 1, + "cube_type": 1, + "note": "", + "services": 7, + "phonetic_name": "", + "owner": 2, + "security": 0, + "attributes": [ + { + "id": 1, + "node_id": 2, + "instance": 0, + "minimum": 15, + "maximum": 30, + "current_value": 22.0, + "target_value": 13.0, + "last_value": 12.0, + "unit": "°C", + "step_value": 0.1, + "editable": 1, + "type": 6, + "state": 2, + "last_changed": 1713695529, + "changed_by": 3, + "changed_by_id": 0, + "based_on": 1, + "data": "", + "name": "", + "options": { + "automations": ["step"], + "history": { + "day": 35, + "week": 5, + "month": 1, + "stepped": true + } + } + }, + { + "id": 2, + "node_id": 2, + "instance": 0, + "minimum": -50, + "maximum": 125, + "current_value": 19.55, + "target_value": 19.55, + "last_value": 21.07, + "unit": "°C", + "step_value": 0.1, + "editable": 0, + "type": 5, + "state": 1, + "last_changed": 1713695528, + "changed_by": 1, + "changed_by_id": 0, + "based_on": 1, + "data": "", + "name": "", + "options": { + "observed_by": [240], + "history": { "day": 1, "week": 26, "month": 6 } + } + } + ] +} diff --git a/tests/components/homee/fixtures/thermostat_with_heating_mode.json b/tests/components/homee/fixtures/thermostat_with_heating_mode.json new file mode 100644 index 00000000000..fe06e9ef4a5 --- /dev/null +++ b/tests/components/homee/fixtures/thermostat_with_heating_mode.json @@ -0,0 +1,127 @@ +{ + "id": 3, + "name": "Test Thermostat 3", + "profile": 3006, + "image": "default", + "favorite": 0, + "order": 32, + "protocol": 1, + "routing": 0, + "state": 1, + "state_changed": 1712840187, + "added": 1655274291, + "history": 1, + "cube_type": 1, + "note": "", + "services": 7, + "phonetic_name": "", + "owner": 2, + "security": 0, + "attributes": [ + { + "id": 1, + "node_id": 3, + "instance": 0, + "minimum": 14, + "maximum": 25, + "current_value": 24.0, + "target_value": 13.0, + "last_value": 12.0, + "unit": "°C", + "step_value": 0.1, + "editable": 1, + "type": 6, + "state": 2, + "last_changed": 1713695529, + "changed_by": 3, + "changed_by_id": 0, + "based_on": 1, + "data": "", + "name": "", + "options": { + "automations": ["step"], + "history": { + "day": 35, + "week": 5, + "month": 1, + "stepped": true + } + } + }, + { + "id": 2, + "node_id": 3, + "instance": 0, + "minimum": -50, + "maximum": 125, + "current_value": 19.55, + "target_value": 19.55, + "last_value": 21.07, + "unit": "°C", + "step_value": 0.1, + "editable": 0, + "type": 5, + "state": 1, + "last_changed": 1713695528, + "changed_by": 1, + "changed_by_id": 0, + "based_on": 1, + "data": "", + "name": "", + "options": { + "observed_by": [240], + "history": { "day": 1, "week": 26, "month": 6 } + } + }, + { + "id": 3, + "node_id": 3, + "instance": 0, + "minimum": 0, + "maximum": 1, + "current_value": 1.0, + "target_value": 1.0, + "last_value": 1.0, + "unit": "", + "step_value": 1.0, + "editable": 1, + "type": 258, + "state": 1, + "last_changed": 1711796635, + "changed_by": 1, + "changed_by_id": 0, + "based_on": 1, + "data": "", + "name": "" + }, + { + "id": 4, + "node_id": 3, + "instance": 0, + "minimum": 0, + "maximum": 100, + "current_value": 70.0, + "target_value": 0.0, + "last_value": 0.0, + "unit": "%", + "step_value": 1.0, + "editable": 0, + "type": 18, + "state": 1, + "last_changed": 1711796633, + "changed_by": 1, + "changed_by_id": 0, + "based_on": 1, + "data": "", + "name": "", + "options": { + "automations": ["step"], + "history": { + "day": 1, + "week": 26, + "month": 6 + } + } + } + ] +} diff --git a/tests/components/homee/fixtures/thermostat_with_preset.json b/tests/components/homee/fixtures/thermostat_with_preset.json new file mode 100644 index 00000000000..63491d45be2 --- /dev/null +++ b/tests/components/homee/fixtures/thermostat_with_preset.json @@ -0,0 +1,98 @@ +{ + "id": 4, + "name": "Test Thermostat 4", + "profile": 3033, + "image": "default", + "favorite": 0, + "order": 32, + "protocol": 1, + "routing": 0, + "state": 1, + "state_changed": 1712840187, + "added": 1655274291, + "history": 1, + "cube_type": 1, + "note": "", + "services": 7, + "phonetic_name": "", + "owner": 2, + "security": 0, + "attributes": [ + { + "id": 1, + "node_id": 4, + "instance": 0, + "minimum": 10, + "maximum": 32, + "current_value": 12.0, + "target_value": 13.0, + "last_value": 12.0, + "unit": "°C", + "step_value": 0.5, + "editable": 1, + "type": 6, + "state": 2, + "last_changed": 1713695529, + "changed_by": 3, + "changed_by_id": 0, + "based_on": 1, + "data": "", + "name": "", + "options": { + "automations": ["step"], + "history": { + "day": 35, + "week": 5, + "month": 1, + "stepped": true + } + } + }, + { + "id": 2, + "node_id": 4, + "instance": 0, + "minimum": -50, + "maximum": 125, + "current_value": 19.55, + "target_value": 19.55, + "last_value": 21.07, + "unit": "°C", + "step_value": 0.1, + "editable": 0, + "type": 5, + "state": 1, + "last_changed": 1713695528, + "changed_by": 1, + "changed_by_id": 0, + "based_on": 1, + "data": "", + "name": "", + "options": { + "observed_by": [240], + "history": { "day": 1, "week": 26, "month": 6 } + } + }, + { + "id": 3, + "node_id": 4, + "instance": 0, + "minimum": 0, + "maximum": 4, + "current_value": 1.0, + "target_value": 1.0, + "last_value": 1.0, + "unit": "", + "step_value": 1.0, + "editable": 1, + "type": 258, + "state": 1, + "last_changed": 1711796635, + "changed_by": 1, + "changed_by_id": 0, + "based_on": 1, + "data": "", + "name": "" + } + ] +} diff --git a/tests/components/homee/snapshots/test_climate.ambr b/tests/components/homee/snapshots/test_climate.ambr new file mode 100644 index 00000000000..b79538ddcf0 --- /dev/null +++ b/tests/components/homee/snapshots/test_climate.ambr @@ -0,0 +1,274 @@ +# serializer version: 1 +# name: test_climate_snapshot[climate.test_thermostat_1-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'hvac_modes': list([ + , + ]), + 'max_temp': 28, + 'min_temp': 12, + 'target_temp_step': 0.1, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'climate', + 'entity_category': None, + 'entity_id': 'climate.test_thermostat_1', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': None, + 'platform': 'homee', + 'previous_unique_id': None, + 'supported_features': , + 'translation_key': 'homee', + 'unique_id': '00055511EECC-1-1', + 'unit_of_measurement': None, + }) +# --- +# name: test_climate_snapshot[climate.test_thermostat_1-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'current_temperature': None, + 'friendly_name': 'Test Thermostat 1', + 'hvac_action': , + 'hvac_modes': list([ + , + ]), + 'max_temp': 28, + 'min_temp': 12, + 'supported_features': , + 'target_temp_step': 0.1, + 'temperature': 20.0, + }), + 'context': , + 'entity_id': 'climate.test_thermostat_1', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'heat', + }) +# --- +# name: test_climate_snapshot[climate.test_thermostat_2-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'hvac_modes': list([ + , + ]), + 'max_temp': 30, + 'min_temp': 15, + 'target_temp_step': 0.1, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'climate', + 'entity_category': None, + 'entity_id': 'climate.test_thermostat_2', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': None, + 'platform': 'homee', + 'previous_unique_id': None, + 'supported_features': , + 'translation_key': 'homee', + 'unique_id': '00055511EECC-2-1', + 'unit_of_measurement': None, + }) +# --- +# name: test_climate_snapshot[climate.test_thermostat_2-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'current_temperature': 19.6, + 'friendly_name': 'Test Thermostat 2', + 'hvac_action': , + 'hvac_modes': list([ + , + ]), + 'max_temp': 30, + 'min_temp': 15, + 'supported_features': , + 'target_temp_step': 0.1, + 'temperature': 22.0, + }), + 'context': , + 'entity_id': 'climate.test_thermostat_2', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'heat', + }) +# --- +# name: test_climate_snapshot[climate.test_thermostat_3-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'hvac_modes': list([ + , + , + ]), + 'max_temp': 25, + 'min_temp': 14, + 'target_temp_step': 0.1, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'climate', + 'entity_category': None, + 'entity_id': 'climate.test_thermostat_3', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': None, + 'platform': 'homee', + 'previous_unique_id': None, + 'supported_features': , + 'translation_key': 'homee', + 'unique_id': '00055511EECC-3-1', + 'unit_of_measurement': None, + }) +# --- +# name: test_climate_snapshot[climate.test_thermostat_3-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'current_temperature': 19.6, + 'friendly_name': 'Test Thermostat 3', + 'hvac_action': , + 'hvac_modes': list([ + , + , + ]), + 'max_temp': 25, + 'min_temp': 14, + 'supported_features': , + 'target_temp_step': 0.1, + 'temperature': 24.0, + }), + 'context': , + 'entity_id': 'climate.test_thermostat_3', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'heat', + }) +# --- +# name: test_climate_snapshot[climate.test_thermostat_4-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'hvac_modes': list([ + , + , + ]), + 'max_temp': 32, + 'min_temp': 10, + 'preset_modes': list([ + 'none', + 'eco', + 'boost', + 'manual', + ]), + 'target_temp_step': 0.5, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'climate', + 'entity_category': None, + 'entity_id': 'climate.test_thermostat_4', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': None, + 'platform': 'homee', + 'previous_unique_id': None, + 'supported_features': , + 'translation_key': 'homee', + 'unique_id': '00055511EECC-4-1', + 'unit_of_measurement': None, + }) +# --- +# name: test_climate_snapshot[climate.test_thermostat_4-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'current_temperature': 19.6, + 'friendly_name': 'Test Thermostat 4', + 'hvac_action': , + 'hvac_modes': list([ + , + , + ]), + 'max_temp': 32, + 'min_temp': 10, + 'preset_mode': 'none', + 'preset_modes': list([ + 'none', + 'eco', + 'boost', + 'manual', + ]), + 'supported_features': , + 'target_temp_step': 0.5, + 'temperature': 12.0, + }), + 'context': , + 'entity_id': 'climate.test_thermostat_4', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'heat', + }) +# --- diff --git a/tests/components/homee/test_climate.py b/tests/components/homee/test_climate.py new file mode 100644 index 00000000000..bb5ad98c7d2 --- /dev/null +++ b/tests/components/homee/test_climate.py @@ -0,0 +1,270 @@ +"""Test Homee climate entities.""" + +from unittest.mock import MagicMock, patch + +from pyHomee.const import AttributeType +import pytest +from syrupy.assertion import SnapshotAssertion + +from homeassistant.components.climate import ( + ATTR_HVAC_ACTION, + ATTR_HVAC_MODE, + ATTR_HVAC_MODES, + ATTR_PRESET_MODE, + ATTR_PRESET_MODES, + ATTR_TEMPERATURE, + DOMAIN as CLIMATE_DOMAIN, + PRESET_BOOST, + PRESET_ECO, + PRESET_NONE, + SERVICE_SET_HVAC_MODE, + SERVICE_SET_PRESET_MODE, + SERVICE_SET_TEMPERATURE, + SERVICE_TURN_OFF, + SERVICE_TURN_ON, + ClimateEntityFeature, + HVACAction, + HVACMode, +) +from homeassistant.components.homee.const import PRESET_MANUAL +from homeassistant.const import ATTR_ENTITY_ID, Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from . import build_mock_node, setup_integration + +from tests.common import MockConfigEntry, snapshot_platform + + +async def setup_mock_climate( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_homee: MagicMock, + file: str, +) -> None: + """Setups a climate node for the tests.""" + mock_homee.nodes = [build_mock_node(file)] + mock_homee.get_node_by_id.return_value = mock_homee.nodes[0] + await setup_integration(hass, mock_config_entry) + + +@pytest.mark.parametrize( + ("file", "entity_id", "features", "hvac_modes"), + [ + ( + "thermostat_only_targettemp.json", + "climate.test_thermostat_1", + ClimateEntityFeature.TARGET_TEMPERATURE, + [HVACMode.HEAT], + ), + ( + "thermostat_with_currenttemp.json", + "climate.test_thermostat_2", + ClimateEntityFeature.TARGET_TEMPERATURE, + [HVACMode.HEAT], + ), + ( + "thermostat_with_heating_mode.json", + "climate.test_thermostat_3", + ClimateEntityFeature.TARGET_TEMPERATURE + | ClimateEntityFeature.TURN_ON + | ClimateEntityFeature.TURN_OFF, + [HVACMode.HEAT, HVACMode.OFF], + ), + ( + "thermostat_with_preset.json", + "climate.test_thermostat_4", + ClimateEntityFeature.TARGET_TEMPERATURE + | ClimateEntityFeature.TURN_ON + | ClimateEntityFeature.TURN_OFF + | ClimateEntityFeature.PRESET_MODE, + [HVACMode.HEAT, HVACMode.OFF], + ), + ], +) +async def test_climate_features( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_homee: MagicMock, + file: str, + entity_id: str, + features: ClimateEntityFeature, + hvac_modes: list[HVACMode], +) -> None: + """Test available features of climate entities.""" + await setup_mock_climate(hass, mock_config_entry, mock_homee, file) + + attributes = hass.states.get(entity_id).attributes + assert attributes["supported_features"] == features + assert attributes[ATTR_HVAC_MODES] == hvac_modes + + +async def test_climate_preset_modes( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_homee: MagicMock, +) -> None: + """Test available preset modes of climate entities.""" + await setup_mock_climate( + hass, mock_config_entry, mock_homee, "thermostat_with_preset.json" + ) + + attributes = hass.states.get("climate.test_thermostat_4").attributes + assert attributes[ATTR_PRESET_MODES] == [ + PRESET_NONE, + PRESET_ECO, + PRESET_BOOST, + PRESET_MANUAL, + ] + + +@pytest.mark.parametrize( + ("attribute_type", "value", "expected"), + [ + (AttributeType.HEATING_MODE, 0.0, HVACAction.OFF), + (AttributeType.CURRENT_VALVE_POSITION, 0.0, HVACAction.IDLE), + (AttributeType.TEMPERATURE, 25.0, HVACAction.IDLE), + (AttributeType.TEMPERATURE, 18.0, HVACAction.HEATING), + ], +) +async def test_hvac_action( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_homee: MagicMock, + attribute_type: AttributeType, + value: float, + expected: HVACAction, +) -> None: + """Test hvac action of climate entities.""" + mock_homee.nodes = [build_mock_node("thermostat_with_heating_mode.json")] + mock_homee.get_node_by_id.return_value = mock_homee.nodes[0] + node = mock_homee.nodes[0] + # set target temperature to 24.0 + node.attributes[0].current_value = 24.0 + attribute = node.get_attribute_by_type(attribute_type) + attribute.current_value = value + await setup_integration(hass, mock_config_entry) + + attributes = hass.states.get("climate.test_thermostat_3").attributes + assert attributes[ATTR_HVAC_ACTION] == expected + + +@pytest.mark.parametrize( + ("preset_mode_int", "expected"), + [ + (0, PRESET_NONE), + (1, PRESET_NONE), + (2, PRESET_ECO), + (3, PRESET_BOOST), + (4, PRESET_MANUAL), + ], +) +async def test_current_preset_mode( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_homee: MagicMock, + preset_mode_int: int, + expected: str, +) -> None: + """Test current preset mode of climate entities.""" + mock_homee.nodes = [build_mock_node("thermostat_with_preset.json")] + mock_homee.get_node_by_id.return_value = mock_homee.nodes[0] + node = mock_homee.nodes[0] + node.attributes[2].current_value = preset_mode_int + await setup_integration(hass, mock_config_entry) + + attributes = hass.states.get("climate.test_thermostat_4").attributes + assert attributes[ATTR_PRESET_MODE] == expected + + +@pytest.mark.parametrize( + ("service", "service_data", "expected"), + [ + ( + SERVICE_TURN_ON, + {}, + (4, 3, 1), + ), + ( + SERVICE_TURN_OFF, + {}, + (4, 3, 0), + ), + ( + SERVICE_SET_HVAC_MODE, + {ATTR_HVAC_MODE: HVACMode.HEAT}, + (4, 3, 1), + ), + ( + SERVICE_SET_HVAC_MODE, + {ATTR_HVAC_MODE: HVACMode.OFF}, + (4, 3, 0), + ), + ( + SERVICE_SET_TEMPERATURE, + {ATTR_TEMPERATURE: 20}, + (4, 1, 20), + ), + ( + SERVICE_SET_PRESET_MODE, + {ATTR_PRESET_MODE: PRESET_NONE}, + (4, 3, 1), + ), + ( + SERVICE_SET_PRESET_MODE, + {ATTR_PRESET_MODE: PRESET_ECO}, + (4, 3, 2), + ), + ( + SERVICE_SET_PRESET_MODE, + {ATTR_PRESET_MODE: PRESET_BOOST}, + (4, 3, 3), + ), + ( + SERVICE_SET_PRESET_MODE, + {ATTR_PRESET_MODE: PRESET_MANUAL}, + (4, 3, 4), + ), + ], +) +async def test_climate_services( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_homee: MagicMock, + service: str, + service_data: dict, + expected: tuple[int, int, int], +) -> None: + """Test available services of climate entities.""" + await setup_mock_climate( + hass, mock_config_entry, mock_homee, "thermostat_with_preset.json" + ) + + await hass.services.async_call( + CLIMATE_DOMAIN, + service, + {ATTR_ENTITY_ID: "climate.test_thermostat_4", **service_data}, + blocking=True, + ) + + mock_homee.set_value.assert_called_once_with(*expected) + + +async def test_climate_snapshot( + hass: HomeAssistant, + mock_homee: MagicMock, + mock_config_entry: MockConfigEntry, + entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, +) -> None: + """Test snapshot of climates.""" + mock_homee.nodes = [ + build_mock_node("thermostat_only_targettemp.json"), + build_mock_node("thermostat_with_currenttemp.json"), + build_mock_node("thermostat_with_heating_mode.json"), + build_mock_node("thermostat_with_preset.json"), + ] + with patch("homeassistant.components.homee.PLATFORMS", [Platform.CLIMATE]): + await setup_integration(hass, mock_config_entry) + + await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) From 6007629293e23d076a1672c0df7216aa80ba06c9 Mon Sep 17 00:00:00 2001 From: Bram Kragten Date: Tue, 1 Apr 2025 19:19:53 +0200 Subject: [PATCH 177/505] Update frontend to 20250401.0 (#142010) --- homeassistant/components/frontend/manifest.json | 2 +- homeassistant/package_constraints.txt | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/frontend/manifest.json b/homeassistant/components/frontend/manifest.json index ef974177947..4cab8375d1b 100644 --- a/homeassistant/components/frontend/manifest.json +++ b/homeassistant/components/frontend/manifest.json @@ -20,5 +20,5 @@ "documentation": "https://www.home-assistant.io/integrations/frontend", "integration_type": "system", "quality_scale": "internal", - "requirements": ["home-assistant-frontend==20250331.0"] + "requirements": ["home-assistant-frontend==20250401.0"] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index ba28ff34157..291d47ec4cf 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -38,7 +38,7 @@ habluetooth==3.37.0 hass-nabucasa==0.94.0 hassil==2.2.3 home-assistant-bluetooth==1.13.1 -home-assistant-frontend==20250331.0 +home-assistant-frontend==20250401.0 home-assistant-intents==2025.3.28 httpx==0.28.1 ifaddr==0.2.0 diff --git a/requirements_all.txt b/requirements_all.txt index e8d1e5ea77f..b906e92998c 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1157,7 +1157,7 @@ hole==0.8.0 holidays==0.69 # homeassistant.components.frontend -home-assistant-frontend==20250331.0 +home-assistant-frontend==20250401.0 # homeassistant.components.conversation home-assistant-intents==2025.3.28 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 77a70929022..ff4f0e15af1 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -984,7 +984,7 @@ hole==0.8.0 holidays==0.69 # homeassistant.components.frontend -home-assistant-frontend==20250331.0 +home-assistant-frontend==20250401.0 # homeassistant.components.conversation home-assistant-intents==2025.3.28 From d9cd62bf6589519128c1d5d280187e05aeb7e7ab Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Tue, 1 Apr 2025 19:20:31 +0200 Subject: [PATCH 178/505] Add LG ThinQ event bus listener to lifecycle hooks (#142006) --- homeassistant/components/lg_thinq/coordinator.py | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/lg_thinq/coordinator.py b/homeassistant/components/lg_thinq/coordinator.py index 513cd27a7b2..9f84c422277 100644 --- a/homeassistant/components/lg_thinq/coordinator.py +++ b/homeassistant/components/lg_thinq/coordinator.py @@ -63,10 +63,12 @@ class DeviceDataUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]): # Add a callback to handle core config update. self.unit_system: str | None = None - self.hass.bus.async_listen( - event_type=EVENT_CORE_CONFIG_UPDATE, - listener=self._handle_update_config, - event_filter=self.async_config_update_filter, + self.config_entry.async_on_unload( + self.hass.bus.async_listen( + event_type=EVENT_CORE_CONFIG_UPDATE, + listener=self._handle_update_config, + event_filter=self.async_config_update_filter, + ) ) async def _handle_update_config(self, _: Event) -> None: From faac51d2198c4f78c4130080ce01923dc4c74a52 Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Tue, 1 Apr 2025 19:22:32 +0200 Subject: [PATCH 179/505] Improve error handling and logging on MQTT update entity state updates when template rederings fails (#141960) --- homeassistant/components/mqtt/update.py | 15 +++++- tests/components/mqtt/test_update.py | 66 +++++++++++++++++++++++++ 2 files changed, 79 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/mqtt/update.py b/homeassistant/components/mqtt/update.py index c4916b5010c..145f0a2562c 100644 --- a/homeassistant/components/mqtt/update.py +++ b/homeassistant/components/mqtt/update.py @@ -26,7 +26,7 @@ from . import subscription from .config import DEFAULT_RETAIN, MQTT_RO_SCHEMA from .const import CONF_COMMAND_TOPIC, CONF_RETAIN, CONF_STATE_TOPIC, PAYLOAD_EMPTY_JSON from .entity import MqttEntity, async_setup_entity_entry_helper -from .models import MqttValueTemplate, ReceiveMessage +from .models import MqttValueTemplate, PayloadSentinel, ReceiveMessage from .schemas import MQTT_ENTITY_COMMON_SCHEMA from .util import valid_publish_topic, valid_subscribe_topic @@ -136,7 +136,18 @@ class MqttUpdate(MqttEntity, UpdateEntity, RestoreEntity): @callback def _handle_state_message_received(self, msg: ReceiveMessage) -> None: """Handle receiving state message via MQTT.""" - payload = self._templates[CONF_VALUE_TEMPLATE](msg.payload) + payload = self._templates[CONF_VALUE_TEMPLATE]( + msg.payload, PayloadSentinel.DEFAULT + ) + + if payload is PayloadSentinel.DEFAULT: + _LOGGER.warning( + "Unable to process payload '%s' for topic %s, with value template '%s'", + msg.payload, + msg.topic, + self._config.get(CONF_VALUE_TEMPLATE), + ) + return if not payload or payload == PAYLOAD_EMPTY_JSON: _LOGGER.debug( diff --git a/tests/components/mqtt/test_update.py b/tests/components/mqtt/test_update.py index d70d7dd792b..87eb381db03 100644 --- a/tests/components/mqtt/test_update.py +++ b/tests/components/mqtt/test_update.py @@ -1,6 +1,7 @@ """The tests for mqtt update component.""" import json +from typing import Any from unittest.mock import patch import pytest @@ -225,6 +226,71 @@ async def test_value_template( assert state.attributes.get("latest_version") == "2.0.0" +@pytest.mark.parametrize( + "hass_config", + [ + { + mqtt.DOMAIN: { + update.DOMAIN: { + "state_topic": "test/update", + "value_template": ( + "{\"latest_version\":\"{{ value_json['update']['latest_version'] }}\"," + "\"installed_version\":\"{{ value_json['update']['installed_version'] }}\"," + "\"update_percentage\":{{ value_json['update'].get('progress', 'null') }}}" + ), + "name": "Test Update", + } + } + } + ], +) +async def test_errornous_value_template( + hass: HomeAssistant, + mqtt_mock_entry: MqttMockHAClientGenerator, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test that it fetches the given payload with a template or handles the exception.""" + state_topic = "test/update" + await mqtt_mock_entry() + + # Simulate a template redendering error with payload + # without "update" mapping + example_payload: dict[str, Any] = { + "child_lock": "UNLOCK", + "current": 0.02, + "energy": 212.92, + "indicator_mode": "off/on", + "linkquality": 65, + "power": 0, + "power_outage_memory": "off", + "state": "ON", + "voltage": 232, + } + + async_fire_mqtt_message(hass, state_topic, json.dumps(example_payload)) + await hass.async_block_till_done() + assert hass.states.get("update.test_update") is not None + assert "Unable to process payload '" in caplog.text + + # Add update info + example_payload["update"] = { + "latest_version": "2.0.0", + "installed_version": "1.9.0", + "progress": 20, + } + + async_fire_mqtt_message(hass, state_topic, json.dumps(example_payload)) + await hass.async_block_till_done() + + state = hass.states.get("update.test_update") + assert state is not None + + assert state.state == STATE_ON + assert state.attributes.get("installed_version") == "1.9.0" + assert state.attributes.get("latest_version") == "2.0.0" + assert state.attributes.get("update_percentage") == 20 + + @pytest.mark.parametrize( "hass_config", [ From 4bfc96c02bb5d4f72c6a457da21d9016ef149f2c Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Tue, 1 Apr 2025 19:36:14 +0200 Subject: [PATCH 180/505] Improve SmartThings deprecation (#141939) * Improve SmartThings deprecation * Improve SmartThings deprecation --- .../components/smartthings/binary_sensor.py | 141 ++++++++--------- .../components/smartthings/strings.json | 16 +- homeassistant/components/smartthings/util.py | 83 ++++++++++ .../snapshots/test_binary_sensor.ambr | 144 ------------------ .../smartthings/test_binary_sensor.py | 126 +++++++++++++-- 5 files changed, 273 insertions(+), 237 deletions(-) create mode 100644 homeassistant/components/smartthings/util.py diff --git a/homeassistant/components/smartthings/binary_sensor.py b/homeassistant/components/smartthings/binary_sensor.py index bd09f1725d3..75a080975ea 100644 --- a/homeassistant/components/smartthings/binary_sensor.py +++ b/homeassistant/components/smartthings/binary_sensor.py @@ -7,26 +7,21 @@ from dataclasses import dataclass from pysmartthings import Attribute, Capability, Category, SmartThings, Status -from homeassistant.components.automation import automations_with_entity from homeassistant.components.binary_sensor import ( + DOMAIN as BINARY_SENSOR_DOMAIN, BinarySensorDeviceClass, BinarySensorEntity, BinarySensorEntityDescription, ) -from homeassistant.components.script import scripts_with_entity from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from homeassistant.helpers.issue_registry import ( - IssueSeverity, - async_create_issue, - async_delete_issue, -) from . import FullDevice, SmartThingsConfigEntry -from .const import DOMAIN, MAIN +from .const import MAIN from .entity import SmartThingsEntity +from .util import deprecate_entity @dataclass(frozen=True, kw_only=True) @@ -192,24 +187,64 @@ async def async_setup_entry( ) -> None: """Add binary sensors for a config entry.""" entry_data = entry.runtime_data - async_add_entities( - SmartThingsBinarySensor( - entry_data.client, device, description, capability, attribute, component - ) - for device in entry_data.devices.values() - for capability, attribute_map in CAPABILITY_TO_SENSORS.items() - for attribute, description in attribute_map.items() - for component in device.status - if capability in device.status[component] - and ( - component == MAIN - or (description.exists_fn is not None and description.exists_fn(component)) - ) - and ( - not description.category - or get_main_component_category(device) in description.category - ) - ) + entities = [] + + entity_registry = er.async_get(hass) + + for device in entry_data.devices.values(): # pylint: disable=too-many-nested-blocks + for capability, attribute_map in CAPABILITY_TO_SENSORS.items(): + for attribute, description in attribute_map.items(): + for component in device.status: + if ( + capability in device.status[component] + and ( + component == MAIN + or ( + description.exists_fn is not None + and description.exists_fn(component) + ) + ) + and ( + not description.category + or get_main_component_category(device) + in description.category + ) + ): + if ( + component == MAIN + and (issue := description.deprecated_fn(device.status)) + is not None + ): + if deprecate_entity( + hass, + entity_registry, + BINARY_SENSOR_DOMAIN, + f"{device.device.device_id}_{component}_{capability}_{attribute}_{attribute}", + f"deprecated_binary_{issue}", + ): + entities.append( + SmartThingsBinarySensor( + entry_data.client, + device, + description, + capability, + attribute, + component, + ) + ) + continue + entities.append( + SmartThingsBinarySensor( + entry_data.client, + device, + description, + capability, + attribute, + component, + ) + ) + + async_add_entities(entities) class SmartThingsBinarySensor(SmartThingsEntity, BinarySensorEntity): @@ -257,57 +292,3 @@ class SmartThingsBinarySensor(SmartThingsEntity, BinarySensorEntity): self.get_attribute_value(self.capability, self._attribute) == self.entity_description.is_on_key ) - - async def async_added_to_hass(self) -> None: - """Call when entity is added to hass.""" - await super().async_added_to_hass() - if (issue := self.entity_description.deprecated_fn(self.device.status)) is None: - return - automations = automations_with_entity(self.hass, self.entity_id) - scripts = scripts_with_entity(self.hass, self.entity_id) - items = automations + scripts - if not items: - return - - entity_reg: er.EntityRegistry = er.async_get(self.hass) - entity_automations = [ - automation_entity - for automation_id in automations - if (automation_entity := entity_reg.async_get(automation_id)) - ] - entity_scripts = [ - script_entity - for script_id in scripts - if (script_entity := entity_reg.async_get(script_id)) - ] - - items_list = [ - f"- [{item.original_name}](/config/automation/edit/{item.unique_id})" - for item in entity_automations - ] + [ - f"- [{item.original_name}](/config/script/edit/{item.unique_id})" - for item in entity_scripts - ] - - async_create_issue( - self.hass, - DOMAIN, - f"deprecated_binary_{issue}_{self.entity_id}", - breaks_in_ha_version="2025.10.0", - is_fixable=False, - severity=IssueSeverity.WARNING, - translation_key=f"deprecated_binary_{issue}", - translation_placeholders={ - "entity": self.entity_id, - "items": "\n".join(items_list), - }, - ) - - async def async_will_remove_from_hass(self) -> None: - """Call when entity will be removed from hass.""" - await super().async_will_remove_from_hass() - if (issue := self.entity_description.deprecated_fn(self.device.status)) is None: - return - async_delete_issue( - self.hass, DOMAIN, f"deprecated_binary_{issue}_{self.entity_id}" - ) diff --git a/homeassistant/components/smartthings/strings.json b/homeassistant/components/smartthings/strings.json index fc3ca66a3af..1fbe535261e 100644 --- a/homeassistant/components/smartthings/strings.json +++ b/homeassistant/components/smartthings/strings.json @@ -480,12 +480,20 @@ }, "issues": { "deprecated_binary_valve": { - "title": "Deprecated valve binary sensor detected in some automations or scripts", - "description": "The valve binary sensor `{entity}` is deprecated and is used in the following automations or scripts:\n{items}\n\nA valve entity with controls is available and should be used going forward. Please use the new valve entity in the above automations or scripts to fix this issue." + "title": "Valve binary sensor deprecated", + "description": "The valve binary sensor {entity_name} (`{entity_id}`) is deprecated and will be removed in the future. A valve entity with controls is available and should be used going forward. Please update your dashboards, templates accordingly and disable the entity to fix this issue." + }, + "deprecated_binary_valve_scripts": { + "title": "[%key:component::smartthings::issues::deprecated_binary_valve::title%]", + "description": "The valve binary sensor {entity_name} (`{entity_id}`) is deprecated and will be removed in the future. The entity is used in the following automations or scripts:\n{items}\n\nA valve entity with controls is available and should be used going forward. Please use the new valve entity in the above automations or scripts and disable the entity to fix this issue." }, "deprecated_binary_fridge_door": { - "title": "Deprecated refrigerator door binary sensor detected in some automations or scripts", - "description": "The refrigerator door binary sensor `{entity}` is deprecated and is used in the following automations or scripts:\n{items}\n\nSeparate entities for cooler and freezer door are available and should be used going forward. Please use them in the above automations or scripts to fix this issue." + "title": "Refrigerator door binary sensor deprecated", + "description": "The refrigerator door binary sensor {entity_name} (`{entity_id}`) is deprecated and will be removed in the future. Separate entities for cooler and freezer door are available and should be used going forward. Please update your dashboards, templates accordingly and disable the entity to fix this issue." + }, + "deprecated_binary_fridge_door_scripts": { + "title": "[%key:component::smartthings::issues::deprecated_binary_fridge_door::title%]", + "description": "The refrigerator door binary sensor {entity_name} (`{entity_id}`) is deprecated and will be removed in the future. The entity is used in the following automations or scripts:\n{items}\n\nSeparate entities for cooler and freezer door are available and should be used going forward. Please use them in the above automations or scripts and disable the entity to fix this issue." }, "deprecated_switch_appliance": { "title": "Deprecated switch detected in some automations or scripts", diff --git a/homeassistant/components/smartthings/util.py b/homeassistant/components/smartthings/util.py new file mode 100644 index 00000000000..b21652ca629 --- /dev/null +++ b/homeassistant/components/smartthings/util.py @@ -0,0 +1,83 @@ +"""Utility functions for SmartThings integration.""" + +from homeassistant.components.automation import automations_with_entity +from homeassistant.components.script import scripts_with_entity +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er +from homeassistant.helpers.issue_registry import ( + IssueSeverity, + async_create_issue, + async_delete_issue, +) + +from .const import DOMAIN + + +def deprecate_entity( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + platform_domain: str, + entity_unique_id: str, + issue_string: str, +) -> bool: + """Create an issue for deprecated entities.""" + if entity_id := entity_registry.async_get_entity_id( + platform_domain, DOMAIN, entity_unique_id + ): + entity_entry = entity_registry.async_get(entity_id) + if not entity_entry: + return False + if entity_entry.disabled: + entity_registry.async_remove(entity_id) + async_delete_issue( + hass, + DOMAIN, + f"{issue_string}_{entity_id}", + ) + return False + translation_key = issue_string + placeholders = { + "entity_id": entity_id, + "entity_name": entity_entry.name or entity_entry.original_name or "Unknown", + } + if items := get_automations_and_scripts_using_entity(hass, entity_id): + translation_key = f"{translation_key}_scripts" + placeholders.update( + { + "items": "\n".join(items), + } + ) + async_create_issue( + hass, + DOMAIN, + f"{issue_string}_{entity_id}", + breaks_in_ha_version="2025.10.0", + is_fixable=False, + severity=IssueSeverity.WARNING, + translation_key=translation_key, + translation_placeholders=placeholders, + ) + return True + return False + + +def get_automations_and_scripts_using_entity( + hass: HomeAssistant, + entity_id: str, +) -> list[str]: + """Get automations and scripts using an entity.""" + automations = automations_with_entity(hass, entity_id) + scripts = scripts_with_entity(hass, entity_id) + if not automations and not scripts: + return [] + + entity_reg = er.async_get(hass) + return [ + f"- [{item.original_name}](/config/{integration}/edit/{item.unique_id})" + for integration, entities in ( + ("automation", automations), + ("script", scripts), + ) + for entity_id in entities + if (item := entity_reg.async_get(entity_id)) + ] diff --git a/tests/components/smartthings/snapshots/test_binary_sensor.ambr b/tests/components/smartthings/snapshots/test_binary_sensor.ambr index d41c36aea64..2419a154e05 100644 --- a/tests/components/smartthings/snapshots/test_binary_sensor.ambr +++ b/tests/components/smartthings/snapshots/test_binary_sensor.ambr @@ -713,54 +713,6 @@ 'state': 'off', }) # --- -# name: test_all_entities[da_ref_normal_000001][binary_sensor.refrigerator_door-entry] - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'binary_sensor', - 'entity_category': None, - 'entity_id': 'binary_sensor.refrigerator_door', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': 'Door', - 'platform': 'smartthings', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': None, - 'unique_id': '7db87911-7dce-1cf2-7119-b953432a2f09_main_contactSensor_contact_contact', - 'unit_of_measurement': None, - }) -# --- -# name: test_all_entities[da_ref_normal_000001][binary_sensor.refrigerator_door-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'door', - 'friendly_name': 'Refrigerator Door', - }), - 'context': , - 'entity_id': 'binary_sensor.refrigerator_door', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'off', - }) -# --- # name: test_all_entities[da_ref_normal_000001][binary_sensor.refrigerator_freezer_door-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -857,54 +809,6 @@ 'state': 'off', }) # --- -# name: test_all_entities[da_ref_normal_01011][binary_sensor.frigo_door-entry] - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'binary_sensor', - 'entity_category': None, - 'entity_id': 'binary_sensor.frigo_door', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': 'Door', - 'platform': 'smartthings', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': None, - 'unique_id': '5758b2ec-563e-f39b-ec39-208e54aabf60_main_contactSensor_contact_contact', - 'unit_of_measurement': None, - }) -# --- -# name: test_all_entities[da_ref_normal_01011][binary_sensor.frigo_door-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'door', - 'friendly_name': 'Frigo Door', - }), - 'context': , - 'entity_id': 'binary_sensor.frigo_door', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'off', - }) -# --- # name: test_all_entities[da_ref_normal_01011][binary_sensor.frigo_freezer_door-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -2139,54 +2043,6 @@ 'state': 'off', }) # --- -# name: test_all_entities[virtual_valve][binary_sensor.volvo_valve-entry] - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'binary_sensor', - 'entity_category': None, - 'entity_id': 'binary_sensor.volvo_valve', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': 'Valve', - 'platform': 'smartthings', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'valve', - 'unique_id': '612ab3c2-3bb0-48f7-b2c0-15b169cb2fc3_main_valve_valve_valve', - 'unit_of_measurement': None, - }) -# --- -# name: test_all_entities[virtual_valve][binary_sensor.volvo_valve-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'opening', - 'friendly_name': 'volvo Valve', - }), - 'context': , - 'entity_id': 'binary_sensor.volvo_valve', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'off', - }) -# --- # name: test_all_entities[virtual_water_sensor][binary_sensor.asd_moisture-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ diff --git a/tests/components/smartthings/test_binary_sensor.py b/tests/components/smartthings/test_binary_sensor.py index 517de034613..f7fcde3746f 100644 --- a/tests/components/smartthings/test_binary_sensor.py +++ b/tests/components/smartthings/test_binary_sensor.py @@ -8,8 +8,9 @@ from syrupy import SnapshotAssertion from homeassistant.components import automation, script from homeassistant.components.automation import automations_with_entity +from homeassistant.components.binary_sensor import DOMAIN as BINARY_SENSOR_DOMAIN from homeassistant.components.script import scripts_with_entity -from homeassistant.components.smartthings import DOMAIN +from homeassistant.components.smartthings import DOMAIN, MAIN from homeassistant.const import STATE_OFF, STATE_ON, Platform from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er, issue_registry as ir @@ -44,7 +45,7 @@ async def test_state_update( """Test state update.""" await setup_integration(hass, mock_config_entry) - assert hass.states.get("binary_sensor.refrigerator_door").state == STATE_OFF + assert hass.states.get("binary_sensor.refrigerator_cooler_door").state == STATE_OFF await trigger_update( hass, @@ -53,35 +54,60 @@ async def test_state_update( Capability.CONTACT_SENSOR, Attribute.CONTACT, "open", + component="cooler", ) - assert hass.states.get("binary_sensor.refrigerator_door").state == STATE_ON + assert hass.states.get("binary_sensor.refrigerator_cooler_door").state == STATE_ON @pytest.mark.usefixtures("entity_registry_enabled_by_default") @pytest.mark.parametrize( - ("device_fixture", "issue_string", "entity_id"), + ("device_fixture", "unique_id", "suggested_object_id", "issue_string", "entity_id"), [ - ("virtual_valve", "valve", "binary_sensor.volvo_valve"), - ("da_ref_normal_000001", "fridge_door", "binary_sensor.refrigerator_door"), + ( + "virtual_valve", + f"612ab3c2-3bb0-48f7-b2c0-15b169cb2fc3_{MAIN}_{Capability.VALVE}_{Attribute.VALVE}_{Attribute.VALVE}", + "volvo_valve", + "valve", + "binary_sensor.volvo_valve", + ), + ( + "da_ref_normal_000001", + f"7db87911-7dce-1cf2-7119-b953432a2f09_{MAIN}_{Capability.CONTACT_SENSOR}_{Attribute.CONTACT}_{Attribute.CONTACT}", + "refrigerator_door", + "fridge_door", + "binary_sensor.refrigerator_door", + ), ], ) -async def test_create_issue( +async def test_create_issue_with_items( hass: HomeAssistant, devices: AsyncMock, mock_config_entry: MockConfigEntry, + entity_registry: er.EntityRegistry, issue_registry: ir.IssueRegistry, + unique_id: str, + suggested_object_id: str, issue_string: str, entity_id: str, ) -> None: """Test we create an issue when an automation or script is using a deprecated entity.""" issue_id = f"deprecated_binary_{issue_string}_{entity_id}" + entity_entry = entity_registry.async_get_or_create( + BINARY_SENSOR_DOMAIN, + DOMAIN, + unique_id, + suggested_object_id=suggested_object_id, + original_name=suggested_object_id, + ) + assert await async_setup_component( hass, automation.DOMAIN, { automation.DOMAIN: { + "id": "test", "alias": "test", "trigger": {"platform": "state", "entity_id": entity_id}, "action": { @@ -113,13 +139,95 @@ async def test_create_issue( await setup_integration(hass, mock_config_entry) + assert hass.states.get(entity_id).state == STATE_OFF + assert automations_with_entity(hass, entity_id)[0] == "automation.test" assert scripts_with_entity(hass, entity_id)[0] == "script.test" assert len(issue_registry.issues) == 1 - assert issue_registry.async_get_issue(DOMAIN, issue_id) + issue = issue_registry.async_get_issue(DOMAIN, issue_id) + assert issue is not None + assert issue.translation_key == f"deprecated_binary_{issue_string}_scripts" + assert issue.translation_placeholders == { + "entity_id": entity_id, + "entity_name": suggested_object_id, + "items": "- [test](/config/automation/edit/test)\n- [test](/config/script/edit/test)", + } - await hass.config_entries.async_unload(mock_config_entry.entry_id) + entity_registry.async_update_entity( + entity_entry.entity_id, + disabled_by=er.RegistryEntryDisabler.USER, + ) + + await hass.config_entries.async_reload(mock_config_entry.entry_id) + await hass.async_block_till_done() + + # Assert the issue is no longer present + assert not issue_registry.async_get_issue(DOMAIN, issue_id) + assert len(issue_registry.issues) == 0 + + +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +@pytest.mark.parametrize( + ("device_fixture", "unique_id", "suggested_object_id", "issue_string", "entity_id"), + [ + ( + "virtual_valve", + f"612ab3c2-3bb0-48f7-b2c0-15b169cb2fc3_{MAIN}_{Capability.VALVE}_{Attribute.VALVE}_{Attribute.VALVE}", + "volvo_valve", + "valve", + "binary_sensor.volvo_valve", + ), + ( + "da_ref_normal_000001", + f"7db87911-7dce-1cf2-7119-b953432a2f09_{MAIN}_{Capability.CONTACT_SENSOR}_{Attribute.CONTACT}_{Attribute.CONTACT}", + "refrigerator_door", + "fridge_door", + "binary_sensor.refrigerator_door", + ), + ], +) +async def test_create_issue( + hass: HomeAssistant, + devices: AsyncMock, + mock_config_entry: MockConfigEntry, + entity_registry: er.EntityRegistry, + issue_registry: ir.IssueRegistry, + unique_id: str, + suggested_object_id: str, + issue_string: str, + entity_id: str, +) -> None: + """Test we create an issue when an automation or script is using a deprecated entity.""" + issue_id = f"deprecated_binary_{issue_string}_{entity_id}" + + entity_entry = entity_registry.async_get_or_create( + BINARY_SENSOR_DOMAIN, + DOMAIN, + unique_id, + suggested_object_id=suggested_object_id, + original_name=suggested_object_id, + ) + + await setup_integration(hass, mock_config_entry) + + assert hass.states.get(entity_id).state == STATE_OFF + + assert len(issue_registry.issues) == 1 + issue = issue_registry.async_get_issue(DOMAIN, issue_id) + assert issue is not None + assert issue.translation_key == f"deprecated_binary_{issue_string}" + assert issue.translation_placeholders == { + "entity_id": entity_id, + "entity_name": suggested_object_id, + } + + entity_registry.async_update_entity( + entity_entry.entity_id, + disabled_by=er.RegistryEntryDisabler.USER, + ) + + await hass.config_entries.async_reload(mock_config_entry.entry_id) await hass.async_block_till_done() # Assert the issue is no longer present From c28a6a867dabde58af9b50d23b8d9b9f8a2bd626 Mon Sep 17 00:00:00 2001 From: Mikko Koo <43339021+98ultimate@users.noreply.github.com> Date: Tue, 1 Apr 2025 20:45:23 +0300 Subject: [PATCH 181/505] Fix nordpool Not to return Unknown if price is exactly 0 (#140647) * now the price will return even if it is exactly 0 * now the price will return even if it is exactly 0 * now the price will return even if it is exactly 0 * clean code * clean code * update testing code coverage * change zero testing to SE4 * remove row duplicate * fix date comments * improve testing * simplify if-return-0 * remove unnecessary tests * order testing rows * restore test_sensor_no_next_price * remove_average_price_test * fix test name --- homeassistant/components/nordpool/sensor.py | 2 +- .../nordpool/fixtures/delivery_period_today.json | 2 +- .../nordpool/snapshots/test_diagnostics.ambr | 2 +- .../components/nordpool/snapshots/test_sensor.ambr | 8 ++++---- tests/components/nordpool/test_sensor.py | 13 +++++++++++++ 5 files changed, 20 insertions(+), 7 deletions(-) diff --git a/homeassistant/components/nordpool/sensor.py b/homeassistant/components/nordpool/sensor.py index c6993826239..4bde12afc3c 100644 --- a/homeassistant/components/nordpool/sensor.py +++ b/homeassistant/components/nordpool/sensor.py @@ -34,7 +34,7 @@ def validate_prices( index: int, ) -> float | None: """Validate and return.""" - if result := func(entity)[area][index]: + if (result := func(entity)[area][index]) is not None: return result / 1000 return None diff --git a/tests/components/nordpool/fixtures/delivery_period_today.json b/tests/components/nordpool/fixtures/delivery_period_today.json index 77d51dc9433..df48c32a9a9 100644 --- a/tests/components/nordpool/fixtures/delivery_period_today.json +++ b/tests/components/nordpool/fixtures/delivery_period_today.json @@ -162,7 +162,7 @@ "deliveryEnd": "2024-11-05T19:00:00Z", "entryPerArea": { "SE3": 1011.77, - "SE4": 1804.46 + "SE4": 0.0 } }, { diff --git a/tests/components/nordpool/snapshots/test_diagnostics.ambr b/tests/components/nordpool/snapshots/test_diagnostics.ambr index 76a3dd96405..d7f7c4041cd 100644 --- a/tests/components/nordpool/snapshots/test_diagnostics.ambr +++ b/tests/components/nordpool/snapshots/test_diagnostics.ambr @@ -519,7 +519,7 @@ 'deliveryStart': '2024-11-05T18:00:00Z', 'entryPerArea': dict({ 'SE3': 1011.77, - 'SE4': 1804.46, + 'SE4': 0.0, }), }), dict({ diff --git a/tests/components/nordpool/snapshots/test_sensor.ambr b/tests/components/nordpool/snapshots/test_sensor.ambr index 86aa49357c5..be2b04cc520 100644 --- a/tests/components/nordpool/snapshots/test_sensor.ambr +++ b/tests/components/nordpool/snapshots/test_sensor.ambr @@ -1332,7 +1332,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '1.80446', + 'state': '0.0', }) # --- # name: test_sensor[sensor.nord_pool_se4_daily_average-entry] @@ -1580,9 +1580,9 @@ # name: test_sensor[sensor.nord_pool_se4_lowest_price-state] StateSnapshot({ 'attributes': ReadOnlyDict({ - 'end': '2024-11-05T03:00:00+00:00', + 'end': '2024-11-05T19:00:00+00:00', 'friendly_name': 'Nord Pool SE4 Lowest price', - 'start': '2024-11-05T02:00:00+00:00', + 'start': '2024-11-05T18:00:00+00:00', 'unit_of_measurement': 'SEK/kWh', }), 'context': , @@ -1590,7 +1590,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '0.06519', + 'state': '0.0', }) # --- # name: test_sensor[sensor.nord_pool_se4_next_price-entry] diff --git a/tests/components/nordpool/test_sensor.py b/tests/components/nordpool/test_sensor.py index 60be1ee3258..082684a2a02 100644 --- a/tests/components/nordpool/test_sensor.py +++ b/tests/components/nordpool/test_sensor.py @@ -33,6 +33,19 @@ async def test_sensor( await snapshot_platform(hass, entity_registry, snapshot, load_int.entry_id) +@pytest.mark.freeze_time("2024-11-05T18:00:00+00:00") +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +async def test_sensor_current_price_is_0( + hass: HomeAssistant, load_int: ConfigEntry +) -> None: + """Test the Nord Pool sensor working if price is 0.""" + + current_price = hass.states.get("sensor.nord_pool_se4_current_price") + + assert current_price is not None + assert current_price.state == "0.0" # SE4 2024-11-05T18:00:00Z + + @pytest.mark.freeze_time("2024-11-05T23:00:00+00:00") @pytest.mark.usefixtures("entity_registry_enabled_by_default") async def test_sensor_no_next_price(hass: HomeAssistant, load_int: ConfigEntry) -> None: From 704777444c5f6c20010b6e10a7473d06c8e66883 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ab=C3=ADlio=20Costa?= Date: Tue, 1 Apr 2025 19:02:24 +0100 Subject: [PATCH 182/505] Refactor Whirlpool sensor platform (#141958) * Refactor Whirlpool sensor platform * Rename sensor classes * Remove unused logging * Split washer dryer translation keys to use icon translations * Address review comments * Remove entity name; fix sentence casing --- homeassistant/components/whirlpool/entity.py | 38 ++++ homeassistant/components/whirlpool/icons.json | 12 ++ homeassistant/components/whirlpool/sensor.py | 179 ++++++++---------- .../components/whirlpool/strings.json | 75 +++++--- tests/components/whirlpool/conftest.py | 8 +- .../whirlpool/snapshots/test_diagnostics.ambr | 4 +- tests/components/whirlpool/test_sensor.py | 4 +- 7 files changed, 187 insertions(+), 133 deletions(-) create mode 100644 homeassistant/components/whirlpool/entity.py create mode 100644 homeassistant/components/whirlpool/icons.json diff --git a/homeassistant/components/whirlpool/entity.py b/homeassistant/components/whirlpool/entity.py new file mode 100644 index 00000000000..e74ed596e1e --- /dev/null +++ b/homeassistant/components/whirlpool/entity.py @@ -0,0 +1,38 @@ +"""Base entity for the Whirlpool integration.""" + +from whirlpool.appliance import Appliance + +from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.entity import Entity + +from .const import DOMAIN + + +class WhirlpoolEntity(Entity): + """Base class for Whirlpool entities.""" + + _attr_has_entity_name = True + + def __init__(self, appliance: Appliance, unique_id_suffix: str = "") -> None: + """Initialize the entity.""" + self._appliance = appliance + + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, appliance.said)}, + name=appliance.name.capitalize(), + manufacturer="Whirlpool", + ) + self._attr_unique_id = f"{appliance.said}{unique_id_suffix}" + + async def async_added_to_hass(self) -> None: + """Register attribute updates callback.""" + self._appliance.register_attr_callback(self.async_write_ha_state) + + async def async_will_remove_from_hass(self) -> None: + """Unregister attribute updates callback.""" + self._appliance.unregister_attr_callback(self.async_write_ha_state) + + @property + def available(self) -> bool: + """Return True if entity is available.""" + return self._appliance.get_online() diff --git a/homeassistant/components/whirlpool/icons.json b/homeassistant/components/whirlpool/icons.json new file mode 100644 index 00000000000..574b491090e --- /dev/null +++ b/homeassistant/components/whirlpool/icons.json @@ -0,0 +1,12 @@ +{ + "entity": { + "sensor": { + "washer_state": { + "default": "mdi:washing-machine" + }, + "dryer_state": { + "default": "mdi:tumble-dryer" + } + } + } +} diff --git a/homeassistant/components/whirlpool/sensor.py b/homeassistant/components/whirlpool/sensor.py index 3d38883b901..d167e3aa730 100644 --- a/homeassistant/components/whirlpool/sensor.py +++ b/homeassistant/components/whirlpool/sensor.py @@ -3,8 +3,9 @@ from collections.abc import Callable from dataclasses import dataclass from datetime import datetime, timedelta -import logging +from typing import override +from whirlpool.appliance import Appliance from whirlpool.washerdryer import MachineState, WasherDryer from homeassistant.components.sensor import ( @@ -13,16 +14,17 @@ from homeassistant.components.sensor import ( SensorEntity, SensorEntityDescription, ) -from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.typing import StateType from homeassistant.util.dt import utcnow from . import WhirlpoolConfigEntry -from .const import DOMAIN +from .entity import WhirlpoolEntity -TANK_FILL = { +SCAN_INTERVAL = timedelta(minutes=5) + +WASHER_TANK_FILL = { "0": "unknown", "1": "empty", "2": "25", @@ -31,7 +33,7 @@ TANK_FILL = { "5": "active", } -MACHINE_STATE = { +WASHER_DRYER_MACHINE_STATE = { MachineState.Standby: "standby", MachineState.Setting: "setting", MachineState.DelayCountdownMode: "delay_countdown", @@ -53,7 +55,7 @@ MACHINE_STATE = { MachineState.SystemInit: "system_initialize", } -CYCLE_FUNC = [ +WASHER_DRYER_CYCLE_FUNC = [ (WasherDryer.get_cycle_status_filling, "cycle_filling"), (WasherDryer.get_cycle_status_rinsing, "cycle_rinsing"), (WasherDryer.get_cycle_status_sensing, "cycle_sensing"), @@ -62,60 +64,69 @@ CYCLE_FUNC = [ (WasherDryer.get_cycle_status_washing, "cycle_washing"), ] -DOOR_OPEN = "door_open" - -_LOGGER = logging.getLogger(__name__) -SCAN_INTERVAL = timedelta(minutes=5) +STATE_DOOR_OPEN = "door_open" -def washer_state(washer: WasherDryer) -> str | None: - """Determine correct states for a washer.""" +def washer_dryer_state(washer_dryer: WasherDryer) -> str | None: + """Determine correct states for a washer/dryer.""" - if washer.get_attribute("Cavity_OpStatusDoorOpen") == "1": - return DOOR_OPEN + if washer_dryer.get_attribute("Cavity_OpStatusDoorOpen") == "1": + return STATE_DOOR_OPEN - machine_state = washer.get_machine_state() + machine_state = washer_dryer.get_machine_state() if machine_state == MachineState.RunningMainCycle: - for func, cycle_name in CYCLE_FUNC: - if func(washer): + for func, cycle_name in WASHER_DRYER_CYCLE_FUNC: + if func(washer_dryer): return cycle_name - return MACHINE_STATE.get(machine_state) + return WASHER_DRYER_MACHINE_STATE.get(machine_state) @dataclass(frozen=True, kw_only=True) class WhirlpoolSensorEntityDescription(SensorEntityDescription): - """Describes Whirlpool Washer sensor entity.""" + """Describes a Whirlpool sensor entity.""" - value_fn: Callable + value_fn: Callable[[Appliance], str | None] -SENSORS: tuple[WhirlpoolSensorEntityDescription, ...] = ( +WASHER_DRYER_STATE_OPTIONS = ( + list(WASHER_DRYER_MACHINE_STATE.values()) + + [value for _, value in WASHER_DRYER_CYCLE_FUNC] + + [STATE_DOOR_OPEN] +) + +WASHER_SENSORS: tuple[WhirlpoolSensorEntityDescription, ...] = ( WhirlpoolSensorEntityDescription( key="state", - translation_key="whirlpool_machine", + translation_key="washer_state", device_class=SensorDeviceClass.ENUM, - options=( - list(MACHINE_STATE.values()) - + [value for _, value in CYCLE_FUNC] - + [DOOR_OPEN] - ), - value_fn=washer_state, + options=WASHER_DRYER_STATE_OPTIONS, + value_fn=washer_dryer_state, ), WhirlpoolSensorEntityDescription( key="DispenseLevel", translation_key="whirlpool_tank", entity_registry_enabled_default=False, device_class=SensorDeviceClass.ENUM, - options=list(TANK_FILL.values()), - value_fn=lambda WasherDryer: TANK_FILL.get( - WasherDryer.get_attribute("WashCavity_OpStatusBulkDispense1Level") + options=list(WASHER_TANK_FILL.values()), + value_fn=lambda washer: WASHER_TANK_FILL.get( + washer.get_attribute("WashCavity_OpStatusBulkDispense1Level") ), ), ) -SENSOR_TIMER: tuple[SensorEntityDescription] = ( +DRYER_SENSORS: tuple[WhirlpoolSensorEntityDescription, ...] = ( + WhirlpoolSensorEntityDescription( + key="state", + translation_key="dryer_state", + device_class=SensorDeviceClass.ENUM, + options=WASHER_DRYER_STATE_OPTIONS, + value_fn=washer_dryer_state, + ), +) + +WASHER_DRYER_TIME_SENSORS: tuple[SensorEntityDescription] = ( SensorEntityDescription( key="timeremaining", translation_key="end_time", @@ -134,106 +145,71 @@ async def async_setup_entry( entities: list = [] appliances_manager = config_entry.runtime_data for washer_dryer in appliances_manager.washer_dryers: + sensor_descriptions = ( + DRYER_SENSORS + if "dryer" in washer_dryer.appliance_info.data_model.lower() + else WASHER_SENSORS + ) + entities.extend( - [WasherDryerClass(washer_dryer, description) for description in SENSORS] + WhirlpoolSensor(washer_dryer, description) + for description in sensor_descriptions ) entities.extend( - [ - WasherDryerTimeClass(washer_dryer, description) - for description in SENSOR_TIMER - ] + WasherDryerTimeSensor(washer_dryer, description) + for description in WASHER_DRYER_TIME_SENSORS ) async_add_entities(entities) -class WasherDryerClass(SensorEntity): - """A class for the whirlpool/maytag washer account.""" +class WhirlpoolSensor(WhirlpoolEntity, SensorEntity): + """A class for the Whirlpool sensors.""" _attr_should_poll = False - _attr_has_entity_name = True def __init__( - self, washer_dryer: WasherDryer, description: WhirlpoolSensorEntityDescription + self, appliance: Appliance, description: WhirlpoolSensorEntityDescription ) -> None: """Initialize the washer sensor.""" - self._wd: WasherDryer = washer_dryer - - self._attr_icon = ( - "mdi:tumble-dryer" - if "dryer" in washer_dryer.appliance_info.data_model.lower() - else "mdi:washing-machine" - ) - + super().__init__(appliance, unique_id_suffix=f"-{description.key}") self.entity_description: WhirlpoolSensorEntityDescription = description - self._attr_device_info = DeviceInfo( - identifiers={(DOMAIN, washer_dryer.said)}, - name=washer_dryer.name.capitalize(), - manufacturer="Whirlpool", - ) - self._attr_unique_id = f"{washer_dryer.said}-{description.key}" - - async def async_added_to_hass(self) -> None: - """Register updates callback.""" - self._wd.register_attr_callback(self.async_write_ha_state) - - async def async_will_remove_from_hass(self) -> None: - """Unregister updates callback.""" - self._wd.unregister_attr_callback(self.async_write_ha_state) - - @property - def available(self) -> bool: - """Return True if entity is available.""" - return self._wd.get_online() @property def native_value(self) -> StateType | str: """Return native value of sensor.""" - return self.entity_description.value_fn(self._wd) + return self.entity_description.value_fn(self._appliance) -class WasherDryerTimeClass(RestoreSensor): - """A timestamp class for the whirlpool/maytag washer account.""" +class WasherDryerTimeSensor(WhirlpoolEntity, RestoreSensor): + """A timestamp class for the Whirlpool washer/dryer.""" _attr_should_poll = True - _attr_has_entity_name = True def __init__( self, washer_dryer: WasherDryer, description: SensorEntityDescription ) -> None: """Initialize the washer sensor.""" - self._wd: WasherDryer = washer_dryer + super().__init__(washer_dryer, unique_id_suffix=f"-{description.key}") + self.entity_description = description - self.entity_description: SensorEntityDescription = description + self._wd = washer_dryer self._running: bool | None = None - self._attr_device_info = DeviceInfo( - identifiers={(DOMAIN, washer_dryer.said)}, - name=washer_dryer.name.capitalize(), - manufacturer="Whirlpool", - ) - self._attr_unique_id = f"{washer_dryer.said}-{description.key}" + self._value: datetime | None = None async def async_added_to_hass(self) -> None: - """Connect washer/dryer to the cloud.""" + """Register attribute updates callback.""" if restored_data := await self.async_get_last_sensor_data(): - self._attr_native_value = restored_data.native_value + if isinstance(restored_data.native_value, datetime): + self._value = restored_data.native_value await super().async_added_to_hass() - self._wd.register_attr_callback(self.update_from_latest_data) - - async def async_will_remove_from_hass(self) -> None: - """Close Whrilpool Appliance sockets before removing.""" - self._wd.unregister_attr_callback(self.update_from_latest_data) - - @property - def available(self) -> bool: - """Return True if entity is available.""" - return self._wd.get_online() async def async_update(self) -> None: """Update status of Whirlpool.""" await self._wd.fetch_data() - @callback - def update_from_latest_data(self) -> None: + @override + @property + def native_value(self) -> datetime | None: """Calculate the time stamp for completion.""" machine_state = self._wd.get_machine_state() now = utcnow() @@ -243,8 +219,7 @@ class WasherDryerTimeClass(RestoreSensor): and self._running ): self._running = False - self._attr_native_value = now - self._async_write_ha_state() + self._value = now if machine_state is MachineState.RunningMainCycle: self._running = True @@ -253,9 +228,9 @@ class WasherDryerTimeClass(RestoreSensor): seconds=int(self._wd.get_attribute("Cavity_TimeStatusEstTimeRemaining")) ) - if self._attr_native_value is None or ( - isinstance(self._attr_native_value, datetime) - and abs(new_timestamp - self._attr_native_value) > timedelta(seconds=60) + if self._value is None or ( + isinstance(self._value, datetime) + and abs(new_timestamp - self._value) > timedelta(seconds=60) ): - self._attr_native_value = new_timestamp - self._async_write_ha_state() + self._value = new_timestamp + return self._value diff --git a/homeassistant/components/whirlpool/strings.json b/homeassistant/components/whirlpool/strings.json index 95df3fb9098..56fee795237 100644 --- a/homeassistant/components/whirlpool/strings.json +++ b/homeassistant/components/whirlpool/strings.json @@ -43,35 +43,64 @@ }, "entity": { "sensor": { - "whirlpool_machine": { - "name": "State", + "washer_state": { "state": { "standby": "[%key:common::state::standby%]", "setting": "Setting", - "delay_countdown": "Delay Countdown", - "delay_paused": "Delay Paused", - "smart_delay": "Smart Delay", - "smart_grid_pause": "[%key:component::whirlpool::entity::sensor::whirlpool_machine::state::smart_delay%]", + "delay_countdown": "Delay countdown", + "delay_paused": "Delay paused", + "smart_delay": "Smart delay", + "smart_grid_pause": "[%key:component::whirlpool::entity::sensor::washer_state::state::smart_delay%]", "pause": "[%key:common::state::paused%]", - "running_maincycle": "Running Maincycle", - "running_postcycle": "Running Postcycle", + "running_maincycle": "Running maincycle", + "running_postcycle": "Running postcycle", "exception": "Exception", "complete": "Complete", - "power_failure": "Power Failure", - "service_diagnostic_mode": "Service Diagnostic Mode", - "factory_diagnostic_mode": "Factory Diagnostic Mode", - "life_test": "Life Test", - "customer_focus_mode": "Customer Focus Mode", - "demo_mode": "Demo Mode", - "hard_stop_or_error": "Hard Stop or Error", - "system_initialize": "System Initialize", - "cycle_filling": "Cycle Filling", - "cycle_rinsing": "Cycle Rinsing", - "cycle_sensing": "Cycle Sensing", - "cycle_soaking": "Cycle Soaking", - "cycle_spinning": "Cycle Spinning", - "cycle_washing": "Cycle Washing", - "door_open": "Door Open" + "power_failure": "Power failure", + "service_diagnostic_mode": "Service diagnostic mode", + "factory_diagnostic_mode": "Factory diagnostic mode", + "life_test": "Life test", + "customer_focus_mode": "Customer focus mode", + "demo_mode": "Demo mode", + "hard_stop_or_error": "Hard stop or error", + "system_initialize": "System initialize", + "cycle_filling": "Cycle filling", + "cycle_rinsing": "Cycle rinsing", + "cycle_sensing": "Cycle sensing", + "cycle_soaking": "Cycle soaking", + "cycle_spinning": "Cycle spinning", + "cycle_washing": "Cycle washing", + "door_open": "Door open" + } + }, + "dryer_state": { + "state": { + "standby": "[%key:common::state::standby%]", + "setting": "[%key:component::whirlpool::entity::sensor::washer_state::state::setting%]", + "delay_countdown": "[%key:component::whirlpool::entity::sensor::washer_state::state::delay_countdown%]", + "delay_paused": "[%key:component::whirlpool::entity::sensor::washer_state::state::delay_paused%]", + "smart_delay": "[%key:component::whirlpool::entity::sensor::washer_state::state::smart_delay%]", + "smart_grid_pause": "[%key:component::whirlpool::entity::sensor::washer_state::state::smart_delay%]", + "pause": "[%key:common::state::paused%]", + "running_maincycle": "[%key:component::whirlpool::entity::sensor::washer_state::state::running_maincycle%]", + "running_postcycle": "[%key:component::whirlpool::entity::sensor::washer_state::state::running_postcycle%]", + "exception": "[%key:component::whirlpool::entity::sensor::washer_state::state::exception%]", + "complete": "[%key:component::whirlpool::entity::sensor::washer_state::state::complete%]", + "power_failure": "[%key:component::whirlpool::entity::sensor::washer_state::state::power_failure%]", + "service_diagnostic_mode": "[%key:component::whirlpool::entity::sensor::washer_state::state::service_diagnostic_mode%]", + "factory_diagnostic_mode": "[%key:component::whirlpool::entity::sensor::washer_state::state::factory_diagnostic_mode%]", + "life_test": "[%key:component::whirlpool::entity::sensor::washer_state::state::life_test%]", + "customer_focus_mode": "[%key:component::whirlpool::entity::sensor::washer_state::state::customer_focus_mode%]", + "demo_mode": "[%key:component::whirlpool::entity::sensor::washer_state::state::demo_mode%]", + "hard_stop_or_error": "[%key:component::whirlpool::entity::sensor::washer_state::state::hard_stop_or_error%]", + "system_initialize": "[%key:component::whirlpool::entity::sensor::washer_state::state::system_initialize%]", + "cycle_filling": "[%key:component::whirlpool::entity::sensor::washer_state::state::cycle_filling%]", + "cycle_rinsing": "[%key:component::whirlpool::entity::sensor::washer_state::state::cycle_rinsing%]", + "cycle_sensing": "[%key:component::whirlpool::entity::sensor::washer_state::state::cycle_sensing%]", + "cycle_soaking": "[%key:component::whirlpool::entity::sensor::washer_state::state::cycle_soaking%]", + "cycle_spinning": "[%key:component::whirlpool::entity::sensor::washer_state::state::cycle_spinning%]", + "cycle_washing": "[%key:component::whirlpool::entity::sensor::washer_state::state::cycle_washing%]", + "door_open": "[%key:component::whirlpool::entity::sensor::washer_state::state::door_open%]" } }, "whirlpool_tank": { diff --git a/tests/components/whirlpool/conftest.py b/tests/components/whirlpool/conftest.py index 93881d3735a..97550729761 100644 --- a/tests/components/whirlpool/conftest.py +++ b/tests/components/whirlpool/conftest.py @@ -153,12 +153,12 @@ def side_effect_function(*args, **kwargs): return None -def get_sensor_mock(said): +def get_sensor_mock(said: str, data_model: str): """Get a mock of a sensor.""" mock_sensor = mock.Mock(said=said) mock_sensor.name = f"WasherDryer {said}" mock_sensor.register_attr_callback = MagicMock() - mock_sensor.appliance_info.data_model = "washer_dryer_model" + mock_sensor.appliance_info.data_model = data_model mock_sensor.appliance_info.category = "washer_dryer" mock_sensor.appliance_info.model_number = "12345" mock_sensor.get_online.return_value = True @@ -179,13 +179,13 @@ def get_sensor_mock(said): @pytest.fixture(name="mock_sensor1_api", autouse=False) def fixture_mock_sensor1_api(): """Set up sensor API fixture.""" - return get_sensor_mock(MOCK_SAID3) + return get_sensor_mock(MOCK_SAID3, "washer") @pytest.fixture(name="mock_sensor2_api", autouse=False) def fixture_mock_sensor2_api(): """Set up sensor API fixture.""" - return get_sensor_mock(MOCK_SAID4) + return get_sensor_mock(MOCK_SAID4, "dryer") @pytest.fixture(name="mock_sensor_api_instances", autouse=False) diff --git a/tests/components/whirlpool/snapshots/test_diagnostics.ambr b/tests/components/whirlpool/snapshots/test_diagnostics.ambr index 7ffae8bc808..7294e914f51 100644 --- a/tests/components/whirlpool/snapshots/test_diagnostics.ambr +++ b/tests/components/whirlpool/snapshots/test_diagnostics.ambr @@ -19,12 +19,12 @@ 'washer_dryers': dict({ 'WasherDryer said3': dict({ 'category': 'washer_dryer', - 'data_model': 'washer_dryer_model', + 'data_model': 'washer', 'model_number': '12345', }), 'WasherDryer said4': dict({ 'category': 'washer_dryer', - 'data_model': 'washer_dryer_model', + 'data_model': 'dryer', 'model_number': '12345', }), }), diff --git a/tests/components/whirlpool/test_sensor.py b/tests/components/whirlpool/test_sensor.py index 95fca331707..40c485a5b9f 100644 --- a/tests/components/whirlpool/test_sensor.py +++ b/tests/components/whirlpool/test_sensor.py @@ -66,7 +66,7 @@ async def test_dryer_sensor_values( await init_integration(hass) - entity_id = f"sensor.washerdryer_{MOCK_SAID4}_state" + entity_id = f"sensor.washerdryer_{MOCK_SAID4}_none" mock_instance = mock_sensor2_api entry = entity_registry.async_get(entity_id) assert entry @@ -130,7 +130,7 @@ async def test_washer_sensor_values( ) await hass.async_block_till_done() - entity_id = f"sensor.washerdryer_{MOCK_SAID3}_state" + entity_id = f"sensor.washerdryer_{MOCK_SAID3}_none" mock_instance = mock_sensor1_api entry = entity_registry.async_get(entity_id) assert entry From bd1c66984f52f5eb2370b8f2fee2f093008829cd Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Tue, 1 Apr 2025 20:23:03 +0200 Subject: [PATCH 183/505] Sentence-case "Heat pump" / "High demand" states in `water_heater` (#142012) --- homeassistant/components/water_heater/strings.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/water_heater/strings.json b/homeassistant/components/water_heater/strings.json index 07e132a0b5b..9cc3a84c3cd 100644 --- a/homeassistant/components/water_heater/strings.json +++ b/homeassistant/components/water_heater/strings.json @@ -14,8 +14,8 @@ "eco": "Eco", "electric": "Electric", "gas": "Gas", - "high_demand": "High Demand", - "heat_pump": "Heat Pump", + "high_demand": "High demand", + "heat_pump": "Heat pump", "performance": "Performance" }, "state_attributes": { From 91c53e9c523aeac8e2924f23da407f7307846e33 Mon Sep 17 00:00:00 2001 From: puddly <32534428+puddly@users.noreply.github.com> Date: Tue, 1 Apr 2025 14:27:06 -0400 Subject: [PATCH 184/505] Fix data in old SkyConnect integration config entries or delete them (#141959) * Delete old SkyConnect integration config entries * Try migrating, if possible * Do not delete config entries, log a failure --- .../homeassistant_sky_connect/__init__.py | 60 +++++++++- .../homeassistant_sky_connect/config_flow.py | 2 +- .../homeassistant_sky_connect/test_init.py | 111 +++++++++++++++++- 3 files changed, 166 insertions(+), 7 deletions(-) diff --git a/homeassistant/components/homeassistant_sky_connect/__init__.py b/homeassistant/components/homeassistant_sky_connect/__init__.py index 1770e902b0f..212826687e1 100644 --- a/homeassistant/components/homeassistant_sky_connect/__init__.py +++ b/homeassistant/components/homeassistant_sky_connect/__init__.py @@ -6,14 +6,29 @@ import logging import os.path from homeassistant.components.homeassistant_hardware.util import guess_firmware_info -from homeassistant.components.usb import USBDevice, async_register_port_event_callback +from homeassistant.components.usb import ( + USBDevice, + async_register_port_event_callback, + scan_serial_ports, +) from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback -from homeassistant.exceptions import ConfigEntryNotReady +from homeassistant.exceptions import ConfigEntryNotReady, HomeAssistantError from homeassistant.helpers import config_validation as cv from homeassistant.helpers.typing import ConfigType -from .const import DESCRIPTION, DEVICE, DOMAIN, FIRMWARE, FIRMWARE_VERSION, PRODUCT +from .const import ( + DESCRIPTION, + DEVICE, + DOMAIN, + FIRMWARE, + FIRMWARE_VERSION, + MANUFACTURER, + PID, + PRODUCT, + SERIAL_NUMBER, + VID, +) _LOGGER = logging.getLogger(__name__) @@ -73,7 +88,7 @@ async def async_migrate_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> """Migrate old entry.""" _LOGGER.debug( - "Migrating from version %s:%s", config_entry.version, config_entry.minor_version + "Migrating from version %s.%s", config_entry.version, config_entry.minor_version ) if config_entry.version == 1: @@ -108,6 +123,43 @@ async def async_migrate_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> minor_version=3, ) + if config_entry.minor_version == 3: + # Old SkyConnect config entries were missing keys + if any( + key not in config_entry.data + for key in (VID, PID, MANUFACTURER, PRODUCT, SERIAL_NUMBER) + ): + serial_ports = await hass.async_add_executor_job(scan_serial_ports) + serial_ports_info = {port.device: port for port in serial_ports} + device = config_entry.data[DEVICE] + + if not (usb_info := serial_ports_info.get(device)): + raise HomeAssistantError( + f"USB device {device} is missing, cannot migrate" + ) + + hass.config_entries.async_update_entry( + config_entry, + data={ + **config_entry.data, + VID: usb_info.vid, + PID: usb_info.pid, + MANUFACTURER: usb_info.manufacturer, + PRODUCT: usb_info.description, + DESCRIPTION: usb_info.description, + SERIAL_NUMBER: usb_info.serial_number, + }, + version=1, + minor_version=4, + ) + else: + # Existing entries are migrated by just incrementing the version + hass.config_entries.async_update_entry( + config_entry, + version=1, + minor_version=4, + ) + _LOGGER.debug( "Migration to version %s.%s successful", config_entry.version, diff --git a/homeassistant/components/homeassistant_sky_connect/config_flow.py b/homeassistant/components/homeassistant_sky_connect/config_flow.py index d28d74a681c..eb5ea214b3e 100644 --- a/homeassistant/components/homeassistant_sky_connect/config_flow.py +++ b/homeassistant/components/homeassistant_sky_connect/config_flow.py @@ -81,7 +81,7 @@ class HomeAssistantSkyConnectConfigFlow( """Handle a config flow for Home Assistant SkyConnect.""" VERSION = 1 - MINOR_VERSION = 3 + MINOR_VERSION = 4 def __init__(self, *args: Any, **kwargs: Any) -> None: """Initialize the config flow.""" diff --git a/tests/components/homeassistant_sky_connect/test_init.py b/tests/components/homeassistant_sky_connect/test_init.py index f38ac158e71..f027a6d2fb8 100644 --- a/tests/components/homeassistant_sky_connect/test_init.py +++ b/tests/components/homeassistant_sky_connect/test_init.py @@ -9,7 +9,15 @@ from homeassistant.components.homeassistant_hardware.util import ( ApplicationType, FirmwareInfo, ) -from homeassistant.components.homeassistant_sky_connect.const import DOMAIN +from homeassistant.components.homeassistant_sky_connect.const import ( + DESCRIPTION, + DOMAIN, + MANUFACTURER, + PID, + PRODUCT, + SERIAL_NUMBER, + VID, +) from homeassistant.components.usb import USBDevice from homeassistant.config_entries import ConfigEntryState from homeassistant.const import EVENT_HOMEASSISTANT_STARTED @@ -57,7 +65,7 @@ async def test_config_entry_migration_v2(hass: HomeAssistant) -> None: await hass.config_entries.async_setup(config_entry.entry_id) assert config_entry.version == 1 - assert config_entry.minor_version == 3 + assert config_entry.minor_version == 4 assert config_entry.data == { "description": "SkyConnect v1.0", "device": "/dev/serial/by-id/usb-Nabu_Casa_SkyConnect_v1.0_9e2adbd75b8beb119fe564a0f320645d-if00-port0", @@ -187,3 +195,102 @@ async def test_usb_device_reactivity(hass: HomeAssistant) -> None: # The integration has reloaded and is now in a failed state await hass.async_block_till_done(wait_background_tasks=True) assert config_entry.state is ConfigEntryState.SETUP_RETRY + + +async def test_bad_config_entry_fixing(hass: HomeAssistant) -> None: + """Test fixing/deleting config entries with bad data.""" + + # Newly-added ZBT-1 + new_entry = MockConfigEntry( + domain=DOMAIN, + unique_id="some_unique_id-9e2adbd75b8beb119fe564a0f320645d", + data={ + "device": "/dev/serial/by-id/usb-Nabu_Casa_SkyConnect_v1.0_9e2adbd75b8beb119fe564a0f320645d-if00-port0", + "vid": "10C4", + "pid": "EA60", + "serial_number": "9e2adbd75b8beb119fe564a0f320645d", + "manufacturer": "Nabu Casa", + "product": "SkyConnect v1.0", + "firmware": "ezsp", + "firmware_version": "7.4.4.0 (build 123)", + }, + version=1, + minor_version=3, + ) + + new_entry.add_to_hass(hass) + + # Old config entry, without firmware info + old_entry = MockConfigEntry( + domain=DOMAIN, + unique_id="some_unique_id-3c0ed67c628beb11b1cd64a0f320645d", + data={ + "device": "/dev/serial/by-id/usb-Nabu_Casa_SkyConnect_v1.0_3c0ed67c628beb11b1cd64a0f320645d-if00-port0", + "vid": "10C4", + "pid": "EA60", + "serial_number": "3c0ed67c628beb11b1cd64a0f320645d", + "manufacturer": "Nabu Casa", + "description": "SkyConnect v1.0", + }, + version=1, + minor_version=1, + ) + + old_entry.add_to_hass(hass) + + # Bad config entry, missing most keys + bad_entry = MockConfigEntry( + domain=DOMAIN, + unique_id="some_unique_id-9f6c4bba657cc9a4f0cea48bc5948562", + data={ + "device": "/dev/serial/by-id/usb-Nabu_Casa_SkyConnect_v1.0_9f6c4bba657cc9a4f0cea48bc5948562-if00-port0", + }, + version=1, + minor_version=2, + ) + + bad_entry.add_to_hass(hass) + + # Bad config entry, missing most keys, but fixable since the device is present + fixable_entry = MockConfigEntry( + domain=DOMAIN, + unique_id="some_unique_id-4f5f3b26d59f8714a78b599690741999", + data={ + "device": "/dev/serial/by-id/usb-Nabu_Casa_SkyConnect_v1.0_4f5f3b26d59f8714a78b599690741999-if00-port0", + }, + version=1, + minor_version=2, + ) + + fixable_entry.add_to_hass(hass) + + with patch( + "homeassistant.components.homeassistant_sky_connect.scan_serial_ports", + return_value=[ + USBDevice( + device="/dev/serial/by-id/usb-Nabu_Casa_SkyConnect_v1.0_4f5f3b26d59f8714a78b599690741999-if00-port0", + vid="10C4", + pid="EA60", + serial_number="4f5f3b26d59f8714a78b599690741999", + manufacturer="Nabu Casa", + description="SkyConnect v1.0", + ) + ], + ): + await async_setup_component(hass, "homeassistant_sky_connect", {}) + + assert hass.config_entries.async_get_entry(new_entry.entry_id) is not None + assert hass.config_entries.async_get_entry(old_entry.entry_id) is not None + assert hass.config_entries.async_get_entry(fixable_entry.entry_id) is not None + + updated_entry = hass.config_entries.async_get_entry(fixable_entry.entry_id) + assert updated_entry is not None + assert updated_entry.data[VID] == "10C4" + assert updated_entry.data[PID] == "EA60" + assert updated_entry.data[SERIAL_NUMBER] == "4f5f3b26d59f8714a78b599690741999" + assert updated_entry.data[MANUFACTURER] == "Nabu Casa" + assert updated_entry.data[PRODUCT] == "SkyConnect v1.0" + assert updated_entry.data[DESCRIPTION] == "SkyConnect v1.0" + + untouched_bad_entry = hass.config_entries.async_get_entry(bad_entry.entry_id) + assert untouched_bad_entry.minor_version == 3 From e7fadcda7b6670d6568eb91ac4a03f4e34488002 Mon Sep 17 00:00:00 2001 From: G Johansson Date: Tue, 1 Apr 2025 20:27:34 +0200 Subject: [PATCH 185/505] Fix train to for multiple stations in Trafikverket Train (#142016) --- homeassistant/components/trafikverket_train/config_flow.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/trafikverket_train/config_flow.py b/homeassistant/components/trafikverket_train/config_flow.py index eb0a4a45791..fb39e14815e 100644 --- a/homeassistant/components/trafikverket_train/config_flow.py +++ b/homeassistant/components/trafikverket_train/config_flow.py @@ -266,7 +266,7 @@ class TVTrainConfigFlow(ConfigFlow, domain=DOMAIN): { CONF_API_KEY: api_key, CONF_FROM: train_from, - CONF_TO: user_input[CONF_TO], + CONF_TO: train_to, CONF_TIME: train_time, CONF_WEEKDAY: train_days, CONF_FILTER_PRODUCT: filter_product, From 177fff3ff05a80c78e8e5d3eee891149af21dc09 Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Tue, 1 Apr 2025 20:28:11 +0200 Subject: [PATCH 186/505] Add type hint on inherrited attribute _message_callback for MQTT mixin classes (#142011) --- homeassistant/components/mqtt/entity.py | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/mqtt/entity.py b/homeassistant/components/mqtt/entity.py index 8446f9041c9..2fe801b6a01 100644 --- a/homeassistant/components/mqtt/entity.py +++ b/homeassistant/components/mqtt/entity.py @@ -399,6 +399,9 @@ class MqttAttributesMixin(Entity): _attributes_extra_blocked: frozenset[str] = frozenset() _attr_tpl: Callable[[ReceivePayloadType], ReceivePayloadType] | None = None + _message_callback: Callable[ + [MessageCallbackType, set[str] | None, ReceiveMessage], None + ] def __init__(self, config: ConfigType) -> None: """Initialize the JSON attributes mixin.""" @@ -433,7 +436,7 @@ class MqttAttributesMixin(Entity): CONF_JSON_ATTRS_TOPIC: { "topic": self._attributes_config.get(CONF_JSON_ATTRS_TOPIC), "msg_callback": partial( - self._message_callback, # type: ignore[attr-defined] + self._message_callback, self._attributes_message_received, {"_attr_extra_state_attributes"}, ), @@ -482,6 +485,10 @@ class MqttAttributesMixin(Entity): class MqttAvailabilityMixin(Entity): """Mixin used for platforms that report availability.""" + _message_callback: Callable[ + [MessageCallbackType, set[str] | None, ReceiveMessage], None + ] + def __init__(self, config: ConfigType) -> None: """Initialize the availability mixin.""" self._availability_sub_state: dict[str, EntitySubscription] = {} @@ -547,7 +554,7 @@ class MqttAvailabilityMixin(Entity): f"availability_{topic}": { "topic": topic, "msg_callback": partial( - self._message_callback, # type: ignore[attr-defined] + self._message_callback, self._availability_message_received, {"available"}, ), From 74c2060c49494f6beb56643d4f01e1426dfa9b16 Mon Sep 17 00:00:00 2001 From: puddly <32534428+puddly@users.noreply.github.com> Date: Tue, 1 Apr 2025 16:39:25 -0400 Subject: [PATCH 187/505] Skip firmware config flow confirmation if the hardware is in use (#142017) * Auto-confirm the discovery if we detect that the device is already in use * Add a unit test --- .../firmware_config_flow.py | 11 ++++++++ .../test_config_flow.py | 26 +++++++++++++++++++ 2 files changed, 37 insertions(+) diff --git a/homeassistant/components/homeassistant_hardware/firmware_config_flow.py b/homeassistant/components/homeassistant_hardware/firmware_config_flow.py index 83031587712..1b4840e5a98 100644 --- a/homeassistant/components/homeassistant_hardware/firmware_config_flow.py +++ b/homeassistant/components/homeassistant_hardware/firmware_config_flow.py @@ -33,6 +33,7 @@ from .util import ( OwningIntegration, get_otbr_addon_manager, get_zigbee_flasher_addon_manager, + guess_firmware_info, guess_hardware_owners, probe_silabs_firmware_info, ) @@ -511,6 +512,16 @@ class BaseFirmwareConfigFlow(BaseFirmwareInstallFlow, ConfigFlow): self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: """Confirm a discovery.""" + assert self._device is not None + fw_info = await guess_firmware_info(self.hass, self._device) + + # If our guess for the firmware type is actually running, we can save the user + # an unnecessary confirmation and silently confirm the flow + for owner in fw_info.owners: + if await owner.is_running(self.hass): + self._probed_firmware_info = fw_info + return self._async_flow_finished() + return await self.async_step_pick_firmware() diff --git a/tests/components/homeassistant_hardware/test_config_flow.py b/tests/components/homeassistant_hardware/test_config_flow.py index 9b7ae3e6f63..2d5067bea3e 100644 --- a/tests/components/homeassistant_hardware/test_config_flow.py +++ b/tests/components/homeassistant_hardware/test_config_flow.py @@ -381,6 +381,32 @@ async def test_config_flow_zigbee_skip_step_if_installed(hass: HomeAssistant) -> assert result["step_id"] == "confirm_zigbee" +async def test_config_flow_auto_confirm_if_running(hass: HomeAssistant) -> None: + """Test the config flow skips the confirmation step the hardware is already used.""" + with patch( + "homeassistant.components.homeassistant_hardware.firmware_config_flow.guess_firmware_info", + return_value=FirmwareInfo( + device=TEST_DEVICE, + firmware_type=ApplicationType.EZSP, + firmware_version="7.4.4.0", + owners=[Mock(is_running=AsyncMock(return_value=True))], + source="guess", + ), + ): + result = await hass.config_entries.flow.async_init( + TEST_DOMAIN, context={"source": "hardware"} + ) + + # There are no steps, the config entry is automatically created + assert result["type"] is FlowResultType.CREATE_ENTRY + config_entry = result["result"] + assert config_entry.data == { + "firmware": "ezsp", + "device": TEST_DEVICE, + "hardware": TEST_HARDWARE_NAME, + } + + async def test_config_flow_thread(hass: HomeAssistant) -> None: """Test the config flow.""" result = await hass.config_entries.flow.async_init( From 6a012498a5d6d815571abbafb5a83076a1b0267d Mon Sep 17 00:00:00 2001 From: puddly <32534428+puddly@users.noreply.github.com> Date: Tue, 1 Apr 2025 18:43:01 -0400 Subject: [PATCH 188/505] Fix entity names for HA hardware firmware update entities (#142029) * Fix entity names for HA hardware firmware update entities * Fix unit tests --- .../components/homeassistant_hardware/update.py | 7 +------ .../components/homeassistant_sky_connect/update.py | 1 - .../components/homeassistant_yellow/strings.json | 2 +- .../components/homeassistant_yellow/update.py | 10 +++++++--- tests/components/homeassistant_hardware/test_update.py | 9 ++++++++- tests/components/homeassistant_yellow/test_update.py | 2 +- 6 files changed, 18 insertions(+), 13 deletions(-) diff --git a/homeassistant/components/homeassistant_hardware/update.py b/homeassistant/components/homeassistant_hardware/update.py index 960facc81f8..1b0f15ca021 100644 --- a/homeassistant/components/homeassistant_hardware/update.py +++ b/homeassistant/components/homeassistant_hardware/update.py @@ -95,8 +95,7 @@ class BaseFirmwareUpdateEntity( _attr_supported_features = ( UpdateEntityFeature.INSTALL | UpdateEntityFeature.PROGRESS ) - # Until this entity can be associated with a device, we must manually name it - _attr_has_entity_name = False + _attr_has_entity_name = True def __init__( self, @@ -195,10 +194,6 @@ class BaseFirmwareUpdateEntity( def _update_attributes(self) -> None: """Recompute the attributes of the entity.""" - - # This entity is not currently associated with a device so we must manually - # give it a name - self._attr_name = f"{self._config_entry.title} Update" self._attr_title = self.entity_description.firmware_name or "Unknown" if ( diff --git a/homeassistant/components/homeassistant_sky_connect/update.py b/homeassistant/components/homeassistant_sky_connect/update.py index 5eaa1e220be..74c28b37eaf 100644 --- a/homeassistant/components/homeassistant_sky_connect/update.py +++ b/homeassistant/components/homeassistant_sky_connect/update.py @@ -168,7 +168,6 @@ class FirmwareUpdateEntity(BaseFirmwareUpdateEntity): """SkyConnect firmware update entity.""" bootloader_reset_type = None - _attr_has_entity_name = True def __init__( self, diff --git a/homeassistant/components/homeassistant_yellow/strings.json b/homeassistant/components/homeassistant_yellow/strings.json index ddff5fd9b6d..41c1438b234 100644 --- a/homeassistant/components/homeassistant_yellow/strings.json +++ b/homeassistant/components/homeassistant_yellow/strings.json @@ -152,7 +152,7 @@ }, "entity": { "update": { - "firmware": { + "radio_firmware": { "name": "Radio firmware" } } diff --git a/homeassistant/components/homeassistant_yellow/update.py b/homeassistant/components/homeassistant_yellow/update.py index 94989d5c6b6..9531bd456cb 100644 --- a/homeassistant/components/homeassistant_yellow/update.py +++ b/homeassistant/components/homeassistant_yellow/update.py @@ -44,6 +44,7 @@ FIRMWARE_ENTITY_DESCRIPTIONS: dict[ ] = { ApplicationType.EZSP: FirmwareUpdateEntityDescription( key="radio_firmware", + translation_key="radio_firmware", display_precision=0, device_class=UpdateDeviceClass.FIRMWARE, entity_category=EntityCategory.CONFIG, @@ -55,6 +56,7 @@ FIRMWARE_ENTITY_DESCRIPTIONS: dict[ ), ApplicationType.SPINEL: FirmwareUpdateEntityDescription( key="radio_firmware", + translation_key="radio_firmware", display_precision=0, device_class=UpdateDeviceClass.FIRMWARE, entity_category=EntityCategory.CONFIG, @@ -65,7 +67,8 @@ FIRMWARE_ENTITY_DESCRIPTIONS: dict[ firmware_name="OpenThread RCP", ), ApplicationType.CPC: FirmwareUpdateEntityDescription( - key="firmware", + key="radio_firmware", + translation_key="radio_firmware", display_precision=0, device_class=UpdateDeviceClass.FIRMWARE, entity_category=EntityCategory.CONFIG, @@ -76,7 +79,8 @@ FIRMWARE_ENTITY_DESCRIPTIONS: dict[ firmware_name="Multiprotocol", ), ApplicationType.GECKO_BOOTLOADER: FirmwareUpdateEntityDescription( - key="firmware", + key="radio_firmware", + translation_key="radio_firmware", display_precision=0, device_class=UpdateDeviceClass.FIRMWARE, entity_category=EntityCategory.CONFIG, @@ -88,6 +92,7 @@ FIRMWARE_ENTITY_DESCRIPTIONS: dict[ ), None: FirmwareUpdateEntityDescription( key="radio_firmware", + translation_key="radio_firmware", display_precision=0, device_class=UpdateDeviceClass.FIRMWARE, entity_category=EntityCategory.CONFIG, @@ -168,7 +173,6 @@ class FirmwareUpdateEntity(BaseFirmwareUpdateEntity): """Yellow firmware update entity.""" bootloader_reset_type = "yellow" # Triggers a GPIO reset - _attr_has_entity_name = True def __init__( self, diff --git a/tests/components/homeassistant_hardware/test_update.py b/tests/components/homeassistant_hardware/test_update.py index 0c351141e12..23d1e546791 100644 --- a/tests/components/homeassistant_hardware/test_update.py +++ b/tests/components/homeassistant_hardware/test_update.py @@ -43,6 +43,7 @@ from homeassistant.core import ( ) from homeassistant.helpers import entity_registry as er from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.setup import async_setup_component from homeassistant.util import dt as dt_util @@ -61,7 +62,7 @@ from tests.common import ( TEST_DOMAIN = "test" TEST_DEVICE = "/dev/serial/by-id/some-unique-serial-device-12345" TEST_FIRMWARE_RELEASES_URL = "https://example.org/firmware" -TEST_UPDATE_ENTITY_ID = "update.test_firmware" +TEST_UPDATE_ENTITY_ID = "update.mock_name_firmware" TEST_MANIFEST = FirmwareManifest( url=URL("https://example.org/firmware"), html_url=URL("https://example.org/release_notes"), @@ -205,6 +206,12 @@ class MockFirmwareUpdateEntity(BaseFirmwareUpdateEntity): """Initialize the mock SkyConnect firmware update entity.""" super().__init__(device, config_entry, update_coordinator, entity_description) self._attr_unique_id = self.entity_description.key + self._attr_device_info = DeviceInfo( + identifiers={(TEST_DOMAIN, "yellow")}, + name="Mock Name", + model="Mock Model", + manufacturer="Mock Manufacturer", + ) # Use the cached firmware info if it exists if self._config_entry.data["firmware"] is not None: diff --git a/tests/components/homeassistant_yellow/test_update.py b/tests/components/homeassistant_yellow/test_update.py index 2cc7b51836c..66404dc2176 100644 --- a/tests/components/homeassistant_yellow/test_update.py +++ b/tests/components/homeassistant_yellow/test_update.py @@ -17,7 +17,7 @@ from homeassistant.setup import async_setup_component from tests.common import MockConfigEntry -UPDATE_ENTITY_ID = "update.home_assistant_yellow_firmware" +UPDATE_ENTITY_ID = "update.home_assistant_yellow_radio_firmware" async def test_yellow_update_entity(hass: HomeAssistant) -> None: From 1040fe50ec59c47201de7b36f261d7428cc60075 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 1 Apr 2025 19:43:43 -1000 Subject: [PATCH 189/505] Bump aiohttp to 3.11.16 (#142034) changelog: https://github.com/aio-libs/aiohttp/compare/v3.11.15...v3.11.16 --- homeassistant/package_constraints.txt | 2 +- pyproject.toml | 2 +- requirements.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 291d47ec4cf..98e0de35a1e 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -6,7 +6,7 @@ aiodns==3.2.0 aiohasupervisor==0.3.0 aiohttp-asyncmdnsresolver==0.1.1 aiohttp-fast-zlib==0.2.3 -aiohttp==3.11.15 +aiohttp==3.11.16 aiohttp_cors==0.7.0 aiousbwatcher==1.1.1 aiozoneinfo==0.2.3 diff --git a/pyproject.toml b/pyproject.toml index b5ba5a0efd3..8d81bf7ff03 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -28,7 +28,7 @@ dependencies = [ # change behavior based on presence of supervisor. Deprecated with #127228 # Lib can be removed with 2025.11 "aiohasupervisor==0.3.0", - "aiohttp==3.11.15", + "aiohttp==3.11.16", "aiohttp_cors==0.7.0", "aiohttp-fast-zlib==0.2.3", "aiohttp-asyncmdnsresolver==0.1.1", diff --git a/requirements.txt b/requirements.txt index 0ef5062201a..f5e475bebce 100644 --- a/requirements.txt +++ b/requirements.txt @@ -5,7 +5,7 @@ # Home Assistant Core aiodns==3.2.0 aiohasupervisor==0.3.0 -aiohttp==3.11.15 +aiohttp==3.11.16 aiohttp_cors==0.7.0 aiohttp-fast-zlib==0.2.3 aiohttp-asyncmdnsresolver==0.1.1 From 253293c986de7e0d154ff6234ba1f7605ac564af Mon Sep 17 00:00:00 2001 From: TheJulianJES Date: Wed, 2 Apr 2025 07:45:17 +0200 Subject: [PATCH 190/505] Bump ZHA to 0.0.55 (#142031) --- homeassistant/components/zha/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/zha/manifest.json b/homeassistant/components/zha/manifest.json index 4daa2f2aa40..1c2d6556271 100644 --- a/homeassistant/components/zha/manifest.json +++ b/homeassistant/components/zha/manifest.json @@ -21,7 +21,7 @@ "zha", "universal_silabs_flasher" ], - "requirements": ["zha==0.0.54"], + "requirements": ["zha==0.0.55"], "usb": [ { "vid": "10C4", diff --git a/requirements_all.txt b/requirements_all.txt index b906e92998c..5e13aa372d7 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -3152,7 +3152,7 @@ zeroconf==0.146.0 zeversolar==0.3.2 # homeassistant.components.zha -zha==0.0.54 +zha==0.0.55 # homeassistant.components.zhong_hong zhong-hong-hvac==1.0.13 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index ff4f0e15af1..3c42737858e 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2542,7 +2542,7 @@ zeroconf==0.146.0 zeversolar==0.3.2 # homeassistant.components.zha -zha==0.0.54 +zha==0.0.55 # homeassistant.components.zwave_js zwave-js-server-python==0.62.0 From 2305cb0131bb8a997199cfaa09f418d817ad39b0 Mon Sep 17 00:00:00 2001 From: Tomek Wasilczyk Date: Wed, 2 Apr 2025 00:07:36 -0700 Subject: [PATCH 191/505] Fix warning about unfinished oauth tasks on shutdown (#141969) * Don't wait for OAuth token task on shutdown To reproduce the warning: 1. Start authentication with integration using OAuth (e.g. SmartThings) 2. When redirected to external login site, just close the page 3. Settings -> Restart Home Assistant * Clarify comment --------- Co-authored-by: Martin Hjelmare --- homeassistant/components/cloud/account_link.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/cloud/account_link.py b/homeassistant/components/cloud/account_link.py index 851d658f8e0..3c3d944d479 100644 --- a/homeassistant/components/cloud/account_link.py +++ b/homeassistant/components/cloud/account_link.py @@ -127,7 +127,11 @@ class CloudOAuth2Implementation(config_entry_oauth2_flow.AbstractOAuth2Implement flow_id=flow_id, user_input=tokens ) - self.hass.async_create_task(await_tokens()) + # It's a background task because it should be cancelled on shutdown and there's nothing else + # we can do in such case. There's also no need to wait for this during setup. + self.hass.async_create_background_task( + await_tokens(), name="Awaiting OAuth tokens" + ) return authorize_url From bb7e1d472380b3422787d5ac92d9fea0890cfa05 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 1 Apr 2025 21:09:39 -1000 Subject: [PATCH 192/505] Reduce overhead to run headers middleware (#142032) Instead of having to itererate a dict, update the headers multidict using a pre-build CIMultiDict which has an internal fast path --- homeassistant/components/http/headers.py | 21 +++++++++++++++------ 1 file changed, 15 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/http/headers.py b/homeassistant/components/http/headers.py index ebc0594e15a..fdb325c7b74 100644 --- a/homeassistant/components/http/headers.py +++ b/homeassistant/components/http/headers.py @@ -3,25 +3,34 @@ from __future__ import annotations from collections.abc import Awaitable, Callable +from typing import Final +from aiohttp import hdrs from aiohttp.web import Application, Request, StreamResponse, middleware from aiohttp.web_exceptions import HTTPException +from multidict import CIMultiDict, istr from homeassistant.core import callback +REFERRER_POLICY: Final[istr] = istr("Referrer-Policy") +X_CONTENT_TYPE_OPTIONS: Final[istr] = istr("X-Content-Type-Options") +X_FRAME_OPTIONS: Final[istr] = istr("X-Frame-Options") + @callback def setup_headers(app: Application, use_x_frame_options: bool) -> None: """Create headers middleware for the app.""" - added_headers = { - "Referrer-Policy": "no-referrer", - "X-Content-Type-Options": "nosniff", - "Server": "", # Empty server header, to prevent aiohttp of setting one. - } + added_headers = CIMultiDict( + { + REFERRER_POLICY: "no-referrer", + X_CONTENT_TYPE_OPTIONS: "nosniff", + hdrs.SERVER: "", # Empty server header, to prevent aiohttp of setting one. + } + ) if use_x_frame_options: - added_headers["X-Frame-Options"] = "SAMEORIGIN" + added_headers[X_FRAME_OPTIONS] = "SAMEORIGIN" @middleware async def headers_middleware( From c35ec1f12bbe18fd7120d57a0448071366e5b6e9 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 2 Apr 2025 09:19:06 +0200 Subject: [PATCH 193/505] Bump actions/dependency-review-action from 4.5.0 to 4.6.0 (#142042) Bumps [actions/dependency-review-action](https://github.com/actions/dependency-review-action) from 4.5.0 to 4.6.0. - [Release notes](https://github.com/actions/dependency-review-action/releases) - [Commits](https://github.com/actions/dependency-review-action/compare/v4.5.0...v4.6.0) --- updated-dependencies: - dependency-name: actions/dependency-review-action dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/ci.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index a843133f1a5..6fc1fdbca1c 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -653,7 +653,7 @@ jobs: - name: Check out code from GitHub uses: actions/checkout@v4.2.2 - name: Dependency review - uses: actions/dependency-review-action@v4.5.0 + uses: actions/dependency-review-action@v4.6.0 with: license-check: false # We use our own license audit checks From 6b45b0f522a071354824c0bee9b9ac2b09e1f12f Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 1 Apr 2025 22:37:27 -1000 Subject: [PATCH 194/505] Bump bluetooth-data-tools to 1.26.5 (#142045) changelog: https://github.com/Bluetooth-Devices/bluetooth-data-tools/compare/v1.26.1...v1.26.5 --- homeassistant/components/bluetooth/manifest.json | 2 +- homeassistant/components/ld2410_ble/manifest.json | 2 +- homeassistant/components/led_ble/manifest.json | 2 +- homeassistant/components/private_ble_device/manifest.json | 2 +- homeassistant/package_constraints.txt | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 7 files changed, 7 insertions(+), 7 deletions(-) diff --git a/homeassistant/components/bluetooth/manifest.json b/homeassistant/components/bluetooth/manifest.json index e4257221374..1b2b0e7267b 100644 --- a/homeassistant/components/bluetooth/manifest.json +++ b/homeassistant/components/bluetooth/manifest.json @@ -19,7 +19,7 @@ "bleak-retry-connector==3.9.0", "bluetooth-adapters==0.21.4", "bluetooth-auto-recovery==1.4.5", - "bluetooth-data-tools==1.26.1", + "bluetooth-data-tools==1.26.5", "dbus-fast==2.43.0", "habluetooth==3.37.0" ] diff --git a/homeassistant/components/ld2410_ble/manifest.json b/homeassistant/components/ld2410_ble/manifest.json index 1896f2109a7..764345710dd 100644 --- a/homeassistant/components/ld2410_ble/manifest.json +++ b/homeassistant/components/ld2410_ble/manifest.json @@ -20,5 +20,5 @@ "documentation": "https://www.home-assistant.io/integrations/ld2410_ble", "integration_type": "device", "iot_class": "local_push", - "requirements": ["bluetooth-data-tools==1.26.1", "ld2410-ble==0.1.1"] + "requirements": ["bluetooth-data-tools==1.26.5", "ld2410-ble==0.1.1"] } diff --git a/homeassistant/components/led_ble/manifest.json b/homeassistant/components/led_ble/manifest.json index 270495c8770..b88ef3f029a 100644 --- a/homeassistant/components/led_ble/manifest.json +++ b/homeassistant/components/led_ble/manifest.json @@ -35,5 +35,5 @@ "dependencies": ["bluetooth_adapters"], "documentation": "https://www.home-assistant.io/integrations/led_ble", "iot_class": "local_polling", - "requirements": ["bluetooth-data-tools==1.26.1", "led-ble==1.1.6"] + "requirements": ["bluetooth-data-tools==1.26.5", "led-ble==1.1.6"] } diff --git a/homeassistant/components/private_ble_device/manifest.json b/homeassistant/components/private_ble_device/manifest.json index 810fce41e05..df24f536527 100644 --- a/homeassistant/components/private_ble_device/manifest.json +++ b/homeassistant/components/private_ble_device/manifest.json @@ -6,5 +6,5 @@ "dependencies": ["bluetooth_adapters"], "documentation": "https://www.home-assistant.io/integrations/private_ble_device", "iot_class": "local_push", - "requirements": ["bluetooth-data-tools==1.26.1"] + "requirements": ["bluetooth-data-tools==1.26.5"] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 98e0de35a1e..28ff8861052 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -24,7 +24,7 @@ bleak-retry-connector==3.9.0 bleak==0.22.3 bluetooth-adapters==0.21.4 bluetooth-auto-recovery==1.4.5 -bluetooth-data-tools==1.26.1 +bluetooth-data-tools==1.26.5 cached-ipaddress==0.10.0 certifi>=2021.5.30 ciso8601==2.3.2 diff --git a/requirements_all.txt b/requirements_all.txt index 5e13aa372d7..fbf0f07ebf6 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -639,7 +639,7 @@ bluetooth-auto-recovery==1.4.5 # homeassistant.components.ld2410_ble # homeassistant.components.led_ble # homeassistant.components.private_ble_device -bluetooth-data-tools==1.26.1 +bluetooth-data-tools==1.26.5 # homeassistant.components.bond bond-async==0.2.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 3c42737858e..5da867578fe 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -564,7 +564,7 @@ bluetooth-auto-recovery==1.4.5 # homeassistant.components.ld2410_ble # homeassistant.components.led_ble # homeassistant.components.private_ble_device -bluetooth-data-tools==1.26.1 +bluetooth-data-tools==1.26.5 # homeassistant.components.bond bond-async==0.2.1 From e02a6f2f195621aaaef100e1c2f5d0a326182c0c Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Wed, 2 Apr 2025 11:00:13 +0200 Subject: [PATCH 195/505] Convert alexa test fixtures to async (#142054) --- .../components/alexa/test_flash_briefings.py | 61 +++---- tests/components/alexa/test_intent.py | 167 +++++++++--------- 2 files changed, 108 insertions(+), 120 deletions(-) diff --git a/tests/components/alexa/test_flash_briefings.py b/tests/components/alexa/test_flash_briefings.py index e76ed4ba6d0..c0f206ee4e2 100644 --- a/tests/components/alexa/test_flash_briefings.py +++ b/tests/components/alexa/test_flash_briefings.py @@ -1,6 +1,5 @@ """The tests for the Alexa component.""" -from asyncio import AbstractEventLoop import datetime from http import HTTPStatus @@ -24,13 +23,11 @@ NPR_NEWS_MP3_URL = "https://pd.npr.org/anon.npr-mp3/npr/news/newscast.mp3" @pytest.fixture -def alexa_client( - event_loop: AbstractEventLoop, +async def alexa_client( hass: HomeAssistant, hass_client: ClientSessionGenerator, ) -> TestClient: """Initialize a Home Assistant server for testing this module.""" - loop = event_loop @callback def mock_service(call): @@ -38,38 +35,36 @@ def alexa_client( hass.services.async_register("test", "alexa", mock_service) - assert loop.run_until_complete( - async_setup_component( - hass, - alexa.DOMAIN, - { - # Key is here to verify we allow other keys in config too - "homeassistant": {}, - "alexa": { - "flash_briefings": { - "password": "pass/abc", - "weather": [ - { - "title": "Weekly forecast", - "text": "This week it will be sunny.", - }, - { - "title": "Current conditions", - "text": "Currently it is 80 degrees fahrenheit.", - }, - ], - "news_audio": { - "title": "NPR", - "audio": NPR_NEWS_MP3_URL, - "display_url": "https://npr.org", - "uid": "uuid", + assert await async_setup_component( + hass, + alexa.DOMAIN, + { + # Key is here to verify we allow other keys in config too + "homeassistant": {}, + "alexa": { + "flash_briefings": { + "password": "pass/abc", + "weather": [ + { + "title": "Weekly forecast", + "text": "This week it will be sunny.", }, - } - }, + { + "title": "Current conditions", + "text": "Currently it is 80 degrees fahrenheit.", + }, + ], + "news_audio": { + "title": "NPR", + "audio": NPR_NEWS_MP3_URL, + "display_url": "https://npr.org", + "uid": "uuid", + }, + } }, - ) + }, ) - return loop.run_until_complete(hass_client()) + return await hass_client() def _flash_briefing_req(client, briefing_id, password="pass%2Fabc"): diff --git a/tests/components/alexa/test_intent.py b/tests/components/alexa/test_intent.py index b82048dca9b..9c9a292c456 100644 --- a/tests/components/alexa/test_intent.py +++ b/tests/components/alexa/test_intent.py @@ -1,6 +1,5 @@ """The tests for the Alexa component.""" -from asyncio import AbstractEventLoop from http import HTTPStatus import json @@ -30,13 +29,11 @@ NPR_NEWS_MP3_URL = "https://pd.npr.org/anon.npr-mp3/npr/news/newscast.mp3" @pytest.fixture -def alexa_client( - event_loop: AbstractEventLoop, +async def alexa_client( hass: HomeAssistant, hass_client: ClientSessionGenerator, ) -> TestClient: """Initialize a Home Assistant server for testing this module.""" - loop = event_loop @callback def mock_service(call): @@ -44,96 +41,92 @@ def alexa_client( hass.services.async_register("test", "alexa", mock_service) - assert loop.run_until_complete( - async_setup_component( - hass, - alexa.DOMAIN, - { - # Key is here to verify we allow other keys in config too - "homeassistant": {}, - "alexa": {}, - }, - ) + assert await async_setup_component( + hass, + alexa.DOMAIN, + { + # Key is here to verify we allow other keys in config too + "homeassistant": {}, + "alexa": {}, + }, ) - assert loop.run_until_complete( - async_setup_component( - hass, - "intent_script", - { - "intent_script": { - "WhereAreWeIntent": { - "speech": { - "type": "plain", - "text": """ - {%- if is_state("device_tracker.paulus", "home") - and is_state("device_tracker.anne_therese", - "home") -%} - You are both home, you silly - {%- else -%} - Anne Therese is at {{ - states("device_tracker.anne_therese") - }} and Paulus is at {{ - states("device_tracker.paulus") - }} - {% endif %} - """, - } + assert await async_setup_component( + hass, + "intent_script", + { + "intent_script": { + "WhereAreWeIntent": { + "speech": { + "type": "plain", + "text": """ + {%- if is_state("device_tracker.paulus", "home") + and is_state("device_tracker.anne_therese", + "home") -%} + You are both home, you silly + {%- else -%} + Anne Therese is at {{ + states("device_tracker.anne_therese") + }} and Paulus is at {{ + states("device_tracker.paulus") + }} + {% endif %} + """, + } + }, + "GetZodiacHoroscopeIntent": { + "speech": { + "type": "plain", + "text": "You told us your sign is {{ ZodiacSign }}.", + } + }, + "GetZodiacHoroscopeIDIntent": { + "speech": { + "type": "plain", + "text": "You told us your sign is {{ ZodiacSign_Id }}.", + } + }, + "AMAZON.PlaybackAction": { + "speech": { + "type": "plain", + "text": "Playing {{ object_byArtist_name }}.", + } + }, + "CallServiceIntent": { + "speech": { + "type": "plain", + "text": "Service called for {{ ZodiacSign }}", }, - "GetZodiacHoroscopeIntent": { - "speech": { - "type": "plain", - "text": "You told us your sign is {{ ZodiacSign }}.", - } + "card": { + "type": "simple", + "title": "Card title for {{ ZodiacSign }}", + "content": "Card content: {{ ZodiacSign }}", }, - "GetZodiacHoroscopeIDIntent": { - "speech": { - "type": "plain", - "text": "You told us your sign is {{ ZodiacSign_Id }}.", - } + "action": { + "service": "test.alexa", + "data_template": {"hello": "{{ ZodiacSign }}"}, + "entity_id": "switch.test", }, - "AMAZON.PlaybackAction": { - "speech": { - "type": "plain", - "text": "Playing {{ object_byArtist_name }}.", - } + }, + APPLICATION_ID: { + "speech": { + "type": "plain", + "text": "LaunchRequest has been received.", + } + }, + APPLICATION_ID_SESSION_OPEN: { + "speech": { + "type": "plain", + "text": "LaunchRequest has been received.", }, - "CallServiceIntent": { - "speech": { - "type": "plain", - "text": "Service called for {{ ZodiacSign }}", - }, - "card": { - "type": "simple", - "title": "Card title for {{ ZodiacSign }}", - "content": "Card content: {{ ZodiacSign }}", - }, - "action": { - "service": "test.alexa", - "data_template": {"hello": "{{ ZodiacSign }}"}, - "entity_id": "switch.test", - }, + "reprompt": { + "type": "plain", + "text": "LaunchRequest has been received.", }, - APPLICATION_ID: { - "speech": { - "type": "plain", - "text": "LaunchRequest has been received.", - } - }, - APPLICATION_ID_SESSION_OPEN: { - "speech": { - "type": "plain", - "text": "LaunchRequest has been received.", - }, - "reprompt": { - "type": "plain", - "text": "LaunchRequest has been received.", - }, - }, - } - }, - ) + }, + } + }, ) - return loop.run_until_complete(hass_client()) + return await hass_client() def _intent_req(client, data=None): From 8432b6a7908cd8ce168215dfa19e0cf57491b7dc Mon Sep 17 00:00:00 2001 From: Robert Resch Date: Wed, 2 Apr 2025 11:48:27 +0200 Subject: [PATCH 196/505] Bump deebot-client to 12.5.0 (#142046) --- homeassistant/components/ecovacs/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/ecovacs/manifest.json b/homeassistant/components/ecovacs/manifest.json index acb5b620719..ad8b3ea70a5 100644 --- a/homeassistant/components/ecovacs/manifest.json +++ b/homeassistant/components/ecovacs/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/ecovacs", "iot_class": "cloud_push", "loggers": ["sleekxmppfs", "sucks", "deebot_client"], - "requirements": ["py-sucks==0.9.10", "deebot-client==12.4.0"] + "requirements": ["py-sucks==0.9.10", "deebot-client==12.5.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index fbf0f07ebf6..ccd12e70731 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -758,7 +758,7 @@ debugpy==1.8.13 # decora==0.6 # homeassistant.components.ecovacs -deebot-client==12.4.0 +deebot-client==12.5.0 # homeassistant.components.ihc # homeassistant.components.namecheapdns diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 5da867578fe..25fa47b9413 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -649,7 +649,7 @@ dbus-fast==2.43.0 debugpy==1.8.13 # homeassistant.components.ecovacs -deebot-client==12.4.0 +deebot-client==12.5.0 # homeassistant.components.ihc # homeassistant.components.namecheapdns From 36857b4b207fd2b4dbc0b564d6d775b673492c22 Mon Sep 17 00:00:00 2001 From: Petro31 <35082313+Petro31@users.noreply.github.com> Date: Wed, 2 Apr 2025 06:38:48 -0400 Subject: [PATCH 197/505] Fix weather templates using new style configuration (#136677) --- homeassistant/components/template/weather.py | 59 +++++++++++++++---- tests/components/template/test_weather.py | 62 ++++++++++++++++++++ 2 files changed, 109 insertions(+), 12 deletions(-) diff --git a/homeassistant/components/template/weather.py b/homeassistant/components/template/weather.py index 7f597f1d9a8..86bab6f5ad1 100644 --- a/homeassistant/components/template/weather.py +++ b/homeassistant/components/template/weather.py @@ -135,6 +135,33 @@ WEATHER_SCHEMA = vol.Schema( PLATFORM_SCHEMA = WEATHER_PLATFORM_SCHEMA.extend(WEATHER_SCHEMA.schema) +@callback +def _async_create_template_tracking_entities( + async_add_entities: AddEntitiesCallback, + hass: HomeAssistant, + definitions: list[dict], + unique_id_prefix: str | None, +) -> None: + """Create the weather entities.""" + entities = [] + + for entity_conf in definitions: + unique_id = entity_conf.get(CONF_UNIQUE_ID) + + if unique_id and unique_id_prefix: + unique_id = f"{unique_id_prefix}-{unique_id}" + + entities.append( + WeatherTemplate( + hass, + entity_conf, + unique_id, + ) + ) + + async_add_entities(entities) + + async def async_setup_platform( hass: HomeAssistant, config: ConfigType, @@ -142,24 +169,32 @@ async def async_setup_platform( discovery_info: DiscoveryInfoType | None = None, ) -> None: """Set up the Template weather.""" - if discovery_info and "coordinator" in discovery_info: + if discovery_info is None: + config = rewrite_common_legacy_to_modern_conf(hass, config) + unique_id = config.get(CONF_UNIQUE_ID) + async_add_entities( + [ + WeatherTemplate( + hass, + config, + unique_id, + ) + ] + ) + return + + if "coordinator" in discovery_info: async_add_entities( TriggerWeatherEntity(hass, discovery_info["coordinator"], config) for config in discovery_info["entities"] ) return - config = rewrite_common_legacy_to_modern_conf(hass, config) - unique_id = config.get(CONF_UNIQUE_ID) - - async_add_entities( - [ - WeatherTemplate( - hass, - config, - unique_id, - ) - ] + _async_create_template_tracking_entities( + async_add_entities, + hass, + discovery_info["entities"], + discovery_info["unique_id"], ) diff --git a/tests/components/template/test_weather.py b/tests/components/template/test_weather.py index 081028b6f5b..5db6a000ccc 100644 --- a/tests/components/template/test_weather.py +++ b/tests/components/template/test_weather.py @@ -928,3 +928,65 @@ async def test_trigger_entity_restore_state_fail( state = hass.states.get("weather.test") assert state.state == STATE_UNKNOWN assert state.attributes.get("temperature") is None + + +async def test_new_style_template_state_text(hass: HomeAssistant) -> None: + """Test the state text of a template.""" + assert await async_setup_component( + hass, + "weather", + { + "weather": [ + {"weather": {"platform": "demo"}}, + ] + }, + ) + assert await async_setup_component( + hass, + "template", + { + "template": { + "weather": { + "name": "test", + "attribution_template": "{{ states('sensor.attribution') }}", + "condition_template": "sunny", + "temperature_template": "{{ states('sensor.temperature') | float }}", + "humidity_template": "{{ states('sensor.humidity') | int }}", + "pressure_template": "{{ states('sensor.pressure') }}", + "wind_speed_template": "{{ states('sensor.windspeed') }}", + "wind_bearing_template": "{{ states('sensor.windbearing') }}", + "ozone_template": "{{ states('sensor.ozone') }}", + "visibility_template": "{{ states('sensor.visibility') }}", + "wind_gust_speed_template": "{{ states('sensor.wind_gust_speed') }}", + "cloud_coverage_template": "{{ states('sensor.cloud_coverage') }}", + "dew_point_template": "{{ states('sensor.dew_point') }}", + "apparent_temperature_template": "{{ states('sensor.apparent_temperature') }}", + }, + }, + }, + ) + + for attr, v_attr, value in ( + ( + "sensor.attribution", + ATTR_ATTRIBUTION, + "The custom attribution", + ), + ("sensor.temperature", ATTR_WEATHER_TEMPERATURE, 22.3), + ("sensor.humidity", ATTR_WEATHER_HUMIDITY, 60), + ("sensor.pressure", ATTR_WEATHER_PRESSURE, 1000), + ("sensor.windspeed", ATTR_WEATHER_WIND_SPEED, 20), + ("sensor.windbearing", ATTR_WEATHER_WIND_BEARING, 180), + ("sensor.ozone", ATTR_WEATHER_OZONE, 25), + ("sensor.visibility", ATTR_WEATHER_VISIBILITY, 4.6), + ("sensor.wind_gust_speed", ATTR_WEATHER_WIND_GUST_SPEED, 30), + ("sensor.cloud_coverage", ATTR_WEATHER_CLOUD_COVERAGE, 75), + ("sensor.dew_point", ATTR_WEATHER_DEW_POINT, 2.2), + ("sensor.apparent_temperature", ATTR_WEATHER_APPARENT_TEMPERATURE, 25), + ): + hass.states.async_set(attr, value) + await hass.async_block_till_done() + state = hass.states.get("weather.test") + assert state is not None + assert state.state == "sunny" + assert state.attributes.get(v_attr) == value From 795e01512afce931036702a5001f11a14f9daf8d Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Wed, 2 Apr 2025 13:49:12 +0200 Subject: [PATCH 198/505] Correct TodoItem docstrings (#142066) --- homeassistant/components/todo/__init__.py | 12 ++---------- 1 file changed, 2 insertions(+), 10 deletions(-) diff --git a/homeassistant/components/todo/__init__.py b/homeassistant/components/todo/__init__.py index 937187c1c6f..c1c921343b8 100644 --- a/homeassistant/components/todo/__init__.py +++ b/homeassistant/components/todo/__init__.py @@ -219,18 +219,10 @@ class TodoItem: """A status or confirmation of the To-do item.""" due: datetime.date | datetime.datetime | None = None - """The date and time that a to-do is expected to be completed. - - This field may be a date or datetime depending whether the entity feature - DUE_DATE or DUE_DATETIME are set. - """ + """The date and time that a to-do is expected to be completed.""" description: str | None = None - """A more complete description of than that provided by the summary. - - This field may be set when TodoListEntityFeature.DESCRIPTION is supported by - the entity. - """ + """A more complete description than that provided by the summary.""" CACHED_PROPERTIES_WITH_ATTR_ = { From ca48b078586ec8711ecb1dd6d9a61a98f9789980 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Wed, 2 Apr 2025 13:54:58 +0200 Subject: [PATCH 199/505] Add Eve brand (#142067) --- homeassistant/brands/eve.json | 5 +++++ homeassistant/generated/integrations.json | 6 ++++++ 2 files changed, 11 insertions(+) create mode 100644 homeassistant/brands/eve.json diff --git a/homeassistant/brands/eve.json b/homeassistant/brands/eve.json new file mode 100644 index 00000000000..f27c8b3d849 --- /dev/null +++ b/homeassistant/brands/eve.json @@ -0,0 +1,5 @@ +{ + "domain": "eve", + "name": "Eve", + "iot_standards": ["matter"] +} diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index 7bc76a28284..d0f0efe8ded 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -1825,6 +1825,12 @@ } } }, + "eve": { + "name": "Eve", + "iot_standards": [ + "matter" + ] + }, "evergy": { "name": "Evergy", "integration_type": "virtual", From 93ea88f3ded293851856a657525468cf018c9426 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Wed, 2 Apr 2025 13:56:23 +0200 Subject: [PATCH 200/505] Improve SmartThings sensor deprecation (#142070) * Improve SmartThings sensor deprecation * Improve SmartThings sensor deprecation * Improve SmartThings sensor deprecation --- .../components/smartthings/sensor.py | 147 ++-- .../components/smartthings/strings.json | 6 +- .../smartthings/snapshots/test_sensor.ambr | 831 ------------------ .../smartthings/test_binary_sensor.py | 2 - tests/components/smartthings/test_sensor.py | 187 +++- 5 files changed, 240 insertions(+), 933 deletions(-) diff --git a/homeassistant/components/smartthings/sensor.py b/homeassistant/components/smartthings/sensor.py index 424483d9617..346516be480 100644 --- a/homeassistant/components/smartthings/sensor.py +++ b/homeassistant/components/smartthings/sensor.py @@ -9,9 +9,8 @@ from typing import Any, cast from pysmartthings import Attribute, Capability, ComponentStatus, SmartThings, Status -from homeassistant.components.automation import automations_with_entity -from homeassistant.components.script import scripts_with_entity from homeassistant.components.sensor import ( + DOMAIN as SENSOR_DOMAIN, SensorDeviceClass, SensorEntity, SensorEntityDescription, @@ -33,16 +32,12 @@ from homeassistant.const import ( from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from homeassistant.helpers.issue_registry import ( - IssueSeverity, - async_create_issue, - async_delete_issue, -) from homeassistant.util import dt as dt_util from . import FullDevice, SmartThingsConfigEntry -from .const import DOMAIN, MAIN +from .const import MAIN from .entity import SmartThingsEntity +from .util import deprecate_entity THERMOSTAT_CAPABILITIES = { Capability.TEMPERATURE_MEASUREMENT, @@ -1021,31 +1016,67 @@ async def async_setup_entry( ) -> None: """Add sensors for a config entry.""" entry_data = entry.runtime_data - async_add_entities( - SmartThingsSensor( - entry_data.client, - device, - description, - capability, - attribute, - ) - for device in entry_data.devices.values() - for capability, attributes in CAPABILITY_TO_SENSORS.items() - if capability in device.status[MAIN] - for attribute, descriptions in attributes.items() - for description in descriptions - if ( - not description.capability_ignore_list - or not any( - all(capability in device.status[MAIN] for capability in capability_list) - for capability_list in description.capability_ignore_list - ) - ) - and ( - not description.exists_fn - or description.exists_fn(device.status[MAIN][capability][attribute]) - ) - ) + entities = [] + + entity_registry = er.async_get(hass) + + for device in entry_data.devices.values(): # pylint: disable=too-many-nested-blocks + for capability, attributes in CAPABILITY_TO_SENSORS.items(): + if capability in device.status[MAIN]: + for attribute, descriptions in attributes.items(): + for description in descriptions: + if ( + not description.capability_ignore_list + or not any( + all( + capability in device.status[MAIN] + for capability in capability_list + ) + for capability_list in description.capability_ignore_list + ) + ) and ( + not description.exists_fn + or description.exists_fn( + device.status[MAIN][capability][attribute] + ) + ): + if ( + description.deprecated + and ( + reason := description.deprecated( + device.status[MAIN] + ) + ) + is not None + ): + if deprecate_entity( + hass, + entity_registry, + SENSOR_DOMAIN, + f"{device.device.device_id}_{MAIN}_{capability}_{attribute}_{description.key}", + f"deprecated_{reason}", + ): + entities.append( + SmartThingsSensor( + entry_data.client, + device, + description, + capability, + attribute, + ) + ) + continue + entities.append( + SmartThingsSensor( + entry_data.client, + device, + description, + capability, + attribute, + ) + ) + + async_add_entities(entities) class SmartThingsSensor(SmartThingsEntity, SensorEntity): @@ -1113,53 +1144,3 @@ class SmartThingsSensor(SmartThingsEntity, SensorEntity): return [] return [option.lower() for option in options] return super().options - - async def async_added_to_hass(self) -> None: - """Call when entity is added to hass.""" - await super().async_added_to_hass() - if ( - not self.entity_description.deprecated - or (reason := self.entity_description.deprecated(self.device.status[MAIN])) - is None - ): - return - automations = automations_with_entity(self.hass, self.entity_id) - scripts = scripts_with_entity(self.hass, self.entity_id) - if not automations and not scripts: - return - - entity_reg: er.EntityRegistry = er.async_get(self.hass) - items_list = [ - f"- [{item.original_name}](/config/{integration}/edit/{item.unique_id})" - for integration, entities in ( - ("automation", automations), - ("script", scripts), - ) - for entity_id in entities - if (item := entity_reg.async_get(entity_id)) - ] - - async_create_issue( - self.hass, - DOMAIN, - f"deprecated_{reason}_{self.entity_id}", - breaks_in_ha_version="2025.10.0", - is_fixable=False, - severity=IssueSeverity.WARNING, - translation_key=f"deprecated_{reason}", - translation_placeholders={ - "entity": self.entity_id, - "items": "\n".join(items_list), - }, - ) - - async def async_will_remove_from_hass(self) -> None: - """Call when entity will be removed from hass.""" - await super().async_will_remove_from_hass() - if ( - not self.entity_description.deprecated - or (reason := self.entity_description.deprecated(self.device.status[MAIN])) - is None - ): - return - async_delete_issue(self.hass, DOMAIN, f"deprecated_{reason}_{self.entity_id}") diff --git a/homeassistant/components/smartthings/strings.json b/homeassistant/components/smartthings/strings.json index 1fbe535261e..43c6a22e66a 100644 --- a/homeassistant/components/smartthings/strings.json +++ b/homeassistant/components/smartthings/strings.json @@ -504,8 +504,12 @@ "description": "The switch `{entity}` is deprecated and a media player entity has been added to replace it.\n\nThe switch was used in the following automations or scripts:\n{items}\n\nPlease use the new media player entity in the above automations or scripts to fix this issue." }, "deprecated_media_player": { + "title": "Media player sensors deprecated", + "description": "The sensor {entity_name} (`{entity_id}`) is deprecated because it has been replaced with a media player entity.\n\nPlease update your dashboards, templates to use the new media player entity and disable the entity to fix this issue." + }, + "deprecated_media_player_scripts": { "title": "Deprecated sensor detected in some automations or scripts", - "description": "The sensor `{entity}` is deprecated because it has been replaced with a media player entity.\n\nThe sensor was used in the following automations or scripts:\n{items}\n\nPlease use the new media player entity in the above automations or scripts to fix this issue." + "description": "The sensor {entity_name} (`{entity_id}`) is deprecated because it has been replaced with a media player entity.\n\nThe sensor was used in the following automations or scripts:\n{items}\n\nPlease use them in the above automations or scripts to use the new media player entity and disable the entity to fix this issue." } } } diff --git a/tests/components/smartthings/snapshots/test_sensor.ambr b/tests/components/smartthings/snapshots/test_sensor.ambr index 7be10ebac91..8ace345be18 100644 --- a/tests/components/smartthings/snapshots/test_sensor.ambr +++ b/tests/components/smartthings/snapshots/test_sensor.ambr @@ -8366,182 +8366,6 @@ 'state': '19.0', }) # --- -# name: test_all_entities[hw_q80r_soundbar][sensor.soundbar_media_input_source-entry] - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': dict({ - 'options': list([ - 'wifi', - 'bluetooth', - 'hdmi1', - 'hdmi2', - 'digital', - ]), - }), - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.soundbar_media_input_source', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': 'Media input source', - 'platform': 'smartthings', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'media_input_source', - 'unique_id': 'afcf3b91-0000-1111-2222-ddff2a0a6577_main_mediaInputSource_inputSource_inputSource', - 'unit_of_measurement': None, - }) -# --- -# name: test_all_entities[hw_q80r_soundbar][sensor.soundbar_media_input_source-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'enum', - 'friendly_name': 'Soundbar Media input source', - 'options': list([ - 'wifi', - 'bluetooth', - 'hdmi1', - 'hdmi2', - 'digital', - ]), - }), - 'context': , - 'entity_id': 'sensor.soundbar_media_input_source', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'wifi', - }) -# --- -# name: test_all_entities[hw_q80r_soundbar][sensor.soundbar_media_playback_status-entry] - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': dict({ - 'options': list([ - 'paused', - 'playing', - 'stopped', - 'fast_forwarding', - 'rewinding', - 'buffering', - ]), - }), - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.soundbar_media_playback_status', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': 'Media playback status', - 'platform': 'smartthings', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'media_playback_status', - 'unique_id': 'afcf3b91-0000-1111-2222-ddff2a0a6577_main_mediaPlayback_playbackStatus_playbackStatus', - 'unit_of_measurement': None, - }) -# --- -# name: test_all_entities[hw_q80r_soundbar][sensor.soundbar_media_playback_status-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'enum', - 'friendly_name': 'Soundbar Media playback status', - 'options': list([ - 'paused', - 'playing', - 'stopped', - 'fast_forwarding', - 'rewinding', - 'buffering', - ]), - }), - 'context': , - 'entity_id': 'sensor.soundbar_media_playback_status', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'playing', - }) -# --- -# name: test_all_entities[hw_q80r_soundbar][sensor.soundbar_volume-entry] - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.soundbar_volume', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': None, - 'original_icon': None, - 'original_name': 'Volume', - 'platform': 'smartthings', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'audio_volume', - 'unique_id': 'afcf3b91-0000-1111-2222-ddff2a0a6577_main_audioVolume_volume_volume', - 'unit_of_measurement': '%', - }) -# --- -# name: test_all_entities[hw_q80r_soundbar][sensor.soundbar_volume-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'Soundbar Volume', - 'unit_of_measurement': '%', - }), - 'context': , - 'entity_id': 'sensor.soundbar_volume', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '1', - }) -# --- # name: test_all_entities[ikea_kadrilj][sensor.kitchen_ikea_kadrilj_window_blind_battery-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -8591,261 +8415,6 @@ 'state': '37', }) # --- -# name: test_all_entities[im_speaker_ai_0001][sensor.galaxy_home_mini_media_input_source-entry] - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.galaxy_home_mini_media_input_source', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': 'Media input source', - 'platform': 'smartthings', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'media_input_source', - 'unique_id': 'c9276e43-fe3c-88c3-1dcc-2eb79e292b8c_main_mediaInputSource_inputSource_inputSource', - 'unit_of_measurement': None, - }) -# --- -# name: test_all_entities[im_speaker_ai_0001][sensor.galaxy_home_mini_media_input_source-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'enum', - 'friendly_name': 'Galaxy Home Mini Media input source', - }), - 'context': , - 'entity_id': 'sensor.galaxy_home_mini_media_input_source', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', - }) -# --- -# name: test_all_entities[im_speaker_ai_0001][sensor.galaxy_home_mini_media_playback_repeat-entry] - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.galaxy_home_mini_media_playback_repeat', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': None, - 'original_icon': None, - 'original_name': 'Media playback repeat', - 'platform': 'smartthings', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'media_playback_repeat', - 'unique_id': 'c9276e43-fe3c-88c3-1dcc-2eb79e292b8c_main_mediaPlaybackRepeat_playbackRepeatMode_playbackRepeatMode', - 'unit_of_measurement': None, - }) -# --- -# name: test_all_entities[im_speaker_ai_0001][sensor.galaxy_home_mini_media_playback_repeat-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'Galaxy Home Mini Media playback repeat', - }), - 'context': , - 'entity_id': 'sensor.galaxy_home_mini_media_playback_repeat', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'off', - }) -# --- -# name: test_all_entities[im_speaker_ai_0001][sensor.galaxy_home_mini_media_playback_shuffle-entry] - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.galaxy_home_mini_media_playback_shuffle', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': None, - 'original_icon': None, - 'original_name': 'Media playback shuffle', - 'platform': 'smartthings', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'media_playback_shuffle', - 'unique_id': 'c9276e43-fe3c-88c3-1dcc-2eb79e292b8c_main_mediaPlaybackShuffle_playbackShuffle_playbackShuffle', - 'unit_of_measurement': None, - }) -# --- -# name: test_all_entities[im_speaker_ai_0001][sensor.galaxy_home_mini_media_playback_shuffle-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'Galaxy Home Mini Media playback shuffle', - }), - 'context': , - 'entity_id': 'sensor.galaxy_home_mini_media_playback_shuffle', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'disabled', - }) -# --- -# name: test_all_entities[im_speaker_ai_0001][sensor.galaxy_home_mini_media_playback_status-entry] - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': dict({ - 'options': list([ - 'paused', - 'playing', - 'stopped', - 'fast_forwarding', - 'rewinding', - 'buffering', - ]), - }), - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.galaxy_home_mini_media_playback_status', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': 'Media playback status', - 'platform': 'smartthings', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'media_playback_status', - 'unique_id': 'c9276e43-fe3c-88c3-1dcc-2eb79e292b8c_main_mediaPlayback_playbackStatus_playbackStatus', - 'unit_of_measurement': None, - }) -# --- -# name: test_all_entities[im_speaker_ai_0001][sensor.galaxy_home_mini_media_playback_status-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'enum', - 'friendly_name': 'Galaxy Home Mini Media playback status', - 'options': list([ - 'paused', - 'playing', - 'stopped', - 'fast_forwarding', - 'rewinding', - 'buffering', - ]), - }), - 'context': , - 'entity_id': 'sensor.galaxy_home_mini_media_playback_status', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'stopped', - }) -# --- -# name: test_all_entities[im_speaker_ai_0001][sensor.galaxy_home_mini_volume-entry] - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.galaxy_home_mini_volume', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': None, - 'original_icon': None, - 'original_name': 'Volume', - 'platform': 'smartthings', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'audio_volume', - 'unique_id': 'c9276e43-fe3c-88c3-1dcc-2eb79e292b8c_main_audioVolume_volume_volume', - 'unit_of_measurement': '%', - }) -# --- -# name: test_all_entities[im_speaker_ai_0001][sensor.galaxy_home_mini_volume-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'Galaxy Home Mini Volume', - 'unit_of_measurement': '%', - }), - 'context': , - 'entity_id': 'sensor.galaxy_home_mini_volume', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '52', - }) -# --- # name: test_all_entities[multipurpose_sensor][sensor.deck_door_battery-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -9184,119 +8753,6 @@ 'state': '20', }) # --- -# name: test_all_entities[sonos_player][sensor.elliots_rum_media_playback_status-entry] - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': dict({ - 'options': list([ - 'paused', - 'playing', - 'stopped', - 'fast_forwarding', - 'rewinding', - 'buffering', - ]), - }), - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.elliots_rum_media_playback_status', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': 'Media playback status', - 'platform': 'smartthings', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'media_playback_status', - 'unique_id': 'c85fced9-c474-4a47-93c2-037cc7829536_main_mediaPlayback_playbackStatus_playbackStatus', - 'unit_of_measurement': None, - }) -# --- -# name: test_all_entities[sonos_player][sensor.elliots_rum_media_playback_status-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'enum', - 'friendly_name': 'Elliots Rum Media playback status', - 'options': list([ - 'paused', - 'playing', - 'stopped', - 'fast_forwarding', - 'rewinding', - 'buffering', - ]), - }), - 'context': , - 'entity_id': 'sensor.elliots_rum_media_playback_status', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'playing', - }) -# --- -# name: test_all_entities[sonos_player][sensor.elliots_rum_volume-entry] - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.elliots_rum_volume', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': None, - 'original_icon': None, - 'original_name': 'Volume', - 'platform': 'smartthings', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'audio_volume', - 'unique_id': 'c85fced9-c474-4a47-93c2-037cc7829536_main_audioVolume_volume_volume', - 'unit_of_measurement': '%', - }) -# --- -# name: test_all_entities[sonos_player][sensor.elliots_rum_volume-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'Elliots Rum Volume', - 'unit_of_measurement': '%', - }), - 'context': , - 'entity_id': 'sensor.elliots_rum_volume', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '15', - }) -# --- # name: test_all_entities[tplink_p110][sensor.spulmaschine_energy-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -9407,119 +8863,6 @@ 'state': '0.0', }) # --- -# name: test_all_entities[vd_network_audio_002s][sensor.soundbar_living_media_playback_status-entry] - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': dict({ - 'options': list([ - 'paused', - 'playing', - 'stopped', - 'fast_forwarding', - 'rewinding', - 'buffering', - ]), - }), - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.soundbar_living_media_playback_status', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': 'Media playback status', - 'platform': 'smartthings', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'media_playback_status', - 'unique_id': '0d94e5db-8501-2355-eb4f-214163702cac_main_mediaPlayback_playbackStatus_playbackStatus', - 'unit_of_measurement': None, - }) -# --- -# name: test_all_entities[vd_network_audio_002s][sensor.soundbar_living_media_playback_status-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'enum', - 'friendly_name': 'Soundbar Living Media playback status', - 'options': list([ - 'paused', - 'playing', - 'stopped', - 'fast_forwarding', - 'rewinding', - 'buffering', - ]), - }), - 'context': , - 'entity_id': 'sensor.soundbar_living_media_playback_status', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'stopped', - }) -# --- -# name: test_all_entities[vd_network_audio_002s][sensor.soundbar_living_volume-entry] - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.soundbar_living_volume', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': None, - 'original_icon': None, - 'original_name': 'Volume', - 'platform': 'smartthings', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'audio_volume', - 'unique_id': '0d94e5db-8501-2355-eb4f-214163702cac_main_audioVolume_volume_volume', - 'unit_of_measurement': '%', - }) -# --- -# name: test_all_entities[vd_network_audio_002s][sensor.soundbar_living_volume-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'Soundbar Living Volume', - 'unit_of_measurement': '%', - }), - 'context': , - 'entity_id': 'sensor.soundbar_living_volume', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '17', - }) -# --- # name: test_all_entities[vd_sensor_light_2023][sensor.light_sensor_55_the_frame_brightness_intensity-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -9571,132 +8914,6 @@ 'state': '2', }) # --- -# name: test_all_entities[vd_stv_2017_k][sensor.tv_samsung_8_series_49_media_input_source-entry] - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': dict({ - 'options': list([ - 'digitaltv', - 'hdmi1', - 'hdmi4', - 'hdmi4', - ]), - }), - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.tv_samsung_8_series_49_media_input_source', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': 'Media input source', - 'platform': 'smartthings', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'media_input_source', - 'unique_id': '4588d2d9-a8cf-40f4-9a0b-ed5dfbaccda1_main_mediaInputSource_inputSource_inputSource', - 'unit_of_measurement': None, - }) -# --- -# name: test_all_entities[vd_stv_2017_k][sensor.tv_samsung_8_series_49_media_input_source-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'enum', - 'friendly_name': '[TV] Samsung 8 Series (49) Media input source', - 'options': list([ - 'digitaltv', - 'hdmi1', - 'hdmi4', - 'hdmi4', - ]), - }), - 'context': , - 'entity_id': 'sensor.tv_samsung_8_series_49_media_input_source', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'hdmi1', - }) -# --- -# name: test_all_entities[vd_stv_2017_k][sensor.tv_samsung_8_series_49_media_playback_status-entry] - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': dict({ - 'options': list([ - 'paused', - 'playing', - 'stopped', - 'fast_forwarding', - 'rewinding', - 'buffering', - ]), - }), - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.tv_samsung_8_series_49_media_playback_status', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': 'Media playback status', - 'platform': 'smartthings', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'media_playback_status', - 'unique_id': '4588d2d9-a8cf-40f4-9a0b-ed5dfbaccda1_main_mediaPlayback_playbackStatus_playbackStatus', - 'unit_of_measurement': None, - }) -# --- -# name: test_all_entities[vd_stv_2017_k][sensor.tv_samsung_8_series_49_media_playback_status-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'enum', - 'friendly_name': '[TV] Samsung 8 Series (49) Media playback status', - 'options': list([ - 'paused', - 'playing', - 'stopped', - 'fast_forwarding', - 'rewinding', - 'buffering', - ]), - }), - 'context': , - 'entity_id': 'sensor.tv_samsung_8_series_49_media_playback_status', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', - }) -# --- # name: test_all_entities[vd_stv_2017_k][sensor.tv_samsung_8_series_49_tv_channel-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -9791,54 +9008,6 @@ 'state': '', }) # --- -# name: test_all_entities[vd_stv_2017_k][sensor.tv_samsung_8_series_49_volume-entry] - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.tv_samsung_8_series_49_volume', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': None, - 'original_icon': None, - 'original_name': 'Volume', - 'platform': 'smartthings', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'audio_volume', - 'unique_id': '4588d2d9-a8cf-40f4-9a0b-ed5dfbaccda1_main_audioVolume_volume_volume', - 'unit_of_measurement': '%', - }) -# --- -# name: test_all_entities[vd_stv_2017_k][sensor.tv_samsung_8_series_49_volume-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': '[TV] Samsung 8 Series (49) Volume', - 'unit_of_measurement': '%', - }), - 'context': , - 'entity_id': 'sensor.tv_samsung_8_series_49_volume', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '13', - }) -# --- # name: test_all_entities[virtual_thermostat][sensor.asd_battery-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ diff --git a/tests/components/smartthings/test_binary_sensor.py b/tests/components/smartthings/test_binary_sensor.py index f7fcde3746f..9f9d8d66317 100644 --- a/tests/components/smartthings/test_binary_sensor.py +++ b/tests/components/smartthings/test_binary_sensor.py @@ -60,7 +60,6 @@ async def test_state_update( assert hass.states.get("binary_sensor.refrigerator_cooler_door").state == STATE_ON -@pytest.mark.usefixtures("entity_registry_enabled_by_default") @pytest.mark.parametrize( ("device_fixture", "unique_id", "suggested_object_id", "issue_string", "entity_id"), [ @@ -167,7 +166,6 @@ async def test_create_issue_with_items( assert len(issue_registry.issues) == 0 -@pytest.mark.usefixtures("entity_registry_enabled_by_default") @pytest.mark.parametrize( ("device_fixture", "unique_id", "suggested_object_id", "issue_string", "entity_id"), [ diff --git a/tests/components/smartthings/test_sensor.py b/tests/components/smartthings/test_sensor.py index fe112b3db6b..e90c177bd6d 100644 --- a/tests/components/smartthings/test_sensor.py +++ b/tests/components/smartthings/test_sensor.py @@ -9,8 +9,9 @@ from syrupy import SnapshotAssertion from homeassistant.components import automation, script from homeassistant.components.automation import automations_with_entity from homeassistant.components.script import scripts_with_entity -from homeassistant.components.smartthings.const import DOMAIN -from homeassistant.const import Platform +from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN +from homeassistant.components.smartthings.const import DOMAIN, MAIN +from homeassistant.const import STATE_UNKNOWN, Platform from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er, issue_registry as ir from homeassistant.setup import async_setup_component @@ -56,35 +57,80 @@ async def test_state_update( assert hass.states.get("sensor.ac_office_granit_temperature").state == "20" -@pytest.mark.usefixtures("entity_registry_enabled_by_default") @pytest.mark.parametrize( - ("device_fixture", "entity_id", "translation_key"), + ( + "device_fixture", + "unique_id", + "suggested_object_id", + "issue_string", + "entity_id", + "expected_state", + ), [ - ("hw_q80r_soundbar", "sensor.soundbar_volume", "media_player"), - ("hw_q80r_soundbar", "sensor.soundbar_media_playback_status", "media_player"), - ("hw_q80r_soundbar", "sensor.soundbar_media_input_source", "media_player"), ( - "im_speaker_ai_0001", - "sensor.galaxy_home_mini_media_playback_shuffle", + "vd_stv_2017_k", + f"4588d2d9-a8cf-40f4-9a0b-ed5dfbaccda1_{MAIN}_{Capability.MEDIA_PLAYBACK}_{Attribute.PLAYBACK_STATUS}_{Attribute.PLAYBACK_STATUS}", + "tv_samsung_8_series_49_media_playback_status", "media_player", + "sensor.tv_samsung_8_series_49_media_playback_status", + STATE_UNKNOWN, + ), + ( + "vd_stv_2017_k", + f"4588d2d9-a8cf-40f4-9a0b-ed5dfbaccda1_{MAIN}_{Capability.AUDIO_VOLUME}_{Attribute.VOLUME}_{Attribute.VOLUME}", + "tv_samsung_8_series_49_volume", + "media_player", + "sensor.tv_samsung_8_series_49_volume", + "13", + ), + ( + "vd_stv_2017_k", + f"4588d2d9-a8cf-40f4-9a0b-ed5dfbaccda1_{MAIN}_{Capability.MEDIA_INPUT_SOURCE}_{Attribute.INPUT_SOURCE}_{Attribute.INPUT_SOURCE}", + "tv_samsung_8_series_49_media_input_source", + "media_player", + "sensor.tv_samsung_8_series_49_media_input_source", + "hdmi1", ), ( "im_speaker_ai_0001", - "sensor.galaxy_home_mini_media_playback_repeat", + f"c9276e43-fe3c-88c3-1dcc-2eb79e292b8c_{MAIN}_{Capability.MEDIA_PLAYBACK_REPEAT}_{Attribute.PLAYBACK_REPEAT_MODE}_{Attribute.PLAYBACK_REPEAT_MODE}", + "galaxy_home_mini_media_playback_repeat", "media_player", + "sensor.galaxy_home_mini_media_playback_repeat", + "off", + ), + ( + "im_speaker_ai_0001", + f"c9276e43-fe3c-88c3-1dcc-2eb79e292b8c_{MAIN}_{Capability.MEDIA_PLAYBACK_SHUFFLE}_{Attribute.PLAYBACK_SHUFFLE}_{Attribute.PLAYBACK_SHUFFLE}", + "galaxy_home_mini_media_playback_shuffle", + "media_player", + "sensor.galaxy_home_mini_media_playback_shuffle", + "disabled", ), ], ) -async def test_create_issue( +async def test_create_issue_with_items( hass: HomeAssistant, devices: AsyncMock, mock_config_entry: MockConfigEntry, + entity_registry: er.EntityRegistry, issue_registry: ir.IssueRegistry, + unique_id: str, + suggested_object_id: str, + issue_string: str, entity_id: str, - translation_key: str, + expected_state: str, ) -> None: """Test we create an issue when an automation or script is using a deprecated entity.""" - issue_id = f"deprecated_{translation_key}_{entity_id}" + issue_id = f"deprecated_{issue_string}_{entity_id}" + + entity_entry = entity_registry.async_get_or_create( + SENSOR_DOMAIN, + DOMAIN, + unique_id, + suggested_object_id=suggested_object_id, + original_name=suggested_object_id, + ) assert await async_setup_component( hass, @@ -123,19 +169,128 @@ async def test_create_issue( await setup_integration(hass, mock_config_entry) + assert hass.states.get(entity_id).state == expected_state + assert automations_with_entity(hass, entity_id)[0] == "automation.test" assert scripts_with_entity(hass, entity_id)[0] == "script.test" assert len(issue_registry.issues) == 1 issue = issue_registry.async_get_issue(DOMAIN, issue_id) assert issue is not None - assert issue.translation_key == f"deprecated_{translation_key}" + assert issue.translation_key == f"deprecated_{issue_string}_scripts" assert issue.translation_placeholders == { - "entity": entity_id, + "entity_id": entity_id, + "entity_name": suggested_object_id, "items": "- [test](/config/automation/edit/test)\n- [test](/config/script/edit/test)", } - await hass.config_entries.async_unload(mock_config_entry.entry_id) + entity_registry.async_update_entity( + entity_entry.entity_id, + disabled_by=er.RegistryEntryDisabler.USER, + ) + + await hass.config_entries.async_reload(mock_config_entry.entry_id) + await hass.async_block_till_done() + + # Assert the issue is no longer present + assert not issue_registry.async_get_issue(DOMAIN, issue_id) + assert len(issue_registry.issues) == 0 + + +@pytest.mark.parametrize( + ( + "device_fixture", + "unique_id", + "suggested_object_id", + "issue_string", + "entity_id", + "expected_state", + ), + [ + ( + "vd_stv_2017_k", + f"4588d2d9-a8cf-40f4-9a0b-ed5dfbaccda1_{MAIN}_{Capability.MEDIA_PLAYBACK}_{Attribute.PLAYBACK_STATUS}_{Attribute.PLAYBACK_STATUS}", + "tv_samsung_8_series_49_media_playback_status", + "media_player", + "sensor.tv_samsung_8_series_49_media_playback_status", + STATE_UNKNOWN, + ), + ( + "vd_stv_2017_k", + f"4588d2d9-a8cf-40f4-9a0b-ed5dfbaccda1_{MAIN}_{Capability.AUDIO_VOLUME}_{Attribute.VOLUME}_{Attribute.VOLUME}", + "tv_samsung_8_series_49_volume", + "media_player", + "sensor.tv_samsung_8_series_49_volume", + "13", + ), + ( + "vd_stv_2017_k", + f"4588d2d9-a8cf-40f4-9a0b-ed5dfbaccda1_{MAIN}_{Capability.MEDIA_INPUT_SOURCE}_{Attribute.INPUT_SOURCE}_{Attribute.INPUT_SOURCE}", + "tv_samsung_8_series_49_media_input_source", + "media_player", + "sensor.tv_samsung_8_series_49_media_input_source", + "hdmi1", + ), + ( + "im_speaker_ai_0001", + f"c9276e43-fe3c-88c3-1dcc-2eb79e292b8c_{MAIN}_{Capability.MEDIA_PLAYBACK_REPEAT}_{Attribute.PLAYBACK_REPEAT_MODE}_{Attribute.PLAYBACK_REPEAT_MODE}", + "galaxy_home_mini_media_playback_repeat", + "media_player", + "sensor.galaxy_home_mini_media_playback_repeat", + "off", + ), + ( + "im_speaker_ai_0001", + f"c9276e43-fe3c-88c3-1dcc-2eb79e292b8c_{MAIN}_{Capability.MEDIA_PLAYBACK_SHUFFLE}_{Attribute.PLAYBACK_SHUFFLE}_{Attribute.PLAYBACK_SHUFFLE}", + "galaxy_home_mini_media_playback_shuffle", + "media_player", + "sensor.galaxy_home_mini_media_playback_shuffle", + "disabled", + ), + ], +) +async def test_create_issue( + hass: HomeAssistant, + devices: AsyncMock, + mock_config_entry: MockConfigEntry, + entity_registry: er.EntityRegistry, + issue_registry: ir.IssueRegistry, + unique_id: str, + suggested_object_id: str, + issue_string: str, + entity_id: str, + expected_state: str, +) -> None: + """Test we create an issue when an automation or script is using a deprecated entity.""" + issue_id = f"deprecated_{issue_string}_{entity_id}" + + entity_entry = entity_registry.async_get_or_create( + SENSOR_DOMAIN, + DOMAIN, + unique_id, + suggested_object_id=suggested_object_id, + original_name=suggested_object_id, + ) + + await setup_integration(hass, mock_config_entry) + + assert hass.states.get(entity_id).state == expected_state + + assert len(issue_registry.issues) == 1 + issue = issue_registry.async_get_issue(DOMAIN, issue_id) + assert issue is not None + assert issue.translation_key == f"deprecated_{issue_string}" + assert issue.translation_placeholders == { + "entity_id": entity_id, + "entity_name": suggested_object_id, + } + + entity_registry.async_update_entity( + entity_entry.entity_id, + disabled_by=er.RegistryEntryDisabler.USER, + ) + + await hass.config_entries.async_reload(mock_config_entry.entry_id) await hass.async_block_till_done() # Assert the issue is no longer present From 93162f6b659fbdb94fcdc7709816588e5da56d39 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Wed, 2 Apr 2025 14:04:48 +0200 Subject: [PATCH 201/505] Mark Event and HassJob with @final (#142055) --- homeassistant/core.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/homeassistant/core.py b/homeassistant/core.py index ec251832dba..b33e9496c7c 100644 --- a/homeassistant/core.py +++ b/homeassistant/core.py @@ -38,6 +38,7 @@ from typing import ( TypedDict, TypeVar, cast, + final, overload, ) @@ -324,6 +325,7 @@ class HassJobType(enum.Enum): Executor = 3 +@final # Final to allow direct checking of the type instead of using isinstance class HassJob[**_P, _R_co]: """Represent a job to be run later. @@ -1317,6 +1319,7 @@ class EventOrigin(enum.Enum): return next((idx for idx, origin in enumerate(EventOrigin) if origin is self)) +@final # Final to allow direct checking of the type instead of using isinstance class Event(Generic[_DataT]): """Representation of an event within the bus.""" From dfd86d56ec632b66702893c7a1626ebd08c76dde Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Wed, 2 Apr 2025 14:05:07 +0200 Subject: [PATCH 202/505] Convert test fixtures to async (#142052) --- tests/auth/providers/test_homeassistant.py | 8 ++-- tests/components/api/test_init.py | 6 +-- tests/components/cloud/conftest.py | 4 +- .../device_tracker/test_device_trigger.py | 26 +++++----- tests/components/duckdns/test_init.py | 10 ++-- tests/components/freedns/test_init.py | 24 +++++----- tests/components/frontend/test_storage.py | 4 +- tests/components/geo_location/test_trigger.py | 26 +++++----- .../google_assistant/test_google_assistant.py | 47 ++++++++----------- tests/components/hassio/conftest.py | 12 ++--- tests/components/http/test_cors.py | 7 +-- .../components/meraki/test_device_tracker.py | 35 ++++++-------- tests/components/namecheapdns/test_init.py | 12 ++--- tests/components/no_ip/test_init.py | 24 +++++----- tests/components/onboarding/test_views.py | 6 +-- .../owntracks/test_device_tracker.py | 24 +++++----- tests/components/owntracks/test_init.py | 6 +-- tests/components/person/conftest.py | 4 +- .../components/rss_feed_template/test_init.py | 9 ++-- tests/components/spaceapi/test_init.py | 8 ++-- tests/components/sun/test_trigger.py | 6 +-- tests/components/webhook/test_init.py | 8 ++-- tests/components/zone/test_trigger.py | 26 +++++----- tests/scripts/test_auth.py | 8 ++-- 24 files changed, 157 insertions(+), 193 deletions(-) diff --git a/tests/auth/providers/test_homeassistant.py b/tests/auth/providers/test_homeassistant.py index dd2ce65b480..42a5ba80643 100644 --- a/tests/auth/providers/test_homeassistant.py +++ b/tests/auth/providers/test_homeassistant.py @@ -19,18 +19,18 @@ from homeassistant.setup import async_setup_component @pytest.fixture -def data(hass: HomeAssistant) -> hass_auth.Data: +async def data(hass: HomeAssistant) -> hass_auth.Data: """Create a loaded data class.""" data = hass_auth.Data(hass) - hass.loop.run_until_complete(data.async_load()) + await data.async_load() return data @pytest.fixture -def legacy_data(hass: HomeAssistant) -> hass_auth.Data: +async def legacy_data(hass: HomeAssistant) -> hass_auth.Data: """Create a loaded legacy data class.""" data = hass_auth.Data(hass) - hass.loop.run_until_complete(data.async_load()) + await data.async_load() data.is_legacy = True return data diff --git a/tests/components/api/test_init.py b/tests/components/api/test_init.py index 6363304effc..26a3d7c7a8c 100644 --- a/tests/components/api/test_init.py +++ b/tests/components/api/test_init.py @@ -22,12 +22,12 @@ from tests.typing import ClientSessionGenerator @pytest.fixture -def mock_api_client( +async def mock_api_client( hass: HomeAssistant, hass_client: ClientSessionGenerator ) -> TestClient: """Start the Home Assistant HTTP component and return admin API client.""" - hass.loop.run_until_complete(async_setup_component(hass, "api", {})) - return hass.loop.run_until_complete(hass_client()) + await async_setup_component(hass, "api", {}) + return await hass_client() async def test_api_list_state_entities( diff --git a/tests/components/cloud/conftest.py b/tests/components/cloud/conftest.py index 2d594fd9345..0e118f251de 100644 --- a/tests/components/cloud/conftest.py +++ b/tests/components/cloud/conftest.py @@ -218,9 +218,9 @@ def mock_user_data() -> Generator[MagicMock]: @pytest.fixture -def mock_cloud_fixture(hass: HomeAssistant) -> CloudPreferences: +async def mock_cloud_fixture(hass: HomeAssistant) -> CloudPreferences: """Fixture for cloud component.""" - hass.loop.run_until_complete(mock_cloud(hass)) + await mock_cloud(hass) return mock_cloud_prefs(hass, {}) diff --git a/tests/components/device_tracker/test_device_trigger.py b/tests/components/device_tracker/test_device_trigger.py index ebff89e1a15..860c470fc37 100644 --- a/tests/components/device_tracker/test_device_trigger.py +++ b/tests/components/device_tracker/test_device_trigger.py @@ -33,21 +33,19 @@ HOME_LONGITUDE = -117.237561 @pytest.fixture(autouse=True) -def setup_zone(hass: HomeAssistant) -> None: +async def setup_zone(hass: HomeAssistant) -> None: """Create test zone.""" - hass.loop.run_until_complete( - async_setup_component( - hass, - zone.DOMAIN, - { - "zone": { - "name": "test", - "latitude": HOME_LATITUDE, - "longitude": HOME_LONGITUDE, - "radius": 250, - } - }, - ) + await async_setup_component( + hass, + zone.DOMAIN, + { + "zone": { + "name": "test", + "latitude": HOME_LATITUDE, + "longitude": HOME_LONGITUDE, + "radius": 250, + } + }, ) diff --git a/tests/components/duckdns/test_init.py b/tests/components/duckdns/test_init.py index 313cc91aa18..7806d57e934 100644 --- a/tests/components/duckdns/test_init.py +++ b/tests/components/duckdns/test_init.py @@ -31,16 +31,16 @@ async def async_set_txt(hass: HomeAssistant, txt: str | None) -> None: @pytest.fixture -def setup_duckdns(hass: HomeAssistant, aioclient_mock: AiohttpClientMocker) -> None: +async def setup_duckdns( + hass: HomeAssistant, aioclient_mock: AiohttpClientMocker +) -> None: """Fixture that sets up DuckDNS.""" aioclient_mock.get( duckdns.UPDATE_URL, params={"domains": DOMAIN, "token": TOKEN}, text="OK" ) - hass.loop.run_until_complete( - async_setup_component( - hass, duckdns.DOMAIN, {"duckdns": {"domain": DOMAIN, "access_token": TOKEN}} - ) + await async_setup_component( + hass, duckdns.DOMAIN, {"duckdns": {"domain": DOMAIN, "access_token": TOKEN}} ) diff --git a/tests/components/freedns/test_init.py b/tests/components/freedns/test_init.py index d142fd767e1..eab0a1793ce 100644 --- a/tests/components/freedns/test_init.py +++ b/tests/components/freedns/test_init.py @@ -16,7 +16,9 @@ UPDATE_URL = freedns.UPDATE_URL @pytest.fixture -def setup_freedns(hass: HomeAssistant, aioclient_mock: AiohttpClientMocker) -> None: +async def setup_freedns( + hass: HomeAssistant, aioclient_mock: AiohttpClientMocker +) -> None: """Fixture that sets up FreeDNS.""" params = {} params[ACCESS_TOKEN] = "" @@ -24,17 +26,15 @@ def setup_freedns(hass: HomeAssistant, aioclient_mock: AiohttpClientMocker) -> N UPDATE_URL, params=params, text="Successfully updated 1 domains." ) - hass.loop.run_until_complete( - async_setup_component( - hass, - freedns.DOMAIN, - { - freedns.DOMAIN: { - "access_token": ACCESS_TOKEN, - "scan_interval": UPDATE_INTERVAL, - } - }, - ) + await async_setup_component( + hass, + freedns.DOMAIN, + { + freedns.DOMAIN: { + "access_token": ACCESS_TOKEN, + "scan_interval": UPDATE_INTERVAL, + } + }, ) diff --git a/tests/components/frontend/test_storage.py b/tests/components/frontend/test_storage.py index ce7f7aeb4a1..360ca151551 100644 --- a/tests/components/frontend/test_storage.py +++ b/tests/components/frontend/test_storage.py @@ -13,9 +13,9 @@ from tests.typing import WebSocketGenerator @pytest.fixture(autouse=True) -def setup_frontend(hass: HomeAssistant) -> None: +async def setup_frontend(hass: HomeAssistant) -> None: """Fixture to setup the frontend.""" - hass.loop.run_until_complete(async_setup_component(hass, "frontend", {})) + await async_setup_component(hass, "frontend", {}) async def test_get_user_data_empty( diff --git a/tests/components/geo_location/test_trigger.py b/tests/components/geo_location/test_trigger.py index 7673f357a08..0a9ad8a5b16 100644 --- a/tests/components/geo_location/test_trigger.py +++ b/tests/components/geo_location/test_trigger.py @@ -29,22 +29,20 @@ def calls(hass: HomeAssistant) -> list[ServiceCall]: @pytest.fixture(autouse=True) -def setup_comp(hass: HomeAssistant) -> None: +async def setup_comp(hass: HomeAssistant) -> None: """Initialize components.""" mock_component(hass, "group") - hass.loop.run_until_complete( - async_setup_component( - hass, - zone.DOMAIN, - { - "zone": { - "name": "test", - "latitude": 32.880837, - "longitude": -117.237561, - "radius": 250, - } - }, - ) + await async_setup_component( + hass, + zone.DOMAIN, + { + "zone": { + "name": "test", + "latitude": 32.880837, + "longitude": -117.237561, + "radius": 250, + } + }, ) diff --git a/tests/components/google_assistant/test_google_assistant.py b/tests/components/google_assistant/test_google_assistant.py index 035a8d151c4..26541d33613 100644 --- a/tests/components/google_assistant/test_google_assistant.py +++ b/tests/components/google_assistant/test_google_assistant.py @@ -1,6 +1,5 @@ """The tests for the Google Assistant component.""" -from asyncio import AbstractEventLoop from http import HTTPStatus import json from unittest.mock import patch @@ -38,32 +37,28 @@ def auth_header(hass_access_token: str) -> dict[str, str]: @pytest.fixture -def assistant_client( - event_loop: AbstractEventLoop, +async def assistant_client( hass: core.HomeAssistant, hass_client_no_auth: ClientSessionGenerator, ) -> TestClient: """Create web client for the Google Assistant API.""" - loop = event_loop - loop.run_until_complete( - setup.async_setup_component( - hass, - "google_assistant", - { - "google_assistant": { - "project_id": PROJECT_ID, - "entity_config": { - "light.ceiling_lights": { - "aliases": ["top lights", "ceiling lights"], - "name": "Roof Lights", - } - }, - } - }, - ) + await setup.async_setup_component( + hass, + "google_assistant", + { + "google_assistant": { + "project_id": PROJECT_ID, + "entity_config": { + "light.ceiling_lights": { + "aliases": ["top lights", "ceiling lights"], + "name": "Roof Lights", + } + }, + } + }, ) - return loop.run_until_complete(hass_client_no_auth()) + return await hass_client_no_auth() @pytest.fixture(autouse=True) @@ -87,16 +82,12 @@ async def wanted_platforms_only() -> None: @pytest.fixture -def hass_fixture( - event_loop: AbstractEventLoop, hass: core.HomeAssistant -) -> core.HomeAssistant: +async def hass_fixture(hass: core.HomeAssistant) -> core.HomeAssistant: """Set up a Home Assistant instance for these tests.""" - loop = event_loop - # We need to do this to get access to homeassistant/turn_(on,off) - loop.run_until_complete(setup.async_setup_component(hass, core.DOMAIN, {})) + await setup.async_setup_component(hass, core.DOMAIN, {}) - loop.run_until_complete(setup.async_setup_component(hass, "demo", {})) + await setup.async_setup_component(hass, "demo", {}) return hass diff --git a/tests/components/hassio/conftest.py b/tests/components/hassio/conftest.py index c9fbf1a7c56..0c6e2158f3b 100644 --- a/tests/components/hassio/conftest.py +++ b/tests/components/hassio/conftest.py @@ -46,7 +46,7 @@ def hassio_env(supervisor_is_connected: AsyncMock) -> Generator[None]: @pytest.fixture -def hassio_stubs( +async def hassio_stubs( hassio_env: None, hass: HomeAssistant, hass_client: ClientSessionGenerator, @@ -75,27 +75,27 @@ def hassio_stubs( "homeassistant.components.hassio.issues.SupervisorIssues.setup", ), ): - hass.loop.run_until_complete(async_setup_component(hass, "hassio", {})) + await async_setup_component(hass, "hassio", {}) return hass_api.call_args[0][1] @pytest.fixture -def hassio_client( +async def hassio_client( hassio_stubs: RefreshToken, hass: HomeAssistant, hass_client: ClientSessionGenerator ) -> TestClient: """Return a Hass.io HTTP client.""" - return hass.loop.run_until_complete(hass_client()) + return await hass_client() @pytest.fixture -def hassio_noauth_client( +async def hassio_noauth_client( hassio_stubs: RefreshToken, hass: HomeAssistant, aiohttp_client: ClientSessionGenerator, ) -> TestClient: """Return a Hass.io HTTP client without auth.""" - return hass.loop.run_until_complete(aiohttp_client(hass.http.app)) + return await aiohttp_client(hass.http.app) @pytest.fixture diff --git a/tests/components/http/test_cors.py b/tests/components/http/test_cors.py index b637220ac6d..0581c7bac2a 100644 --- a/tests/components/http/test_cors.py +++ b/tests/components/http/test_cors.py @@ -1,6 +1,5 @@ """Test cors for the HTTP component.""" -from asyncio import AbstractEventLoop from http import HTTPStatus from pathlib import Path from unittest.mock import patch @@ -55,14 +54,12 @@ async def mock_handler(request): @pytest.fixture -def client( - event_loop: AbstractEventLoop, aiohttp_client: ClientSessionGenerator -) -> TestClient: +async def client(aiohttp_client: ClientSessionGenerator) -> TestClient: """Fixture to set up a web.Application.""" app = web.Application() setup_cors(app, [TRUSTED_ORIGIN]) app[KEY_ALLOW_CONFIGURED_CORS](app.router.add_get("/", mock_handler)) - return event_loop.run_until_complete(aiohttp_client(app)) + return await aiohttp_client(app) async def test_cors_requests(client) -> None: diff --git a/tests/components/meraki/test_device_tracker.py b/tests/components/meraki/test_device_tracker.py index 139396a0689..c187ca8ce75 100644 --- a/tests/components/meraki/test_device_tracker.py +++ b/tests/components/meraki/test_device_tracker.py @@ -1,6 +1,5 @@ """The tests the for Meraki device tracker.""" -from asyncio import AbstractEventLoop from http import HTTPStatus import json @@ -22,31 +21,25 @@ from tests.typing import ClientSessionGenerator @pytest.fixture -def meraki_client( - event_loop: AbstractEventLoop, +async def meraki_client( hass: HomeAssistant, hass_client: ClientSessionGenerator, ) -> TestClient: """Meraki mock client.""" - loop = event_loop + assert await async_setup_component( + hass, + device_tracker.DOMAIN, + { + device_tracker.DOMAIN: { + CONF_PLATFORM: "meraki", + CONF_VALIDATOR: "validator", + CONF_SECRET: "secret", + } + }, + ) + await hass.async_block_till_done() - async def setup_and_wait(): - result = await async_setup_component( - hass, - device_tracker.DOMAIN, - { - device_tracker.DOMAIN: { - CONF_PLATFORM: "meraki", - CONF_VALIDATOR: "validator", - CONF_SECRET: "secret", - } - }, - ) - await hass.async_block_till_done() - return result - - assert loop.run_until_complete(setup_and_wait()) - return loop.run_until_complete(hass_client()) + return await hass_client() async def test_invalid_or_missing_data( diff --git a/tests/components/namecheapdns/test_init.py b/tests/components/namecheapdns/test_init.py index 1d5b4ca5949..b7c1fe732c0 100644 --- a/tests/components/namecheapdns/test_init.py +++ b/tests/components/namecheapdns/test_init.py @@ -18,7 +18,7 @@ PASSWORD = "abcdefgh" @pytest.fixture -def setup_namecheapdns( +async def setup_namecheapdns( hass: HomeAssistant, aioclient_mock: AiohttpClientMocker ) -> None: """Fixture that sets up NamecheapDNS.""" @@ -28,12 +28,10 @@ def setup_namecheapdns( text="0", ) - hass.loop.run_until_complete( - async_setup_component( - hass, - namecheapdns.DOMAIN, - {"namecheapdns": {"host": HOST, "domain": DOMAIN, "password": PASSWORD}}, - ) + await async_setup_component( + hass, + namecheapdns.DOMAIN, + {"namecheapdns": {"host": HOST, "domain": DOMAIN, "password": PASSWORD}}, ) diff --git a/tests/components/no_ip/test_init.py b/tests/components/no_ip/test_init.py index e344b984e7d..4e9c5d67c74 100644 --- a/tests/components/no_ip/test_init.py +++ b/tests/components/no_ip/test_init.py @@ -22,22 +22,20 @@ USERNAME = "abc@123.com" @pytest.fixture -def setup_no_ip(hass: HomeAssistant, aioclient_mock: AiohttpClientMocker) -> None: +async def setup_no_ip(hass: HomeAssistant, aioclient_mock: AiohttpClientMocker) -> None: """Fixture that sets up NO-IP.""" aioclient_mock.get(UPDATE_URL, params={"hostname": DOMAIN}, text="good 0.0.0.0") - hass.loop.run_until_complete( - async_setup_component( - hass, - no_ip.DOMAIN, - { - no_ip.DOMAIN: { - "domain": DOMAIN, - "username": USERNAME, - "password": PASSWORD, - } - }, - ) + await async_setup_component( + hass, + no_ip.DOMAIN, + { + no_ip.DOMAIN: { + "domain": DOMAIN, + "username": USERNAME, + "password": PASSWORD, + } + }, ) diff --git a/tests/components/onboarding/test_views.py b/tests/components/onboarding/test_views.py index 509dece7dd0..9c5e93e49fe 100644 --- a/tests/components/onboarding/test_views.py +++ b/tests/components/onboarding/test_views.py @@ -36,11 +36,9 @@ from tests.typing import ClientSessionGenerator @pytest.fixture(autouse=True) -def auth_active(hass: HomeAssistant) -> None: +async def auth_active(hass: HomeAssistant) -> None: """Ensure auth is always active.""" - hass.loop.run_until_complete( - register_auth_provider(hass, {"type": "homeassistant"}) - ) + await register_auth_provider(hass, {"type": "homeassistant"}) @pytest.fixture(name="rpi") diff --git a/tests/components/owntracks/test_device_tracker.py b/tests/components/owntracks/test_device_tracker.py index 93f40d0ae3d..a659244e0a0 100644 --- a/tests/components/owntracks/test_device_tracker.py +++ b/tests/components/owntracks/test_device_tracker.py @@ -291,13 +291,13 @@ BAD_JSON_SUFFIX = "** and it ends here ^^" @pytest.fixture -def setup_comp( +async def setup_comp( hass: HomeAssistant, mock_device_tracker_conf: list[Device], mqtt_mock: MqttMockHAClient, ) -> None: """Initialize components.""" - hass.loop.run_until_complete(async_setup_component(hass, "device_tracker", {})) + await async_setup_component(hass, "device_tracker", {}) hass.states.async_set("zone.inner", "zoning", INNER_ZONE) @@ -320,7 +320,7 @@ async def setup_owntracks( @pytest.fixture -def context(hass: HomeAssistant, setup_comp: None) -> OwnTracksContextFactory: +async def context(hass: HomeAssistant, setup_comp: None) -> OwnTracksContextFactory: """Set up the mocked context.""" orig_context = owntracks.OwnTracksContext context = None @@ -331,16 +331,14 @@ def context(hass: HomeAssistant, setup_comp: None) -> OwnTracksContextFactory: context = orig_context(*args) return context - hass.loop.run_until_complete( - setup_owntracks( - hass, - { - CONF_MAX_GPS_ACCURACY: 200, - CONF_WAYPOINT_IMPORT: True, - CONF_WAYPOINT_WHITELIST: ["jon", "greg"], - }, - store_context, - ) + await setup_owntracks( + hass, + { + CONF_MAX_GPS_ACCURACY: 200, + CONF_WAYPOINT_IMPORT: True, + CONF_WAYPOINT_WHITELIST: ["jon", "greg"], + }, + store_context, ) def get_context(): diff --git a/tests/components/owntracks/test_init.py b/tests/components/owntracks/test_init.py index 5ef0efb0ab9..266a66b2760 100644 --- a/tests/components/owntracks/test_init.py +++ b/tests/components/owntracks/test_init.py @@ -43,7 +43,7 @@ def mock_dev_track(mock_device_tracker_conf: list[Device]) -> None: @pytest.fixture -def mock_client( +async def mock_client( hass: HomeAssistant, hass_client_no_auth: ClientSessionGenerator ) -> TestClient: """Start the Home Assistant HTTP component.""" @@ -54,9 +54,9 @@ def mock_client( MockConfigEntry( domain="owntracks", data={"webhook_id": "owntracks_test", "secret": "abcd"} ).add_to_hass(hass) - hass.loop.run_until_complete(async_setup_component(hass, "owntracks", {})) + await async_setup_component(hass, "owntracks", {}) - return hass.loop.run_until_complete(hass_client_no_auth()) + return await hass_client_no_auth() async def test_handle_valid_message(mock_client) -> None: diff --git a/tests/components/person/conftest.py b/tests/components/person/conftest.py index a6dc95ccc9e..2b1724f0c48 100644 --- a/tests/components/person/conftest.py +++ b/tests/components/person/conftest.py @@ -31,7 +31,7 @@ def storage_collection(hass: HomeAssistant) -> person.PersonStorageCollection: @pytest.fixture -def storage_setup( +async def storage_setup( hass: HomeAssistant, hass_storage: dict[str, Any], hass_admin_user: MockUser ) -> None: """Storage setup.""" @@ -49,4 +49,4 @@ def storage_setup( ] }, } - assert hass.loop.run_until_complete(async_setup_component(hass, DOMAIN, {})) + assert await async_setup_component(hass, DOMAIN, {}) diff --git a/tests/components/rss_feed_template/test_init.py b/tests/components/rss_feed_template/test_init.py index 802fbb2244b..3b708b577af 100644 --- a/tests/components/rss_feed_template/test_init.py +++ b/tests/components/rss_feed_template/test_init.py @@ -1,6 +1,5 @@ """The tests for the rss_feed_api component.""" -from asyncio import AbstractEventLoop from http import HTTPStatus from aiohttp.test_utils import TestClient @@ -14,13 +13,11 @@ from tests.typing import ClientSessionGenerator @pytest.fixture -def mock_http_client( - event_loop: AbstractEventLoop, +async def mock_http_client( hass: HomeAssistant, hass_client: ClientSessionGenerator, ) -> TestClient: """Set up test fixture.""" - loop = event_loop config = { "rss_feed_template": { "testfeed": { @@ -35,8 +32,8 @@ def mock_http_client( } } - loop.run_until_complete(async_setup_component(hass, "rss_feed_template", config)) - return loop.run_until_complete(hass_client()) + await async_setup_component(hass, "rss_feed_template", config) + return await hass_client() async def test_get_nonexistant_feed(mock_http_client) -> None: diff --git a/tests/components/spaceapi/test_init.py b/tests/components/spaceapi/test_init.py index 8c0e897947a..154ddb9253e 100644 --- a/tests/components/spaceapi/test_init.py +++ b/tests/components/spaceapi/test_init.py @@ -94,10 +94,12 @@ SENSOR_OUTPUT = { @pytest.fixture -def mock_client(hass: HomeAssistant, hass_client: ClientSessionGenerator) -> TestClient: +async def mock_client( + hass: HomeAssistant, hass_client: ClientSessionGenerator +) -> TestClient: """Start the Home Assistant HTTP component.""" with patch("homeassistant.components.spaceapi", return_value=True): - hass.loop.run_until_complete(async_setup_component(hass, "spaceapi", CONFIG)) + await async_setup_component(hass, "spaceapi", CONFIG) hass.states.async_set( "test.temp1", @@ -126,7 +128,7 @@ def mock_client(hass: HomeAssistant, hass_client: ClientSessionGenerator) -> Tes "test.hum1", 88, attributes={ATTR_UNIT_OF_MEASUREMENT: PERCENTAGE} ) - return hass.loop.run_until_complete(hass_client()) + return await hass_client() async def test_spaceapi_get(hass: HomeAssistant, mock_client) -> None: diff --git a/tests/components/sun/test_trigger.py b/tests/components/sun/test_trigger.py index a7aeae25ac7..ec848c61338 100644 --- a/tests/components/sun/test_trigger.py +++ b/tests/components/sun/test_trigger.py @@ -27,12 +27,10 @@ def stub_blueprint_populate_autouse(stub_blueprint_populate: None) -> None: @pytest.fixture(autouse=True) -def setup_comp(hass: HomeAssistant) -> None: +async def setup_comp(hass: HomeAssistant) -> None: """Initialize components.""" mock_component(hass, "group") - hass.loop.run_until_complete( - async_setup_component(hass, sun.DOMAIN, {sun.DOMAIN: {}}) - ) + await async_setup_component(hass, sun.DOMAIN, {sun.DOMAIN: {}}) async def test_sunset_trigger( diff --git a/tests/components/webhook/test_init.py b/tests/components/webhook/test_init.py index 15ec1b15ee5..20fe5024962 100644 --- a/tests/components/webhook/test_init.py +++ b/tests/components/webhook/test_init.py @@ -17,10 +17,12 @@ from tests.typing import ClientSessionGenerator, WebSocketGenerator @pytest.fixture -def mock_client(hass: HomeAssistant, hass_client: ClientSessionGenerator) -> TestClient: +async def mock_client( + hass: HomeAssistant, hass_client: ClientSessionGenerator +) -> TestClient: """Create http client for webhooks.""" - hass.loop.run_until_complete(async_setup_component(hass, "webhook", {})) - return hass.loop.run_until_complete(hass_client()) + await async_setup_component(hass, "webhook", {}) + return await hass_client() async def test_unregistering_webhook(hass: HomeAssistant, mock_client) -> None: diff --git a/tests/components/zone/test_trigger.py b/tests/components/zone/test_trigger.py index a28b3c0592a..27276c6905f 100644 --- a/tests/components/zone/test_trigger.py +++ b/tests/components/zone/test_trigger.py @@ -17,22 +17,20 @@ def stub_blueprint_populate_autouse(stub_blueprint_populate: None) -> None: @pytest.fixture(autouse=True) -def setup_comp(hass: HomeAssistant) -> None: +async def setup_comp(hass: HomeAssistant) -> None: """Initialize components.""" mock_component(hass, "group") - hass.loop.run_until_complete( - async_setup_component( - hass, - zone.DOMAIN, - { - "zone": { - "name": "test", - "latitude": 32.880837, - "longitude": -117.237561, - "radius": 250, - } - }, - ) + await async_setup_component( + hass, + zone.DOMAIN, + { + "zone": { + "name": "test", + "latitude": 32.880837, + "longitude": -117.237561, + "radius": 250, + } + }, ) diff --git a/tests/scripts/test_auth.py b/tests/scripts/test_auth.py index e52a2cc6567..e9b6f4f718f 100644 --- a/tests/scripts/test_auth.py +++ b/tests/scripts/test_auth.py @@ -26,12 +26,10 @@ def reset_log_level() -> Generator[None]: @pytest.fixture -def provider(hass: HomeAssistant) -> hass_auth.HassAuthProvider: +async def provider(hass: HomeAssistant) -> hass_auth.HassAuthProvider: """Home Assistant auth provider.""" - provider = hass.loop.run_until_complete( - register_auth_provider(hass, {"type": "homeassistant"}) - ) - hass.loop.run_until_complete(provider.async_initialize()) + provider = await register_auth_provider(hass, {"type": "homeassistant"}) + await provider.async_initialize() return provider From 8200c234dd9a3befa7f827d013dfde689ee2e8a6 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Wed, 2 Apr 2025 14:05:23 +0200 Subject: [PATCH 203/505] Mark logbook.EventAsRow with @final (#142058) --- homeassistant/components/logbook/models.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/logbook/models.py b/homeassistant/components/logbook/models.py index 40b904c1279..f27a470a23d 100644 --- a/homeassistant/components/logbook/models.py +++ b/homeassistant/components/logbook/models.py @@ -4,7 +4,7 @@ from __future__ import annotations from collections.abc import Callable, Mapping from dataclasses import dataclass -from typing import TYPE_CHECKING, Any, Final, NamedTuple, cast +from typing import TYPE_CHECKING, Any, Final, NamedTuple, cast, final from propcache.api import cached_property from sqlalchemy.engine.row import Row @@ -114,6 +114,7 @@ DATA_POS: Final = 11 CONTEXT_POS: Final = 12 +@final # Final to allow direct checking of the type instead of using isinstance class EventAsRow(NamedTuple): """Convert an event to a row. From 6fbee5c2e362702c20b33534b5911b3d26dbd004 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Wed, 2 Apr 2025 14:06:01 +0200 Subject: [PATCH 204/505] Mark ReadOnlyDict with @final (#142059) --- homeassistant/util/read_only_dict.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/homeassistant/util/read_only_dict.py b/homeassistant/util/read_only_dict.py index 02befa78f60..3e4710cf220 100644 --- a/homeassistant/util/read_only_dict.py +++ b/homeassistant/util/read_only_dict.py @@ -1,7 +1,7 @@ """Read only dictionary.""" from copy import deepcopy -from typing import Any +from typing import Any, final def _readonly(*args: Any, **kwargs: Any) -> Any: @@ -9,6 +9,7 @@ def _readonly(*args: Any, **kwargs: Any) -> Any: raise RuntimeError("Cannot modify ReadOnlyDict") +@final # Final to allow direct checking of the type instead of using isinstance class ReadOnlyDict[_KT, _VT](dict[_KT, _VT]): """Read only version of dict that is compatible with dict types.""" From feff5355c870ebf5d7894c0fd745ce07e3678332 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Wed, 2 Apr 2025 15:05:43 +0200 Subject: [PATCH 205/505] Mark Integration with @final (#142057) --- homeassistant/loader.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/homeassistant/loader.py b/homeassistant/loader.py index 20763dc7b30..e904fa4bdaf 100644 --- a/homeassistant/loader.py +++ b/homeassistant/loader.py @@ -18,7 +18,7 @@ import pathlib import sys import time from types import ModuleType -from typing import TYPE_CHECKING, Any, Literal, Protocol, TypedDict, cast +from typing import TYPE_CHECKING, Any, Literal, Protocol, TypedDict, cast, final from awesomeversion import ( AwesomeVersion, @@ -646,6 +646,7 @@ def async_register_preload_platform(hass: HomeAssistant, platform_name: str) -> preload_platforms.append(platform_name) +@final # Final to allow direct checking of the type instead of using isinstance class Integration: """An integration in Home Assistant.""" From f8113ae80b4cd340f2770111eb284973c4e2be2e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ab=C3=ADlio=20Costa?= Date: Wed, 2 Apr 2025 14:07:00 +0100 Subject: [PATCH 206/505] Allow excluding modules from noisy logs check (#142020) * Allow excluding modules from noisy logs check * Cache non-excluded modules; hardcode self module name; optimize call * Address review comments --- homeassistant/util/logging.py | 25 +++++++++++++++++++++---- tests/util/test_logging.py | 35 +++++++++++++++++++++++++++++++++++ 2 files changed, 56 insertions(+), 4 deletions(-) diff --git a/homeassistant/util/logging.py b/homeassistant/util/logging.py index 1e516742bfe..d5dfab7da6c 100644 --- a/homeassistant/util/logging.py +++ b/homeassistant/util/logging.py @@ -29,16 +29,22 @@ class HomeAssistantQueueListener(logging.handlers.QueueListener): LOG_COUNTS_RESET_INTERVAL = 300 MAX_LOGS_COUNT = 200 + EXCLUDED_LOG_COUNT_MODULES = [ + "homeassistant.components.automation", + "homeassistant.components.script", + "homeassistant.setup", + "homeassistant.util.logging", + ] + _last_reset: float _log_counts: dict[str, int] - _warned_modules: set[str] def __init__( self, queue: SimpleQueue[logging.Handler], *handlers: logging.Handler ) -> None: """Initialize the handler.""" super().__init__(queue, *handlers) - self._warned_modules = set() + self._module_log_count_skip_flags: dict[str, bool] = {} self._reset_counters(time.time()) @override @@ -53,7 +59,11 @@ class HomeAssistantQueueListener(logging.handlers.QueueListener): self._reset_counters(record.created) module_name = record.name - if module_name == __name__ or module_name in self._warned_modules: + + if skip_flag := self._module_log_count_skip_flags.get(module_name): + return + + if skip_flag is None and self._update_skip_flags(module_name): return self._log_counts[module_name] += 1 @@ -66,13 +76,20 @@ class HomeAssistantQueueListener(logging.handlers.QueueListener): module_name, module_count, ) - self._warned_modules.add(module_name) + self._module_log_count_skip_flags[module_name] = True def _reset_counters(self, time_sec: float) -> None: _LOGGER.debug("Resetting log counters") self._last_reset = time_sec self._log_counts = defaultdict(int) + def _update_skip_flags(self, module_name: str) -> bool: + excluded = any( + module_name.startswith(prefix) for prefix in self.EXCLUDED_LOG_COUNT_MODULES + ) + self._module_log_count_skip_flags[module_name] = excluded + return excluded + class HomeAssistantQueueHandler(logging.handlers.QueueHandler): """Process the log in another thread.""" diff --git a/tests/util/test_logging.py b/tests/util/test_logging.py index d213a68d7f2..ba473ee0c58 100644 --- a/tests/util/test_logging.py +++ b/tests/util/test_logging.py @@ -160,6 +160,10 @@ async def test_catch_log_exception_catches_and_logs() -> None: @patch("homeassistant.util.logging.HomeAssistantQueueListener.MAX_LOGS_COUNT", 5) +@patch( + "homeassistant.util.logging.HomeAssistantQueueListener.EXCLUDED_LOG_COUNT_MODULES", + ["excluded"], +) @pytest.mark.parametrize( ( "logger1_count", @@ -182,6 +186,7 @@ async def test_noisy_loggers( logging_util.async_activate_log_queue_handler(hass) logger1 = logging.getLogger("noisy1") logger2 = logging.getLogger("noisy2.module") + logger_excluded = logging.getLogger("excluded.module") for _ in range(logger1_count): logger1.info("This is a log") @@ -189,6 +194,9 @@ async def test_noisy_loggers( for _ in range(logger2_count): logger2.info("This is another log") + for _ in range(logging_util.HomeAssistantQueueListener.MAX_LOGS_COUNT + 1): + logger_excluded.info("This log should not trigger a warning") + await empty_log_queue() assert ( @@ -203,6 +211,33 @@ async def test_noisy_loggers( ) == logger2_expected_notices ) + # Ensure that the excluded module did not trigger a warning + assert ( + caplog.text.count("is logging too frequently") + == logger1_expected_notices + logger2_expected_notices + ) + + # close the handler so the queue thread stops + logging.root.handlers[0].close() + + +@patch("homeassistant.util.logging.HomeAssistantQueueListener.MAX_LOGS_COUNT", 1) +async def test_noisy_loggers_ignores_self( + hass: HomeAssistant, caplog: pytest.LogCaptureFixture +) -> None: + """Test that the noisy loggers warning does not trigger a warning for its own module.""" + + logging_util.async_activate_log_queue_handler(hass) + logger1 = logging.getLogger("noisy_module1") + logger2 = logging.getLogger("noisy_module2") + logger3 = logging.getLogger("noisy_module3") + + logger1.info("This is a log") + logger2.info("This is a log") + logger3.info("This is a log") + + await empty_log_queue() + assert caplog.text.count("logging too frequently") == 3 # close the handler so the queue thread stops logging.root.handlers[0].close() From 833a8be2d14a4e3ab01b722966300d1c411f59c9 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Wed, 2 Apr 2025 15:33:17 +0200 Subject: [PATCH 207/505] Improve SmartThings switch deprecation (#142072) --- .../components/smartthings/binary_sensor.py | 11 +- homeassistant/components/smartthings/const.py | 11 +- .../components/smartthings/strings.json | 16 +- .../components/smartthings/switch.py | 143 ++--- .../smartthings/snapshots/test_switch.ambr | 517 ------------------ tests/components/smartthings/test_switch.py | 200 ++++++- 6 files changed, 272 insertions(+), 626 deletions(-) diff --git a/homeassistant/components/smartthings/binary_sensor.py b/homeassistant/components/smartthings/binary_sensor.py index 75a080975ea..0fe0e7fe919 100644 --- a/homeassistant/components/smartthings/binary_sensor.py +++ b/homeassistant/components/smartthings/binary_sensor.py @@ -19,7 +19,7 @@ from homeassistant.helpers import entity_registry as er from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import FullDevice, SmartThingsConfigEntry -from .const import MAIN +from .const import INVALID_SWITCH_CATEGORIES, MAIN from .entity import SmartThingsEntity from .util import deprecate_entity @@ -127,14 +127,7 @@ CAPABILITY_TO_SENSORS: dict[ key=Attribute.SWITCH, device_class=BinarySensorDeviceClass.POWER, is_on_key="on", - category={ - Category.CLOTHING_CARE_MACHINE, - Category.COOKTOP, - Category.DISHWASHER, - Category.DRYER, - Category.MICROWAVE, - Category.WASHER, - }, + category=INVALID_SWITCH_CATEGORIES, ) }, Capability.TAMPER_ALERT: { diff --git a/homeassistant/components/smartthings/const.py b/homeassistant/components/smartthings/const.py index a3ec9a38200..8f27b785688 100644 --- a/homeassistant/components/smartthings/const.py +++ b/homeassistant/components/smartthings/const.py @@ -1,6 +1,6 @@ """Constants used by the SmartThings component and platforms.""" -from pysmartthings import Attribute, Capability +from pysmartthings import Attribute, Capability, Category DOMAIN = "smartthings" @@ -109,3 +109,12 @@ SENSOR_ATTRIBUTES_TO_CAPABILITIES: dict[str, str] = { Attribute.WASHER_MODE: Capability.WASHER_MODE, Attribute.WASHER_JOB_STATE: Capability.WASHER_OPERATING_STATE, } + +INVALID_SWITCH_CATEGORIES = { + Category.CLOTHING_CARE_MACHINE, + Category.COOKTOP, + Category.DRYER, + Category.WASHER, + Category.MICROWAVE, + Category.DISHWASHER, +} diff --git a/homeassistant/components/smartthings/strings.json b/homeassistant/components/smartthings/strings.json index 43c6a22e66a..0a0b17c3b59 100644 --- a/homeassistant/components/smartthings/strings.json +++ b/homeassistant/components/smartthings/strings.json @@ -496,12 +496,20 @@ "description": "The refrigerator door binary sensor {entity_name} (`{entity_id}`) is deprecated and will be removed in the future. The entity is used in the following automations or scripts:\n{items}\n\nSeparate entities for cooler and freezer door are available and should be used going forward. Please use them in the above automations or scripts and disable the entity to fix this issue." }, "deprecated_switch_appliance": { - "title": "Deprecated switch detected in some automations or scripts", - "description": "The switch `{entity}` is deprecated because the actions did not work, so it has been replaced with a binary sensor instead.\n\nThe switch was used in the following automations or scripts:\n{items}\n\nPlease use the new binary sensor in the above automations or scripts to fix this issue." + "title": "Appliance switch deprecated", + "description": "The switch {entity_name} (`{entity_id}`) is deprecated because the actions did not work, so it has been replaced with a binary sensor instead.\n\nPlease update your dashboards, templates accordingly and disable the entity to fix this issue." + }, + "deprecated_switch_appliance_scripts": { + "title": "[%key:component::smartthings::issues::deprecated_switch_appliance::title%]", + "description": "The switch {entity_name} (`{entity_id}`) is deprecated because the actions did not work, so it has been replaced with a binary sensor instead.\n\nThe switch was used in the following automations or scripts:\n{items}\n\nPlease use the new binary sensor in the above automations or scripts and disable the entity to fix this issue." }, "deprecated_switch_media_player": { "title": "[%key:component::smartthings::issues::deprecated_switch_appliance::title%]", - "description": "The switch `{entity}` is deprecated and a media player entity has been added to replace it.\n\nThe switch was used in the following automations or scripts:\n{items}\n\nPlease use the new media player entity in the above automations or scripts to fix this issue." + "description": "The switch {entity_name} (`{entity_id}`) is deprecated and a media player entity has been added to replace it.\n\nPlease use the new media player entity in the above automations or scripts and disable the entity to fix this issue." + }, + "deprecated_switch_media_player_scripts": { + "title": "[%key:component::smartthings::issues::deprecated_switch_appliance::title%]", + "description": "The switch {entity_name} (`{entity_id}`) is deprecated and a media player entity has been added to replace it.\n\nThe switch was used in the following automations or scripts:\n{items}\n\nPlease use the new media player entity in the above automations or scripts and disable the entity to fix this issue." }, "deprecated_media_player": { "title": "Media player sensors deprecated", @@ -509,7 +517,7 @@ }, "deprecated_media_player_scripts": { "title": "Deprecated sensor detected in some automations or scripts", - "description": "The sensor {entity_name} (`{entity_id}`) is deprecated because it has been replaced with a media player entity.\n\nThe sensor was used in the following automations or scripts:\n{items}\n\nPlease use them in the above automations or scripts to use the new media player entity and disable the entity to fix this issue." + "description": "The sensor {entity_name} (`{entity_id}`) is deprecated because it has been replaced with a media player entity.\n\nThe sensor was used in the following automations or scripts:\n{items}\n\nPlease update the above automations or scripts to use the new media player entity and disable the entity to fix this issue." } } } diff --git a/homeassistant/components/smartthings/switch.py b/homeassistant/components/smartthings/switch.py index e5b74de3241..4e62957d3d4 100644 --- a/homeassistant/components/smartthings/switch.py +++ b/homeassistant/components/smartthings/switch.py @@ -5,23 +5,21 @@ from __future__ import annotations from dataclasses import dataclass from typing import Any -from pysmartthings import Attribute, Capability, Category, Command, SmartThings +from pysmartthings import Attribute, Capability, Command, SmartThings -from homeassistant.components.automation import automations_with_entity -from homeassistant.components.script import scripts_with_entity -from homeassistant.components.switch import SwitchEntity, SwitchEntityDescription +from homeassistant.components.switch import ( + DOMAIN as SWITCH_DOMAIN, + SwitchEntity, + SwitchEntityDescription, +) from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from homeassistant.helpers.issue_registry import ( - IssueSeverity, - async_create_issue, - async_delete_issue, -) from . import FullDevice, SmartThingsConfigEntry -from .const import DOMAIN, MAIN +from .const import INVALID_SWITCH_CATEGORIES, MAIN from .entity import SmartThingsEntity +from .util import deprecate_entity CAPABILITIES = ( Capability.SWITCH_LEVEL, @@ -37,6 +35,12 @@ AC_CAPABILITIES = ( Capability.THERMOSTAT_COOLING_SETPOINT, ) +MEDIA_PLAYER_CAPABILITIES = ( + Capability.AUDIO_MUTE, + Capability.AUDIO_VOLUME, + Capability.MEDIA_PLAYBACK, +) + @dataclass(frozen=True, kw_only=True) class SmartThingsSwitchEntityDescription(SwitchEntityDescription): @@ -92,13 +96,6 @@ async def async_setup_entry( """Add switches for a config entry.""" entry_data = entry.runtime_data entities: list[SmartThingsEntity] = [ - SmartThingsSwitch(entry_data.client, device, SWITCH, Capability.SWITCH) - for device in entry_data.devices.values() - if Capability.SWITCH in device.status[MAIN] - and not any(capability in device.status[MAIN] for capability in CAPABILITIES) - and not all(capability in device.status[MAIN] for capability in AC_CAPABILITIES) - ] - entities.extend( SmartThingsCommandSwitch( entry_data.client, device, @@ -108,7 +105,7 @@ async def async_setup_entry( for device in entry_data.devices.values() for capability, description in CAPABILITY_TO_COMMAND_SWITCHES.items() if capability in device.status[MAIN] - ) + ] entities.extend( SmartThingsSwitch( entry_data.client, @@ -129,6 +126,51 @@ async def async_setup_entry( ) ) ) + entity_registry = er.async_get(hass) + for device in entry_data.devices.values(): + if ( + Capability.SWITCH in device.status[MAIN] + and not any( + capability in device.status[MAIN] for capability in CAPABILITIES + ) + and not all( + capability in device.status[MAIN] for capability in AC_CAPABILITIES + ) + ): + media_player = all( + capability in device.status[MAIN] + for capability in MEDIA_PLAYER_CAPABILITIES + ) + appliance = ( + device.device.components[MAIN].manufacturer_category + in INVALID_SWITCH_CATEGORIES + ) + if media_player or appliance: + issue = "media_player" if media_player else "appliance" + if deprecate_entity( + hass, + entity_registry, + SWITCH_DOMAIN, + f"{device.device.device_id}_{MAIN}_{Capability.SWITCH}_{Attribute.SWITCH}_{Attribute.SWITCH}", + f"deprecated_switch_{issue}", + ): + entities.append( + SmartThingsSwitch( + entry_data.client, + device, + SWITCH, + Capability.SWITCH, + ) + ) + continue + entities.append( + SmartThingsSwitch( + entry_data.client, + device, + SWITCH, + Capability.SWITCH, + ) + ) async_add_entities(entities) @@ -136,7 +178,6 @@ class SmartThingsSwitch(SmartThingsEntity, SwitchEntity): """Define a SmartThings switch.""" entity_description: SmartThingsSwitchEntityDescription - created_issue: bool = False def __init__( self, @@ -182,70 +223,6 @@ class SmartThingsSwitch(SmartThingsEntity, SwitchEntity): == "on" ) - async def async_added_to_hass(self) -> None: - """Call when entity is added to hass.""" - await super().async_added_to_hass() - media_player = all( - capability in self.device.status[MAIN] - for capability in ( - Capability.AUDIO_MUTE, - Capability.AUDIO_VOLUME, - Capability.MEDIA_PLAYBACK, - ) - ) - if ( - self.entity_description != SWITCH - and self.device.device.components[MAIN].manufacturer_category - not in { - Category.CLOTHING_CARE_MACHINE, - Category.COOKTOP, - Category.DRYER, - Category.WASHER, - Category.MICROWAVE, - Category.DISHWASHER, - } - ) or (self.entity_description != SWITCH and not media_player): - return - automations = automations_with_entity(self.hass, self.entity_id) - scripts = scripts_with_entity(self.hass, self.entity_id) - if not automations and not scripts: - return - - entity_reg: er.EntityRegistry = er.async_get(self.hass) - items_list = [ - f"- [{item.original_name}](/config/{integration}/edit/{item.unique_id})" - for integration, entities in ( - ("automation", automations), - ("script", scripts), - ) - for entity_id in entities - if (item := entity_reg.async_get(entity_id)) - ] - - identifier = "media_player" if media_player else "appliance" - - self.created_issue = True - async_create_issue( - self.hass, - DOMAIN, - f"deprecated_switch_{self.entity_id}", - breaks_in_ha_version="2025.10.0", - is_fixable=False, - severity=IssueSeverity.WARNING, - translation_key=f"deprecated_switch_{identifier}", - translation_placeholders={ - "entity": self.entity_id, - "items": "\n".join(items_list), - }, - ) - - async def async_will_remove_from_hass(self) -> None: - """Call when entity will be removed from hass.""" - await super().async_will_remove_from_hass() - if not self.created_issue: - return - async_delete_issue(self.hass, DOMAIN, f"deprecated_switch_{self.entity_id}") - class SmartThingsCommandSwitch(SmartThingsSwitch): """Define a SmartThings command switch.""" diff --git a/tests/components/smartthings/snapshots/test_switch.ambr b/tests/components/smartthings/snapshots/test_switch.ambr index 8c95d2f20fc..d14d4d02aa4 100644 --- a/tests/components/smartthings/snapshots/test_switch.ambr +++ b/tests/components/smartthings/snapshots/test_switch.ambr @@ -46,100 +46,6 @@ 'state': 'on', }) # --- -# name: test_all_entities[da_ks_cooktop_31001][switch.induction_hob-entry] - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'switch', - 'entity_category': None, - 'entity_id': 'switch.induction_hob', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': None, - 'original_icon': None, - 'original_name': None, - 'platform': 'smartthings', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': None, - 'unique_id': '808dbd84-f357-47e2-a0cd-3b66fa22d584_main_switch_switch_switch', - 'unit_of_measurement': None, - }) -# --- -# name: test_all_entities[da_ks_cooktop_31001][switch.induction_hob-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'Induction Hob', - }), - 'context': , - 'entity_id': 'switch.induction_hob', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'off', - }) -# --- -# name: test_all_entities[da_ks_microwave_0101x][switch.microwave-entry] - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'switch', - 'entity_category': None, - 'entity_id': 'switch.microwave', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': None, - 'original_icon': None, - 'original_name': None, - 'platform': 'smartthings', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': None, - 'unique_id': '2bad3237-4886-e699-1b90-4a51a3d55c8a_main_switch_switch_switch', - 'unit_of_measurement': None, - }) -# --- -# name: test_all_entities[da_ks_microwave_0101x][switch.microwave-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'Microwave', - }), - 'context': , - 'entity_id': 'switch.microwave', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'off', - }) -# --- # name: test_all_entities[da_ref_normal_000001][switch.refrigerator_ice_maker-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -281,147 +187,6 @@ 'state': 'off', }) # --- -# name: test_all_entities[da_wm_dw_000001][switch.dishwasher-entry] - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'switch', - 'entity_category': None, - 'entity_id': 'switch.dishwasher', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': None, - 'original_icon': None, - 'original_name': None, - 'platform': 'smartthings', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': None, - 'unique_id': 'f36dc7ce-cac0-0667-dc14-a3704eb5e676_main_switch_switch_switch', - 'unit_of_measurement': None, - }) -# --- -# name: test_all_entities[da_wm_dw_000001][switch.dishwasher-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'Dishwasher', - }), - 'context': , - 'entity_id': 'switch.dishwasher', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'off', - }) -# --- -# name: test_all_entities[da_wm_sc_000001][switch.airdresser-entry] - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'switch', - 'entity_category': None, - 'entity_id': 'switch.airdresser', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': None, - 'original_icon': None, - 'original_name': None, - 'platform': 'smartthings', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': None, - 'unique_id': 'b93211bf-9d96-bd21-3b2f-964fcc87f5cc_main_switch_switch_switch', - 'unit_of_measurement': None, - }) -# --- -# name: test_all_entities[da_wm_sc_000001][switch.airdresser-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'AirDresser', - }), - 'context': , - 'entity_id': 'switch.airdresser', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'off', - }) -# --- -# name: test_all_entities[da_wm_wd_000001][switch.dryer-entry] - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'switch', - 'entity_category': None, - 'entity_id': 'switch.dryer', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': None, - 'original_icon': None, - 'original_name': None, - 'platform': 'smartthings', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': None, - 'unique_id': '02f7256e-8353-5bdd-547f-bd5b1647e01b_main_switch_switch_switch', - 'unit_of_measurement': None, - }) -# --- -# name: test_all_entities[da_wm_wd_000001][switch.dryer-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'Dryer', - }), - 'context': , - 'entity_id': 'switch.dryer', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'off', - }) -# --- # name: test_all_entities[da_wm_wd_000001][switch.dryer_wrinkle_prevent-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -469,53 +234,6 @@ 'state': 'off', }) # --- -# name: test_all_entities[da_wm_wd_000001_1][switch.seca_roupa-entry] - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'switch', - 'entity_category': None, - 'entity_id': 'switch.seca_roupa', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': None, - 'original_icon': None, - 'original_name': None, - 'platform': 'smartthings', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': None, - 'unique_id': '3a6c4e05-811d-5041-e956-3d04c424cbcd_main_switch_switch_switch', - 'unit_of_measurement': None, - }) -# --- -# name: test_all_entities[da_wm_wd_000001_1][switch.seca_roupa-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'Seca-Roupa', - }), - 'context': , - 'entity_id': 'switch.seca_roupa', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'off', - }) -# --- # name: test_all_entities[da_wm_wd_000001_1][switch.seca_roupa_wrinkle_prevent-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -563,100 +281,6 @@ 'state': 'off', }) # --- -# name: test_all_entities[da_wm_wm_000001][switch.washer-entry] - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'switch', - 'entity_category': None, - 'entity_id': 'switch.washer', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': None, - 'original_icon': None, - 'original_name': None, - 'platform': 'smartthings', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': None, - 'unique_id': 'f984b91d-f250-9d42-3436-33f09a422a47_main_switch_switch_switch', - 'unit_of_measurement': None, - }) -# --- -# name: test_all_entities[da_wm_wm_000001][switch.washer-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'Washer', - }), - 'context': , - 'entity_id': 'switch.washer', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'off', - }) -# --- -# name: test_all_entities[da_wm_wm_000001_1][switch.washing_machine-entry] - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'switch', - 'entity_category': None, - 'entity_id': 'switch.washing_machine', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': None, - 'original_icon': None, - 'original_name': None, - 'platform': 'smartthings', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': None, - 'unique_id': '63803fae-cbed-f356-a063-2cf148ae3ca7_main_switch_switch_switch', - 'unit_of_measurement': None, - }) -# --- -# name: test_all_entities[da_wm_wm_000001_1][switch.washing_machine-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'Washing Machine', - }), - 'context': , - 'entity_id': 'switch.washing_machine', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'on', - }) -# --- # name: test_all_entities[da_wm_wm_000001_1][switch.washing_machine_bubble_soak-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -751,53 +375,6 @@ 'state': 'off', }) # --- -# name: test_all_entities[hw_q80r_soundbar][switch.soundbar-entry] - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'switch', - 'entity_category': None, - 'entity_id': 'switch.soundbar', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': None, - 'original_icon': None, - 'original_name': None, - 'platform': 'smartthings', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': None, - 'unique_id': 'afcf3b91-0000-1111-2222-ddff2a0a6577_main_switch_switch_switch', - 'unit_of_measurement': None, - }) -# --- -# name: test_all_entities[hw_q80r_soundbar][switch.soundbar-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'Soundbar', - }), - 'context': , - 'entity_id': 'switch.soundbar', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'on', - }) -# --- # name: test_all_entities[sensibo_airconditioner_1][switch.office-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -939,53 +516,6 @@ 'state': 'on', }) # --- -# name: test_all_entities[vd_network_audio_002s][switch.soundbar_living-entry] - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'switch', - 'entity_category': None, - 'entity_id': 'switch.soundbar_living', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': None, - 'original_icon': None, - 'original_name': None, - 'platform': 'smartthings', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': None, - 'unique_id': '0d94e5db-8501-2355-eb4f-214163702cac_main_switch_switch_switch', - 'unit_of_measurement': None, - }) -# --- -# name: test_all_entities[vd_network_audio_002s][switch.soundbar_living-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'Soundbar Living', - }), - 'context': , - 'entity_id': 'switch.soundbar_living', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'on', - }) -# --- # name: test_all_entities[vd_sensor_light_2023][switch.light_sensor_55_the_frame-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -1033,50 +563,3 @@ 'state': 'off', }) # --- -# name: test_all_entities[vd_stv_2017_k][switch.tv_samsung_8_series_49-entry] - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'switch', - 'entity_category': None, - 'entity_id': 'switch.tv_samsung_8_series_49', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': None, - 'original_icon': None, - 'original_name': None, - 'platform': 'smartthings', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': None, - 'unique_id': '4588d2d9-a8cf-40f4-9a0b-ed5dfbaccda1_main_switch_switch_switch', - 'unit_of_measurement': None, - }) -# --- -# name: test_all_entities[vd_stv_2017_k][switch.tv_samsung_8_series_49-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': '[TV] Samsung 8 Series (49)', - }), - 'context': , - 'entity_id': 'switch.tv_samsung_8_series_49', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'on', - }) -# --- diff --git a/tests/components/smartthings/test_switch.py b/tests/components/smartthings/test_switch.py index 2e360ff68e3..a47ecde7e0d 100644 --- a/tests/components/smartthings/test_switch.py +++ b/tests/components/smartthings/test_switch.py @@ -126,25 +126,86 @@ async def test_state_update( assert hass.states.get("switch.2nd_floor_hallway").state == STATE_OFF -@pytest.mark.usefixtures("entity_registry_enabled_by_default") @pytest.mark.parametrize( - ("device_fixture", "entity_id", "translation_key"), + ("device_fixture", "device_id", "suggested_object_id", "issue_string"), [ - ("da_wm_wm_000001", "switch.washer", "deprecated_switch_appliance"), - ("da_wm_wd_000001", "switch.dryer", "deprecated_switch_appliance"), - ("hw_q80r_soundbar", "switch.soundbar", "deprecated_switch_media_player"), + ( + "da_ks_cooktop_31001", + "808dbd84-f357-47e2-a0cd-3b66fa22d584", + "induction_hob", + "appliance", + ), + ( + "da_ks_microwave_0101x", + "2bad3237-4886-e699-1b90-4a51a3d55c8a", + "microwave", + "appliance", + ), + ( + "da_wm_dw_000001", + "f36dc7ce-cac0-0667-dc14-a3704eb5e676", + "dishwasher", + "appliance", + ), + ( + "da_wm_sc_000001", + "b93211bf-9d96-bd21-3b2f-964fcc87f5cc", + "airdresser", + "appliance", + ), + ( + "da_wm_wd_000001", + "02f7256e-8353-5bdd-547f-bd5b1647e01b", + "dryer", + "appliance", + ), + ( + "da_wm_wm_000001", + "f984b91d-f250-9d42-3436-33f09a422a47", + "washer", + "appliance", + ), + ( + "hw_q80r_soundbar", + "afcf3b91-0000-1111-2222-ddff2a0a6577", + "soundbar", + "media_player", + ), + ( + "vd_network_audio_002s", + "0d94e5db-8501-2355-eb4f-214163702cac", + "soundbar_living", + "media_player", + ), + ( + "vd_stv_2017_k", + "4588d2d9-a8cf-40f4-9a0b-ed5dfbaccda1", + "tv_samsung_8_series_49", + "media_player", + ), ], ) -async def test_create_issue( +async def test_create_issue_with_items( hass: HomeAssistant, devices: AsyncMock, mock_config_entry: MockConfigEntry, + entity_registry: er.EntityRegistry, issue_registry: ir.IssueRegistry, - entity_id: str, - translation_key: str, + device_id: str, + suggested_object_id: str, + issue_string: str, ) -> None: """Test we create an issue when an automation or script is using a deprecated entity.""" - issue_id = f"deprecated_switch_{entity_id}" + entity_id = f"switch.{suggested_object_id}" + issue_id = f"deprecated_switch_{issue_string}_{entity_id}" + + entity_entry = entity_registry.async_get_or_create( + SWITCH_DOMAIN, + DOMAIN, + f"{device_id}_{MAIN}_{Capability.SWITCH}_{Attribute.SWITCH}_{Attribute.SWITCH}", + suggested_object_id=suggested_object_id, + original_name=suggested_object_id, + ) assert await async_setup_component( hass, @@ -183,19 +244,134 @@ async def test_create_issue( await setup_integration(hass, mock_config_entry) + assert hass.states.get(entity_id).state in [STATE_OFF, STATE_ON] + assert automations_with_entity(hass, entity_id)[0] == "automation.test" assert scripts_with_entity(hass, entity_id)[0] == "script.test" assert len(issue_registry.issues) == 1 issue = issue_registry.async_get_issue(DOMAIN, issue_id) assert issue is not None - assert issue.translation_key == translation_key + assert issue.translation_key == f"deprecated_switch_{issue_string}_scripts" assert issue.translation_placeholders == { - "entity": entity_id, + "entity_id": entity_id, + "entity_name": suggested_object_id, "items": "- [test](/config/automation/edit/test)\n- [test](/config/script/edit/test)", } - await hass.config_entries.async_unload(mock_config_entry.entry_id) + entity_registry.async_update_entity( + entity_entry.entity_id, + disabled_by=er.RegistryEntryDisabler.USER, + ) + + await hass.config_entries.async_reload(mock_config_entry.entry_id) + await hass.async_block_till_done() + + # Assert the issue is no longer present + assert not issue_registry.async_get_issue(DOMAIN, issue_id) + assert len(issue_registry.issues) == 0 + + +@pytest.mark.parametrize( + ("device_fixture", "device_id", "suggested_object_id", "issue_string"), + [ + ( + "da_ks_cooktop_31001", + "808dbd84-f357-47e2-a0cd-3b66fa22d584", + "induction_hob", + "appliance", + ), + ( + "da_ks_microwave_0101x", + "2bad3237-4886-e699-1b90-4a51a3d55c8a", + "microwave", + "appliance", + ), + ( + "da_wm_dw_000001", + "f36dc7ce-cac0-0667-dc14-a3704eb5e676", + "dishwasher", + "appliance", + ), + ( + "da_wm_sc_000001", + "b93211bf-9d96-bd21-3b2f-964fcc87f5cc", + "airdresser", + "appliance", + ), + ( + "da_wm_wd_000001", + "02f7256e-8353-5bdd-547f-bd5b1647e01b", + "dryer", + "appliance", + ), + ( + "da_wm_wm_000001", + "f984b91d-f250-9d42-3436-33f09a422a47", + "washer", + "appliance", + ), + ( + "hw_q80r_soundbar", + "afcf3b91-0000-1111-2222-ddff2a0a6577", + "soundbar", + "media_player", + ), + ( + "vd_network_audio_002s", + "0d94e5db-8501-2355-eb4f-214163702cac", + "soundbar_living", + "media_player", + ), + ( + "vd_stv_2017_k", + "4588d2d9-a8cf-40f4-9a0b-ed5dfbaccda1", + "tv_samsung_8_series_49", + "media_player", + ), + ], +) +async def test_create_issue( + hass: HomeAssistant, + devices: AsyncMock, + mock_config_entry: MockConfigEntry, + entity_registry: er.EntityRegistry, + issue_registry: ir.IssueRegistry, + device_id: str, + suggested_object_id: str, + issue_string: str, +) -> None: + """Test we create an issue when an automation or script is using a deprecated entity.""" + entity_id = f"switch.{suggested_object_id}" + issue_id = f"deprecated_switch_{issue_string}_{entity_id}" + + entity_entry = entity_registry.async_get_or_create( + SWITCH_DOMAIN, + DOMAIN, + f"{device_id}_{MAIN}_{Capability.SWITCH}_{Attribute.SWITCH}_{Attribute.SWITCH}", + suggested_object_id=suggested_object_id, + original_name=suggested_object_id, + ) + + await setup_integration(hass, mock_config_entry) + + assert hass.states.get(entity_id).state in [STATE_OFF, STATE_ON] + + assert len(issue_registry.issues) == 1 + issue = issue_registry.async_get_issue(DOMAIN, issue_id) + assert issue is not None + assert issue.translation_key == f"deprecated_switch_{issue_string}" + assert issue.translation_placeholders == { + "entity_id": entity_id, + "entity_name": suggested_object_id, + } + + entity_registry.async_update_entity( + entity_entry.entity_id, + disabled_by=er.RegistryEntryDisabler.USER, + ) + + await hass.config_entries.async_reload(mock_config_entry.entry_id) await hass.async_block_till_done() # Assert the issue is no longer present From 4c44d2f4d96db79e8d98479e8926362cb600a090 Mon Sep 17 00:00:00 2001 From: puddly <32534428+puddly@users.noreply.github.com> Date: Wed, 2 Apr 2025 09:33:41 -0400 Subject: [PATCH 208/505] Translation key for ZBT-1 integration failing due to disconnection (#142077) Translation key for device disconnected --- .../components/homeassistant_sky_connect/__init__.py | 5 ++++- .../components/homeassistant_sky_connect/strings.json | 5 +++++ 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/homeassistant_sky_connect/__init__.py b/homeassistant/components/homeassistant_sky_connect/__init__.py index 212826687e1..dfc129ddc75 100644 --- a/homeassistant/components/homeassistant_sky_connect/__init__.py +++ b/homeassistant/components/homeassistant_sky_connect/__init__.py @@ -71,7 +71,10 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: # Postpone loading the config entry if the device is missing device_path = entry.data[DEVICE] if not await hass.async_add_executor_job(os.path.exists, device_path): - raise ConfigEntryNotReady + raise ConfigEntryNotReady( + translation_domain=DOMAIN, + translation_key="device_disconnected", + ) await hass.config_entries.async_forward_entry_setups(entry, ["update"]) diff --git a/homeassistant/components/homeassistant_sky_connect/strings.json b/homeassistant/components/homeassistant_sky_connect/strings.json index a596b9846ce..a990f025e8d 100644 --- a/homeassistant/components/homeassistant_sky_connect/strings.json +++ b/homeassistant/components/homeassistant_sky_connect/strings.json @@ -195,5 +195,10 @@ "run_zigbee_flasher_addon": "[%key:component::homeassistant_hardware::firmware_picker::options::progress::run_zigbee_flasher_addon%]", "uninstall_zigbee_flasher_addon": "[%key:component::homeassistant_hardware::firmware_picker::options::progress::uninstall_zigbee_flasher_addon%]" } + }, + "exceptions": { + "device_disconnected": { + "message": "The device is not plugged in" + } } } From 0871bf13a4b7cb2cf028b96219b1f49f4cf7abdc Mon Sep 17 00:00:00 2001 From: Marcel van der Veldt Date: Wed, 2 Apr 2025 15:39:31 +0200 Subject: [PATCH 209/505] Deprecate None effect instead of breaking it for Hue (#142073) * Deprecate effect none instead of breaking it for Hue * add guard for unknown effect value * revert guard * Fix * Add test * Add test * Add test --------- Co-authored-by: Joostlek --- homeassistant/components/hue/strings.json | 6 ++++ homeassistant/components/hue/v2/light.py | 21 +++++++++++ tests/components/hue/test_light_v2.py | 44 +++++++++++++++++++++-- 3 files changed, 69 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/hue/strings.json b/homeassistant/components/hue/strings.json index 6d2e9054c6f..3326dd1043f 100644 --- a/homeassistant/components/hue/strings.json +++ b/homeassistant/components/hue/strings.json @@ -197,5 +197,11 @@ } } } + }, + "issues": { + "deprecated_effect_none": { + "title": "Light turned on with deprecated effect", + "description": "A light was turned on with the deprecated effect `None`. This has been replaced with `off`. Please update any automations, scenes, or scripts that use this effect." + } } } diff --git a/homeassistant/components/hue/v2/light.py b/homeassistant/components/hue/v2/light.py index 757b69c7b7b..8eb7ec8936e 100644 --- a/homeassistant/components/hue/v2/light.py +++ b/homeassistant/components/hue/v2/light.py @@ -29,6 +29,7 @@ from homeassistant.components.light import ( from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback +from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue from homeassistant.util import color as color_util from ..bridge import HueBridge @@ -44,6 +45,9 @@ FALLBACK_MIN_KELVIN = 6500 FALLBACK_MAX_KELVIN = 2000 FALLBACK_KELVIN = 5800 # halfway +# HA 2025.4 replaced the deprecated effect "None" with HA default "off" +DEPRECATED_EFFECT_NONE = "None" + async def async_setup_entry( hass: HomeAssistant, @@ -233,6 +237,23 @@ class HueLight(HueBaseEntity, LightEntity): self._color_temp_active = color_temp is not None flash = kwargs.get(ATTR_FLASH) effect = effect_str = kwargs.get(ATTR_EFFECT) + if effect_str == DEPRECATED_EFFECT_NONE: + # deprecated effect "None" is now "off" + effect_str = EFFECT_OFF + async_create_issue( + self.hass, + DOMAIN, + "deprecated_effect_none", + breaks_in_ha_version="2025.10.0", + is_fixable=False, + severity=IssueSeverity.WARNING, + translation_key="deprecated_effect_none", + ) + self.logger.warning( + "Detected deprecated effect 'None' in %s, use 'off' instead. " + "This will stop working in HA 2025.10", + self.entity_id, + ) if effect_str == EFFECT_OFF: # ignore effect if set to "off" and we have no effect active # the special effect "off" is only used to stop an active effect diff --git a/tests/components/hue/test_light_v2.py b/tests/components/hue/test_light_v2.py index 3d323d4d31c..f4a6fcfba93 100644 --- a/tests/components/hue/test_light_v2.py +++ b/tests/components/hue/test_light_v2.py @@ -2,9 +2,14 @@ from unittest.mock import Mock -from homeassistant.components.light import ColorMode +from homeassistant.components.light import ( + ATTR_EFFECT, + DOMAIN as LIGHT_DOMAIN, + ColorMode, +) +from homeassistant.const import ATTR_ENTITY_ID, SERVICE_TURN_ON from homeassistant.core import HomeAssistant -from homeassistant.helpers import entity_registry as er +from homeassistant.helpers import entity_registry as er, issue_registry as ir from homeassistant.util.json import JsonArrayType from .conftest import setup_platform @@ -639,3 +644,38 @@ async def test_grouped_lights( mock_bridge_v2.mock_requests[index]["json"]["identify"]["action"] == "identify" ) + + +async def test_light_turn_on_service_deprecation( + hass: HomeAssistant, + mock_bridge_v2: Mock, + v2_resources_test_data: JsonArrayType, + issue_registry: ir.IssueRegistry, +) -> None: + """Test calling the turn on service on a light.""" + await mock_bridge_v2.api.load_test_data(v2_resources_test_data) + + test_light_id = "light.hue_light_with_color_temperature_only" + + await setup_platform(hass, mock_bridge_v2, "light") + + event = { + "id": "3a6710fa-4474-4eba-b533-5e6e72968feb", + "type": "light", + "effects": {"status": "candle"}, + } + mock_bridge_v2.api.emit_event("update", event) + await hass.async_block_till_done() + + # test disable effect + # it should send a request with effect set to "no_effect" + await hass.services.async_call( + LIGHT_DOMAIN, + SERVICE_TURN_ON, + { + ATTR_ENTITY_ID: test_light_id, + ATTR_EFFECT: "None", + }, + blocking=True, + ) + assert mock_bridge_v2.mock_requests[0]["json"]["effects"]["effect"] == "no_effect" From 6b34c38d21978e086356a0d897d4bb7eb8c2b72f Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Wed, 2 Apr 2025 17:00:29 +0200 Subject: [PATCH 210/505] Fix state class for battery sensors in AVM Fritz!SmartHome (#142078) * set proper state class for battery sensor * fix tests --- homeassistant/components/fritzbox/sensor.py | 1 + tests/components/fritzbox/test_binary_sensor.py | 8 ++++++-- tests/components/fritzbox/test_climate.py | 8 ++++++-- tests/components/fritzbox/test_sensor.py | 2 +- 4 files changed, 14 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/fritzbox/sensor.py b/homeassistant/components/fritzbox/sensor.py index bed7004bd6a..801a3a67a6e 100644 --- a/homeassistant/components/fritzbox/sensor.py +++ b/homeassistant/components/fritzbox/sensor.py @@ -137,6 +137,7 @@ SENSOR_TYPES: Final[tuple[FritzSensorEntityDescription, ...]] = ( key="battery", native_unit_of_measurement=PERCENTAGE, device_class=SensorDeviceClass.BATTERY, + state_class=SensorStateClass.MEASUREMENT, entity_category=EntityCategory.DIAGNOSTIC, suitable=lambda device: device.battery_level is not None, native_value=lambda device: device.battery_level, diff --git a/tests/components/fritzbox/test_binary_sensor.py b/tests/components/fritzbox/test_binary_sensor.py index 594ed14a7d1..d5b0b5d196b 100644 --- a/tests/components/fritzbox/test_binary_sensor.py +++ b/tests/components/fritzbox/test_binary_sensor.py @@ -11,7 +11,11 @@ from homeassistant.components.binary_sensor import ( BinarySensorDeviceClass, ) from homeassistant.components.fritzbox.const import DOMAIN as FB_DOMAIN -from homeassistant.components.sensor import ATTR_STATE_CLASS, DOMAIN as SENSOR_DOMAIN +from homeassistant.components.sensor import ( + ATTR_STATE_CLASS, + DOMAIN as SENSOR_DOMAIN, + SensorStateClass, +) from homeassistant.const import ( ATTR_DEVICE_CLASS, ATTR_FRIENDLY_NAME, @@ -71,7 +75,7 @@ async def test_setup(hass: HomeAssistant, fritz: Mock) -> None: assert state.state == "23" assert state.attributes[ATTR_FRIENDLY_NAME] == f"{CONF_FAKE_NAME} Battery" assert state.attributes[ATTR_UNIT_OF_MEASUREMENT] == PERCENTAGE - assert ATTR_STATE_CLASS not in state.attributes + assert state.attributes[ATTR_STATE_CLASS] is SensorStateClass.MEASUREMENT async def test_is_off(hass: HomeAssistant, fritz: Mock) -> None: diff --git a/tests/components/fritzbox/test_climate.py b/tests/components/fritzbox/test_climate.py index 7766d906f68..699a2b8c53e 100644 --- a/tests/components/fritzbox/test_climate.py +++ b/tests/components/fritzbox/test_climate.py @@ -37,7 +37,11 @@ from homeassistant.components.fritzbox.const import ( ATTR_STATE_WINDOW_OPEN, DOMAIN as FB_DOMAIN, ) -from homeassistant.components.sensor import ATTR_STATE_CLASS, DOMAIN as SENSOR_DOMAIN +from homeassistant.components.sensor import ( + ATTR_STATE_CLASS, + DOMAIN as SENSOR_DOMAIN, + SensorStateClass, +) from homeassistant.const import ( ATTR_BATTERY_LEVEL, ATTR_ENTITY_ID, @@ -99,7 +103,7 @@ async def test_setup(hass: HomeAssistant, fritz: Mock) -> None: assert state.state == "23" assert state.attributes[ATTR_FRIENDLY_NAME] == f"{CONF_FAKE_NAME} Battery" assert state.attributes[ATTR_UNIT_OF_MEASUREMENT] == PERCENTAGE - assert ATTR_STATE_CLASS not in state.attributes + assert state.attributes[ATTR_STATE_CLASS] is SensorStateClass.MEASUREMENT state = hass.states.get(f"{SENSOR_DOMAIN}.{CONF_FAKE_NAME}_comfort_temperature") assert state diff --git a/tests/components/fritzbox/test_sensor.py b/tests/components/fritzbox/test_sensor.py index 67b2c3e8ab6..cb136eee993 100644 --- a/tests/components/fritzbox/test_sensor.py +++ b/tests/components/fritzbox/test_sensor.py @@ -71,7 +71,7 @@ async def test_setup( "23", f"{CONF_FAKE_NAME} Battery", PERCENTAGE, - None, + SensorStateClass.MEASUREMENT, EntityCategory.DIAGNOSTIC, ], ) From 2a66c03d73b428096a2dc441d503d2de303aab9b Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Wed, 2 Apr 2025 17:15:36 +0200 Subject: [PATCH 211/505] Fix switch name Unknown in SmartThings (#142081) Fix switch name Unknown --- homeassistant/components/smartthings/strings.json | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/smartthings/strings.json b/homeassistant/components/smartthings/strings.json index 0a0b17c3b59..dfcaa094d1b 100644 --- a/homeassistant/components/smartthings/strings.json +++ b/homeassistant/components/smartthings/strings.json @@ -497,19 +497,19 @@ }, "deprecated_switch_appliance": { "title": "Appliance switch deprecated", - "description": "The switch {entity_name} (`{entity_id}`) is deprecated because the actions did not work, so it has been replaced with a binary sensor instead.\n\nPlease update your dashboards, templates accordingly and disable the entity to fix this issue." + "description": "The switch `{entity_id}` is deprecated because the actions did not work, so it has been replaced with a binary sensor instead.\n\nPlease update your dashboards, templates accordingly and disable the entity to fix this issue." }, "deprecated_switch_appliance_scripts": { "title": "[%key:component::smartthings::issues::deprecated_switch_appliance::title%]", - "description": "The switch {entity_name} (`{entity_id}`) is deprecated because the actions did not work, so it has been replaced with a binary sensor instead.\n\nThe switch was used in the following automations or scripts:\n{items}\n\nPlease use the new binary sensor in the above automations or scripts and disable the entity to fix this issue." + "description": "The switch `{entity_id}` is deprecated because the actions did not work, so it has been replaced with a binary sensor instead.\n\nThe switch was used in the following automations or scripts:\n{items}\n\nPlease use the new binary sensor in the above automations or scripts and disable the entity to fix this issue." }, "deprecated_switch_media_player": { "title": "[%key:component::smartthings::issues::deprecated_switch_appliance::title%]", - "description": "The switch {entity_name} (`{entity_id}`) is deprecated and a media player entity has been added to replace it.\n\nPlease use the new media player entity in the above automations or scripts and disable the entity to fix this issue." + "description": "The switch `{entity_id}` is deprecated and a media player entity has been added to replace it.\n\nPlease use the new media player entity in the above automations or scripts and disable the entity to fix this issue." }, "deprecated_switch_media_player_scripts": { "title": "[%key:component::smartthings::issues::deprecated_switch_appliance::title%]", - "description": "The switch {entity_name} (`{entity_id}`) is deprecated and a media player entity has been added to replace it.\n\nThe switch was used in the following automations or scripts:\n{items}\n\nPlease use the new media player entity in the above automations or scripts and disable the entity to fix this issue." + "description": "The switch `{entity_id}` is deprecated and a media player entity has been added to replace it.\n\nThe switch was used in the following automations or scripts:\n{items}\n\nPlease use the new media player entity in the above automations or scripts and disable the entity to fix this issue." }, "deprecated_media_player": { "title": "Media player sensors deprecated", From 30ea27d4a5a57ae28ea6c0209624bb8dac814970 Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Wed, 2 Apr 2025 17:33:36 +0200 Subject: [PATCH 212/505] Replace "to log into" with "to log in to" in `incomfort` (#142060) * Replace "to log into" with "to log in to" in `incomfort` Also fix one missing sentence-casing of "gateway". * Replace duplicate "data_description" strings with references --- homeassistant/components/incomfort/strings.json | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/incomfort/strings.json b/homeassistant/components/incomfort/strings.json index 31fec77f455..6a07849b01d 100644 --- a/homeassistant/components/incomfort/strings.json +++ b/homeassistant/components/incomfort/strings.json @@ -10,8 +10,8 @@ }, "data_description": { "host": "Hostname or IP-address of the Intergas gateway.", - "username": "The username to log into the gateway. This is `admin` in most cases.", - "password": "The password to log into the gateway, is printed at the bottom of the gateway or is `intergas` for some older devices." + "username": "The username to log in to the gateway. This is `admin` in most cases.", + "password": "The password to log in to the gateway, is printed at the bottom of the gateway or is `intergas` for some older devices." } }, "dhcp_auth": { @@ -22,8 +22,8 @@ "password": "[%key:common::config_flow::data::password%]" }, "data_description": { - "username": "The username to log into the gateway. This is `admin` in most cases.", - "password": "The password to log into the gateway, is printed at the bottom of the Gateway or is `intergas` for some older devices." + "username": "[%key:component::incomfort::config::step::user::data_description::username%]", + "password": "[%key:component::incomfort::config::step::user::data_description::password%]" } }, "dhcp_confirm": { From a7be9e664356e6ee496c3cb6169dcf39f0964dec Mon Sep 17 00:00:00 2001 From: Simone Chemelli Date: Wed, 2 Apr 2025 19:17:51 +0200 Subject: [PATCH 213/505] Fix humidifier platform for Comelit (#141854) * Fix humidifier platform for Comelit * apply review comment --- homeassistant/components/comelit/humidifier.py | 6 +++++- homeassistant/components/comelit/strings.json | 4 +++- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/comelit/humidifier.py b/homeassistant/components/comelit/humidifier.py index ad8f49ed5e2..d7b20f731a9 100644 --- a/homeassistant/components/comelit/humidifier.py +++ b/homeassistant/components/comelit/humidifier.py @@ -162,7 +162,7 @@ class ComelitHumidifierEntity(CoordinatorEntity[ComelitSerialBridge], Humidifier async def async_set_humidity(self, humidity: int) -> None: """Set new target humidity.""" - if self.mode == HumidifierComelitMode.OFF: + if not self._attr_is_on: raise ServiceValidationError( translation_domain=DOMAIN, translation_key="humidity_while_off", @@ -190,9 +190,13 @@ class ComelitHumidifierEntity(CoordinatorEntity[ComelitSerialBridge], Humidifier await self.coordinator.api.set_humidity_status( self._device.index, self._set_command ) + self._attr_is_on = True + self.async_write_ha_state() async def async_turn_off(self, **kwargs: Any) -> None: """Turn off.""" await self.coordinator.api.set_humidity_status( self._device.index, HumidifierComelitCommand.OFF ) + self._attr_is_on = False + self.async_write_ha_state() diff --git a/homeassistant/components/comelit/strings.json b/homeassistant/components/comelit/strings.json index a738c837d1b..d4d0b8f670f 100644 --- a/homeassistant/components/comelit/strings.json +++ b/homeassistant/components/comelit/strings.json @@ -52,7 +52,9 @@ "rest": "Rest", "sabotated": "Sabotated" } - }, + } + }, + "humidifier": { "humidifier": { "name": "Humidifier" }, From 260121720945fdf1fc49dd287f28ead168a25223 Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Wed, 2 Apr 2025 19:50:55 +0200 Subject: [PATCH 214/505] Use common states for battery sensor in `withings` (#142043) Use common states for battery level in `withings` --- homeassistant/components/withings/strings.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/withings/strings.json b/homeassistant/components/withings/strings.json index 775ef5cdaab..746fa244c8e 100644 --- a/homeassistant/components/withings/strings.json +++ b/homeassistant/components/withings/strings.json @@ -313,9 +313,9 @@ "battery": { "name": "[%key:component::sensor::entity_component::battery::name%]", "state": { - "low": "Low", - "medium": "Medium", - "high": "High" + "low": "[%key:common::state::low%]", + "medium": "[%key:common::state::medium%]", + "high": "[%key:common::state::high%]" } } } From 2876e5d0cd597bf6cdcb0078d5ae3f3aeb2557f0 Mon Sep 17 00:00:00 2001 From: tdfountain <174762217+tdfountain@users.noreply.github.com> Date: Wed, 2 Apr 2025 13:29:27 -0700 Subject: [PATCH 215/505] Improve and add missing config flow strings in NUT (#142035) * Improve and add missing config descriptions * Fix string * Conform to Microsoft style guidelines on 'sign in' * Note username and password are optional --------- Co-authored-by: J. Nick Koston --- homeassistant/components/nut/strings.json | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/nut/strings.json b/homeassistant/components/nut/strings.json index a7231b22235..dc232cd28c6 100644 --- a/homeassistant/components/nut/strings.json +++ b/homeassistant/components/nut/strings.json @@ -10,13 +10,16 @@ "password": "[%key:common::config_flow::data::password%]" }, "data_description": { - "host": "The hostname or IP address of your NUT server." + "host": "The IP address or hostname of your NUT server.", + "port": "The network port of your NUT server. The NUT server's default port is '3493'.", + "username": "The username to sign in to your NUT server. The usernmae is optional.", + "password": "The password to sign in to your NUT server. The password is optional." } }, "ups": { - "title": "Choose the UPS to Monitor", + "title": "Choose the NUT server UPS to monitor", "data": { - "alias": "Alias" + "alias": "NUT server UPS name" } }, "reauth_confirm": { From 691cb378a07c32dd424b2d997fef463bcbb9d32b Mon Sep 17 00:00:00 2001 From: currand Date: Wed, 2 Apr 2025 16:29:40 -0400 Subject: [PATCH 216/505] Correctly support humidification and dehumidification in Nexia Thermostats (#139792) * Add set_dehumidify_setpoint service. Refactor set_humidify_setpoint. * Add closest_value function in utils * Refactor target humidity * Update tests for util.py * Refactor target humidity. Update tests. * Remove duplicate constant * Add humidify and dehumidfy sensors * Update sensor names * Remove clamping and commented code * Iplement suggestions from review * Switch order check order * remove closest_value() * Update strings for clarity/grammar * Update strings for grammar/clarity * tweaks --------- Co-authored-by: J. Nick Koston --- homeassistant/components/nexia/climate.py | 54 +++++++++++++++++--- homeassistant/components/nexia/icons.json | 3 ++ homeassistant/components/nexia/sensor.py | 29 +++++++++++ homeassistant/components/nexia/services.yaml | 14 +++++ homeassistant/components/nexia/strings.json | 22 ++++++-- 5 files changed, 111 insertions(+), 11 deletions(-) diff --git a/homeassistant/components/nexia/climate.py b/homeassistant/components/nexia/climate.py index e9de81cca7c..e9637a16ae0 100644 --- a/homeassistant/components/nexia/climate.py +++ b/homeassistant/components/nexia/climate.py @@ -53,13 +53,18 @@ PARALLEL_UPDATES = 1 # keep data in sync with only one connection at a time SERVICE_SET_AIRCLEANER_MODE = "set_aircleaner_mode" SERVICE_SET_HUMIDIFY_SETPOINT = "set_humidify_setpoint" +SERVICE_SET_DEHUMIDIFY_SETPOINT = "set_dehumidify_setpoint" SERVICE_SET_HVAC_RUN_MODE = "set_hvac_run_mode" SET_AIRCLEANER_SCHEMA: VolDictType = { vol.Required(ATTR_AIRCLEANER_MODE): cv.string, } -SET_HUMIDITY_SCHEMA: VolDictType = { +SET_HUMIDIFY_SCHEMA: VolDictType = { + vol.Required(ATTR_HUMIDITY): vol.All(vol.Coerce(int), vol.Range(min=10, max=45)), +} + +SET_DEHUMIDIFY_SCHEMA: VolDictType = { vol.Required(ATTR_HUMIDITY): vol.All(vol.Coerce(int), vol.Range(min=35, max=65)), } @@ -126,9 +131,14 @@ async def async_setup_entry( platform.async_register_entity_service( SERVICE_SET_HUMIDIFY_SETPOINT, - SET_HUMIDITY_SCHEMA, + SET_HUMIDIFY_SCHEMA, f"async_{SERVICE_SET_HUMIDIFY_SETPOINT}", ) + platform.async_register_entity_service( + SERVICE_SET_DEHUMIDIFY_SETPOINT, + SET_DEHUMIDIFY_SCHEMA, + f"async_{SERVICE_SET_DEHUMIDIFY_SETPOINT}", + ) platform.async_register_entity_service( SERVICE_SET_AIRCLEANER_MODE, SET_AIRCLEANER_SCHEMA, @@ -224,20 +234,48 @@ class NexiaZone(NexiaThermostatZoneEntity, ClimateEntity): return self._zone.get_preset() async def async_set_humidity(self, humidity: int) -> None: - """Dehumidify target.""" - if self._thermostat.has_dehumidify_support(): - await self.async_set_dehumidify_setpoint(humidity) + """Set humidity targets. + + HA doesn't support separate humidify and dehumidify targets. + Set the target for the current mode if in [heat, cool] + otherwise set both targets to the clamped values. + """ + zone_current_mode = self._zone.get_current_mode() + if zone_current_mode == OPERATION_MODE_HEAT: + if self._thermostat.has_humidify_support(): + await self.async_set_humidify_setpoint(humidity) + elif zone_current_mode == OPERATION_MODE_COOL: + if self._thermostat.has_dehumidify_support(): + await self.async_set_dehumidify_setpoint(humidity) else: - await self.async_set_humidify_setpoint(humidity) + if self._thermostat.has_humidify_support(): + await self.async_set_humidify_setpoint(humidity) + if self._thermostat.has_dehumidify_support(): + await self.async_set_dehumidify_setpoint(humidity) self._signal_thermostat_update() @property - def target_humidity(self): - """Humidity indoors setpoint.""" + def target_humidity(self) -> float | None: + """Humidity indoors setpoint. + + In systems that support both humidification and dehumidification, + two values for target exist. We must choose one to return. + + :return: The target humidity setpoint. + """ + + # If heat is on, always return humidify value first + if ( + self._has_humidify_support + and self._zone.get_current_mode() == OPERATION_MODE_HEAT + ): + return percent_conv(self._thermostat.get_humidify_setpoint()) + # Fall back to previous behavior of returning dehumidify value then humidify if self._has_dehumidify_support: return percent_conv(self._thermostat.get_dehumidify_setpoint()) if self._has_humidify_support: return percent_conv(self._thermostat.get_humidify_setpoint()) + return None @property diff --git a/homeassistant/components/nexia/icons.json b/homeassistant/components/nexia/icons.json index a2157f5c035..c9434a332df 100644 --- a/homeassistant/components/nexia/icons.json +++ b/homeassistant/components/nexia/icons.json @@ -26,6 +26,9 @@ "set_humidify_setpoint": { "service": "mdi:water-percent" }, + "set_dehumidify_setpoint": { + "service": "mdi:water-percent" + }, "set_hvac_run_mode": { "service": "mdi:hvac" } diff --git a/homeassistant/components/nexia/sensor.py b/homeassistant/components/nexia/sensor.py index 293a9308cb4..648b5dc3eeb 100644 --- a/homeassistant/components/nexia/sensor.py +++ b/homeassistant/components/nexia/sensor.py @@ -114,6 +114,35 @@ async def async_setup_entry( percent_conv, ) ) + # Heating Humidification Setpoint + if thermostat.has_humidify_support(): + entities.append( + NexiaThermostatSensor( + coordinator, + thermostat, + "get_humidify_setpoint", + "get_humidify_setpoint", + SensorDeviceClass.HUMIDITY, + PERCENTAGE, + SensorStateClass.MEASUREMENT, + percent_conv, + ) + ) + + # Cooling Dehumidification Setpoint + if thermostat.has_dehumidify_support(): + entities.append( + NexiaThermostatSensor( + coordinator, + thermostat, + "get_dehumidify_setpoint", + "get_dehumidify_setpoint", + SensorDeviceClass.HUMIDITY, + PERCENTAGE, + SensorStateClass.MEASUREMENT, + percent_conv, + ) + ) # Zone Sensors for zone_id in thermostat.get_zone_ids(): diff --git a/homeassistant/components/nexia/services.yaml b/homeassistant/components/nexia/services.yaml index ede1f311acf..d010676d14a 100644 --- a/homeassistant/components/nexia/services.yaml +++ b/homeassistant/components/nexia/services.yaml @@ -14,6 +14,20 @@ set_aircleaner_mode: - "quick" set_humidify_setpoint: + target: + entity: + integration: nexia + domain: climate + fields: + humidity: + required: true + selector: + number: + min: 10 + max: 45 + unit_of_measurement: "%" + +set_dehumidify_setpoint: target: entity: integration: nexia diff --git a/homeassistant/components/nexia/strings.json b/homeassistant/components/nexia/strings.json index 43da2cf05c7..f6b08d5e8e5 100644 --- a/homeassistant/components/nexia/strings.json +++ b/homeassistant/components/nexia/strings.json @@ -53,6 +53,12 @@ }, "zone_setpoint_status": { "name": "Zone setpoint status" + }, + "get_humidify_setpoint": { + "name": "Heating humidify setpoint" + }, + "get_dehumidify_setpoint": { + "name": "Cooling dehumidify setpoint" } }, "switch": { @@ -76,12 +82,22 @@ } }, "set_humidify_setpoint": { - "name": "Set humidify set point", - "description": "Sets the target humidity.", + "name": "Set humidify setpoint", + "description": "Sets the target humidity for heating.", "fields": { "humidity": { "name": "Humidity", - "description": "The humidification setpoint." + "description": "The setpoint for humidification when heating." + } + } + }, + "set_dehumidify_setpoint": { + "name": "Set dehumidify setpoint", + "description": "Sets the target humidity for cooling.", + "fields": { + "humidity": { + "name": "Humidity", + "description": "The setpoint for dehumidification when cooling." } } }, From 17f6ded7b091643b92ab2d3c3ff690dd5dc3d97e Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Wed, 2 Apr 2025 22:32:24 +0200 Subject: [PATCH 217/505] Use common states for "Low"/"Medium"/"High" in `wyoming` (#142096) --- homeassistant/components/wyoming/strings.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/wyoming/strings.json b/homeassistant/components/wyoming/strings.json index 4480b00d867..2578b0e5278 100644 --- a/homeassistant/components/wyoming/strings.json +++ b/homeassistant/components/wyoming/strings.json @@ -41,9 +41,9 @@ "name": "Noise suppression level", "state": { "off": "[%key:common::state::off%]", - "low": "Low", - "medium": "Medium", - "high": "High", + "low": "[%key:common::state::low%]", + "medium": "[%key:common::state::medium%]", + "high": "[%key:common::state::high%]", "max": "Max" } }, From f8a15c822872421bd189b4312d25a8298ddd24a4 Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Wed, 2 Apr 2025 22:35:52 +0200 Subject: [PATCH 218/505] Use common states for "Low"/"Medium"/"High" in `matter` (#142095) --- homeassistant/components/matter/strings.json | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/matter/strings.json b/homeassistant/components/matter/strings.json index c34666c03bb..81a9a4ba796 100644 --- a/homeassistant/components/matter/strings.json +++ b/homeassistant/components/matter/strings.json @@ -135,9 +135,9 @@ "state_attributes": { "preset_mode": { "state": { - "low": "Low", - "medium": "Medium", - "high": "High", + "low": "[%key:common::state::low%]", + "medium": "[%key:common::state::medium%]", + "high": "[%key:common::state::high%]", "auto": "Auto", "natural_wind": "Natural wind", "sleep_wind": "Sleep wind" @@ -189,9 +189,9 @@ "sensitivity_level": { "name": "Sensitivity", "state": { - "low": "[%key:component::matter::entity::fan::fan::state_attributes::preset_mode::state::low%]", + "low": "[%key:common::state::low%]", "standard": "Standard", - "high": "[%key:component::matter::entity::fan::fan::state_attributes::preset_mode::state::high%]" + "high": "[%key:common::state::high%]" } }, "startup_on_off": { @@ -230,7 +230,7 @@ "name": "Contamination state", "state": { "normal": "Normal", - "low": "[%key:component::matter::entity::fan::fan::state_attributes::preset_mode::state::low%]", + "low": "[%key:common::state::low%]", "warning": "Warning", "critical": "Critical" } From 48cbe226093e50ffc157527494ee3ee65f49f8e9 Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Wed, 2 Apr 2025 22:36:04 +0200 Subject: [PATCH 219/505] =?UTF-8?q?Replace=20"Sign=20into=20=E2=80=A6"=20w?= =?UTF-8?q?ith=20"Sign=20in=20to=20=E2=80=A6"=20in=20`sharkiq`=20(#142087)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fix grammar bug "to sign into" in `sharkiq` --- homeassistant/components/sharkiq/strings.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/sharkiq/strings.json b/homeassistant/components/sharkiq/strings.json index 3c4c98db38f..33826baaf5b 100644 --- a/homeassistant/components/sharkiq/strings.json +++ b/homeassistant/components/sharkiq/strings.json @@ -3,7 +3,7 @@ "flow_title": "Add Shark IQ account", "step": { "user": { - "description": "Sign into your SharkClean account to control your devices.", + "description": "Sign in to your SharkClean account to control your devices.", "data": { "username": "[%key:common::config_flow::data::username%]", "password": "[%key:common::config_flow::data::password%]", From 314f658d922bcc144198ab214ac520e4dba4691b Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Wed, 2 Apr 2025 22:36:31 +0200 Subject: [PATCH 220/505] Fix grammar bug "to sign into" in `hive` (#142086) --- homeassistant/components/hive/strings.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/hive/strings.json b/homeassistant/components/hive/strings.json index 6323a2eecbf..5fa15b68d1a 100644 --- a/homeassistant/components/hive/strings.json +++ b/homeassistant/components/hive/strings.json @@ -34,9 +34,9 @@ } }, "error": { - "invalid_username": "Failed to sign into Hive. Your email address is not recognised.", - "invalid_password": "Failed to sign into Hive. Incorrect password, please try again.", - "invalid_code": "Failed to sign into Hive. Your two-factor authentication code was incorrect.", + "invalid_username": "Failed to sign in to Hive. Your email address is not recognised.", + "invalid_password": "Failed to sign in to Hive. Incorrect password, please try again.", + "invalid_code": "Failed to sign in to Hive. Your two-factor authentication code was incorrect.", "no_internet_available": "An Internet connection is required to connect to Hive.", "unknown": "[%key:common::config_flow::error::unknown%]" }, From 23ade8180a73dc8f4766b031caa975ed07d7fde0 Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Wed, 2 Apr 2025 22:37:09 +0200 Subject: [PATCH 221/505] Replace "to log into" with "to log in to" in `honeywell` (#142063) --- homeassistant/components/honeywell/strings.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/honeywell/strings.json b/homeassistant/components/honeywell/strings.json index ca152b99ccf..67295ec5802 100644 --- a/homeassistant/components/honeywell/strings.json +++ b/homeassistant/components/honeywell/strings.json @@ -2,7 +2,7 @@ "config": { "step": { "user": { - "description": "Please enter the credentials used to log into mytotalconnectcomfort.com.", + "description": "Please enter the credentials used to log in to mytotalconnectcomfort.com.", "data": { "username": "[%key:common::config_flow::data::username%]", "password": "[%key:common::config_flow::data::password%]" From 06edb2e36ba21982a43baab94ade74a7f8260007 Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Wed, 2 Apr 2025 22:37:19 +0200 Subject: [PATCH 222/505] Use common states for selectors in `openai_conversation` (#142056) --- .../components/openai_conversation/strings.json | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/openai_conversation/strings.json b/homeassistant/components/openai_conversation/strings.json index 91c1c475bd6..42baf40d470 100644 --- a/homeassistant/components/openai_conversation/strings.json +++ b/homeassistant/components/openai_conversation/strings.json @@ -46,16 +46,16 @@ "selector": { "reasoning_effort": { "options": { - "low": "Low", - "medium": "Medium", - "high": "High" + "low": "[%key:common::state::low%]", + "medium": "[%key:common::state::medium%]", + "high": "[%key:common::state::high%]" } }, "search_context_size": { "options": { - "low": "Low", - "medium": "Medium", - "high": "High" + "low": "[%key:common::state::low%]", + "medium": "[%key:common::state::medium%]", + "high": "[%key:common::state::high%]" } } }, From 5d0de138f67410b0de6167c4d737f655f757d25e Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Wed, 2 Apr 2025 22:37:27 +0200 Subject: [PATCH 223/505] Use common states for "speed" in `motionblinds_ble` (#142050) --- homeassistant/components/motionblinds_ble/strings.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/motionblinds_ble/strings.json b/homeassistant/components/motionblinds_ble/strings.json index cc7cbbd69e2..4589c2d873b 100644 --- a/homeassistant/components/motionblinds_ble/strings.json +++ b/homeassistant/components/motionblinds_ble/strings.json @@ -62,9 +62,9 @@ "speed": { "name": "Speed", "state": { - "1": "Low", - "2": "Medium", - "3": "High" + "1": "[%key:common::state::low%]", + "2": "[%key:common::state::medium%]", + "3": "[%key:common::state::high%]" } } }, From d13beec3e1bc1c2bd83e6e6a0d95d603641898b1 Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Wed, 2 Apr 2025 22:37:42 +0200 Subject: [PATCH 224/505] Use more common states for "foot_warmer_temp" in `sleepiq` (#142048) --- homeassistant/components/sleepiq/strings.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/sleepiq/strings.json b/homeassistant/components/sleepiq/strings.json index 60f6026304b..634202d6da8 100644 --- a/homeassistant/components/sleepiq/strings.json +++ b/homeassistant/components/sleepiq/strings.json @@ -29,9 +29,9 @@ "foot_warmer_temp": { "state": { "off": "[%key:common::state::off%]", - "low": "Low", - "medium": "Medium", - "high": "High" + "low": "[%key:common::state::low%]", + "medium": "[%key:common::state::medium%]", + "high": "[%key:common::state::high%]" } } } From d56a3ac652e0d6c0dcaf97ed5ac5c5da464e5982 Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Wed, 2 Apr 2025 22:37:56 +0200 Subject: [PATCH 225/505] Use common states for "wi_fi_strength" in `aquacell` (#142047) --- homeassistant/components/aquacell/strings.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/aquacell/strings.json b/homeassistant/components/aquacell/strings.json index 53304d04804..e07adf3c199 100644 --- a/homeassistant/components/aquacell/strings.json +++ b/homeassistant/components/aquacell/strings.json @@ -36,9 +36,9 @@ "wi_fi_strength": { "name": "Wi-Fi strength", "state": { - "low": "Low", - "medium": "Medium", - "high": "High" + "low": "[%key:common::state::low%]", + "medium": "[%key:common::state::medium%]", + "high": "[%key:common::state::high%]" } } } From 519a416837796349062ba786d5df8d6bfca23719 Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Wed, 2 Apr 2025 22:38:14 +0200 Subject: [PATCH 226/505] Use common states for "ptc_level" in `xiaomi_miio` (#142044) --- homeassistant/components/xiaomi_miio/strings.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/xiaomi_miio/strings.json b/homeassistant/components/xiaomi_miio/strings.json index bd3b3499689..7df4dc18283 100644 --- a/homeassistant/components/xiaomi_miio/strings.json +++ b/homeassistant/components/xiaomi_miio/strings.json @@ -88,9 +88,9 @@ }, "ptc_level": { "state": { - "low": "Low", - "medium": "Medium", - "high": "High" + "low": "[%key:common::state::low%]", + "medium": "[%key:common::state::medium%]", + "high": "[%key:common::state::high%]" } } }, From ec96e54f879c38c150816a578e1d893a19ee677d Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Wed, 2 Apr 2025 22:39:35 +0200 Subject: [PATCH 227/505] Avoid unnecessary reload in apple_tv reauth flow (#142079) --- homeassistant/components/apple_tv/config_flow.py | 5 ++++- tests/components/apple_tv/test_config_flow.py | 13 +++++++++---- 2 files changed, 13 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/apple_tv/config_flow.py b/homeassistant/components/apple_tv/config_flow.py index 76c4681a30d..b026da33231 100644 --- a/homeassistant/components/apple_tv/config_flow.py +++ b/homeassistant/components/apple_tv/config_flow.py @@ -20,6 +20,7 @@ import voluptuous as vol from homeassistant.components import zeroconf from homeassistant.config_entries import ( SOURCE_IGNORE, + SOURCE_REAUTH, SOURCE_ZEROCONF, ConfigEntry, ConfigFlow, @@ -381,7 +382,9 @@ class AppleTVConfigFlow(ConfigFlow, domain=DOMAIN): CONF_IDENTIFIERS: list(combined_identifiers), }, ) - if entry.source != SOURCE_IGNORE: + # Don't reload ignored entries or in the middle of reauth, + # e.g. if the user is entering a new PIN + if entry.source != SOURCE_IGNORE and self.source != SOURCE_REAUTH: self.hass.config_entries.async_schedule_reload(entry.entry_id) if not allow_exist: raise DeviceAlreadyConfigured diff --git a/tests/components/apple_tv/test_config_flow.py b/tests/components/apple_tv/test_config_flow.py index a13eb3c605b..c9d698e068b 100644 --- a/tests/components/apple_tv/test_config_flow.py +++ b/tests/components/apple_tv/test_config_flow.py @@ -59,12 +59,12 @@ def use_mocked_zeroconf(mock_async_zeroconf: MagicMock) -> None: @pytest.fixture(autouse=True) -def mock_setup_entry() -> Generator[None]: +def mock_setup_entry() -> Generator[Mock]: """Mock setting up a config entry.""" with patch( "homeassistant.components.apple_tv.async_setup_entry", return_value=True - ): - yield + ) as setup_entry: + yield setup_entry # User Flows @@ -1183,7 +1183,9 @@ async def test_zeroconf_mismatch(hass: HomeAssistant, mock_scan: AsyncMock) -> N @pytest.mark.usefixtures("mrp_device", "pairing") -async def test_reconfigure_update_credentials(hass: HomeAssistant) -> None: +async def test_reconfigure_update_credentials( + hass: HomeAssistant, mock_setup_entry: Mock +) -> None: """Test that reconfigure flow updates config entry.""" config_entry = MockConfigEntry( domain="apple_tv", unique_id="mrpid", data={"identifiers": ["mrpid"]} @@ -1215,6 +1217,9 @@ async def test_reconfigure_update_credentials(hass: HomeAssistant) -> None: "identifiers": ["mrpid"], } + await hass.async_block_till_done() + assert len(mock_setup_entry.mock_calls) == 1 + # Options From e8335b1ed77b7e0b918e3b3be17a61b84ba5ed55 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ab=C3=ADlio=20Costa?= Date: Wed, 2 Apr 2025 21:42:38 +0100 Subject: [PATCH 228/505] Revert "Move setup messages from info to debug level" (#142023) Revert "Move setup messages from info to debug level (#141834)" This reverts commit 663d0691a780c25c9fa93ec4fae16bcc08966609. --- homeassistant/setup.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/setup.py b/homeassistant/setup.py index 7f037482f0d..aeaea1146a1 100644 --- a/homeassistant/setup.py +++ b/homeassistant/setup.py @@ -386,7 +386,7 @@ async def _async_setup_component( }, ) - _LOGGER.debug("Setting up %s", domain) + _LOGGER.info("Setting up %s", domain) with async_start_setup(hass, integration=domain, phase=SetupPhases.SETUP): if hasattr(component, "PLATFORM_SCHEMA"): @@ -782,7 +782,7 @@ def async_start_setup( # platforms, but we only care about the longest time. group_setup_times[phase] = max(group_setup_times[phase], time_taken) if group is None: - _LOGGER.debug( + _LOGGER.info( "Setup of domain %s took %.2f seconds", integration, time_taken ) elif _LOGGER.isEnabledFor(logging.DEBUG): From 02ca1f288998ff97c11bb76ca00f45ecf1f616fb Mon Sep 17 00:00:00 2001 From: tdfountain <174762217+tdfountain@users.noreply.github.com> Date: Wed, 2 Apr 2025 14:08:13 -0700 Subject: [PATCH 229/505] Fix strings username data description in NUT (#142115) Fix strings username data description --- homeassistant/components/nut/strings.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/nut/strings.json b/homeassistant/components/nut/strings.json index dc232cd28c6..bda377b9bae 100644 --- a/homeassistant/components/nut/strings.json +++ b/homeassistant/components/nut/strings.json @@ -12,7 +12,7 @@ "data_description": { "host": "The IP address or hostname of your NUT server.", "port": "The network port of your NUT server. The NUT server's default port is '3493'.", - "username": "The username to sign in to your NUT server. The usernmae is optional.", + "username": "The username to sign in to your NUT server. The username is optional.", "password": "The password to sign in to your NUT server. The password is optional." } }, From 33d895bc7d4b3116af83c42750c060929b2d0509 Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Thu, 3 Apr 2025 00:14:07 +0200 Subject: [PATCH 230/505] Use snapshot_platform in all platform test modules for AVM Fritz!SmartHome (#142093) use snapshot_platform in all platform test modules --- tests/components/fritzbox/__init__.py | 6 +- .../snapshots/test_binary_sensor.ambr | 145 ++++ .../fritzbox/snapshots/test_button.ambr | 48 ++ .../fritzbox/snapshots/test_climate.ambr | 80 ++ .../fritzbox/snapshots/test_cover.ambr | 51 ++ .../fritzbox/snapshots/test_light.ambr | 278 ++++++ .../fritzbox/snapshots/test_sensor.ambr | 810 ++++++++++++++++++ .../fritzbox/snapshots/test_switch.ambr | 48 ++ .../components/fritzbox/test_binary_sensor.py | 84 +- tests/components/fritzbox/test_button.py | 42 +- tests/components/fritzbox/test_climate.py | 172 +--- tests/components/fritzbox/test_cover.py | 48 +- tests/components/fritzbox/test_light.py | 116 ++- tests/components/fritzbox/test_sensor.py | 99 +-- tests/components/fritzbox/test_switch.py | 113 +-- 15 files changed, 1683 insertions(+), 457 deletions(-) create mode 100644 tests/components/fritzbox/snapshots/test_binary_sensor.ambr create mode 100644 tests/components/fritzbox/snapshots/test_button.ambr create mode 100644 tests/components/fritzbox/snapshots/test_climate.ambr create mode 100644 tests/components/fritzbox/snapshots/test_cover.ambr create mode 100644 tests/components/fritzbox/snapshots/test_light.ambr create mode 100644 tests/components/fritzbox/snapshots/test_sensor.ambr create mode 100644 tests/components/fritzbox/snapshots/test_switch.ambr diff --git a/tests/components/fritzbox/__init__.py b/tests/components/fritzbox/__init__.py index 034b86497db..1f310e1d3cb 100644 --- a/tests/components/fritzbox/__init__.py +++ b/tests/components/fritzbox/__init__.py @@ -25,7 +25,7 @@ async def setup_config_entry( device: Mock | None = None, fritz: Mock | None = None, template: Mock | None = None, -) -> bool: +) -> MockConfigEntry: """Do setup of a MockConfigEntry.""" entry = MockConfigEntry( domain=DOMAIN, @@ -39,10 +39,10 @@ async def setup_config_entry( if template is not None and fritz is not None: fritz().get_templates.return_value = [template] - result = await hass.config_entries.async_setup(entry.entry_id) + await hass.config_entries.async_setup(entry.entry_id) if device is not None: await hass.async_block_till_done() - return result + return entry def set_devices( diff --git a/tests/components/fritzbox/snapshots/test_binary_sensor.ambr b/tests/components/fritzbox/snapshots/test_binary_sensor.ambr new file mode 100644 index 00000000000..5b3e00dfa93 --- /dev/null +++ b/tests/components/fritzbox/snapshots/test_binary_sensor.ambr @@ -0,0 +1,145 @@ +# serializer version: 1 +# name: test_setup[binary_sensor.fake_name_alarm-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.fake_name_alarm', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Alarm', + 'platform': 'fritzbox', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'alarm', + 'unique_id': '12345 1234567_alarm', + 'unit_of_measurement': None, + }) +# --- +# name: test_setup[binary_sensor.fake_name_alarm-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'window', + 'friendly_name': 'fake_name Alarm', + }), + 'context': , + 'entity_id': 'binary_sensor.fake_name_alarm', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_setup[binary_sensor.fake_name_button_lock_on_device-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': , + 'entity_id': 'binary_sensor.fake_name_button_lock_on_device', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Button lock on device', + 'platform': 'fritzbox', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'lock', + 'unique_id': '12345 1234567_lock', + 'unit_of_measurement': None, + }) +# --- +# name: test_setup[binary_sensor.fake_name_button_lock_on_device-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'lock', + 'friendly_name': 'fake_name Button lock on device', + }), + 'context': , + 'entity_id': 'binary_sensor.fake_name_button_lock_on_device', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_setup[binary_sensor.fake_name_button_lock_via_ui-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': , + 'entity_id': 'binary_sensor.fake_name_button_lock_via_ui', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Button lock via UI', + 'platform': 'fritzbox', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'device_lock', + 'unique_id': '12345 1234567_device_lock', + 'unit_of_measurement': None, + }) +# --- +# name: test_setup[binary_sensor.fake_name_button_lock_via_ui-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'lock', + 'friendly_name': 'fake_name Button lock via UI', + }), + 'context': , + 'entity_id': 'binary_sensor.fake_name_button_lock_via_ui', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- diff --git a/tests/components/fritzbox/snapshots/test_button.ambr b/tests/components/fritzbox/snapshots/test_button.ambr new file mode 100644 index 00000000000..95e757da3cc --- /dev/null +++ b/tests/components/fritzbox/snapshots/test_button.ambr @@ -0,0 +1,48 @@ +# serializer version: 1 +# name: test_setup[button.fake_name-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'button', + 'entity_category': None, + 'entity_id': 'button.fake_name', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'fake_name', + 'platform': 'fritzbox', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '12345 1234567', + 'unit_of_measurement': None, + }) +# --- +# name: test_setup[button.fake_name-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'fake_name', + }), + 'context': , + 'entity_id': 'button.fake_name', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- diff --git a/tests/components/fritzbox/snapshots/test_climate.ambr b/tests/components/fritzbox/snapshots/test_climate.ambr new file mode 100644 index 00000000000..26e06105152 --- /dev/null +++ b/tests/components/fritzbox/snapshots/test_climate.ambr @@ -0,0 +1,80 @@ +# serializer version: 1 +# name: test_setup[climate.fake_name-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'hvac_modes': list([ + , + , + ]), + 'max_temp': 28.0, + 'min_temp': 8.0, + 'preset_modes': list([ + 'eco', + 'comfort', + 'boost', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'climate', + 'entity_category': None, + 'entity_id': 'climate.fake_name', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'fake_name', + 'platform': 'fritzbox', + 'previous_unique_id': None, + 'supported_features': , + 'translation_key': 'thermostat', + 'unique_id': '12345 1234567', + 'unit_of_measurement': None, + }) +# --- +# name: test_setup[climate.fake_name-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'battery_level': 23, + 'battery_low': True, + 'current_temperature': 18.0, + 'friendly_name': 'fake_name', + 'holiday_mode': False, + 'hvac_modes': list([ + , + , + ]), + 'max_temp': 28.0, + 'min_temp': 8.0, + 'preset_mode': None, + 'preset_modes': list([ + 'eco', + 'comfort', + 'boost', + ]), + 'summer_mode': False, + 'supported_features': , + 'temperature': 19.5, + 'window_open': 'fake_window', + }), + 'context': , + 'entity_id': 'climate.fake_name', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'heat', + }) +# --- diff --git a/tests/components/fritzbox/snapshots/test_cover.ambr b/tests/components/fritzbox/snapshots/test_cover.ambr new file mode 100644 index 00000000000..ce6b305e154 --- /dev/null +++ b/tests/components/fritzbox/snapshots/test_cover.ambr @@ -0,0 +1,51 @@ +# serializer version: 1 +# name: test_setup[cover.fake_name-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'cover', + 'entity_category': None, + 'entity_id': 'cover.fake_name', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'fake_name', + 'platform': 'fritzbox', + 'previous_unique_id': None, + 'supported_features': , + 'translation_key': None, + 'unique_id': '12345 1234567', + 'unit_of_measurement': None, + }) +# --- +# name: test_setup[cover.fake_name-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'current_position': 100, + 'device_class': 'blind', + 'friendly_name': 'fake_name', + 'supported_features': , + }), + 'context': , + 'entity_id': 'cover.fake_name', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'open', + }) +# --- diff --git a/tests/components/fritzbox/snapshots/test_light.ambr b/tests/components/fritzbox/snapshots/test_light.ambr new file mode 100644 index 00000000000..f6f4516bdec --- /dev/null +++ b/tests/components/fritzbox/snapshots/test_light.ambr @@ -0,0 +1,278 @@ +# serializer version: 1 +# name: test_setup[light.fake_name-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max_color_temp_kelvin': 6500, + 'max_mireds': 370, + 'min_color_temp_kelvin': 2700, + 'min_mireds': 153, + 'supported_color_modes': list([ + , + , + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'light', + 'entity_category': None, + 'entity_id': 'light.fake_name', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'fake_name', + 'platform': 'fritzbox', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '12345 1234567', + 'unit_of_measurement': None, + }) +# --- +# name: test_setup[light.fake_name-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'brightness': 100, + 'color_mode': , + 'color_temp': 370, + 'color_temp_kelvin': 2700, + 'friendly_name': 'fake_name', + 'hs_color': tuple( + 28.395, + 65.723, + ), + 'max_color_temp_kelvin': 6500, + 'max_mireds': 370, + 'min_color_temp_kelvin': 2700, + 'min_mireds': 153, + 'rgb_color': tuple( + 255, + 167, + 87, + ), + 'supported_color_modes': list([ + , + , + ]), + 'supported_features': , + 'xy_color': tuple( + 0.525, + 0.388, + ), + }), + 'context': , + 'entity_id': 'light.fake_name', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_setup_color[light.fake_name-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max_color_temp_kelvin': 6500, + 'max_mireds': 370, + 'min_color_temp_kelvin': 2700, + 'min_mireds': 153, + 'supported_color_modes': list([ + , + , + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'light', + 'entity_category': None, + 'entity_id': 'light.fake_name', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'fake_name', + 'platform': 'fritzbox', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '12345 1234567', + 'unit_of_measurement': None, + }) +# --- +# name: test_setup_color[light.fake_name-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'brightness': 100, + 'color_mode': , + 'color_temp': None, + 'color_temp_kelvin': None, + 'friendly_name': 'fake_name', + 'hs_color': tuple( + 100, + 70.0, + ), + 'max_color_temp_kelvin': 6500, + 'max_mireds': 370, + 'min_color_temp_kelvin': 2700, + 'min_mireds': 153, + 'rgb_color': tuple( + 136, + 255, + 77, + ), + 'supported_color_modes': list([ + , + , + ]), + 'supported_features': , + 'xy_color': tuple( + 0.271, + 0.609, + ), + }), + 'context': , + 'entity_id': 'light.fake_name', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_setup_non_color[light.fake_name-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'supported_color_modes': list([ + , + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'light', + 'entity_category': None, + 'entity_id': 'light.fake_name', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'fake_name', + 'platform': 'fritzbox', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '12345 1234567', + 'unit_of_measurement': None, + }) +# --- +# name: test_setup_non_color[light.fake_name-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'brightness': 100, + 'color_mode': , + 'friendly_name': 'fake_name', + 'supported_color_modes': list([ + , + ]), + 'supported_features': , + }), + 'context': , + 'entity_id': 'light.fake_name', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_setup_non_color_non_level[light.fake_name-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'supported_color_modes': list([ + , + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'light', + 'entity_category': None, + 'entity_id': 'light.fake_name', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'fake_name', + 'platform': 'fritzbox', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '12345 1234567', + 'unit_of_measurement': None, + }) +# --- +# name: test_setup_non_color_non_level[light.fake_name-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'color_mode': , + 'friendly_name': 'fake_name', + 'supported_color_modes': list([ + , + ]), + 'supported_features': , + }), + 'context': , + 'entity_id': 'light.fake_name', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- diff --git a/tests/components/fritzbox/snapshots/test_sensor.ambr b/tests/components/fritzbox/snapshots/test_sensor.ambr new file mode 100644 index 00000000000..68f8e161d07 --- /dev/null +++ b/tests/components/fritzbox/snapshots/test_sensor.ambr @@ -0,0 +1,810 @@ +# serializer version: 1 +# name: test_setup[FritzDeviceBinarySensorMock][sensor.fake_name_battery-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.fake_name_battery', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Battery', + 'platform': 'fritzbox', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '12345 1234567_battery', + 'unit_of_measurement': '%', + }) +# --- +# name: test_setup[FritzDeviceBinarySensorMock][sensor.fake_name_battery-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'battery', + 'friendly_name': 'fake_name Battery', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.fake_name_battery', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '23', + }) +# --- +# name: test_setup[FritzDeviceClimateMock][sensor.fake_name_battery-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.fake_name_battery', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Battery', + 'platform': 'fritzbox', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '12345 1234567_battery', + 'unit_of_measurement': '%', + }) +# --- +# name: test_setup[FritzDeviceClimateMock][sensor.fake_name_battery-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'battery', + 'friendly_name': 'fake_name Battery', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.fake_name_battery', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '23', + }) +# --- +# name: test_setup[FritzDeviceClimateMock][sensor.fake_name_comfort_temperature-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.fake_name_comfort_temperature', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Comfort temperature', + 'platform': 'fritzbox', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'comfort_temperature', + 'unique_id': '12345 1234567_comfort_temperature', + 'unit_of_measurement': , + }) +# --- +# name: test_setup[FritzDeviceClimateMock][sensor.fake_name_comfort_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'fake_name Comfort temperature', + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.fake_name_comfort_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '22.0', + }) +# --- +# name: test_setup[FritzDeviceClimateMock][sensor.fake_name_current_scheduled_preset-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.fake_name_current_scheduled_preset', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Current scheduled preset', + 'platform': 'fritzbox', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'scheduled_preset', + 'unique_id': '12345 1234567_scheduled_preset', + 'unit_of_measurement': None, + }) +# --- +# name: test_setup[FritzDeviceClimateMock][sensor.fake_name_current_scheduled_preset-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'fake_name Current scheduled preset', + }), + 'context': , + 'entity_id': 'sensor.fake_name_current_scheduled_preset', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'eco', + }) +# --- +# name: test_setup[FritzDeviceClimateMock][sensor.fake_name_eco_temperature-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.fake_name_eco_temperature', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Eco temperature', + 'platform': 'fritzbox', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'eco_temperature', + 'unique_id': '12345 1234567_eco_temperature', + 'unit_of_measurement': , + }) +# --- +# name: test_setup[FritzDeviceClimateMock][sensor.fake_name_eco_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'fake_name Eco temperature', + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.fake_name_eco_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '16.0', + }) +# --- +# name: test_setup[FritzDeviceClimateMock][sensor.fake_name_next_scheduled_change_time-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.fake_name_next_scheduled_change_time', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Next scheduled change time', + 'platform': 'fritzbox', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'nextchange_time', + 'unique_id': '12345 1234567_nextchange_time', + 'unit_of_measurement': None, + }) +# --- +# name: test_setup[FritzDeviceClimateMock][sensor.fake_name_next_scheduled_change_time-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'timestamp', + 'friendly_name': 'fake_name Next scheduled change time', + }), + 'context': , + 'entity_id': 'sensor.fake_name_next_scheduled_change_time', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '2024-09-20T18:00:00+00:00', + }) +# --- +# name: test_setup[FritzDeviceClimateMock][sensor.fake_name_next_scheduled_preset-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.fake_name_next_scheduled_preset', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Next scheduled preset', + 'platform': 'fritzbox', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'nextchange_preset', + 'unique_id': '12345 1234567_nextchange_preset', + 'unit_of_measurement': None, + }) +# --- +# name: test_setup[FritzDeviceClimateMock][sensor.fake_name_next_scheduled_preset-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'fake_name Next scheduled preset', + }), + 'context': , + 'entity_id': 'sensor.fake_name_next_scheduled_preset', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'comfort', + }) +# --- +# name: test_setup[FritzDeviceClimateMock][sensor.fake_name_next_scheduled_temperature-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.fake_name_next_scheduled_temperature', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Next scheduled temperature', + 'platform': 'fritzbox', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'nextchange_temperature', + 'unique_id': '12345 1234567_nextchange_temperature', + 'unit_of_measurement': , + }) +# --- +# name: test_setup[FritzDeviceClimateMock][sensor.fake_name_next_scheduled_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'fake_name Next scheduled temperature', + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.fake_name_next_scheduled_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '22.0', + }) +# --- +# name: test_setup[FritzDeviceSensorMock][sensor.fake_name_battery-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.fake_name_battery', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Battery', + 'platform': 'fritzbox', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '12345 1234567_battery', + 'unit_of_measurement': '%', + }) +# --- +# name: test_setup[FritzDeviceSensorMock][sensor.fake_name_battery-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'battery', + 'friendly_name': 'fake_name Battery', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.fake_name_battery', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '23', + }) +# --- +# name: test_setup[FritzDeviceSensorMock][sensor.fake_name_humidity-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.fake_name_humidity', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Humidity', + 'platform': 'fritzbox', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '12345 1234567_humidity', + 'unit_of_measurement': '%', + }) +# --- +# name: test_setup[FritzDeviceSensorMock][sensor.fake_name_humidity-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'humidity', + 'friendly_name': 'fake_name Humidity', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.fake_name_humidity', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '42', + }) +# --- +# name: test_setup[FritzDeviceSensorMock][sensor.fake_name_temperature-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.fake_name_temperature', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Temperature', + 'platform': 'fritzbox', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '12345 1234567_temperature', + 'unit_of_measurement': , + }) +# --- +# name: test_setup[FritzDeviceSensorMock][sensor.fake_name_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'fake_name Temperature', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.fake_name_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '1.23', + }) +# --- +# name: test_setup[FritzDeviceSwitchMock][sensor.fake_name_current-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.fake_name_current', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Current', + 'platform': 'fritzbox', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '12345 1234567_electric_current', + 'unit_of_measurement': , + }) +# --- +# name: test_setup[FritzDeviceSwitchMock][sensor.fake_name_current-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'current', + 'friendly_name': 'fake_name Current', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.fake_name_current', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.025', + }) +# --- +# name: test_setup[FritzDeviceSwitchMock][sensor.fake_name_energy-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.fake_name_energy', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Energy', + 'platform': 'fritzbox', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '12345 1234567_total_energy', + 'unit_of_measurement': , + }) +# --- +# name: test_setup[FritzDeviceSwitchMock][sensor.fake_name_energy-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'fake_name Energy', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.fake_name_energy', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '1.234', + }) +# --- +# name: test_setup[FritzDeviceSwitchMock][sensor.fake_name_power-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.fake_name_power', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Power', + 'platform': 'fritzbox', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '12345 1234567_power_consumption', + 'unit_of_measurement': , + }) +# --- +# name: test_setup[FritzDeviceSwitchMock][sensor.fake_name_power-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'fake_name Power', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.fake_name_power', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '5.678', + }) +# --- +# name: test_setup[FritzDeviceSwitchMock][sensor.fake_name_temperature-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.fake_name_temperature', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Temperature', + 'platform': 'fritzbox', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '12345 1234567_temperature', + 'unit_of_measurement': , + }) +# --- +# name: test_setup[FritzDeviceSwitchMock][sensor.fake_name_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'fake_name Temperature', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.fake_name_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '1.23', + }) +# --- +# name: test_setup[FritzDeviceSwitchMock][sensor.fake_name_voltage-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.fake_name_voltage', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Voltage', + 'platform': 'fritzbox', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '12345 1234567_voltage', + 'unit_of_measurement': , + }) +# --- +# name: test_setup[FritzDeviceSwitchMock][sensor.fake_name_voltage-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'voltage', + 'friendly_name': 'fake_name Voltage', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.fake_name_voltage', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '230.0', + }) +# --- diff --git a/tests/components/fritzbox/snapshots/test_switch.ambr b/tests/components/fritzbox/snapshots/test_switch.ambr new file mode 100644 index 00000000000..23deb8183fc --- /dev/null +++ b/tests/components/fritzbox/snapshots/test_switch.ambr @@ -0,0 +1,48 @@ +# serializer version: 1 +# name: test_setup[switch.fake_name-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': None, + 'entity_id': 'switch.fake_name', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'fake_name', + 'platform': 'fritzbox', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '12345 1234567', + 'unit_of_measurement': None, + }) +# --- +# name: test_setup[switch.fake_name-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'fake_name', + }), + 'context': , + 'entity_id': 'switch.fake_name', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- diff --git a/tests/components/fritzbox/test_binary_sensor.py b/tests/components/fritzbox/test_binary_sensor.py index d5b0b5d196b..3244d007fa6 100644 --- a/tests/components/fritzbox/test_binary_sensor.py +++ b/tests/components/fritzbox/test_binary_sensor.py @@ -2,87 +2,49 @@ from datetime import timedelta from unittest import mock -from unittest.mock import Mock +from unittest.mock import Mock, patch from requests.exceptions import HTTPError +from syrupy import SnapshotAssertion -from homeassistant.components.binary_sensor import ( - DOMAIN as BINARY_SENSOR_DOMAIN, - BinarySensorDeviceClass, -) +from homeassistant.components.binary_sensor import DOMAIN as BINARY_SENSOR_DOMAIN from homeassistant.components.fritzbox.const import DOMAIN as FB_DOMAIN -from homeassistant.components.sensor import ( - ATTR_STATE_CLASS, - DOMAIN as SENSOR_DOMAIN, - SensorStateClass, -) -from homeassistant.const import ( - ATTR_DEVICE_CLASS, - ATTR_FRIENDLY_NAME, - ATTR_UNIT_OF_MEASUREMENT, - CONF_DEVICES, - PERCENTAGE, - STATE_OFF, - STATE_ON, - STATE_UNAVAILABLE, -) +from homeassistant.config_entries import ConfigEntryState +from homeassistant.const import CONF_DEVICES, STATE_UNAVAILABLE, Platform from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er from homeassistant.util import dt as dt_util from . import FritzDeviceBinarySensorMock, set_devices, setup_config_entry from .const import CONF_FAKE_NAME, MOCK_CONFIG -from tests.common import async_fire_time_changed +from tests.common import async_fire_time_changed, snapshot_platform ENTITY_ID = f"{BINARY_SENSOR_DOMAIN}.{CONF_FAKE_NAME}" -async def test_setup(hass: HomeAssistant, fritz: Mock) -> None: +async def test_setup( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, + fritz: Mock, +) -> None: """Test setup of platform.""" device = FritzDeviceBinarySensorMock() - assert await setup_config_entry( - hass, MOCK_CONFIG[FB_DOMAIN][CONF_DEVICES][0], ENTITY_ID, device, fritz - ) + with patch("homeassistant.components.fritzbox.PLATFORMS", [Platform.BINARY_SENSOR]): + entry = await setup_config_entry( + hass, MOCK_CONFIG[FB_DOMAIN][CONF_DEVICES][0], ENTITY_ID, device, fritz + ) + assert entry.state is ConfigEntryState.LOADED - state = hass.states.get(f"{ENTITY_ID}_alarm") - assert state - assert state.state == STATE_ON - assert state.attributes[ATTR_FRIENDLY_NAME] == f"{CONF_FAKE_NAME} Alarm" - assert state.attributes[ATTR_DEVICE_CLASS] == BinarySensorDeviceClass.WINDOW - assert ATTR_STATE_CLASS not in state.attributes - - state = hass.states.get(f"{ENTITY_ID}_button_lock_on_device") - assert state - assert state.state == STATE_OFF - assert ( - state.attributes[ATTR_FRIENDLY_NAME] - == f"{CONF_FAKE_NAME} Button lock on device" - ) - assert state.attributes[ATTR_DEVICE_CLASS] == BinarySensorDeviceClass.LOCK - assert ATTR_STATE_CLASS not in state.attributes - - state = hass.states.get(f"{ENTITY_ID}_button_lock_via_ui") - assert state - assert state.state == STATE_OFF - assert ( - state.attributes[ATTR_FRIENDLY_NAME] == f"{CONF_FAKE_NAME} Button lock via UI" - ) - assert state.attributes[ATTR_DEVICE_CLASS] == BinarySensorDeviceClass.LOCK - assert ATTR_STATE_CLASS not in state.attributes - - state = hass.states.get(f"{SENSOR_DOMAIN}.{CONF_FAKE_NAME}_battery") - assert state - assert state.state == "23" - assert state.attributes[ATTR_FRIENDLY_NAME] == f"{CONF_FAKE_NAME} Battery" - assert state.attributes[ATTR_UNIT_OF_MEASUREMENT] == PERCENTAGE - assert state.attributes[ATTR_STATE_CLASS] is SensorStateClass.MEASUREMENT + await snapshot_platform(hass, entity_registry, snapshot, entry.entry_id) async def test_is_off(hass: HomeAssistant, fritz: Mock) -> None: """Test state of platform.""" device = FritzDeviceBinarySensorMock() device.present = False - assert await setup_config_entry( + await setup_config_entry( hass, MOCK_CONFIG[FB_DOMAIN][CONF_DEVICES][0], ENTITY_ID, device, fritz ) @@ -102,7 +64,7 @@ async def test_is_off(hass: HomeAssistant, fritz: Mock) -> None: async def test_update(hass: HomeAssistant, fritz: Mock) -> None: """Test update without error.""" device = FritzDeviceBinarySensorMock() - assert await setup_config_entry( + await setup_config_entry( hass, MOCK_CONFIG[FB_DOMAIN][CONF_DEVICES][0], ENTITY_ID, device, fritz ) @@ -121,7 +83,7 @@ async def test_update_error(hass: HomeAssistant, fritz: Mock) -> None: """Test update with error.""" device = FritzDeviceBinarySensorMock() device.update.side_effect = [mock.DEFAULT, HTTPError("Boom")] - assert await setup_config_entry( + await setup_config_entry( hass, MOCK_CONFIG[FB_DOMAIN][CONF_DEVICES][0], ENTITY_ID, device, fritz ) @@ -139,7 +101,7 @@ async def test_update_error(hass: HomeAssistant, fritz: Mock) -> None: async def test_discover_new_device(hass: HomeAssistant, fritz: Mock) -> None: """Test adding new discovered devices during runtime.""" device = FritzDeviceBinarySensorMock() - assert await setup_config_entry( + await setup_config_entry( hass, MOCK_CONFIG[FB_DOMAIN][CONF_DEVICES][0], ENTITY_ID, device, fritz ) diff --git a/tests/components/fritzbox/test_button.py b/tests/components/fritzbox/test_button.py index 0053a8d3446..5280cd7cc83 100644 --- a/tests/components/fritzbox/test_button.py +++ b/tests/components/fritzbox/test_button.py @@ -1,44 +1,50 @@ """Tests for AVM Fritz!Box templates.""" from datetime import timedelta -from unittest.mock import Mock +from unittest.mock import Mock, patch + +from syrupy import SnapshotAssertion from homeassistant.components.button import DOMAIN as BUTTON_DOMAIN, SERVICE_PRESS from homeassistant.components.fritzbox.const import DOMAIN as FB_DOMAIN -from homeassistant.const import ( - ATTR_ENTITY_ID, - ATTR_FRIENDLY_NAME, - CONF_DEVICES, - STATE_UNKNOWN, -) +from homeassistant.config_entries import ConfigEntryState +from homeassistant.const import ATTR_ENTITY_ID, CONF_DEVICES, Platform from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er from homeassistant.util import dt as dt_util from . import FritzEntityBaseMock, set_devices, setup_config_entry from .const import CONF_FAKE_NAME, MOCK_CONFIG -from tests.common import async_fire_time_changed +from tests.common import async_fire_time_changed, snapshot_platform ENTITY_ID = f"{BUTTON_DOMAIN}.{CONF_FAKE_NAME}" -async def test_setup(hass: HomeAssistant, fritz: Mock) -> None: +async def test_setup( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, + fritz: Mock, +) -> None: """Test if is initialized correctly.""" template = FritzEntityBaseMock() - assert await setup_config_entry( - hass, MOCK_CONFIG[FB_DOMAIN][CONF_DEVICES][0], fritz=fritz, template=template - ) + with patch("homeassistant.components.fritzbox.PLATFORMS", [Platform.BUTTON]): + entry = await setup_config_entry( + hass, + MOCK_CONFIG[FB_DOMAIN][CONF_DEVICES][0], + fritz=fritz, + template=template, + ) + assert entry.state is ConfigEntryState.LOADED - state = hass.states.get(ENTITY_ID) - assert state - assert state.attributes[ATTR_FRIENDLY_NAME] == CONF_FAKE_NAME - assert state.state == STATE_UNKNOWN + await snapshot_platform(hass, entity_registry, snapshot, entry.entry_id) async def test_apply_template(hass: HomeAssistant, fritz: Mock) -> None: """Test if applies works.""" template = FritzEntityBaseMock() - assert await setup_config_entry( + await setup_config_entry( hass, MOCK_CONFIG[FB_DOMAIN][CONF_DEVICES][0], fritz=fritz, template=template ) @@ -51,7 +57,7 @@ async def test_apply_template(hass: HomeAssistant, fritz: Mock) -> None: async def test_discover_new_device(hass: HomeAssistant, fritz: Mock) -> None: """Test adding new discovered devices during runtime.""" template = FritzEntityBaseMock() - assert await setup_config_entry( + await setup_config_entry( hass, MOCK_CONFIG[FB_DOMAIN][CONF_DEVICES][0], fritz=fritz, template=template ) diff --git a/tests/components/fritzbox/test_climate.py b/tests/components/fritzbox/test_climate.py index 699a2b8c53e..e21191fcbbb 100644 --- a/tests/components/fritzbox/test_climate.py +++ b/tests/components/fritzbox/test_climate.py @@ -1,11 +1,12 @@ """Tests for AVM Fritz!Box climate component.""" from datetime import timedelta -from unittest.mock import Mock, _Call, call +from unittest.mock import Mock, _Call, call, patch from freezegun.api import FrozenDateTimeFactory import pytest from requests.exceptions import HTTPError +from syrupy import SnapshotAssertion from homeassistant.components.climate import ( ATTR_CURRENT_TEMPERATURE, @@ -31,29 +32,15 @@ from homeassistant.components.fritzbox.climate import ( PRESET_SUMMER, ) from homeassistant.components.fritzbox.const import ( - ATTR_STATE_BATTERY_LOW, ATTR_STATE_HOLIDAY_MODE, ATTR_STATE_SUMMER_MODE, - ATTR_STATE_WINDOW_OPEN, DOMAIN as FB_DOMAIN, ) -from homeassistant.components.sensor import ( - ATTR_STATE_CLASS, - DOMAIN as SENSOR_DOMAIN, - SensorStateClass, -) -from homeassistant.const import ( - ATTR_BATTERY_LEVEL, - ATTR_ENTITY_ID, - ATTR_FRIENDLY_NAME, - ATTR_TEMPERATURE, - ATTR_UNIT_OF_MEASUREMENT, - CONF_DEVICES, - PERCENTAGE, - UnitOfTemperature, -) +from homeassistant.config_entries import ConfigEntryState +from homeassistant.const import ATTR_ENTITY_ID, ATTR_TEMPERATURE, CONF_DEVICES, Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers import entity_registry as er from homeassistant.util import dt as dt_util from . import ( @@ -64,127 +51,31 @@ from . import ( ) from .const import CONF_FAKE_NAME, MOCK_CONFIG -from tests.common import async_fire_time_changed +from tests.common import async_fire_time_changed, snapshot_platform ENTITY_ID = f"{CLIMATE_DOMAIN}.{CONF_FAKE_NAME}" -async def test_setup(hass: HomeAssistant, fritz: Mock) -> None: +async def test_setup( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, + fritz: Mock, +) -> None: """Test setup of platform.""" device = FritzDeviceClimateMock() - assert await setup_config_entry( - hass, MOCK_CONFIG[FB_DOMAIN][CONF_DEVICES][0], ENTITY_ID, device, fritz - ) + with patch("homeassistant.components.fritzbox.PLATFORMS", [Platform.CLIMATE]): + entry = await setup_config_entry( + hass, MOCK_CONFIG[FB_DOMAIN][CONF_DEVICES][0], ENTITY_ID, device, fritz + ) - state = hass.states.get(ENTITY_ID) - assert state - assert state.attributes[ATTR_BATTERY_LEVEL] == 23 - assert state.attributes[ATTR_CURRENT_TEMPERATURE] == 18 - assert state.attributes[ATTR_FRIENDLY_NAME] == CONF_FAKE_NAME - assert state.attributes[ATTR_HVAC_MODES] == [HVACMode.HEAT, HVACMode.OFF] - assert state.attributes[ATTR_MAX_TEMP] == 28 - assert state.attributes[ATTR_MIN_TEMP] == 8 - assert state.attributes[ATTR_PRESET_MODE] is None - assert state.attributes[ATTR_PRESET_MODES] == [ - PRESET_ECO, - PRESET_COMFORT, - PRESET_BOOST, - ] - assert state.attributes[ATTR_STATE_BATTERY_LOW] is True - assert state.attributes[ATTR_STATE_HOLIDAY_MODE] is False - assert state.attributes[ATTR_STATE_SUMMER_MODE] is False - assert state.attributes[ATTR_STATE_WINDOW_OPEN] == "fake_window" - assert state.attributes[ATTR_TEMPERATURE] == 19.5 - assert ATTR_STATE_CLASS not in state.attributes - assert state.state == HVACMode.HEAT - - state = hass.states.get(f"{SENSOR_DOMAIN}.{CONF_FAKE_NAME}_battery") - assert state - assert state.state == "23" - assert state.attributes[ATTR_FRIENDLY_NAME] == f"{CONF_FAKE_NAME} Battery" - assert state.attributes[ATTR_UNIT_OF_MEASUREMENT] == PERCENTAGE - assert state.attributes[ATTR_STATE_CLASS] is SensorStateClass.MEASUREMENT - - state = hass.states.get(f"{SENSOR_DOMAIN}.{CONF_FAKE_NAME}_comfort_temperature") - assert state - assert state.state == "22.0" - assert ( - state.attributes[ATTR_FRIENDLY_NAME] == f"{CONF_FAKE_NAME} Comfort temperature" - ) - assert state.attributes[ATTR_UNIT_OF_MEASUREMENT] == UnitOfTemperature.CELSIUS - assert ATTR_STATE_CLASS not in state.attributes - - state = hass.states.get(f"{SENSOR_DOMAIN}.{CONF_FAKE_NAME}_eco_temperature") - assert state - assert state.state == "16.0" - assert state.attributes[ATTR_FRIENDLY_NAME] == f"{CONF_FAKE_NAME} Eco temperature" - assert state.attributes[ATTR_UNIT_OF_MEASUREMENT] == UnitOfTemperature.CELSIUS - assert ATTR_STATE_CLASS not in state.attributes - - state = hass.states.get( - f"{SENSOR_DOMAIN}.{CONF_FAKE_NAME}_next_scheduled_temperature" - ) - assert state - assert state.state == "22.0" - assert ( - state.attributes[ATTR_FRIENDLY_NAME] - == f"{CONF_FAKE_NAME} Next scheduled temperature" - ) - assert state.attributes[ATTR_UNIT_OF_MEASUREMENT] == UnitOfTemperature.CELSIUS - assert ATTR_STATE_CLASS not in state.attributes - - state = hass.states.get( - f"{SENSOR_DOMAIN}.{CONF_FAKE_NAME}_next_scheduled_change_time" - ) - assert state - assert state.state == "2024-09-20T18:00:00+00:00" - assert ( - state.attributes[ATTR_FRIENDLY_NAME] - == f"{CONF_FAKE_NAME} Next scheduled change time" - ) - assert ATTR_STATE_CLASS not in state.attributes - - state = hass.states.get(f"{SENSOR_DOMAIN}.{CONF_FAKE_NAME}_next_scheduled_preset") - assert state - assert state.state == PRESET_COMFORT - assert ( - state.attributes[ATTR_FRIENDLY_NAME] - == f"{CONF_FAKE_NAME} Next scheduled preset" - ) - assert ATTR_STATE_CLASS not in state.attributes - - state = hass.states.get( - f"{SENSOR_DOMAIN}.{CONF_FAKE_NAME}_current_scheduled_preset" - ) - assert state - assert state.state == PRESET_ECO - assert ( - state.attributes[ATTR_FRIENDLY_NAME] - == f"{CONF_FAKE_NAME} Current scheduled preset" - ) - assert ATTR_STATE_CLASS not in state.attributes - - device.nextchange_temperature = 16 - - next_update = dt_util.utcnow() + timedelta(seconds=200) - async_fire_time_changed(hass, next_update) - await hass.async_block_till_done(wait_background_tasks=True) - - state = hass.states.get(f"{SENSOR_DOMAIN}.{CONF_FAKE_NAME}_next_scheduled_preset") - assert state - assert state.state == PRESET_ECO - - state = hass.states.get( - f"{SENSOR_DOMAIN}.{CONF_FAKE_NAME}_current_scheduled_preset" - ) - assert state - assert state.state == PRESET_COMFORT + await snapshot_platform(hass, entity_registry, snapshot, entry.entry_id) async def test_hkr_wo_temperature_sensor(hass: HomeAssistant, fritz: Mock) -> None: """Test hkr without exposing dedicated temperature sensor data block.""" device = FritzDeviceClimateWithoutTempSensorMock() - assert await setup_config_entry( + await setup_config_entry( hass, MOCK_CONFIG[FB_DOMAIN][CONF_DEVICES][0], ENTITY_ID, device, fritz ) @@ -197,7 +88,7 @@ async def test_target_temperature_on(hass: HomeAssistant, fritz: Mock) -> None: """Test turn device on.""" device = FritzDeviceClimateMock() device.target_temperature = 127.0 - assert await setup_config_entry( + await setup_config_entry( hass, MOCK_CONFIG[FB_DOMAIN][CONF_DEVICES][0], ENTITY_ID, device, fritz ) @@ -210,7 +101,7 @@ async def test_target_temperature_off(hass: HomeAssistant, fritz: Mock) -> None: """Test turn device on.""" device = FritzDeviceClimateMock() device.target_temperature = 126.5 - assert await setup_config_entry( + await setup_config_entry( hass, MOCK_CONFIG[FB_DOMAIN][CONF_DEVICES][0], ENTITY_ID, device, fritz ) @@ -222,7 +113,7 @@ async def test_target_temperature_off(hass: HomeAssistant, fritz: Mock) -> None: async def test_update(hass: HomeAssistant, fritz: Mock) -> None: """Test update without error.""" device = FritzDeviceClimateMock() - assert await setup_config_entry( + await setup_config_entry( hass, MOCK_CONFIG[FB_DOMAIN][CONF_DEVICES][0], ENTITY_ID, device, fritz ) @@ -253,7 +144,7 @@ async def test_automatic_offset(hass: HomeAssistant, fritz: Mock) -> None: device.temperature = 18 device.actual_temperature = 19 device.target_temperature = 20 - assert await setup_config_entry( + await setup_config_entry( hass, MOCK_CONFIG[FB_DOMAIN][CONF_DEVICES][0], ENTITY_ID, device, fritz ) @@ -269,9 +160,10 @@ async def test_update_error(hass: HomeAssistant, fritz: Mock) -> None: """Test update with error.""" device = FritzDeviceClimateMock() fritz().update_devices.side_effect = HTTPError("Boom") - assert not await setup_config_entry( + entry = await setup_config_entry( hass, MOCK_CONFIG[FB_DOMAIN][CONF_DEVICES][0], ENTITY_ID, device, fritz ) + assert entry.state is ConfigEntryState.SETUP_RETRY assert fritz().update_devices.call_count == 2 assert fritz().login.call_count == 2 @@ -312,7 +204,7 @@ async def test_set_temperature( ) -> None: """Test setting temperature.""" device = FritzDeviceClimateMock() - assert await setup_config_entry( + await setup_config_entry( hass, MOCK_CONFIG[FB_DOMAIN][CONF_DEVICES][0], ENTITY_ID, device, fritz ) @@ -366,7 +258,7 @@ async def test_set_hvac_mode( else: device.nextchange_endperiod = 0 - assert await setup_config_entry( + await setup_config_entry( hass, MOCK_CONFIG[FB_DOMAIN][CONF_DEVICES][0], ENTITY_ID, device, fritz ) @@ -398,7 +290,7 @@ async def test_set_preset_mode_comfort( """Test setting preset mode.""" device = FritzDeviceClimateMock() device.comfort_temperature = comfort_temperature - assert await setup_config_entry( + await setup_config_entry( hass, MOCK_CONFIG[FB_DOMAIN][CONF_DEVICES][0], ENTITY_ID, device, fritz ) @@ -429,7 +321,7 @@ async def test_set_preset_mode_eco( """Test setting preset mode.""" device = FritzDeviceClimateMock() device.eco_temperature = eco_temperature - assert await setup_config_entry( + await setup_config_entry( hass, MOCK_CONFIG[FB_DOMAIN][CONF_DEVICES][0], ENTITY_ID, device, fritz ) @@ -449,7 +341,7 @@ async def test_set_preset_mode_boost( ) -> None: """Test setting preset mode.""" device = FritzDeviceClimateMock() - assert await setup_config_entry( + await setup_config_entry( hass, MOCK_CONFIG[FB_DOMAIN][CONF_DEVICES][0], ENTITY_ID, device, fritz ) @@ -468,7 +360,7 @@ async def test_preset_mode_update(hass: HomeAssistant, fritz: Mock) -> None: device = FritzDeviceClimateMock() device.comfort_temperature = 23 device.eco_temperature = 20 - assert await setup_config_entry( + await setup_config_entry( hass, MOCK_CONFIG[FB_DOMAIN][CONF_DEVICES][0], ENTITY_ID, device, fritz ) @@ -513,7 +405,7 @@ async def test_preset_mode_update(hass: HomeAssistant, fritz: Mock) -> None: async def test_discover_new_device(hass: HomeAssistant, fritz: Mock) -> None: """Test adding new discovered devices during runtime.""" device = FritzDeviceClimateMock() - assert await setup_config_entry( + await setup_config_entry( hass, MOCK_CONFIG[FB_DOMAIN][CONF_DEVICES][0], ENTITY_ID, device, fritz ) @@ -538,7 +430,7 @@ async def test_holidy_summer_mode( ) -> None: """Test holiday and summer mode.""" device = FritzDeviceClimateMock() - assert await setup_config_entry( + await setup_config_entry( hass, MOCK_CONFIG[FB_DOMAIN][CONF_DEVICES][0], ENTITY_ID, device, fritz ) diff --git a/tests/components/fritzbox/test_cover.py b/tests/components/fritzbox/test_cover.py index 535306e4ef2..a1332e9715b 100644 --- a/tests/components/fritzbox/test_cover.py +++ b/tests/components/fritzbox/test_cover.py @@ -1,15 +1,13 @@ """Tests for AVM Fritz!Box switch component.""" from datetime import timedelta -from unittest.mock import Mock, call +from unittest.mock import Mock, call, patch -from homeassistant.components.cover import ( - ATTR_CURRENT_POSITION, - ATTR_POSITION, - DOMAIN as COVER_DOMAIN, - CoverState, -) +from syrupy import SnapshotAssertion + +from homeassistant.components.cover import ATTR_POSITION, DOMAIN as COVER_DOMAIN from homeassistant.components.fritzbox.const import DOMAIN as FB_DOMAIN +from homeassistant.config_entries import ConfigEntryState from homeassistant.const import ( ATTR_ENTITY_ID, CONF_DEVICES, @@ -18,8 +16,10 @@ from homeassistant.const import ( SERVICE_SET_COVER_POSITION, SERVICE_STOP_COVER, STATE_UNKNOWN, + Platform, ) from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er from homeassistant.util import dt as dt_util from . import ( @@ -30,28 +30,32 @@ from . import ( ) from .const import CONF_FAKE_NAME, MOCK_CONFIG -from tests.common import async_fire_time_changed +from tests.common import async_fire_time_changed, snapshot_platform ENTITY_ID = f"{COVER_DOMAIN}.{CONF_FAKE_NAME}" -async def test_setup(hass: HomeAssistant, fritz: Mock) -> None: +async def test_setup( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, + fritz: Mock, +) -> None: """Test setup of platform.""" device = FritzDeviceCoverMock() - assert await setup_config_entry( - hass, MOCK_CONFIG[FB_DOMAIN][CONF_DEVICES][0], ENTITY_ID, device, fritz - ) + with patch("homeassistant.components.fritzbox.PLATFORMS", [Platform.COVER]): + entry = await setup_config_entry( + hass, MOCK_CONFIG[FB_DOMAIN][CONF_DEVICES][0], ENTITY_ID, device, fritz + ) + assert entry.state is ConfigEntryState.LOADED - state = hass.states.get(ENTITY_ID) - assert state - assert state.state == CoverState.OPEN - assert state.attributes[ATTR_CURRENT_POSITION] == 100 + await snapshot_platform(hass, entity_registry, snapshot, entry.entry_id) async def test_unknown_position(hass: HomeAssistant, fritz: Mock) -> None: """Test cover with unknown position.""" device = FritzDeviceCoverUnknownPositionMock() - assert await setup_config_entry( + await setup_config_entry( hass, MOCK_CONFIG[FB_DOMAIN][CONF_DEVICES][0], ENTITY_ID, device, fritz ) @@ -63,7 +67,7 @@ async def test_unknown_position(hass: HomeAssistant, fritz: Mock) -> None: async def test_open_cover(hass: HomeAssistant, fritz: Mock) -> None: """Test opening the cover.""" device = FritzDeviceCoverMock() - assert await setup_config_entry( + await setup_config_entry( hass, MOCK_CONFIG[FB_DOMAIN][CONF_DEVICES][0], ENTITY_ID, device, fritz ) @@ -76,7 +80,7 @@ async def test_open_cover(hass: HomeAssistant, fritz: Mock) -> None: async def test_close_cover(hass: HomeAssistant, fritz: Mock) -> None: """Test closing the device.""" device = FritzDeviceCoverMock() - assert await setup_config_entry( + await setup_config_entry( hass, MOCK_CONFIG[FB_DOMAIN][CONF_DEVICES][0], ENTITY_ID, device, fritz ) @@ -89,7 +93,7 @@ async def test_close_cover(hass: HomeAssistant, fritz: Mock) -> None: async def test_set_position_cover(hass: HomeAssistant, fritz: Mock) -> None: """Test stopping the device.""" device = FritzDeviceCoverMock() - assert await setup_config_entry( + await setup_config_entry( hass, MOCK_CONFIG[FB_DOMAIN][CONF_DEVICES][0], ENTITY_ID, device, fritz ) @@ -105,7 +109,7 @@ async def test_set_position_cover(hass: HomeAssistant, fritz: Mock) -> None: async def test_stop_cover(hass: HomeAssistant, fritz: Mock) -> None: """Test stopping the device.""" device = FritzDeviceCoverMock() - assert await setup_config_entry( + await setup_config_entry( hass, MOCK_CONFIG[FB_DOMAIN][CONF_DEVICES][0], ENTITY_ID, device, fritz ) @@ -118,7 +122,7 @@ async def test_stop_cover(hass: HomeAssistant, fritz: Mock) -> None: async def test_discover_new_device(hass: HomeAssistant, fritz: Mock) -> None: """Test adding new discovered devices during runtime.""" device = FritzDeviceCoverMock() - assert await setup_config_entry( + await setup_config_entry( hass, MOCK_CONFIG[FB_DOMAIN][CONF_DEVICES][0], ENTITY_ID, device, fritz ) diff --git a/tests/components/fritzbox/test_light.py b/tests/components/fritzbox/test_light.py index fe8bb32066e..d9a81bf8f21 100644 --- a/tests/components/fritzbox/test_light.py +++ b/tests/components/fritzbox/test_light.py @@ -1,9 +1,10 @@ """Tests for AVM Fritz!Box light component.""" from datetime import timedelta -from unittest.mock import Mock, call +from unittest.mock import Mock, call, patch from requests.exceptions import HTTPError +from syrupy import SnapshotAssertion from homeassistant.components.fritzbox.const import ( COLOR_MODE, @@ -12,35 +13,36 @@ from homeassistant.components.fritzbox.const import ( ) from homeassistant.components.light import ( ATTR_BRIGHTNESS, - ATTR_COLOR_MODE, ATTR_COLOR_TEMP_KELVIN, ATTR_HS_COLOR, - ATTR_MAX_COLOR_TEMP_KELVIN, - ATTR_MIN_COLOR_TEMP_KELVIN, - ATTR_SUPPORTED_COLOR_MODES, DOMAIN as LIGHT_DOMAIN, - ColorMode, ) +from homeassistant.config_entries import ConfigEntryState from homeassistant.const import ( ATTR_ENTITY_ID, - ATTR_FRIENDLY_NAME, CONF_DEVICES, SERVICE_TURN_OFF, SERVICE_TURN_ON, - STATE_ON, + Platform, ) from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er from homeassistant.util import dt as dt_util from . import FritzDeviceLightMock, set_devices, setup_config_entry from .const import CONF_FAKE_NAME, MOCK_CONFIG -from tests.common import async_fire_time_changed +from tests.common import async_fire_time_changed, snapshot_platform ENTITY_ID = f"{LIGHT_DOMAIN}.{CONF_FAKE_NAME}" -async def test_setup(hass: HomeAssistant, fritz: Mock) -> None: +async def test_setup( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, + fritz: Mock, +) -> None: """Test setup of platform.""" device = FritzDeviceLightMock() device.get_color_temps.return_value = [2700, 6500] @@ -50,42 +52,42 @@ async def test_setup(hass: HomeAssistant, fritz: Mock) -> None: device.color_mode = COLOR_TEMP_MODE device.color_temp = 2700 - assert await setup_config_entry( - hass, MOCK_CONFIG[FB_DOMAIN][CONF_DEVICES][0], ENTITY_ID, device, fritz - ) + with patch("homeassistant.components.fritzbox.PLATFORMS", [Platform.LIGHT]): + entry = await setup_config_entry( + hass, MOCK_CONFIG[FB_DOMAIN][CONF_DEVICES][0], ENTITY_ID, device, fritz + ) + assert entry.state is ConfigEntryState.LOADED - state = hass.states.get(ENTITY_ID) - assert state - assert state.state == STATE_ON - assert state.attributes[ATTR_FRIENDLY_NAME] == "fake_name" - assert state.attributes[ATTR_COLOR_MODE] == ColorMode.COLOR_TEMP - assert state.attributes[ATTR_COLOR_TEMP_KELVIN] == 2700 - assert state.attributes[ATTR_MIN_COLOR_TEMP_KELVIN] == 2700 - assert state.attributes[ATTR_MAX_COLOR_TEMP_KELVIN] == 6500 - assert state.attributes[ATTR_HS_COLOR] == (28.395, 65.723) - assert state.attributes[ATTR_SUPPORTED_COLOR_MODES] == ["color_temp", "hs"] + await snapshot_platform(hass, entity_registry, snapshot, entry.entry_id) -async def test_setup_non_color(hass: HomeAssistant, fritz: Mock) -> None: +async def test_setup_non_color( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, + fritz: Mock, +) -> None: """Test setup of platform of non color bulb.""" device = FritzDeviceLightMock() device.has_color = False device.get_color_temps.return_value = [] device.get_colors.return_value = {} - assert await setup_config_entry( - hass, MOCK_CONFIG[FB_DOMAIN][CONF_DEVICES][0], ENTITY_ID, device, fritz - ) + with patch("homeassistant.components.fritzbox.PLATFORMS", [Platform.LIGHT]): + entry = await setup_config_entry( + hass, MOCK_CONFIG[FB_DOMAIN][CONF_DEVICES][0], ENTITY_ID, device, fritz + ) + assert entry.state is ConfigEntryState.LOADED - state = hass.states.get(ENTITY_ID) - assert state - assert state.state == STATE_ON - assert state.attributes[ATTR_FRIENDLY_NAME] == "fake_name" - assert state.attributes[ATTR_BRIGHTNESS] == 100 - assert state.attributes[ATTR_SUPPORTED_COLOR_MODES] == ["brightness"] + await snapshot_platform(hass, entity_registry, snapshot, entry.entry_id) -async def test_setup_non_color_non_level(hass: HomeAssistant, fritz: Mock) -> None: +async def test_setup_non_color_non_level( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, + fritz: Mock, +) -> None: """Test setup of platform of non color and non level bulb.""" device = FritzDeviceLightMock() device.has_color = False @@ -93,22 +95,21 @@ async def test_setup_non_color_non_level(hass: HomeAssistant, fritz: Mock) -> No device.get_color_temps.return_value = [] device.get_colors.return_value = {} - assert await setup_config_entry( - hass, MOCK_CONFIG[FB_DOMAIN][CONF_DEVICES][0], ENTITY_ID, device, fritz - ) + with patch("homeassistant.components.fritzbox.PLATFORMS", [Platform.LIGHT]): + entry = await setup_config_entry( + hass, MOCK_CONFIG[FB_DOMAIN][CONF_DEVICES][0], ENTITY_ID, device, fritz + ) + assert entry.state is ConfigEntryState.LOADED - state = hass.states.get(ENTITY_ID) - assert state - assert state.state == STATE_ON - assert state.attributes[ATTR_FRIENDLY_NAME] == "fake_name" - assert ATTR_BRIGHTNESS not in state.attributes - assert state.attributes[ATTR_SUPPORTED_COLOR_MODES] == ["onoff"] - assert state.attributes[ATTR_COLOR_MODE] == ColorMode.ONOFF - assert state.attributes.get(ATTR_COLOR_TEMP_KELVIN) is None - assert state.attributes.get(ATTR_HS_COLOR) is None + await snapshot_platform(hass, entity_registry, snapshot, entry.entry_id) -async def test_setup_color(hass: HomeAssistant, fritz: Mock) -> None: +async def test_setup_color( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, + fritz: Mock, +) -> None: """Test setup of platform in color mode.""" device = FritzDeviceLightMock() device.get_color_temps.return_value = [2700, 6500] @@ -119,19 +120,13 @@ async def test_setup_color(hass: HomeAssistant, fritz: Mock) -> None: device.hue = 100 device.saturation = 70 * 255.0 / 100.0 - assert await setup_config_entry( - hass, MOCK_CONFIG[FB_DOMAIN][CONF_DEVICES][0], ENTITY_ID, device, fritz - ) + with patch("homeassistant.components.fritzbox.PLATFORMS", [Platform.LIGHT]): + entry = await setup_config_entry( + hass, MOCK_CONFIG[FB_DOMAIN][CONF_DEVICES][0], ENTITY_ID, device, fritz + ) + assert entry.state is ConfigEntryState.LOADED - state = hass.states.get(ENTITY_ID) - assert state - assert state.state == STATE_ON - assert state.attributes[ATTR_FRIENDLY_NAME] == "fake_name" - assert state.attributes[ATTR_COLOR_MODE] == ColorMode.HS - assert state.attributes[ATTR_COLOR_TEMP_KELVIN] is None - assert state.attributes[ATTR_BRIGHTNESS] == 100 - assert state.attributes[ATTR_HS_COLOR] == (100, 70) - assert state.attributes[ATTR_SUPPORTED_COLOR_MODES] == ["color_temp", "hs"] + await snapshot_platform(hass, entity_registry, snapshot, entry.entry_id) async def test_turn_on(hass: HomeAssistant, fritz: Mock) -> None: @@ -258,9 +253,10 @@ async def test_update_error(hass: HomeAssistant, fritz: Mock) -> None: "Red": [("100", "70", "10"), ("100", "50", "10"), ("100", "30", "10")] } fritz().update_devices.side_effect = HTTPError("Boom") - assert not await setup_config_entry( + entry = await setup_config_entry( hass, MOCK_CONFIG[FB_DOMAIN][CONF_DEVICES][0], ENTITY_ID, device, fritz ) + assert entry.state is ConfigEntryState.SETUP_RETRY assert fritz().update_devices.call_count == 2 assert fritz().login.call_count == 2 diff --git a/tests/components/fritzbox/test_sensor.py b/tests/components/fritzbox/test_sensor.py index cb136eee993..28d21f9fd39 100644 --- a/tests/components/fritzbox/test_sensor.py +++ b/tests/components/fritzbox/test_sensor.py @@ -1,97 +1,69 @@ """Tests for AVM Fritz!Box sensor component.""" from datetime import timedelta -from unittest.mock import Mock +from unittest.mock import Mock, patch import pytest from requests.exceptions import HTTPError +from syrupy import SnapshotAssertion from homeassistant.components.climate import PRESET_COMFORT, PRESET_ECO from homeassistant.components.fritzbox.const import DOMAIN as FB_DOMAIN -from homeassistant.components.sensor import ( - ATTR_STATE_CLASS, - DOMAIN as SENSOR_DOMAIN, - SensorStateClass, -) -from homeassistant.const import ( - ATTR_FRIENDLY_NAME, - ATTR_UNIT_OF_MEASUREMENT, - CONF_DEVICES, - PERCENTAGE, - STATE_UNKNOWN, - EntityCategory, - UnitOfTemperature, -) +from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN +from homeassistant.config_entries import ConfigEntryState +from homeassistant.const import CONF_DEVICES, STATE_UNKNOWN, Platform from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er from homeassistant.util import dt as dt_util from . import ( + FritzDeviceBinarySensorMock, FritzDeviceClimateMock, FritzDeviceSensorMock, + FritzDeviceSwitchMock, + FritzEntityBaseMock, set_devices, setup_config_entry, ) from .const import CONF_FAKE_NAME, MOCK_CONFIG -from tests.common import async_fire_time_changed +from tests.common import async_fire_time_changed, snapshot_platform ENTITY_ID = f"{SENSOR_DOMAIN}.{CONF_FAKE_NAME}" +@pytest.mark.parametrize( + "device", + [ + FritzDeviceBinarySensorMock, + FritzDeviceClimateMock, + FritzDeviceSensorMock, + FritzDeviceSwitchMock, + ], +) async def test_setup( - hass: HomeAssistant, entity_registry: er.EntityRegistry, fritz: Mock + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, + fritz: Mock, + device: FritzEntityBaseMock, ) -> None: - """Test setup of platform.""" - device = FritzDeviceSensorMock() - assert await setup_config_entry( - hass, MOCK_CONFIG[FB_DOMAIN][CONF_DEVICES][0], ENTITY_ID, device, fritz - ) - await hass.async_block_till_done() + """Test setup of sensor platform for different device types.""" + device = device() - sensors = ( - [ - f"{ENTITY_ID}_temperature", - "1.23", - f"{CONF_FAKE_NAME} Temperature", - UnitOfTemperature.CELSIUS, - SensorStateClass.MEASUREMENT, - None, - ], - [ - f"{ENTITY_ID}_humidity", - "42", - f"{CONF_FAKE_NAME} Humidity", - PERCENTAGE, - SensorStateClass.MEASUREMENT, - None, - ], - [ - f"{ENTITY_ID}_battery", - "23", - f"{CONF_FAKE_NAME} Battery", - PERCENTAGE, - SensorStateClass.MEASUREMENT, - EntityCategory.DIAGNOSTIC, - ], - ) + with patch("homeassistant.components.fritzbox.PLATFORMS", [Platform.SENSOR]): + entry = await setup_config_entry( + hass, MOCK_CONFIG[FB_DOMAIN][CONF_DEVICES][0], ENTITY_ID, device, fritz + ) + assert entry.state is ConfigEntryState.LOADED - for sensor in sensors: - state = hass.states.get(sensor[0]) - assert state - assert state.state == sensor[1] - assert state.attributes[ATTR_FRIENDLY_NAME] == sensor[2] - assert state.attributes[ATTR_UNIT_OF_MEASUREMENT] == sensor[3] - assert state.attributes.get(ATTR_STATE_CLASS) == sensor[4] - entry = entity_registry.async_get(sensor[0]) - assert entry - assert entry.entity_category is sensor[5] + await snapshot_platform(hass, entity_registry, snapshot, entry.entry_id) async def test_update(hass: HomeAssistant, fritz: Mock) -> None: """Test update without error.""" device = FritzDeviceSensorMock() - assert await setup_config_entry( + await setup_config_entry( hass, MOCK_CONFIG[FB_DOMAIN][CONF_DEVICES][0], ENTITY_ID, device, fritz ) assert fritz().update_devices.call_count == 1 @@ -109,9 +81,10 @@ async def test_update_error(hass: HomeAssistant, fritz: Mock) -> None: """Test update with error.""" device = FritzDeviceSensorMock() fritz().update_devices.side_effect = HTTPError("Boom") - assert not await setup_config_entry( + entry = await setup_config_entry( hass, MOCK_CONFIG[FB_DOMAIN][CONF_DEVICES][0], ENTITY_ID, device, fritz ) + assert entry.state is ConfigEntryState.SETUP_RETRY assert fritz().update_devices.call_count == 2 assert fritz().login.call_count == 2 @@ -126,7 +99,7 @@ async def test_update_error(hass: HomeAssistant, fritz: Mock) -> None: async def test_discover_new_device(hass: HomeAssistant, fritz: Mock) -> None: """Test adding new discovered devices during runtime.""" device = FritzDeviceSensorMock() - assert await setup_config_entry( + await setup_config_entry( hass, MOCK_CONFIG[FB_DOMAIN][CONF_DEVICES][0], ENTITY_ID, device, fritz ) @@ -175,7 +148,7 @@ async def test_next_change_sensors( device.nextchange_endperiod = next_changes[0] device.nextchange_temperature = next_changes[1] - assert await setup_config_entry( + await setup_config_entry( hass, MOCK_CONFIG[FB_DOMAIN][CONF_DEVICES][0], ENTITY_ID, device, fritz ) diff --git a/tests/components/fritzbox/test_switch.py b/tests/components/fritzbox/test_switch.py index 511725c663f..cb6b563d344 100644 --- a/tests/components/fritzbox/test_switch.py +++ b/tests/components/fritzbox/test_switch.py @@ -1,33 +1,22 @@ """Tests for AVM Fritz!Box switch component.""" from datetime import timedelta -from unittest.mock import Mock +from unittest.mock import Mock, patch import pytest from requests.exceptions import HTTPError +from syrupy import SnapshotAssertion from homeassistant.components.fritzbox.const import DOMAIN as FB_DOMAIN -from homeassistant.components.sensor import ( - ATTR_STATE_CLASS, - DOMAIN as SENSOR_DOMAIN, - SensorStateClass, -) from homeassistant.components.switch import DOMAIN as SWITCH_DOMAIN +from homeassistant.config_entries import ConfigEntryState from homeassistant.const import ( ATTR_ENTITY_ID, - ATTR_FRIENDLY_NAME, - ATTR_UNIT_OF_MEASUREMENT, CONF_DEVICES, SERVICE_TURN_OFF, SERVICE_TURN_ON, - STATE_ON, STATE_UNAVAILABLE, - EntityCategory, - UnitOfElectricCurrent, - UnitOfElectricPotential, - UnitOfEnergy, - UnitOfPower, - UnitOfTemperature, + Platform, ) from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError @@ -37,89 +26,32 @@ from homeassistant.util import dt as dt_util from . import FritzDeviceSwitchMock, set_devices, setup_config_entry from .const import CONF_FAKE_NAME, MOCK_CONFIG -from tests.common import async_fire_time_changed +from tests.common import async_fire_time_changed, snapshot_platform ENTITY_ID = f"{SWITCH_DOMAIN}.{CONF_FAKE_NAME}" async def test_setup( - hass: HomeAssistant, entity_registry: er.EntityRegistry, fritz: Mock + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, + fritz: Mock, ) -> None: """Test setup of platform.""" device = FritzDeviceSwitchMock() - assert await setup_config_entry( - hass, MOCK_CONFIG[FB_DOMAIN][CONF_DEVICES][0], ENTITY_ID, device, fritz - ) + with patch("homeassistant.components.fritzbox.PLATFORMS", [Platform.SWITCH]): + entry = await setup_config_entry( + hass, MOCK_CONFIG[FB_DOMAIN][CONF_DEVICES][0], ENTITY_ID, device, fritz + ) + assert entry.state is ConfigEntryState.LOADED - state = hass.states.get(ENTITY_ID) - assert state - assert state.state == STATE_ON - assert state.attributes[ATTR_FRIENDLY_NAME] == CONF_FAKE_NAME - assert ATTR_STATE_CLASS not in state.attributes - - state = hass.states.get(f"{ENTITY_ID}_humidity") - assert state is None - - sensors = ( - [ - f"{SENSOR_DOMAIN}.{CONF_FAKE_NAME}_temperature", - "1.23", - f"{CONF_FAKE_NAME} Temperature", - UnitOfTemperature.CELSIUS, - SensorStateClass.MEASUREMENT, - EntityCategory.DIAGNOSTIC, - ], - [ - f"{SENSOR_DOMAIN}.{CONF_FAKE_NAME}_power", - "5.678", - f"{CONF_FAKE_NAME} Power", - UnitOfPower.WATT, - SensorStateClass.MEASUREMENT, - None, - ], - [ - f"{SENSOR_DOMAIN}.{CONF_FAKE_NAME}_energy", - "1.234", - f"{CONF_FAKE_NAME} Energy", - UnitOfEnergy.KILO_WATT_HOUR, - SensorStateClass.TOTAL_INCREASING, - None, - ], - [ - f"{SENSOR_DOMAIN}.{CONF_FAKE_NAME}_voltage", - "230.0", - f"{CONF_FAKE_NAME} Voltage", - UnitOfElectricPotential.VOLT, - SensorStateClass.MEASUREMENT, - None, - ], - [ - f"{SENSOR_DOMAIN}.{CONF_FAKE_NAME}_current", - "0.025", - f"{CONF_FAKE_NAME} Current", - UnitOfElectricCurrent.AMPERE, - SensorStateClass.MEASUREMENT, - None, - ], - ) - - for sensor in sensors: - state = hass.states.get(sensor[0]) - assert state - assert state.state == sensor[1] - assert state.attributes[ATTR_FRIENDLY_NAME] == sensor[2] - assert state.attributes[ATTR_UNIT_OF_MEASUREMENT] == sensor[3] - assert state.attributes[ATTR_STATE_CLASS] == sensor[4] - assert state.attributes[ATTR_STATE_CLASS] == sensor[4] - entry = entity_registry.async_get(sensor[0]) - assert entry - assert entry.entity_category is sensor[5] + await snapshot_platform(hass, entity_registry, snapshot, entry.entry_id) async def test_turn_on(hass: HomeAssistant, fritz: Mock) -> None: """Test turn device on.""" device = FritzDeviceSwitchMock() - assert await setup_config_entry( + await setup_config_entry( hass, MOCK_CONFIG[FB_DOMAIN][CONF_DEVICES][0], ENTITY_ID, device, fritz ) @@ -133,7 +65,7 @@ async def test_turn_off(hass: HomeAssistant, fritz: Mock) -> None: """Test turn device off.""" device = FritzDeviceSwitchMock() - assert await setup_config_entry( + await setup_config_entry( hass, MOCK_CONFIG[FB_DOMAIN][CONF_DEVICES][0], ENTITY_ID, device, fritz ) @@ -149,7 +81,7 @@ async def test_toggle_while_locked(hass: HomeAssistant, fritz: Mock) -> None: device = FritzDeviceSwitchMock() device.lock = True - assert await setup_config_entry( + await setup_config_entry( hass, MOCK_CONFIG[FB_DOMAIN][CONF_DEVICES][0], ENTITY_ID, device, fritz ) @@ -173,7 +105,7 @@ async def test_toggle_while_locked(hass: HomeAssistant, fritz: Mock) -> None: async def test_update(hass: HomeAssistant, fritz: Mock) -> None: """Test update without error.""" device = FritzDeviceSwitchMock() - assert await setup_config_entry( + await setup_config_entry( hass, MOCK_CONFIG[FB_DOMAIN][CONF_DEVICES][0], ENTITY_ID, device, fritz ) assert fritz().update_devices.call_count == 1 @@ -191,9 +123,10 @@ async def test_update_error(hass: HomeAssistant, fritz: Mock) -> None: """Test update with error.""" device = FritzDeviceSwitchMock() fritz().update_devices.side_effect = HTTPError("Boom") - assert not await setup_config_entry( + entry = await setup_config_entry( hass, MOCK_CONFIG[FB_DOMAIN][CONF_DEVICES][0], ENTITY_ID, device, fritz ) + assert entry.state is ConfigEntryState.SETUP_RETRY assert fritz().update_devices.call_count == 2 assert fritz().login.call_count == 2 @@ -211,7 +144,7 @@ async def test_assume_device_unavailable(hass: HomeAssistant, fritz: Mock) -> No device.voltage = 0 device.energy = 0 device.power = 0 - assert await setup_config_entry( + await setup_config_entry( hass, MOCK_CONFIG[FB_DOMAIN][CONF_DEVICES][0], ENTITY_ID, device, fritz ) @@ -223,7 +156,7 @@ async def test_assume_device_unavailable(hass: HomeAssistant, fritz: Mock) -> No async def test_discover_new_device(hass: HomeAssistant, fritz: Mock) -> None: """Test adding new discovered devices during runtime.""" device = FritzDeviceSwitchMock() - assert await setup_config_entry( + await setup_config_entry( hass, MOCK_CONFIG[FB_DOMAIN][CONF_DEVICES][0], ENTITY_ID, device, fritz ) From 09d25f322a99ee07ea62502223ab228eec0294b2 Mon Sep 17 00:00:00 2001 From: Brett Adams Date: Thu, 3 Apr 2025 16:12:41 +1000 Subject: [PATCH 231/505] Bump tesla-fleet-api to v1.0.17 (#142131) bump --- homeassistant/components/tesla_fleet/manifest.json | 2 +- homeassistant/components/teslemetry/manifest.json | 2 +- homeassistant/components/tessie/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 5 files changed, 5 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/tesla_fleet/manifest.json b/homeassistant/components/tesla_fleet/manifest.json index 56dc49ad111..53c8e7d554c 100644 --- a/homeassistant/components/tesla_fleet/manifest.json +++ b/homeassistant/components/tesla_fleet/manifest.json @@ -7,5 +7,5 @@ "documentation": "https://www.home-assistant.io/integrations/tesla_fleet", "iot_class": "cloud_polling", "loggers": ["tesla-fleet-api"], - "requirements": ["tesla-fleet-api==1.0.16"] + "requirements": ["tesla-fleet-api==1.0.17"] } diff --git a/homeassistant/components/teslemetry/manifest.json b/homeassistant/components/teslemetry/manifest.json index cae5a8f3c01..a8f1fd0daec 100644 --- a/homeassistant/components/teslemetry/manifest.json +++ b/homeassistant/components/teslemetry/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/teslemetry", "iot_class": "cloud_polling", "loggers": ["tesla-fleet-api"], - "requirements": ["tesla-fleet-api==1.0.16", "teslemetry-stream==0.6.12"] + "requirements": ["tesla-fleet-api==1.0.17", "teslemetry-stream==0.6.12"] } diff --git a/homeassistant/components/tessie/manifest.json b/homeassistant/components/tessie/manifest.json index 3f96bb226ab..3f71bcb95e3 100644 --- a/homeassistant/components/tessie/manifest.json +++ b/homeassistant/components/tessie/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/tessie", "iot_class": "cloud_polling", "loggers": ["tessie", "tesla-fleet-api"], - "requirements": ["tessie-api==0.1.1", "tesla-fleet-api==1.0.16"] + "requirements": ["tessie-api==0.1.1", "tesla-fleet-api==1.0.17"] } diff --git a/requirements_all.txt b/requirements_all.txt index ccd12e70731..ed270eb4747 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2878,7 +2878,7 @@ temperusb==1.6.1 # homeassistant.components.tesla_fleet # homeassistant.components.teslemetry # homeassistant.components.tessie -tesla-fleet-api==1.0.16 +tesla-fleet-api==1.0.17 # homeassistant.components.powerwall tesla-powerwall==0.5.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 25fa47b9413..f580528a6f9 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2316,7 +2316,7 @@ temperusb==1.6.1 # homeassistant.components.tesla_fleet # homeassistant.components.teslemetry # homeassistant.components.tessie -tesla-fleet-api==1.0.16 +tesla-fleet-api==1.0.17 # homeassistant.components.powerwall tesla-powerwall==0.5.2 From 1860db4632b3f0a5f047790ae4453edce7be7ea7 Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Thu, 3 Apr 2025 08:13:16 +0200 Subject: [PATCH 232/505] Use common state for "Medium" in `iron_os` (#142117) --- homeassistant/components/iron_os/strings.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/iron_os/strings.json b/homeassistant/components/iron_os/strings.json index ddae9a3020f..629f7c32c9b 100644 --- a/homeassistant/components/iron_os/strings.json +++ b/homeassistant/components/iron_os/strings.json @@ -123,7 +123,7 @@ "state": { "off": "[%key:common::state::off%]", "slow": "[%key:component::iron_os::common::slow%]", - "medium": "Medium", + "medium": "[%key:common::state::medium%]", "fast": "[%key:component::iron_os::common::fast%]" } }, From df5cdf7de4a37659e8035d36c911186f7b6f3437 Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Thu, 3 Apr 2025 08:14:02 +0200 Subject: [PATCH 233/505] Use common states for "Low"/"Medium"/"High" in `litterrobot` (#142112) --- homeassistant/components/litterrobot/strings.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/litterrobot/strings.json b/homeassistant/components/litterrobot/strings.json index 052427f3032..55dbc0ea645 100644 --- a/homeassistant/components/litterrobot/strings.json +++ b/homeassistant/components/litterrobot/strings.json @@ -118,9 +118,9 @@ "brightness_level": { "name": "Panel brightness", "state": { - "low": "Low", - "medium": "Medium", - "high": "High" + "low": "[%key:common::state::low%]", + "medium": "[%key:common::state::medium%]", + "high": "[%key:common::state::high%]" } } }, From 03c70e18dffba71b2d054a56c74ab648150dfe47 Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Thu, 3 Apr 2025 08:14:14 +0200 Subject: [PATCH 234/505] Use common states for "Low"/"Medium"/"High" in `roborock` (#142113) --- homeassistant/components/roborock/strings.json | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/roborock/strings.json b/homeassistant/components/roborock/strings.json index 4546856ec8b..d27f4064170 100644 --- a/homeassistant/components/roborock/strings.json +++ b/homeassistant/components/roborock/strings.json @@ -368,12 +368,12 @@ "name": "Mop intensity", "state": { "off": "[%key:common::state::off%]", - "low": "Low", + "low": "[%key:common::state::low%]", "mild": "Mild", - "medium": "Medium", + "medium": "[%key:common::state::medium%]", "moderate": "Moderate", "max": "Max", - "high": "High", + "high": "[%key:common::state::high%]", "intense": "Intense", "custom": "[%key:component::roborock::entity::select::mop_mode::state::custom%]", "custom_water_flow": "Custom water flow", @@ -433,7 +433,7 @@ "off": "[%key:common::state::off%]", "max": "[%key:component::roborock::entity::select::mop_intensity::state::max%]", "max_plus": "Max plus", - "medium": "Medium", + "medium": "[%key:common::state::medium%]", "quiet": "Quiet", "silent": "Silent", "standard": "[%key:component::roborock::entity::select::mop_mode::state::standard%]", From 4a562b5085cab94caae25a3e1b1a6b3432cd09b7 Mon Sep 17 00:00:00 2001 From: elmurato <1382097+elmurato@users.noreply.github.com> Date: Thu, 3 Apr 2025 08:45:06 +0200 Subject: [PATCH 235/505] Improve exception handling in Pterodactyl (#141955) Improve exception handling --- homeassistant/components/pterodactyl/api.py | 47 +++++++------- .../components/pterodactyl/button.py | 12 +++- .../components/pterodactyl/config_flow.py | 6 +- .../components/pterodactyl/coordinator.py | 6 +- .../components/pterodactyl/strings.json | 1 + .../pterodactyl/test_config_flow.py | 64 ++++++------------- 6 files changed, 61 insertions(+), 75 deletions(-) diff --git a/homeassistant/components/pterodactyl/api.py b/homeassistant/components/pterodactyl/api.py index a60962ecf51..40ede9de103 100644 --- a/homeassistant/components/pterodactyl/api.py +++ b/homeassistant/components/pterodactyl/api.py @@ -5,20 +5,16 @@ from enum import StrEnum import logging from pydactyl import PterodactylClient -from pydactyl.exceptions import ( - BadRequestError, - ClientConfigError, - PterodactylApiError, - PydactylError, -) +from pydactyl.exceptions import BadRequestError, PterodactylApiError +from requests.exceptions import ConnectionError, HTTPError from homeassistant.core import HomeAssistant _LOGGER = logging.getLogger(__name__) -class PterodactylConfigurationError(Exception): - """Raised when the configuration is invalid.""" +class PterodactylAuthorizationError(Exception): + """Raised when access to server is unauthorized.""" class PterodactylConnectionError(Exception): @@ -75,13 +71,12 @@ class PterodactylAPI: paginated_response = await self.hass.async_add_executor_job( self.pterodactyl.client.servers.list_servers ) - except ClientConfigError as error: - raise PterodactylConfigurationError(error) from error - except ( - PydactylError, - BadRequestError, - PterodactylApiError, - ) as error: + except (BadRequestError, PterodactylApiError, ConnectionError) as error: + raise PterodactylConnectionError(error) from error + except HTTPError as error: + if error.response.status_code == 401: + raise PterodactylAuthorizationError(error) from error + raise PterodactylConnectionError(error) from error else: game_servers = paginated_response.collect() @@ -108,11 +103,12 @@ class PterodactylAPI: server, utilization = await self.hass.async_add_executor_job( self.get_server_data, identifier ) - except ( - PydactylError, - BadRequestError, - PterodactylApiError, - ) as error: + except (BadRequestError, PterodactylApiError, ConnectionError) as error: + raise PterodactylConnectionError(error) from error + except HTTPError as error: + if error.response.status_code == 401: + raise PterodactylAuthorizationError(error) from error + raise PterodactylConnectionError(error) from error else: data[identifier] = PterodactylData( @@ -145,9 +141,10 @@ class PterodactylAPI: identifier, command, ) - except ( - PydactylError, - BadRequestError, - PterodactylApiError, - ) as error: + except (BadRequestError, PterodactylApiError, ConnectionError) as error: + raise PterodactylConnectionError(error) from error + except HTTPError as error: + if error.response.status_code == 401: + raise PterodactylAuthorizationError(error) from error + raise PterodactylConnectionError(error) from error diff --git a/homeassistant/components/pterodactyl/button.py b/homeassistant/components/pterodactyl/button.py index a1201f3ced5..44d3a6d0a82 100644 --- a/homeassistant/components/pterodactyl/button.py +++ b/homeassistant/components/pterodactyl/button.py @@ -9,7 +9,11 @@ from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from .api import PterodactylCommand, PterodactylConnectionError +from .api import ( + PterodactylAuthorizationError, + PterodactylCommand, + PterodactylConnectionError, +) from .coordinator import PterodactylConfigEntry, PterodactylCoordinator from .entity import PterodactylEntity @@ -94,5 +98,9 @@ class PterodactylButtonEntity(PterodactylEntity, ButtonEntity): ) except PterodactylConnectionError as err: raise HomeAssistantError( - f"Failed to send action '{self.entity_description.key}'" + f"Failed to send action '{self.entity_description.key}': Connection error" + ) from err + except PterodactylAuthorizationError as err: + raise HomeAssistantError( + f"Failed to send action '{self.entity_description.key}': Unauthorized" ) from err diff --git a/homeassistant/components/pterodactyl/config_flow.py b/homeassistant/components/pterodactyl/config_flow.py index a36069d2bb9..e78ae776123 100644 --- a/homeassistant/components/pterodactyl/config_flow.py +++ b/homeassistant/components/pterodactyl/config_flow.py @@ -13,7 +13,7 @@ from homeassistant.const import CONF_API_KEY, CONF_URL from .api import ( PterodactylAPI, - PterodactylConfigurationError, + PterodactylAuthorizationError, PterodactylConnectionError, ) from .const import DOMAIN @@ -49,7 +49,9 @@ class PterodactylConfigFlow(ConfigFlow, domain=DOMAIN): try: await api.async_init() - except (PterodactylConfigurationError, PterodactylConnectionError): + except PterodactylAuthorizationError: + errors["base"] = "invalid_auth" + except PterodactylConnectionError: errors["base"] = "cannot_connect" except Exception: _LOGGER.exception("Unexpected exception occurred during config flow") diff --git a/homeassistant/components/pterodactyl/coordinator.py b/homeassistant/components/pterodactyl/coordinator.py index 36456ade630..c8456ce9e55 100644 --- a/homeassistant/components/pterodactyl/coordinator.py +++ b/homeassistant/components/pterodactyl/coordinator.py @@ -12,7 +12,7 @@ from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, Upda from .api import ( PterodactylAPI, - PterodactylConfigurationError, + PterodactylAuthorizationError, PterodactylConnectionError, PterodactylData, ) @@ -55,12 +55,12 @@ class PterodactylCoordinator(DataUpdateCoordinator[dict[str, PterodactylData]]): try: await self.api.async_init() - except PterodactylConfigurationError as error: + except (PterodactylAuthorizationError, PterodactylConnectionError) as error: raise UpdateFailed(error) from error async def _async_update_data(self) -> dict[str, PterodactylData]: """Get updated data from the Pterodactyl server.""" try: return await self.api.async_get_data() - except PterodactylConnectionError as error: + except (PterodactylAuthorizationError, PterodactylConnectionError) as error: raise UpdateFailed(error) from error diff --git a/homeassistant/components/pterodactyl/strings.json b/homeassistant/components/pterodactyl/strings.json index 97b33566f39..fe2b7730e1b 100644 --- a/homeassistant/components/pterodactyl/strings.json +++ b/homeassistant/components/pterodactyl/strings.json @@ -14,6 +14,7 @@ }, "error": { "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", "unknown": "[%key:common::config_flow::error::unknown%]" }, "abort": { diff --git a/tests/components/pterodactyl/test_config_flow.py b/tests/components/pterodactyl/test_config_flow.py index 14bb2d2f69f..3cb7f1c19d4 100644 --- a/tests/components/pterodactyl/test_config_flow.py +++ b/tests/components/pterodactyl/test_config_flow.py @@ -1,8 +1,10 @@ """Test the Pterodactyl config flow.""" from pydactyl import PterodactylClient -from pydactyl.exceptions import ClientConfigError, PterodactylApiError +from pydactyl.exceptions import BadRequestError, PterodactylApiError import pytest +from requests.exceptions import HTTPError +from requests.models import Response from homeassistant.components.pterodactyl.const import DOMAIN from homeassistant.config_entries import SOURCE_USER @@ -14,6 +16,14 @@ from .conftest import TEST_URL, TEST_USER_INPUT from tests.common import MockConfigEntry +def mock_response(): + """Mock HTTP response.""" + mock = Response() + mock.status_code = 401 + + return mock + + @pytest.mark.usefixtures("mock_pterodactyl", "mock_setup_entry") async def test_full_flow(hass: HomeAssistant) -> None: """Test full flow without errors.""" @@ -36,18 +46,21 @@ async def test_full_flow(hass: HomeAssistant) -> None: @pytest.mark.usefixtures("mock_setup_entry") @pytest.mark.parametrize( - "exception_type", + ("exception_type", "expected_error"), [ - ClientConfigError, - PterodactylApiError, + (PterodactylApiError, "cannot_connect"), + (BadRequestError, "cannot_connect"), + (Exception, "unknown"), + (HTTPError(response=mock_response()), "invalid_auth"), ], ) -async def test_recovery_after_api_error( +async def test_recovery_after_error( hass: HomeAssistant, - exception_type, + exception_type: Exception, + expected_error: str, mock_pterodactyl: PterodactylClient, ) -> None: - """Test recovery after an API error.""" + """Test recovery after an error.""" result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_USER} ) @@ -63,42 +76,7 @@ async def test_recovery_after_api_error( await hass.async_block_till_done() assert result["type"] is FlowResultType.FORM - assert result["errors"] == {"base": "cannot_connect"} - - mock_pterodactyl.reset_mock(side_effect=True) - - result = await hass.config_entries.flow.async_configure( - flow_id=result["flow_id"], user_input=TEST_USER_INPUT - ) - await hass.async_block_till_done() - - assert result["type"] is FlowResultType.CREATE_ENTRY - assert result["title"] == TEST_URL - assert result["data"] == TEST_USER_INPUT - - -@pytest.mark.usefixtures("mock_setup_entry") -async def test_recovery_after_unknown_error( - hass: HomeAssistant, - mock_pterodactyl: PterodactylClient, -) -> None: - """Test recovery after an API error.""" - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": SOURCE_USER} - ) - assert result["type"] is FlowResultType.FORM - assert result["errors"] == {} - - mock_pterodactyl.client.servers.list_servers.side_effect = Exception - - result = await hass.config_entries.flow.async_configure( - flow_id=result["flow_id"], - user_input=TEST_USER_INPUT, - ) - await hass.async_block_till_done() - - assert result["type"] is FlowResultType.FORM - assert result["errors"] == {"base": "unknown"} + assert result["errors"] == {"base": expected_error} mock_pterodactyl.reset_mock(side_effect=True) From db44ed845da912ff1f03a1740d9ce9ceea064067 Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Thu, 3 Apr 2025 09:24:48 +0200 Subject: [PATCH 236/505] Use common states for "Low"/"Medium"/"High" in `ecovacs` (#142140) --- homeassistant/components/ecovacs/strings.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/ecovacs/strings.json b/homeassistant/components/ecovacs/strings.json index 515eb1c3141..f74c8b90f00 100644 --- a/homeassistant/components/ecovacs/strings.json +++ b/homeassistant/components/ecovacs/strings.json @@ -176,9 +176,9 @@ "water_amount": { "name": "Water flow level", "state": { - "high": "High", - "low": "Low", - "medium": "Medium", + "high": "[%key:common::state::high%]", + "low": "[%key:common::state::low%]", + "medium": "[%key:common::state::medium%]", "ultrahigh": "Ultrahigh" } }, From dfa180ba64e0c21ac9fc981b35440323926794f4 Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Thu, 3 Apr 2025 09:33:06 +0200 Subject: [PATCH 237/505] Use common states for "Low"/"Medium"/"High" in `home_connect` (#142142) * Use common states for "Low"/"Medium"/"High" in `home_connect` Replaces two occurrences of "Low"/"Medium"/"High" each with the (new) common strings. * Replace internal references with common ones --- .../components/home_connect/strings.json | 24 +++++++++---------- 1 file changed, 12 insertions(+), 12 deletions(-) diff --git a/homeassistant/components/home_connect/strings.json b/homeassistant/components/home_connect/strings.json index ad7f67968f5..dfbe1ca26fe 100644 --- a/homeassistant/components/home_connect/strings.json +++ b/homeassistant/components/home_connect/strings.json @@ -487,9 +487,9 @@ }, "warming_level": { "options": { - "cooking_oven_enum_type_warming_level_low": "Low", - "cooking_oven_enum_type_warming_level_medium": "Medium", - "cooking_oven_enum_type_warming_level_high": "High" + "cooking_oven_enum_type_warming_level_low": "[%key:common::state::low%]", + "cooking_oven_enum_type_warming_level_medium": "[%key:common::state::medium%]", + "cooking_oven_enum_type_warming_level_high": "[%key:common::state::high%]" } }, "washer_temperature": { @@ -522,9 +522,9 @@ "laundry_care_washer_enum_type_spin_speed_r_p_m_1400": "1400 rpm", "laundry_care_washer_enum_type_spin_speed_r_p_m_1600": "1600 rpm", "laundry_care_washer_enum_type_spin_speed_ul_off": "[%key:common::state::off%]", - "laundry_care_washer_enum_type_spin_speed_ul_low": "Low", - "laundry_care_washer_enum_type_spin_speed_ul_medium": "Medium", - "laundry_care_washer_enum_type_spin_speed_ul_high": "High" + "laundry_care_washer_enum_type_spin_speed_ul_low": "[%key:common::state::low%]", + "laundry_care_washer_enum_type_spin_speed_ul_medium": "[%key:common::state::medium%]", + "laundry_care_washer_enum_type_spin_speed_ul_high": "[%key:common::state::high%]" } }, "vario_perfect": { @@ -1468,9 +1468,9 @@ "warming_level": { "name": "[%key:component::home_connect::services::set_program_and_options::fields::cooking_oven_option_warming_level::name%]", "state": { - "cooking_oven_enum_type_warming_level_low": "[%key:component::home_connect::selector::warming_level::options::cooking_oven_enum_type_warming_level_low%]", - "cooking_oven_enum_type_warming_level_medium": "[%key:component::home_connect::selector::warming_level::options::cooking_oven_enum_type_warming_level_medium%]", - "cooking_oven_enum_type_warming_level_high": "[%key:component::home_connect::selector::warming_level::options::cooking_oven_enum_type_warming_level_high%]" + "cooking_oven_enum_type_warming_level_low": "[%key:common::state::low%]", + "cooking_oven_enum_type_warming_level_medium": "[%key:common::state::medium%]", + "cooking_oven_enum_type_warming_level_high": "[%key:common::state::high%]" } }, "washer_temperature": { @@ -1505,9 +1505,9 @@ "laundry_care_washer_enum_type_spin_speed_r_p_m_1400": "[%key:component::home_connect::selector::spin_speed::options::laundry_care_washer_enum_type_spin_speed_r_p_m_1400%]", "laundry_care_washer_enum_type_spin_speed_r_p_m_1600": "[%key:component::home_connect::selector::spin_speed::options::laundry_care_washer_enum_type_spin_speed_r_p_m_1600%]", "laundry_care_washer_enum_type_spin_speed_ul_off": "[%key:common::state::off%]", - "laundry_care_washer_enum_type_spin_speed_ul_low": "[%key:component::home_connect::selector::spin_speed::options::laundry_care_washer_enum_type_spin_speed_ul_low%]", - "laundry_care_washer_enum_type_spin_speed_ul_medium": "[%key:component::home_connect::selector::spin_speed::options::laundry_care_washer_enum_type_spin_speed_ul_medium%]", - "laundry_care_washer_enum_type_spin_speed_ul_high": "[%key:component::home_connect::selector::spin_speed::options::laundry_care_washer_enum_type_spin_speed_ul_high%]" + "laundry_care_washer_enum_type_spin_speed_ul_low": "[%key:common::state::low%]", + "laundry_care_washer_enum_type_spin_speed_ul_medium": "[%key:common::state::medium%]", + "laundry_care_washer_enum_type_spin_speed_ul_high": "[%key:common::state::high%]" } }, "vario_perfect": { From 0b61b6233436af6c027ce0ca9549a2d2a7851e45 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 2 Apr 2025 21:38:50 -1000 Subject: [PATCH 238/505] Avoid logging a warning when replacing an ignored config entry (#142114) Replacing an ignored config entry with one from the user flow should not generate a warning. We should only warn if we are replacing a usable config entry. Followup to adjust the warning added in #130567 cc @epenet --- homeassistant/config_entries.py | 6 +++++- tests/test_config_entries.py | 9 ++++++--- 2 files changed, 11 insertions(+), 4 deletions(-) diff --git a/homeassistant/config_entries.py b/homeassistant/config_entries.py index 81df30210e1..73393ba75d5 100644 --- a/homeassistant/config_entries.py +++ b/homeassistant/config_entries.py @@ -1612,7 +1612,11 @@ class ConfigEntriesFlowManager( result["handler"], flow.unique_id ) - if existing_entry is not None and flow.handler != "mobile_app": + if ( + existing_entry is not None + and flow.handler != "mobile_app" + and existing_entry.source != SOURCE_IGNORE + ): # This causes the old entry to be removed and replaced, when the flow # should instead be aborted. # In case of manual flows, integrations should implement options, reauth, diff --git a/tests/test_config_entries.py b/tests/test_config_entries.py index 6147102f68f..2d9d18a067d 100644 --- a/tests/test_config_entries.py +++ b/tests/test_config_entries.py @@ -8797,15 +8797,17 @@ async def test_add_description_placeholder_automatically_not_overwrites( @pytest.mark.parametrize( - ("domain", "expected_log"), + ("domain", "source", "expected_log"), [ - ("some_integration", True), - ("mobile_app", False), + ("some_integration", config_entries.SOURCE_USER, True), + ("some_integration", config_entries.SOURCE_IGNORE, False), + ("mobile_app", config_entries.SOURCE_USER, False), ], ) async def test_create_entry_existing_unique_id( hass: HomeAssistant, domain: str, + source: str, expected_log: bool, caplog: pytest.LogCaptureFixture, ) -> None: @@ -8816,6 +8818,7 @@ async def test_create_entry_existing_unique_id( entry_id="01J915Q6T9F6G5V0QJX6HBC94T", data={"host": "any", "port": 123}, unique_id="mock-unique-id", + source=source, ) entry.add_to_hass(hass) From ec396513a236e014c8af40524492654265cb09ef Mon Sep 17 00:00:00 2001 From: G Johansson Date: Thu, 3 Apr 2025 09:43:00 +0200 Subject: [PATCH 239/505] Bump pysmhi to 1.0.1 (#142111) --- homeassistant/components/smhi/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/smhi/manifest.json b/homeassistant/components/smhi/manifest.json index fc3af634764..89443fc7e27 100644 --- a/homeassistant/components/smhi/manifest.json +++ b/homeassistant/components/smhi/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/smhi", "iot_class": "cloud_polling", "loggers": ["pysmhi"], - "requirements": ["pysmhi==1.0.0"] + "requirements": ["pysmhi==1.0.1"] } diff --git a/requirements_all.txt b/requirements_all.txt index ed270eb4747..7ab60f23782 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2325,7 +2325,7 @@ pysmartthings==3.0.1 pysmarty2==0.10.2 # homeassistant.components.smhi -pysmhi==1.0.0 +pysmhi==1.0.1 # homeassistant.components.edl21 pysml==0.0.12 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index f580528a6f9..a60f8d913e3 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1895,7 +1895,7 @@ pysmartthings==3.0.1 pysmarty2==0.10.2 # homeassistant.components.smhi -pysmhi==1.0.0 +pysmhi==1.0.1 # homeassistant.components.edl21 pysml==0.0.12 From 98c56bce4b07fd0c55f37409f5f121e0f50bbfcc Mon Sep 17 00:00:00 2001 From: Arie Catsman <120491684+catsmanac@users.noreply.github.com> Date: Thu, 3 Apr 2025 09:46:09 +0200 Subject: [PATCH 240/505] Bump pyenphase to 1.25.5 (#142107) --- homeassistant/components/enphase_envoy/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/enphase_envoy/manifest.json b/homeassistant/components/enphase_envoy/manifest.json index e51a7427504..88183fe4cfd 100644 --- a/homeassistant/components/enphase_envoy/manifest.json +++ b/homeassistant/components/enphase_envoy/manifest.json @@ -6,7 +6,7 @@ "documentation": "https://www.home-assistant.io/integrations/enphase_envoy", "iot_class": "local_polling", "loggers": ["pyenphase"], - "requirements": ["pyenphase==1.25.1"], + "requirements": ["pyenphase==1.25.5"], "zeroconf": [ { "type": "_enphase-envoy._tcp.local." diff --git a/requirements_all.txt b/requirements_all.txt index 7ab60f23782..c43c9db93c6 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1948,7 +1948,7 @@ pyeiscp==0.0.7 pyemoncms==0.1.1 # homeassistant.components.enphase_envoy -pyenphase==1.25.1 +pyenphase==1.25.5 # homeassistant.components.envisalink pyenvisalink==4.7 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index a60f8d913e3..5f6810fd248 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1590,7 +1590,7 @@ pyeiscp==0.0.7 pyemoncms==0.1.1 # homeassistant.components.enphase_envoy -pyenphase==1.25.1 +pyenphase==1.25.5 # homeassistant.components.everlights pyeverlights==0.1.0 From 1d694450ef2ecf4922b7bbc3edb473f783a3c451 Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Thu, 3 Apr 2025 09:46:49 +0200 Subject: [PATCH 241/505] Use common states for "Low" and "High" in `balboa` (#142150) --- homeassistant/components/balboa/strings.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/balboa/strings.json b/homeassistant/components/balboa/strings.json index 784ce8533a8..8297e2e3b9f 100644 --- a/homeassistant/components/balboa/strings.json +++ b/homeassistant/components/balboa/strings.json @@ -103,8 +103,8 @@ "temperature_range": { "name": "Temperature range", "state": { - "low": "Low", - "high": "High" + "low": "[%key:common::state::low%]", + "high": "[%key:common::state::high%]" } } }, From c2eb72fce486bc4fb6ace0e3c3bd04b0e96fc2bc Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Thu, 3 Apr 2025 09:47:54 +0200 Subject: [PATCH 242/505] Use common states for "Low" and "High" in `yale_smart_alarm` (#142149) --- homeassistant/components/yale_smart_alarm/strings.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/yale_smart_alarm/strings.json b/homeassistant/components/yale_smart_alarm/strings.json index ebcf0b3af63..fd8d403da8d 100644 --- a/homeassistant/components/yale_smart_alarm/strings.json +++ b/homeassistant/components/yale_smart_alarm/strings.json @@ -71,8 +71,8 @@ "volume": { "name": "Volume", "state": { - "high": "High", - "low": "Low", + "high": "[%key:common::state::high%]", + "low": "[%key:common::state::low%]", "off": "[%key:common::state::off%]" } } From b7bc9607a2972301093f6b110c17db1ab0876c24 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Thu, 3 Apr 2025 10:21:26 +0200 Subject: [PATCH 243/505] Fix lying comment in ConfigEntriesFlowManager.async_finish_flow (#142146) --- homeassistant/config_entries.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/config_entries.py b/homeassistant/config_entries.py index 73393ba75d5..016b199744c 100644 --- a/homeassistant/config_entries.py +++ b/homeassistant/config_entries.py @@ -1526,7 +1526,7 @@ class ConfigEntriesFlowManager( ir.async_delete_issue(self.hass, HOMEASSISTANT_DOMAIN, issue_id) if result["type"] != data_entry_flow.FlowResultType.CREATE_ENTRY: - # If there's an ignored config entry with a matching unique ID, + # If there's a config entry with a matching unique ID, # update the discovery key. if ( (discovery_key := flow.context.get("discovery_key")) From 8b3a43258dd673ae4db714e983e253d92eff0667 Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Thu, 3 Apr 2025 10:31:45 +0200 Subject: [PATCH 244/505] Use common states for "Low" and "High" in `dsmr_reader` (#142159) --- homeassistant/components/dsmr_reader/strings.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/dsmr_reader/strings.json b/homeassistant/components/dsmr_reader/strings.json index 90cf0533a72..d405898a393 100644 --- a/homeassistant/components/dsmr_reader/strings.json +++ b/homeassistant/components/dsmr_reader/strings.json @@ -140,8 +140,8 @@ "electricity_tariff": { "name": "Electricity tariff", "state": { - "low": "Low", - "high": "High" + "low": "[%key:common::state::low%]", + "high": "[%key:common::state::high%]" } }, "power_failure_count": { From 934e81db43f5b9b06ba4cff2dab83170e8652671 Mon Sep 17 00:00:00 2001 From: Retha Runolfsson <137745329+zerzhang@users.noreply.github.com> Date: Thu, 3 Apr 2025 17:48:16 +0800 Subject: [PATCH 245/505] Bump PySwitchBot to 0.59.0 (#142166) update pyswitchbot to 0590 --- homeassistant/components/switchbot/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/switchbot/manifest.json b/homeassistant/components/switchbot/manifest.json index d9f6f98d1fd..3c68facf1e9 100644 --- a/homeassistant/components/switchbot/manifest.json +++ b/homeassistant/components/switchbot/manifest.json @@ -39,5 +39,5 @@ "documentation": "https://www.home-assistant.io/integrations/switchbot", "iot_class": "local_push", "loggers": ["switchbot"], - "requirements": ["PySwitchbot==0.58.0"] + "requirements": ["PySwitchbot==0.59.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index c43c9db93c6..51c5e7296a0 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -84,7 +84,7 @@ PyQRCode==1.2.1 PyRMVtransport==0.3.3 # homeassistant.components.switchbot -PySwitchbot==0.58.0 +PySwitchbot==0.59.0 # homeassistant.components.switchmate PySwitchmate==0.5.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 5f6810fd248..83ff3ab8315 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -81,7 +81,7 @@ PyQRCode==1.2.1 PyRMVtransport==0.3.3 # homeassistant.components.switchbot -PySwitchbot==0.58.0 +PySwitchbot==0.59.0 # homeassistant.components.syncthru PySyncThru==0.8.0 From 7a9a4db8d7d5859edaf6a884f4133cb6cfaae11d Mon Sep 17 00:00:00 2001 From: Sanjay Govind Date: Thu, 3 Apr 2025 23:05:08 +1300 Subject: [PATCH 246/505] Add diagnostics for bosch alam integration (#142165) * add diagnostics to bosch_alarm * use snapshot --- .../components/bosch_alarm/diagnostics.py | 73 +++++ tests/components/bosch_alarm/conftest.py | 68 +++- .../snapshots/test_diagnostics.ambr | 290 ++++++++++++++++++ .../bosch_alarm/test_diagnostics.py | 32 ++ 4 files changed, 461 insertions(+), 2 deletions(-) create mode 100644 homeassistant/components/bosch_alarm/diagnostics.py create mode 100644 tests/components/bosch_alarm/snapshots/test_diagnostics.ambr create mode 100644 tests/components/bosch_alarm/test_diagnostics.py diff --git a/homeassistant/components/bosch_alarm/diagnostics.py b/homeassistant/components/bosch_alarm/diagnostics.py new file mode 100644 index 00000000000..2e93052ea95 --- /dev/null +++ b/homeassistant/components/bosch_alarm/diagnostics.py @@ -0,0 +1,73 @@ +"""Diagnostics for bosch alarm.""" + +from typing import Any + +from homeassistant.components.diagnostics import async_redact_data +from homeassistant.const import CONF_PASSWORD +from homeassistant.core import HomeAssistant + +from . import BoschAlarmConfigEntry +from .const import CONF_INSTALLER_CODE, CONF_USER_CODE + +TO_REDACT = [CONF_INSTALLER_CODE, CONF_USER_CODE, CONF_PASSWORD] + + +async def async_get_config_entry_diagnostics( + hass: HomeAssistant, entry: BoschAlarmConfigEntry +) -> dict[str, Any]: + """Return diagnostics for a config entry.""" + + return { + "entry_data": async_redact_data(entry.data, TO_REDACT), + "data": { + "model": entry.runtime_data.model, + "serial_number": entry.runtime_data.serial_number, + "protocol_version": entry.runtime_data.protocol_version, + "firmware_version": entry.runtime_data.firmware_version, + "areas": [ + { + "id": area_id, + "name": area.name, + "all_ready": area.all_ready, + "part_ready": area.part_ready, + "faults": area.faults, + "alarms": area.alarms, + "disarmed": area.is_disarmed(), + "arming": area.is_arming(), + "pending": area.is_pending(), + "part_armed": area.is_part_armed(), + "all_armed": area.is_all_armed(), + "armed": area.is_armed(), + "triggered": area.is_triggered(), + } + for area_id, area in entry.runtime_data.areas.items() + ], + "points": [ + { + "id": point_id, + "name": point.name, + "open": point.is_open(), + "normal": point.is_normal(), + } + for point_id, point in entry.runtime_data.points.items() + ], + "doors": [ + { + "id": door_id, + "name": door.name, + "open": door.is_open(), + "locked": door.is_locked(), + } + for door_id, door in entry.runtime_data.doors.items() + ], + "outputs": [ + { + "id": output_id, + "name": output.name, + "active": output.is_active(), + } + for output_id, output in entry.runtime_data.outputs.items() + ], + "history_events": entry.runtime_data.events, + }, + } diff --git a/tests/components/bosch_alarm/conftest.py b/tests/components/bosch_alarm/conftest.py index 45ec0072a37..8358624b003 100644 --- a/tests/components/bosch_alarm/conftest.py +++ b/tests/components/bosch_alarm/conftest.py @@ -4,7 +4,7 @@ from collections.abc import Generator from typing import Any from unittest.mock import AsyncMock, patch -from bosch_alarm_mode2.panel import Area +from bosch_alarm_mode2.panel import Area, Door, Output, Point from bosch_alarm_mode2.utils import Observable import pytest @@ -78,14 +78,65 @@ def mock_setup_entry() -> Generator[AsyncMock]: yield mock_setup_entry +@pytest.fixture +def points() -> Generator[dict[int, Point]]: + """Define a mocked door.""" + names = [ + "Window", + "Door", + "Motion Detector", + "CO Detector", + "Smoke Detector", + "Glassbreak Sensor", + "Bedroom", + ] + points = {} + for i, name in enumerate(names): + mock = AsyncMock(spec=Point) + mock.name = name + mock.status_observer = AsyncMock(spec=Observable) + mock.is_open.return_value = False + mock.is_normal.return_value = True + points[i] = mock + return points + + +@pytest.fixture +def output() -> Generator[Output]: + """Define a mocked output.""" + mock = AsyncMock(spec=Output) + mock.name = "Output A" + mock.status_observer = AsyncMock(spec=Observable) + mock.is_active.return_value = False + return mock + + +@pytest.fixture +def door() -> Generator[Door]: + """Define a mocked door.""" + mock = AsyncMock(spec=Door) + mock.name = "Main Door" + mock.status_observer = AsyncMock(spec=Observable) + mock.is_open.return_value = False + mock.is_locked.return_value = True + return mock + + @pytest.fixture def area() -> Generator[Area]: """Define a mocked area.""" mock = AsyncMock(spec=Area) mock.name = "Area1" mock.status_observer = AsyncMock(spec=Observable) + mock.alarm_observer = AsyncMock(spec=Observable) + mock.ready_observer = AsyncMock(spec=Observable) + mock.alarms = [] + mock.faults = [] + mock.all_ready = True + mock.part_ready = True mock.is_triggered.return_value = False mock.is_disarmed.return_value = True + mock.is_armed.return_value = False mock.is_arming.return_value = False mock.is_pending.return_value = False mock.is_part_armed.return_value = False @@ -95,7 +146,12 @@ def area() -> Generator[Area]: @pytest.fixture def mock_panel( - area: AsyncMock, model_name: str, serial_number: str | None + area: AsyncMock, + door: AsyncMock, + output: AsyncMock, + points: dict[int, AsyncMock], + model_name: str, + serial_number: str | None, ) -> Generator[AsyncMock]: """Define a fixture to set up Bosch Alarm.""" with ( @@ -106,10 +162,18 @@ def mock_panel( ): client = mock_panel.return_value client.areas = {1: area} + client.doors = {1: door} + client.outputs = {1: output} + client.points = points client.model = model_name + client.faults = [] + client.events = [] client.firmware_version = "1.0.0" + client.protocol_version = "1.0.0" client.serial_number = serial_number client.connection_status_observer = AsyncMock(spec=Observable) + client.faults_observer = AsyncMock(spec=Observable) + client.history_observer = AsyncMock(spec=Observable) yield client diff --git a/tests/components/bosch_alarm/snapshots/test_diagnostics.ambr b/tests/components/bosch_alarm/snapshots/test_diagnostics.ambr new file mode 100644 index 00000000000..23ea722325f --- /dev/null +++ b/tests/components/bosch_alarm/snapshots/test_diagnostics.ambr @@ -0,0 +1,290 @@ +# serializer version: 1 +# name: test_diagnostics[amax_3000] + dict({ + 'data': dict({ + 'areas': list([ + dict({ + 'alarms': list([ + ]), + 'all_armed': False, + 'all_ready': True, + 'armed': False, + 'arming': False, + 'disarmed': True, + 'faults': list([ + ]), + 'id': 1, + 'name': 'Area1', + 'part_armed': False, + 'part_ready': True, + 'pending': False, + 'triggered': False, + }), + ]), + 'doors': list([ + dict({ + 'id': 1, + 'locked': True, + 'name': 'Main Door', + 'open': False, + }), + ]), + 'firmware_version': '1.0.0', + 'history_events': list([ + ]), + 'model': 'AMAX 3000', + 'outputs': list([ + dict({ + 'active': False, + 'id': 1, + 'name': 'Output A', + }), + ]), + 'points': list([ + dict({ + 'id': 0, + 'name': 'Window', + 'normal': True, + 'open': False, + }), + dict({ + 'id': 1, + 'name': 'Door', + 'normal': True, + 'open': False, + }), + dict({ + 'id': 2, + 'name': 'Motion Detector', + 'normal': True, + 'open': False, + }), + dict({ + 'id': 3, + 'name': 'CO Detector', + 'normal': True, + 'open': False, + }), + dict({ + 'id': 4, + 'name': 'Smoke Detector', + 'normal': True, + 'open': False, + }), + dict({ + 'id': 5, + 'name': 'Glassbreak Sensor', + 'normal': True, + 'open': False, + }), + dict({ + 'id': 6, + 'name': 'Bedroom', + 'normal': True, + 'open': False, + }), + ]), + 'protocol_version': '1.0.0', + 'serial_number': None, + }), + 'entry_data': dict({ + 'host': '0.0.0.0', + 'installer_code': '**REDACTED**', + 'model': 'AMAX 3000', + 'password': '**REDACTED**', + 'port': 7700, + }), + }) +# --- +# name: test_diagnostics[b5512] + dict({ + 'data': dict({ + 'areas': list([ + dict({ + 'alarms': list([ + ]), + 'all_armed': False, + 'all_ready': True, + 'armed': False, + 'arming': False, + 'disarmed': True, + 'faults': list([ + ]), + 'id': 1, + 'name': 'Area1', + 'part_armed': False, + 'part_ready': True, + 'pending': False, + 'triggered': False, + }), + ]), + 'doors': list([ + dict({ + 'id': 1, + 'locked': True, + 'name': 'Main Door', + 'open': False, + }), + ]), + 'firmware_version': '1.0.0', + 'history_events': list([ + ]), + 'model': 'B5512 (US1B)', + 'outputs': list([ + dict({ + 'active': False, + 'id': 1, + 'name': 'Output A', + }), + ]), + 'points': list([ + dict({ + 'id': 0, + 'name': 'Window', + 'normal': True, + 'open': False, + }), + dict({ + 'id': 1, + 'name': 'Door', + 'normal': True, + 'open': False, + }), + dict({ + 'id': 2, + 'name': 'Motion Detector', + 'normal': True, + 'open': False, + }), + dict({ + 'id': 3, + 'name': 'CO Detector', + 'normal': True, + 'open': False, + }), + dict({ + 'id': 4, + 'name': 'Smoke Detector', + 'normal': True, + 'open': False, + }), + dict({ + 'id': 5, + 'name': 'Glassbreak Sensor', + 'normal': True, + 'open': False, + }), + dict({ + 'id': 6, + 'name': 'Bedroom', + 'normal': True, + 'open': False, + }), + ]), + 'protocol_version': '1.0.0', + 'serial_number': None, + }), + 'entry_data': dict({ + 'host': '0.0.0.0', + 'model': 'B5512 (US1B)', + 'password': '**REDACTED**', + 'port': 7700, + }), + }) +# --- +# name: test_diagnostics[solution_3000] + dict({ + 'data': dict({ + 'areas': list([ + dict({ + 'alarms': list([ + ]), + 'all_armed': False, + 'all_ready': True, + 'armed': False, + 'arming': False, + 'disarmed': True, + 'faults': list([ + ]), + 'id': 1, + 'name': 'Area1', + 'part_armed': False, + 'part_ready': True, + 'pending': False, + 'triggered': False, + }), + ]), + 'doors': list([ + dict({ + 'id': 1, + 'locked': True, + 'name': 'Main Door', + 'open': False, + }), + ]), + 'firmware_version': '1.0.0', + 'history_events': list([ + ]), + 'model': 'Solution 3000', + 'outputs': list([ + dict({ + 'active': False, + 'id': 1, + 'name': 'Output A', + }), + ]), + 'points': list([ + dict({ + 'id': 0, + 'name': 'Window', + 'normal': True, + 'open': False, + }), + dict({ + 'id': 1, + 'name': 'Door', + 'normal': True, + 'open': False, + }), + dict({ + 'id': 2, + 'name': 'Motion Detector', + 'normal': True, + 'open': False, + }), + dict({ + 'id': 3, + 'name': 'CO Detector', + 'normal': True, + 'open': False, + }), + dict({ + 'id': 4, + 'name': 'Smoke Detector', + 'normal': True, + 'open': False, + }), + dict({ + 'id': 5, + 'name': 'Glassbreak Sensor', + 'normal': True, + 'open': False, + }), + dict({ + 'id': 6, + 'name': 'Bedroom', + 'normal': True, + 'open': False, + }), + ]), + 'protocol_version': '1.0.0', + 'serial_number': '1234567890', + }), + 'entry_data': dict({ + 'host': '0.0.0.0', + 'model': 'Solution 3000', + 'port': 7700, + 'user_code': '**REDACTED**', + }), + }) +# --- diff --git a/tests/components/bosch_alarm/test_diagnostics.py b/tests/components/bosch_alarm/test_diagnostics.py new file mode 100644 index 00000000000..3e10878bd07 --- /dev/null +++ b/tests/components/bosch_alarm/test_diagnostics.py @@ -0,0 +1,32 @@ +"""Test the Bosch Alarm diagnostics.""" + +from typing import Any +from unittest.mock import AsyncMock + +from syrupy.assertion import SnapshotAssertion + +from homeassistant.core import HomeAssistant + +from . import setup_integration + +from tests.common import MockConfigEntry +from tests.components.diagnostics import get_diagnostics_for_config_entry +from tests.typing import ClientSessionGenerator + + +async def test_diagnostics( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + mock_panel: AsyncMock, + area: AsyncMock, + model_name: str, + serial_number: str, + snapshot: SnapshotAssertion, + mock_config_entry: MockConfigEntry, + config_flow_data: dict[str, Any], +) -> None: + """Test generating diagnostics for bosch alarm.""" + await setup_integration(hass, mock_config_entry) + + diag = await get_diagnostics_for_config_entry(hass, hass_client, mock_config_entry) + assert diag == snapshot From cf005feace6ed047597bddd4500795c5453bc450 Mon Sep 17 00:00:00 2001 From: Paul Bottein Date: Thu, 3 Apr 2025 13:13:52 +0200 Subject: [PATCH 247/505] Add translation for hassio update entity name (#142090) --- homeassistant/components/hassio/strings.json | 5 +++++ homeassistant/components/hassio/update.py | 2 +- 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/hassio/strings.json b/homeassistant/components/hassio/strings.json index cc7cfdd5f2c..68a747eb16d 100644 --- a/homeassistant/components/hassio/strings.json +++ b/homeassistant/components/hassio/strings.json @@ -265,6 +265,11 @@ "version_latest": { "name": "Newest version" } + }, + "update": { + "update": { + "name": "[%key:component::update::title%]" + } } }, "services": { diff --git a/homeassistant/components/hassio/update.py b/homeassistant/components/hassio/update.py index 4ea703e87c3..263cf2dfe13 100644 --- a/homeassistant/components/hassio/update.py +++ b/homeassistant/components/hassio/update.py @@ -39,7 +39,7 @@ from .entity import ( from .update_helper import update_addon, update_core ENTITY_DESCRIPTION = UpdateEntityDescription( - name="Update", + translation_key="update", key=ATTR_VERSION_LATEST, ) From b2af1084f92f9a67eb8747a815dc3cd15b995e16 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ab=C3=ADlio=20Costa?= Date: Thu, 3 Apr 2025 15:35:37 +0100 Subject: [PATCH 248/505] Update Whirlpool to 0.20.0 (#142119) --- .../components/whirlpool/manifest.json | 2 +- homeassistant/components/whirlpool/sensor.py | 22 ++++++++---------- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/whirlpool/conftest.py | 16 +++---------- tests/components/whirlpool/test_sensor.py | 23 ++++--------------- 6 files changed, 19 insertions(+), 48 deletions(-) diff --git a/homeassistant/components/whirlpool/manifest.json b/homeassistant/components/whirlpool/manifest.json index ace2e31791d..be47ab619e9 100644 --- a/homeassistant/components/whirlpool/manifest.json +++ b/homeassistant/components/whirlpool/manifest.json @@ -7,5 +7,5 @@ "integration_type": "hub", "iot_class": "cloud_push", "loggers": ["whirlpool"], - "requirements": ["whirlpool-sixth-sense==0.19.1"] + "requirements": ["whirlpool-sixth-sense==0.20.0"] } diff --git a/homeassistant/components/whirlpool/sensor.py b/homeassistant/components/whirlpool/sensor.py index d167e3aa730..44d17228135 100644 --- a/homeassistant/components/whirlpool/sensor.py +++ b/homeassistant/components/whirlpool/sensor.py @@ -25,12 +25,12 @@ from .entity import WhirlpoolEntity SCAN_INTERVAL = timedelta(minutes=5) WASHER_TANK_FILL = { - "0": "unknown", - "1": "empty", - "2": "25", - "3": "50", - "4": "100", - "5": "active", + 0: "unknown", + 1: "empty", + 2: "25", + 3: "50", + 4: "100", + 5: "active", } WASHER_DRYER_MACHINE_STATE = { @@ -70,7 +70,7 @@ STATE_DOOR_OPEN = "door_open" def washer_dryer_state(washer_dryer: WasherDryer) -> str | None: """Determine correct states for a washer/dryer.""" - if washer_dryer.get_attribute("Cavity_OpStatusDoorOpen") == "1": + if washer_dryer.get_door_open(): return STATE_DOOR_OPEN machine_state = washer_dryer.get_machine_state() @@ -110,9 +110,7 @@ WASHER_SENSORS: tuple[WhirlpoolSensorEntityDescription, ...] = ( entity_registry_enabled_default=False, device_class=SensorDeviceClass.ENUM, options=list(WASHER_TANK_FILL.values()), - value_fn=lambda washer: WASHER_TANK_FILL.get( - washer.get_attribute("WashCavity_OpStatusBulkDispense1Level") - ), + value_fn=lambda washer: WASHER_TANK_FILL.get(washer.get_dispense_1_level()), ), ) @@ -224,9 +222,7 @@ class WasherDryerTimeSensor(WhirlpoolEntity, RestoreSensor): if machine_state is MachineState.RunningMainCycle: self._running = True - new_timestamp = now + timedelta( - seconds=int(self._wd.get_attribute("Cavity_TimeStatusEstTimeRemaining")) - ) + new_timestamp = now + timedelta(seconds=self._wd.get_time_remaining()) if self._value is None or ( isinstance(self._value, datetime) diff --git a/requirements_all.txt b/requirements_all.txt index 51c5e7296a0..6fc6bf9e375 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -3067,7 +3067,7 @@ webmin-xmlrpc==0.0.2 weheat==2025.2.26 # homeassistant.components.whirlpool -whirlpool-sixth-sense==0.19.1 +whirlpool-sixth-sense==0.20.0 # homeassistant.components.whois whois==0.9.27 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 83ff3ab8315..04a2880af00 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2469,7 +2469,7 @@ webmin-xmlrpc==0.0.2 weheat==2025.2.26 # homeassistant.components.whirlpool -whirlpool-sixth-sense==0.19.1 +whirlpool-sixth-sense==0.20.0 # homeassistant.components.whois whois==0.9.27 diff --git a/tests/components/whirlpool/conftest.py b/tests/components/whirlpool/conftest.py index 97550729761..5d063f02924 100644 --- a/tests/components/whirlpool/conftest.py +++ b/tests/components/whirlpool/conftest.py @@ -141,18 +141,6 @@ def fixture_mock_aircon_api_instances(mock_aircon1_api, mock_aircon2_api): yield mock_aircon_api -def side_effect_function(*args, **kwargs): - """Return correct value for attribute.""" - if args[0] == "Cavity_TimeStatusEstTimeRemaining": - return 3540 - if args[0] == "Cavity_OpStatusDoorOpen": - return "0" - if args[0] == "WashCavity_OpStatusBulkDispense1Level": - return "3" - - return None - - def get_sensor_mock(said: str, data_model: str): """Get a mock of a sensor.""" mock_sensor = mock.Mock(said=said) @@ -165,7 +153,9 @@ def get_sensor_mock(said: str, data_model: str): mock_sensor.get_machine_state.return_value = ( whirlpool.washerdryer.MachineState.Standby ) - mock_sensor.get_attribute.side_effect = side_effect_function + mock_sensor.get_door_open.return_value = False + mock_sensor.get_dispense_1_level.return_value = 3 + mock_sensor.get_time_remaining.return_value = 3540 mock_sensor.get_cycle_status_filling.return_value = False mock_sensor.get_cycle_status_rinsing.return_value = False mock_sensor.get_cycle_status_sensing.return_value = False diff --git a/tests/components/whirlpool/test_sensor.py b/tests/components/whirlpool/test_sensor.py index 40c485a5b9f..43a5421391b 100644 --- a/tests/components/whirlpool/test_sensor.py +++ b/tests/components/whirlpool/test_sensor.py @@ -30,20 +30,6 @@ async def update_sensor_state( return hass.states.get(entity_id) -def side_effect_function_open_door(*args, **kwargs): - """Return correct value for attribute.""" - if args[0] == "Cavity_TimeStatusEstTimeRemaining": - return 3540 - - if args[0] == "Cavity_OpStatusDoorOpen": - return "1" - - if args[0] == "WashCavity_OpStatusBulkDispense1Level": - return "3" - - return None - - async def test_dryer_sensor_values( hass: HomeAssistant, mock_sensor2_api: MagicMock, entity_registry: er.EntityRegistry ) -> None: @@ -258,7 +244,7 @@ async def test_washer_sensor_values( mock_instance.get_machine_state.return_value = MachineState.Complete mock_instance.attr_value_to_bool.side_effect = None - mock_instance.get_attribute.side_effect = side_effect_function_open_door + mock_instance.get_door_open.return_value = True state = await update_sensor_state(hass, entity_id, mock_instance) assert state is not None assert state.state == "door_open" @@ -338,8 +324,7 @@ async def test_callback(hass: HomeAssistant, mock_sensor1_api: MagicMock) -> Non state = hass.states.get(f"sensor.washerdryer_{MOCK_SAID3}_end_time") assert state.state == thetimestamp.isoformat() mock_sensor1_api.get_machine_state.return_value = MachineState.RunningMainCycle - mock_sensor1_api.get_attribute.side_effect = None - mock_sensor1_api.get_attribute.return_value = "60" + mock_sensor1_api.get_time_remaining.return_value = 60 callback() # Test new timestamp when machine starts a cycle. @@ -348,13 +333,13 @@ async def test_callback(hass: HomeAssistant, mock_sensor1_api: MagicMock) -> Non assert state.state != thetimestamp.isoformat() # Test no timestamp change for < 60 seconds time change. - mock_sensor1_api.get_attribute.return_value = "65" + mock_sensor1_api.get_time_remaining.return_value = 65 callback() state = hass.states.get(f"sensor.washerdryer_{MOCK_SAID3}_end_time") assert state.state == time # Test timestamp change for > 60 seconds. - mock_sensor1_api.get_attribute.return_value = "125" + mock_sensor1_api.get_time_remaining.return_value = 125 callback() state = hass.states.get(f"sensor.washerdryer_{MOCK_SAID3}_end_time") newtime = utc_from_timestamp(as_timestamp(time) + 65) From 53d2347c1041ed2edc38245ef37968f8fd03c44f Mon Sep 17 00:00:00 2001 From: Fredrik Erlandsson Date: Thu, 3 Apr 2025 16:54:23 +0200 Subject: [PATCH 249/505] Fix blocking event loop - daikin (#141442) * fix blocking event loop * create ssl_context directly * update manifest * update manifest.json --- homeassistant/components/daikin/__init__.py | 2 ++ homeassistant/components/daikin/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 5 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/daikin/__init__.py b/homeassistant/components/daikin/__init__.py index 0eaffa39ee9..88a7b71e3ed 100644 --- a/homeassistant/components/daikin/__init__.py +++ b/homeassistant/components/daikin/__init__.py @@ -21,6 +21,7 @@ from homeassistant.exceptions import ConfigEntryNotReady from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC +from homeassistant.util.ssl import client_context_no_verify from .const import KEY_MAC, TIMEOUT from .coordinator import DaikinConfigEntry, DaikinCoordinator @@ -48,6 +49,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: DaikinConfigEntry) -> bo key=entry.data.get(CONF_API_KEY), uuid=entry.data.get(CONF_UUID), password=entry.data.get(CONF_PASSWORD), + ssl_context=client_context_no_verify(), ) _LOGGER.debug("Connection to %s successful", host) except TimeoutError as err: diff --git a/homeassistant/components/daikin/manifest.json b/homeassistant/components/daikin/manifest.json index 86fc804ec92..947fe514747 100644 --- a/homeassistant/components/daikin/manifest.json +++ b/homeassistant/components/daikin/manifest.json @@ -6,6 +6,6 @@ "documentation": "https://www.home-assistant.io/integrations/daikin", "iot_class": "local_polling", "loggers": ["pydaikin"], - "requirements": ["pydaikin==2.14.1"], + "requirements": ["pydaikin==2.15.0"], "zeroconf": ["_dkapi._tcp.local."] } diff --git a/requirements_all.txt b/requirements_all.txt index 6fc6bf9e375..1ee0342dde8 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1891,7 +1891,7 @@ pycsspeechtts==1.0.8 # pycups==2.0.4 # homeassistant.components.daikin -pydaikin==2.14.1 +pydaikin==2.15.0 # homeassistant.components.danfoss_air pydanfossair==0.1.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 04a2880af00..950307a695a 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1548,7 +1548,7 @@ pycountry==24.6.1 pycsspeechtts==1.0.8 # homeassistant.components.daikin -pydaikin==2.14.1 +pydaikin==2.15.0 # homeassistant.components.deako pydeako==0.6.0 From fefa2a9dd6481a4ae79f5dc97f008f2c8a2b81ee Mon Sep 17 00:00:00 2001 From: rappenze Date: Thu, 3 Apr 2025 18:36:44 +0200 Subject: [PATCH 250/505] Fix fibaro setup (#142201) --- homeassistant/components/fibaro/__init__.py | 1 + 1 file changed, 1 insertion(+) diff --git a/homeassistant/components/fibaro/__init__.py b/homeassistant/components/fibaro/__init__.py index 33b2598a636..a4f59d8ab76 100644 --- a/homeassistant/components/fibaro/__init__.py +++ b/homeassistant/components/fibaro/__init__.py @@ -301,6 +301,7 @@ class FibaroController: device.ha_id = ( f"{slugify(room_name)}_{slugify(device.name)}_{device.fibaro_id}" ) + platform = None if device.enabled and (not device.is_plugin or self._import_plugins): platform = self._map_device_to_platform(device) if platform is None: From 380fb6176bdb30d9d1039dcf686d97a12a228865 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Thu, 3 Apr 2025 20:12:24 +0200 Subject: [PATCH 251/505] Add preset mode to SmartThings climate (#142180) * Add preset mode to SmartThings climate * Add preset mode to SmartThings climate --- homeassistant/components/smartthings/climate.py | 13 ++++++++++++- .../fixtures/device_status/da_ac_rac_000001.json | 2 +- .../smartthings/snapshots/test_climate.ambr | 2 +- .../smartthings/snapshots/test_diagnostics.ambr | 2 +- 4 files changed, 15 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/smartthings/climate.py b/homeassistant/components/smartthings/climate.py index 49499732c24..f2f9479584c 100644 --- a/homeassistant/components/smartthings/climate.py +++ b/homeassistant/components/smartthings/climate.py @@ -333,7 +333,6 @@ class SmartThingsAirConditioner(SmartThingsEntity, ClimateEntity): """Define a SmartThings Air Conditioner.""" _attr_name = None - _attr_preset_mode = None def __init__(self, client: SmartThings, device: FullDevice) -> None: """Init the class.""" @@ -545,6 +544,18 @@ class SmartThingsAirConditioner(SmartThingsEntity, ClimateEntity): SWING_OFF, ) + @property + def preset_mode(self) -> str | None: + """Return the preset mode.""" + if self.supports_capability(Capability.CUSTOM_AIR_CONDITIONER_OPTIONAL_MODE): + mode = self.get_attribute_value( + Capability.CUSTOM_AIR_CONDITIONER_OPTIONAL_MODE, + Attribute.AC_OPTIONAL_MODE, + ) + if mode == WINDFREE: + return WINDFREE + return None + def _determine_preset_modes(self) -> list[str] | None: """Return a list of available preset modes.""" if self.supports_capability(Capability.CUSTOM_AIR_CONDITIONER_OPTIONAL_MODE): diff --git a/tests/components/smartthings/fixtures/device_status/da_ac_rac_000001.json b/tests/components/smartthings/fixtures/device_status/da_ac_rac_000001.json index c80fcf9c298..f6cdd661a99 100644 --- a/tests/components/smartthings/fixtures/device_status/da_ac_rac_000001.json +++ b/tests/components/smartthings/fixtures/device_status/da_ac_rac_000001.json @@ -473,7 +473,7 @@ "timestamp": "2024-09-10T10:26:28.781Z" }, "acOptionalMode": { - "value": "off", + "value": "windFree", "timestamp": "2025-02-09T09:14:39.642Z" } }, diff --git a/tests/components/smartthings/snapshots/test_climate.ambr b/tests/components/smartthings/snapshots/test_climate.ambr index 19cfe971d7f..633b02568fc 100644 --- a/tests/components/smartthings/snapshots/test_climate.ambr +++ b/tests/components/smartthings/snapshots/test_climate.ambr @@ -211,7 +211,7 @@ ]), 'max_temp': 35, 'min_temp': 7, - 'preset_mode': None, + 'preset_mode': 'windFree', 'preset_modes': list([ 'windFree', ]), diff --git a/tests/components/smartthings/snapshots/test_diagnostics.ambr b/tests/components/smartthings/snapshots/test_diagnostics.ambr index b9847bf9746..dc7f699de27 100644 --- a/tests/components/smartthings/snapshots/test_diagnostics.ambr +++ b/tests/components/smartthings/snapshots/test_diagnostics.ambr @@ -1065,7 +1065,7 @@ 'custom.airConditionerOptionalMode': dict({ 'acOptionalMode': dict({ 'timestamp': '2025-02-09T09:14:39.642Z', - 'value': 'off', + 'value': 'windFree', }), 'supportedAcOptionalMode': dict({ 'timestamp': '2024-09-10T10:26:28.781Z', From 3ed4859db9410fa0f16efe0eaeb9d66f2a69d911 Mon Sep 17 00:00:00 2001 From: Erwin Douna Date: Thu, 3 Apr 2025 20:30:34 +0200 Subject: [PATCH 252/505] Tado bump to 0.18.11 (#142175) * Bump to version 0.18.11 * Adding hassfest files --- homeassistant/components/tado/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/tado/manifest.json b/homeassistant/components/tado/manifest.json index 75ddbacc585..eba13d469f3 100644 --- a/homeassistant/components/tado/manifest.json +++ b/homeassistant/components/tado/manifest.json @@ -14,5 +14,5 @@ }, "iot_class": "cloud_polling", "loggers": ["PyTado"], - "requirements": ["python-tado==0.18.9"] + "requirements": ["python-tado==0.18.11"] } diff --git a/requirements_all.txt b/requirements_all.txt index 1ee0342dde8..56a79ff1c1c 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2482,7 +2482,7 @@ python-snoo==0.6.5 python-songpal==0.16.2 # homeassistant.components.tado -python-tado==0.18.9 +python-tado==0.18.11 # homeassistant.components.technove python-technove==2.0.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 950307a695a..4f37fc47279 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2013,7 +2013,7 @@ python-snoo==0.6.5 python-songpal==0.16.2 # homeassistant.components.tado -python-tado==0.18.9 +python-tado==0.18.11 # homeassistant.components.technove python-technove==2.0.0 From 3b2ff38f02c55d12f1755afdd4e7efbd8da11817 Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Thu, 3 Apr 2025 20:35:34 +0200 Subject: [PATCH 253/505] Use common states for "Low"/"Medium"/"High" in `yolink` (#142139) --- homeassistant/components/yolink/strings.json | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/yolink/strings.json b/homeassistant/components/yolink/strings.json index 8ec7612fd73..b4cfe80f287 100644 --- a/homeassistant/components/yolink/strings.json +++ b/homeassistant/components/yolink/strings.json @@ -72,7 +72,11 @@ }, "power_failure_alarm_volume": { "name": "Power failure alarm volume", - "state": { "low": "Low", "medium": "Medium", "high": "High" } + "state": { + "low": "[%key:common::state::low%]", + "medium": "[%key:common::state::medium%]", + "high": "[%key:common::state::high%]" + } }, "power_failure_alarm_beep": { "name": "Power failure alarm beep", From 30e50d261d309276cf23927de0d7e283be3cf36b Mon Sep 17 00:00:00 2001 From: Ivan Lopez Hernandez Date: Thu, 3 Apr 2025 13:23:59 -0700 Subject: [PATCH 254/505] Made Google Search enable dependent on Assist availability (#141712) * Made Google Search enable dependent on Assist availability * Show error instead of rendering again * Cleanup test code --- .../config_flow.py | 18 +- .../strings.json | 3 + .../test_config_flow.py | 164 +++++++++++++++--- 3 files changed, 151 insertions(+), 34 deletions(-) diff --git a/homeassistant/components/google_generative_ai_conversation/config_flow.py b/homeassistant/components/google_generative_ai_conversation/config_flow.py index b7753c21bf9..ac6cb696a7d 100644 --- a/homeassistant/components/google_generative_ai_conversation/config_flow.py +++ b/homeassistant/components/google_generative_ai_conversation/config_flow.py @@ -179,28 +179,30 @@ class GoogleGenerativeAIOptionsFlow(OptionsFlow): ) -> ConfigFlowResult: """Manage the options.""" options: dict[str, Any] | MappingProxyType[str, Any] = self.config_entry.options + errors: dict[str, str] = {} if user_input is not None: if user_input[CONF_RECOMMENDED] == self.last_rendered_recommended: if user_input[CONF_LLM_HASS_API] == "none": user_input.pop(CONF_LLM_HASS_API) - return self.async_create_entry(title="", data=user_input) + if not ( + user_input.get(CONF_LLM_HASS_API, "none") != "none" + and user_input.get(CONF_USE_GOOGLE_SEARCH_TOOL, False) is True + ): + # Don't allow to save options that enable the Google Seearch tool with an Assist API + return self.async_create_entry(title="", data=user_input) + errors[CONF_USE_GOOGLE_SEARCH_TOOL] = "invalid_google_search_option" # Re-render the options again, now with the recommended options shown/hidden self.last_rendered_recommended = user_input[CONF_RECOMMENDED] - options = { - CONF_RECOMMENDED: user_input[CONF_RECOMMENDED], - CONF_PROMPT: user_input[CONF_PROMPT], - CONF_LLM_HASS_API: user_input[CONF_LLM_HASS_API], - } + options = user_input schema = await google_generative_ai_config_option_schema( self.hass, options, self._genai_client ) return self.async_show_form( - step_id="init", - data_schema=vol.Schema(schema), + step_id="init", data_schema=vol.Schema(schema), errors=errors ) diff --git a/homeassistant/components/google_generative_ai_conversation/strings.json b/homeassistant/components/google_generative_ai_conversation/strings.json index b814f89469a..cd7af711d3a 100644 --- a/homeassistant/components/google_generative_ai_conversation/strings.json +++ b/homeassistant/components/google_generative_ai_conversation/strings.json @@ -43,6 +43,9 @@ "prompt": "Instruct how the LLM should respond. This can be a template." } } + }, + "error": { + "invalid_google_search_option": "Google Search cannot be enabled alongside any Assist capability, this can only be used when Assist is set to \"No control\"." } }, "services": { diff --git a/tests/components/google_generative_ai_conversation/test_config_flow.py b/tests/components/google_generative_ai_conversation/test_config_flow.py index f7635c0b45e..8fda02b335d 100644 --- a/tests/components/google_generative_ai_conversation/test_config_flow.py +++ b/tests/components/google_generative_ai_conversation/test_config_flow.py @@ -39,9 +39,8 @@ from . import CLIENT_ERROR_500, CLIENT_ERROR_API_KEY_INVALID from tests.common import MockConfigEntry -@pytest.fixture -def mock_models(): - """Mock the model list API.""" +def get_models_pager(): + """Return a generator that yields the models.""" model_20_flash = Mock( display_name="Gemini 2.0 Flash", supported_actions=["generateContent"], @@ -72,11 +71,7 @@ def mock_models(): yield model_15_pro yield model_10_pro - with patch( - "google.genai.models.AsyncModels.list", - return_value=models_pager(), - ): - yield + return models_pager() async def test_form(hass: HomeAssistant) -> None: @@ -119,8 +114,13 @@ async def test_form(hass: HomeAssistant) -> None: assert len(mock_setup_entry.mock_calls) == 1 +def will_options_be_rendered_again(current_options, new_options) -> bool: + """Determine if options will be rendered again.""" + return current_options.get(CONF_RECOMMENDED) != new_options.get(CONF_RECOMMENDED) + + @pytest.mark.parametrize( - ("current_options", "new_options", "expected_options"), + ("current_options", "new_options", "expected_options", "errors"), [ ( { @@ -147,6 +147,7 @@ async def test_form(hass: HomeAssistant) -> None: CONF_DANGEROUS_BLOCK_THRESHOLD: RECOMMENDED_HARM_BLOCK_THRESHOLD, CONF_USE_GOOGLE_SEARCH_TOOL: RECOMMENDED_USE_GOOGLE_SEARCH_TOOL, }, + None, ), ( { @@ -157,6 +158,7 @@ async def test_form(hass: HomeAssistant) -> None: CONF_TOP_P: RECOMMENDED_TOP_P, CONF_TOP_K: RECOMMENDED_TOP_K, CONF_MAX_TOKENS: RECOMMENDED_MAX_TOKENS, + CONF_USE_GOOGLE_SEARCH_TOOL: True, }, { CONF_RECOMMENDED: True, @@ -168,6 +170,98 @@ async def test_form(hass: HomeAssistant) -> None: CONF_LLM_HASS_API: "assist", CONF_PROMPT: "", }, + None, + ), + ( + { + CONF_RECOMMENDED: False, + CONF_PROMPT: "Speak like a pirate", + CONF_TEMPERATURE: 0.3, + CONF_CHAT_MODEL: RECOMMENDED_CHAT_MODEL, + CONF_TOP_P: RECOMMENDED_TOP_P, + CONF_TOP_K: RECOMMENDED_TOP_K, + CONF_MAX_TOKENS: RECOMMENDED_MAX_TOKENS, + CONF_HARASSMENT_BLOCK_THRESHOLD: RECOMMENDED_HARM_BLOCK_THRESHOLD, + CONF_HATE_BLOCK_THRESHOLD: RECOMMENDED_HARM_BLOCK_THRESHOLD, + CONF_SEXUAL_BLOCK_THRESHOLD: RECOMMENDED_HARM_BLOCK_THRESHOLD, + CONF_DANGEROUS_BLOCK_THRESHOLD: RECOMMENDED_HARM_BLOCK_THRESHOLD, + CONF_USE_GOOGLE_SEARCH_TOOL: RECOMMENDED_USE_GOOGLE_SEARCH_TOOL, + }, + { + CONF_RECOMMENDED: False, + CONF_PROMPT: "Speak like a pirate", + CONF_TEMPERATURE: 0.3, + CONF_CHAT_MODEL: RECOMMENDED_CHAT_MODEL, + CONF_TOP_P: RECOMMENDED_TOP_P, + CONF_TOP_K: RECOMMENDED_TOP_K, + CONF_MAX_TOKENS: RECOMMENDED_MAX_TOKENS, + CONF_HARASSMENT_BLOCK_THRESHOLD: RECOMMENDED_HARM_BLOCK_THRESHOLD, + CONF_HATE_BLOCK_THRESHOLD: RECOMMENDED_HARM_BLOCK_THRESHOLD, + CONF_SEXUAL_BLOCK_THRESHOLD: RECOMMENDED_HARM_BLOCK_THRESHOLD, + CONF_DANGEROUS_BLOCK_THRESHOLD: RECOMMENDED_HARM_BLOCK_THRESHOLD, + CONF_USE_GOOGLE_SEARCH_TOOL: True, + }, + { + CONF_RECOMMENDED: False, + CONF_PROMPT: "Speak like a pirate", + CONF_TEMPERATURE: 0.3, + CONF_CHAT_MODEL: RECOMMENDED_CHAT_MODEL, + CONF_TOP_P: RECOMMENDED_TOP_P, + CONF_TOP_K: RECOMMENDED_TOP_K, + CONF_MAX_TOKENS: RECOMMENDED_MAX_TOKENS, + CONF_HARASSMENT_BLOCK_THRESHOLD: RECOMMENDED_HARM_BLOCK_THRESHOLD, + CONF_HATE_BLOCK_THRESHOLD: RECOMMENDED_HARM_BLOCK_THRESHOLD, + CONF_SEXUAL_BLOCK_THRESHOLD: RECOMMENDED_HARM_BLOCK_THRESHOLD, + CONF_DANGEROUS_BLOCK_THRESHOLD: RECOMMENDED_HARM_BLOCK_THRESHOLD, + CONF_USE_GOOGLE_SEARCH_TOOL: True, + }, + None, + ), + ( + { + CONF_RECOMMENDED: False, + CONF_PROMPT: "Speak like a pirate", + CONF_TEMPERATURE: 0.3, + CONF_CHAT_MODEL: RECOMMENDED_CHAT_MODEL, + CONF_TOP_P: RECOMMENDED_TOP_P, + CONF_TOP_K: RECOMMENDED_TOP_K, + CONF_MAX_TOKENS: RECOMMENDED_MAX_TOKENS, + CONF_HARASSMENT_BLOCK_THRESHOLD: RECOMMENDED_HARM_BLOCK_THRESHOLD, + CONF_HATE_BLOCK_THRESHOLD: RECOMMENDED_HARM_BLOCK_THRESHOLD, + CONF_SEXUAL_BLOCK_THRESHOLD: RECOMMENDED_HARM_BLOCK_THRESHOLD, + CONF_DANGEROUS_BLOCK_THRESHOLD: RECOMMENDED_HARM_BLOCK_THRESHOLD, + CONF_USE_GOOGLE_SEARCH_TOOL: True, + }, + { + CONF_RECOMMENDED: False, + CONF_PROMPT: "Speak like a pirate", + CONF_LLM_HASS_API: "assist", + CONF_TEMPERATURE: 0.3, + CONF_CHAT_MODEL: RECOMMENDED_CHAT_MODEL, + CONF_TOP_P: RECOMMENDED_TOP_P, + CONF_TOP_K: RECOMMENDED_TOP_K, + CONF_MAX_TOKENS: RECOMMENDED_MAX_TOKENS, + CONF_HARASSMENT_BLOCK_THRESHOLD: RECOMMENDED_HARM_BLOCK_THRESHOLD, + CONF_HATE_BLOCK_THRESHOLD: RECOMMENDED_HARM_BLOCK_THRESHOLD, + CONF_SEXUAL_BLOCK_THRESHOLD: RECOMMENDED_HARM_BLOCK_THRESHOLD, + CONF_DANGEROUS_BLOCK_THRESHOLD: RECOMMENDED_HARM_BLOCK_THRESHOLD, + CONF_USE_GOOGLE_SEARCH_TOOL: True, + }, + { + CONF_RECOMMENDED: False, + CONF_PROMPT: "Speak like a pirate", + CONF_TEMPERATURE: 0.3, + CONF_CHAT_MODEL: RECOMMENDED_CHAT_MODEL, + CONF_TOP_P: RECOMMENDED_TOP_P, + CONF_TOP_K: RECOMMENDED_TOP_K, + CONF_MAX_TOKENS: RECOMMENDED_MAX_TOKENS, + CONF_HARASSMENT_BLOCK_THRESHOLD: RECOMMENDED_HARM_BLOCK_THRESHOLD, + CONF_HATE_BLOCK_THRESHOLD: RECOMMENDED_HARM_BLOCK_THRESHOLD, + CONF_SEXUAL_BLOCK_THRESHOLD: RECOMMENDED_HARM_BLOCK_THRESHOLD, + CONF_DANGEROUS_BLOCK_THRESHOLD: RECOMMENDED_HARM_BLOCK_THRESHOLD, + CONF_USE_GOOGLE_SEARCH_TOOL: True, + }, + {CONF_USE_GOOGLE_SEARCH_TOOL: "invalid_google_search_option"}, ), ], ) @@ -175,10 +269,10 @@ async def test_form(hass: HomeAssistant) -> None: async def test_options_switching( hass: HomeAssistant, mock_config_entry: MockConfigEntry, - mock_models, current_options, new_options, expected_options, + errors, ) -> None: """Test the options form.""" with patch("google.genai.models.AsyncModels.get"): @@ -186,24 +280,42 @@ async def test_options_switching( mock_config_entry, options=current_options ) await hass.async_block_till_done() - options_flow = await hass.config_entries.options.async_init( - mock_config_entry.entry_id - ) - if current_options.get(CONF_RECOMMENDED) != new_options.get(CONF_RECOMMENDED): - options_flow = await hass.config_entries.options.async_configure( - options_flow["flow_id"], - { - **current_options, - CONF_RECOMMENDED: new_options[CONF_RECOMMENDED], - }, + with patch( + "google.genai.models.AsyncModels.list", + return_value=get_models_pager(), + ): + options_flow = await hass.config_entries.options.async_init( + mock_config_entry.entry_id + ) + if will_options_be_rendered_again(current_options, new_options): + retry_options = { + **current_options, + CONF_RECOMMENDED: new_options[CONF_RECOMMENDED], + } + with patch( + "google.genai.models.AsyncModels.list", + return_value=get_models_pager(), + ): + options_flow = await hass.config_entries.options.async_configure( + options_flow["flow_id"], + retry_options, + ) + with patch( + "google.genai.models.AsyncModels.list", + return_value=get_models_pager(), + ): + options = await hass.config_entries.options.async_configure( + options_flow["flow_id"], + new_options, ) - options = await hass.config_entries.options.async_configure( - options_flow["flow_id"], - new_options, - ) await hass.async_block_till_done() - assert options["type"] is FlowResultType.CREATE_ENTRY - assert options["data"] == expected_options + if errors is None: + assert options["type"] is FlowResultType.CREATE_ENTRY + assert options["data"] == expected_options + + else: + assert options["type"] is FlowResultType.FORM + assert options.get("errors", None) == errors @pytest.mark.parametrize( From b9d819e0e515254da230a962434a702ae2a12aa4 Mon Sep 17 00:00:00 2001 From: Marcel van der Veldt Date: Thu, 3 Apr 2025 22:26:56 +0200 Subject: [PATCH 255/505] Do not create a HA mediaplayer for the builtin Music Assistant player (#142192) Do not create a HA mediaplayer for the builtin Music player --- homeassistant/components/music_assistant/media_player.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/homeassistant/components/music_assistant/media_player.py b/homeassistant/components/music_assistant/media_player.py index 01a103f9bc4..08176307829 100644 --- a/homeassistant/components/music_assistant/media_player.py +++ b/homeassistant/components/music_assistant/media_player.py @@ -151,6 +151,8 @@ async def async_setup_entry( assert event.object_id is not None if event.object_id in added_ids: return + if not player.expose_to_ha: + return added_ids.add(event.object_id) async_add_entities([MusicAssistantPlayer(mass, event.object_id)]) @@ -159,6 +161,8 @@ async def async_setup_entry( mass_players = [] # add all current players for player in mass.players: + if not player.expose_to_ha: + continue added_ids.add(player.player_id) mass_players.append(MusicAssistantPlayer(mass, player.player_id)) From b84096097c9815f7d5de04561895c891be70a510 Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Thu, 3 Apr 2025 23:25:13 +0200 Subject: [PATCH 256/505] Make `calendar.get_events` action description consistent (#142170) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Changes it to match the standard HA style using descriptive wording and changes to "Retrieves …" matching other "Get xyz" actions. --- homeassistant/components/calendar/strings.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/calendar/strings.json b/homeassistant/components/calendar/strings.json index c0127c20d05..6612ea5209d 100644 --- a/homeassistant/components/calendar/strings.json +++ b/homeassistant/components/calendar/strings.json @@ -74,7 +74,7 @@ }, "get_events": { "name": "Get events", - "description": "Get events on a calendar within a time range.", + "description": "Retrieves events on a calendar within a time range.", "fields": { "start_date_time": { "name": "Start time", From 74d6019f81e8dbc5883731cac8e82823394b0e7f Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Thu, 3 Apr 2025 23:47:57 +0200 Subject: [PATCH 257/505] Use common states for "Low"/"Medium"/"High" in `tessie` (#142209) --- homeassistant/components/tessie/strings.json | 54 ++++++++++---------- 1 file changed, 27 insertions(+), 27 deletions(-) diff --git a/homeassistant/components/tessie/strings.json b/homeassistant/components/tessie/strings.json index f956e9cefd6..5de18f13140 100644 --- a/homeassistant/components/tessie/strings.json +++ b/homeassistant/components/tessie/strings.json @@ -246,81 +246,81 @@ "name": "Seat heater left", "state": { "off": "[%key:common::state::off%]", - "low": "Low", - "medium": "Medium", - "high": "High" + "low": "[%key:common::state::low%]", + "medium": "[%key:common::state::medium%]", + "high": "[%key:common::state::high%]" } }, "climate_state_seat_heater_right": { "name": "Seat heater right", "state": { "off": "[%key:common::state::off%]", - "low": "[%key:component::tessie::entity::select::climate_state_seat_heater_left::state::low%]", - "medium": "[%key:component::tessie::entity::select::climate_state_seat_heater_left::state::medium%]", - "high": "[%key:component::tessie::entity::select::climate_state_seat_heater_left::state::high%]" + "low": "[%key:common::state::low%]", + "medium": "[%key:common::state::medium%]", + "high": "[%key:common::state::high%]" } }, "climate_state_seat_heater_rear_left": { "name": "Seat heater rear left", "state": { "off": "[%key:common::state::off%]", - "low": "[%key:component::tessie::entity::select::climate_state_seat_heater_left::state::low%]", - "medium": "[%key:component::tessie::entity::select::climate_state_seat_heater_left::state::medium%]", - "high": "[%key:component::tessie::entity::select::climate_state_seat_heater_left::state::high%]" + "low": "[%key:common::state::low%]", + "medium": "[%key:common::state::medium%]", + "high": "[%key:common::state::high%]" } }, "climate_state_seat_heater_rear_center": { "name": "Seat heater rear center", "state": { "off": "[%key:common::state::off%]", - "low": "[%key:component::tessie::entity::select::climate_state_seat_heater_left::state::low%]", - "medium": "[%key:component::tessie::entity::select::climate_state_seat_heater_left::state::medium%]", - "high": "[%key:component::tessie::entity::select::climate_state_seat_heater_left::state::high%]" + "low": "[%key:common::state::low%]", + "medium": "[%key:common::state::medium%]", + "high": "[%key:common::state::high%]" } }, "climate_state_seat_heater_rear_right": { "name": "Seat heater rear right", "state": { "off": "[%key:common::state::off%]", - "low": "[%key:component::tessie::entity::select::climate_state_seat_heater_left::state::low%]", - "medium": "[%key:component::tessie::entity::select::climate_state_seat_heater_left::state::medium%]", - "high": "[%key:component::tessie::entity::select::climate_state_seat_heater_left::state::high%]" + "low": "[%key:common::state::low%]", + "medium": "[%key:common::state::medium%]", + "high": "[%key:common::state::high%]" } }, "climate_state_seat_heater_third_row_left": { "name": "Seat heater third row left", "state": { "off": "[%key:common::state::off%]", - "low": "[%key:component::tessie::entity::select::climate_state_seat_heater_left::state::low%]", - "medium": "[%key:component::tessie::entity::select::climate_state_seat_heater_left::state::medium%]", - "high": "[%key:component::tessie::entity::select::climate_state_seat_heater_left::state::high%]" + "low": "[%key:common::state::low%]", + "medium": "[%key:common::state::medium%]", + "high": "[%key:common::state::high%]" } }, "climate_state_seat_heater_third_row_right": { "name": "Seat heater third row right", "state": { "off": "[%key:common::state::off%]", - "low": "[%key:component::tessie::entity::select::climate_state_seat_heater_left::state::low%]", - "medium": "[%key:component::tessie::entity::select::climate_state_seat_heater_left::state::medium%]", - "high": "[%key:component::tessie::entity::select::climate_state_seat_heater_left::state::high%]" + "low": "[%key:common::state::low%]", + "medium": "[%key:common::state::medium%]", + "high": "[%key:common::state::high%]" } }, "climate_state_seat_fan_front_left": { "name": "Seat cooler left", "state": { "off": "[%key:common::state::off%]", - "low": "[%key:component::tessie::entity::select::climate_state_seat_heater_left::state::low%]", - "medium": "[%key:component::tessie::entity::select::climate_state_seat_heater_left::state::medium%]", - "high": "[%key:component::tessie::entity::select::climate_state_seat_heater_left::state::high%]" + "low": "[%key:common::state::low%]", + "medium": "[%key:common::state::medium%]", + "high": "[%key:common::state::high%]" } }, "climate_state_seat_fan_front_right": { "name": "Seat cooler right", "state": { "off": "[%key:common::state::off%]", - "low": "[%key:component::tessie::entity::select::climate_state_seat_heater_left::state::low%]", - "medium": "[%key:component::tessie::entity::select::climate_state_seat_heater_left::state::medium%]", - "high": "[%key:component::tessie::entity::select::climate_state_seat_heater_left::state::high%]" + "low": "[%key:common::state::low%]", + "medium": "[%key:common::state::medium%]", + "high": "[%key:common::state::high%]" } }, "components_customer_preferred_export_rule": { From 2f180c96c848ef105bee6fa261c57563c377fe1c Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Thu, 3 Apr 2025 23:48:14 +0200 Subject: [PATCH 258/505] Use common states for "Low"/"Medium"/"High" in `teslemetry` (#142210) --- .../components/teslemetry/strings.json | 46 +++++++++---------- 1 file changed, 23 insertions(+), 23 deletions(-) diff --git a/homeassistant/components/teslemetry/strings.json b/homeassistant/components/teslemetry/strings.json index 69a99fa52f3..4ff78781c7f 100644 --- a/homeassistant/components/teslemetry/strings.json +++ b/homeassistant/components/teslemetry/strings.json @@ -262,71 +262,71 @@ "climate_state_seat_heater_left": { "name": "Seat heater front left", "state": { - "high": "High", - "low": "Low", - "medium": "Medium", + "high": "[%key:common::state::high%]", + "low": "[%key:common::state::low%]", + "medium": "[%key:common::state::medium%]", "off": "[%key:common::state::off%]" } }, "climate_state_seat_heater_rear_center": { "name": "Seat heater rear center", "state": { - "high": "[%key:component::teslemetry::entity::select::climate_state_seat_heater_left::state::high%]", - "low": "[%key:component::teslemetry::entity::select::climate_state_seat_heater_left::state::low%]", - "medium": "[%key:component::teslemetry::entity::select::climate_state_seat_heater_left::state::medium%]", + "high": "[%key:common::state::high%]", + "low": "[%key:common::state::low%]", + "medium": "[%key:common::state::medium%]", "off": "[%key:common::state::off%]" } }, "climate_state_seat_heater_rear_left": { "name": "Seat heater rear left", "state": { - "high": "[%key:component::teslemetry::entity::select::climate_state_seat_heater_left::state::high%]", - "low": "[%key:component::teslemetry::entity::select::climate_state_seat_heater_left::state::low%]", - "medium": "[%key:component::teslemetry::entity::select::climate_state_seat_heater_left::state::medium%]", + "high": "[%key:common::state::high%]", + "low": "[%key:common::state::low%]", + "medium": "[%key:common::state::medium%]", "off": "[%key:common::state::off%]" } }, "climate_state_seat_heater_rear_right": { "name": "Seat heater rear right", "state": { - "high": "[%key:component::teslemetry::entity::select::climate_state_seat_heater_left::state::high%]", - "low": "[%key:component::teslemetry::entity::select::climate_state_seat_heater_left::state::low%]", - "medium": "[%key:component::teslemetry::entity::select::climate_state_seat_heater_left::state::medium%]", + "high": "[%key:common::state::high%]", + "low": "[%key:common::state::low%]", + "medium": "[%key:common::state::medium%]", "off": "[%key:common::state::off%]" } }, "climate_state_seat_heater_right": { "name": "Seat heater front right", "state": { - "high": "[%key:component::teslemetry::entity::select::climate_state_seat_heater_left::state::high%]", - "low": "[%key:component::teslemetry::entity::select::climate_state_seat_heater_left::state::low%]", - "medium": "[%key:component::teslemetry::entity::select::climate_state_seat_heater_left::state::medium%]", + "high": "[%key:common::state::high%]", + "low": "[%key:common::state::low%]", + "medium": "[%key:common::state::medium%]", "off": "[%key:common::state::off%]" } }, "climate_state_seat_heater_third_row_left": { "name": "Seat heater third row left", "state": { - "high": "[%key:component::teslemetry::entity::select::climate_state_seat_heater_left::state::high%]", - "low": "[%key:component::teslemetry::entity::select::climate_state_seat_heater_left::state::low%]", - "medium": "[%key:component::teslemetry::entity::select::climate_state_seat_heater_left::state::medium%]", + "high": "[%key:common::state::high%]", + "low": "[%key:common::state::low%]", + "medium": "[%key:common::state::medium%]", "off": "[%key:common::state::off%]" } }, "climate_state_seat_heater_third_row_right": { "name": "Seat heater third row right", "state": { - "high": "[%key:component::teslemetry::entity::select::climate_state_seat_heater_left::state::high%]", - "low": "[%key:component::teslemetry::entity::select::climate_state_seat_heater_left::state::low%]", - "medium": "[%key:component::teslemetry::entity::select::climate_state_seat_heater_left::state::medium%]", + "high": "[%key:common::state::high%]", + "low": "[%key:common::state::low%]", + "medium": "[%key:common::state::medium%]", "off": "[%key:common::state::off%]" } }, "climate_state_steering_wheel_heat_level": { "name": "Steering wheel heater", "state": { - "high": "[%key:component::teslemetry::entity::select::climate_state_seat_heater_left::state::high%]", - "low": "[%key:component::teslemetry::entity::select::climate_state_seat_heater_left::state::low%]", + "high": "[%key:common::state::high%]", + "low": "[%key:common::state::low%]", "off": "[%key:common::state::off%]" } }, From 7751964db482092d2878a9222d7622288f278e36 Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Thu, 3 Apr 2025 23:48:33 +0200 Subject: [PATCH 259/505] Use common states for "Low"/"Medium"/"High" in `tesla_fleet` (#142211) --- .../components/tesla_fleet/strings.json | 46 +++++++++---------- 1 file changed, 23 insertions(+), 23 deletions(-) diff --git a/homeassistant/components/tesla_fleet/strings.json b/homeassistant/components/tesla_fleet/strings.json index c5a03e183e4..e4da161c63d 100644 --- a/homeassistant/components/tesla_fleet/strings.json +++ b/homeassistant/components/tesla_fleet/strings.json @@ -206,71 +206,71 @@ "climate_state_seat_heater_left": { "name": "Seat heater front left", "state": { - "high": "High", - "low": "Low", - "medium": "Medium", + "high": "[%key:common::state::high%]", + "low": "[%key:common::state::low%]", + "medium": "[%key:common::state::medium%]", "off": "[%key:common::state::off%]" } }, "climate_state_seat_heater_rear_center": { "name": "Seat heater rear center", "state": { - "high": "[%key:component::tesla_fleet::entity::select::climate_state_seat_heater_left::state::high%]", - "low": "[%key:component::tesla_fleet::entity::select::climate_state_seat_heater_left::state::low%]", - "medium": "[%key:component::tesla_fleet::entity::select::climate_state_seat_heater_left::state::medium%]", + "high": "[%key:common::state::high%]", + "low": "[%key:common::state::low%]", + "medium": "[%key:common::state::medium%]", "off": "[%key:common::state::off%]" } }, "climate_state_seat_heater_rear_left": { "name": "Seat heater rear left", "state": { - "high": "[%key:component::tesla_fleet::entity::select::climate_state_seat_heater_left::state::high%]", - "low": "[%key:component::tesla_fleet::entity::select::climate_state_seat_heater_left::state::low%]", - "medium": "[%key:component::tesla_fleet::entity::select::climate_state_seat_heater_left::state::medium%]", + "high": "[%key:common::state::high%]", + "low": "[%key:common::state::low%]", + "medium": "[%key:common::state::medium%]", "off": "[%key:common::state::off%]" } }, "climate_state_seat_heater_rear_right": { "name": "Seat heater rear right", "state": { - "high": "[%key:component::tesla_fleet::entity::select::climate_state_seat_heater_left::state::high%]", - "low": "[%key:component::tesla_fleet::entity::select::climate_state_seat_heater_left::state::low%]", - "medium": "[%key:component::tesla_fleet::entity::select::climate_state_seat_heater_left::state::medium%]", + "high": "[%key:common::state::high%]", + "low": "[%key:common::state::low%]", + "medium": "[%key:common::state::medium%]", "off": "[%key:common::state::off%]" } }, "climate_state_seat_heater_right": { "name": "Seat heater front right", "state": { - "high": "[%key:component::tesla_fleet::entity::select::climate_state_seat_heater_left::state::high%]", - "low": "[%key:component::tesla_fleet::entity::select::climate_state_seat_heater_left::state::low%]", - "medium": "[%key:component::tesla_fleet::entity::select::climate_state_seat_heater_left::state::medium%]", + "high": "[%key:common::state::high%]", + "low": "[%key:common::state::low%]", + "medium": "[%key:common::state::medium%]", "off": "[%key:common::state::off%]" } }, "climate_state_seat_heater_third_row_left": { "name": "Seat heater third row left", "state": { - "high": "[%key:component::tesla_fleet::entity::select::climate_state_seat_heater_left::state::high%]", - "low": "[%key:component::tesla_fleet::entity::select::climate_state_seat_heater_left::state::low%]", - "medium": "[%key:component::tesla_fleet::entity::select::climate_state_seat_heater_left::state::medium%]", + "high": "[%key:common::state::high%]", + "low": "[%key:common::state::low%]", + "medium": "[%key:common::state::medium%]", "off": "[%key:common::state::off%]" } }, "climate_state_seat_heater_third_row_right": { "name": "Seat heater third row right", "state": { - "high": "[%key:component::tesla_fleet::entity::select::climate_state_seat_heater_left::state::high%]", - "low": "[%key:component::tesla_fleet::entity::select::climate_state_seat_heater_left::state::low%]", - "medium": "[%key:component::tesla_fleet::entity::select::climate_state_seat_heater_left::state::medium%]", + "high": "[%key:common::state::high%]", + "low": "[%key:common::state::low%]", + "medium": "[%key:common::state::medium%]", "off": "[%key:common::state::off%]" } }, "climate_state_steering_wheel_heat_level": { "name": "Steering wheel heater", "state": { - "high": "[%key:component::tesla_fleet::entity::select::climate_state_seat_heater_left::state::high%]", - "low": "[%key:component::tesla_fleet::entity::select::climate_state_seat_heater_left::state::low%]", + "high": "[%key:common::state::high%]", + "low": "[%key:common::state::low%]", "off": "[%key:common::state::off%]" } }, From b9e17c6cc68f636fe46b2728038ed55fec4ca5cb Mon Sep 17 00:00:00 2001 From: DeerMaximum <43999966+DeerMaximum@users.noreply.github.com> Date: Thu, 3 Apr 2025 23:50:06 +0200 Subject: [PATCH 260/505] Bump pynina to 0.3.5 (#142218) --- homeassistant/components/nina/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/nina/manifest.json b/homeassistant/components/nina/manifest.json index 45212c0220b..8bb9a347373 100644 --- a/homeassistant/components/nina/manifest.json +++ b/homeassistant/components/nina/manifest.json @@ -6,6 +6,6 @@ "documentation": "https://www.home-assistant.io/integrations/nina", "iot_class": "cloud_polling", "loggers": ["pynina"], - "requirements": ["PyNINA==0.3.4"], + "requirements": ["PyNINA==0.3.5"], "single_config_entry": true } diff --git a/requirements_all.txt b/requirements_all.txt index 56a79ff1c1c..a297732529e 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -70,7 +70,7 @@ PyMetno==0.13.0 PyMicroBot==0.0.17 # homeassistant.components.nina -PyNINA==0.3.4 +PyNINA==0.3.5 # homeassistant.components.mobile_app # homeassistant.components.owntracks diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 4f37fc47279..0bacc580831 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -67,7 +67,7 @@ PyMetno==0.13.0 PyMicroBot==0.0.17 # homeassistant.components.nina -PyNINA==0.3.4 +PyNINA==0.3.5 # homeassistant.components.mobile_app # homeassistant.components.owntracks From 7152c8659178ba02d676dfdfbbd3d6dd234b9ade Mon Sep 17 00:00:00 2001 From: puddly <32534428+puddly@users.noreply.github.com> Date: Thu, 3 Apr 2025 21:09:13 -0400 Subject: [PATCH 261/505] Hide broken ZBT-1 config entries on the hardware page (#142110) * Hide bad ZBT-1 config entries on the hardware page * Set up the bad config entry in the unit test * Roll into a list comprehension * Remove constant changes * Fix condition in unit test --- .../homeassistant_sky_connect/hardware.py | 8 +++++++- .../homeassistant_sky_connect/test_hardware.py | 18 +++++++++++++++++- 2 files changed, 24 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/homeassistant_sky_connect/hardware.py b/homeassistant/components/homeassistant_sky_connect/hardware.py index 2872077111a..9bfa5d16655 100644 --- a/homeassistant/components/homeassistant_sky_connect/hardware.py +++ b/homeassistant/components/homeassistant_sky_connect/hardware.py @@ -5,17 +5,21 @@ from __future__ import annotations from homeassistant.components.hardware.models import HardwareInfo, USBInfo from homeassistant.core import HomeAssistant, callback +from .config_flow import HomeAssistantSkyConnectConfigFlow from .const import DOMAIN from .util import get_hardware_variant DOCUMENTATION_URL = "https://skyconnect.home-assistant.io/documentation/" +EXPECTED_ENTRY_VERSION = ( + HomeAssistantSkyConnectConfigFlow.VERSION, + HomeAssistantSkyConnectConfigFlow.MINOR_VERSION, +) @callback def async_info(hass: HomeAssistant) -> list[HardwareInfo]: """Return board info.""" entries = hass.config_entries.async_entries(DOMAIN) - return [ HardwareInfo( board=None, @@ -31,4 +35,6 @@ def async_info(hass: HomeAssistant) -> list[HardwareInfo]: url=DOCUMENTATION_URL, ) for entry in entries + # Ignore unmigrated config entries in the hardware page + if (entry.version, entry.minor_version) == EXPECTED_ENTRY_VERSION ] diff --git a/tests/components/homeassistant_sky_connect/test_hardware.py b/tests/components/homeassistant_sky_connect/test_hardware.py index f39e648b0f2..e59a1e7df06 100644 --- a/tests/components/homeassistant_sky_connect/test_hardware.py +++ b/tests/components/homeassistant_sky_connect/test_hardware.py @@ -28,6 +28,10 @@ CONFIG_ENTRY_DATA_2 = { "firmware": "ezsp", } +CONFIG_ENTRY_DATA_BAD = { + "device": "/dev/serial/by-id/usb-Nabu_Casa_Home_Assistant_Connect_ZBT-1_a87b7d75b18beb119fe564a0f320645d-if00-port0", +} + async def test_hardware_info( hass: HomeAssistant, hass_ws_client: WebSocketGenerator, addon_store_info @@ -59,9 +63,20 @@ async def test_hardware_info( minor_version=2, ) config_entry_2.add_to_hass(hass) - assert await hass.config_entries.async_setup(config_entry_2.entry_id) + config_entry_bad = MockConfigEntry( + data=CONFIG_ENTRY_DATA_BAD, + domain=DOMAIN, + options={}, + title="Home Assistant Connect ZBT-1", + unique_id="unique_3", + version=1, + minor_version=2, + ) + config_entry_bad.add_to_hass(hass) + assert not await hass.config_entries.async_setup(config_entry_bad.entry_id) + client = await hass_ws_client(hass) await client.send_json({"id": 1, "type": "hardware/info"}) @@ -97,5 +112,6 @@ async def test_hardware_info( "name": "Home Assistant Connect ZBT-1", "url": "https://skyconnect.home-assistant.io/documentation/", }, + # Bad entry is skipped ] } From 5424fa0a00e0b6904a82c198ddf6e2cc18f79331 Mon Sep 17 00:00:00 2001 From: Thomas55555 <59625598+Thomas55555@users.noreply.github.com> Date: Fri, 4 Apr 2025 07:21:40 +0200 Subject: [PATCH 262/505] Bump ical to 9.1.0 (#142197) --- homeassistant/components/google/manifest.json | 2 +- homeassistant/components/local_calendar/manifest.json | 2 +- homeassistant/components/local_todo/manifest.json | 2 +- homeassistant/components/remote_calendar/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 6 files changed, 6 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/google/manifest.json b/homeassistant/components/google/manifest.json index efce97a0d6f..2bedc7a3163 100644 --- a/homeassistant/components/google/manifest.json +++ b/homeassistant/components/google/manifest.json @@ -7,5 +7,5 @@ "documentation": "https://www.home-assistant.io/integrations/google", "iot_class": "cloud_polling", "loggers": ["googleapiclient"], - "requirements": ["gcal-sync==7.0.0", "oauth2client==4.1.3", "ical==9.0.3"] + "requirements": ["gcal-sync==7.0.0", "oauth2client==4.1.3", "ical==9.1.0"] } diff --git a/homeassistant/components/local_calendar/manifest.json b/homeassistant/components/local_calendar/manifest.json index 528552aaa57..90cd5a6d2ac 100644 --- a/homeassistant/components/local_calendar/manifest.json +++ b/homeassistant/components/local_calendar/manifest.json @@ -7,5 +7,5 @@ "documentation": "https://www.home-assistant.io/integrations/local_calendar", "iot_class": "local_polling", "loggers": ["ical"], - "requirements": ["ical==9.0.3"] + "requirements": ["ical==9.1.0"] } diff --git a/homeassistant/components/local_todo/manifest.json b/homeassistant/components/local_todo/manifest.json index 6f117131c20..a630c18c669 100644 --- a/homeassistant/components/local_todo/manifest.json +++ b/homeassistant/components/local_todo/manifest.json @@ -5,5 +5,5 @@ "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/local_todo", "iot_class": "local_polling", - "requirements": ["ical==9.0.3"] + "requirements": ["ical==9.1.0"] } diff --git a/homeassistant/components/remote_calendar/manifest.json b/homeassistant/components/remote_calendar/manifest.json index 256f5baf0ff..da078395484 100644 --- a/homeassistant/components/remote_calendar/manifest.json +++ b/homeassistant/components/remote_calendar/manifest.json @@ -8,5 +8,5 @@ "iot_class": "cloud_polling", "loggers": ["ical"], "quality_scale": "silver", - "requirements": ["ical==9.0.3"] + "requirements": ["ical==9.1.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index a297732529e..d8d04d55a7e 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1196,7 +1196,7 @@ ibmiotf==0.3.4 # homeassistant.components.local_calendar # homeassistant.components.local_todo # homeassistant.components.remote_calendar -ical==9.0.3 +ical==9.1.0 # homeassistant.components.caldav icalendar==6.1.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 0bacc580831..a92dd9128c0 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1014,7 +1014,7 @@ ibeacon-ble==1.2.0 # homeassistant.components.local_calendar # homeassistant.components.local_todo # homeassistant.components.remote_calendar -ical==9.0.3 +ical==9.1.0 # homeassistant.components.caldav icalendar==6.1.0 From 95ffa20bd5048041f810b5193a8a8c253b5ff712 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 3 Apr 2025 20:43:04 -1000 Subject: [PATCH 263/505] Bump bleak-esphome to 2.13.1 (#142233) * Bump bleak-esphome to 2.13.0 changelog: https://github.com/Bluetooth-Devices/bleak-esphome/compare/v2.12.0...v2.13.0 * 13.1 --- homeassistant/components/eq3btsmart/manifest.json | 2 +- homeassistant/components/esphome/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/eq3btsmart/manifest.json b/homeassistant/components/eq3btsmart/manifest.json index ab62c962982..d99de32b09c 100644 --- a/homeassistant/components/eq3btsmart/manifest.json +++ b/homeassistant/components/eq3btsmart/manifest.json @@ -22,5 +22,5 @@ "integration_type": "device", "iot_class": "local_polling", "loggers": ["eq3btsmart"], - "requirements": ["eq3btsmart==1.4.1", "bleak-esphome==2.12.0"] + "requirements": ["eq3btsmart==1.4.1", "bleak-esphome==2.13.1"] } diff --git a/homeassistant/components/esphome/manifest.json b/homeassistant/components/esphome/manifest.json index 954968f5e2c..bd81e122981 100644 --- a/homeassistant/components/esphome/manifest.json +++ b/homeassistant/components/esphome/manifest.json @@ -18,7 +18,7 @@ "requirements": [ "aioesphomeapi==29.8.0", "esphome-dashboard-api==1.2.3", - "bleak-esphome==2.12.0" + "bleak-esphome==2.13.1" ], "zeroconf": ["_esphomelib._tcp.local."] } diff --git a/requirements_all.txt b/requirements_all.txt index d8d04d55a7e..9228d9bb865 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -603,7 +603,7 @@ bizkaibus==0.1.1 # homeassistant.components.eq3btsmart # homeassistant.components.esphome -bleak-esphome==2.12.0 +bleak-esphome==2.13.1 # homeassistant.components.bluetooth bleak-retry-connector==3.9.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index a92dd9128c0..53979b58c0d 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -534,7 +534,7 @@ bimmer-connected[china]==0.17.2 # homeassistant.components.eq3btsmart # homeassistant.components.esphome -bleak-esphome==2.12.0 +bleak-esphome==2.13.1 # homeassistant.components.bluetooth bleak-retry-connector==3.9.0 From 471b05ff4b81e166a0a4e5ef22b75cbe054b5f07 Mon Sep 17 00:00:00 2001 From: tdfountain <174762217+tdfountain@users.noreply.github.com> Date: Thu, 3 Apr 2025 23:45:52 -0700 Subject: [PATCH 264/505] Improve config entry type hints in NUT (#142237) Fix config entry type hints --- homeassistant/components/nut/__init__.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/nut/__init__.py b/homeassistant/components/nut/__init__.py index 3c67b28196a..9e1e77a2aaf 100644 --- a/homeassistant/components/nut/__init__.py +++ b/homeassistant/components/nut/__init__.py @@ -180,12 +180,12 @@ async def async_setup_entry(hass: HomeAssistant, entry: NutConfigEntry) -> bool: return True -async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_unload_entry(hass: HomeAssistant, entry: NutConfigEntry) -> bool: """Unload a config entry.""" return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) -async def _async_update_listener(hass: HomeAssistant, entry: ConfigEntry) -> None: +async def _async_update_listener(hass: HomeAssistant, entry: NutConfigEntry) -> None: """Handle options update.""" await hass.config_entries.async_reload(entry.entry_id) From 88455702bb08594daca31a18d44737423465a87f Mon Sep 17 00:00:00 2001 From: Brett Adams Date: Fri, 4 Apr 2025 16:51:09 +1000 Subject: [PATCH 265/505] Slow down polling in Tesla Fleet (#142130) * Slow down polling * Fix tests --- .../components/tesla_fleet/coordinator.py | 2 +- tests/components/tesla_fleet/test_init.py | 88 ++++++++++--------- 2 files changed, 46 insertions(+), 44 deletions(-) diff --git a/homeassistant/components/tesla_fleet/coordinator.py b/homeassistant/components/tesla_fleet/coordinator.py index 50a69258a31..20d2d70b5dc 100644 --- a/homeassistant/components/tesla_fleet/coordinator.py +++ b/homeassistant/components/tesla_fleet/coordinator.py @@ -27,7 +27,7 @@ if TYPE_CHECKING: from .const import ENERGY_HISTORY_FIELDS, LOGGER, TeslaFleetState -VEHICLE_INTERVAL_SECONDS = 300 +VEHICLE_INTERVAL_SECONDS = 600 VEHICLE_INTERVAL = timedelta(seconds=VEHICLE_INTERVAL_SECONDS) VEHICLE_WAIT = timedelta(minutes=15) diff --git a/tests/components/tesla_fleet/test_init.py b/tests/components/tesla_fleet/test_init.py index ff103ce03c2..7bd90a3568c 100644 --- a/tests/components/tesla_fleet/test_init.py +++ b/tests/components/tesla_fleet/test_init.py @@ -1,6 +1,7 @@ """Test the Tesla Fleet init.""" from copy import deepcopy +from datetime import timedelta from unittest.mock import AsyncMock, patch from aiohttp import RequestInfo @@ -231,57 +232,58 @@ async def test_vehicle_sleep( freezer: FrozenDateTimeFactory, ) -> None: """Test coordinator refresh with an error.""" - await setup_platform(hass, normal_config_entry) - assert mock_vehicle_data.call_count == 1 - freezer.tick(VEHICLE_WAIT + VEHICLE_INTERVAL) - async_fire_time_changed(hass) - # Let vehicle sleep, no updates for 15 minutes - await hass.async_block_till_done() - assert mock_vehicle_data.call_count == 2 + TEST_INTERVAL = timedelta(seconds=120) - freezer.tick(VEHICLE_INTERVAL) - async_fire_time_changed(hass) - # No polling, call_count should not increase - await hass.async_block_till_done() - assert mock_vehicle_data.call_count == 2 + with patch( + "homeassistant.components.tesla_fleet.coordinator.VEHICLE_INTERVAL", + TEST_INTERVAL, + ): + await setup_platform(hass, normal_config_entry) + assert mock_vehicle_data.call_count == 1 - freezer.tick(VEHICLE_INTERVAL) - async_fire_time_changed(hass) - # No polling, call_count should not increase - await hass.async_block_till_done() - assert mock_vehicle_data.call_count == 2 + freezer.tick(VEHICLE_WAIT + TEST_INTERVAL) + async_fire_time_changed(hass) + # Let vehicle sleep, no updates for 15 minutes + await hass.async_block_till_done() + assert mock_vehicle_data.call_count == 2 - freezer.tick(VEHICLE_WAIT) - async_fire_time_changed(hass) - # Vehicle didn't sleep, go back to normal - await hass.async_block_till_done() - assert mock_vehicle_data.call_count == 3 + freezer.tick(TEST_INTERVAL) + async_fire_time_changed(hass) + # No polling, call_count should not increase + await hass.async_block_till_done() + assert mock_vehicle_data.call_count == 2 - freezer.tick(VEHICLE_INTERVAL) - async_fire_time_changed(hass) - # Regular polling - await hass.async_block_till_done() - assert mock_vehicle_data.call_count == 4 + freezer.tick(VEHICLE_WAIT) + async_fire_time_changed(hass) + # Vehicle didn't sleep, go back to normal + await hass.async_block_till_done() + assert mock_vehicle_data.call_count == 3 - mock_vehicle_data.return_value = VEHICLE_DATA_ALT - freezer.tick(VEHICLE_INTERVAL) - async_fire_time_changed(hass) - # Vehicle active - await hass.async_block_till_done() - assert mock_vehicle_data.call_count == 5 + freezer.tick(TEST_INTERVAL) + async_fire_time_changed(hass) + # Regular polling + await hass.async_block_till_done() + assert mock_vehicle_data.call_count == 4 - freezer.tick(VEHICLE_WAIT) - async_fire_time_changed(hass) - # Dont let sleep when active - await hass.async_block_till_done() - assert mock_vehicle_data.call_count == 6 + mock_vehicle_data.return_value = VEHICLE_DATA_ALT + freezer.tick(TEST_INTERVAL) + async_fire_time_changed(hass) + # Vehicle active + await hass.async_block_till_done() + assert mock_vehicle_data.call_count == 5 - freezer.tick(VEHICLE_WAIT) - async_fire_time_changed(hass) - # Dont let sleep when active - await hass.async_block_till_done() - assert mock_vehicle_data.call_count == 7 + freezer.tick(TEST_INTERVAL) + async_fire_time_changed(hass) + # Dont let sleep when active + await hass.async_block_till_done() + assert mock_vehicle_data.call_count == 6 + + freezer.tick(TEST_INTERVAL) + async_fire_time_changed(hass) + # Dont let sleep when active + await hass.async_block_till_done() + assert mock_vehicle_data.call_count == 7 # Test Energy Live Coordinator From 1cc8a170e6ce139a2137cd52a557f01e8467fc8b Mon Sep 17 00:00:00 2001 From: Brett Adams Date: Fri, 4 Apr 2025 16:54:13 +1000 Subject: [PATCH 266/505] Bump teslemetry-stream (#142234) bump stream --- homeassistant/components/teslemetry/binary_sensor.py | 2 +- homeassistant/components/teslemetry/manifest.json | 2 +- homeassistant/components/teslemetry/sensor.py | 2 ++ requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 5 files changed, 6 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/teslemetry/binary_sensor.py b/homeassistant/components/teslemetry/binary_sensor.py index 9d14df4501b..d0ba48d281e 100644 --- a/homeassistant/components/teslemetry/binary_sensor.py +++ b/homeassistant/components/teslemetry/binary_sensor.py @@ -293,7 +293,7 @@ VEHICLE_DESCRIPTIONS: tuple[TeslemetryBinarySensorEntityDescription, ...] = ( ), TeslemetryBinarySensorEntityDescription( key="dc_dc_enable", - streaming_key=Signal.DC_DC_ENABLE, + streaming_key=Signal.DCDC_ENABLE, entity_registry_enabled_default=False, ), TeslemetryBinarySensorEntityDescription( diff --git a/homeassistant/components/teslemetry/manifest.json b/homeassistant/components/teslemetry/manifest.json index a8f1fd0daec..4c21bb017d8 100644 --- a/homeassistant/components/teslemetry/manifest.json +++ b/homeassistant/components/teslemetry/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/teslemetry", "iot_class": "cloud_polling", "loggers": ["tesla-fleet-api"], - "requirements": ["tesla-fleet-api==1.0.17", "teslemetry-stream==0.6.12"] + "requirements": ["tesla-fleet-api==1.0.17", "teslemetry-stream==0.7.1"] } diff --git a/homeassistant/components/teslemetry/sensor.py b/homeassistant/components/teslemetry/sensor.py index b1c6b487bf9..1ba4536ac2b 100644 --- a/homeassistant/components/teslemetry/sensor.py +++ b/homeassistant/components/teslemetry/sensor.py @@ -124,6 +124,8 @@ VEHICLE_DESCRIPTIONS: tuple[TeslemetryVehicleSensorEntityDescription, ...] = ( TeslemetryVehicleSensorEntityDescription( key="charge_state_charger_voltage", polling=True, + streaming_key=Signal.CHARGER_VOLTAGE, + streaming_firmware="2024.44.32", state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=UnitOfElectricPotential.VOLT, device_class=SensorDeviceClass.VOLTAGE, diff --git a/requirements_all.txt b/requirements_all.txt index 9228d9bb865..929d7df13fb 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2887,7 +2887,7 @@ tesla-powerwall==0.5.2 tesla-wall-connector==1.0.2 # homeassistant.components.teslemetry -teslemetry-stream==0.6.12 +teslemetry-stream==0.7.1 # homeassistant.components.tessie tessie-api==0.1.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 53979b58c0d..80dbfca65b6 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2325,7 +2325,7 @@ tesla-powerwall==0.5.2 tesla-wall-connector==1.0.2 # homeassistant.components.teslemetry -teslemetry-stream==0.6.12 +teslemetry-stream==0.7.1 # homeassistant.components.tessie tessie-api==0.1.1 From 5e04347f135385e4ba0e9c99e5e6b856cba78d40 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 3 Apr 2025 20:56:41 -1000 Subject: [PATCH 267/505] Bump bluetooth-data-tools to 1.27.0 (#142221) changelog: https://github.com/Bluetooth-Devices/bluetooth-data-tools/compare/v1.26.5...v1.27.0 --- homeassistant/components/bluetooth/manifest.json | 2 +- homeassistant/components/ld2410_ble/manifest.json | 2 +- homeassistant/components/led_ble/manifest.json | 2 +- homeassistant/components/private_ble_device/manifest.json | 2 +- homeassistant/package_constraints.txt | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 7 files changed, 7 insertions(+), 7 deletions(-) diff --git a/homeassistant/components/bluetooth/manifest.json b/homeassistant/components/bluetooth/manifest.json index 1b2b0e7267b..d13411b62c4 100644 --- a/homeassistant/components/bluetooth/manifest.json +++ b/homeassistant/components/bluetooth/manifest.json @@ -19,7 +19,7 @@ "bleak-retry-connector==3.9.0", "bluetooth-adapters==0.21.4", "bluetooth-auto-recovery==1.4.5", - "bluetooth-data-tools==1.26.5", + "bluetooth-data-tools==1.27.0", "dbus-fast==2.43.0", "habluetooth==3.37.0" ] diff --git a/homeassistant/components/ld2410_ble/manifest.json b/homeassistant/components/ld2410_ble/manifest.json index 764345710dd..3d8f8793e25 100644 --- a/homeassistant/components/ld2410_ble/manifest.json +++ b/homeassistant/components/ld2410_ble/manifest.json @@ -20,5 +20,5 @@ "documentation": "https://www.home-assistant.io/integrations/ld2410_ble", "integration_type": "device", "iot_class": "local_push", - "requirements": ["bluetooth-data-tools==1.26.5", "ld2410-ble==0.1.1"] + "requirements": ["bluetooth-data-tools==1.27.0", "ld2410-ble==0.1.1"] } diff --git a/homeassistant/components/led_ble/manifest.json b/homeassistant/components/led_ble/manifest.json index b88ef3f029a..62ad21eb99a 100644 --- a/homeassistant/components/led_ble/manifest.json +++ b/homeassistant/components/led_ble/manifest.json @@ -35,5 +35,5 @@ "dependencies": ["bluetooth_adapters"], "documentation": "https://www.home-assistant.io/integrations/led_ble", "iot_class": "local_polling", - "requirements": ["bluetooth-data-tools==1.26.5", "led-ble==1.1.6"] + "requirements": ["bluetooth-data-tools==1.27.0", "led-ble==1.1.6"] } diff --git a/homeassistant/components/private_ble_device/manifest.json b/homeassistant/components/private_ble_device/manifest.json index df24f536527..ceafd8dc4f7 100644 --- a/homeassistant/components/private_ble_device/manifest.json +++ b/homeassistant/components/private_ble_device/manifest.json @@ -6,5 +6,5 @@ "dependencies": ["bluetooth_adapters"], "documentation": "https://www.home-assistant.io/integrations/private_ble_device", "iot_class": "local_push", - "requirements": ["bluetooth-data-tools==1.26.5"] + "requirements": ["bluetooth-data-tools==1.27.0"] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 28ff8861052..5d2d6171c6c 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -24,7 +24,7 @@ bleak-retry-connector==3.9.0 bleak==0.22.3 bluetooth-adapters==0.21.4 bluetooth-auto-recovery==1.4.5 -bluetooth-data-tools==1.26.5 +bluetooth-data-tools==1.27.0 cached-ipaddress==0.10.0 certifi>=2021.5.30 ciso8601==2.3.2 diff --git a/requirements_all.txt b/requirements_all.txt index 929d7df13fb..3738d44aef0 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -639,7 +639,7 @@ bluetooth-auto-recovery==1.4.5 # homeassistant.components.ld2410_ble # homeassistant.components.led_ble # homeassistant.components.private_ble_device -bluetooth-data-tools==1.26.5 +bluetooth-data-tools==1.27.0 # homeassistant.components.bond bond-async==0.2.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 80dbfca65b6..b2849725e6f 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -564,7 +564,7 @@ bluetooth-auto-recovery==1.4.5 # homeassistant.components.ld2410_ble # homeassistant.components.led_ble # homeassistant.components.private_ble_device -bluetooth-data-tools==1.26.5 +bluetooth-data-tools==1.27.0 # homeassistant.components.bond bond-async==0.2.1 From b7d9ad1c7d2fbffaf925e69366358c80cdfc2a51 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=2E=20Diego=20Rodr=C3=ADguez=20Royo?= Date: Fri, 4 Apr 2025 09:12:57 +0200 Subject: [PATCH 268/505] Bump aiohomeconnect to 0.17.0 (#142244) --- homeassistant/components/home_connect/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/home_connect/manifest.json b/homeassistant/components/home_connect/manifest.json index 62892e7c85b..c5e277c4974 100644 --- a/homeassistant/components/home_connect/manifest.json +++ b/homeassistant/components/home_connect/manifest.json @@ -7,6 +7,6 @@ "documentation": "https://www.home-assistant.io/integrations/home_connect", "iot_class": "cloud_push", "loggers": ["aiohomeconnect"], - "requirements": ["aiohomeconnect==0.16.3"], + "requirements": ["aiohomeconnect==0.17.0"], "single_config_entry": true } diff --git a/requirements_all.txt b/requirements_all.txt index 3738d44aef0..ce05b71dc81 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -264,7 +264,7 @@ aioharmony==0.5.2 aiohasupervisor==0.3.0 # homeassistant.components.home_connect -aiohomeconnect==0.16.3 +aiohomeconnect==0.17.0 # homeassistant.components.homekit_controller aiohomekit==3.2.13 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index b2849725e6f..2cdf3198987 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -249,7 +249,7 @@ aioharmony==0.5.2 aiohasupervisor==0.3.0 # homeassistant.components.home_connect -aiohomeconnect==0.16.3 +aiohomeconnect==0.17.0 # homeassistant.components.homekit_controller aiohomekit==3.2.13 From b5721604b9b59b21b2b14c799366effeb6361caa Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Fri, 4 Apr 2025 09:45:36 +0200 Subject: [PATCH 269/505] Use common states for "Low"/"Medium"/"High" in `lg_thinq` (#142253) --- .../components/lg_thinq/strings.json | 24 +++++++++---------- 1 file changed, 12 insertions(+), 12 deletions(-) diff --git a/homeassistant/components/lg_thinq/strings.json b/homeassistant/components/lg_thinq/strings.json index bb3865254a3..767c984da3a 100644 --- a/homeassistant/components/lg_thinq/strings.json +++ b/homeassistant/components/lg_thinq/strings.json @@ -119,9 +119,9 @@ "fan_mode": { "state": { "slow": "Slow", - "low": "Low", - "mid": "Medium", - "high": "High", + "low": "[%key:common::state::low%]", + "mid": "[%key:common::state::medium%]", + "high": "[%key:common::state::high%]", "power": "[%key:component::lg_thinq::entity::sensor::current_job_mode::state::high%]", "auto": "[%key:component::lg_thinq::entity::sensor::growth_mode::state::standard%]" } @@ -390,17 +390,17 @@ "temperature_state": { "name": "[%key:component::sensor::entity_component::temperature::name%]", "state": { - "high": "High", + "high": "[%key:common::state::high%]", "normal": "Good", - "low": "Low" + "low": "[%key:common::state::low%]" } }, "temperature_state_for_location": { "name": "[%key:component::lg_thinq::entity::number::target_temperature_for_location::name%]", "state": { - "high": "[%key:component::lg_thinq::entity::sensor::temperature_state::state::high%]", + "high": "[%key:common::state::high%]", "normal": "[%key:component::lg_thinq::entity::sensor::temperature_state::state::normal%]", - "low": "[%key:component::lg_thinq::entity::sensor::temperature_state::state::low%]" + "low": "[%key:common::state::low%]" } }, "current_state": { @@ -781,8 +781,8 @@ "name": "Battery", "state": { "high": "Full", - "mid": "Medium", - "low": "Low", + "mid": "[%key:common::state::medium%]", + "low": "[%key:common::state::low%]", "warning": "Empty" } }, @@ -876,9 +876,9 @@ "name": "Speed", "state": { "slow": "[%key:component::lg_thinq::entity::climate::climate_air_conditioner::state_attributes::fan_mode::state::slow%]", - "low": "Low", - "mid": "Medium", - "high": "High", + "low": "[%key:common::state::low%]", + "mid": "[%key:common::state::medium%]", + "high": "[%key:common::state::high%]", "power": "Turbo", "turbo": "[%key:component::lg_thinq::entity::select::wind_strength::state::power%]", "auto": "[%key:component::lg_thinq::entity::sensor::growth_mode::state::standard%]", From 9ed8419b5d98192ddcb8d799b739f6164165d293 Mon Sep 17 00:00:00 2001 From: tdfountain <174762217+tdfountain@users.noreply.github.com> Date: Fri, 4 Apr 2025 00:48:39 -0700 Subject: [PATCH 270/505] Add device class ENUM and options for sensors in NUT (#142242) Add device class ENUM and options for sensors --- homeassistant/components/nut/sensor.py | 32 ++++++++++++++++-- homeassistant/components/nut/strings.json | 41 ++++++++++++++++++++--- 2 files changed, 66 insertions(+), 7 deletions(-) diff --git a/homeassistant/components/nut/sensor.py b/homeassistant/components/nut/sensor.py index 1781615b0f9..5822f7f7b02 100644 --- a/homeassistant/components/nut/sensor.py +++ b/homeassistant/components/nut/sensor.py @@ -40,13 +40,31 @@ AMBIENT_SENSORS = { "ambient.temperature", "ambient.temperature.status", } -AMBIENT_THRESHOLD_STATUS_OPTIONS = [ +BATTERY_CHARGER_STATUS_OPTIONS = [ + "charging", + "discharging", + "floating", + "resting", + "unknown", + "disabled", + "off", +] +FREQUENCY_STATUS_OPTIONS = [ + "good", + "out-of-range", +] +THRESHOLD_STATUS_OPTIONS = [ "good", "warning-low", "critical-low", "warning-high", "critical-high", ] +UPS_BEEPER_STATUS_OPTIONS = [ + "enabled", + "disabled", + "muted", +] _LOGGER = logging.getLogger(__name__) @@ -64,7 +82,7 @@ SENSOR_TYPES: Final[dict[str, SensorEntityDescription]] = { key="ambient.humidity.status", translation_key="ambient_humidity_status", device_class=SensorDeviceClass.ENUM, - options=AMBIENT_THRESHOLD_STATUS_OPTIONS, + options=THRESHOLD_STATUS_OPTIONS, entity_category=EntityCategory.DIAGNOSTIC, ), "ambient.temperature": SensorEntityDescription( @@ -79,7 +97,7 @@ SENSOR_TYPES: Final[dict[str, SensorEntityDescription]] = { key="ambient.temperature.status", translation_key="ambient_temperature_status", device_class=SensorDeviceClass.ENUM, - options=AMBIENT_THRESHOLD_STATUS_OPTIONS, + options=THRESHOLD_STATUS_OPTIONS, entity_category=EntityCategory.DIAGNOSTIC, ), "battery.alarm.threshold": SensorEntityDescription( @@ -126,6 +144,8 @@ SENSOR_TYPES: Final[dict[str, SensorEntityDescription]] = { "battery.charger.status": SensorEntityDescription( key="battery.charger.status", translation_key="battery_charger_status", + device_class=SensorDeviceClass.ENUM, + options=BATTERY_CHARGER_STATUS_OPTIONS, ), "battery.current": SensorEntityDescription( key="battery.current", @@ -374,6 +394,8 @@ SENSOR_TYPES: Final[dict[str, SensorEntityDescription]] = { "input.current.status": SensorEntityDescription( key="input.current.status", translation_key="input_current_status", + device_class=SensorDeviceClass.ENUM, + options=THRESHOLD_STATUS_OPTIONS, entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, ), @@ -397,6 +419,8 @@ SENSOR_TYPES: Final[dict[str, SensorEntityDescription]] = { "input.frequency.status": SensorEntityDescription( key="input.frequency.status", translation_key="input_frequency_status", + device_class=SensorDeviceClass.ENUM, + options=FREQUENCY_STATUS_OPTIONS, entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, ), @@ -792,6 +816,8 @@ SENSOR_TYPES: Final[dict[str, SensorEntityDescription]] = { "ups.beeper.status": SensorEntityDescription( key="ups.beeper.status", translation_key="ups_beeper_status", + device_class=SensorDeviceClass.ENUM, + options=UPS_BEEPER_STATUS_OPTIONS, entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, ), diff --git a/homeassistant/components/nut/strings.json b/homeassistant/components/nut/strings.json index bda377b9bae..0830b806bd0 100644 --- a/homeassistant/components/nut/strings.json +++ b/homeassistant/components/nut/strings.json @@ -104,7 +104,18 @@ "battery_charge_low": { "name": "Low battery setpoint" }, "battery_charge_restart": { "name": "Minimum battery to start" }, "battery_charge_warning": { "name": "Warning battery setpoint" }, - "battery_charger_status": { "name": "Charging status" }, + "battery_charger_status": { + "name": "Charging status", + "state": { + "charging": "Charging", + "discharging": "Discharging", + "floating": "Floating", + "resting": "Resting", + "unknown": "Unknown", + "disabled": "Disabled", + "off": "Off" + } + }, "battery_current": { "name": "Battery current" }, "battery_current_total": { "name": "Total battery current" }, "battery_date": { "name": "Battery date" }, @@ -135,10 +146,25 @@ "input_bypass_realpower": { "name": "Input bypass real power" }, "input_bypass_voltage": { "name": "Input bypass voltage" }, "input_current": { "name": "Input current" }, - "input_current_status": { "name": "Input current status" }, + "input_current_status": { + "name": "Input current status", + "state": { + "good": "[%key:component::nut::entity::sensor::ambient_humidity_status::state::good%]", + "warning-low": "[%key:component::nut::entity::sensor::ambient_humidity_status::state::warning-low%]", + "critical-low": "[%key:component::nut::entity::sensor::ambient_humidity_status::state::critical-low%]", + "warning-high": "[%key:component::nut::entity::sensor::ambient_humidity_status::state::warning-high%]", + "critical-high": "[%key:component::nut::entity::sensor::ambient_humidity_status::state::critical-high%]" + } + }, "input_frequency": { "name": "Input frequency" }, "input_frequency_nominal": { "name": "Input nominal frequency" }, - "input_frequency_status": { "name": "Input frequency status" }, + "input_frequency_status": { + "name": "Input frequency status", + "state": { + "good": "Good", + "out-of-range": "Out of range" + } + }, "input_l1_current": { "name": "Input L1 current" }, "input_l1_frequency": { "name": "Input L1 line frequency" }, "input_l1_n_voltage": { "name": "Input L1 voltage" }, @@ -194,7 +220,14 @@ "output_voltage": { "name": "Output voltage" }, "output_voltage_nominal": { "name": "Nominal output voltage" }, "ups_alarm": { "name": "Alarms" }, - "ups_beeper_status": { "name": "Beeper status" }, + "ups_beeper_status": { + "name": "Beeper status", + "state": { + "enabled": "Enabled", + "disabled": "Disabled", + "muted": "Muted" + } + }, "ups_contacts": { "name": "External contacts" }, "ups_delay_reboot": { "name": "UPS reboot delay" }, "ups_delay_shutdown": { "name": "UPS shutdown delay" }, From 93418f587cda661e9b73f8b9d0ed29f081e3123b Mon Sep 17 00:00:00 2001 From: David Bonnes Date: Fri, 4 Apr 2025 08:52:18 +0100 Subject: [PATCH 271/505] Bump evohome-async to 1.0.5 (#141871) bump client to 1.0.5 --- homeassistant/components/evohome/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/evohome/manifest.json b/homeassistant/components/evohome/manifest.json index 44e4cdb1128..21c8874135a 100644 --- a/homeassistant/components/evohome/manifest.json +++ b/homeassistant/components/evohome/manifest.json @@ -6,5 +6,5 @@ "iot_class": "cloud_polling", "loggers": ["evohome", "evohomeasync", "evohomeasync2"], "quality_scale": "legacy", - "requirements": ["evohome-async==1.0.4"] + "requirements": ["evohome-async==1.0.5"] } diff --git a/requirements_all.txt b/requirements_all.txt index ce05b71dc81..47e30d011e5 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -901,7 +901,7 @@ eufylife-ble-client==0.1.8 # evdev==1.6.1 # homeassistant.components.evohome -evohome-async==1.0.4 +evohome-async==1.0.5 # homeassistant.components.bryant_evolution evolutionhttp==0.0.18 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 2cdf3198987..b4d66226845 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -768,7 +768,7 @@ eternalegypt==0.0.16 eufylife-ble-client==0.1.8 # homeassistant.components.evohome -evohome-async==1.0.4 +evohome-async==1.0.5 # homeassistant.components.bryant_evolution evolutionhttp==0.0.18 From 79fe8650f87bcf7394bb33d4be0a1a310e3ccde9 Mon Sep 17 00:00:00 2001 From: David Bonnes Date: Fri, 4 Apr 2025 08:54:18 +0100 Subject: [PATCH 272/505] Tweak evohome to handle older TCC-compatible systems (#142226) Handle zone.id == TCS.id --- homeassistant/components/evohome/climate.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/evohome/climate.py b/homeassistant/components/evohome/climate.py index b44dc9791b0..40439c1eb02 100644 --- a/homeassistant/components/evohome/climate.py +++ b/homeassistant/components/evohome/climate.py @@ -152,7 +152,7 @@ class EvoZone(EvoChild, EvoClimateEntity): super().__init__(coordinator, evo_device) self._evo_id = evo_device.id - if evo_device.model.startswith("VisionProWifi"): + if evo_device.id == evo_device.tcs.id: # this system does not have a distinct ID for the zone self._attr_unique_id = f"{evo_device.id}z" else: From 5ca044177188f7e922a364332154d0812ad991fa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=2E=20Diego=20Rodr=C3=ADguez=20Royo?= Date: Fri, 4 Apr 2025 09:56:40 +0200 Subject: [PATCH 273/505] Do not fetch disconnected Home Connect appliances (#142200) * Do not fetch disconnected Home Connect appliances * Apply suggestions Co-authored-by: Martin Hjelmare * Update docstring --------- Co-authored-by: Martin Hjelmare --- .../components/home_connect/coordinator.py | 32 ++++++--- .../home_connect/test_coordinator.py | 71 +++++++++++++++++-- 2 files changed, 87 insertions(+), 16 deletions(-) diff --git a/homeassistant/components/home_connect/coordinator.py b/homeassistant/components/home_connect/coordinator.py index 5e24ed25abd..fb86bb2edc6 100644 --- a/homeassistant/components/home_connect/coordinator.py +++ b/homeassistant/components/home_connect/coordinator.py @@ -74,6 +74,19 @@ class HomeConnectApplianceData: self.settings.update(other.settings) self.status.update(other.status) + @classmethod + def empty(cls, appliance: HomeAppliance) -> HomeConnectApplianceData: + """Return empty data.""" + return cls( + commands=set(), + events={}, + info=appliance, + options={}, + programs=[], + settings={}, + status={}, + ) + class HomeConnectCoordinator( DataUpdateCoordinator[dict[str, HomeConnectApplianceData]] @@ -362,15 +375,7 @@ class HomeConnectCoordinator( model=appliance.vib, ) if appliance.ha_id not in self.data: - self.data[appliance.ha_id] = HomeConnectApplianceData( - commands=set(), - events={}, - info=appliance, - options={}, - programs=[], - settings={}, - status={}, - ) + self.data[appliance.ha_id] = HomeConnectApplianceData.empty(appliance) else: self.data[appliance.ha_id].info.connected = appliance.connected old_appliances.remove(appliance.ha_id) @@ -406,6 +411,15 @@ class HomeConnectCoordinator( name=appliance.name, model=appliance.vib, ) + if not appliance.connected: + _LOGGER.debug( + "Appliance %s is not connected, skipping data fetch", + appliance.ha_id, + ) + if appliance_data_to_update: + appliance_data_to_update.info.connected = False + return appliance_data_to_update + return HomeConnectApplianceData.empty(appliance) try: settings = { setting.key: setting diff --git a/tests/components/home_connect/test_coordinator.py b/tests/components/home_connect/test_coordinator.py index e6a3390b284..d3b514bcc17 100644 --- a/tests/components/home_connect/test_coordinator.py +++ b/tests/components/home_connect/test_coordinator.py @@ -54,6 +54,14 @@ from homeassistant.util import dt as dt_util from tests.common import MockConfigEntry, async_fire_time_changed +INITIAL_FETCH_CLIENT_METHODS = [ + "get_settings", + "get_status", + "get_all_programs", + "get_available_commands", + "get_available_program", +] + @pytest.fixture def platforms() -> list[str]: @@ -214,15 +222,32 @@ async def test_coordinator_failure_refresh_and_stream( assert state.state != STATE_UNAVAILABLE +@pytest.mark.parametrize( + "appliance", + ["Dishwasher"], + indirect=True, +) +async def test_coordinator_not_fetching_on_disconnected_appliance( + config_entry: MockConfigEntry, + integration_setup: Callable[[MagicMock], Awaitable[bool]], + setup_credentials: None, + client: MagicMock, + appliance: HomeAppliance, +) -> None: + """Test that the coordinator does not fetch anything on disconnected appliance.""" + appliance.connected = False + + assert config_entry.state == ConfigEntryState.NOT_LOADED + await integration_setup(client) + assert config_entry.state == ConfigEntryState.LOADED + + for method in INITIAL_FETCH_CLIENT_METHODS: + assert getattr(client, method).call_count == 0 + + @pytest.mark.parametrize( "mock_method", - [ - "get_settings", - "get_status", - "get_all_programs", - "get_available_commands", - "get_available_program", - ], + INITIAL_FETCH_CLIENT_METHODS, ) async def test_coordinator_update_failing( mock_method: str, @@ -552,3 +577,35 @@ async def test_devices_updated_on_refresh( assert not device_registry.async_get_device({(DOMAIN, appliances[0].ha_id)}) for appliance in appliances[2:3]: assert device_registry.async_get_device({(DOMAIN, appliance.ha_id)}) + + +@pytest.mark.parametrize("appliance", ["Washer"], indirect=True) +async def test_paired_disconnected_devices_not_fetching( + hass: HomeAssistant, + config_entry: MockConfigEntry, + integration_setup: Callable[[MagicMock], Awaitable[bool]], + setup_credentials: None, + client: MagicMock, + appliance: HomeAppliance, +) -> None: + """Test that Home Connect API is not fetched after pairing a disconnected device.""" + client.get_home_appliances = AsyncMock(return_value=ArrayOfHomeAppliances([])) + assert config_entry.state == ConfigEntryState.NOT_LOADED + assert await integration_setup(client) + assert config_entry.state == ConfigEntryState.LOADED + + appliance.connected = False + await client.add_events( + [ + EventMessage( + appliance.ha_id, + EventType.PAIRED, + data=ArrayOfEvents([]), + ) + ] + ) + await hass.async_block_till_done() + + client.get_specific_appliance.assert_awaited_once_with(appliance.ha_id) + for method in INITIAL_FETCH_CLIENT_METHODS: + assert getattr(client, method).call_count == 0 From 986095482ff0950df7a6ce98a8e666ca12d8208f Mon Sep 17 00:00:00 2001 From: Erwin Douna Date: Fri, 4 Apr 2025 12:16:19 +0200 Subject: [PATCH 274/505] Tado add diagnostics platform (#142225) * Add diagnostics platform * Fix * Update * Fix --- homeassistant/components/tado/diagnostics.py | 20 +++ .../tado/snapshots/test_diagnostics.ambr | 143 ++++++++++++++++++ tests/components/tado/test_diagnostics.py | 28 ++++ 3 files changed, 191 insertions(+) create mode 100644 homeassistant/components/tado/diagnostics.py create mode 100644 tests/components/tado/snapshots/test_diagnostics.ambr create mode 100644 tests/components/tado/test_diagnostics.py diff --git a/homeassistant/components/tado/diagnostics.py b/homeassistant/components/tado/diagnostics.py new file mode 100644 index 00000000000..0426707c6a9 --- /dev/null +++ b/homeassistant/components/tado/diagnostics.py @@ -0,0 +1,20 @@ +"""Provides diagnostics for Tado.""" + +from __future__ import annotations + +from typing import Any + +from homeassistant.core import HomeAssistant + +from . import TadoConfigEntry + + +async def async_get_config_entry_diagnostics( + hass: HomeAssistant, config_entry: TadoConfigEntry +) -> dict[str, Any]: + """Return diagnostics for a Tado config entry.""" + + return { + "data": config_entry.runtime_data.coordinator.data, + "mobile_devices": config_entry.runtime_data.mobile_coordinator.data, + } diff --git a/tests/components/tado/snapshots/test_diagnostics.ambr b/tests/components/tado/snapshots/test_diagnostics.ambr new file mode 100644 index 00000000000..eefb818a88c --- /dev/null +++ b/tests/components/tado/snapshots/test_diagnostics.ambr @@ -0,0 +1,143 @@ +# serializer version: 1 +# name: test_get_config_entry_diagnostics + dict({ + 'data': dict({ + 'device': dict({ + 'WR1': dict({ + 'accessPointWiFi': dict({ + 'ssid': 'tado8480', + }), + 'characteristics': dict({ + 'capabilities': list([ + 'INSIDE_TEMPERATURE_MEASUREMENT', + 'IDENTIFY', + ]), + }), + 'commandTableUploadState': 'FINISHED', + 'connectionState': dict({ + 'timestamp': '2020-03-23T18:30:07.377Z', + 'value': True, + }), + 'currentFwVersion': '59.4', + 'deviceType': 'WR02', + 'serialNo': 'WR1', + 'shortSerialNo': 'WR1', + 'temperatureOffset': dict({ + 'celsius': -1.0, + 'fahrenheit': -1.8, + }), + }), + 'WR4': dict({ + 'accessPointWiFi': dict({ + 'ssid': 'tado8480', + }), + 'characteristics': dict({ + 'capabilities': list([ + 'INSIDE_TEMPERATURE_MEASUREMENT', + 'IDENTIFY', + ]), + }), + 'childLockEnabled': False, + 'commandTableUploadState': 'FINISHED', + 'connectionState': dict({ + 'timestamp': '2020-03-23T18:30:07.377Z', + 'value': True, + }), + 'currentFwVersion': '59.4', + 'deviceType': 'WR02', + 'duties': list([ + 'ZONE_UI', + 'ZONE_DRIVER', + 'ZONE_LEADER', + ]), + 'serialNo': 'WR4', + 'shortSerialNo': 'WR4', + 'temperatureOffset': dict({ + 'celsius': -1.0, + 'fahrenheit': -1.8, + }), + }), + }), + 'geofence': dict({ + 'presence': 'HOME', + 'presenceLocked': False, + }), + 'weather': dict({ + 'outsideTemperature': dict({ + 'celsius': 7.46, + 'fahrenheit': 45.43, + 'precision': dict({ + 'celsius': 0.01, + 'fahrenheit': 0.01, + }), + 'timestamp': '2020-12-22T08:13:13.652Z', + 'type': 'TEMPERATURE', + }), + 'solarIntensity': dict({ + 'percentage': 2.1, + 'timestamp': '2020-12-22T08:13:13.652Z', + 'type': 'PERCENTAGE', + }), + 'weatherState': dict({ + 'timestamp': '2020-12-22T08:13:13.652Z', + 'type': 'WEATHER_STATE', + 'value': 'FOGGY', + }), + }), + 'zone': dict({ + '1': dict({ + '__type': "", + 'repr': "TadoZone(zone_id=1, current_temp=20.65, connection=None, current_temp_timestamp='2020-03-10T07:44:11.947Z', current_humidity=45.2, current_humidity_timestamp='2020-03-10T07:44:11.947Z', is_away=False, current_hvac_action='IDLE', current_fan_speed=None, current_fan_level=None, current_hvac_mode='HEAT', current_swing_mode='OFF', current_vertical_swing_mode='OFF', current_horizontal_swing_mode='OFF', target_temp=20.5, available=True, power='ON', link='ONLINE', ac_power_timestamp=None, heating_power_timestamp='2020-03-10T07:47:45.978Z', ac_power=None, heating_power=None, heating_power_percentage=0.0, tado_mode='HOME', overlay_termination_type='MANUAL', overlay_termination_timestamp=None, default_overlay_termination_type='MANUAL', default_overlay_termination_duration=None, preparation=False, open_window=False, open_window_detected=False, open_window_attr={}, precision=0.1)", + }), + '2': dict({ + '__type': "", + 'repr': "TadoZone(zone_id=2, current_temp=None, connection=None, current_temp_timestamp=None, current_humidity=None, current_humidity_timestamp=None, is_away=False, current_hvac_action='IDLE', current_fan_speed=None, current_fan_level=None, current_hvac_mode='SMART_SCHEDULE', current_swing_mode='OFF', current_vertical_swing_mode='OFF', current_horizontal_swing_mode='OFF', target_temp=65.0, available=True, power='ON', link='ONLINE', ac_power_timestamp=None, heating_power_timestamp=None, ac_power=None, heating_power=None, heating_power_percentage=None, tado_mode='HOME', overlay_termination_type=None, overlay_termination_timestamp=None, default_overlay_termination_type='MANUAL', default_overlay_termination_duration=None, preparation=False, open_window=False, open_window_detected=False, open_window_attr={}, precision=0.1)", + }), + '3': dict({ + '__type': "", + 'repr': "TadoZone(zone_id=3, current_temp=24.76, connection=None, current_temp_timestamp='2020-03-05T03:57:38.850Z', current_humidity=60.9, current_humidity_timestamp='2020-03-05T03:57:38.850Z', is_away=False, current_hvac_action='COOLING', current_fan_speed='AUTO', current_fan_level=None, current_hvac_mode='COOL', current_swing_mode='OFF', current_vertical_swing_mode='OFF', current_horizontal_swing_mode='OFF', target_temp=17.78, available=True, power='ON', link='ONLINE', ac_power_timestamp='2020-03-05T04:01:07.162Z', heating_power_timestamp=None, ac_power='ON', heating_power=None, heating_power_percentage=None, tado_mode='HOME', overlay_termination_type='TADO_MODE', overlay_termination_timestamp=None, default_overlay_termination_type='MANUAL', default_overlay_termination_duration=None, preparation=False, open_window=False, open_window_detected=False, open_window_attr={}, precision=0.1)", + }), + '4': dict({ + '__type': "", + 'repr': "TadoZone(zone_id=4, current_temp=None, connection=None, current_temp_timestamp=None, current_humidity=None, current_humidity_timestamp=None, is_away=False, current_hvac_action='IDLE', current_fan_speed=None, current_fan_level=None, current_hvac_mode='HEATING', current_swing_mode='OFF', current_vertical_swing_mode='OFF', current_horizontal_swing_mode='OFF', target_temp=30.0, available=True, power='ON', link='ONLINE', ac_power_timestamp=None, heating_power_timestamp=None, ac_power=None, heating_power=None, heating_power_percentage=None, tado_mode='HOME', overlay_termination_type='TADO_MODE', overlay_termination_timestamp=None, default_overlay_termination_type='MANUAL', default_overlay_termination_duration=None, preparation=False, open_window=False, open_window_detected=False, open_window_attr={}, precision=0.1)", + }), + '5': dict({ + '__type': "", + 'repr': "TadoZone(zone_id=5, current_temp=20.88, connection=None, current_temp_timestamp='2020-03-28T02:09:27.830Z', current_humidity=42.3, current_humidity_timestamp='2020-03-28T02:09:27.830Z', is_away=False, current_hvac_action='HEATING', current_fan_speed='AUTO', current_fan_level=None, current_hvac_mode='SMART_SCHEDULE', current_swing_mode='ON', current_vertical_swing_mode='OFF', current_horizontal_swing_mode='OFF', target_temp=20.0, available=True, power='ON', link='ONLINE', ac_power_timestamp='2020-03-27T23:02:22.260Z', heating_power_timestamp=None, ac_power='ON', heating_power=None, heating_power_percentage=None, tado_mode='HOME', overlay_termination_type=None, overlay_termination_timestamp=None, default_overlay_termination_type='MANUAL', default_overlay_termination_duration=None, preparation=False, open_window=False, open_window_detected=False, open_window_attr={}, precision=0.1)", + }), + '6': dict({ + '__type': "", + 'repr': "TadoZone(zone_id=6, current_temp=24.3, connection=None, current_temp_timestamp='2024-06-28T22: 23: 15.679Z', current_humidity=70.9, current_humidity_timestamp='2024-06-28T22: 23: 15.679Z', is_away=False, current_hvac_action='HEATING', current_fan_speed='AUTO', current_fan_level='LEVEL3', current_hvac_mode='HEAT', current_swing_mode='OFF', current_vertical_swing_mode='ON', current_horizontal_swing_mode='ON', target_temp=25.0, available=True, power='ON', link='ONLINE', ac_power_timestamp='2022-07-13T18: 06: 58.183Z', heating_power_timestamp=None, ac_power='ON', heating_power=None, heating_power_percentage=None, tado_mode='HOME', overlay_termination_type='MANUAL', overlay_termination_timestamp=None, default_overlay_termination_type='MANUAL', default_overlay_termination_duration=None, preparation=False, open_window=False, open_window_detected=False, open_window_attr={}, precision=0.1)", + }), + }), + }), + 'mobile_devices': dict({ + 'mobile_device': dict({ + '123456': dict({ + 'deviceMetadata': dict({ + 'locale': 'nl', + 'model': 'Samsung', + 'osVersion': '14', + 'platform': 'Android', + }), + 'id': 123456, + 'name': 'Home', + 'settings': dict({ + 'geoTrackingEnabled': False, + 'onDemandLogRetrievalEnabled': False, + 'pushNotifications': dict({ + 'awayModeReminder': True, + 'energyIqReminder': False, + 'energySavingsReportReminder': True, + 'homeModeReminder': True, + 'incidentDetection': True, + 'lowBatteryReminder': True, + 'openWindowReminder': True, + }), + 'specialOffersEnabled': False, + }), + }), + }), + }), + }) +# --- diff --git a/tests/components/tado/test_diagnostics.py b/tests/components/tado/test_diagnostics.py new file mode 100644 index 00000000000..3a4f04b0a4c --- /dev/null +++ b/tests/components/tado/test_diagnostics.py @@ -0,0 +1,28 @@ +"""Test the Tado component diagnostics.""" + +from syrupy import SnapshotAssertion +from syrupy.filters import props + +from homeassistant.components.tado.const import DOMAIN +from homeassistant.core import HomeAssistant + +from .util import async_init_integration + +from tests.common import MockConfigEntry +from tests.components.diagnostics import get_diagnostics_for_config_entry +from tests.typing import ClientSessionGenerator + + +async def test_get_config_entry_diagnostics( + hass: HomeAssistant, + snapshot: SnapshotAssertion, + hass_client: ClientSessionGenerator, +) -> None: + """Test if get_config_entry_diagnostics returns the correct data.""" + await async_init_integration(hass) + + config_entry: MockConfigEntry = hass.config_entries.async_entries(DOMAIN)[0] + diagnostics = await get_diagnostics_for_config_entry( + hass, hass_client, config_entry + ) + assert diagnostics == snapshot(exclude=props("created_at", "modified_at")) From a407a3c98d2429b23cb41695f48c4d580c62fb4b Mon Sep 17 00:00:00 2001 From: Josef Zweck Date: Fri, 4 Apr 2025 12:32:14 +0200 Subject: [PATCH 275/505] Fix skyconnect tests (#142262) fix tests --- tests/components/zha/test_repairs.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/tests/components/zha/test_repairs.py b/tests/components/zha/test_repairs.py index af81ac0d586..0ff863f0c45 100644 --- a/tests/components/zha/test_repairs.py +++ b/tests/components/zha/test_repairs.py @@ -49,7 +49,8 @@ def test_detect_radio_hardware(hass: HomeAssistant) -> None: "product": "SkyConnect v1.0", "firmware": "ezsp", }, - version=2, + version=1, + minor_version=4, domain=SKYCONNECT_DOMAIN, options={}, title="Home Assistant SkyConnect", @@ -66,7 +67,8 @@ def test_detect_radio_hardware(hass: HomeAssistant) -> None: "product": "Home Assistant Connect ZBT-1", "firmware": "ezsp", }, - version=2, + version=1, + minor_version=4, domain=SKYCONNECT_DOMAIN, options={}, title="Home Assistant Connect ZBT-1", From a05785529fc39fe64301ea735476e7625d7ebdcb Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Fri, 4 Apr 2025 14:39:54 +0200 Subject: [PATCH 276/505] Fix RuntimeWarning in homeassistant_hardware (#142269) --- .../homeassistant_hardware/test_config_flow_failures.py | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/components/homeassistant_hardware/test_config_flow_failures.py b/tests/components/homeassistant_hardware/test_config_flow_failures.py index 251c4743bfe..38c2696a62a 100644 --- a/tests/components/homeassistant_hardware/test_config_flow_failures.py +++ b/tests/components/homeassistant_hardware/test_config_flow_failures.py @@ -45,6 +45,7 @@ async def fixture_mock_supervisor_client(supervisor_client: AsyncMock): STEP_PICK_FIRMWARE_THREAD, ], ) +@pytest.mark.usefixtures("addon_store_info") async def test_config_flow_cannot_probe_firmware( next_step: str, hass: HomeAssistant ) -> None: From 5eea5858eadac6591cf417be90f7cb45b7a97ff3 Mon Sep 17 00:00:00 2001 From: Bram Kragten Date: Fri, 4 Apr 2025 16:38:56 +0200 Subject: [PATCH 277/505] Update frontend to 20250404.0 (#142274) --- homeassistant/components/frontend/manifest.json | 2 +- homeassistant/package_constraints.txt | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/frontend/manifest.json b/homeassistant/components/frontend/manifest.json index 4cab8375d1b..140d90c5dbe 100644 --- a/homeassistant/components/frontend/manifest.json +++ b/homeassistant/components/frontend/manifest.json @@ -20,5 +20,5 @@ "documentation": "https://www.home-assistant.io/integrations/frontend", "integration_type": "system", "quality_scale": "internal", - "requirements": ["home-assistant-frontend==20250401.0"] + "requirements": ["home-assistant-frontend==20250404.0"] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 5d2d6171c6c..fbf10ce142a 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -38,7 +38,7 @@ habluetooth==3.37.0 hass-nabucasa==0.94.0 hassil==2.2.3 home-assistant-bluetooth==1.13.1 -home-assistant-frontend==20250401.0 +home-assistant-frontend==20250404.0 home-assistant-intents==2025.3.28 httpx==0.28.1 ifaddr==0.2.0 diff --git a/requirements_all.txt b/requirements_all.txt index 47e30d011e5..f0080b0aa8e 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1157,7 +1157,7 @@ hole==0.8.0 holidays==0.69 # homeassistant.components.frontend -home-assistant-frontend==20250401.0 +home-assistant-frontend==20250404.0 # homeassistant.components.conversation home-assistant-intents==2025.3.28 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index b4d66226845..bb046586b53 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -984,7 +984,7 @@ hole==0.8.0 holidays==0.69 # homeassistant.components.frontend -home-assistant-frontend==20250401.0 +home-assistant-frontend==20250404.0 # homeassistant.components.conversation home-assistant-intents==2025.3.28 From f4ed9edec65ec8408eb7bdd4bed2b854fb907175 Mon Sep 17 00:00:00 2001 From: tdfountain <174762217+tdfountain@users.noreply.github.com> Date: Fri, 4 Apr 2025 08:06:37 -0700 Subject: [PATCH 278/505] Use common state strings in NUT (#142284) User common state strings --- homeassistant/components/nut/strings.json | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/nut/strings.json b/homeassistant/components/nut/strings.json index 0830b806bd0..fe06bef3903 100644 --- a/homeassistant/components/nut/strings.json +++ b/homeassistant/components/nut/strings.json @@ -107,12 +107,12 @@ "battery_charger_status": { "name": "Charging status", "state": { - "charging": "Charging", - "discharging": "Discharging", + "charging": "[%key:common::state::charging%]", + "discharging": "[%key:common::state::discharging%]", "floating": "Floating", "resting": "Resting", "unknown": "Unknown", - "disabled": "Disabled", + "disabled": "[%key:common::state::disabled%]", "off": "Off" } }, @@ -223,8 +223,8 @@ "ups_beeper_status": { "name": "Beeper status", "state": { - "enabled": "Enabled", - "disabled": "Disabled", + "enabled": "[%key:common::state::enabled%]", + "disabled": "[%key:common::state::disabled%]", "muted": "Muted" } }, From 61d2c9335f169b9a3644a3625e99cfc35fb75c49 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Fri, 4 Apr 2025 17:52:06 +0200 Subject: [PATCH 279/505] Bump pySmartThings to 3.0.2 (#142257) Co-authored-by: Robert Resch --- homeassistant/components/smartthings/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/smartthings/manifest.json b/homeassistant/components/smartthings/manifest.json index 2af3e5c193b..dda7ef53cf5 100644 --- a/homeassistant/components/smartthings/manifest.json +++ b/homeassistant/components/smartthings/manifest.json @@ -30,5 +30,5 @@ "iot_class": "cloud_push", "loggers": ["pysmartthings"], "quality_scale": "bronze", - "requirements": ["pysmartthings==3.0.1"] + "requirements": ["pysmartthings==3.0.2"] } diff --git a/requirements_all.txt b/requirements_all.txt index f0080b0aa8e..ef84c29eeef 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2319,7 +2319,7 @@ pysma==0.7.5 pysmappee==0.2.29 # homeassistant.components.smartthings -pysmartthings==3.0.1 +pysmartthings==3.0.2 # homeassistant.components.smarty pysmarty2==0.10.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index bb046586b53..dc7813b2186 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1889,7 +1889,7 @@ pysma==0.7.5 pysmappee==0.2.29 # homeassistant.components.smartthings -pysmartthings==3.0.1 +pysmartthings==3.0.2 # homeassistant.components.smarty pysmarty2==0.10.2 From 3c60bff7dc737d947af995c0008f3b5e7373325f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ludovic=20BOU=C3=89?= Date: Fri, 4 Apr 2025 17:57:22 +0200 Subject: [PATCH 280/505] Add support for Matter EVSE devicetype (#137189) * Binary sensors * Add tests * Update strings * Enable testing * Add command_timeout to MatterEntityDescription * Add entities * Update strings.json * Add sensors * Add tests * Move command_timeout keyword to MatterGenericCommandSwitch * Icons * Update snapshots * Add tests for switch entity * Fix switch tests * Rename states * Update strings.json * Update snapshot * Rename charging switch * Remove MatterEntity * Update strings.json * Update snapshots * Update snaphots 2/2 * Update strings * Update test binary --- .../components/matter/binary_sensor.py | 57 ++ homeassistant/components/matter/entity.py | 1 + homeassistant/components/matter/icons.json | 12 + homeassistant/components/matter/sensor.py | 92 +++ homeassistant/components/matter/strings.json | 48 ++ homeassistant/components/matter/switch.py | 91 +++ tests/components/matter/conftest.py | 1 + .../fixtures/nodes/silabs_evse_charging.json | 580 ++++++++++++++++++ .../matter/snapshots/test_binary_sensor.ambr | 144 +++++ .../matter/snapshots/test_select.ambr | 122 ++++ .../matter/snapshots/test_sensor.ambr | 317 ++++++++++ .../matter/snapshots/test_switch.ambr | 47 ++ tests/components/matter/test_binary_sensor.py | 50 ++ tests/components/matter/test_sensor.py | 68 ++ tests/components/matter/test_switch.py | 44 ++ 15 files changed, 1674 insertions(+) create mode 100644 tests/components/matter/fixtures/nodes/silabs_evse_charging.json diff --git a/homeassistant/components/matter/binary_sensor.py b/homeassistant/components/matter/binary_sensor.py index b5665e5d47a..a55df58cac7 100644 --- a/homeassistant/components/matter/binary_sensor.py +++ b/homeassistant/components/matter/binary_sensor.py @@ -265,4 +265,61 @@ DISCOVERY_SCHEMAS = [ entity_class=MatterBinarySensor, required_attributes=(clusters.SmokeCoAlarm.Attributes.InterconnectCOAlarm,), ), + MatterDiscoverySchema( + platform=Platform.BINARY_SENSOR, + entity_description=MatterBinarySensorEntityDescription( + key="EnergyEvseChargingStatusSensor", + translation_key="evse_charging_status", + device_class=BinarySensorDeviceClass.BATTERY_CHARGING, + measurement_to_ha={ + clusters.EnergyEvse.Enums.StateEnum.kNotPluggedIn: False, + clusters.EnergyEvse.Enums.StateEnum.kPluggedInNoDemand: False, + clusters.EnergyEvse.Enums.StateEnum.kPluggedInDemand: False, + clusters.EnergyEvse.Enums.StateEnum.kPluggedInCharging: True, + clusters.EnergyEvse.Enums.StateEnum.kPluggedInDischarging: False, + clusters.EnergyEvse.Enums.StateEnum.kSessionEnding: False, + clusters.EnergyEvse.Enums.StateEnum.kFault: False, + }.get, + ), + entity_class=MatterBinarySensor, + required_attributes=(clusters.EnergyEvse.Attributes.State,), + allow_multi=True, # also used for sensor entity + ), + MatterDiscoverySchema( + platform=Platform.BINARY_SENSOR, + entity_description=MatterBinarySensorEntityDescription( + key="EnergyEvsePlugStateSensor", + translation_key="evse_plug_state", + device_class=BinarySensorDeviceClass.PLUG, + measurement_to_ha={ + clusters.EnergyEvse.Enums.StateEnum.kNotPluggedIn: False, + clusters.EnergyEvse.Enums.StateEnum.kPluggedInNoDemand: True, + clusters.EnergyEvse.Enums.StateEnum.kPluggedInDemand: True, + clusters.EnergyEvse.Enums.StateEnum.kPluggedInCharging: True, + clusters.EnergyEvse.Enums.StateEnum.kPluggedInDischarging: True, + clusters.EnergyEvse.Enums.StateEnum.kSessionEnding: False, + clusters.EnergyEvse.Enums.StateEnum.kFault: False, + }.get, + ), + entity_class=MatterBinarySensor, + required_attributes=(clusters.EnergyEvse.Attributes.State,), + allow_multi=True, # also used for sensor entity + ), + MatterDiscoverySchema( + platform=Platform.BINARY_SENSOR, + entity_description=MatterBinarySensorEntityDescription( + key="EnergyEvseSupplyStateSensor", + translation_key="evse_supply_charging_state", + device_class=BinarySensorDeviceClass.RUNNING, + measurement_to_ha={ + clusters.EnergyEvse.Enums.SupplyStateEnum.kDisabled: False, + clusters.EnergyEvse.Enums.SupplyStateEnum.kChargingEnabled: True, + clusters.EnergyEvse.Enums.SupplyStateEnum.kDischargingEnabled: False, + clusters.EnergyEvse.Enums.SupplyStateEnum.kDisabledDiagnostics: False, + }.get, + ), + entity_class=MatterBinarySensor, + required_attributes=(clusters.EnergyEvse.Attributes.SupplyState,), + allow_multi=True, # also used for sensor entity + ), ] diff --git a/homeassistant/components/matter/entity.py b/homeassistant/components/matter/entity.py index 96696193466..fded57d34f5 100644 --- a/homeassistant/components/matter/entity.py +++ b/homeassistant/components/matter/entity.py @@ -61,6 +61,7 @@ class MatterEntityDescription(EntityDescription): # convert the value from the primary attribute to the value used by HA measurement_to_ha: Callable[[Any], Any] | None = None ha_to_native_value: Callable[[Any], Any] | None = None + command_timeout: int | None = None class MatterEntity(Entity): diff --git a/homeassistant/components/matter/icons.json b/homeassistant/components/matter/icons.json index f9217cabcc4..fed51708870 100644 --- a/homeassistant/components/matter/icons.json +++ b/homeassistant/components/matter/icons.json @@ -71,6 +71,15 @@ }, "battery_replacement_description": { "default": "mdi:battery-sync-outline" + }, + "evse_state": { + "default": "mdi:ev-station" + }, + "evse_supply_state": { + "default": "mdi:ev-station" + }, + "evse_fault_state": { + "default": "mdi:ev-station" } }, "switch": { @@ -80,6 +89,9 @@ "on": "mdi:lock", "off": "mdi:lock-off" } + }, + "evse_charging_switch": { + "default": "mdi:ev-station" } } } diff --git a/homeassistant/components/matter/sensor.py b/homeassistant/components/matter/sensor.py index 10f8db275f5..82d8ec1727c 100644 --- a/homeassistant/components/matter/sensor.py +++ b/homeassistant/components/matter/sensor.py @@ -77,6 +77,25 @@ OPERATIONAL_STATE_MAP = { clusters.RvcOperationalState.Enums.OperationalStateEnum.kDocked: "docked", } +EVSE_FAULT_STATE_MAP = { + clusters.EnergyEvse.Enums.FaultStateEnum.kNoError: "no_error", + clusters.EnergyEvse.Enums.FaultStateEnum.kMeterFailure: "meter_failure", + clusters.EnergyEvse.Enums.FaultStateEnum.kOverVoltage: "over_voltage", + clusters.EnergyEvse.Enums.FaultStateEnum.kUnderVoltage: "under_voltage", + clusters.EnergyEvse.Enums.FaultStateEnum.kOverCurrent: "over_current", + clusters.EnergyEvse.Enums.FaultStateEnum.kContactWetFailure: "contact_wet_failure", + clusters.EnergyEvse.Enums.FaultStateEnum.kContactDryFailure: "contact_dry_failure", + clusters.EnergyEvse.Enums.FaultStateEnum.kPowerLoss: "power_loss", + clusters.EnergyEvse.Enums.FaultStateEnum.kPowerQuality: "power_quality", + clusters.EnergyEvse.Enums.FaultStateEnum.kPilotShortCircuit: "pilot_short_circuit", + clusters.EnergyEvse.Enums.FaultStateEnum.kEmergencyStop: "emergency_stop", + clusters.EnergyEvse.Enums.FaultStateEnum.kEVDisconnected: "ev_disconnected", + clusters.EnergyEvse.Enums.FaultStateEnum.kWrongPowerSupply: "wrong_power_supply", + clusters.EnergyEvse.Enums.FaultStateEnum.kLiveNeutralSwap: "live_neutral_swap", + clusters.EnergyEvse.Enums.FaultStateEnum.kOverTemperature: "over_temperature", + clusters.EnergyEvse.Enums.FaultStateEnum.kOther: "other", +} + async def async_setup_entry( hass: HomeAssistant, @@ -904,4 +923,77 @@ DISCOVERY_SCHEMAS = [ # don't discover this entry if the supported state list is empty secondary_value_is_not=[], ), + MatterDiscoverySchema( + platform=Platform.SENSOR, + entity_description=MatterSensorEntityDescription( + key="EnergyEvseFaultState", + translation_key="evse_fault_state", + device_class=SensorDeviceClass.ENUM, + entity_category=EntityCategory.DIAGNOSTIC, + options=list(EVSE_FAULT_STATE_MAP.values()), + measurement_to_ha=EVSE_FAULT_STATE_MAP.get, + ), + entity_class=MatterSensor, + required_attributes=(clusters.EnergyEvse.Attributes.FaultState,), + ), + MatterDiscoverySchema( + platform=Platform.SENSOR, + entity_description=MatterSensorEntityDescription( + key="EnergyEvseCircuitCapacity", + translation_key="evse_circuit_capacity", + device_class=SensorDeviceClass.CURRENT, + entity_category=EntityCategory.DIAGNOSTIC, + native_unit_of_measurement=UnitOfElectricCurrent.MILLIAMPERE, + suggested_unit_of_measurement=UnitOfElectricCurrent.AMPERE, + suggested_display_precision=2, + state_class=SensorStateClass.MEASUREMENT, + ), + entity_class=MatterSensor, + required_attributes=(clusters.EnergyEvse.Attributes.CircuitCapacity,), + ), + MatterDiscoverySchema( + platform=Platform.SENSOR, + entity_description=MatterSensorEntityDescription( + key="EnergyEvseMinimumChargeCurrent", + translation_key="evse_min_charge_current", + device_class=SensorDeviceClass.CURRENT, + entity_category=EntityCategory.DIAGNOSTIC, + native_unit_of_measurement=UnitOfElectricCurrent.MILLIAMPERE, + suggested_unit_of_measurement=UnitOfElectricCurrent.AMPERE, + suggested_display_precision=2, + state_class=SensorStateClass.MEASUREMENT, + ), + entity_class=MatterSensor, + required_attributes=(clusters.EnergyEvse.Attributes.MinimumChargeCurrent,), + ), + MatterDiscoverySchema( + platform=Platform.SENSOR, + entity_description=MatterSensorEntityDescription( + key="EnergyEvseMaximumChargeCurrent", + translation_key="evse_max_charge_current", + device_class=SensorDeviceClass.CURRENT, + entity_category=EntityCategory.DIAGNOSTIC, + native_unit_of_measurement=UnitOfElectricCurrent.MILLIAMPERE, + suggested_unit_of_measurement=UnitOfElectricCurrent.AMPERE, + suggested_display_precision=2, + state_class=SensorStateClass.MEASUREMENT, + ), + entity_class=MatterSensor, + required_attributes=(clusters.EnergyEvse.Attributes.MaximumChargeCurrent,), + ), + MatterDiscoverySchema( + platform=Platform.SENSOR, + entity_description=MatterSensorEntityDescription( + key="EnergyEvseUserMaximumChargeCurrent", + translation_key="evse_user_max_charge_current", + device_class=SensorDeviceClass.CURRENT, + entity_category=EntityCategory.DIAGNOSTIC, + native_unit_of_measurement=UnitOfElectricCurrent.MILLIAMPERE, + suggested_unit_of_measurement=UnitOfElectricCurrent.AMPERE, + suggested_display_precision=2, + state_class=SensorStateClass.MEASUREMENT, + ), + entity_class=MatterSensor, + required_attributes=(clusters.EnergyEvse.Attributes.UserMaximumChargeCurrent,), + ), ] diff --git a/homeassistant/components/matter/strings.json b/homeassistant/components/matter/strings.json index 81a9a4ba796..4fa49f887d9 100644 --- a/homeassistant/components/matter/strings.json +++ b/homeassistant/components/matter/strings.json @@ -76,6 +76,15 @@ }, "muted": { "name": "Muted" + }, + "evse_charging_status": { + "name": "Charging status" + }, + "evse_plug": { + "name": "Plug state" + }, + "evse_supply_charging_state": { + "name": "Supply charging state" } }, "button": { @@ -278,6 +287,42 @@ }, "current_phase": { "name": "Current phase" + }, + "evse_fault_state": { + "name": "Fault state", + "state": { + "no_error": "OK", + "meter_failure": "Meter failure", + "over_voltage": "Overvoltage", + "under_voltage": "Undervoltage", + "over_current": "Overcurrent", + "contact_wet_failure": "Contact wet failure", + "contact_dry_failure": "Contact dry failure", + "power_loss": "Power loss", + "power_quality": "Power quality", + "pilot_short_circuit": "Pilot short circuit", + "emergency_stop": "Emergency stop", + "ev_disconnected": "EV disconnected", + "wrong_power_supply": "Wrong power supply", + "live_neutral_swap": "Live/neutral swap", + "over_temperature": "Overtemperature", + "other": "Other fault" + } + }, + "evse_circuit_capacity": { + "name": "Circuit capacity" + }, + "evse_charge_current": { + "name": "Charge current" + }, + "evse_min_charge_current": { + "name": "Min charge current" + }, + "evse_max_charge_current": { + "name": "Max charge current" + }, + "evse_user_max_charge_current": { + "name": "User max charge current" } }, "switch": { @@ -289,6 +334,9 @@ }, "child_lock": { "name": "Child lock" + }, + "evse_charging_switch": { + "name": "Enable charging" } }, "vacuum": { diff --git a/homeassistant/components/matter/switch.py b/homeassistant/components/matter/switch.py index af4803af9a1..870a9098492 100644 --- a/homeassistant/components/matter/switch.py +++ b/homeassistant/components/matter/switch.py @@ -2,10 +2,12 @@ from __future__ import annotations +from collections.abc import Callable from dataclasses import dataclass from typing import Any from chip.clusters import Objects as clusters +from chip.clusters.Objects import ClusterCommand, NullValue from matter_server.client.models import device_types from homeassistant.components.switch import ( @@ -22,6 +24,13 @@ from .entity import MatterEntity, MatterEntityDescription from .helpers import get_matter from .models import MatterDiscoverySchema +EVSE_SUPPLY_STATE_MAP = { + clusters.EnergyEvse.Enums.SupplyStateEnum.kDisabled: False, + clusters.EnergyEvse.Enums.SupplyStateEnum.kChargingEnabled: True, + clusters.EnergyEvse.Enums.SupplyStateEnum.kDischargingEnabled: False, + clusters.EnergyEvse.Enums.SupplyStateEnum.kDisabledDiagnostics: False, +} + async def async_setup_entry( hass: HomeAssistant, @@ -58,6 +67,66 @@ class MatterSwitch(MatterEntity, SwitchEntity): ) +class MatterGenericCommandSwitch(MatterSwitch): + """Representation of a Matter switch.""" + + entity_description: MatterGenericCommandSwitchEntityDescription + + _platform_translation_key = "switch" + + async def async_turn_on(self, **kwargs: Any) -> None: + """Turn switch on.""" + if self.entity_description.on_command: + # custom command defined to set the new value + await self.send_device_command( + self.entity_description.on_command(), + self.entity_description.command_timeout, + ) + + async def async_turn_off(self, **kwargs: Any) -> None: + """Turn switch off.""" + if self.entity_description.off_command: + await self.send_device_command( + self.entity_description.off_command(), + self.entity_description.command_timeout, + ) + + @callback + def _update_from_device(self) -> None: + """Update from device.""" + value = self.get_matter_attribute_value(self._entity_info.primary_attribute) + if value_convert := self.entity_description.measurement_to_ha: + value = value_convert(value) + self._attr_is_on = value + + async def send_device_command( + self, + command: ClusterCommand, + command_timeout: int | None = None, + **kwargs: Any, + ) -> None: + """Send device command with timeout.""" + await self.matter_client.send_device_command( + node_id=self._endpoint.node.node_id, + endpoint_id=self._endpoint.endpoint_id, + command=command, + timed_request_timeout_ms=command_timeout, + **kwargs, + ) + + +@dataclass(frozen=True) +class MatterGenericCommandSwitchEntityDescription( + SwitchEntityDescription, MatterEntityDescription +): + """Describe Matter Generic command Switch entities.""" + + # command: a custom callback to create the command to send to the device + on_command: Callable[[], Any] | None = None + off_command: Callable[[], Any] | None = None + command_timeout: int | None = None + + @dataclass(frozen=True) class MatterNumericSwitchEntityDescription( SwitchEntityDescription, MatterEntityDescription @@ -194,4 +263,26 @@ DISCOVERY_SCHEMAS = [ ), vendor_id=(4874,), ), + MatterDiscoverySchema( + platform=Platform.SWITCH, + entity_description=MatterGenericCommandSwitchEntityDescription( + key="EnergyEvseChargingSwitch", + translation_key="evse_charging_switch", + on_command=lambda: clusters.EnergyEvse.Commands.EnableCharging( + chargingEnabledUntil=NullValue, + minimumChargeCurrent=0, + maximumChargeCurrent=0, + ), + off_command=clusters.EnergyEvse.Commands.Disable, + command_timeout=3000, + measurement_to_ha=EVSE_SUPPLY_STATE_MAP.get, + ), + entity_class=MatterGenericCommandSwitch, + required_attributes=( + clusters.EnergyEvse.Attributes.SupplyState, + clusters.EnergyEvse.Attributes.AcceptedCommandList, + ), + value_contains=clusters.EnergyEvse.Commands.EnableCharging.command_id, + allow_multi=True, + ), ] diff --git a/tests/components/matter/conftest.py b/tests/components/matter/conftest.py index d7429f6087d..a085a1e3540 100644 --- a/tests/components/matter/conftest.py +++ b/tests/components/matter/conftest.py @@ -104,6 +104,7 @@ async def integration_fixture( "pressure_sensor", "room_airconditioner", "silabs_dishwasher", + "silabs_evse_charging", "silabs_laundrywasher", "smoke_detector", "switch_unit", diff --git a/tests/components/matter/fixtures/nodes/silabs_evse_charging.json b/tests/components/matter/fixtures/nodes/silabs_evse_charging.json new file mode 100644 index 00000000000..3188ba81ad6 --- /dev/null +++ b/tests/components/matter/fixtures/nodes/silabs_evse_charging.json @@ -0,0 +1,580 @@ +{ + "node_id": 23, + "date_commissioned": "2024-12-17T18:14:53.210190", + "last_interview": "2024-12-17T18:14:53.211611", + "interview_version": 6, + "available": true, + "is_bridge": false, + "attributes": { + "0/29/0": [ + { + "0": 22, + "1": 1 + } + ], + "0/29/1": [29, 31, 40, 43, 44, 45, 48, 49, 51, 60, 62, 63], + "0/29/2": [], + "0/29/3": [1], + "0/29/65532": 0, + "0/29/65533": 2, + "0/29/65528": [], + "0/29/65529": [], + "0/29/65531": [0, 1, 2, 3, 65528, 65529, 65531, 65532, 65533], + "0/31/0": [ + { + "1": 5, + "2": 2, + "3": [112233], + "4": null, + "254": 1 + } + ], + "0/31/2": 4, + "0/31/3": 3, + "0/31/4": 4, + "0/31/65532": 0, + "0/31/65533": 2, + "0/31/65528": [], + "0/31/65529": [], + "0/31/65531": [0, 2, 3, 4, 65528, 65529, 65531, 65532, 65533], + "0/40/0": 18, + "0/40/1": "Silabs", + "0/40/2": 65521, + "0/40/3": "evse", + "0/40/4": 32769, + "0/40/5": "evse", + "0/40/6": "**REDACTED**", + "0/40/7": 0, + "0/40/8": "TEST_VERSION", + "0/40/9": 1, + "0/40/10": "1.0", + "0/40/15": "TEST_SN", + "0/40/18": "evse", + "0/40/19": { + "0": 3, + "1": 65535 + }, + "0/40/21": 17039360, + "0/40/22": 1, + "0/40/65532": 0, + "0/40/65533": 4, + "0/40/65528": [], + "0/40/65529": [], + "0/40/65531": [ + 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 15, 18, 19, 21, 22, 65528, 65529, 65531, + 65532, 65533 + ], + "0/43/0": "en-US", + "0/43/1": [ + "en-US", + "de-DE", + "fr-FR", + "en-GB", + "es-ES", + "zh-CN", + "it-IT", + "ja-JP" + ], + "0/43/65532": 0, + "0/43/65533": 1, + "0/43/65528": [], + "0/43/65529": [], + "0/43/65531": [0, 1, 65528, 65529, 65531, 65532, 65533], + "0/44/0": 0, + "0/44/65532": 0, + "0/44/65533": 1, + "0/44/65528": [], + "0/44/65529": [], + "0/44/65531": [0, 65528, 65529, 65531, 65532, 65533], + "0/45/65532": 0, + "0/45/65533": 1, + "0/45/65528": [], + "0/45/65529": [], + "0/45/65531": [65528, 65529, 65531, 65532, 65533], + "0/48/0": 0, + "0/48/1": { + "0": 60, + "1": 900 + }, + "0/48/2": 0, + "0/48/3": 2, + "0/48/4": true, + "0/48/65532": 0, + "0/48/65533": 2, + "0/48/65528": [1, 3, 5], + "0/48/65529": [0, 2, 4], + "0/48/65531": [0, 1, 2, 3, 4, 65528, 65529, 65531, 65532, 65533], + "0/49/0": 1, + "0/49/1": [ + { + "0": "ZW5zMzM=", + "1": true + } + ], + "0/49/4": true, + "0/49/5": null, + "0/49/6": null, + "0/49/7": null, + "0/49/8": [0], + "0/49/65532": 4, + "0/49/65533": 2, + "0/49/65528": [], + "0/49/65529": [], + "0/49/65531": [0, 1, 4, 5, 6, 7, 8, 65528, 65529, 65531, 65532, 65533], + "0/51/0": [ + { + "0": "docker0", + "1": false, + "2": null, + "3": null, + "4": "AkI9NTnB", + "5": ["rBEAAQ=="], + "6": [""], + "7": 0 + }, + { + "0": "ens33", + "1": true, + "2": null, + "3": null, + "4": "AAwp/F0T", + "5": ["wKgBpw=="], + "6": [ + "KgEOCgKzOZCNB+q+Uz0I9w==", + "KgEOCgKzOZC/O1Ew1WvS4A==", + "/oAAAAAAAADml3Ozl7GZug==" + ], + "7": 2 + }, + { + "0": "lo", + "1": true, + "2": null, + "3": null, + "4": "AAAAAAAA", + "5": ["fwAAAQ=="], + "6": ["AAAAAAAAAAAAAAAAAAAAAQ=="], + "7": 0 + } + ], + "0/51/1": 1, + "0/51/2": 10129, + "0/51/8": true, + "0/51/65532": 0, + "0/51/65533": 2, + "0/51/65528": [2], + "0/51/65529": [0, 1], + "0/51/65531": [0, 1, 2, 8, 65528, 65529, 65531, 65532, 65533], + "0/60/0": 0, + "0/60/1": null, + "0/60/2": null, + "0/60/65532": 0, + "0/60/65533": 1, + "0/60/65528": [], + "0/60/65529": [0, 2], + "0/60/65531": [0, 1, 2, 65528, 65529, 65531, 65532, 65533], + "0/62/0": [ + { + "1": "FTABAQEkAgE3AyQTAhgmBIAigScmBYAlTTo3BiQVASQRFxgkBwEkCAEwCUEECp4PASYUFk/DwQqGNBikYdiBRDJZbrfF4AYK8Y9jOeIpx7Xy+giJhmTpAVZ662hwszsFDGULGY/owXtMrqTxEDcKNQEoARgkAgE2AwQCBAEYMAQUqBmxO16fPQhbf33Gb2XwQ+NkXpswBRTx8+4bdkuqlxInfB5LXkhRBBvS2hgwC0A8aefsLm663Vuy+TkSvn/oLhRqt2phrG+i5aM5o15xiWDjnNVdUYpT09+K0mgVoMdFuFsmoWQxQh6jahaFJzUgGA==", + "2": "FTABAQEkAgE3AyQUARgmBIAigScmBYAlTTo3BiQTAhgkBwEkCAEwCUEEGp55xGRB0FBQ3Yw7ayQSzVtYA0BtCJFm9vRRcdr+nk0cuGX6zrUowSYOO/qiRBEACcCNNSqKh+DpRm2uVLOtaDcKNQEpARgkAmAwBBTx8+4bdkuqlxInfB5LXkhRBBvS2jAFFIxTG68U5WQVsk8AtvSQyeK3KLqPGDALQIw/6q5ILMNdOMcSif8HNbEgpjBeaBMfUpzOJFCRPM16sv1xiq3mALZj0u+iG8lUJEvDJOFKPoBvsOubwIwRgAQY", + "254": 1 + } + ], + "0/62/1": [ + { + "1": "BMeyHMXjJpVWF9saehBu7pZLTwdopKZTl5JdhU0/ozZ/sk1paVFE1U8OtuZqM/S/4W/fnkCnUrQ/Xcs7Ddy0hPE=", + "2": 65521, + "3": 1, + "4": 23, + "5": "HA_test", + "254": 1 + }, + { + "1": "BBF47gm4BEBA6LXQluAHjn6P3+MZKrhuMcJligg1xcBM7X++F7GsZFh4hYAhdmD9HHwhtZxH2c85aAzbpikViwI=", + "2": 65521, + "3": 1, + "4": 100, + "5": "", + "254": 2 + } + ], + "0/62/2": 16, + "0/62/3": 2, + "0/62/4": [ + "FTABAQEkAgE3AyQUARgmBIAigScmBYAlTTo3BiQUARgkBwEkCAEwCUEEx7IcxeMmlVYX2xp6EG7ulktPB2ikplOXkl2FTT+jNn+yTWlpUUTVTw625moz9L/hb9+eQKdStD9dyzsN3LSE8TcKNQEpARgkAmAwBBSMUxuvFOVkFbJPALb0kMnityi6jzAFFIxTG68U5WQVsk8AtvSQyeK3KLqPGDALQPBVUg+OBUWl1pe/k55ZigAZl3lfBP1Qd5zQP4AUB45mNTzdli8DRCj+h7cIs3JHQQPlUaRvG5xUoBZ+C7Gg2sQY", + "FTABAQEkAgE3AyQUARgmBIAigScmBYAlTTo3BiQUARgkBwEkCAEwCUEEEXjuCbgEQEDotdCW4AeOfo/f4xkquG4xwmWKCDXFwEztf74XsaxkWHiFgCF2YP0cfCG1nEfZzzloDNumKRWLAjcKNQEpARgkAmAwBBQD3rx0jOdkiCPt06hxW7Z2jJBPXTAFFAPevHSM52SII+3TqHFbtnaMkE9dGDALQL+L3Zc6En6Ionk6WIz+lM50iwOEzTi9VwyYQRUdtO99T8jRX52+Olh6zcUtWQuYO2XYiH2OZ8lM4guqqnS8U4UY" + ], + "0/62/5": 1, + "0/62/65532": 0, + "0/62/65533": 1, + "0/62/65528": [1, 3, 5, 8], + "0/62/65529": [0, 2, 4, 6, 7, 9, 10, 11], + "0/62/65531": [0, 1, 2, 3, 4, 5, 65528, 65529, 65531, 65532, 65533], + "0/63/0": [], + "0/63/1": [], + "0/63/2": 4, + "0/63/3": 3, + "0/63/65532": 0, + "0/63/65533": 2, + "0/63/65528": [2, 5], + "0/63/65529": [0, 1, 3, 4], + "0/63/65531": [0, 1, 2, 3, 65528, 65529, 65531, 65532, 65533], + "1/3/0": 0, + "1/3/1": 0, + "1/3/65532": 0, + "1/3/65533": 5, + "1/3/65528": [], + "1/3/65529": [0, 64], + "1/3/65531": [0, 1, 65528, 65529, 65531, 65532, 65533], + "1/29/0": [ + { + "0": 1293, + "1": 1 + }, + { + "0": 1292, + "1": 1 + }, + { + "0": 1296, + "1": 1 + }, + { + "0": 17, + "1": 1 + } + ], + "1/29/1": [3, 29, 47, 144, 145, 152, 153, 156, 157, 159], + "1/29/2": [], + "1/29/3": [], + "1/29/65532": 0, + "1/29/65533": 2, + "1/29/65528": [], + "1/29/65529": [], + "1/29/65531": [0, 1, 2, 3, 65528, 65529, 65531, 65532, 65533], + "1/47/0": 1, + "1/47/1": 0, + "1/47/2": "Primary Mains Power", + "1/47/5": 0, + "1/47/7": 230000, + "1/47/8": 32000, + "1/47/31": [1], + "1/47/65532": 1, + "1/47/65533": 3, + "1/47/65528": [], + "1/47/65529": [], + "1/47/65531": [0, 1, 2, 5, 7, 8, 31, 65528, 65529, 65531, 65532, 65533], + "1/144/0": 2, + "1/144/1": 3, + "1/144/2": [ + { + "0": 5, + "1": true, + "2": -50000000, + "3": 50000000, + "4": [ + { + "0": -50000000, + "1": -10000000, + "2": 5000, + "3": 2000, + "4": 3000 + }, + { + "0": -9999999, + "1": 9999999, + "2": 1000, + "3": 100, + "4": 500 + }, + { + "0": 10000000, + "1": 50000000, + "2": 5000, + "3": 2000, + "4": 3000 + } + ] + }, + { + "0": 2, + "1": true, + "2": -100000, + "3": 100000, + "4": [ + { + "0": -100000, + "1": -5000, + "2": 5000, + "3": 2000, + "4": 3000 + }, + { + "0": -4999, + "1": 4999, + "2": 1000, + "3": 100, + "4": 500 + }, + { + "0": 5000, + "1": 100000, + "2": 5000, + "3": 2000, + "4": 3000 + } + ] + }, + { + "0": 1, + "1": true, + "2": -500000, + "3": 500000, + "4": [ + { + "0": -500000, + "1": -100000, + "2": 5000, + "3": 2000, + "4": 3000 + }, + { + "0": -99999, + "1": 99999, + "2": 1000, + "3": 100, + "4": 500 + }, + { + "0": 100000, + "1": 500000, + "2": 5000, + "3": 2000, + "4": 3000 + } + ] + } + ], + "1/144/3": [], + "1/144/4": null, + "1/144/5": null, + "1/144/6": null, + "1/144/7": null, + "1/144/8": null, + "1/144/9": null, + "1/144/10": null, + "1/144/11": null, + "1/144/12": null, + "1/144/13": null, + "1/144/14": null, + "1/144/15": [ + { + "0": 1, + "1": 100000 + } + ], + "1/144/16": [ + { + "0": 1, + "1": 100000 + } + ], + "1/144/17": null, + "1/144/18": null, + "1/144/65532": 31, + "1/144/65533": 1, + "1/144/65528": [], + "1/144/65529": [], + "1/144/65531": [ + 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 65528, + 65529, 65531, 65532, 65533 + ], + "1/145/0": { + "0": 14, + "1": true, + "2": 0, + "3": 1000000000000000, + "4": [ + { + "0": 98440650424323, + "1": 98442759724168, + "2": 0, + "3": 0, + "5": 140728898420739, + "6": 98440650424355 + } + ] + }, + "1/145/1": null, + "1/145/2": null, + "1/145/3": null, + "1/145/4": null, + "1/145/5": { + "0": 0, + "1": 0, + "2": 0, + "3": 0 + }, + "1/145/65532": 15, + "1/145/65533": 1, + "1/145/65528": [], + "1/145/65529": [], + "1/145/65531": [0, 1, 2, 3, 4, 5, 65528, 65529, 65531, 65532, 65533], + "1/152/0": 0, + "1/152/1": false, + "1/152/2": 1, + "1/152/3": 1200000, + "1/152/4": 7600000, + "1/152/5": null, + "1/152/6": null, + "1/152/7": 0, + "1/152/65532": 123, + "1/152/65533": 4, + "1/152/65528": [], + "1/152/65529": [0, 1, 2, 3, 4, 5, 6, 7], + "1/152/65531": [0, 1, 2, 3, 4, 5, 6, 7, 65528, 65529, 65531, 65532, 65533], + "1/153/0": 3, + "1/153/1": 1, + "1/153/2": 0, + "1/153/3": null, + "1/153/5": 32000, + "1/153/6": 2000, + "1/153/7": 30000, + "1/153/9": 32000, + "1/153/10": 600, + "1/153/35": null, + "1/153/36": null, + "1/153/37": null, + "1/153/38": null, + "1/153/39": null, + "1/153/64": 2, + "1/153/65": 0, + "1/153/66": 0, + "1/153/65532": 9, + "1/153/65533": 3, + "1/153/65528": [0], + "1/153/65529": [1, 2, 5, 6, 7, 4], + "1/153/65531": [ + 0, 1, 2, 3, 5, 6, 7, 9, 10, 35, 36, 37, 38, 39, 64, 65, 66, 65528, 65529, + 65531, 65532, 65533 + ], + "1/156/65532": 1, + "1/156/65533": 1, + "1/156/65528": [], + "1/156/65529": [], + "1/156/65531": [65528, 65529, 65531, 65532, 65533], + "1/157/0": [ + { + "0": "Manual", + "1": 0, + "2": [ + { + "1": 16384 + } + ] + }, + { + "0": "Auto-scheduled", + "1": 1, + "2": [ + { + "1": 16385 + } + ] + }, + { + "0": "Solar", + "1": 2, + "2": [ + { + "1": 16386 + } + ] + }, + { + "0": "Auto-scheduled with Solar charging", + "1": 3, + "2": [ + { + "1": 16385 + }, + { + "1": 16386 + } + ] + } + ], + "1/157/1": 1, + "1/157/65532": 0, + "1/157/65533": 2, + "1/157/65528": [1], + "1/157/65529": [0], + "1/157/65531": [0, 1, 65528, 65529, 65531, 65532, 65533], + "1/159/0": [ + { + "0": "No energy management (forecast only)", + "1": 0, + "2": [ + { + "1": 16384 + } + ] + }, + { + "0": "Device optimizes (no local or grid control)", + "1": 1, + "2": [ + { + "1": 16385 + } + ] + }, + { + "0": "Optimized within building", + "1": 2, + "2": [ + { + "1": 16386 + }, + { + "1": 16385 + } + ] + }, + { + "0": "Optimized for grid", + "1": 3, + "2": [ + { + "1": 16385 + }, + { + "1": 16387 + } + ] + }, + { + "0": "Optimized for grid and building", + "1": 4, + "2": [ + { + "1": 16386 + }, + { + "1": 16385 + }, + { + "1": 16387 + } + ] + } + ], + "1/159/1": 3, + "1/159/65532": 0, + "1/159/65533": 2, + "1/159/65528": [1], + "1/159/65529": [0], + "1/159/65531": [0, 1, 65528, 65529, 65531, 65532, 65533] + }, + "attribute_subscriptions": [] +} diff --git a/tests/components/matter/snapshots/test_binary_sensor.ambr b/tests/components/matter/snapshots/test_binary_sensor.ambr index c8de905d03f..ec5317ba808 100644 --- a/tests/components/matter/snapshots/test_binary_sensor.ambr +++ b/tests/components/matter/snapshots/test_binary_sensor.ambr @@ -383,6 +383,150 @@ 'state': 'off', }) # --- +# name: test_binary_sensors[silabs_evse_charging][binary_sensor.evse_charging_status-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.evse_charging_status', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Charging status', + 'platform': 'matter', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'evse_charging_status', + 'unique_id': '00000000000004D2-0000000000000017-MatterNodeDevice-1-EnergyEvseChargingStatusSensor-153-0', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensors[silabs_evse_charging][binary_sensor.evse_charging_status-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'battery_charging', + 'friendly_name': 'evse Charging status', + }), + 'context': , + 'entity_id': 'binary_sensor.evse_charging_status', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_binary_sensors[silabs_evse_charging][binary_sensor.evse_plug-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.evse_plug', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Plug', + 'platform': 'matter', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'evse_plug_state', + 'unique_id': '00000000000004D2-0000000000000017-MatterNodeDevice-1-EnergyEvsePlugStateSensor-153-0', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensors[silabs_evse_charging][binary_sensor.evse_plug-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'plug', + 'friendly_name': 'evse Plug', + }), + 'context': , + 'entity_id': 'binary_sensor.evse_plug', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_binary_sensors[silabs_evse_charging][binary_sensor.evse_supply_charging_state-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.evse_supply_charging_state', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Supply charging state', + 'platform': 'matter', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'evse_supply_charging_state', + 'unique_id': '00000000000004D2-0000000000000017-MatterNodeDevice-1-EnergyEvseSupplyStateSensor-153-1', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensors[silabs_evse_charging][binary_sensor.evse_supply_charging_state-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'running', + 'friendly_name': 'evse Supply charging state', + }), + 'context': , + 'entity_id': 'binary_sensor.evse_supply_charging_state', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- # name: test_binary_sensors[smoke_detector][binary_sensor.smoke_sensor_battery_alert-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ diff --git a/tests/components/matter/snapshots/test_select.ambr b/tests/components/matter/snapshots/test_select.ambr index 772ee297e13..8ad579214d0 100644 --- a/tests/components/matter/snapshots/test_select.ambr +++ b/tests/components/matter/snapshots/test_select.ambr @@ -1543,6 +1543,128 @@ 'state': 'previous', }) # --- +# name: test_selects[silabs_evse_charging][select.evse_energy_management_mode-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'No energy management (forecast only)', + 'Device optimizes (no local or grid control)', + 'Optimized within building', + 'Optimized for grid', + 'Optimized for grid and building', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'select', + 'entity_category': None, + 'entity_id': 'select.evse_energy_management_mode', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Energy management mode', + 'platform': 'matter', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'device_energy_management_mode', + 'unique_id': '00000000000004D2-0000000000000017-MatterNodeDevice-1-MatterDeviceEnergyManagementMode-159-1', + 'unit_of_measurement': None, + }) +# --- +# name: test_selects[silabs_evse_charging][select.evse_energy_management_mode-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'evse Energy management mode', + 'options': list([ + 'No energy management (forecast only)', + 'Device optimizes (no local or grid control)', + 'Optimized within building', + 'Optimized for grid', + 'Optimized for grid and building', + ]), + }), + 'context': , + 'entity_id': 'select.evse_energy_management_mode', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'Optimized for grid', + }) +# --- +# name: test_selects[silabs_evse_charging][select.evse_mode-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'Manual', + 'Auto-scheduled', + 'Solar', + 'Auto-scheduled with Solar charging', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'select', + 'entity_category': None, + 'entity_id': 'select.evse_mode', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Mode', + 'platform': 'matter', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'mode', + 'unique_id': '00000000000004D2-0000000000000017-MatterNodeDevice-1-MatterEnergyEvseMode-157-1', + 'unit_of_measurement': None, + }) +# --- +# name: test_selects[silabs_evse_charging][select.evse_mode-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'evse Mode', + 'options': list([ + 'Manual', + 'Auto-scheduled', + 'Solar', + 'Auto-scheduled with Solar charging', + ]), + }), + 'context': , + 'entity_id': 'select.evse_mode', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'Auto-scheduled', + }) +# --- # name: test_selects[silabs_laundrywasher][select.laundrywasher_number_of_rinses-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ diff --git a/tests/components/matter/snapshots/test_sensor.ambr b/tests/components/matter/snapshots/test_sensor.ambr index cb26f1d8e70..b3395551d74 100644 --- a/tests/components/matter/snapshots/test_sensor.ambr +++ b/tests/components/matter/snapshots/test_sensor.ambr @@ -2866,6 +2866,323 @@ 'state': '120.0', }) # --- +# name: test_sensors[silabs_evse_charging][sensor.evse_circuit_capacity-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.evse_circuit_capacity', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Circuit capacity', + 'platform': 'matter', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'evse_circuit_capacity', + 'unique_id': '00000000000004D2-0000000000000017-MatterNodeDevice-1-EnergyEvseCircuitCapacity-153-5', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[silabs_evse_charging][sensor.evse_circuit_capacity-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'current', + 'friendly_name': 'evse Circuit capacity', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.evse_circuit_capacity', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '32.0', + }) +# --- +# name: test_sensors[silabs_evse_charging][sensor.evse_fault_state-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'no_error', + 'meter_failure', + 'over_voltage', + 'under_voltage', + 'over_current', + 'contact_wet_failure', + 'contact_dry_failure', + 'power_loss', + 'power_quality', + 'pilot_short_circuit', + 'emergency_stop', + 'ev_disconnected', + 'wrong_power_supply', + 'live_neutral_swap', + 'over_temperature', + 'other', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.evse_fault_state', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Fault state', + 'platform': 'matter', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'evse_fault_state', + 'unique_id': '00000000000004D2-0000000000000017-MatterNodeDevice-1-EnergyEvseFaultState-153-2', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensors[silabs_evse_charging][sensor.evse_fault_state-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'enum', + 'friendly_name': 'evse Fault state', + 'options': list([ + 'no_error', + 'meter_failure', + 'over_voltage', + 'under_voltage', + 'over_current', + 'contact_wet_failure', + 'contact_dry_failure', + 'power_loss', + 'power_quality', + 'pilot_short_circuit', + 'emergency_stop', + 'ev_disconnected', + 'wrong_power_supply', + 'live_neutral_swap', + 'over_temperature', + 'other', + ]), + }), + 'context': , + 'entity_id': 'sensor.evse_fault_state', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'no_error', + }) +# --- +# name: test_sensors[silabs_evse_charging][sensor.evse_max_charge_current-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.evse_max_charge_current', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Max charge current', + 'platform': 'matter', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'evse_max_charge_current', + 'unique_id': '00000000000004D2-0000000000000017-MatterNodeDevice-1-EnergyEvseMaximumChargeCurrent-153-7', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[silabs_evse_charging][sensor.evse_max_charge_current-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'current', + 'friendly_name': 'evse Max charge current', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.evse_max_charge_current', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '30.0', + }) +# --- +# name: test_sensors[silabs_evse_charging][sensor.evse_min_charge_current-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.evse_min_charge_current', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Min charge current', + 'platform': 'matter', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'evse_min_charge_current', + 'unique_id': '00000000000004D2-0000000000000017-MatterNodeDevice-1-EnergyEvseMinimumChargeCurrent-153-6', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[silabs_evse_charging][sensor.evse_min_charge_current-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'current', + 'friendly_name': 'evse Min charge current', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.evse_min_charge_current', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '2.0', + }) +# --- +# name: test_sensors[silabs_evse_charging][sensor.evse_user_max_charge_current-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.evse_user_max_charge_current', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'User max charge current', + 'platform': 'matter', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'evse_user_max_charge_current', + 'unique_id': '00000000000004D2-0000000000000017-MatterNodeDevice-1-EnergyEvseUserMaximumChargeCurrent-153-9', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[silabs_evse_charging][sensor.evse_user_max_charge_current-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'current', + 'friendly_name': 'evse User max charge current', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.evse_user_max_charge_current', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '32.0', + }) +# --- # name: test_sensors[silabs_laundrywasher][sensor.laundrywasher_current-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ diff --git a/tests/components/matter/snapshots/test_switch.ambr b/tests/components/matter/snapshots/test_switch.ambr index ebf43117846..d60a2933e6f 100644 --- a/tests/components/matter/snapshots/test_switch.ambr +++ b/tests/components/matter/snapshots/test_switch.ambr @@ -334,6 +334,53 @@ 'state': 'off', }) # --- +# name: test_switches[silabs_evse_charging][switch.evse_enable_charging-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': None, + 'entity_id': 'switch.evse_enable_charging', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Enable charging', + 'platform': 'matter', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'evse_charging_switch', + 'unique_id': '00000000000004D2-0000000000000017-MatterNodeDevice-1-EnergyEvseChargingSwitch-153-1', + 'unit_of_measurement': None, + }) +# --- +# name: test_switches[silabs_evse_charging][switch.evse_enable_charging-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'evse Enable charging', + }), + 'context': , + 'entity_id': 'switch.evse_enable_charging', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- # name: test_switches[switch_unit][switch.mock_switchunit-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ diff --git a/tests/components/matter/test_binary_sensor.py b/tests/components/matter/test_binary_sensor.py index cddee975ac8..acd150d9131 100644 --- a/tests/components/matter/test_binary_sensor.py +++ b/tests/components/matter/test_binary_sensor.py @@ -147,3 +147,53 @@ async def test_optional_sensor_from_featuremap( ) state = hass.states.get(entity_id) assert state is None + + +@pytest.mark.parametrize("node_fixture", ["silabs_evse_charging"]) +async def test_evse_sensor( + hass: HomeAssistant, + matter_client: MagicMock, + matter_node: MatterNode, +) -> None: + """Test evse sensors.""" + # Test StateEnum value with binary_sensor.evse_charging_status + entity_id = "binary_sensor.evse_charging_status" + state = hass.states.get(entity_id) + assert state + assert state.state == "on" + # switch to PluggedInDemand state + set_node_attribute(matter_node, 1, 153, 0, 2) + await trigger_subscription_callback( + hass, matter_client, data=(matter_node.node_id, "1/153/0", 2) + ) + state = hass.states.get(entity_id) + assert state + assert state.state == "off" + + # Test StateEnum value with binary_sensor.evse_plug + entity_id = "binary_sensor.evse_plug" + state = hass.states.get(entity_id) + assert state + assert state.state == "on" + # switch to NotPluggedIn state + set_node_attribute(matter_node, 1, 153, 0, 0) + await trigger_subscription_callback( + hass, matter_client, data=(matter_node.node_id, "1/153/0", 0) + ) + state = hass.states.get(entity_id) + assert state + assert state.state == "off" + + # Test SupplyStateEnum value with binary_sensor.evse_supply_charging + entity_id = "binary_sensor.evse_supply_charging_state" + state = hass.states.get(entity_id) + assert state + assert state.state == "on" + # switch to Disabled state + set_node_attribute(matter_node, 1, 153, 1, 0) + await trigger_subscription_callback( + hass, matter_client, data=(matter_node.node_id, "1/153/1", 0) + ) + state = hass.states.get(entity_id) + assert state + assert state.state == "off" diff --git a/tests/components/matter/test_sensor.py b/tests/components/matter/test_sensor.py index 251aab73e3b..bcdb573b3c8 100644 --- a/tests/components/matter/test_sensor.py +++ b/tests/components/matter/test_sensor.py @@ -399,3 +399,71 @@ async def test_list_sensor( state = hass.states.get("sensor.laundrywasher_current_phase") assert state assert state.state == "rinse" + + +@pytest.mark.parametrize("node_fixture", ["silabs_evse_charging"]) +async def test_evse_sensor( + hass: HomeAssistant, + matter_client: MagicMock, + matter_node: MatterNode, +) -> None: + """Test evse sensors.""" + # EnergyEvseFaultState + state = hass.states.get("sensor.evse_fault_state") + assert state + assert state.state == "no_error" + + set_node_attribute(matter_node, 1, 153, 2, 4) + await trigger_subscription_callback(hass, matter_client) + + state = hass.states.get("sensor.evse_fault_state") + assert state + assert state.state == "over_current" + + # EnergyEvseCircuitCapacity + state = hass.states.get("sensor.evse_circuit_capacity") + assert state + assert state.state == "32.0" + + set_node_attribute(matter_node, 1, 153, 5, 63000) + await trigger_subscription_callback(hass, matter_client) + + state = hass.states.get("sensor.evse_circuit_capacity") + assert state + assert state.state == "63.0" + + # EnergyEvseMinimumChargeCurrent + state = hass.states.get("sensor.evse_min_charge_current") + assert state + assert state.state == "2.0" + + set_node_attribute(matter_node, 1, 153, 6, 5000) + await trigger_subscription_callback(hass, matter_client) + + state = hass.states.get("sensor.evse_min_charge_current") + assert state + assert state.state == "5.0" + + # EnergyEvseMaximumChargeCurrent + state = hass.states.get("sensor.evse_max_charge_current") + assert state + assert state.state == "30.0" + + set_node_attribute(matter_node, 1, 153, 7, 20000) + await trigger_subscription_callback(hass, matter_client) + + state = hass.states.get("sensor.evse_max_charge_current") + assert state + assert state.state == "20.0" + + # EnergyEvseUserMaximumChargeCurrent + state = hass.states.get("sensor.evse_user_max_charge_current") + assert state + assert state.state == "32.0" + + set_node_attribute(matter_node, 1, 153, 9, 63000) + await trigger_subscription_callback(hass, matter_client) + + state = hass.states.get("sensor.evse_user_max_charge_current") + assert state + assert state.state == "63.0" diff --git a/tests/components/matter/test_switch.py b/tests/components/matter/test_switch.py index e82848fcc3a..f294cd31a26 100644 --- a/tests/components/matter/test_switch.py +++ b/tests/components/matter/test_switch.py @@ -3,6 +3,7 @@ from unittest.mock import MagicMock, call from chip.clusters import Objects as clusters +from chip.clusters.Objects import NullValue from matter_server.client.models.node import MatterNode from matter_server.common.errors import MatterError from matter_server.common.helpers.util import create_attribute_path_from_attribute @@ -188,3 +189,46 @@ async def test_matter_exception_on_command( }, blocking=True, ) + + +@pytest.mark.parametrize("node_fixture", ["silabs_evse_charging"]) +async def test_evse_sensor( + hass: HomeAssistant, + matter_client: MagicMock, + matter_node: MatterNode, +) -> None: + """Test evse sensors.""" + state = hass.states.get("switch.evse_enable_charging") + assert state + assert state.state == "on" + # test switch service + await hass.services.async_call( + "switch", + "turn_off", + {"entity_id": "switch.evse_enable_charging"}, + blocking=True, + ) + assert matter_client.send_device_command.call_count == 1 + assert matter_client.send_device_command.call_args == call( + node_id=matter_node.node_id, + endpoint_id=1, + command=clusters.EnergyEvse.Commands.Disable(), + timed_request_timeout_ms=3000, + ) + await hass.services.async_call( + "switch", + "turn_on", + {"entity_id": "switch.evse_enable_charging"}, + blocking=True, + ) + assert matter_client.send_device_command.call_count == 2 + assert matter_client.send_device_command.call_args == call( + node_id=matter_node.node_id, + endpoint_id=1, + command=clusters.EnergyEvse.Commands.EnableCharging( + chargingEnabledUntil=NullValue, + minimumChargeCurrent=0, + maximumChargeCurrent=0, + ), + timed_request_timeout_ms=3000, + ) From 9c538d1e227cfdb87b36c8855e3f5b09e8cf9733 Mon Sep 17 00:00:00 2001 From: Klaas Schoute Date: Fri, 4 Apr 2025 21:18:09 +0200 Subject: [PATCH 281/505] Bump forecast-solar lib to v4.1.0 (#142280) Co-authored-by: Jan-Philipp Benecke --- homeassistant/components/forecast_solar/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/forecast_solar/manifest.json b/homeassistant/components/forecast_solar/manifest.json index 1eb9c98701d..769bda56adc 100644 --- a/homeassistant/components/forecast_solar/manifest.json +++ b/homeassistant/components/forecast_solar/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/forecast_solar", "integration_type": "service", "iot_class": "cloud_polling", - "requirements": ["forecast-solar==4.0.0"] + "requirements": ["forecast-solar==4.1.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index ef84c29eeef..22287c8c577 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -954,7 +954,7 @@ fnv-hash-fast==1.4.0 foobot_async==1.0.0 # homeassistant.components.forecast_solar -forecast-solar==4.0.0 +forecast-solar==4.1.0 # homeassistant.components.fortios fortiosapi==1.0.5 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index dc7813b2186..0e914b91275 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -814,7 +814,7 @@ fnv-hash-fast==1.4.0 foobot_async==1.0.0 # homeassistant.components.forecast_solar -forecast-solar==4.0.0 +forecast-solar==4.1.0 # homeassistant.components.freebox freebox-api==1.2.2 From 64e17356473cf4daf6859f4978de910ba20ca764 Mon Sep 17 00:00:00 2001 From: Robert Resch Date: Fri, 4 Apr 2025 21:19:15 +0200 Subject: [PATCH 282/505] Fix circular mean by always storing and using the weighted one (#142208) * Fix circular mean by always storing and using the weighted one * fix * Fix test --- .../components/recorder/statistics.py | 86 +++++++++++-------- homeassistant/components/sensor/recorder.py | 4 +- tests/components/sensor/test_recorder.py | 46 +++++----- 3 files changed, 76 insertions(+), 60 deletions(-) diff --git a/homeassistant/components/recorder/statistics.py b/homeassistant/components/recorder/statistics.py index 2507a66899e..80c0028ef7a 100644 --- a/homeassistant/components/recorder/statistics.py +++ b/homeassistant/components/recorder/statistics.py @@ -139,14 +139,13 @@ def query_circular_mean(table: type[StatisticsBase]) -> tuple[Label, Label]: # in Python. # https://en.wikipedia.org/wiki/Circular_mean radians = func.radians(table.mean) + weighted_sum_sin = func.sum(func.sin(radians) * table.mean_weight) + weighted_sum_cos = func.sum(func.cos(radians) * table.mean_weight) weight = func.sqrt( - func.power(func.sum(func.sin(radians) * table.mean_weight), 2) - + func.power(func.sum(func.cos(radians) * table.mean_weight), 2) + func.power(weighted_sum_sin, 2) + func.power(weighted_sum_cos, 2) ) return ( - func.degrees( - func.atan2(func.sum(func.sin(radians)), func.sum(func.cos(radians))) - ).label("mean"), + func.degrees(func.atan2(weighted_sum_sin, weighted_sum_cos)).label("mean"), weight.label("mean_weight"), ) @@ -240,18 +239,20 @@ DEG_TO_RAD = math.pi / 180 RAD_TO_DEG = 180 / math.pi -def weighted_circular_mean(values: Iterable[tuple[float, float]]) -> float: - """Return the weighted circular mean of the values.""" - sin_sum = sum(math.sin(x * DEG_TO_RAD) * weight for x, weight in values) - cos_sum = sum(math.cos(x * DEG_TO_RAD) * weight for x, weight in values) - return (RAD_TO_DEG * math.atan2(sin_sum, cos_sum)) % 360 +def weighted_circular_mean( + values: Iterable[tuple[float, float]], +) -> tuple[float, float]: + """Return the weighted circular mean and the weight of the values.""" + weighted_sin_sum, weighted_cos_sum = 0.0, 0.0 + for x, weight in values: + rad_x = x * DEG_TO_RAD + weighted_sin_sum += math.sin(rad_x) * weight + weighted_cos_sum += math.cos(rad_x) * weight - -def circular_mean(values: list[float]) -> float: - """Return the circular mean of the values.""" - sin_sum = sum(math.sin(x * DEG_TO_RAD) for x in values) - cos_sum = sum(math.cos(x * DEG_TO_RAD) for x in values) - return (RAD_TO_DEG * math.atan2(sin_sum, cos_sum)) % 360 + return ( + (RAD_TO_DEG * math.atan2(weighted_sin_sum, weighted_cos_sum)) % 360, + math.sqrt(weighted_sin_sum**2 + weighted_cos_sum**2), + ) _LOGGER = logging.getLogger(__name__) @@ -300,6 +301,7 @@ class StatisticsRow(BaseStatisticsRow, total=False): min: float | None max: float | None mean: float | None + mean_weight: float | None change: float | None @@ -1023,7 +1025,7 @@ def _reduce_statistics( _want_sum = "sum" in types for statistic_id, stat_list in stats.items(): max_values: list[float] = [] - mean_values: list[float] = [] + mean_values: list[tuple[float, float]] = [] min_values: list[float] = [] prev_stat: StatisticsRow = stat_list[0] fake_entry: StatisticsRow = {"start": stat_list[-1]["start"] + period_seconds} @@ -1039,12 +1041,15 @@ def _reduce_statistics( } if _want_mean: row["mean"] = None + row["mean_weight"] = None if mean_values: match metadata[statistic_id][1]["mean_type"]: case StatisticMeanType.ARITHMETIC: - row["mean"] = mean(mean_values) + row["mean"] = mean([x[0] for x in mean_values]) case StatisticMeanType.CIRCULAR: - row["mean"] = circular_mean(mean_values) + row["mean"], row["mean_weight"] = ( + weighted_circular_mean(mean_values) + ) mean_values.clear() if _want_min: row["min"] = min(min_values) if min_values else None @@ -1063,7 +1068,8 @@ def _reduce_statistics( max_values.append(_max) if _want_mean: if (_mean := statistic.get("mean")) is not None: - mean_values.append(_mean) + _mean_weight = statistic.get("mean_weight") or 0.0 + mean_values.append((_mean, _mean_weight)) if _want_min and (_min := statistic.get("min")) is not None: min_values.append(_min) prev_stat = statistic @@ -1385,7 +1391,7 @@ def _get_max_mean_min_statistic( match metadata[1]["mean_type"]: case StatisticMeanType.CIRCULAR: if circular_means := max_mean_min["circular_means"]: - mean_value = weighted_circular_mean(circular_means) + mean_value = weighted_circular_mean(circular_means)[0] case StatisticMeanType.ARITHMETIC: if (mean_value := max_mean_min.get("mean_acc")) is not None and ( duration := max_mean_min.get("duration") @@ -1739,12 +1745,12 @@ def statistic_during_period( _type_column_mapping = { - "last_reset": "last_reset_ts", - "max": "max", - "mean": "mean", - "min": "min", - "state": "state", - "sum": "sum", + "last_reset": ("last_reset_ts",), + "max": ("max",), + "mean": ("mean", "mean_weight"), + "min": ("min",), + "state": ("state",), + "sum": ("sum",), } @@ -1756,12 +1762,13 @@ def _generate_select_columns_for_types_stmt( track_on: list[str | None] = [ table.__tablename__, # type: ignore[attr-defined] ] - for key, column in _type_column_mapping.items(): - if key in types: - columns = columns.add_columns(getattr(table, column)) - track_on.append(column) - else: - track_on.append(None) + for key, type_columns in _type_column_mapping.items(): + for column in type_columns: + if key in types: + columns = columns.add_columns(getattr(table, column)) + track_on.append(column) + else: + track_on.append(None) return lambda_stmt(lambda: columns, track_on=track_on) @@ -1944,6 +1951,12 @@ def _statistics_during_period_with_session( hass, session, start_time, units, _types, table, metadata, result ) + # filter out mean_weight as it is only needed to reduce statistics + # and not needed in the result + for stats_rows in result.values(): + for row in stats_rows: + row.pop("mean_weight", None) + # Return statistics combined with metadata return result @@ -2391,7 +2404,12 @@ def _sorted_statistics_to_dict( field_map["last_reset"] = field_map.pop("last_reset_ts") sum_idx = field_map["sum"] if "sum" in types else None sum_only = len(types) == 1 and sum_idx is not None - row_mapping = tuple((key, field_map[key]) for key in types if key in field_map) + row_mapping = tuple( + (column, field_map[column]) + for key in types + for column in ({key, *_type_column_mapping.get(key, ())}) + if column in field_map + ) # Append all statistic entries, and optionally do unit conversion table_duration_seconds = table.duration.total_seconds() for meta_id, db_rows in stats_by_meta_id.items(): diff --git a/homeassistant/components/sensor/recorder.py b/homeassistant/components/sensor/recorder.py index cb80fa7d2ce..c321caa616d 100644 --- a/homeassistant/components/sensor/recorder.py +++ b/homeassistant/components/sensor/recorder.py @@ -160,7 +160,7 @@ def _time_weighted_arithmetic_mean( def _time_weighted_circular_mean( fstates: list[tuple[float, State]], start: datetime.datetime, end: datetime.datetime -) -> float: +) -> tuple[float, float]: """Calculate a time weighted circular mean. The circular mean is calculated by weighting the states by duration in seconds between @@ -623,7 +623,7 @@ def compile_statistics( # noqa: C901 valid_float_states, start, end ) case StatisticMeanType.CIRCULAR: - stat["mean"] = _time_weighted_circular_mean( + stat["mean"], stat["mean_weight"] = _time_weighted_circular_mean( valid_float_states, start, end ) diff --git a/tests/components/sensor/test_recorder.py b/tests/components/sensor/test_recorder.py index 962c0a0ef8f..43f185f939a 100644 --- a/tests/components/sensor/test_recorder.py +++ b/tests/components/sensor/test_recorder.py @@ -4508,23 +4508,19 @@ async def test_compile_statistics_hourly_daily_monthly_summary( duration += dur return total / duration - def _time_weighted_circular_mean(values: list[tuple[float, int]]): + def _weighted_circular_mean( + values: Iterable[tuple[float, float]], + ) -> tuple[float, float]: sin_sum = 0 cos_sum = 0 - for x, dur in values: - sin_sum += math.sin(x * DEG_TO_RAD) * dur - cos_sum += math.cos(x * DEG_TO_RAD) * dur + for x, weight in values: + sin_sum += math.sin(x * DEG_TO_RAD) * weight + cos_sum += math.cos(x * DEG_TO_RAD) * weight - return (RAD_TO_DEG * math.atan2(sin_sum, cos_sum)) % 360 - - def _circular_mean(values: list[float]) -> float: - sin_sum = 0 - cos_sum = 0 - for x in values: - sin_sum += math.sin(x * DEG_TO_RAD) - cos_sum += math.cos(x * DEG_TO_RAD) - - return (RAD_TO_DEG * math.atan2(sin_sum, cos_sum)) % 360 + return ( + (RAD_TO_DEG * math.atan2(sin_sum, cos_sum)) % 360, + math.sqrt(sin_sum**2 + cos_sum**2), + ) def _min(seq, last_state): if last_state is None: @@ -4631,7 +4627,7 @@ async def test_compile_statistics_hourly_daily_monthly_summary( values = [(seq, durations[j]) for j, seq in enumerate(seq)] if (state := last_states["sensor.test5"]) is not None: values.append((state, 5)) - expected_means["sensor.test5"].append(_time_weighted_circular_mean(values)) + expected_means["sensor.test5"].append(_weighted_circular_mean(values)) last_states["sensor.test5"] = seq[-1] start += timedelta(minutes=5) @@ -4733,15 +4729,17 @@ async def test_compile_statistics_hourly_daily_monthly_summary( start = zero end = zero + timedelta(minutes=5) for i in range(24): - for entity_id in ( - "sensor.test1", - "sensor.test2", - "sensor.test3", - "sensor.test4", - "sensor.test5", + for entity_id, mean_extractor in ( + ("sensor.test1", lambda x: x), + ("sensor.test2", lambda x: x), + ("sensor.test3", lambda x: x), + ("sensor.test4", lambda x: x), + ("sensor.test5", lambda x: x[0]), ): expected_average = ( - expected_means[entity_id][i] if entity_id in expected_means else None + mean_extractor(expected_means[entity_id][i]) + if entity_id in expected_means + else None ) expected_minimum = ( expected_minima[entity_id][i] if entity_id in expected_minima else None @@ -4772,7 +4770,7 @@ async def test_compile_statistics_hourly_daily_monthly_summary( assert stats == expected_stats def verify_stats( - period: Literal["5minute", "day", "hour", "week", "month"], + period: Literal["hour", "day", "week", "month"], start: datetime, next_datetime: Callable[[datetime], datetime], ) -> None: @@ -4791,7 +4789,7 @@ async def test_compile_statistics_hourly_daily_monthly_summary( ("sensor.test2", mean), ("sensor.test3", mean), ("sensor.test4", mean), - ("sensor.test5", _circular_mean), + ("sensor.test5", lambda x: _weighted_circular_mean(x)[0]), ): expected_average = ( mean_fn(expected_means[entity_id][i * 12 : (i + 1) * 12]) From 69e241d2e652b6e15ba1cc4fe9dca878c4d7a61d Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Fri, 4 Apr 2025 22:03:02 +0200 Subject: [PATCH 283/505] Add Docker host networking issue detection (#142259) * Add Docker host networking issue detection * Update homeassistant/components/network/strings.json Co-authored-by: Jan-Philipp Benecke * Process review comments --------- Co-authored-by: Jan-Philipp Benecke --- homeassistant/components/network/__init__.py | 31 +++++++++++- homeassistant/components/network/strings.json | 6 +++ .../network/snapshots/test_init.ambr | 22 +++++++++ tests/components/network/test_init.py | 49 +++++++++++++++++++ 4 files changed, 107 insertions(+), 1 deletion(-) create mode 100644 tests/components/network/snapshots/test_init.ambr diff --git a/homeassistant/components/network/__init__.py b/homeassistant/components/network/__init__.py index 200cce86997..14c7dc55cf0 100644 --- a/homeassistant/components/network/__init__.py +++ b/homeassistant/components/network/__init__.py @@ -4,12 +4,14 @@ from __future__ import annotations from ipaddress import IPv4Address, IPv6Address, ip_interface import logging +from pathlib import Path from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers import config_validation as cv +from homeassistant.helpers import config_validation as cv, issue_registry as ir from homeassistant.helpers.typing import UNDEFINED, ConfigType, UndefinedType from homeassistant.loader import bind_hass +from homeassistant.util import package from . import util from .const import ( @@ -27,6 +29,19 @@ _LOGGER = logging.getLogger(__name__) CONFIG_SCHEMA = cv.empty_config_schema(DOMAIN) +def _check_docker_without_host_networking() -> bool: + """Check if we are not using host networking in Docker.""" + if not package.is_docker_env(): + # We are not in Docker, so we don't need to check for host networking + return True + + if Path("/proc/sys/net/ipv4/ip_forward").exists(): + # If we can read this file, we likely have host networking + return True + + return False + + @bind_hass async def async_get_adapters(hass: HomeAssistant) -> list[Adapter]: """Get the network adapter configuration.""" @@ -166,5 +181,19 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: await async_get_network(hass) + if not await hass.async_add_executor_job(_check_docker_without_host_networking): + docs_url = "https://docs.docker.com/network/network-tutorial-host/" + install_url = "https://www.home-assistant.io/installation/linux#install-home-assistant-container" + ir.async_create_issue( + hass, + DOMAIN, + "docker_host_network", + is_fixable=False, + severity=ir.IssueSeverity.WARNING, + translation_key="docker_host_network", + learn_more_url=install_url, + translation_placeholders={"docs_url": docs_url, "install_url": install_url}, + ) + async_register_websocket_commands(hass) return True diff --git a/homeassistant/components/network/strings.json b/homeassistant/components/network/strings.json index 6aca7343221..3e135fff60b 100644 --- a/homeassistant/components/network/strings.json +++ b/homeassistant/components/network/strings.json @@ -6,5 +6,11 @@ "ipv6_addresses": "IPv6 addresses", "announce_addresses": "Announce addresses" } + }, + "issues": { + "docker_host_network": { + "title": "Home Assistant is not using host networking", + "description": "Home Assistant is running in a container without host networking mode. This can cause networking issues with device discovery, multicast, broadcast, other network features, and incorrectly detecting its own URL and IP addresses, causing issues with media players and sending audio responses to voice assistants.\n\nIt is recommended to run Home Assistant with host networking by adding the `--network host` flag to your Docker run command or setting `network_mode: host` in your `docker-compose.yml` file.\n\nSee the [Docker documentation]({docs_url}) for more information about Docker host networking and refer to the [Home Assistant installation guide]({install_url}) for our recommended and supported setup." + } } } diff --git a/tests/components/network/snapshots/test_init.ambr b/tests/components/network/snapshots/test_init.ambr new file mode 100644 index 00000000000..268c8e0d44f --- /dev/null +++ b/tests/components/network/snapshots/test_init.ambr @@ -0,0 +1,22 @@ +# serializer version: 1 +# name: test_repair_docker_host_network_without_host_networking[mock_socket0] + IssueRegistryItemSnapshot({ + 'active': True, + 'breaks_in_ha_version': None, + 'created': , + 'data': None, + 'dismissed_version': None, + 'domain': 'network', + 'is_fixable': False, + 'is_persistent': False, + 'issue_domain': None, + 'issue_id': 'docker_host_network', + 'learn_more_url': 'https://www.home-assistant.io/installation/linux#install-home-assistant-container', + 'severity': , + 'translation_key': 'docker_host_network', + 'translation_placeholders': dict({ + 'docs_url': 'https://docs.docker.com/network/network-tutorial-host/', + 'install_url': 'https://www.home-assistant.io/installation/linux#install-home-assistant-container', + }), + }) +# --- diff --git a/tests/components/network/test_init.py b/tests/components/network/test_init.py index a2352e6af9e..372dba1772d 100644 --- a/tests/components/network/test_init.py +++ b/tests/components/network/test_init.py @@ -1,10 +1,13 @@ """Test the Network Configuration.""" +from __future__ import annotations + from ipaddress import IPv4Address from typing import Any from unittest.mock import MagicMock, Mock, patch import pytest +from syrupy.assertion import SnapshotAssertion from homeassistant.components import network from homeassistant.components.network.const import ( @@ -17,6 +20,7 @@ from homeassistant.components.network.const import ( ) from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers import issue_registry as ir from homeassistant.setup import async_setup_component from . import LOOPBACK_IPADDR, NO_LOOPBACK_IPADDR @@ -801,3 +805,48 @@ async def test_websocket_network_url( "external": None, "cloud": None, } + + +@pytest.mark.parametrize("mock_socket", [[]], indirect=True) +@pytest.mark.usefixtures("mock_socket") +async def test_repair_docker_host_network_not_docker( + hass: HomeAssistant, issue_registry: ir.IssueRegistry +) -> None: + """Test repair is not created when not in Docker.""" + with patch("homeassistant.util.package.is_docker_env", return_value=False): + assert await async_setup_component(hass, "network", {}) + + assert not issue_registry.async_get_issue(DOMAIN, "docker_host_network") + + +@pytest.mark.parametrize("mock_socket", [[]], indirect=True) +@pytest.mark.usefixtures("mock_socket") +async def test_repair_docker_host_network_with_host_networking( + hass: HomeAssistant, issue_registry: ir.IssueRegistry +) -> None: + """Test repair is not created when in Docker with host networking.""" + with ( + patch("homeassistant.util.package.is_docker_env", return_value=True), + patch("homeassistant.components.network.Path.exists", return_value=True), + ): + assert await async_setup_component(hass, "network", {}) + + assert not issue_registry.async_get_issue(DOMAIN, "docker_host_network") + + +@pytest.mark.parametrize("mock_socket", [[]], indirect=True) +@pytest.mark.usefixtures("mock_socket") +async def test_repair_docker_host_network_without_host_networking( + hass: HomeAssistant, + issue_registry: ir.IssueRegistry, + snapshot: SnapshotAssertion, +) -> None: + """Test repair is created when in Docker without host networking.""" + with ( + patch("homeassistant.util.package.is_docker_env", return_value=True), + patch("homeassistant.components.network.Path.exists", return_value=False), + ): + assert await async_setup_component(hass, "network", {}) + + assert (issue := issue_registry.async_get_issue(DOMAIN, "docker_host_network")) + assert issue == snapshot From 8d95fb3b313a5158d4cf61f25d9c97e2b8a7d16a Mon Sep 17 00:00:00 2001 From: Petro31 <35082313+Petro31@users.noreply.github.com> Date: Fri, 4 Apr 2025 16:17:52 -0400 Subject: [PATCH 284/505] Fix empty actions (#142292) * Apply fix * Add tests for alarm button cover lock * update light * add number tests * test select * add switch tests * test vacuum * update lock test --- .../template/alarm_control_panel.py | 3 +- homeassistant/components/template/button.py | 3 +- homeassistant/components/template/cover.py | 3 +- homeassistant/components/template/fan.py | 3 +- homeassistant/components/template/light.py | 8 +- homeassistant/components/template/lock.py | 3 +- homeassistant/components/template/select.py | 6 +- homeassistant/components/template/switch.py | 5 +- homeassistant/components/template/vacuum.py | 3 +- .../template/test_alarm_control_panel.py | 15 +++ tests/components/template/test_button.py | 39 ++++++ tests/components/template/test_cover.py | 49 +++++++ tests/components/template/test_light.py | 115 ++++++++++++++++ tests/components/template/test_lock.py | 51 ++++++- tests/components/template/test_number.py | 76 ++++++++++- tests/components/template/test_select.py | 77 ++++++++++- tests/components/template/test_switch.py | 46 +++++++ tests/components/template/test_vacuum.py | 127 +++++++++++++++++- 18 files changed, 613 insertions(+), 19 deletions(-) diff --git a/homeassistant/components/template/alarm_control_panel.py b/homeassistant/components/template/alarm_control_panel.py index 40206a5ccbb..208077a4153 100644 --- a/homeassistant/components/template/alarm_control_panel.py +++ b/homeassistant/components/template/alarm_control_panel.py @@ -214,7 +214,8 @@ class AlarmControlPanelTemplate(TemplateEntity, AlarmControlPanelEntity, Restore ), (CONF_TRIGGER_ACTION, AlarmControlPanelEntityFeature.TRIGGER), ): - if action_config := config.get(action_id): + # Scripts can be an empty list, therefore we need to check for None + if (action_config := config.get(action_id)) is not None: self.add_script(action_id, action_config, name, DOMAIN) self._attr_supported_features |= supported_feature diff --git a/homeassistant/components/template/button.py b/homeassistant/components/template/button.py index 7a205446585..4ee8844d6e7 100644 --- a/homeassistant/components/template/button.py +++ b/homeassistant/components/template/button.py @@ -120,7 +120,8 @@ class TemplateButtonEntity(TemplateEntity, ButtonEntity): """Initialize the button.""" super().__init__(hass, config=config, unique_id=unique_id) assert self._attr_name is not None - if action := config.get(CONF_PRESS): + # Scripts can be an empty list, therefore we need to check for None + if (action := config.get(CONF_PRESS)) is not None: self.add_script(CONF_PRESS, action, self._attr_name, DOMAIN) self._attr_device_class = config.get(CONF_DEVICE_CLASS) self._attr_state = None diff --git a/homeassistant/components/template/cover.py b/homeassistant/components/template/cover.py index 7a8e347ee8f..7c9c0ea9d53 100644 --- a/homeassistant/components/template/cover.py +++ b/homeassistant/components/template/cover.py @@ -172,7 +172,8 @@ class CoverTemplate(TemplateEntity, CoverEntity): (POSITION_ACTION, CoverEntityFeature.SET_POSITION), (TILT_ACTION, TILT_FEATURES), ): - if action_config := config.get(action_id): + # Scripts can be an empty list, therefore we need to check for None + if (action_config := config.get(action_id)) is not None: self.add_script(action_id, action_config, name, DOMAIN) self._attr_supported_features |= supported_feature diff --git a/homeassistant/components/template/fan.py b/homeassistant/components/template/fan.py index 6e0f9fe5e0c..f3bc26391a9 100644 --- a/homeassistant/components/template/fan.py +++ b/homeassistant/components/template/fan.py @@ -157,7 +157,8 @@ class TemplateFan(TemplateEntity, FanEntity): CONF_SET_OSCILLATING_ACTION, CONF_SET_DIRECTION_ACTION, ): - if action_config := config.get(action_id): + # Scripts can be an empty list, therefore we need to check for None + if (action_config := config.get(action_id)) is not None: self.add_script(action_id, action_config, name, DOMAIN) self._state: bool | None = False diff --git a/homeassistant/components/template/light.py b/homeassistant/components/template/light.py index 1cc47c74aa0..c58709eba5e 100644 --- a/homeassistant/components/template/light.py +++ b/homeassistant/components/template/light.py @@ -296,7 +296,8 @@ class LightTemplate(TemplateEntity, LightEntity): self._supports_transition_template = config.get(CONF_SUPPORTS_TRANSITION) for action_id in (CONF_ON_ACTION, CONF_OFF_ACTION, CONF_EFFECT_ACTION): - if action_config := config.get(action_id): + # Scripts can be an empty list, therefore we need to check for None + if (action_config := config.get(action_id)) is not None: self.add_script(action_id, action_config, name, DOMAIN) self._state = False @@ -323,7 +324,8 @@ class LightTemplate(TemplateEntity, LightEntity): (CONF_RGBW_ACTION, ColorMode.RGBW), (CONF_RGBWW_ACTION, ColorMode.RGBWW), ): - if action_config := config.get(action_id): + # Scripts can be an empty list, therefore we need to check for None + if (action_config := config.get(action_id)) is not None: self.add_script(action_id, action_config, name, DOMAIN) color_modes.add(color_mode) self._supported_color_modes = filter_supported_color_modes(color_modes) @@ -333,7 +335,7 @@ class LightTemplate(TemplateEntity, LightEntity): self._color_mode = next(iter(self._supported_color_modes)) self._attr_supported_features = LightEntityFeature(0) - if self._action_scripts.get(CONF_EFFECT_ACTION): + if (self._action_scripts.get(CONF_EFFECT_ACTION)) is not None: self._attr_supported_features |= LightEntityFeature.EFFECT if self._supports_transition is True: self._attr_supported_features |= LightEntityFeature.TRANSITION diff --git a/homeassistant/components/template/lock.py b/homeassistant/components/template/lock.py index b19cadff26c..12a3e66cb5e 100644 --- a/homeassistant/components/template/lock.py +++ b/homeassistant/components/template/lock.py @@ -98,7 +98,8 @@ class TemplateLock(TemplateEntity, LockEntity): (CONF_UNLOCK, 0), (CONF_OPEN, LockEntityFeature.OPEN), ): - if action_config := config.get(action_id): + # Scripts can be an empty list, therefore we need to check for None + if (action_config := config.get(action_id)) is not None: self.add_script(action_id, action_config, name, DOMAIN) self._attr_supported_features |= supported_feature self._code_format_template = config.get(CONF_CODE_FORMAT_TEMPLATE) diff --git a/homeassistant/components/template/select.py b/homeassistant/components/template/select.py index eb60a3dbfe4..74d88ee96c4 100644 --- a/homeassistant/components/template/select.py +++ b/homeassistant/components/template/select.py @@ -141,7 +141,8 @@ class TemplateSelect(TemplateEntity, SelectEntity): super().__init__(hass, config=config, unique_id=unique_id) assert self._attr_name is not None self._value_template = config[CONF_STATE] - if select_option := config.get(CONF_SELECT_OPTION): + # Scripts can be an empty list, therefore we need to check for None + if (select_option := config.get(CONF_SELECT_OPTION)) is not None: self.add_script(CONF_SELECT_OPTION, select_option, self._attr_name, DOMAIN) self._options_template = config[ATTR_OPTIONS] self._attr_assumed_state = self._optimistic = config.get(CONF_OPTIMISTIC, False) @@ -197,7 +198,8 @@ class TriggerSelectEntity(TriggerEntity, SelectEntity): ) -> None: """Initialize the entity.""" super().__init__(hass, coordinator, config) - if select_option := config.get(CONF_SELECT_OPTION): + # Scripts can be an empty list, therefore we need to check for None + if (select_option := config.get(CONF_SELECT_OPTION)) is not None: self.add_script( CONF_SELECT_OPTION, select_option, diff --git a/homeassistant/components/template/switch.py b/homeassistant/components/template/switch.py index fb3aeb1e42a..1d18ea9d5ca 100644 --- a/homeassistant/components/template/switch.py +++ b/homeassistant/components/template/switch.py @@ -226,9 +226,10 @@ class SwitchTemplate(TemplateEntity, SwitchEntity, RestoreEntity): assert name is not None self._template = config.get(CONF_STATE) - if on_action := config.get(CONF_TURN_ON): + # Scripts can be an empty list, therefore we need to check for None + if (on_action := config.get(CONF_TURN_ON)) is not None: self.add_script(CONF_TURN_ON, on_action, name, DOMAIN) - if off_action := config.get(CONF_TURN_OFF): + if (off_action := config.get(CONF_TURN_OFF)) is not None: self.add_script(CONF_TURN_OFF, off_action, name, DOMAIN) self._state: bool | None = False diff --git a/homeassistant/components/template/vacuum.py b/homeassistant/components/template/vacuum.py index c4d41b52f31..1e18b06436a 100644 --- a/homeassistant/components/template/vacuum.py +++ b/homeassistant/components/template/vacuum.py @@ -158,7 +158,8 @@ class TemplateVacuum(TemplateEntity, StateVacuumEntity): (SERVICE_LOCATE, VacuumEntityFeature.LOCATE), (SERVICE_SET_FAN_SPEED, VacuumEntityFeature.FAN_SPEED), ): - if action_config := config.get(action_id): + # Scripts can be an empty list, therefore we need to check for None + if (action_config := config.get(action_id)) is not None: self.add_script(action_id, action_config, name, DOMAIN) self._attr_supported_features |= supported_feature diff --git a/tests/components/template/test_alarm_control_panel.py b/tests/components/template/test_alarm_control_panel.py index 4b259fabac2..2a99e00a9ce 100644 --- a/tests/components/template/test_alarm_control_panel.py +++ b/tests/components/template/test_alarm_control_panel.py @@ -82,6 +82,15 @@ OPTIMISTIC_TEMPLATE_ALARM_CONFIG = { "data": {"code": "{{ this.entity_id }}"}, }, } +EMPTY_ACTIONS = { + "arm_away": [], + "arm_home": [], + "arm_night": [], + "arm_vacation": [], + "arm_custom_bypass": [], + "disarm": [], + "trigger": [], +} TEMPLATE_ALARM_CONFIG = { @@ -173,6 +182,12 @@ async def test_setup_config_entry( "panels": {"test_template_panel": OPTIMISTIC_TEMPLATE_ALARM_CONFIG}, } }, + { + "alarm_control_panel": { + "platform": "template", + "panels": {"test_template_panel": EMPTY_ACTIONS}, + } + }, ], ) @pytest.mark.usefixtures("start_ha") diff --git a/tests/components/template/test_button.py b/tests/components/template/test_button.py index b201385240c..31239dbaf92 100644 --- a/tests/components/template/test_button.py +++ b/tests/components/template/test_button.py @@ -93,6 +93,45 @@ async def test_missing_optional_config(hass: HomeAssistant) -> None: _verify(hass, STATE_UNKNOWN) +async def test_missing_emtpy_press_action_config( + hass: HomeAssistant, + freezer: FrozenDateTimeFactory, +) -> None: + """Test: missing optional template is ok.""" + with assert_setup_component(1, "template"): + assert await setup.async_setup_component( + hass, + "template", + { + "template": { + "button": { + "press": [], + }, + } + }, + ) + + await hass.async_block_till_done() + await hass.async_start() + await hass.async_block_till_done() + + _verify(hass, STATE_UNKNOWN) + + now = dt.datetime.now(dt.UTC) + freezer.move_to(now) + await hass.services.async_call( + BUTTON_DOMAIN, + SERVICE_PRESS, + {CONF_ENTITY_ID: _TEST_BUTTON}, + blocking=True, + ) + + _verify( + hass, + now.isoformat(), + ) + + async def test_missing_required_keys(hass: HomeAssistant) -> None: """Test: missing required fields will fail.""" with assert_setup_component(0, "template"): diff --git a/tests/components/template/test_cover.py b/tests/components/template/test_cover.py index c49db59c2ee..668592e388b 100644 --- a/tests/components/template/test_cover.py +++ b/tests/components/template/test_cover.py @@ -9,6 +9,7 @@ from homeassistant.components.cover import ( ATTR_POSITION, ATTR_TILT_POSITION, DOMAIN as COVER_DOMAIN, + CoverEntityFeature, CoverState, ) from homeassistant.const import ( @@ -28,6 +29,7 @@ from homeassistant.const import ( STATE_UNKNOWN, ) from homeassistant.core import HomeAssistant, ServiceCall +from homeassistant.setup import async_setup_component from tests.common import assert_setup_component @@ -1123,3 +1125,50 @@ async def test_self_referencing_icon_with_no_template_is_not_a_loop( assert len(hass.states.async_all()) == 1 assert "Template loop detected" not in caplog.text + + +@pytest.mark.parametrize( + ("script", "supported_feature"), + [ + ("stop_cover", CoverEntityFeature.STOP), + ("set_cover_position", CoverEntityFeature.SET_POSITION), + ( + "set_cover_tilt_position", + CoverEntityFeature.OPEN_TILT + | CoverEntityFeature.CLOSE_TILT + | CoverEntityFeature.STOP_TILT + | CoverEntityFeature.SET_TILT_POSITION, + ), + ], +) +async def test_emtpy_action_config( + hass: HomeAssistant, script: str, supported_feature: CoverEntityFeature +) -> None: + """Test configuration with empty script.""" + with assert_setup_component(1, COVER_DOMAIN): + assert await async_setup_component( + hass, + COVER_DOMAIN, + { + COVER_DOMAIN: { + "platform": "template", + "covers": { + "test_template_cover": { + "open_cover": [], + "close_cover": [], + script: [], + } + }, + } + }, + ) + + await hass.async_block_till_done() + await hass.async_start() + await hass.async_block_till_done() + + state = hass.states.get("cover.test_template_cover") + assert ( + state.attributes["supported_features"] + == CoverEntityFeature.OPEN | CoverEntityFeature.CLOSE | supported_feature + ) diff --git a/tests/components/template/test_light.py b/tests/components/template/test_light.py index 1a739b4921e..c0aade84e0f 100644 --- a/tests/components/template/test_light.py +++ b/tests/components/template/test_light.py @@ -556,6 +556,42 @@ async def setup_single_action_light( ) +@pytest.fixture +async def setup_empty_action_light( + hass: HomeAssistant, + count: int, + style: ConfigurationStyle, + action: str, + extra_config: dict, +) -> None: + """Do setup of light integration.""" + if style == ConfigurationStyle.LEGACY: + await async_setup_legacy_format( + hass, + count, + { + "test_template_light": { + "turn_on": [], + "turn_off": [], + action: [], + **extra_config, + } + }, + ) + elif style == ConfigurationStyle.MODERN: + await async_setup_new_format( + hass, + count, + { + "name": "test_template_light", + "turn_on": [], + "turn_off": [], + action: [], + **extra_config, + }, + ) + + @pytest.fixture async def setup_light_with_effects( hass: HomeAssistant, @@ -2404,3 +2440,82 @@ async def test_nested_unique_id( entry = entity_registry.async_get("light.test_b") assert entry assert entry.unique_id == "x-b" + + +@pytest.mark.parametrize(("count", "extra_config"), [(1, {})]) +@pytest.mark.parametrize( + "style", + [ + ConfigurationStyle.LEGACY, + ConfigurationStyle.MODERN, + ], +) +@pytest.mark.parametrize( + ("action", "color_mode"), + [ + ("set_level", ColorMode.BRIGHTNESS), + ("set_temperature", ColorMode.COLOR_TEMP), + ("set_hs", ColorMode.HS), + ("set_rgb", ColorMode.RGB), + ("set_rgbw", ColorMode.RGBW), + ("set_rgbww", ColorMode.RGBWW), + ], +) +async def test_empty_color_mode_action_config( + hass: HomeAssistant, + color_mode: ColorMode, + setup_empty_action_light, +) -> None: + """Test empty actions for color mode actions.""" + state = hass.states.get("light.test_template_light") + assert state.attributes["supported_color_modes"] == [color_mode] + + await hass.services.async_call( + light.DOMAIN, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: "light.test_template_light"}, + blocking=True, + ) + + state = hass.states.get("light.test_template_light") + assert state.state == STATE_ON + + await hass.services.async_call( + light.DOMAIN, + SERVICE_TURN_OFF, + {ATTR_ENTITY_ID: "light.test_template_light"}, + blocking=True, + ) + + state = hass.states.get("light.test_template_light") + assert state.state == STATE_OFF + + +@pytest.mark.parametrize(("count"), [1]) +@pytest.mark.parametrize( + ("style", "extra_config"), + [ + ( + ConfigurationStyle.LEGACY, + { + "effect_list_template": "{{ ['a'] }}", + "effect_template": "{{ 'a' }}", + }, + ), + ( + ConfigurationStyle.MODERN, + { + "effect_list": "{{ ['a'] }}", + "effect": "{{ 'a' }}", + }, + ), + ], +) +@pytest.mark.parametrize("action", ["set_effect"]) +async def test_effect_with_empty_action( + hass: HomeAssistant, + setup_empty_action_light, +) -> None: + """Test empty set_effect action.""" + state = hass.states.get("light.test_template_light") + assert state.attributes["supported_features"] == LightEntityFeature.EFFECT diff --git a/tests/components/template/test_lock.py b/tests/components/template/test_lock.py index d9cb294c41f..50baa11b2d0 100644 --- a/tests/components/template/test_lock.py +++ b/tests/components/template/test_lock.py @@ -4,7 +4,7 @@ import pytest from homeassistant import setup from homeassistant.components import lock -from homeassistant.components.lock import LockState +from homeassistant.components.lock import LockEntityFeature, LockState from homeassistant.const import ( ATTR_CODE, ATTR_ENTITY_ID, @@ -15,6 +15,8 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant, ServiceCall +from tests.common import assert_setup_component + OPTIMISTIC_LOCK_CONFIG = { "platform": "template", "lock": { @@ -718,3 +720,50 @@ async def test_unique_id(hass: HomeAssistant) -> None: await hass.async_block_till_done() assert len(hass.states.async_all("lock")) == 1 + + +async def test_emtpy_action_config(hass: HomeAssistant) -> None: + """Test configuration with empty script.""" + with assert_setup_component(1, lock.DOMAIN): + assert await setup.async_setup_component( + hass, + lock.DOMAIN, + { + lock.DOMAIN: { + "platform": "template", + "value_template": "{{ 0 == 1 }}", + "lock": [], + "unlock": [], + "open": [], + "name": "test_template_lock", + "optimistic": True, + }, + }, + ) + + await hass.async_block_till_done() + await hass.async_start() + await hass.async_block_till_done() + + state = hass.states.get("lock.test_template_lock") + assert state.attributes["supported_features"] == LockEntityFeature.OPEN + + await hass.services.async_call( + lock.DOMAIN, + lock.SERVICE_UNLOCK, + {ATTR_ENTITY_ID: "lock.test_template_lock"}, + ) + await hass.async_block_till_done() + + state = hass.states.get("lock.test_template_lock") + assert state.state == LockState.UNLOCKED + + await hass.services.async_call( + lock.DOMAIN, + lock.SERVICE_LOCK, + {ATTR_ENTITY_ID: "lock.test_template_lock"}, + ) + await hass.async_block_till_done() + + state = hass.states.get("lock.test_template_lock") + assert state.state == LockState.LOCKED diff --git a/tests/components/template/test_number.py b/tests/components/template/test_number.py index f73a943e752..5201541e2e0 100644 --- a/tests/components/template/test_number.py +++ b/tests/components/template/test_number.py @@ -1,8 +1,12 @@ """The tests for the Template number platform.""" +from typing import Any + +import pytest from syrupy.assertion import SnapshotAssertion from homeassistant import setup +from homeassistant.components import number, template from homeassistant.components.input_number import ( ATTR_VALUE as INPUT_NUMBER_ATTR_VALUE, DOMAIN as INPUT_NUMBER_DOMAIN, @@ -18,6 +22,7 @@ from homeassistant.components.number import ( ) from homeassistant.components.template import DOMAIN from homeassistant.const import ( + ATTR_ENTITY_ID, ATTR_ICON, CONF_ENTITY_ID, CONF_UNIT_OF_MEASUREMENT, @@ -25,10 +30,14 @@ from homeassistant.const import ( ) from homeassistant.core import Context, HomeAssistant, ServiceCall from homeassistant.helpers import device_registry as dr, entity_registry as er +from homeassistant.setup import async_setup_component + +from .conftest import ConfigurationStyle from tests.common import MockConfigEntry, assert_setup_component, async_capture_events -_TEST_NUMBER = "number.template_number" +_TEST_OBJECT_ID = "template_number" +_TEST_NUMBER = f"number.{_TEST_OBJECT_ID}" # Represent for number's value _VALUE_INPUT_NUMBER = "input_number.value" # Represent for number's minimum @@ -50,6 +59,38 @@ _VALUE_INPUT_NUMBER_CONFIG = { } +async def async_setup_modern_format( + hass: HomeAssistant, count: int, number_config: dict[str, Any] +) -> None: + """Do setup of number integration via new format.""" + config = {"template": {"number": number_config}} + + with assert_setup_component(count, template.DOMAIN): + assert await async_setup_component( + hass, + template.DOMAIN, + config, + ) + + await hass.async_block_till_done() + await hass.async_start() + await hass.async_block_till_done() + + +@pytest.fixture +async def setup_number( + hass: HomeAssistant, + count: int, + style: ConfigurationStyle, + number_config: dict[str, Any], +) -> None: + """Do setup of number integration.""" + if style == ConfigurationStyle.MODERN: + await async_setup_modern_format( + hass, count, {"name": _TEST_OBJECT_ID, **number_config} + ) + + async def test_setup_config_entry( hass: HomeAssistant, snapshot: SnapshotAssertion, @@ -565,3 +606,36 @@ async def test_device_id( template_entity = entity_registry.async_get("number.my_template") assert template_entity is not None assert template_entity.device_id == device_entry.id + + +@pytest.mark.parametrize( + ("count", "number_config"), + [ + ( + 1, + { + "state": "{{ 1 }}", + "set_value": [], + "step": "{{ 1 }}", + "optimistic": True, + }, + ) + ], +) +@pytest.mark.parametrize( + "style", + [ + ConfigurationStyle.MODERN, + ], +) +async def test_empty_action_config(hass: HomeAssistant, setup_number) -> None: + """Test configuration with empty script.""" + await hass.services.async_call( + number.DOMAIN, + number.SERVICE_SET_VALUE, + {ATTR_ENTITY_ID: _TEST_NUMBER, "value": 4}, + blocking=True, + ) + + state = hass.states.get(_TEST_NUMBER) + assert float(state.state) == 4 diff --git a/tests/components/template/test_select.py b/tests/components/template/test_select.py index 59ab45aeb36..b2bc56af44a 100644 --- a/tests/components/template/test_select.py +++ b/tests/components/template/test_select.py @@ -1,8 +1,12 @@ """The tests for the Template select platform.""" +from typing import Any + +import pytest from syrupy.assertion import SnapshotAssertion from homeassistant import setup +from homeassistant.components import select, template from homeassistant.components.input_select import ( ATTR_OPTION as INPUT_SELECT_ATTR_OPTION, ATTR_OPTIONS as INPUT_SELECT_ATTR_OPTIONS, @@ -17,17 +21,53 @@ from homeassistant.components.select import ( SERVICE_SELECT_OPTION as SELECT_SERVICE_SELECT_OPTION, ) from homeassistant.components.template import DOMAIN -from homeassistant.const import ATTR_ICON, CONF_ENTITY_ID, STATE_UNKNOWN +from homeassistant.const import ATTR_ENTITY_ID, ATTR_ICON, CONF_ENTITY_ID, STATE_UNKNOWN from homeassistant.core import Context, HomeAssistant, ServiceCall from homeassistant.helpers import device_registry as dr, entity_registry as er +from homeassistant.setup import async_setup_component + +from .conftest import ConfigurationStyle from tests.common import MockConfigEntry, assert_setup_component, async_capture_events -_TEST_SELECT = "select.template_select" +_TEST_OBJECT_ID = "template_select" +_TEST_SELECT = f"select.{_TEST_OBJECT_ID}" # Represent for select's current_option _OPTION_INPUT_SELECT = "input_select.option" +async def async_setup_modern_format( + hass: HomeAssistant, count: int, select_config: dict[str, Any] +) -> None: + """Do setup of select integration via new format.""" + config = {"template": {"select": select_config}} + + with assert_setup_component(count, template.DOMAIN): + assert await async_setup_component( + hass, + template.DOMAIN, + config, + ) + + await hass.async_block_till_done() + await hass.async_start() + await hass.async_block_till_done() + + +@pytest.fixture +async def setup_select( + hass: HomeAssistant, + count: int, + style: ConfigurationStyle, + select_config: dict[str, Any], +) -> None: + """Do setup of select integration.""" + if style == ConfigurationStyle.MODERN: + await async_setup_modern_format( + hass, count, {"name": _TEST_OBJECT_ID, **select_config} + ) + + async def test_setup_config_entry( hass: HomeAssistant, snapshot: SnapshotAssertion, @@ -527,3 +567,36 @@ async def test_device_id( template_entity = entity_registry.async_get("select.my_template") assert template_entity is not None assert template_entity.device_id == device_entry.id + + +@pytest.mark.parametrize( + ("count", "select_config"), + [ + ( + 1, + { + "state": "{{ 'b' }}", + "select_option": [], + "options": "{{ ['a', 'b'] }}", + "optimistic": True, + }, + ) + ], +) +@pytest.mark.parametrize( + "style", + [ + ConfigurationStyle.MODERN, + ], +) +async def test_empty_action_config(hass: HomeAssistant, setup_select) -> None: + """Test configuration with empty script.""" + await hass.services.async_call( + select.DOMAIN, + select.SERVICE_SELECT_OPTION, + {ATTR_ENTITY_ID: _TEST_SELECT, "option": "a"}, + blocking=True, + ) + + state = hass.states.get(_TEST_SELECT) + assert state.state == "a" diff --git a/tests/components/template/test_switch.py b/tests/components/template/test_switch.py index d8877851efe..43db93ac146 100644 --- a/tests/components/template/test_switch.py +++ b/tests/components/template/test_switch.py @@ -981,3 +981,49 @@ async def test_device_id( template_entity = entity_registry.async_get("switch.my_template") assert template_entity is not None assert template_entity.device_id == device_entry.id + + +@pytest.mark.parametrize("count", [1]) +@pytest.mark.parametrize( + ("style", "switch_config"), + [ + ( + ConfigurationStyle.LEGACY, + { + TEST_OBJECT_ID: { + "turn_on": [], + "turn_off": [], + }, + }, + ), + ( + ConfigurationStyle.MODERN, + { + "name": TEST_OBJECT_ID, + "turn_on": [], + "turn_off": [], + }, + ), + ], +) +async def test_empty_action_config(hass: HomeAssistant, setup_switch) -> None: + """Test configuration with empty script.""" + await hass.services.async_call( + switch.DOMAIN, + switch.SERVICE_TURN_ON, + {ATTR_ENTITY_ID: TEST_ENTITY_ID}, + blocking=True, + ) + + state = hass.states.get(TEST_ENTITY_ID) + assert state.state == STATE_ON + + await hass.services.async_call( + switch.DOMAIN, + switch.SERVICE_TURN_OFF, + {ATTR_ENTITY_ID: TEST_ENTITY_ID}, + blocking=True, + ) + + state = hass.states.get(TEST_ENTITY_ID) + assert state.state == STATE_OFF diff --git a/tests/components/template/test_vacuum.py b/tests/components/template/test_vacuum.py index 6053a2bd9ec..cc5bc9b39e3 100644 --- a/tests/components/template/test_vacuum.py +++ b/tests/components/template/test_vacuum.py @@ -1,18 +1,29 @@ """The tests for the Template vacuum platform.""" +from typing import Any + import pytest from homeassistant import setup -from homeassistant.components.vacuum import ATTR_BATTERY_LEVEL, VacuumActivity +from homeassistant.components import vacuum +from homeassistant.components.vacuum import ( + ATTR_BATTERY_LEVEL, + VacuumActivity, + VacuumEntityFeature, +) from homeassistant.const import STATE_OFF, STATE_ON, STATE_UNAVAILABLE, STATE_UNKNOWN from homeassistant.core import HomeAssistant, ServiceCall from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.entity_component import async_update_entity +from homeassistant.setup import async_setup_component + +from .conftest import ConfigurationStyle from tests.common import assert_setup_component from tests.components.vacuum import common -_TEST_VACUUM = "vacuum.test_vacuum" +_TEST_OBJECT_ID = "test_vacuum" +_TEST_VACUUM = f"vacuum.{_TEST_OBJECT_ID}" _STATE_INPUT_SELECT = "input_select.state" _SPOT_CLEANING_INPUT_BOOLEAN = "input_boolean.spot_cleaning" _LOCATING_INPUT_BOOLEAN = "input_boolean.locating" @@ -20,6 +31,50 @@ _FAN_SPEED_INPUT_SELECT = "input_select.fan_speed" _BATTERY_LEVEL_INPUT_NUMBER = "input_number.battery_level" +async def async_setup_legacy_format( + hass: HomeAssistant, count: int, vacuum_config: dict[str, Any] +) -> None: + """Do setup of number integration via new format.""" + config = {"vacuum": {"platform": "template", "vacuums": vacuum_config}} + + with assert_setup_component(count, vacuum.DOMAIN): + assert await async_setup_component( + hass, + vacuum.DOMAIN, + config, + ) + + await hass.async_block_till_done() + await hass.async_start() + await hass.async_block_till_done() + + +@pytest.fixture +async def setup_vacuum( + hass: HomeAssistant, + count: int, + style: ConfigurationStyle, + vacuum_config: dict[str, Any], +) -> None: + """Do setup of number integration.""" + if style == ConfigurationStyle.LEGACY: + await async_setup_legacy_format(hass, count, vacuum_config) + + +@pytest.fixture +async def setup_test_vacuum_with_extra_config( + hass: HomeAssistant, + count: int, + style: ConfigurationStyle, + vacuum_config: dict[str, Any], + extra_config: dict[str, Any], +) -> None: + """Do setup of number integration.""" + config = {_TEST_OBJECT_ID: {**vacuum_config, **extra_config}} + if style == ConfigurationStyle.LEGACY: + await async_setup_legacy_format(hass, count, config) + + @pytest.mark.parametrize(("count", "domain"), [(1, "vacuum")]) @pytest.mark.parametrize( ("parm1", "parm2", "config"), @@ -697,3 +752,71 @@ async def _register_components(hass: HomeAssistant) -> None: await hass.async_block_till_done() await hass.async_start() await hass.async_block_till_done() + + +@pytest.mark.parametrize("count", [1]) +@pytest.mark.parametrize( + ("style", "vacuum_config"), + [ + ( + ConfigurationStyle.LEGACY, + { + "start": [], + }, + ), + ], +) +@pytest.mark.parametrize( + ("extra_config", "supported_features"), + [ + ( + { + "pause": [], + }, + VacuumEntityFeature.PAUSE, + ), + ( + { + "stop": [], + }, + VacuumEntityFeature.STOP, + ), + ( + { + "return_to_base": [], + }, + VacuumEntityFeature.RETURN_HOME, + ), + ( + { + "clean_spot": [], + }, + VacuumEntityFeature.CLEAN_SPOT, + ), + ( + { + "locate": [], + }, + VacuumEntityFeature.LOCATE, + ), + ( + { + "set_fan_speed": [], + }, + VacuumEntityFeature.FAN_SPEED, + ), + ], +) +async def test_empty_action_config( + hass: HomeAssistant, + supported_features: VacuumEntityFeature, + setup_test_vacuum_with_extra_config, +) -> None: + """Test configuration with empty script.""" + await common.async_start(hass, _TEST_VACUUM) + await hass.async_block_till_done() + + state = hass.states.get(_TEST_VACUUM) + assert state.attributes["supported_features"] == ( + VacuumEntityFeature.STATE | VacuumEntityFeature.START | supported_features + ) From 0abe57edaa701af82a214f314e29d518427ab1e2 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 4 Apr 2025 10:28:55 -1000 Subject: [PATCH 285/505] Avoid checking if debug logging is enabled on every WebSocket message (#142258) Co-authored-by: epenet <6771947+epenet@users.noreply.github.com> --- .../components/websocket_api/http.py | 23 +++++++++---- tests/components/websocket_api/test_http.py | 34 +++++++++++++++++++ 2 files changed, 50 insertions(+), 7 deletions(-) diff --git a/homeassistant/components/websocket_api/http.py b/homeassistant/components/websocket_api/http.py index ebca497193b..4250da149ad 100644 --- a/homeassistant/components/websocket_api/http.py +++ b/homeassistant/components/websocket_api/http.py @@ -14,7 +14,7 @@ from aiohttp import WSMsgType, web from aiohttp.http_websocket import WebSocketWriter from homeassistant.components.http import KEY_HASS, HomeAssistantView -from homeassistant.const import EVENT_HOMEASSISTANT_STOP +from homeassistant.const import EVENT_HOMEASSISTANT_STOP, EVENT_LOGGING_CHANGED from homeassistant.core import Event, HomeAssistant, callback from homeassistant.helpers.dispatcher import async_dispatcher_send from homeassistant.helpers.event import async_call_later @@ -73,6 +73,7 @@ class WebSocketHandler: "_authenticated", "_closing", "_connection", + "_debug", "_handle_task", "_hass", "_logger", @@ -107,6 +108,12 @@ class WebSocketHandler: self._message_queue: deque[bytes] = deque() self._ready_future: asyncio.Future[int] | None = None self._release_ready_queue_size: int = 0 + self._async_logging_changed() + + @callback + def _async_logging_changed(self, event: Event | None = None) -> None: + """Handle logging change.""" + self._debug = self._logger.isEnabledFor(logging.DEBUG) def __repr__(self) -> str: """Return the representation.""" @@ -137,7 +144,6 @@ class WebSocketHandler: logger = self._logger wsock = self._wsock loop = self._loop - is_debug_log_enabled = partial(logger.isEnabledFor, logging.DEBUG) debug = logger.debug can_coalesce = connection.can_coalesce ready_message_count = len(message_queue) @@ -157,14 +163,14 @@ class WebSocketHandler: if not can_coalesce or ready_message_count == 1: message = message_queue.popleft() - if is_debug_log_enabled(): + if self._debug: debug("%s: Sending %s", self.description, message) await send_bytes_text(message) continue coalesced_messages = b"".join((b"[", b",".join(message_queue), b"]")) message_queue.clear() - if is_debug_log_enabled(): + if self._debug: debug("%s: Sending %s", self.description, coalesced_messages) await send_bytes_text(coalesced_messages) except asyncio.CancelledError: @@ -325,6 +331,9 @@ class WebSocketHandler: unsub_stop = hass.bus.async_listen( EVENT_HOMEASSISTANT_STOP, self._async_handle_hass_stop ) + cancel_logging_listener = hass.bus.async_listen( + EVENT_LOGGING_CHANGED, self._async_logging_changed + ) writer = wsock._writer # noqa: SLF001 if TYPE_CHECKING: @@ -354,6 +363,7 @@ class WebSocketHandler: "%s: Unexpected error inside websocket API", self.description ) finally: + cancel_logging_listener() unsub_stop() self._cancel_peak_checker() @@ -401,7 +411,7 @@ class WebSocketHandler: except ValueError as err: raise Disconnect("Received invalid JSON during auth phase") from err - if self._logger.isEnabledFor(logging.DEBUG): + if self._debug: self._logger.debug("%s: Received %s", self.description, auth_msg_data) connection = await auth.async_handle(auth_msg_data) # As the webserver is now started before the start @@ -463,7 +473,6 @@ class WebSocketHandler: wsock = self._wsock async_handle_str = connection.async_handle async_handle_binary = connection.async_handle_binary - _debug_enabled = partial(self._logger.isEnabledFor, logging.DEBUG) # Command phase while not wsock.closed: @@ -496,7 +505,7 @@ class WebSocketHandler: except ValueError as ex: raise Disconnect("Received invalid JSON.") from ex - if _debug_enabled(): + if self._debug: self._logger.debug( "%s: Received %s", self.description, command_msg_data ) diff --git a/tests/components/websocket_api/test_http.py b/tests/components/websocket_api/test_http.py index 370aab1067a..075f5fa9c0a 100644 --- a/tests/components/websocket_api/test_http.py +++ b/tests/components/websocket_api/test_http.py @@ -16,6 +16,7 @@ from homeassistant.components.websocket_api import ( ) from homeassistant.components.websocket_api.connection import ActiveConnection from homeassistant.core import HomeAssistant, callback +from homeassistant.setup import async_setup_component from homeassistant.util.dt import utcnow from tests.common import async_fire_time_changed @@ -523,3 +524,36 @@ async def test_binary_message( assert "Received binary message for non-existing handler 0" in caplog.text assert "Received binary message for non-existing handler 3" in caplog.text assert "Received binary message for non-existing handler 10" in caplog.text + + +async def test_enable_disable_debug_logging( + hass: HomeAssistant, + websocket_client: MockHAClientWebSocket, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test enabling and disabling debug logging.""" + assert await async_setup_component(hass, "logger", {"logger": {}}) + await hass.services.async_call( + "logger", + "set_level", + {"homeassistant.components.websocket_api": "DEBUG"}, + blocking=True, + ) + await hass.async_block_till_done() + await websocket_client.send_json({"id": 1, "type": "ping"}) + msg = await websocket_client.receive_json() + assert msg["id"] == 1 + assert msg["type"] == "pong" + assert 'Sending b\'{"id":1,"type":"pong"}\'' in caplog.text + await hass.services.async_call( + "logger", + "set_level", + {"homeassistant.components.websocket_api": "WARNING"}, + blocking=True, + ) + await hass.async_block_till_done() + await websocket_client.send_json({"id": 2, "type": "ping"}) + msg = await websocket_client.receive_json() + assert msg["id"] == 2 + assert msg["type"] == "pong" + assert 'Sending b\'{"id":2,"type":"pong"}\'' not in caplog.text From 52724c5c226e119d0a97bb0d44a67562baa902be Mon Sep 17 00:00:00 2001 From: Erwin Douna Date: Fri, 4 Apr 2025 23:20:23 +0200 Subject: [PATCH 286/505] Bump DSMR parser to 1.4.3 (#142303) --- homeassistant/components/dsmr/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/dsmr/manifest.json b/homeassistant/components/dsmr/manifest.json index 561f06d1bbe..f9e78ac616f 100644 --- a/homeassistant/components/dsmr/manifest.json +++ b/homeassistant/components/dsmr/manifest.json @@ -7,5 +7,5 @@ "integration_type": "hub", "iot_class": "local_push", "loggers": ["dsmr_parser"], - "requirements": ["dsmr-parser==1.4.2"] + "requirements": ["dsmr-parser==1.4.3"] } diff --git a/requirements_all.txt b/requirements_all.txt index 22287c8c577..f5f6c0582d0 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -802,7 +802,7 @@ dremel3dpy==2.1.1 dropmqttapi==1.0.3 # homeassistant.components.dsmr -dsmr-parser==1.4.2 +dsmr-parser==1.4.3 # homeassistant.components.dwd_weather_warnings dwdwfsapi==1.0.7 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 0e914b91275..4e2cec122ae 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -690,7 +690,7 @@ dremel3dpy==2.1.1 dropmqttapi==1.0.3 # homeassistant.components.dsmr -dsmr-parser==1.4.2 +dsmr-parser==1.4.3 # homeassistant.components.dwd_weather_warnings dwdwfsapi==1.0.7 From 39ebc103df0afcee52606bb14594b3b01b7868c3 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 4 Apr 2025 11:20:36 -1000 Subject: [PATCH 287/505] Bump pydantic to 2.11.2 (#142302) changelog: https://github.com/pydantic/pydantic/compare/v2.11.1...v2.11.2 --- homeassistant/package_constraints.txt | 2 +- requirements_test.txt | 2 +- script/gen_requirements_all.py | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index fbf10ce142a..c714efb8a9c 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -130,7 +130,7 @@ multidict>=6.0.2 backoff>=2.0 # ensure pydantic version does not float since it might have breaking changes -pydantic==2.11.1 +pydantic==2.11.2 # Required for Python 3.12.4 compatibility (#119223). mashumaro>=3.13.1 diff --git a/requirements_test.txt b/requirements_test.txt index f7b04f0a6bd..b53b1fd8840 100644 --- a/requirements_test.txt +++ b/requirements_test.txt @@ -14,7 +14,7 @@ license-expression==30.4.1 mock-open==1.4.0 mypy-dev==1.16.0a7 pre-commit==4.0.0 -pydantic==2.11.1 +pydantic==2.11.2 pylint==3.3.6 pylint-per-file-ignores==1.4.0 pipdeptree==2.25.1 diff --git a/script/gen_requirements_all.py b/script/gen_requirements_all.py index 11698f01e45..f155068c7e7 100755 --- a/script/gen_requirements_all.py +++ b/script/gen_requirements_all.py @@ -159,7 +159,7 @@ multidict>=6.0.2 backoff>=2.0 # ensure pydantic version does not float since it might have breaking changes -pydantic==2.11.1 +pydantic==2.11.2 # Required for Python 3.12.4 compatibility (#119223). mashumaro>=3.13.1 From f9cd0f37f75feec01e9702e96f8b06a3b2c117c1 Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Fri, 4 Apr 2025 23:22:05 +0200 Subject: [PATCH 288/505] Add common states "Normal", "Very high" and "Very low" (#142167) --- homeassistant/strings.json | 3 +++ 1 file changed, 3 insertions(+) diff --git a/homeassistant/strings.json b/homeassistant/strings.json index 763d50e79d7..14190ba008d 100644 --- a/homeassistant/strings.json +++ b/homeassistant/strings.json @@ -133,6 +133,7 @@ "low": "Low", "medium": "Medium", "no": "No", + "normal": "Normal", "not_home": "Away", "off": "Off", "on": "On", @@ -142,6 +143,8 @@ "standby": "Standby", "stopped": "Stopped", "unlocked": "Unlocked", + "very_high": "Very high", + "very_low": "Very low", "yes": "Yes" }, "time": { From 1e55d4b613cfe6310bbf04ad7f552e64d9f8aff6 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Fri, 4 Apr 2025 23:26:43 +0200 Subject: [PATCH 289/505] Restore "Promote after dependencies in bootstrap" (#142001) Revert "Revert "Promote after dependencies in bootstrap" (#141584)" This reverts commit de1e06c39bce99f55ea36175e29cc1d76bc35836. --- homeassistant/bootstrap.py | 28 +++++++++++----------------- tests/test_bootstrap.py | 18 ++++++++++-------- 2 files changed, 21 insertions(+), 25 deletions(-) diff --git a/homeassistant/bootstrap.py b/homeassistant/bootstrap.py index 02a3b8c8fcc..962c7871028 100644 --- a/homeassistant/bootstrap.py +++ b/homeassistant/bootstrap.py @@ -859,8 +859,14 @@ async def _async_set_up_integrations( integrations, all_integrations = await _async_resolve_domains_and_preload( hass, config ) - all_domains = set(all_integrations) - domains = set(integrations) + # Detect all cycles + integrations_after_dependencies = ( + await loader.resolve_integrations_after_dependencies( + hass, all_integrations.values(), set(all_integrations) + ) + ) + all_domains = set(integrations_after_dependencies) + domains = set(integrations) & all_domains _LOGGER.info( "Domains to be set up: %s | %s", @@ -868,6 +874,8 @@ async def _async_set_up_integrations( all_domains - domains, ) + async_set_domains_to_be_loaded(hass, all_domains) + # Initialize recorder if "recorder" in all_domains: recorder.async_initialize_recorder(hass) @@ -900,24 +908,12 @@ async def _async_set_up_integrations( stage_dep_domains_unfiltered = { dep for domain in stage_domains - for dep in all_integrations[domain].all_dependencies + for dep in integrations_after_dependencies[domain] if dep not in stage_domains } stage_dep_domains = stage_dep_domains_unfiltered - hass.config.components stage_all_domains = stage_domains | stage_dep_domains - stage_all_integrations = { - domain: all_integrations[domain] for domain in stage_all_domains - } - # Detect all cycles - stage_integrations_after_dependencies = ( - await loader.resolve_integrations_after_dependencies( - hass, stage_all_integrations.values(), stage_all_domains - ) - ) - stage_all_domains = set(stage_integrations_after_dependencies) - stage_domains &= stage_all_domains - stage_dep_domains &= stage_all_domains _LOGGER.info( "Setting up stage %s: %s | %s\nDependencies: %s | %s", @@ -928,8 +924,6 @@ async def _async_set_up_integrations( stage_dep_domains_unfiltered - stage_dep_domains, ) - async_set_domains_to_be_loaded(hass, stage_all_domains) - if timeout is None: await _async_setup_multi_components(hass, stage_all_domains, config) continue diff --git a/tests/test_bootstrap.py b/tests/test_bootstrap.py index 1fb87ac5ef6..ca75dc51c56 100644 --- a/tests/test_bootstrap.py +++ b/tests/test_bootstrap.py @@ -252,8 +252,8 @@ async def test_setup_after_deps_all_present(hass: HomeAssistant) -> None: @pytest.mark.parametrize("load_registries", [False]) -async def test_setup_after_deps_in_stage_1_ignored(hass: HomeAssistant) -> None: - """Test after_dependencies are ignored in stage 1.""" +async def test_setup_after_deps_in_stage_1(hass: HomeAssistant) -> None: + """Test after_dependencies are promoted in stage 1.""" # This test relies on this assert "cloud" in bootstrap.STAGE_1_INTEGRATIONS order = [] @@ -295,7 +295,7 @@ async def test_setup_after_deps_in_stage_1_ignored(hass: HomeAssistant) -> None: assert "normal_integration" in hass.config.components assert "cloud" in hass.config.components - assert order == ["cloud", "an_after_dep", "normal_integration"] + assert order == ["an_after_dep", "normal_integration", "cloud"] @pytest.mark.parametrize("load_registries", [False]) @@ -304,7 +304,7 @@ async def test_setup_after_deps_manifests_are_loaded_even_if_not_setup( ) -> None: """Ensure we preload manifests for after deps even if they are not setup. - Its important that we preload the after dep manifests even if they are not setup + It's important that we preload the after dep manifests even if they are not setup since we will always have to check their requirements since any integration that lists an after dep may import it and we have to ensure requirements are up to date before the after dep can be imported. @@ -371,7 +371,7 @@ async def test_setup_after_deps_manifests_are_loaded_even_if_not_setup( assert "an_after_dep" not in hass.config.components assert "an_after_dep_of_after_dep" not in hass.config.components assert "an_after_dep_of_after_dep_of_after_dep" not in hass.config.components - assert order == ["cloud", "normal_integration"] + assert order == ["normal_integration", "cloud"] assert loader.async_get_loaded_integration(hass, "an_after_dep") is not None assert ( loader.async_get_loaded_integration(hass, "an_after_dep_of_after_dep") @@ -456,9 +456,9 @@ async def test_setup_frontend_before_recorder(hass: HomeAssistant) -> None: assert order == [ "http", + "an_after_dep", "frontend", "recorder", - "an_after_dep", "normal_integration", ] @@ -1577,8 +1577,10 @@ async def test_no_base_platforms_loaded_before_recorder(hass: HomeAssistant) -> assert not isinstance(integrations_or_excs, Exception) integrations[domain] = integration - integrations_all_dependencies = await loader.resolve_integrations_dependencies( - hass, integrations.values() + integrations_all_dependencies = ( + await loader.resolve_integrations_after_dependencies( + hass, integrations.values(), ignore_exceptions=True + ) ) all_integrations = integrations.copy() all_integrations.update( From 1d10c81ff3b581dab5fbbd7434c7cefa0e4b41d2 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 4 Apr 2025 11:27:05 -1000 Subject: [PATCH 290/505] Add coverage to flux_led to ensure a user flow can replace an ignored entry (#142103) * Ensure a flux_led user flow can replace an ignored entry Allow ignored devices to be selected in the user step and replace the ignored entry. Same as #137056 and #137052 but for flux_led * works as-is was a problem with core.config_entries --- tests/components/flux_led/test_config_flow.py | 54 +++++++++++++++++++ 1 file changed, 54 insertions(+) diff --git a/tests/components/flux_led/test_config_flow.py b/tests/components/flux_led/test_config_flow.py index f486d27244e..14ac4dd23ab 100644 --- a/tests/components/flux_led/test_config_flow.py +++ b/tests/components/flux_led/test_config_flow.py @@ -356,6 +356,60 @@ async def test_manual_working_discovery(hass: HomeAssistant) -> None: assert result2["reason"] == "already_configured" +async def test_user_flow_can_replace_ignored(hass: HomeAssistant) -> None: + """Test a user flow can replace an ignored entry.""" + ignored_entry = MockConfigEntry( + domain=DOMAIN, + data={CONF_HOST: IP_ADDRESS}, + unique_id=MAC_ADDRESS, + title=DEFAULT_ENTRY_TITLE, + source=config_entries.SOURCE_IGNORE, + ) + ignored_entry.add_to_hass(hass) + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + assert not result["errors"] + + # Cannot connect (timeout) + with _patch_discovery(no_device=True), _patch_wifibulb(no_device=True): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], {CONF_HOST: IP_ADDRESS} + ) + await hass.async_block_till_done() + + assert result2["type"] is FlowResultType.FORM + assert result2["step_id"] == "user" + assert result2["errors"] == {"base": "cannot_connect"} + + # Success + with ( + _patch_discovery(), + _patch_wifibulb(), + patch(f"{MODULE}.async_setup", return_value=True), + patch(f"{MODULE}.async_setup_entry", return_value=True), + ): + result4 = await hass.config_entries.flow.async_configure( + result["flow_id"], {CONF_HOST: IP_ADDRESS} + ) + await hass.async_block_till_done() + assert result4["type"] is FlowResultType.CREATE_ENTRY + assert result4["title"] == DEFAULT_ENTRY_TITLE + assert result4["data"] == { + CONF_MINOR_VERSION: 4, + CONF_HOST: IP_ADDRESS, + CONF_MODEL: MODEL, + CONF_MODEL_NUM: MODEL_NUM, + CONF_MODEL_INFO: MODEL, + CONF_MODEL_DESCRIPTION: MODEL_DESCRIPTION, + CONF_REMOTE_ACCESS_ENABLED: True, + CONF_REMOTE_ACCESS_HOST: "the.cloud", + CONF_REMOTE_ACCESS_PORT: 8816, + } + + async def test_manual_no_discovery_data(hass: HomeAssistant) -> None: """Test manually setup without discovery data.""" result = await hass.config_entries.flow.async_init( From 414fe53261b4fd5484cb46b1cd842bcafe766990 Mon Sep 17 00:00:00 2001 From: Emily Love Watson Date: Fri, 4 Apr 2025 20:23:22 -0500 Subject: [PATCH 291/505] Bump pykulersky dependency (#142311) --- homeassistant/components/kulersky/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/kulersky/manifest.json b/homeassistant/components/kulersky/manifest.json index e0d9ec4fe36..49c4d4c1847 100644 --- a/homeassistant/components/kulersky/manifest.json +++ b/homeassistant/components/kulersky/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/kulersky", "iot_class": "local_polling", "loggers": ["bleak", "pykulersky"], - "requirements": ["pykulersky==0.5.2"] + "requirements": ["pykulersky==0.5.8"] } diff --git a/requirements_all.txt b/requirements_all.txt index f5f6c0582d0..02258e6261d 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2077,7 +2077,7 @@ pykoplenti==1.3.0 pykrakenapi==0.1.8 # homeassistant.components.kulersky -pykulersky==0.5.2 +pykulersky==0.5.8 # homeassistant.components.kwb pykwb==0.0.8 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 4e2cec122ae..cd91423e032 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1695,7 +1695,7 @@ pykoplenti==1.3.0 pykrakenapi==0.1.8 # homeassistant.components.kulersky -pykulersky==0.5.2 +pykulersky==0.5.8 # homeassistant.components.lamarzocco pylamarzocco==1.4.9 From 31c660557de310fd4e9a8d0ec428a807b90894fb Mon Sep 17 00:00:00 2001 From: Luke Lashley Date: Fri, 4 Apr 2025 22:58:46 -0400 Subject: [PATCH 292/505] Update Roborock map more consistently on state change (#142228) * update map more consistently on state change * Makecoordinator keep track of last_updated_state --- homeassistant/components/roborock/coordinator.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/roborock/coordinator.py b/homeassistant/components/roborock/coordinator.py index cc0bee1cd5f..4e59a092e0a 100644 --- a/homeassistant/components/roborock/coordinator.py +++ b/homeassistant/components/roborock/coordinator.py @@ -153,6 +153,7 @@ class RoborockDataUpdateCoordinator(DataUpdateCoordinator[DeviceProp]): ImageConfig(scale=MAP_SCALE), [], ) + self.last_update_state: str | None = None @cached_property def dock_device_info(self) -> DeviceInfo: @@ -291,7 +292,6 @@ class RoborockDataUpdateCoordinator(DataUpdateCoordinator[DeviceProp]): async def _async_update_data(self) -> DeviceProp: """Update data via library.""" - previous_state = self.roborock_device_info.props.status.state_name try: # Update device props and standard api information await self._update_device_prop() @@ -308,7 +308,7 @@ class RoborockDataUpdateCoordinator(DataUpdateCoordinator[DeviceProp]): and (dt_util.utcnow() - self.maps[self.current_map].last_updated) > IMAGE_CACHE_INTERVAL ) - or previous_state != new_status.state_name + or self.last_update_state != new_status.state_name ): try: await self.update_map() @@ -330,6 +330,7 @@ class RoborockDataUpdateCoordinator(DataUpdateCoordinator[DeviceProp]): self.update_interval = V1_CLOUD_NOT_CLEANING_INTERVAL else: self.update_interval = V1_LOCAL_NOT_CLEANING_INTERVAL + self.last_update_state = self.roborock_device_info.props.status.state_name return self.roborock_device_info.props def _set_current_map(self) -> None: From be32968ed4272e24ef4cef5f5a4da0c1b0fc0934 Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Sat, 5 Apr 2025 11:01:47 +0200 Subject: [PATCH 293/505] Use common states for sensor levels in `overkiz` (#142325) --- homeassistant/components/overkiz/strings.json | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/homeassistant/components/overkiz/strings.json b/homeassistant/components/overkiz/strings.json index 05b5eac4b21..da6c01219f1 100644 --- a/homeassistant/components/overkiz/strings.json +++ b/homeassistant/components/overkiz/strings.json @@ -120,10 +120,10 @@ "battery": { "state": { "full": "Full", - "low": "Low", - "normal": "Normal", - "medium": "Medium", - "verylow": "Very low", + "low": "[%key:common::state::low%]", + "normal": "[%key:common::state::normal%]", + "medium": "[%key:common::state::medium%]", + "verylow": "[%key:common::state::very_low%]", "good": "Good", "critical": "Critical" } @@ -131,9 +131,9 @@ "discrete_rssi_level": { "state": { "good": "Good", - "low": "Low", - "normal": "Normal", - "verylow": "Very low" + "low": "[%key:common::state::low%]", + "normal": "[%key:common::state::normal%]", + "verylow": "[%key:common::state::very_low%]" } }, "priority_lock_originator": { From e235a04dae53fb56f5929822c948e87e948950cb Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Sat, 5 Apr 2025 11:02:46 +0200 Subject: [PATCH 294/505] Use common states for sensor levels in `nam` (#142323) --- homeassistant/components/nam/strings.json | 60 +++++++++++------------ 1 file changed, 30 insertions(+), 30 deletions(-) diff --git a/homeassistant/components/nam/strings.json b/homeassistant/components/nam/strings.json index 000dfe74112..b02eecaa41e 100644 --- a/homeassistant/components/nam/strings.json +++ b/homeassistant/components/nam/strings.json @@ -96,20 +96,20 @@ "pmsx003_caqi_level": { "name": "PMSx003 common air quality index level", "state": { - "very_low": "Very low", - "low": "Low", - "medium": "Medium", - "high": "High", - "very_high": "Very high" + "very_low": "[%key:common::state::very_low%]", + "low": "[%key:common::state::low%]", + "medium": "[%key:common::state::medium%]", + "high": "[%key:common::state::high%]", + "very_high": "[%key:common::state::very_high%]" }, "state_attributes": { "options": { "state": { - "very_low": "[%key:component::nam::entity::sensor::pmsx003_caqi_level::state::very_low%]", - "low": "[%key:component::nam::entity::sensor::pmsx003_caqi_level::state::low%]", - "medium": "[%key:component::nam::entity::sensor::pmsx003_caqi_level::state::medium%]", - "high": "[%key:component::nam::entity::sensor::pmsx003_caqi_level::state::high%]", - "very_high": "[%key:component::nam::entity::sensor::pmsx003_caqi_level::state::very_high%]" + "very_low": "[%key:common::state::very_low%]", + "low": "[%key:common::state::low%]", + "medium": "[%key:common::state::medium%]", + "high": "[%key:common::state::high%]", + "very_high": "[%key:common::state::very_high%]" } } } @@ -129,20 +129,20 @@ "sds011_caqi_level": { "name": "SDS011 common air quality index level", "state": { - "very_low": "[%key:component::nam::entity::sensor::pmsx003_caqi_level::state::very_low%]", - "low": "[%key:component::nam::entity::sensor::pmsx003_caqi_level::state::low%]", - "medium": "[%key:component::nam::entity::sensor::pmsx003_caqi_level::state::medium%]", - "high": "[%key:component::nam::entity::sensor::pmsx003_caqi_level::state::high%]", - "very_high": "[%key:component::nam::entity::sensor::pmsx003_caqi_level::state::very_high%]" + "very_low": "[%key:common::state::very_low%]", + "low": "[%key:common::state::low%]", + "medium": "[%key:common::state::medium%]", + "high": "[%key:common::state::high%]", + "very_high": "[%key:common::state::very_high%]" }, "state_attributes": { "options": { "state": { - "very_low": "[%key:component::nam::entity::sensor::pmsx003_caqi_level::state::very_low%]", - "low": "[%key:component::nam::entity::sensor::pmsx003_caqi_level::state::low%]", - "medium": "[%key:component::nam::entity::sensor::pmsx003_caqi_level::state::medium%]", - "high": "[%key:component::nam::entity::sensor::pmsx003_caqi_level::state::high%]", - "very_high": "[%key:component::nam::entity::sensor::pmsx003_caqi_level::state::very_high%]" + "very_low": "[%key:common::state::very_low%]", + "low": "[%key:common::state::low%]", + "medium": "[%key:common::state::medium%]", + "high": "[%key:common::state::high%]", + "very_high": "[%key:common::state::very_high%]" } } } @@ -165,20 +165,20 @@ "sps30_caqi_level": { "name": "SPS30 common air quality index level", "state": { - "very_low": "[%key:component::nam::entity::sensor::pmsx003_caqi_level::state::very_low%]", - "low": "[%key:component::nam::entity::sensor::pmsx003_caqi_level::state::low%]", - "medium": "[%key:component::nam::entity::sensor::pmsx003_caqi_level::state::medium%]", - "high": "[%key:component::nam::entity::sensor::pmsx003_caqi_level::state::high%]", - "very_high": "[%key:component::nam::entity::sensor::pmsx003_caqi_level::state::very_high%]" + "very_low": "[%key:common::state::very_low%]", + "low": "[%key:common::state::low%]", + "medium": "[%key:common::state::medium%]", + "high": "[%key:common::state::high%]", + "very_high": "[%key:common::state::very_high%]" }, "state_attributes": { "options": { "state": { - "very_low": "[%key:component::nam::entity::sensor::pmsx003_caqi_level::state::very_low%]", - "low": "[%key:component::nam::entity::sensor::pmsx003_caqi_level::state::low%]", - "medium": "[%key:component::nam::entity::sensor::pmsx003_caqi_level::state::medium%]", - "high": "[%key:component::nam::entity::sensor::pmsx003_caqi_level::state::high%]", - "very_high": "[%key:component::nam::entity::sensor::pmsx003_caqi_level::state::very_high%]" + "very_low": "[%key:common::state::very_low%]", + "low": "[%key:common::state::low%]", + "medium": "[%key:common::state::medium%]", + "high": "[%key:common::state::high%]", + "very_high": "[%key:common::state::very_high%]" } } } From d07378e87b25a93cabbac4706e87d8764dbb425d Mon Sep 17 00:00:00 2001 From: tronikos Date: Sat, 5 Apr 2025 02:03:20 -0700 Subject: [PATCH 295/505] Bump opower to 0.10.0 (#142321) --- homeassistant/components/opower/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/opower/manifest.json b/homeassistant/components/opower/manifest.json index 2da4511c0aa..e691d01257a 100644 --- a/homeassistant/components/opower/manifest.json +++ b/homeassistant/components/opower/manifest.json @@ -7,5 +7,5 @@ "documentation": "https://www.home-assistant.io/integrations/opower", "iot_class": "cloud_polling", "loggers": ["opower"], - "requirements": ["opower==0.9.0"] + "requirements": ["opower==0.10.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index 02258e6261d..fd6ce683fd3 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1607,7 +1607,7 @@ openwrt-luci-rpc==1.1.17 openwrt-ubus-rpc==0.0.2 # homeassistant.components.opower -opower==0.9.0 +opower==0.10.0 # homeassistant.components.oralb oralb-ble==0.17.6 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index cd91423e032..39ba516906d 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1341,7 +1341,7 @@ openhomedevice==2.2.0 openwebifpy==4.3.1 # homeassistant.components.opower -opower==0.9.0 +opower==0.10.0 # homeassistant.components.oralb oralb-ble==0.17.6 From d7e36513b52d6e482361394f57c1aba42d310456 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 4 Apr 2025 23:05:01 -1000 Subject: [PATCH 296/505] Bump inkbird-ble to 0.10.1 (#142314) * Bump inkbird-ble to 0.10.0 changelog: https://github.com/Bluetooth-Devices/inkbird-ble/compare/v0.9.0...v0.10.0 * Apply suggestions from code review --- homeassistant/components/inkbird/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/inkbird/manifest.json b/homeassistant/components/inkbird/manifest.json index aaa9c4b3473..ea980babf7e 100644 --- a/homeassistant/components/inkbird/manifest.json +++ b/homeassistant/components/inkbird/manifest.json @@ -40,5 +40,5 @@ "dependencies": ["bluetooth_adapters"], "documentation": "https://www.home-assistant.io/integrations/inkbird", "iot_class": "local_push", - "requirements": ["inkbird-ble==0.9.0"] + "requirements": ["inkbird-ble==0.10.1"] } diff --git a/requirements_all.txt b/requirements_all.txt index fd6ce683fd3..63f7a299746 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1232,7 +1232,7 @@ influxdb-client==1.24.0 influxdb==5.3.1 # homeassistant.components.inkbird -inkbird-ble==0.9.0 +inkbird-ble==0.10.1 # homeassistant.components.insteon insteon-frontend-home-assistant==0.5.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 39ba516906d..7adba92bf6b 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1044,7 +1044,7 @@ influxdb-client==1.24.0 influxdb==5.3.1 # homeassistant.components.inkbird -inkbird-ble==0.9.0 +inkbird-ble==0.10.1 # homeassistant.components.insteon insteon-frontend-home-assistant==0.5.0 From 4ab31e2d4e2eb81b2a43c6fd38ae47cf1d78d683 Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Sat, 5 Apr 2025 11:05:43 +0200 Subject: [PATCH 297/505] Use common states for sensor levels in `tomorrowio` (#142324) --- .../components/tomorrowio/strings.json | 36 +++++++++---------- 1 file changed, 18 insertions(+), 18 deletions(-) diff --git a/homeassistant/components/tomorrowio/strings.json b/homeassistant/components/tomorrowio/strings.json index 03a8a169920..c3f52155d29 100644 --- a/homeassistant/components/tomorrowio/strings.json +++ b/homeassistant/components/tomorrowio/strings.json @@ -115,33 +115,33 @@ "name": "Tree pollen index", "state": { "none": "None", - "very_low": "Very low", - "low": "Low", - "medium": "Medium", - "high": "High", - "very_high": "Very high" + "very_low": "[%key:common::state::very_low%]", + "low": "[%key:common::state::low%]", + "medium": "[%key:common::state::medium%]", + "high": "[%key:common::state::high%]", + "very_high": "[%key:common::state::very_high%]" } }, "weed_pollen_index": { "name": "Weed pollen index", "state": { "none": "[%key:component::tomorrowio::entity::sensor::pollen_index::state::none%]", - "very_low": "[%key:component::tomorrowio::entity::sensor::pollen_index::state::very_low%]", - "low": "[%key:component::tomorrowio::entity::sensor::pollen_index::state::low%]", - "medium": "[%key:component::tomorrowio::entity::sensor::pollen_index::state::medium%]", - "high": "[%key:component::tomorrowio::entity::sensor::pollen_index::state::high%]", - "very_high": "[%key:component::tomorrowio::entity::sensor::pollen_index::state::very_high%]" + "very_low": "[%key:common::state::very_low%]", + "low": "[%key:common::state::low%]", + "medium": "[%key:common::state::medium%]", + "high": "[%key:common::state::high%]", + "very_high": "[%key:common::state::very_high%]" } }, "grass_pollen_index": { "name": "Grass pollen index", "state": { "none": "[%key:component::tomorrowio::entity::sensor::pollen_index::state::none%]", - "very_low": "[%key:component::tomorrowio::entity::sensor::pollen_index::state::very_low%]", - "low": "[%key:component::tomorrowio::entity::sensor::pollen_index::state::low%]", - "medium": "[%key:component::tomorrowio::entity::sensor::pollen_index::state::medium%]", - "high": "[%key:component::tomorrowio::entity::sensor::pollen_index::state::high%]", - "very_high": "[%key:component::tomorrowio::entity::sensor::pollen_index::state::very_high%]" + "very_low": "[%key:common::state::very_low%]", + "low": "[%key:common::state::low%]", + "medium": "[%key:common::state::medium%]", + "high": "[%key:common::state::high%]", + "very_high": "[%key:common::state::very_high%]" } }, "fire_index": { @@ -153,10 +153,10 @@ "uv_radiation_health_concern": { "name": "UV radiation health concern", "state": { - "low": "Low", + "low": "[%key:common::state::low%]", "moderate": "Moderate", - "high": "High", - "very_high": "Very high", + "high": "[%key:common::state::high%]", + "very_high": "[%key:common::state::very_high%]", "extreme": "Extreme" } } From 1ab8deff3d7c1e825c6537840e3b5cd582f423e4 Mon Sep 17 00:00:00 2001 From: Tomek Wasilczyk <1107414+twasilczyk@users.noreply.github.com> Date: Sat, 5 Apr 2025 03:12:34 -0700 Subject: [PATCH 298/505] Add missing test_all requirements (#142036) Fix homeassistant_hardware handling and add missing test_all requirements --- requirements_test_all.txt | 6 ++++++ script/gen_requirements_all.py | 3 ++- 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 7adba92bf6b..f1e2e8590e3 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -949,6 +949,9 @@ ha-iotawattpy==0.1.2 # homeassistant.components.philips_js ha-philipsjs==3.2.2 +# homeassistant.components.homeassistant_hardware +ha-silabs-firmware-client==0.2.0 + # homeassistant.components.habitica habiticalib==0.3.7 @@ -2396,6 +2399,9 @@ ultraheat-api==0.5.7 # homeassistant.components.unifiprotect unifi-discovery==1.2.0 +# homeassistant.components.homeassistant_hardware +universal-silabs-flasher==0.0.30 + # homeassistant.components.upb upb-lib==0.6.1 diff --git a/script/gen_requirements_all.py b/script/gen_requirements_all.py index f155068c7e7..acc87ec2731 100755 --- a/script/gen_requirements_all.py +++ b/script/gen_requirements_all.py @@ -266,7 +266,8 @@ def has_tests(module: str) -> bool: Test if exists: tests/components/hue/__init__.py """ path = ( - Path(module.replace(".", "/").replace("homeassistant", "tests")) / "__init__.py" + Path(module.replace(".", "/").replace("homeassistant", "tests", 1)) + / "__init__.py" ) return path.exists() From 904265bca7111bf174179049c4d5c0b8fbbbd3f8 Mon Sep 17 00:00:00 2001 From: elmurato <1382097+elmurato@users.noreply.github.com> Date: Sat, 5 Apr 2025 13:56:52 +0200 Subject: [PATCH 299/505] Add reauth flow to Pterodactyl (#142285) * Add reauth flow * Add common function to validate connection in config flow * Fix remaining review findings --- .../components/pterodactyl/config_flow.py | 68 +++++++++++++++--- .../components/pterodactyl/coordinator.py | 9 ++- .../components/pterodactyl/quality_scale.yaml | 2 +- .../components/pterodactyl/strings.json | 13 +++- .../pterodactyl/test_config_flow.py | 71 ++++++++++++++++++- 5 files changed, 145 insertions(+), 18 deletions(-) diff --git a/homeassistant/components/pterodactyl/config_flow.py b/homeassistant/components/pterodactyl/config_flow.py index e78ae776123..db03c89f95e 100644 --- a/homeassistant/components/pterodactyl/config_flow.py +++ b/homeassistant/components/pterodactyl/config_flow.py @@ -2,6 +2,7 @@ from __future__ import annotations +from collections.abc import Mapping import logging from typing import Any @@ -29,36 +30,81 @@ STEP_USER_DATA_SCHEMA = vol.Schema( } ) +STEP_REAUTH_DATA_SCHEMA = vol.Schema( + { + vol.Required(CONF_API_KEY): str, + } +) + class PterodactylConfigFlow(ConfigFlow, domain=DOMAIN): """Handle a config flow for Pterodactyl.""" VERSION = 1 + async def async_validate_connection(self, url: str, api_key: str) -> dict[str, str]: + """Validate the connection to the Pterodactyl server.""" + errors: dict[str, str] = {} + api = PterodactylAPI(self.hass, url, api_key) + + try: + await api.async_init() + except PterodactylAuthorizationError: + errors["base"] = "invalid_auth" + except PterodactylConnectionError: + errors["base"] = "cannot_connect" + except Exception: + _LOGGER.exception("Unexpected exception occurred during config flow") + errors["base"] = "unknown" + + return errors + async def async_step_user( self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: """Handle the initial step.""" errors: dict[str, str] = {} + if user_input is not None: url = URL(user_input[CONF_URL]).human_repr() api_key = user_input[CONF_API_KEY] self._async_abort_entries_match({CONF_URL: url}) - api = PterodactylAPI(self.hass, url, api_key) + errors = await self.async_validate_connection(url, api_key) - try: - await api.async_init() - except PterodactylAuthorizationError: - errors["base"] = "invalid_auth" - except PterodactylConnectionError: - errors["base"] = "cannot_connect" - except Exception: - _LOGGER.exception("Unexpected exception occurred during config flow") - errors["base"] = "unknown" - else: + if not errors: return self.async_create_entry(title=url, data=user_input) return self.async_show_form( step_id="user", data_schema=STEP_USER_DATA_SCHEMA, errors=errors ) + + async def async_step_reauth( + self, entry_data: Mapping[str, Any] + ) -> ConfigFlowResult: + """Perform re-authentication on an API authentication error.""" + return await self.async_step_reauth_confirm() + + async def async_step_reauth_confirm( + self, user_input: Mapping[str, Any] | None = None + ) -> ConfigFlowResult: + """Dialog that informs the user that re-authentication is required.""" + errors: dict[str, str] = {} + + if user_input is not None: + reauth_entry = self._get_reauth_entry() + url = reauth_entry.data[CONF_URL] + api_key = user_input[CONF_API_KEY] + + errors = await self.async_validate_connection(url, api_key) + + if not errors: + return self.async_update_reload_and_abort( + reauth_entry, data_updates=user_input + ) + + return self.async_show_form( + step_id="reauth_confirm", + data_schema=STEP_REAUTH_DATA_SCHEMA, + errors=errors, + ) diff --git a/homeassistant/components/pterodactyl/coordinator.py b/homeassistant/components/pterodactyl/coordinator.py index c8456ce9e55..6d644e96e4c 100644 --- a/homeassistant/components/pterodactyl/coordinator.py +++ b/homeassistant/components/pterodactyl/coordinator.py @@ -8,6 +8,7 @@ import logging from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_API_KEY, CONF_URL from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryAuthFailed from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from .api import ( @@ -55,12 +56,16 @@ class PterodactylCoordinator(DataUpdateCoordinator[dict[str, PterodactylData]]): try: await self.api.async_init() - except (PterodactylAuthorizationError, PterodactylConnectionError) as error: + except PterodactylConnectionError as error: raise UpdateFailed(error) from error + except PterodactylAuthorizationError as error: + raise ConfigEntryAuthFailed(error) from error async def _async_update_data(self) -> dict[str, PterodactylData]: """Get updated data from the Pterodactyl server.""" try: return await self.api.async_get_data() - except (PterodactylAuthorizationError, PterodactylConnectionError) as error: + except PterodactylConnectionError as error: raise UpdateFailed(error) from error + except PterodactylAuthorizationError as error: + raise ConfigEntryAuthFailed(error) from error diff --git a/homeassistant/components/pterodactyl/quality_scale.yaml b/homeassistant/components/pterodactyl/quality_scale.yaml index dae3b9fa11a..80ebb3fc7e3 100644 --- a/homeassistant/components/pterodactyl/quality_scale.yaml +++ b/homeassistant/components/pterodactyl/quality_scale.yaml @@ -51,7 +51,7 @@ rules: status: done comment: Handled by coordinator. parallel-updates: done - reauthentication-flow: todo + reauthentication-flow: done test-coverage: todo # Gold diff --git a/homeassistant/components/pterodactyl/strings.json b/homeassistant/components/pterodactyl/strings.json index fe2b7730e1b..3d01700f189 100644 --- a/homeassistant/components/pterodactyl/strings.json +++ b/homeassistant/components/pterodactyl/strings.json @@ -10,6 +10,16 @@ "url": "The URL of your Pterodactyl server, including the protocol (http:// or https://) and optionally the port number.", "api_key": "The account API key for accessing your Pterodactyl server." } + }, + "reauth_confirm": { + "title": "[%key:common::config_flow::title::reauth%]", + "description": "Please update your account API key.", + "data": { + "api_key": "[%key:common::config_flow::data::api_key%]" + }, + "data_description": { + "api_key": "[%key:component::pterodactyl::config::step::user::data_description::api_key%]" + } } }, "error": { @@ -18,7 +28,8 @@ "unknown": "[%key:common::config_flow::error::unknown%]" }, "abort": { - "already_configured": "[%key:common::config_flow::abort::already_configured_service%]" + "already_configured": "[%key:common::config_flow::abort::already_configured_service%]", + "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]" } }, "entity": { diff --git a/tests/components/pterodactyl/test_config_flow.py b/tests/components/pterodactyl/test_config_flow.py index 3cb7f1c19d4..88247085083 100644 --- a/tests/components/pterodactyl/test_config_flow.py +++ b/tests/components/pterodactyl/test_config_flow.py @@ -8,10 +8,11 @@ from requests.models import Response from homeassistant.components.pterodactyl.const import DOMAIN from homeassistant.config_entries import SOURCE_USER +from homeassistant.const import CONF_API_KEY, CONF_URL from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType -from .conftest import TEST_URL, TEST_USER_INPUT +from .conftest import TEST_API_KEY, TEST_URL, TEST_USER_INPUT from tests.common import MockConfigEntry @@ -90,11 +91,10 @@ async def test_recovery_after_error( assert result["data"] == TEST_USER_INPUT -@pytest.mark.usefixtures("mock_setup_entry") +@pytest.mark.usefixtures("mock_setup_entry", "mock_pterodactyl") async def test_service_already_configured( hass: HomeAssistant, mock_config_entry: MockConfigEntry, - mock_pterodactyl: PterodactylClient, ) -> None: """Test config flow abort if the Pterodactyl server is already configured.""" mock_config_entry.add_to_hass(hass) @@ -105,3 +105,68 @@ async def test_service_already_configured( assert result["type"] is FlowResultType.ABORT assert result["reason"] == "already_configured" + + +@pytest.mark.usefixtures("mock_pterodactyl", "mock_setup_entry") +async def test_reauth_full_flow( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, +) -> None: + """Test reauth config flow success.""" + mock_config_entry.add_to_hass(hass) + result = await mock_config_entry.start_reauth_flow(hass) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "reauth_confirm" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={CONF_API_KEY: TEST_API_KEY} + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reauth_successful" + assert mock_config_entry.data[CONF_URL] == TEST_URL + assert mock_config_entry.data[CONF_API_KEY] == TEST_API_KEY + + +@pytest.mark.usefixtures("mock_setup_entry") +@pytest.mark.parametrize( + ("exception_type", "expected_error"), + [ + (PterodactylApiError, "cannot_connect"), + (BadRequestError, "cannot_connect"), + (Exception, "unknown"), + (HTTPError(response=mock_response()), "invalid_auth"), + ], +) +async def test_reauth_recovery_after_error( + hass: HomeAssistant, + exception_type: Exception, + expected_error: str, + mock_config_entry: MockConfigEntry, + mock_pterodactyl: PterodactylClient, +) -> None: + """Test recovery after an error during re-authentication.""" + mock_config_entry.add_to_hass(hass) + result = await mock_config_entry.start_reauth_flow(hass) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "reauth_confirm" + + mock_pterodactyl.client.servers.list_servers.side_effect = exception_type + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={CONF_API_KEY: TEST_API_KEY} + ) + + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {"base": expected_error} + + mock_pterodactyl.reset_mock(side_effect=True) + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={CONF_API_KEY: TEST_API_KEY} + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reauth_successful" + assert mock_config_entry.data[CONF_URL] == TEST_URL + assert mock_config_entry.data[CONF_API_KEY] == TEST_API_KEY From 9692d637ca40b42a9f40ca4769a3e9fc387e6aae Mon Sep 17 00:00:00 2001 From: Sanjay Govind Date: Sun, 6 Apr 2025 01:26:35 +1300 Subject: [PATCH 300/505] Add reauth flow to bosch_alarm (#142251) * add reauth flow * fix tests * move not happy flow to its own test * reference existing strings * Update test_config_flow.py --- .../components/bosch_alarm/__init__.py | 11 ++- .../components/bosch_alarm/config_flow.py | 53 ++++++++++++++ .../components/bosch_alarm/quality_scale.yaml | 2 +- .../components/bosch_alarm/strings.json | 23 +++++- .../bosch_alarm/test_config_flow.py | 73 +++++++++++++++++++ tests/components/bosch_alarm/test_init.py | 16 +++- 6 files changed, 172 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/bosch_alarm/__init__.py b/homeassistant/components/bosch_alarm/__init__.py index bc7fee46f60..ddd736b47c0 100644 --- a/homeassistant/components/bosch_alarm/__init__.py +++ b/homeassistant/components/bosch_alarm/__init__.py @@ -9,7 +9,7 @@ from bosch_alarm_mode2 import Panel from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_PORT, Platform from homeassistant.core import HomeAssistant -from homeassistant.exceptions import ConfigEntryNotReady +from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady from homeassistant.helpers import device_registry as dr from .const import CONF_INSTALLER_CODE, CONF_USER_CODE, DOMAIN @@ -34,10 +34,15 @@ async def async_setup_entry(hass: HomeAssistant, entry: BoschAlarmConfigEntry) - await panel.connect() except (PermissionError, ValueError) as err: await panel.disconnect() - raise ConfigEntryNotReady from err + raise ConfigEntryAuthFailed( + translation_domain=DOMAIN, translation_key="authentication_failed" + ) from err except (TimeoutError, OSError, ConnectionRefusedError, SSLError) as err: await panel.disconnect() - raise ConfigEntryNotReady("Connection failed") from err + raise ConfigEntryNotReady( + translation_domain=DOMAIN, + translation_key="cannot_connect", + ) from err entry.runtime_data = panel diff --git a/homeassistant/components/bosch_alarm/config_flow.py b/homeassistant/components/bosch_alarm/config_flow.py index e48f2a11944..4b1e3e511fc 100644 --- a/homeassistant/components/bosch_alarm/config_flow.py +++ b/homeassistant/components/bosch_alarm/config_flow.py @@ -3,6 +3,7 @@ from __future__ import annotations import asyncio +from collections.abc import Mapping import logging import ssl from typing import Any @@ -163,3 +164,55 @@ class BoschAlarmConfigFlow(ConfigFlow, domain=DOMAIN): data_schema=self.add_suggested_values_to_schema(schema, user_input), errors=errors, ) + + async def async_step_reauth( + self, entry_data: Mapping[str, Any] + ) -> ConfigFlowResult: + """Perform reauth upon an authentication error.""" + self._data = dict(entry_data) + return await self.async_step_reauth_confirm() + + async def async_step_reauth_confirm( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle the reauth step.""" + errors: dict[str, str] = {} + + # Each model variant requires a different authentication flow + if "Solution" in self._data[CONF_MODEL]: + schema = STEP_AUTH_DATA_SCHEMA_SOLUTION + elif "AMAX" in self._data[CONF_MODEL]: + schema = STEP_AUTH_DATA_SCHEMA_AMAX + else: + schema = STEP_AUTH_DATA_SCHEMA_BG + + if user_input is not None: + reauth_entry = self._get_reauth_entry() + self._data.update(user_input) + try: + (_, _) = await try_connect(self._data, Panel.LOAD_EXTENDED_INFO) + except (PermissionError, ValueError) as e: + errors["base"] = "invalid_auth" + _LOGGER.error("Authentication Error: %s", e) + except ( + OSError, + ConnectionRefusedError, + ssl.SSLError, + TimeoutError, + ) as e: + _LOGGER.error("Connection Error: %s", e) + errors["base"] = "cannot_connect" + except Exception: + _LOGGER.exception("Unexpected exception") + errors["base"] = "unknown" + else: + return self.async_update_reload_and_abort( + reauth_entry, + data_updates=user_input, + ) + + return self.async_show_form( + step_id="reauth_confirm", + data_schema=self.add_suggested_values_to_schema(schema, user_input), + errors=errors, + ) diff --git a/homeassistant/components/bosch_alarm/quality_scale.yaml b/homeassistant/components/bosch_alarm/quality_scale.yaml index 467760fb863..75c331ede40 100644 --- a/homeassistant/components/bosch_alarm/quality_scale.yaml +++ b/homeassistant/components/bosch_alarm/quality_scale.yaml @@ -40,7 +40,7 @@ rules: integration-owner: done log-when-unavailable: todo parallel-updates: todo - reauthentication-flow: todo + reauthentication-flow: done test-coverage: done # Gold diff --git a/homeassistant/components/bosch_alarm/strings.json b/homeassistant/components/bosch_alarm/strings.json index f4846021b55..3123c1697f3 100644 --- a/homeassistant/components/bosch_alarm/strings.json +++ b/homeassistant/components/bosch_alarm/strings.json @@ -22,6 +22,18 @@ "installer_code": "The installer code from your panel", "user_code": "The user code from your panel" } + }, + "reauth_confirm": { + "data": { + "password": "[%key:common::config_flow::data::password%]", + "installer_code": "[%key:component::bosch_alarm::config::step::auth::data::installer_code%]", + "user_code": "[%key:component::bosch_alarm::config::step::auth::data::user_code%]" + }, + "data_description": { + "password": "[%key:component::bosch_alarm::config::step::auth::data_description::password%]", + "installer_code": "[%key:component::bosch_alarm::config::step::auth::data_description::installer_code%]", + "user_code": "[%key:component::bosch_alarm::config::step::auth::data_description::user_code%]" + } } }, "error": { @@ -30,7 +42,16 @@ "unknown": "[%key:common::config_flow::error::unknown%]" }, "abort": { - "already_configured": "[%key:common::config_flow::abort::already_configured_device%]" + "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", + "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]" + } + }, + "exceptions": { + "cannot_connect": { + "message": "Could not connect to panel." + }, + "authentication_failed": { + "message": "Incorrect credentials for panel." } } } diff --git a/tests/components/bosch_alarm/test_config_flow.py b/tests/components/bosch_alarm/test_config_flow.py index 066b3008821..4a1c9dad3ea 100644 --- a/tests/components/bosch_alarm/test_config_flow.py +++ b/tests/components/bosch_alarm/test_config_flow.py @@ -13,6 +13,8 @@ from homeassistant.const import CONF_HOST, CONF_MODEL, CONF_PORT from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType +from . import setup_integration + from tests.common import MockConfigEntry @@ -210,3 +212,74 @@ async def test_entry_already_configured_serial( assert result["type"] is FlowResultType.ABORT assert result["reason"] == "already_configured" + + +async def test_reauth_flow_success( + hass: HomeAssistant, + mock_setup_entry: AsyncMock, + mock_config_entry: MockConfigEntry, + mock_panel: AsyncMock, + model_name: str, + serial_number: str, + config_flow_data: dict[str, Any], +) -> None: + """Test reauth flow.""" + await setup_integration(hass, mock_config_entry) + result = await mock_config_entry.start_reauth_flow(hass) + + config_flow_data = {k: f"{v}2" for k, v in config_flow_data.items()} + + assert result["step_id"] == "reauth_confirm" + # Now check it works when there are no errors + mock_panel.connect.side_effect = None + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input=config_flow_data, + ) + assert result["reason"] == "reauth_successful" + compare = {**mock_config_entry.data, **config_flow_data} + assert compare == mock_config_entry.data + + +@pytest.mark.parametrize( + ("exception", "message"), + [ + (OSError(), "cannot_connect"), + (PermissionError(), "invalid_auth"), + (Exception(), "unknown"), + ], +) +async def test_reauth_flow_error( + hass: HomeAssistant, + mock_setup_entry: AsyncMock, + mock_config_entry: MockConfigEntry, + mock_panel: AsyncMock, + model_name: str, + serial_number: str, + config_flow_data: dict[str, Any], + exception: Exception, + message: str, +) -> None: + """Test reauth flow.""" + await setup_integration(hass, mock_config_entry) + result = await mock_config_entry.start_reauth_flow(hass) + + config_flow_data = {k: f"{v}2" for k, v in config_flow_data.items()} + + assert result["step_id"] == "reauth_confirm" + mock_panel.connect.side_effect = exception + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input=config_flow_data, + ) + assert result["step_id"] == "reauth_confirm" + assert result["errors"]["base"] == message + + mock_panel.connect.side_effect = None + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input=config_flow_data, + ) + assert result["reason"] == "reauth_successful" + compare = {**mock_config_entry.data, **config_flow_data} + assert compare == mock_config_entry.data diff --git a/tests/components/bosch_alarm/test_init.py b/tests/components/bosch_alarm/test_init.py index 0497a91eadf..13e938bd711 100644 --- a/tests/components/bosch_alarm/test_init.py +++ b/tests/components/bosch_alarm/test_init.py @@ -20,12 +20,26 @@ def disable_platform_only(): @pytest.mark.parametrize("model", ["solution_3000"]) -@pytest.mark.parametrize("exception", [PermissionError(), TimeoutError()]) +@pytest.mark.parametrize("exception", [PermissionError()]) async def test_incorrect_auth( hass: HomeAssistant, mock_panel: AsyncMock, mock_config_entry: MockConfigEntry, exception: Exception, +) -> None: + """Test errors with incorrect auth.""" + mock_panel.connect.side_effect = exception + await setup_integration(hass, mock_config_entry) + assert mock_config_entry.state is ConfigEntryState.SETUP_ERROR + + +@pytest.mark.parametrize("model", ["solution_3000"]) +@pytest.mark.parametrize("exception", [TimeoutError()]) +async def test_connection_error( + hass: HomeAssistant, + mock_panel: AsyncMock, + mock_config_entry: MockConfigEntry, + exception: Exception, ) -> None: """Test errors with incorrect auth.""" mock_panel.connect.side_effect = exception From f29019960694946aa5d88a99cfa3d2232a862029 Mon Sep 17 00:00:00 2001 From: Thomas55555 <59625598+Thomas55555@users.noreply.github.com> Date: Sat, 5 Apr 2025 18:07:06 +0200 Subject: [PATCH 301/505] Add error details in remote calendar flow (#141753) * Add error details in remote calendar flow * no args * adjust * json * Apply suggestions * remove description placeholder --- homeassistant/components/remote_calendar/config_flow.py | 5 ++++- homeassistant/components/remote_calendar/strings.json | 2 +- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/remote_calendar/config_flow.py b/homeassistant/components/remote_calendar/config_flow.py index cc9f45e2767..802a7eb7cea 100644 --- a/homeassistant/components/remote_calendar/config_flow.py +++ b/homeassistant/components/remote_calendar/config_flow.py @@ -69,7 +69,10 @@ class RemoteCalendarConfigFlow(ConfigFlow, domain=DOMAIN): ) except CalendarParseError as err: errors["base"] = "invalid_ics_file" - _LOGGER.debug("Invalid .ics file: %s", err) + _LOGGER.error("Error reading the calendar information: %s", err.message) + _LOGGER.debug( + "Additional calendar error detail: %s", str(err.detailed_error) + ) else: return self.async_create_entry( title=user_input[CONF_CALENDAR_NAME], data=user_input diff --git a/homeassistant/components/remote_calendar/strings.json b/homeassistant/components/remote_calendar/strings.json index fff2d4abbb3..ef7f20d4699 100644 --- a/homeassistant/components/remote_calendar/strings.json +++ b/homeassistant/components/remote_calendar/strings.json @@ -20,7 +20,7 @@ "error": { "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", "forbidden": "The server understood the request but refuses to authorize it.", - "invalid_ics_file": "[%key:component::local_calendar::config::error::invalid_ics_file%]" + "invalid_ics_file": "There was a problem reading the calendar information. See the error log for additional details." } }, "exceptions": { From 9f4b2ad05a92c5257cbc7faff1eeb3b2e6fb028b Mon Sep 17 00:00:00 2001 From: Ernst Klamer Date: Sat, 5 Apr 2025 20:13:51 +0200 Subject: [PATCH 302/505] Bump xiaomi-ble to 0.35.0 (#142350) bump xiaomi-ble --- homeassistant/components/xiaomi_ble/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/xiaomi_ble/manifest.json b/homeassistant/components/xiaomi_ble/manifest.json index 26dd82c73bc..d7156246d38 100644 --- a/homeassistant/components/xiaomi_ble/manifest.json +++ b/homeassistant/components/xiaomi_ble/manifest.json @@ -24,5 +24,5 @@ "dependencies": ["bluetooth_adapters"], "documentation": "https://www.home-assistant.io/integrations/xiaomi_ble", "iot_class": "local_push", - "requirements": ["xiaomi-ble==0.33.0"] + "requirements": ["xiaomi-ble==0.35.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index 63f7a299746..ebbfbe6d964 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -3091,7 +3091,7 @@ wyoming==1.5.4 xbox-webapi==2.1.0 # homeassistant.components.xiaomi_ble -xiaomi-ble==0.33.0 +xiaomi-ble==0.35.0 # homeassistant.components.knx xknx==3.6.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index f1e2e8590e3..95759e4e12f 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2496,7 +2496,7 @@ wyoming==1.5.4 xbox-webapi==2.1.0 # homeassistant.components.xiaomi_ble -xiaomi-ble==0.33.0 +xiaomi-ble==0.35.0 # homeassistant.components.knx xknx==3.6.0 From 236f33537b4a4bfb76a221979422c56c0e6d94fe Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Sat, 5 Apr 2025 20:20:27 +0200 Subject: [PATCH 303/505] Use common states for "Low" and "Normal" in `dsmr` (#142354) Use common state for "Low" and "Normal" in `dsmr` --- homeassistant/components/dsmr/strings.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/dsmr/strings.json b/homeassistant/components/dsmr/strings.json index 871dd382f2b..e95e9ae870a 100644 --- a/homeassistant/components/dsmr/strings.json +++ b/homeassistant/components/dsmr/strings.json @@ -51,8 +51,8 @@ "electricity_active_tariff": { "name": "Active tariff", "state": { - "low": "Low", - "normal": "Normal" + "low": "[%key:common::state::low%]", + "normal": "[%key:common::state::normal%]" } }, "electricity_delivered_tariff_1": { From 913d3d4ac68e8d56298885520b79ccca3a07b60f Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Sat, 5 Apr 2025 20:21:03 +0200 Subject: [PATCH 304/505] Use common states for sensor levels in `openuv` (#142346) Replace states "Low", "High" and "Very high" with (new) common states. --- homeassistant/components/openuv/strings.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/openuv/strings.json b/homeassistant/components/openuv/strings.json index 9349d2cc116..f3b9aa686d5 100644 --- a/homeassistant/components/openuv/strings.json +++ b/homeassistant/components/openuv/strings.json @@ -54,10 +54,10 @@ "name": "Current UV level", "state": { "extreme": "Extreme", - "high": "High", - "low": "Low", + "high": "[%key:common::state::high%]", + "low": "[%key:common::state::low%]", "moderate": "Moderate", - "very_high": "Very high" + "very_high": "[%key:common::state::very_high%]" } }, "max_uv_index": { From 8121d147a6a1331492b80dba88c79ea47301d657 Mon Sep 17 00:00:00 2001 From: Andre Lengwenus Date: Sat, 5 Apr 2025 20:48:16 +0200 Subject: [PATCH 305/505] Add SensorDeviceClass and unit for LCN CO2 sensor. (#142320) Add SesnorDeviceClass and unit for LCN CO2 sensor. --- homeassistant/components/lcn/sensor.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/homeassistant/components/lcn/sensor.py b/homeassistant/components/lcn/sensor.py index 7783df8679a..0c78ea6637a 100644 --- a/homeassistant/components/lcn/sensor.py +++ b/homeassistant/components/lcn/sensor.py @@ -13,6 +13,7 @@ from homeassistant.components.sensor import ( ) from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( + CONCENTRATION_PARTS_PER_MILLION, CONF_DOMAIN, CONF_ENTITIES, CONF_SOURCE, @@ -49,6 +50,7 @@ DEVICE_CLASS_MAPPING = { pypck.lcn_defs.VarUnit.METERPERSECOND: SensorDeviceClass.SPEED, pypck.lcn_defs.VarUnit.VOLT: SensorDeviceClass.VOLTAGE, pypck.lcn_defs.VarUnit.AMPERE: SensorDeviceClass.CURRENT, + pypck.lcn_defs.VarUnit.PPM: SensorDeviceClass.CO2, } UNIT_OF_MEASUREMENT_MAPPING = { @@ -60,6 +62,7 @@ UNIT_OF_MEASUREMENT_MAPPING = { pypck.lcn_defs.VarUnit.METERPERSECOND: UnitOfSpeed.METERS_PER_SECOND, pypck.lcn_defs.VarUnit.VOLT: UnitOfElectricPotential.VOLT, pypck.lcn_defs.VarUnit.AMPERE: UnitOfElectricCurrent.AMPERE, + pypck.lcn_defs.VarUnit.PPM: CONCENTRATION_PARTS_PER_MILLION, } From 051a5030471a8f888871dd7fef9e60ee647f732b Mon Sep 17 00:00:00 2001 From: tronikos Date: Sat, 5 Apr 2025 11:49:38 -0700 Subject: [PATCH 306/505] Add a description for the enable_google_search_tool option in Google AI (#142322) * Add a description for the enable_google_search_tool option in Google AI * Use quotes --- .../components/google_generative_ai_conversation/strings.json | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/google_generative_ai_conversation/strings.json b/homeassistant/components/google_generative_ai_conversation/strings.json index cd7af711d3a..2697f30eda0 100644 --- a/homeassistant/components/google_generative_ai_conversation/strings.json +++ b/homeassistant/components/google_generative_ai_conversation/strings.json @@ -40,7 +40,8 @@ "enable_google_search_tool": "Enable Google Search tool" }, "data_description": { - "prompt": "Instruct how the LLM should respond. This can be a template." + "prompt": "Instruct how the LLM should respond. This can be a template.", + "enable_google_search_tool": "Only works with \"No control\" in the \"Control Home Assistant\" setting. See docs for a workaround using it with \"Assist\"." } } }, From a29ba51bdb49b60f7af2d8a002a09109aa09769f Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Sat, 5 Apr 2025 21:11:46 +0200 Subject: [PATCH 307/505] Use common states for sensor levels in `accuweather` (#142345) Replace states "Low", "High" and "Very high" with (new) common states. --- .../components/accuweather/strings.json | 36 +++++++++---------- 1 file changed, 18 insertions(+), 18 deletions(-) diff --git a/homeassistant/components/accuweather/strings.json b/homeassistant/components/accuweather/strings.json index e1a71c5e1a5..e81ef782d98 100644 --- a/homeassistant/components/accuweather/strings.json +++ b/homeassistant/components/accuweather/strings.json @@ -72,10 +72,10 @@ "level": { "name": "Level", "state": { - "high": "High", - "low": "Low", + "high": "[%key:common::state::high%]", + "low": "[%key:common::state::low%]", "moderate": "Moderate", - "very_high": "Very high" + "very_high": "[%key:common::state::very_high%]" } } } @@ -89,10 +89,10 @@ "level": { "name": "[%key:component::accuweather::entity::sensor::grass_pollen::state_attributes::level::name%]", "state": { - "high": "[%key:component::accuweather::entity::sensor::grass_pollen::state_attributes::level::state::high%]", - "low": "[%key:component::accuweather::entity::sensor::grass_pollen::state_attributes::level::state::low%]", + "high": "[%key:common::state::high%]", + "low": "[%key:common::state::low%]", "moderate": "[%key:component::accuweather::entity::sensor::grass_pollen::state_attributes::level::state::moderate%]", - "very_high": "[%key:component::accuweather::entity::sensor::grass_pollen::state_attributes::level::state::very_high%]" + "very_high": "[%key:common::state::very_high%]" } } } @@ -123,10 +123,10 @@ "level": { "name": "[%key:component::accuweather::entity::sensor::grass_pollen::state_attributes::level::name%]", "state": { - "high": "[%key:component::accuweather::entity::sensor::grass_pollen::state_attributes::level::state::high%]", - "low": "[%key:component::accuweather::entity::sensor::grass_pollen::state_attributes::level::state::low%]", + "high": "[%key:common::state::high%]", + "low": "[%key:common::state::low%]", "moderate": "[%key:component::accuweather::entity::sensor::grass_pollen::state_attributes::level::state::moderate%]", - "very_high": "[%key:component::accuweather::entity::sensor::grass_pollen::state_attributes::level::state::very_high%]" + "very_high": "[%key:common::state::very_high%]" } } } @@ -167,10 +167,10 @@ "level": { "name": "[%key:component::accuweather::entity::sensor::grass_pollen::state_attributes::level::name%]", "state": { - "high": "[%key:component::accuweather::entity::sensor::grass_pollen::state_attributes::level::state::high%]", - "low": "[%key:component::accuweather::entity::sensor::grass_pollen::state_attributes::level::state::low%]", + "high": "[%key:common::state::high%]", + "low": "[%key:common::state::low%]", "moderate": "[%key:component::accuweather::entity::sensor::grass_pollen::state_attributes::level::state::moderate%]", - "very_high": "[%key:component::accuweather::entity::sensor::grass_pollen::state_attributes::level::state::very_high%]" + "very_high": "[%key:common::state::very_high%]" } } } @@ -181,10 +181,10 @@ "level": { "name": "[%key:component::accuweather::entity::sensor::grass_pollen::state_attributes::level::name%]", "state": { - "high": "[%key:component::accuweather::entity::sensor::grass_pollen::state_attributes::level::state::high%]", - "low": "[%key:component::accuweather::entity::sensor::grass_pollen::state_attributes::level::state::low%]", + "high": "[%key:common::state::high%]", + "low": "[%key:common::state::low%]", "moderate": "[%key:component::accuweather::entity::sensor::grass_pollen::state_attributes::level::state::moderate%]", - "very_high": "[%key:component::accuweather::entity::sensor::grass_pollen::state_attributes::level::state::very_high%]" + "very_high": "[%key:common::state::very_high%]" } } } @@ -195,10 +195,10 @@ "level": { "name": "[%key:component::accuweather::entity::sensor::grass_pollen::state_attributes::level::name%]", "state": { - "high": "[%key:component::accuweather::entity::sensor::grass_pollen::state_attributes::level::state::high%]", - "low": "[%key:component::accuweather::entity::sensor::grass_pollen::state_attributes::level::state::low%]", + "high": "[%key:common::state::high%]", + "low": "[%key:common::state::low%]", "moderate": "[%key:component::accuweather::entity::sensor::grass_pollen::state_attributes::level::state::moderate%]", - "very_high": "[%key:component::accuweather::entity::sensor::grass_pollen::state_attributes::level::state::very_high%]" + "very_high": "[%key:common::state::very_high%]" } } } From 52143155e78a2cf769fa02ee415aae760da70757 Mon Sep 17 00:00:00 2001 From: Maciej Bieniek Date: Sat, 5 Apr 2025 21:12:13 +0200 Subject: [PATCH 308/505] Record quality scale for IMGW-PIB (#141380) * Record quality scale for IMGW-PIB * Update quality scale * Add the scale to the manifest * Typo * Suggested --- .../components/imgw_pib/manifest.json | 1 + .../components/imgw_pib/quality_scale.yaml | 88 +++++++++++++++++++ script/hassfest/quality_scale.py | 2 - 3 files changed, 89 insertions(+), 2 deletions(-) create mode 100644 homeassistant/components/imgw_pib/quality_scale.yaml diff --git a/homeassistant/components/imgw_pib/manifest.json b/homeassistant/components/imgw_pib/manifest.json index 3d8b34055fd..e2d6e2bf584 100644 --- a/homeassistant/components/imgw_pib/manifest.json +++ b/homeassistant/components/imgw_pib/manifest.json @@ -5,5 +5,6 @@ "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/imgw_pib", "iot_class": "cloud_polling", + "quality_scale": "silver", "requirements": ["imgw_pib==1.0.10"] } diff --git a/homeassistant/components/imgw_pib/quality_scale.yaml b/homeassistant/components/imgw_pib/quality_scale.yaml new file mode 100644 index 00000000000..6634c915255 --- /dev/null +++ b/homeassistant/components/imgw_pib/quality_scale.yaml @@ -0,0 +1,88 @@ +rules: + # Bronze + action-setup: + status: exempt + comment: The integration does not register services. + appropriate-polling: done + brands: done + common-modules: done + config-flow-test-coverage: done + config-flow: done + dependency-transparency: done + docs-actions: + status: exempt + comment: The integration does not register services. + docs-high-level-description: done + docs-installation-instructions: done + docs-removal-instructions: done + entity-event-setup: done + entity-unique-id: done + has-entity-name: done + runtime-data: done + test-before-configure: done + test-before-setup: done + unique-config-entry: done + + # Silver + action-exceptions: + status: exempt + comment: The integration does not register services. + config-entry-unloading: done + docs-configuration-parameters: + status: exempt + comment: No options to configure. + docs-installation-parameters: done + entity-unavailable: done + integration-owner: done + log-when-unavailable: done + parallel-updates: done + reauthentication-flow: + status: exempt + comment: No authentication required. + test-coverage: done + + # Gold + devices: done + diagnostics: done + discovery-update-info: + status: exempt + comment: The integration is a cloud service and thus does not support discovery. + discovery: + status: exempt + comment: The integration is a cloud service and thus does not support discovery. + docs-data-update: todo + docs-examples: todo + docs-known-limitations: todo + docs-supported-devices: + status: exempt + comment: This is a service, which doesn't integrate with any devices. + docs-supported-functions: todo + docs-troubleshooting: + status: exempt + comment: No known issues that could be resolved by the user. + docs-use-cases: todo + dynamic-devices: + status: exempt + comment: This integration has a fixed single service. + entity-category: done + entity-device-class: done + entity-disabled-by-default: + status: exempt + comment: This integration does not have any entities that should disabled by default. + entity-translations: done + exception-translations: done + icon-translations: done + reconfiguration-flow: + status: exempt + comment: Only parameter that could be changed station_id would force a new config entry. + repair-issues: + status: exempt + comment: This integration doesn't have any cases where raising an issue is needed. + stale-devices: + status: exempt + comment: This integration has a fixed single service. + + # Platinum + async-dependency: done + inject-websession: done + strict-typing: done diff --git a/script/hassfest/quality_scale.py b/script/hassfest/quality_scale.py index fdcbe16f092..49da98f5872 100644 --- a/script/hassfest/quality_scale.py +++ b/script/hassfest/quality_scale.py @@ -513,7 +513,6 @@ INTEGRATIONS_WITHOUT_QUALITY_SCALE_FILE = [ "iglo", "ign_sismologia", "ihc", - "imgw_pib", "improv_ble", "influxdb", "inkbird", @@ -1573,7 +1572,6 @@ INTEGRATIONS_WITHOUT_SCALE = [ "ign_sismologia", "ihc", "imap", - "imgw_pib", "improv_ble", "influxdb", "inkbird", From 660cbc136f40f49e414d2f8d80c18dfbbe7a2a65 Mon Sep 17 00:00:00 2001 From: Andrew Sayre <6730289+andrewsayre@users.noreply.github.com> Date: Sat, 5 Apr 2025 15:05:01 -0500 Subject: [PATCH 309/505] Add move queue item HEOS entity service (#142301) --- homeassistant/components/heos/const.py | 2 + homeassistant/components/heos/icons.json | 3 ++ homeassistant/components/heos/media_player.py | 7 +++ homeassistant/components/heos/services.py | 15 +++++++ homeassistant/components/heos/services.yaml | 20 +++++++++ homeassistant/components/heos/strings.json | 14 ++++++ tests/components/heos/__init__.py | 1 + tests/components/heos/test_media_player.py | 44 +++++++++++++++++++ 8 files changed, 106 insertions(+) diff --git a/homeassistant/components/heos/const.py b/homeassistant/components/heos/const.py index b83da128c91..d49fc17aa53 100644 --- a/homeassistant/components/heos/const.py +++ b/homeassistant/components/heos/const.py @@ -2,6 +2,7 @@ ATTR_PASSWORD = "password" ATTR_USERNAME = "username" +ATTR_DESTINATION_POSITION = "destination_position" ATTR_QUEUE_IDS = "queue_ids" DOMAIN = "heos" ENTRY_TITLE = "HEOS System" @@ -9,6 +10,7 @@ SERVICE_GET_QUEUE = "get_queue" SERVICE_GROUP_VOLUME_SET = "group_volume_set" SERVICE_GROUP_VOLUME_DOWN = "group_volume_down" SERVICE_GROUP_VOLUME_UP = "group_volume_up" +SERVICE_MOVE_QUEUE_ITEM = "move_queue_item" SERVICE_REMOVE_FROM_QUEUE = "remove_from_queue" SERVICE_SIGN_IN = "sign_in" SERVICE_SIGN_OUT = "sign_out" diff --git a/homeassistant/components/heos/icons.json b/homeassistant/components/heos/icons.json index c11b499fc0b..b03f15a4b0f 100644 --- a/homeassistant/components/heos/icons.json +++ b/homeassistant/components/heos/icons.json @@ -6,6 +6,9 @@ "remove_from_queue": { "service": "mdi:playlist-remove" }, + "move_queue_item": { + "service": "mdi:playlist-edit" + }, "group_volume_set": { "service": "mdi:volume-medium" }, diff --git a/homeassistant/components/heos/media_player.py b/homeassistant/components/heos/media_player.py index 65314439c18..294da492e31 100644 --- a/homeassistant/components/heos/media_player.py +++ b/homeassistant/components/heos/media_player.py @@ -479,6 +479,13 @@ class HeosMediaPlayer(CoordinatorEntity[HeosCoordinator], MediaPlayerEntity): """Remove items from the queue.""" await self._player.remove_from_queue(queue_ids) + @catch_action_error("move queue item") + async def async_move_queue_item( + self, queue_ids: list[int], destination_position: int + ) -> None: + """Move items in the queue.""" + await self._player.move_queue_item(queue_ids, destination_position) + @property def available(self) -> bool: """Return True if the device is available.""" diff --git a/homeassistant/components/heos/services.py b/homeassistant/components/heos/services.py index fe8c887691c..86c6f6d0533 100644 --- a/homeassistant/components/heos/services.py +++ b/homeassistant/components/heos/services.py @@ -19,6 +19,7 @@ from homeassistant.helpers import ( from homeassistant.helpers.typing import VolDictType, VolSchemaType from .const import ( + ATTR_DESTINATION_POSITION, ATTR_PASSWORD, ATTR_QUEUE_IDS, ATTR_USERNAME, @@ -27,6 +28,7 @@ from .const import ( SERVICE_GROUP_VOLUME_DOWN, SERVICE_GROUP_VOLUME_SET, SERVICE_GROUP_VOLUME_UP, + SERVICE_MOVE_QUEUE_ITEM, SERVICE_REMOVE_FROM_QUEUE, SERVICE_SIGN_IN, SERVICE_SIGN_OUT, @@ -87,6 +89,16 @@ REMOVE_FROM_QUEUE_SCHEMA: Final[VolDictType] = { GROUP_VOLUME_SET_SCHEMA: Final[VolDictType] = { vol.Required(ATTR_MEDIA_VOLUME_LEVEL): cv.small_float } +MOVE_QEUEUE_ITEM_SCHEMA: Final[VolDictType] = { + vol.Required(ATTR_QUEUE_IDS): vol.All( + cv.ensure_list, + [vol.All(vol.Coerce(int), vol.Range(min=1, max=1000))], + vol.Unique(), + ), + vol.Required(ATTR_DESTINATION_POSITION): vol.All( + vol.Coerce(int), vol.Range(min=1, max=1000) + ), +} MEDIA_PLAYER_ENTITY_SERVICES: Final = ( # Player queue services @@ -96,6 +108,9 @@ MEDIA_PLAYER_ENTITY_SERVICES: Final = ( EntityServiceDescription( SERVICE_REMOVE_FROM_QUEUE, "async_remove_from_queue", REMOVE_FROM_QUEUE_SCHEMA ), + EntityServiceDescription( + SERVICE_MOVE_QUEUE_ITEM, "async_move_queue_item", MOVE_QEUEUE_ITEM_SCHEMA + ), # Group volume services EntityServiceDescription( SERVICE_GROUP_VOLUME_SET, diff --git a/homeassistant/components/heos/services.yaml b/homeassistant/components/heos/services.yaml index fd74b2f90c4..333a15940bc 100644 --- a/homeassistant/components/heos/services.yaml +++ b/homeassistant/components/heos/services.yaml @@ -17,6 +17,26 @@ remove_from_queue: multiple: true type: number +move_queue_item: + target: + entity: + integration: heos + domain: media_player + fields: + queue_ids: + required: true + selector: + text: + multiple: true + type: number + destination_position: + required: true + selector: + number: + min: 1 + max: 1000 + step: 1 + group_volume_set: target: entity: diff --git a/homeassistant/components/heos/strings.json b/homeassistant/components/heos/strings.json index 982d15a06fa..c99d73a70d7 100644 --- a/homeassistant/components/heos/strings.json +++ b/homeassistant/components/heos/strings.json @@ -100,6 +100,20 @@ } } }, + "move_queue_item": { + "name": "Move queue item", + "description": "Move one or more items within the play queue.", + "fields": { + "queue_ids": { + "name": "Queue IDs", + "description": "The IDs (indexes) of the items in the queue to move." + }, + "destination_position": { + "name": "Destination position", + "description": "The position index in the queue to move the items to." + } + } + }, "group_volume_down": { "name": "Turn down group volume", "description": "Turns down the group volume." diff --git a/tests/components/heos/__init__.py b/tests/components/heos/__init__.py index cdf93c202f0..edc128f2f78 100644 --- a/tests/components/heos/__init__.py +++ b/tests/components/heos/__init__.py @@ -39,6 +39,7 @@ class MockHeos(Heos): self.player_clear_queue: AsyncMock = AsyncMock() self.player_get_queue: AsyncMock = AsyncMock() self.player_get_quick_selects: AsyncMock = AsyncMock() + self.player_move_queue_item: AsyncMock = AsyncMock() self.player_play_next: AsyncMock = AsyncMock() self.player_play_previous: AsyncMock = AsyncMock() self.player_play_queue: AsyncMock = AsyncMock() diff --git a/tests/components/heos/test_media_player.py b/tests/components/heos/test_media_player.py index 085a42337b3..30d17f4a8ca 100644 --- a/tests/components/heos/test_media_player.py +++ b/tests/components/heos/test_media_player.py @@ -27,12 +27,14 @@ from syrupy.assertion import SnapshotAssertion from syrupy.filters import props from homeassistant.components.heos.const import ( + ATTR_DESTINATION_POSITION, ATTR_QUEUE_IDS, DOMAIN, SERVICE_GET_QUEUE, SERVICE_GROUP_VOLUME_DOWN, SERVICE_GROUP_VOLUME_SET, SERVICE_GROUP_VOLUME_UP, + SERVICE_MOVE_QUEUE_ITEM, SERVICE_REMOVE_FROM_QUEUE, ) from homeassistant.components.media_player import ( @@ -1784,3 +1786,45 @@ async def test_remove_from_queue( blocking=True, ) controller.player_remove_from_queue.assert_called_once_with(1, [1, 2]) + + +async def test_move_queue_item_queue( + hass: HomeAssistant, config_entry: MockConfigEntry, controller: MockHeos +) -> None: + """Test the move queue service.""" + config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.services.async_call( + DOMAIN, + SERVICE_MOVE_QUEUE_ITEM, + { + ATTR_ENTITY_ID: "media_player.test_player", + ATTR_QUEUE_IDS: [1, "2"], + ATTR_DESTINATION_POSITION: 10, + }, + blocking=True, + ) + controller.player_move_queue_item.assert_called_once_with(1, [1, 2], 10) + + +async def test_move_queue_item_queue_error_raises( + hass: HomeAssistant, config_entry: MockConfigEntry, controller: MockHeos +) -> None: + """Test move queue raises error when failed.""" + config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(config_entry.entry_id) + controller.player_move_queue_item.side_effect = HeosError("error") + with pytest.raises( + HomeAssistantError, + match=re.escape("Unable to move queue item: error"), + ): + await hass.services.async_call( + DOMAIN, + SERVICE_MOVE_QUEUE_ITEM, + { + ATTR_ENTITY_ID: "media_player.test_player", + ATTR_QUEUE_IDS: [1, "2"], + ATTR_DESTINATION_POSITION: 10, + }, + blocking=True, + ) From ae0f27c42f863844ec77e1749c1b2d0ef5dac03a Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Sat, 5 Apr 2025 22:38:11 +0200 Subject: [PATCH 310/505] Limit mqtt info logging for discovery of new components (#142344) * Limit mqtt info logging for discovery of new component * Keep in bail out, when debug logging is not enabled --- homeassistant/components/mqtt/discovery.py | 16 ++++++---------- 1 file changed, 6 insertions(+), 10 deletions(-) diff --git a/homeassistant/components/mqtt/discovery.py b/homeassistant/components/mqtt/discovery.py index a14240ce008..a527e712615 100644 --- a/homeassistant/components/mqtt/discovery.py +++ b/homeassistant/components/mqtt/discovery.py @@ -154,18 +154,14 @@ def get_origin_support_url(discovery_payload: MQTTDiscoveryPayload) -> str | Non @callback def async_log_discovery_origin_info( - message: str, discovery_payload: MQTTDiscoveryPayload, level: int = logging.INFO + message: str, discovery_payload: MQTTDiscoveryPayload ) -> None: """Log information about the discovery and origin.""" - # We only log origin info once per device discovery - if not _LOGGER.isEnabledFor(level): - # bail out early if logging is disabled + if not _LOGGER.isEnabledFor(logging.DEBUG): + # bail out early if debug logging is disabled return - _LOGGER.log( - level, - "%s%s", - message, - get_origin_log_string(discovery_payload, include_url=True), + _LOGGER.debug( + "%s%s", message, get_origin_log_string(discovery_payload, include_url=True) ) @@ -562,7 +558,7 @@ async def async_start( # noqa: C901 elif already_discovered: # Dispatch update message = f"Component has already been discovered: {component} {discovery_id}, sending update" - async_log_discovery_origin_info(message, payload, logging.DEBUG) + async_log_discovery_origin_info(message, payload) async_dispatcher_send( hass, MQTT_DISCOVERY_UPDATED.format(*discovery_hash), payload ) From 6da37691ff641937551f6bd7d7075e2c28fcbe1f Mon Sep 17 00:00:00 2001 From: Arie Catsman <120491684+catsmanac@users.noreply.github.com> Date: Sat, 5 Apr 2025 22:51:22 +0200 Subject: [PATCH 311/505] Improve enphase_envoy diagnostics error handling to retain collected data (#142255) Improve enphase_envoy Diagnostics error handling to retain collected data --- .../components/enphase_envoy/diagnostics.py | 28 ++++----- .../snapshots/test_diagnostics.ambr | 61 ++++++++++++++++++- 2 files changed, 74 insertions(+), 15 deletions(-) diff --git a/homeassistant/components/enphase_envoy/diagnostics.py b/homeassistant/components/enphase_envoy/diagnostics.py index d5b3880cf24..80eed76574f 100644 --- a/homeassistant/components/enphase_envoy/diagnostics.py +++ b/homeassistant/components/enphase_envoy/diagnostics.py @@ -66,16 +66,19 @@ async def _get_fixture_collection(envoy: Envoy, serial: str) -> dict[str, Any]: ] for end_point in end_points: - response = await envoy.request(end_point) - fixture_data[end_point] = response.text.replace("\n", "").replace( - serial, CLEAN_TEXT - ) - fixture_data[f"{end_point}_log"] = json_dumps( - { - "headers": dict(response.headers.items()), - "code": response.status_code, - } - ) + try: + response = await envoy.request(end_point) + fixture_data[end_point] = response.text.replace("\n", "").replace( + serial, CLEAN_TEXT + ) + fixture_data[f"{end_point}_log"] = json_dumps( + { + "headers": dict(response.headers.items()), + "code": response.status_code, + } + ) + except EnvoyError as err: + fixture_data[f"{end_point}_log"] = {"Error": repr(err)} return fixture_data @@ -160,10 +163,7 @@ async def async_get_config_entry_diagnostics( fixture_data: dict[str, Any] = {} if entry.options.get(OPTION_DIAGNOSTICS_INCLUDE_FIXTURES, False): - try: - fixture_data = await _get_fixture_collection(envoy=envoy, serial=old_serial) - except EnvoyError as err: - fixture_data["Error"] = repr(err) + fixture_data = await _get_fixture_collection(envoy=envoy, serial=old_serial) diagnostic_data: dict[str, Any] = { "config_entry": async_redact_data(entry.as_dict(), TO_REDACT), diff --git a/tests/components/enphase_envoy/snapshots/test_diagnostics.ambr b/tests/components/enphase_envoy/snapshots/test_diagnostics.ambr index 152cf803258..69ef4ecaead 100644 --- a/tests/components/enphase_envoy/snapshots/test_diagnostics.ambr +++ b/tests/components/enphase_envoy/snapshots/test_diagnostics.ambr @@ -1373,7 +1373,66 @@ ]), }), 'fixtures': dict({ - 'Error': "EnvoyError('Test')", + '/admin/lib/tariff_log': dict({ + 'Error': "EnvoyError('Test')", + }), + '/api/v1/production/inverters_log': dict({ + 'Error': "EnvoyError('Test')", + }), + '/api/v1/production_log': dict({ + 'Error': "EnvoyError('Test')", + }), + '/info_log': dict({ + 'Error': "EnvoyError('Test')", + }), + '/ivp/ensemble/dry_contacts_log': dict({ + 'Error': "EnvoyError('Test')", + }), + '/ivp/ensemble/generator_log': dict({ + 'Error': "EnvoyError('Test')", + }), + '/ivp/ensemble/inventory_log': dict({ + 'Error': "EnvoyError('Test')", + }), + '/ivp/ensemble/power_log': dict({ + 'Error': "EnvoyError('Test')", + }), + '/ivp/ensemble/secctrl_log': dict({ + 'Error': "EnvoyError('Test')", + }), + '/ivp/ensemble/status_log': dict({ + 'Error': "EnvoyError('Test')", + }), + '/ivp/meters/readings_log': dict({ + 'Error': "EnvoyError('Test')", + }), + '/ivp/meters_log': dict({ + 'Error': "EnvoyError('Test')", + }), + '/ivp/sc/pvlimit_log': dict({ + 'Error': "EnvoyError('Test')", + }), + '/ivp/ss/dry_contact_settings_log': dict({ + 'Error': "EnvoyError('Test')", + }), + '/ivp/ss/gen_config_log': dict({ + 'Error': "EnvoyError('Test')", + }), + '/ivp/ss/gen_schedule_log': dict({ + 'Error': "EnvoyError('Test')", + }), + '/ivp/ss/pel_settings_log': dict({ + 'Error': "EnvoyError('Test')", + }), + '/production.json?details=1_log': dict({ + 'Error': "EnvoyError('Test')", + }), + '/production.json_log': dict({ + 'Error': "EnvoyError('Test')", + }), + '/production_log': dict({ + 'Error': "EnvoyError('Test')", + }), }), 'raw_data': dict({ 'varies_by': 'firmware_version', From 33cbebc7279e7092976017e15dc8c9383d68de49 Mon Sep 17 00:00:00 2001 From: Robert Resch Date: Sat, 5 Apr 2025 22:51:43 +0200 Subject: [PATCH 312/505] Add some Xiaomi BLE sensor translations (#142109) --- homeassistant/components/xiaomi_ble/sensor.py | 31 ++++++++++++++----- .../components/xiaomi_ble/strings.json | 8 +++++ tests/components/xiaomi_ble/test_sensor.py | 27 ++++++++-------- 3 files changed, 46 insertions(+), 20 deletions(-) diff --git a/homeassistant/components/xiaomi_ble/sensor.py b/homeassistant/components/xiaomi_ble/sensor.py index 01f15ff09b8..57dfaead232 100644 --- a/homeassistant/components/xiaomi_ble/sensor.py +++ b/homeassistant/components/xiaomi_ble/sensor.py @@ -9,9 +9,11 @@ from xiaomi_ble.parser import ExtendedSensorDeviceClass from homeassistant.components.bluetooth.passive_update_processor import ( PassiveBluetoothDataUpdate, + PassiveBluetoothEntityKey, PassiveBluetoothProcessorEntity, ) from homeassistant.components.sensor import ( + EntityDescription, SensorDeviceClass, SensorEntity, SensorEntityDescription, @@ -78,6 +80,7 @@ SENSOR_DESCRIPTIONS = { icon="mdi:omega", native_unit_of_measurement=Units.OHM, state_class=SensorStateClass.MEASUREMENT, + translation_key="impedance", ), # Mass sensor (kg) (DeviceClass.MASS, Units.MASS_KILOGRAMS): SensorEntityDescription( @@ -93,6 +96,7 @@ SENSOR_DESCRIPTIONS = { native_unit_of_measurement=UnitOfMass.KILOGRAMS, state_class=SensorStateClass.MEASUREMENT, entity_category=EntityCategory.DIAGNOSTIC, + translation_key="weight_non_stabilized", ), (DeviceClass.MOISTURE, Units.PERCENTAGE): SensorEntityDescription( key=f"{DeviceClass.MOISTURE}_{Units.PERCENTAGE}", @@ -180,18 +184,20 @@ def sensor_update_to_bluetooth_data_update( sensor_update: SensorUpdate, ) -> PassiveBluetoothDataUpdate[float | None]: """Convert a sensor update to a bluetooth data update.""" + entity_descriptions: dict[PassiveBluetoothEntityKey, EntityDescription] = { + device_key_to_bluetooth_entity_key(device_key): SENSOR_DESCRIPTIONS[ + (description.device_class, description.native_unit_of_measurement) + ] + for device_key, description in sensor_update.entity_descriptions.items() + if description.device_class + } + return PassiveBluetoothDataUpdate( devices={ device_id: sensor_device_info_to_hass_device_info(device_info) for device_id, device_info in sensor_update.devices.items() }, - entity_descriptions={ - device_key_to_bluetooth_entity_key(device_key): SENSOR_DESCRIPTIONS[ - (description.device_class, description.native_unit_of_measurement) - ] - for device_key, description in sensor_update.entity_descriptions.items() - if description.device_class - }, + entity_descriptions=entity_descriptions, entity_data={ device_key_to_bluetooth_entity_key(device_key): cast( float | None, sensor_values.native_value @@ -201,6 +207,17 @@ def sensor_update_to_bluetooth_data_update( entity_names={ device_key_to_bluetooth_entity_key(device_key): sensor_values.name for device_key, sensor_values in sensor_update.entity_values.items() + # Add names where the entity description has neither a translation_key nor + # a device_class + if ( + description := entity_descriptions.get( + device_key_to_bluetooth_entity_key(device_key) + ) + ) + is None + or ( + description.translation_key is None and description.device_class is None + ) }, ) diff --git a/homeassistant/components/xiaomi_ble/strings.json b/homeassistant/components/xiaomi_ble/strings.json index 4ea4a47c61e..cdee3fc3838 100644 --- a/homeassistant/components/xiaomi_ble/strings.json +++ b/homeassistant/components/xiaomi_ble/strings.json @@ -227,6 +227,14 @@ } } } + }, + "sensor": { + "impedance": { + "name": "Impedance" + }, + "weight_non_stabilized": { + "name": "Weight non stabilized" + } } } } diff --git a/tests/components/xiaomi_ble/test_sensor.py b/tests/components/xiaomi_ble/test_sensor.py index 11a20a62d02..f5625d4e74d 100644 --- a/tests/components/xiaomi_ble/test_sensor.py +++ b/tests/components/xiaomi_ble/test_sensor.py @@ -694,21 +694,21 @@ async def test_miscale_v1_uuid(hass: HomeAssistant) -> None: assert len(hass.states.async_all()) == 2 mass_non_stabilized_sensor = hass.states.get( - "sensor.mi_smart_scale_b5dc_mass_non_stabilized" + "sensor.mi_smart_scale_b5dc_weight_non_stabilized" ) mass_non_stabilized_sensor_attr = mass_non_stabilized_sensor.attributes assert mass_non_stabilized_sensor.state == "86.55" assert ( mass_non_stabilized_sensor_attr[ATTR_FRIENDLY_NAME] - == "Mi Smart Scale (B5DC) Mass Non Stabilized" + == "Mi Smart Scale (B5DC) Weight non stabilized" ) assert mass_non_stabilized_sensor_attr[ATTR_UNIT_OF_MEASUREMENT] == "kg" assert mass_non_stabilized_sensor_attr[ATTR_STATE_CLASS] == "measurement" - mass_sensor = hass.states.get("sensor.mi_smart_scale_b5dc_mass") + mass_sensor = hass.states.get("sensor.mi_smart_scale_b5dc_weight") mass_sensor_attr = mass_sensor.attributes assert mass_sensor.state == "86.55" - assert mass_sensor_attr[ATTR_FRIENDLY_NAME] == "Mi Smart Scale (B5DC) Mass" + assert mass_sensor_attr[ATTR_FRIENDLY_NAME] == "Mi Smart Scale (B5DC) Weight" assert mass_sensor_attr[ATTR_UNIT_OF_MEASUREMENT] == "kg" assert mass_sensor_attr[ATTR_STATE_CLASS] == "measurement" @@ -736,22 +736,23 @@ async def test_miscale_v2_uuid(hass: HomeAssistant) -> None: assert len(hass.states.async_all()) == 3 mass_non_stabilized_sensor = hass.states.get( - "sensor.mi_body_composition_scale_b5dc_mass_non_stabilized" + "sensor.mi_body_composition_scale_b5dc_weight_non_stabilized" ) mass_non_stabilized_sensor_attr = mass_non_stabilized_sensor.attributes assert mass_non_stabilized_sensor.state == "85.15" assert ( mass_non_stabilized_sensor_attr[ATTR_FRIENDLY_NAME] - == "Mi Body Composition Scale (B5DC) Mass Non Stabilized" + == "Mi Body Composition Scale (B5DC) Weight non stabilized" ) assert mass_non_stabilized_sensor_attr[ATTR_UNIT_OF_MEASUREMENT] == "kg" assert mass_non_stabilized_sensor_attr[ATTR_STATE_CLASS] == "measurement" - mass_sensor = hass.states.get("sensor.mi_body_composition_scale_b5dc_mass") + mass_sensor = hass.states.get("sensor.mi_body_composition_scale_b5dc_weight") mass_sensor_attr = mass_sensor.attributes assert mass_sensor.state == "85.15" assert ( - mass_sensor_attr[ATTR_FRIENDLY_NAME] == "Mi Body Composition Scale (B5DC) Mass" + mass_sensor_attr[ATTR_FRIENDLY_NAME] + == "Mi Body Composition Scale (B5DC) Weight" ) assert mass_sensor_attr[ATTR_UNIT_OF_MEASUREMENT] == "kg" assert mass_sensor_attr[ATTR_STATE_CLASS] == "measurement" @@ -845,7 +846,7 @@ async def test_sleepy_device(hass: HomeAssistant) -> None: assert len(hass.states.async_all()) == 2 mass_non_stabilized_sensor = hass.states.get( - "sensor.mi_smart_scale_b5dc_mass_non_stabilized" + "sensor.mi_smart_scale_b5dc_weight_non_stabilized" ) assert mass_non_stabilized_sensor.state == "86.55" @@ -866,7 +867,7 @@ async def test_sleepy_device(hass: HomeAssistant) -> None: await hass.async_block_till_done() mass_non_stabilized_sensor = hass.states.get( - "sensor.mi_smart_scale_b5dc_mass_non_stabilized" + "sensor.mi_smart_scale_b5dc_weight_non_stabilized" ) # Sleepy devices should keep their state over time @@ -896,7 +897,7 @@ async def test_sleepy_device_restore_state(hass: HomeAssistant) -> None: assert len(hass.states.async_all()) == 2 mass_non_stabilized_sensor = hass.states.get( - "sensor.mi_smart_scale_b5dc_mass_non_stabilized" + "sensor.mi_smart_scale_b5dc_weight_non_stabilized" ) assert mass_non_stabilized_sensor.state == "86.55" @@ -917,7 +918,7 @@ async def test_sleepy_device_restore_state(hass: HomeAssistant) -> None: await hass.async_block_till_done() mass_non_stabilized_sensor = hass.states.get( - "sensor.mi_smart_scale_b5dc_mass_non_stabilized" + "sensor.mi_smart_scale_b5dc_weight_non_stabilized" ) # Sleepy devices should keep their state over time @@ -930,7 +931,7 @@ async def test_sleepy_device_restore_state(hass: HomeAssistant) -> None: await hass.async_block_till_done() mass_non_stabilized_sensor = hass.states.get( - "sensor.mi_smart_scale_b5dc_mass_non_stabilized" + "sensor.mi_smart_scale_b5dc_weight_non_stabilized" ) # Sleepy devices should keep their state over time and restore it From cd7d7cd35c8b1c3dae1fdbca79c533e6eaa4608a Mon Sep 17 00:00:00 2001 From: tdfountain <174762217+tdfountain@users.noreply.github.com> Date: Sat, 5 Apr 2025 14:02:46 -0700 Subject: [PATCH 313/505] Add reconfiguration flow to NUT (#142127) * Add reconfiguration flow * Check host/port/alias without comparing strings * Replace repeat strings with references --- homeassistant/components/nut/config_flow.py | 138 +++- homeassistant/components/nut/strings.json | 25 +- tests/components/nut/test_config_flow.py | 778 +++++++++++++++++++- tests/components/nut/util.py | 27 +- 4 files changed, 948 insertions(+), 20 deletions(-) diff --git a/homeassistant/components/nut/config_flow.py b/homeassistant/components/nut/config_flow.py index 5996c1c0087..a69d898ff6c 100644 --- a/homeassistant/components/nut/config_flow.py +++ b/homeassistant/components/nut/config_flow.py @@ -4,6 +4,7 @@ from __future__ import annotations from collections.abc import Mapping import logging +from types import MappingProxyType from typing import Any from aionut import NUTError, NUTLoginError @@ -27,16 +28,26 @@ from .const import DEFAULT_HOST, DEFAULT_PORT, DOMAIN _LOGGER = logging.getLogger(__name__) -AUTH_SCHEMA = {vol.Optional(CONF_USERNAME): str, vol.Optional(CONF_PASSWORD): str} +REAUTH_SCHEMA = {vol.Optional(CONF_USERNAME): str, vol.Optional(CONF_PASSWORD): str} + +PASSWORD_NOT_CHANGED = "__**password_not_changed**__" -def _base_schema(nut_config: dict[str, Any]) -> vol.Schema: +def _base_schema( + nut_config: dict[str, Any] | MappingProxyType[str, Any], + use_password_not_changed: bool = False, +) -> vol.Schema: """Generate base schema.""" base_schema = { vol.Optional(CONF_HOST, default=nut_config.get(CONF_HOST) or DEFAULT_HOST): str, vol.Optional(CONF_PORT, default=nut_config.get(CONF_PORT) or DEFAULT_PORT): int, + vol.Optional(CONF_USERNAME, default=nut_config.get(CONF_USERNAME) or ""): str, + vol.Optional( + CONF_PASSWORD, + default=PASSWORD_NOT_CHANGED if use_password_not_changed else "", + ): str, } - base_schema.update(AUTH_SCHEMA) + return vol.Schema(base_schema) @@ -66,6 +77,26 @@ async def validate_input(hass: HomeAssistant, data: dict[str, Any]) -> dict[str, return {"ups_list": nut_data.ups_list, "available_resources": status} +def _check_host_port_alias_match( + first: Mapping[str, Any], second: Mapping[str, Any] +) -> bool: + """Check if first and second have the same host, port and alias.""" + + if first[CONF_HOST] != second[CONF_HOST] or first[CONF_PORT] != second[CONF_PORT]: + return False + + first_alias = first.get(CONF_ALIAS) + second_alias = second.get(CONF_ALIAS) + if (first_alias is None and second_alias is None) or ( + first_alias is not None + and second_alias is not None + and first_alias == second_alias + ): + return True + + return False + + def _format_host_port_alias(user_input: Mapping[str, Any]) -> str: """Format a host, port, and alias so it can be used for comparison or display.""" host = user_input[CONF_HOST] @@ -137,7 +168,7 @@ class NutConfigFlow(ConfigFlow, domain=DOMAIN): async def async_step_ups( self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: - """Handle the picking the ups.""" + """Handle selecting the NUT device alias.""" errors: dict[str, str] = {} placeholders: dict[str, str] = {} nut_config = self.nut_config @@ -163,6 +194,99 @@ class NutConfigFlow(ConfigFlow, domain=DOMAIN): description_placeholders=placeholders, ) + async def async_step_reconfigure( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle reconfiguration of the integration.""" + + errors: dict[str, str] = {} + placeholders: dict[str, str] = {} + reconfigure_entry = self._get_reconfigure_entry() + nut_config = self.nut_config + + if user_input is not None: + nut_config.update(user_input) + + info, errors, placeholders = await self._async_validate_or_error(nut_config) + + if not errors: + if len(info["ups_list"]) > 1: + self.ups_list = info["ups_list"] + return await self.async_step_reconfigure_ups() + + if not _check_host_port_alias_match( + reconfigure_entry.data, + nut_config, + ) and (self._host_port_alias_already_configured(nut_config)): + return self.async_abort(reason="already_configured") + + if unique_id := _unique_id_from_status(info["available_resources"]): + await self.async_set_unique_id(unique_id) + self._abort_if_unique_id_mismatch(reason="unique_id_mismatch") + if nut_config[CONF_PASSWORD] == PASSWORD_NOT_CHANGED: + nut_config.pop(CONF_PASSWORD) + + new_title = _format_host_port_alias(nut_config) + return self.async_update_reload_and_abort( + self._get_reconfigure_entry(), + unique_id=unique_id, + title=new_title, + data_updates=nut_config, + ) + + return self.async_show_form( + step_id="reconfigure", + data_schema=_base_schema( + reconfigure_entry.data, + use_password_not_changed=True, + ), + errors=errors, + description_placeholders=placeholders, + ) + + async def async_step_reconfigure_ups( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle selecting the NUT device alias.""" + + errors: dict[str, str] = {} + placeholders: dict[str, str] = {} + reconfigure_entry = self._get_reconfigure_entry() + nut_config = self.nut_config + + if user_input is not None: + self.nut_config.update(user_input) + + if not _check_host_port_alias_match( + reconfigure_entry.data, + nut_config, + ) and (self._host_port_alias_already_configured(nut_config)): + return self.async_abort(reason="already_configured") + + info, errors, placeholders = await self._async_validate_or_error(nut_config) + if not errors: + if unique_id := _unique_id_from_status(info["available_resources"]): + await self.async_set_unique_id(unique_id) + self._abort_if_unique_id_mismatch(reason="unique_id_mismatch") + + if nut_config[CONF_PASSWORD] == PASSWORD_NOT_CHANGED: + nut_config.pop(CONF_PASSWORD) + + new_title = _format_host_port_alias(nut_config) + return self.async_update_reload_and_abort( + self._get_reconfigure_entry(), + unique_id=unique_id, + title=new_title, + data_updates=nut_config, + ) + + return self.async_show_form( + step_id="reconfigure_ups", + data_schema=_ups_schema(self.ups_list or {}), + errors=errors, + description_placeholders=placeholders, + ) + def _host_port_alias_already_configured(self, user_input: dict[str, Any]) -> bool: """See if we already have a nut entry matching user input configured.""" existing_host_port_aliases = { @@ -204,6 +328,7 @@ class NutConfigFlow(ConfigFlow, domain=DOMAIN): self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: """Handle reauth input.""" + errors: dict[str, str] = {} existing_entry = self.reauth_entry assert existing_entry @@ -212,6 +337,7 @@ class NutConfigFlow(ConfigFlow, domain=DOMAIN): CONF_HOST: existing_data[CONF_HOST], CONF_PORT: existing_data[CONF_PORT], } + if user_input is not None: new_config = { **existing_data, @@ -229,8 +355,8 @@ class NutConfigFlow(ConfigFlow, domain=DOMAIN): description_placeholders.update(placeholders) return self.async_show_form( - description_placeholders=description_placeholders, step_id="reauth_confirm", - data_schema=vol.Schema(AUTH_SCHEMA), + data_schema=vol.Schema(REAUTH_SCHEMA), errors=errors, + description_placeholders=description_placeholders, ) diff --git a/homeassistant/components/nut/strings.json b/homeassistant/components/nut/strings.json index fe06bef3903..1e6cee786d3 100644 --- a/homeassistant/components/nut/strings.json +++ b/homeassistant/components/nut/strings.json @@ -28,6 +28,27 @@ "username": "[%key:common::config_flow::data::username%]", "password": "[%key:common::config_flow::data::password%]" } + }, + "reconfigure": { + "description": "[%key:component::nut::config::step::user::description%]", + "data": { + "host": "[%key:common::config_flow::data::host%]", + "port": "[%key:common::config_flow::data::port%]", + "username": "[%key:common::config_flow::data::username%]", + "password": "[%key:common::config_flow::data::password%]" + }, + "data_description": { + "host": "[%key:component::nut::config::step::user::data_description::host%]", + "port": "[%key:component::nut::config::step::user::data_description::port%]", + "username": "[%key:component::nut::config::step::user::data_description::username%]", + "password": "[%key:component::nut::config::step::user::data_description::password%]" + } + }, + "reconfigure_ups": { + "title": "[%key:component::nut::config::step::ups::title%]", + "data": { + "alias": "[%key:component::nut::config::step::ups::data::alias%]" + } } }, "error": { @@ -38,7 +59,9 @@ "abort": { "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", "no_ups_found": "There are no UPS devices available on the NUT server.", - "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]" + "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]", + "reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]", + "unique_id_mismatch": "The device's manufacturer, model and serial number identifier does not match the previous identifier." } }, "device_automation": { diff --git a/tests/components/nut/test_config_flow.py b/tests/components/nut/test_config_flow.py index 6237ad341b4..c0e7f9ffeff 100644 --- a/tests/components/nut/test_config_flow.py +++ b/tests/components/nut/test_config_flow.py @@ -6,6 +6,7 @@ from unittest.mock import patch from aionut import NUTError, NUTLoginError from homeassistant import config_entries, setup +from homeassistant.components.nut.config_flow import PASSWORD_NOT_CHANGED from homeassistant.components.nut.const import DOMAIN from homeassistant.const import ( CONF_ALIAS, @@ -83,8 +84,8 @@ async def test_form_zeroconf(hass: HomeAssistant) -> None: assert len(mock_setup_entry.mock_calls) == 1 -async def test_form_user_one_ups(hass: HomeAssistant) -> None: - """Test we get the form.""" +async def test_form_user_one_alias(hass: HomeAssistant) -> None: + """Test we can configure a device with one alias.""" await setup.async_setup_component(hass, "persistent_notification", {}) result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} @@ -128,8 +129,8 @@ async def test_form_user_one_ups(hass: HomeAssistant) -> None: assert len(mock_setup_entry.mock_calls) == 1 -async def test_form_user_multiple_ups(hass: HomeAssistant) -> None: - """Test we get the form.""" +async def test_form_user_multiple_aliases(hass: HomeAssistant) -> None: + """Test we can configure device with multiple aliases.""" await setup.async_setup_component(hass, "persistent_notification", {}) config_entry = MockConfigEntry( @@ -194,7 +195,7 @@ async def test_form_user_multiple_ups(hass: HomeAssistant) -> None: assert len(mock_setup_entry.mock_calls) == 2 -async def test_form_user_one_ups_with_ignored_entry(hass: HomeAssistant) -> None: +async def test_form_user_one_alias_with_ignored_entry(hass: HomeAssistant) -> None: """Test we can setup a new one when there is an ignored one.""" ignored_entry = MockConfigEntry( domain=DOMAIN, data={}, source=config_entries.SOURCE_IGNORE @@ -244,8 +245,8 @@ async def test_form_user_one_ups_with_ignored_entry(hass: HomeAssistant) -> None assert len(mock_setup_entry.mock_calls) == 1 -async def test_form_no_upses_found(hass: HomeAssistant) -> None: - """Test we abort when the NUT server has not UPSes.""" +async def test_form_no_aliases_found(hass: HomeAssistant) -> None: + """Test we abort when the NUT server has no aliases.""" result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} ) @@ -561,8 +562,8 @@ async def test_abort_duplicate_unique_ids(hass: HomeAssistant) -> None: assert result2["reason"] == "already_configured" -async def test_abort_multiple_ups_duplicate_unique_ids(hass: HomeAssistant) -> None: - """Test we abort on multiple devices if unique_id is already setup.""" +async def test_abort_multiple_aliases_duplicate_unique_ids(hass: HomeAssistant) -> None: + """Test we abort on multiple aliases if unique_id is already setup.""" list_vars = { "device.mfr": "Some manufacturer", @@ -670,3 +671,762 @@ async def test_abort_if_already_setup_alias(hass: HomeAssistant) -> None: assert result3["type"] is FlowResultType.ABORT assert result3["reason"] == "already_configured" + + +async def test_reconfigure_one_alias_successful(hass: HomeAssistant) -> None: + """Test reconfigure one alias successful.""" + entry = await async_init_integration( + hass, + host="1.1.1.1", + port=123, + username="test-username", + password="test-password", + list_ups={"ups1": "UPS 1"}, + list_vars={"battery.voltage": "voltage"}, + ) + + result = await entry.start_reconfigure_flow(hass) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "reconfigure" + + mock_pynut = _get_mock_nutclient( + list_vars={"battery.voltage": "voltage"}, + list_ups={"ups1": "UPS 1"}, + ) + + with patch( + "homeassistant.components.nut.AIONUTClient", + return_value=mock_pynut, + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_HOST: "2.2.2.2", + CONF_PORT: 456, + CONF_USERNAME: "test-new-username", + CONF_PASSWORD: "test-new-password", + }, + ) + + assert result2["type"] is FlowResultType.ABORT + assert result2["reason"] == "reconfigure_successful" + + assert entry.data[CONF_HOST] == "2.2.2.2" + assert entry.data[CONF_PORT] == 456 + assert entry.data[CONF_USERNAME] == "test-new-username" + assert entry.data[CONF_PASSWORD] == "test-new-password" + + +async def test_reconfigure_one_alias_nochange(hass: HomeAssistant) -> None: + """Test reconfigure one alias when there is no change.""" + entry = await async_init_integration( + hass, + host="1.1.1.1", + port=123, + username="test-username", + password="test-password", + list_ups={"ups1": "UPS 1"}, + list_vars={"battery.voltage": "voltage"}, + ) + + result = await entry.start_reconfigure_flow(hass) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "reconfigure" + + mock_pynut = _get_mock_nutclient( + list_ups={"ups1": "UPS 1"}, + list_vars={"battery.voltage": "voltage"}, + ) + + with patch( + "homeassistant.components.nut.AIONUTClient", + return_value=mock_pynut, + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_HOST: entry.data[CONF_HOST], + CONF_PORT: int(entry.data[CONF_PORT]), + CONF_USERNAME: entry.data[CONF_USERNAME], + CONF_PASSWORD: entry.data[CONF_PASSWORD], + }, + ) + + assert result2["type"] is FlowResultType.ABORT + assert result2["reason"] == "reconfigure_successful" + + assert entry.data[CONF_HOST] == "1.1.1.1" + assert entry.data[CONF_PORT] == 123 + assert entry.data[CONF_USERNAME] == "test-username" + assert entry.data[CONF_PASSWORD] == "test-password" + + +async def test_reconfigure_one_alias_password_nochange(hass: HomeAssistant) -> None: + """Test reconfigure one alias when there is no password change.""" + entry = await async_init_integration( + hass, + host="1.1.1.1", + port=123, + username="test-username", + password="test-password", + list_ups={"ups1": "UPS 1"}, + list_vars={"battery.voltage": "voltage"}, + ) + + result = await entry.start_reconfigure_flow(hass) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "reconfigure" + + mock_pynut = _get_mock_nutclient( + list_vars={"battery.voltage": "voltage"}, + list_ups={"ups1": "UPS 1"}, + ) + + with patch( + "homeassistant.components.nut.AIONUTClient", + return_value=mock_pynut, + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_HOST: "2.2.2.2", + CONF_PORT: 456, + CONF_USERNAME: "test-new-username", + CONF_PASSWORD: PASSWORD_NOT_CHANGED, + }, + ) + + assert result2["type"] is FlowResultType.ABORT + assert result2["reason"] == "reconfigure_successful" + + assert entry.data[CONF_HOST] == "2.2.2.2" + assert entry.data[CONF_PORT] == 456 + assert entry.data[CONF_USERNAME] == "test-new-username" + assert entry.data[CONF_PASSWORD] == "test-password" + + +async def test_reconfigure_one_alias_already_configured(hass: HomeAssistant) -> None: + """Test reconfigure when config changed to an existing host/port/alias.""" + entry = await async_init_integration( + hass, + host="1.1.1.1", + port=123, + username="test-username", + password="test-password", + list_ups={"ups1": "UPS 1"}, + list_vars={"battery.voltage": "voltage"}, + ) + + entry2 = await async_init_integration( + hass, + host="2.2.2.2", + port=456, + username="test-username", + password="test-password", + list_ups={"ups1": "UPS 1"}, + list_vars={"battery.voltage": "voltage"}, + ) + + result = await entry2.start_reconfigure_flow(hass) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "reconfigure" + + mock_pynut = _get_mock_nutclient( + list_ups={"ups1": "UPS 1"}, + list_vars={"battery.voltage": "voltage"}, + ) + + with patch( + "homeassistant.components.nut.AIONUTClient", + return_value=mock_pynut, + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_HOST: entry.data[CONF_HOST], + CONF_PORT: int(entry.data[CONF_PORT]), + CONF_USERNAME: entry.data[CONF_USERNAME], + CONF_PASSWORD: entry.data[CONF_PASSWORD], + }, + ) + + assert result2["type"] is FlowResultType.ABORT + assert result2["reason"] == "already_configured" + + assert entry.data[CONF_HOST] == "1.1.1.1" + assert entry.data[CONF_PORT] == 123 + assert entry.data[CONF_USERNAME] == "test-username" + assert entry.data[CONF_PASSWORD] == "test-password" + + assert entry2.data[CONF_HOST] == "2.2.2.2" + assert entry2.data[CONF_PORT] == 456 + assert entry2.data[CONF_USERNAME] == "test-username" + assert entry2.data[CONF_PASSWORD] == "test-password" + + +async def test_reconfigure_one_alias_unique_id_change(hass: HomeAssistant) -> None: + """Test reconfigure when the unique ID is changed.""" + entry = await async_init_integration( + hass, + host="1.1.1.1", + port=123, + username="test-username", + password="test-password", + list_ups={"ups1": "UPS 1"}, + list_vars={ + "device.mfr": "Some manufacturer", + "device.model": "Some model", + "device.serial": "0000-1", + }, + ) + + result = await entry.start_reconfigure_flow(hass) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "reconfigure" + + mock_pynut = _get_mock_nutclient( + list_ups={"ups1": "UPS 1"}, + list_vars={ + "device.mfr": "Another manufacturer", + "device.model": "Another model", + "device.serial": "0000-2", + }, + ) + + with patch( + "homeassistant.components.nut.AIONUTClient", + return_value=mock_pynut, + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_HOST: entry.data[CONF_HOST], + CONF_PORT: entry.data[CONF_PORT], + CONF_USERNAME: entry.data[CONF_USERNAME], + CONF_PASSWORD: entry.data[CONF_PASSWORD], + }, + ) + + assert result2["type"] is FlowResultType.ABORT + assert result2["reason"] == "unique_id_mismatch" + + +async def test_reconfigure_one_alias_duplicate_unique_ids(hass: HomeAssistant) -> None: + """Test reconfigure that results in a duplicate unique ID.""" + + list_vars = { + "device.mfr": "Some manufacturer", + "device.model": "Some model", + "device.serial": "0000-1", + } + + await async_init_integration( + hass, + host="1.1.1.1", + port=123, + username="test-username", + password="test-password", + list_ups={"ups1": "UPS 1"}, + list_vars=list_vars, + ) + + entry2 = await async_init_integration( + hass, + host="2.2.2.2", + port=456, + username="test-username", + password="test-password", + list_ups={"ups2": "UPS 2"}, + list_vars={ + "device.mfr": "Another manufacturer", + "device.model": "Another model", + "device.serial": "0000-2", + }, + ) + + result = await entry2.start_reconfigure_flow(hass) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "reconfigure" + + mock_pynut = _get_mock_nutclient( + list_ups={"ups2": "UPS 2"}, + list_vars=list_vars, + ) + + with patch( + "homeassistant.components.nut.AIONUTClient", + return_value=mock_pynut, + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_HOST: "3.3.3.3", + CONF_PORT: 789, + CONF_USERNAME: "test-new-username", + CONF_PASSWORD: "test-new-password", + }, + ) + + assert result2["type"] is FlowResultType.ABORT + assert result2["reason"] == "unique_id_mismatch" + + +async def test_reconfigure_multiple_aliases_successful(hass: HomeAssistant) -> None: + """Test reconfigure with multiple aliases is successful.""" + entry = await async_init_integration( + hass, + host="1.1.1.1", + port=123, + username="test-username", + password="test-password", + list_ups={"ups1": "UPS 1"}, + list_vars={"battery.voltage": "voltage"}, + ) + + result = await entry.start_reconfigure_flow(hass) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "reconfigure" + + mock_pynut = _get_mock_nutclient( + list_ups={ + "ups1": "UPS 1", + "ups2": "UPS 2", + }, + list_vars={"battery.voltage": "voltage"}, + ) + + with patch( + "homeassistant.components.nut.AIONUTClient", + return_value=mock_pynut, + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_HOST: "2.2.2.2", + CONF_PORT: 456, + CONF_USERNAME: "test-new-username", + CONF_PASSWORD: "test-new-password", + }, + ) + + assert result2["type"] is FlowResultType.FORM + assert result2["step_id"] == "reconfigure_ups" + + with ( + patch( + "homeassistant.components.nut.AIONUTClient", + return_value=mock_pynut, + ), + patch( + "homeassistant.components.nut.async_setup_entry", + return_value=True, + ), + ): + result3 = await hass.config_entries.flow.async_configure( + result2["flow_id"], + {CONF_ALIAS: "ups2"}, + ) + await hass.async_block_till_done() + + assert result3["type"] is FlowResultType.ABORT + assert result3["reason"] == "reconfigure_successful" + + assert entry.data[CONF_HOST] == "2.2.2.2" + assert entry.data[CONF_PORT] == 456 + assert entry.data[CONF_USERNAME] == "test-new-username" + assert entry.data[CONF_PASSWORD] == "test-new-password" + assert entry.data[CONF_ALIAS] == "ups2" + + +async def test_reconfigure_multiple_aliases_nochange(hass: HomeAssistant) -> None: + """Test reconfigure with multiple aliases and no change.""" + entry = await async_init_integration( + hass, + host="1.1.1.1", + port=123, + username="test-username", + password="test-password", + list_ups={"ups1": "UPS 1"}, + list_vars={"battery.voltage": "voltage"}, + ) + + result = await entry.start_reconfigure_flow(hass) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "reconfigure" + + mock_pynut = _get_mock_nutclient( + list_ups={ + "ups1": "UPS 1", + "ups2": "UPS 2", + }, + list_vars={"battery.voltage": "voltage"}, + ) + + with patch( + "homeassistant.components.nut.AIONUTClient", + return_value=mock_pynut, + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_HOST: entry.data[CONF_HOST], + CONF_PORT: entry.data[CONF_PORT], + CONF_USERNAME: entry.data[CONF_USERNAME], + CONF_PASSWORD: entry.data[CONF_PASSWORD], + }, + ) + + assert result2["type"] is FlowResultType.FORM + assert result2["step_id"] == "reconfigure_ups" + + with ( + patch( + "homeassistant.components.nut.AIONUTClient", + return_value=mock_pynut, + ), + patch( + "homeassistant.components.nut.async_setup_entry", + return_value=True, + ), + ): + result3 = await hass.config_entries.flow.async_configure( + result2["flow_id"], + {CONF_ALIAS: "ups1"}, + ) + await hass.async_block_till_done() + + assert result3["type"] is FlowResultType.ABORT + assert result3["reason"] == "reconfigure_successful" + + assert entry.data[CONF_HOST] == "1.1.1.1" + assert entry.data[CONF_PORT] == 123 + assert entry.data[CONF_USERNAME] == "test-username" + assert entry.data[CONF_PASSWORD] == "test-password" + assert entry.data[CONF_ALIAS] == "ups1" + + +async def test_reconfigure_multiple_aliases_password_nochange( + hass: HomeAssistant, +) -> None: + """Test reconfigure with multiple aliases when no password change.""" + entry = await async_init_integration( + hass, + host="1.1.1.1", + port=123, + username="test-username", + password="test-password", + list_ups={"ups1": "UPS 1"}, + list_vars={"battery.voltage": "voltage"}, + ) + + result = await entry.start_reconfigure_flow(hass) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "reconfigure" + + mock_pynut = _get_mock_nutclient( + list_ups={ + "ups1": "UPS 1", + "ups2": "UPS 2", + }, + list_vars={"battery.voltage": "voltage"}, + ) + + with patch( + "homeassistant.components.nut.AIONUTClient", + return_value=mock_pynut, + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_HOST: "2.2.2.2", + CONF_PORT: 456, + CONF_USERNAME: "test-new-username", + CONF_PASSWORD: PASSWORD_NOT_CHANGED, + }, + ) + + assert result2["type"] is FlowResultType.FORM + assert result2["step_id"] == "reconfigure_ups" + + with ( + patch( + "homeassistant.components.nut.AIONUTClient", + return_value=mock_pynut, + ), + patch( + "homeassistant.components.nut.async_setup_entry", + return_value=True, + ), + ): + result3 = await hass.config_entries.flow.async_configure( + result2["flow_id"], + {CONF_ALIAS: "ups2"}, + ) + await hass.async_block_till_done() + + assert result3["type"] is FlowResultType.ABORT + assert result3["reason"] == "reconfigure_successful" + + assert entry.data[CONF_HOST] == "2.2.2.2" + assert entry.data[CONF_PORT] == 456 + assert entry.data[CONF_USERNAME] == "test-new-username" + assert entry.data[CONF_PASSWORD] == "test-password" + assert entry.data[CONF_ALIAS] == "ups2" + + +async def test_reconfigure_multiple_aliases_already_configured( + hass: HomeAssistant, +) -> None: + """Test reconfigure multi aliases changed to existing host/port/alias.""" + entry = await async_init_integration( + hass, + host="1.1.1.1", + port=123, + alias="ups1", + username="test-username", + password="test-password", + list_ups={"ups1": "UPS 1", "ups2": "UPS 2"}, + list_vars={"battery.voltage": "voltage"}, + ) + + entry2 = await async_init_integration( + hass, + host="2.2.2.2", + port=456, + alias="ups2", + username="test-username", + password="test-password", + list_ups={"ups1": "UPS 1"}, + list_vars={"battery.voltage": "voltage"}, + ) + + assert entry2.data[CONF_HOST] == "2.2.2.2" + assert entry2.data[CONF_PORT] == 456 + assert entry2.data[CONF_USERNAME] == "test-username" + assert entry2.data[CONF_PASSWORD] == "test-password" + + result = await entry2.start_reconfigure_flow(hass) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "reconfigure" + + mock_pynut = _get_mock_nutclient( + list_ups={ + "ups1": "UPS 1", + "ups2": "UPS 2", + }, + list_vars={"battery.voltage": "voltage"}, + ) + + with patch( + "homeassistant.components.nut.AIONUTClient", + return_value=mock_pynut, + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_HOST: entry.data[CONF_HOST], + CONF_PORT: entry.data[CONF_PORT], + CONF_USERNAME: entry.data[CONF_USERNAME], + CONF_PASSWORD: entry.data[CONF_PASSWORD], + }, + ) + + assert result2["type"] is FlowResultType.FORM + assert result2["step_id"] == "reconfigure_ups" + + with ( + patch( + "homeassistant.components.nut.AIONUTClient", + return_value=mock_pynut, + ), + patch( + "homeassistant.components.nut.async_setup_entry", + return_value=True, + ), + ): + result3 = await hass.config_entries.flow.async_configure( + result2["flow_id"], + {CONF_ALIAS: entry.data[CONF_ALIAS]}, + ) + await hass.async_block_till_done() + + assert result3["type"] is FlowResultType.ABORT + assert result3["reason"] == "already_configured" + + assert entry.data[CONF_HOST] == "1.1.1.1" + assert entry.data[CONF_PORT] == 123 + assert entry.data[CONF_USERNAME] == "test-username" + assert entry.data[CONF_PASSWORD] == "test-password" + assert entry.data[CONF_ALIAS] == "ups1" + + assert entry2.data[CONF_HOST] == "2.2.2.2" + assert entry2.data[CONF_PORT] == 456 + assert entry2.data[CONF_USERNAME] == "test-username" + assert entry2.data[CONF_PASSWORD] == "test-password" + assert entry2.data[CONF_ALIAS] == "ups2" + + +async def test_reconfigure_multiple_aliases_unique_id_change( + hass: HomeAssistant, +) -> None: + """Test reconfigure with multiple aliases and the unique ID is changed.""" + entry = await async_init_integration( + hass, + host="1.1.1.1", + port=123, + alias="ups1", + username="test-username", + password="test-password", + list_ups={"ups1": "UPS 1", "ups2": "UPS 2"}, + list_vars={"battery.voltage": "voltage"}, + ) + + result = await entry.start_reconfigure_flow(hass) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "reconfigure" + + mock_pynut = _get_mock_nutclient( + list_ups={ + "ups1": "UPS 1", + "ups2": "UPS 2", + }, + list_vars={ + "device.mfr": "Another manufacturer", + "device.model": "Another model", + "device.serial": "0000-2", + }, + ) + + with patch( + "homeassistant.components.nut.AIONUTClient", + return_value=mock_pynut, + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_HOST: entry.data[CONF_HOST], + CONF_PORT: entry.data[CONF_PORT], + CONF_USERNAME: entry.data[CONF_USERNAME], + CONF_PASSWORD: entry.data[CONF_PASSWORD], + }, + ) + + assert result2["type"] is FlowResultType.FORM + assert result2["step_id"] == "reconfigure_ups" + + with ( + patch( + "homeassistant.components.nut.AIONUTClient", + return_value=mock_pynut, + ), + patch( + "homeassistant.components.nut.async_setup_entry", + return_value=True, + ), + ): + result3 = await hass.config_entries.flow.async_configure( + result2["flow_id"], + {CONF_ALIAS: entry.data[CONF_ALIAS]}, + ) + await hass.async_block_till_done() + + assert result3["type"] is FlowResultType.ABORT + assert result3["reason"] == "unique_id_mismatch" + + +async def test_reconfigure_multiple_aliases_duplicate_unique_ids( + hass: HomeAssistant, +) -> None: + """Test reconfigure multi aliases that results in duplicate unique ID.""" + + list_vars = { + "device.mfr": "Some manufacturer", + "device.model": "Some model", + "device.serial": "0000-1", + } + + entry = await async_init_integration( + hass, + host="1.1.1.1", + port=123, + alias="ups1", + username="test-username", + password="test-password", + list_ups={"ups1": "UPS 1", "ups2": "UPS 2"}, + list_vars=list_vars, + ) + + entry2 = await async_init_integration( + hass, + host="2.2.2.2", + port=456, + alias="ups2", + username="test-username", + password="test-password", + list_ups={"ups1": "UPS 1"}, + list_vars={ + "device.mfr": "Another manufacturer", + "device.model": "Another model", + "device.serial": "0000-2", + }, + ) + + result = await entry2.start_reconfigure_flow(hass) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "reconfigure" + + mock_pynut = _get_mock_nutclient( + list_ups={ + "ups1": "UPS 1", + "ups2": "UPS 2", + }, + list_vars=list_vars, + ) + + with patch( + "homeassistant.components.nut.AIONUTClient", + return_value=mock_pynut, + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_HOST: "3.3.3.3", + CONF_PORT: 789, + CONF_USERNAME: "test-new-username", + CONF_PASSWORD: "test-new-password", + }, + ) + + assert result2["type"] is FlowResultType.FORM + assert result2["step_id"] == "reconfigure_ups" + + with ( + patch( + "homeassistant.components.nut.AIONUTClient", + return_value=mock_pynut, + ), + patch( + "homeassistant.components.nut.async_setup_entry", + return_value=True, + ), + ): + result3 = await hass.config_entries.flow.async_configure( + result2["flow_id"], + {CONF_ALIAS: entry.data[CONF_ALIAS]}, + ) + await hass.async_block_till_done() + + assert result3["type"] is FlowResultType.ABORT + assert result3["reason"] == "unique_id_mismatch" diff --git a/tests/components/nut/util.py b/tests/components/nut/util.py index 07c073f0286..889fdc327af 100644 --- a/tests/components/nut/util.py +++ b/tests/components/nut/util.py @@ -1,10 +1,17 @@ """Tests for the nut integration.""" import json +from typing import Any from unittest.mock import AsyncMock, MagicMock, patch from homeassistant.components.nut.const import DOMAIN -from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_PORT, CONF_USERNAME +from homeassistant.const import ( + CONF_ALIAS, + CONF_HOST, + CONF_PASSWORD, + CONF_PORT, + CONF_USERNAME, +) from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er @@ -35,8 +42,11 @@ def _get_mock_nutclient( async def async_init_integration( hass: HomeAssistant, ups_fixture: str | None = None, + host: str = "mock", + port: str = "mock", username: str = "mock", password: str = "mock", + alias: str | None = None, list_ups: dict[str, str] | None = None, list_vars: dict[str, str] | None = None, list_commands_return_value: dict[str, str] | None = None, @@ -65,15 +75,24 @@ async def async_init_integration( "homeassistant.components.nut.AIONUTClient", return_value=mock_pynut, ): + extra_config_entry_data: dict[str, Any] = {} + + if alias is not None: + extra_config_entry_data = { + CONF_ALIAS: alias, + } + entry = MockConfigEntry( domain=DOMAIN, data={ - CONF_HOST: "mock", + CONF_HOST: host, CONF_PASSWORD: password, - CONF_PORT: "mock", + CONF_PORT: port, CONF_USERNAME: username, - }, + } + | extra_config_entry_data, ) + entry.add_to_hass(hass) await hass.config_entries.async_setup(entry.entry_id) From bd8c723e08777abccf44b123b20098d928f399d4 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 5 Apr 2025 11:07:21 -1000 Subject: [PATCH 314/505] Bump flux_led to 1.2.0 (#142362) changelog: https://github.com/lightinglibs/flux_led/compare/1.1.3...1.2.0 --- homeassistant/components/flux_led/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/flux_led/manifest.json b/homeassistant/components/flux_led/manifest.json index fcb16c9742b..2c5e1b3839e 100644 --- a/homeassistant/components/flux_led/manifest.json +++ b/homeassistant/components/flux_led/manifest.json @@ -53,5 +53,5 @@ "documentation": "https://www.home-assistant.io/integrations/flux_led", "iot_class": "local_push", "loggers": ["flux_led"], - "requirements": ["flux-led==1.1.3"] + "requirements": ["flux-led==1.2.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index ebbfbe6d964..5aa70b17115 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -944,7 +944,7 @@ flexit_bacnet==2.2.3 flipr-api==1.6.1 # homeassistant.components.flux_led -flux-led==1.1.3 +flux-led==1.2.0 # homeassistant.components.homekit # homeassistant.components.recorder diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 95759e4e12f..b17e98256c3 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -804,7 +804,7 @@ flexit_bacnet==2.2.3 flipr-api==1.6.1 # homeassistant.components.flux_led -flux-led==1.1.3 +flux-led==1.2.0 # homeassistant.components.homekit # homeassistant.components.recorder From 0a7b4d18dcebf6dc24698de9eab580dcc3ed0ad8 Mon Sep 17 00:00:00 2001 From: Luke Lashley Date: Sat, 5 Apr 2025 19:45:00 -0400 Subject: [PATCH 315/505] Check that the current roboorck map exists before updating it. (#142341) * Check that the current map exists * Add a few extra checks * Update coordinator.py Co-authored-by: Allen Porter * fixlint --------- Co-authored-by: Allen Porter --- .../components/roborock/coordinator.py | 18 +++++++++++------- homeassistant/components/roborock/sensor.py | 10 ++++++++-- 2 files changed, 19 insertions(+), 9 deletions(-) diff --git a/homeassistant/components/roborock/coordinator.py b/homeassistant/components/roborock/coordinator.py index 4e59a092e0a..2439a4f904a 100644 --- a/homeassistant/components/roborock/coordinator.py +++ b/homeassistant/components/roborock/coordinator.py @@ -226,7 +226,7 @@ class RoborockDataUpdateCoordinator(DataUpdateCoordinator[DeviceProp]): """Update the currently selected map.""" # The current map was set in the props update, so these can be done without # worry of applying them to the wrong map. - if self.current_map is None: + if self.current_map is None or self.current_map not in self.maps: # This exists as a safeguard/ to keep mypy happy. return try: @@ -302,13 +302,17 @@ class RoborockDataUpdateCoordinator(DataUpdateCoordinator[DeviceProp]): # If the vacuum is currently cleaning and it has been IMAGE_CACHE_INTERVAL # since the last map update, you can update the map. new_status = self.roborock_device_info.props.status - if self.current_map is not None and ( - ( - new_status.in_cleaning - and (dt_util.utcnow() - self.maps[self.current_map].last_updated) - > IMAGE_CACHE_INTERVAL + if ( + self.current_map is not None + and (current_map := self.maps.get(self.current_map)) + and ( + ( + new_status.in_cleaning + and (dt_util.utcnow() - current_map.last_updated) + > IMAGE_CACHE_INTERVAL + ) + or self.last_update_state != new_status.state_name ) - or self.last_update_state != new_status.state_name ): try: await self.update_map() diff --git a/homeassistant/components/roborock/sensor.py b/homeassistant/components/roborock/sensor.py index 33ecaf74d4f..a007d6fa457 100644 --- a/homeassistant/components/roborock/sensor.py +++ b/homeassistant/components/roborock/sensor.py @@ -381,7 +381,10 @@ class RoborockCurrentRoom(RoborockCoordinatedEntityV1, SensorEntity): @property def options(self) -> list[str]: """Return the currently valid rooms.""" - if self.coordinator.current_map is not None: + if ( + self.coordinator.current_map is not None + and self.coordinator.current_map in self.coordinator.maps + ): return list( self.coordinator.maps[self.coordinator.current_map].rooms.values() ) @@ -390,7 +393,10 @@ class RoborockCurrentRoom(RoborockCoordinatedEntityV1, SensorEntity): @property def native_value(self) -> str | None: """Return the value reported by the sensor.""" - if self.coordinator.current_map is not None: + if ( + self.coordinator.current_map is not None + and self.coordinator.current_map in self.coordinator.maps + ): return self.coordinator.maps[self.coordinator.current_map].current_room return None From dcef86a30d7c201c725ad314550ddfcef11b3b15 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 5 Apr 2025 14:22:23 -1000 Subject: [PATCH 316/505] Add DHCP discovery support to Bond (#142372) * Add DHCP discovery support to Bond * fixes * unique ids are always upper * raise_on_progress=False for user * Update tests/components/bond/test_config_flow.py Co-authored-by: Joost Lekkerkerker * assert unique id --------- Co-authored-by: Joost Lekkerkerker --- homeassistant/components/bond/config_flow.py | 34 +++- homeassistant/components/bond/manifest.json | 10 ++ homeassistant/generated/dhcp.py | 10 ++ tests/components/bond/test_config_flow.py | 157 +++++++++++++++++++ 4 files changed, 206 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/bond/config_flow.py b/homeassistant/components/bond/config_flow.py index 38abd63186a..ffa0098840c 100644 --- a/homeassistant/components/bond/config_flow.py +++ b/homeassistant/components/bond/config_flow.py @@ -16,6 +16,7 @@ from homeassistant.const import CONF_ACCESS_TOKEN, CONF_HOST, CONF_NAME from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo from .const import DOMAIN @@ -91,11 +92,22 @@ class BondConfigFlow(ConfigFlow, domain=DOMAIN): self._discovered[CONF_ACCESS_TOKEN] = token try: - _, hub_name = await _validate_input(self.hass, self._discovered) + bond_id, hub_name = await _validate_input(self.hass, self._discovered) except InputValidationError: return + await self.async_set_unique_id(bond_id) + self._abort_if_unique_id_configured(updates={CONF_HOST: host}) self._discovered[CONF_NAME] = hub_name + async def async_step_dhcp( + self, discovery_info: DhcpServiceInfo + ) -> ConfigFlowResult: + """Handle a flow initialized by dhcp discovery.""" + host = discovery_info.ip + bond_id = discovery_info.hostname.partition("-")[2].upper() + await self.async_set_unique_id(bond_id) + return await self.async_step_any_discovery(bond_id, host) + async def async_step_zeroconf( self, discovery_info: ZeroconfServiceInfo ) -> ConfigFlowResult: @@ -104,11 +116,17 @@ class BondConfigFlow(ConfigFlow, domain=DOMAIN): host: str = discovery_info.host bond_id = name.partition(".")[0] await self.async_set_unique_id(bond_id) + return await self.async_step_any_discovery(bond_id, host) + + async def async_step_any_discovery( + self, bond_id: str, host: str + ) -> ConfigFlowResult: + """Handle a flow initialized by discovery.""" for entry in self._async_current_entries(): if entry.unique_id != bond_id: continue updates = {CONF_HOST: host} - if entry.state == ConfigEntryState.SETUP_ERROR and ( + if entry.state is ConfigEntryState.SETUP_ERROR and ( token := await async_get_token(self.hass, host) ): updates[CONF_ACCESS_TOKEN] = token @@ -153,10 +171,14 @@ class BondConfigFlow(ConfigFlow, domain=DOMAIN): CONF_HOST: self._discovered[CONF_HOST], } try: - _, hub_name = await _validate_input(self.hass, data) + bond_id, hub_name = await _validate_input(self.hass, data) except InputValidationError as error: errors["base"] = error.base else: + await self.async_set_unique_id(bond_id) + self._abort_if_unique_id_configured( + updates={CONF_HOST: self._discovered[CONF_HOST]} + ) return self.async_create_entry( title=hub_name, data=data, @@ -185,8 +207,10 @@ class BondConfigFlow(ConfigFlow, domain=DOMAIN): except InputValidationError as error: errors["base"] = error.base else: - await self.async_set_unique_id(bond_id) - self._abort_if_unique_id_configured() + await self.async_set_unique_id(bond_id, raise_on_progress=False) + self._abort_if_unique_id_configured( + updates={CONF_HOST: user_input[CONF_HOST]} + ) return self.async_create_entry(title=hub_name, data=user_input) return self.async_show_form( diff --git a/homeassistant/components/bond/manifest.json b/homeassistant/components/bond/manifest.json index 1d4c110f4fd..704b9934970 100644 --- a/homeassistant/components/bond/manifest.json +++ b/homeassistant/components/bond/manifest.json @@ -3,6 +3,16 @@ "name": "Bond", "codeowners": ["@bdraco", "@prystupa", "@joshs85", "@marciogranzotto"], "config_flow": true, + "dhcp": [ + { + "hostname": "bond-*", + "macaddress": "3C6A2C1*" + }, + { + "hostname": "bond-*", + "macaddress": "F44E38*" + } + ], "documentation": "https://www.home-assistant.io/integrations/bond", "iot_class": "local_push", "loggers": ["bond_async"], diff --git a/homeassistant/generated/dhcp.py b/homeassistant/generated/dhcp.py index 8ee1ea270f3..9a8fd349a8b 100644 --- a/homeassistant/generated/dhcp.py +++ b/homeassistant/generated/dhcp.py @@ -84,6 +84,16 @@ DHCP: Final[list[dict[str, str | bool]]] = [ "hostname": "blink*", "macaddress": "20A171*", }, + { + "domain": "bond", + "hostname": "bond-*", + "macaddress": "3C6A2C1*", + }, + { + "domain": "bond", + "hostname": "bond-*", + "macaddress": "F44E38*", + }, { "domain": "broadlink", "registered_devices": True, diff --git a/tests/components/bond/test_config_flow.py b/tests/components/bond/test_config_flow.py index 73aece4af6b..e5139b253aa 100644 --- a/tests/components/bond/test_config_flow.py +++ b/tests/components/bond/test_config_flow.py @@ -15,6 +15,8 @@ from homeassistant.config_entries import ConfigEntryState from homeassistant.const import CONF_ACCESS_TOKEN, CONF_HOST from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType +from homeassistant.helpers.device_registry import format_mac +from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo from .common import ( @@ -63,6 +65,59 @@ async def test_user_form(hass: HomeAssistant) -> None: assert len(mock_setup_entry.mock_calls) == 1 +async def test_user_form_can_create_when_already_discovered( + hass: HomeAssistant, +) -> None: + """Test we get the user initiated form can create when already discovered.""" + + with patch_bond_version(), patch_bond_token(): + zc_result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_ZEROCONF}, + data=ZeroconfServiceInfo( + ip_address=ip_address("127.0.0.1"), + ip_addresses=[ip_address("127.0.0.1")], + hostname="mock_hostname", + name="ZXXX12345.some-other-tail-info", + port=None, + properties={}, + type="mock_type", + ), + ) + assert zc_result["type"] is FlowResultType.FORM + assert zc_result["errors"] == {} + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {} + + with ( + patch_bond_version(return_value={"bondid": "ZXXX12345"}), + patch_bond_device_ids(return_value=["f6776c11", "f6776c12"]), + patch_bond_bridge(), + patch_bond_device_properties(), + patch_bond_device(), + patch_bond_device_state(), + _patch_async_setup_entry() as mock_setup_entry, + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_HOST: "some host", CONF_ACCESS_TOKEN: "test-token"}, + ) + await hass.async_block_till_done() + + assert result2["type"] is FlowResultType.CREATE_ENTRY + assert result2["title"] == "bond-name" + assert result2["data"] == { + CONF_HOST: "some host", + CONF_ACCESS_TOKEN: "test-token", + } + assert result2["result"].unique_id == "ZXXX12345" + assert len(mock_setup_entry.mock_calls) == 1 + + async def test_user_form_with_non_bridge(hass: HomeAssistant) -> None: """Test setup a smart by bond fan.""" @@ -97,6 +152,7 @@ async def test_user_form_with_non_bridge(hass: HomeAssistant) -> None: CONF_HOST: "some host", CONF_ACCESS_TOKEN: "test-token", } + assert result2["result"].unique_id == "KXXX12345" assert len(mock_setup_entry.mock_calls) == 1 @@ -253,6 +309,107 @@ async def test_zeroconf_form(hass: HomeAssistant) -> None: assert len(mock_setup_entry.mock_calls) == 1 +async def test_dhcp_discovery(hass: HomeAssistant) -> None: + """Test DHCP discovery.""" + + with patch_bond_version(), patch_bond_token(): + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_DHCP}, + data=DhcpServiceInfo( + ip="127.0.0.1", + hostname="Bond-KVPRBDJ45842", + macaddress=format_mac("3c:6a:2c:1c:8c:80"), + ), + ) + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {} + + with ( + patch_bond_version(return_value={"bondid": "KVPRBDJ45842"}), + patch_bond_bridge(), + patch_bond_device_ids(), + _patch_async_setup_entry() as mock_setup_entry, + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_ACCESS_TOKEN: "test-token"}, + ) + + assert result2["type"] is FlowResultType.CREATE_ENTRY + assert result2["title"] == "bond-name" + assert result2["data"] == { + CONF_HOST: "127.0.0.1", + CONF_ACCESS_TOKEN: "test-token", + } + assert result2["result"].unique_id == "KVPRBDJ45842" + assert len(mock_setup_entry.mock_calls) == 1 + + +async def test_dhcp_discovery_already_exists(hass: HomeAssistant) -> None: + """Test DHCP discovery for an already existing entry.""" + + entry = MockConfigEntry( + domain=DOMAIN, + unique_id="KVPRBDJ45842", + ) + entry.add_to_hass(hass) + + with ( + patch_bond_version(return_value={"bondid": "KVPRBDJ45842"}), + patch_bond_token(), + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_DHCP}, + data=DhcpServiceInfo( + ip="127.0.0.1", + hostname="Bond-KVPRBDJ45842".lower(), + macaddress=format_mac("3c:6a:2c:1c:8c:80"), + ), + ) + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" + + +async def test_dhcp_discovery_short_name(hass: HomeAssistant) -> None: + """Test DHCP discovery with the name cut off.""" + + with patch_bond_version(), patch_bond_token(): + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_DHCP}, + data=DhcpServiceInfo( + ip="127.0.0.1", + hostname="Bond-KVPRBDJ", + macaddress=format_mac("3c:6a:2c:1c:8c:80"), + ), + ) + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {} + + with ( + patch_bond_version(return_value={"bondid": "KVPRBDJ45842"}), + patch_bond_bridge(), + patch_bond_device_ids(), + _patch_async_setup_entry() as mock_setup_entry, + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_ACCESS_TOKEN: "test-token"}, + ) + await hass.async_block_till_done() + + assert result2["type"] is FlowResultType.CREATE_ENTRY + assert result2["title"] == "bond-name" + assert result2["data"] == { + CONF_HOST: "127.0.0.1", + CONF_ACCESS_TOKEN: "test-token", + } + assert result2["result"].unique_id == "KVPRBDJ45842" + assert len(mock_setup_entry.mock_calls) == 1 + + async def test_zeroconf_form_token_unavailable(hass: HomeAssistant) -> None: """Test we get the discovery form and we handle the token being unavailable.""" From 55de21477c537ffd1e0cb29c2765442129fdae94 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 5 Apr 2025 17:35:19 -1000 Subject: [PATCH 317/505] Bump yarl to 1.19.0 (#142379) --- homeassistant/package_constraints.txt | 2 +- pyproject.toml | 2 +- requirements.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index c714efb8a9c..af75218bf7e 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -74,7 +74,7 @@ voluptuous-openapi==0.0.6 voluptuous-serialize==2.6.0 voluptuous==0.15.2 webrtc-models==0.3.0 -yarl==1.18.3 +yarl==1.19.0 zeroconf==0.146.0 # Constrain pycryptodome to avoid vulnerability diff --git a/pyproject.toml b/pyproject.toml index 8d81bf7ff03..7c35d1d2f71 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -121,7 +121,7 @@ dependencies = [ "voluptuous==0.15.2", "voluptuous-serialize==2.6.0", "voluptuous-openapi==0.0.6", - "yarl==1.18.3", + "yarl==1.19.0", "webrtc-models==0.3.0", "zeroconf==0.146.0", ] diff --git a/requirements.txt b/requirements.txt index f5e475bebce..b07a8710e5d 100644 --- a/requirements.txt +++ b/requirements.txt @@ -58,6 +58,6 @@ uv==0.6.10 voluptuous==0.15.2 voluptuous-serialize==2.6.0 voluptuous-openapi==0.0.6 -yarl==1.18.3 +yarl==1.19.0 webrtc-models==0.3.0 zeroconf==0.146.0 From c93b4cf61aabf9a60b2ff9e6e53bf7ea74e7ad6d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ville=20Skytt=C3=A4?= Date: Sun, 6 Apr 2025 09:23:45 +0300 Subject: [PATCH 318/505] Upgrade url-normalize to 2.2.0 (#142365) * https://github.com/niksite/url-normalize/releases/tag/2.0.0 * https://github.com/niksite/url-normalize/releases/tag/2.0.1 * https://github.com/niksite/url-normalize/releases/tag/2.1.0 * https://github.com/niksite/url-normalize/releases/tag/2.2.0 --- homeassistant/components/huawei_lte/manifest.json | 2 +- homeassistant/components/syncthru/manifest.json | 2 +- homeassistant/components/zwave_me/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 5 files changed, 5 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/huawei_lte/manifest.json b/homeassistant/components/huawei_lte/manifest.json index 6720d6718ef..ce5316553ed 100644 --- a/homeassistant/components/huawei_lte/manifest.json +++ b/homeassistant/components/huawei_lte/manifest.json @@ -9,7 +9,7 @@ "requirements": [ "huawei-lte-api==1.10.0", "stringcase==1.2.0", - "url-normalize==1.4.3" + "url-normalize==2.2.0" ], "ssdp": [ { diff --git a/homeassistant/components/syncthru/manifest.json b/homeassistant/components/syncthru/manifest.json index 461ce9bfd3a..11c688eb9af 100644 --- a/homeassistant/components/syncthru/manifest.json +++ b/homeassistant/components/syncthru/manifest.json @@ -6,7 +6,7 @@ "documentation": "https://www.home-assistant.io/integrations/syncthru", "iot_class": "local_polling", "loggers": ["pysyncthru"], - "requirements": ["PySyncThru==0.8.0", "url-normalize==1.4.3"], + "requirements": ["PySyncThru==0.8.0", "url-normalize==2.2.0"], "ssdp": [ { "deviceType": "urn:schemas-upnp-org:device:Printer:1", diff --git a/homeassistant/components/zwave_me/manifest.json b/homeassistant/components/zwave_me/manifest.json index d5c5a69cb96..43a39de29c5 100644 --- a/homeassistant/components/zwave_me/manifest.json +++ b/homeassistant/components/zwave_me/manifest.json @@ -6,7 +6,7 @@ "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/zwave_me", "iot_class": "local_push", - "requirements": ["zwave-me-ws==0.4.3", "url-normalize==1.4.3"], + "requirements": ["zwave-me-ws==0.4.3", "url-normalize==2.2.0"], "zeroconf": [ { "type": "_hap._tcp.local.", diff --git a/requirements_all.txt b/requirements_all.txt index 5aa70b17115..c138a83924a 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2991,7 +2991,7 @@ upcloud-api==2.6.0 # homeassistant.components.huawei_lte # homeassistant.components.syncthru # homeassistant.components.zwave_me -url-normalize==1.4.3 +url-normalize==2.2.0 # homeassistant.components.uvc uvcclient==0.12.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index b17e98256c3..df9e228f367 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2411,7 +2411,7 @@ upcloud-api==2.6.0 # homeassistant.components.huawei_lte # homeassistant.components.syncthru # homeassistant.components.zwave_me -url-normalize==1.4.3 +url-normalize==2.2.0 # homeassistant.components.uvc uvcclient==0.12.1 From 62845fe4a7edc077098aa5a12ec35107383e2e01 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=81lvaro=20Fern=C3=A1ndez=20Rojas?= Date: Sun, 6 Apr 2025 10:01:26 +0200 Subject: [PATCH 319/505] Update aioairzone to v1.0.0 (#142385) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Álvaro Fernández Rojas --- homeassistant/components/airzone/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- .../airzone/snapshots/test_diagnostics.ambr | 16 ++++++++++++++++ tests/components/airzone/util.py | 10 ++++++++++ 5 files changed, 29 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/airzone/manifest.json b/homeassistant/components/airzone/manifest.json index 95ed9d200f4..1b636de0a47 100644 --- a/homeassistant/components/airzone/manifest.json +++ b/homeassistant/components/airzone/manifest.json @@ -11,5 +11,5 @@ "documentation": "https://www.home-assistant.io/integrations/airzone", "iot_class": "local_polling", "loggers": ["aioairzone"], - "requirements": ["aioairzone==0.9.9"] + "requirements": ["aioairzone==1.0.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index c138a83924a..a51b5a132ef 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -182,7 +182,7 @@ aioairq==0.4.4 aioairzone-cloud==0.6.11 # homeassistant.components.airzone -aioairzone==0.9.9 +aioairzone==1.0.0 # homeassistant.components.ambient_network # homeassistant.components.ambient_station diff --git a/requirements_test_all.txt b/requirements_test_all.txt index df9e228f367..6e8aa17b5fe 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -170,7 +170,7 @@ aioairq==0.4.4 aioairzone-cloud==0.6.11 # homeassistant.components.airzone -aioairzone==0.9.9 +aioairzone==1.0.0 # homeassistant.components.ambient_network # homeassistant.components.ambient_station diff --git a/tests/components/airzone/snapshots/test_diagnostics.ambr b/tests/components/airzone/snapshots/test_diagnostics.ambr index b4976c07e1b..09dea8c354c 100644 --- a/tests/components/airzone/snapshots/test_diagnostics.ambr +++ b/tests/components/airzone/snapshots/test_diagnostics.ambr @@ -44,9 +44,11 @@ }), dict({ 'air_demand': 1, + 'battery': 99, 'coldStage': 1, 'coldStages': 1, 'coldangle': 2, + 'coverage': 72, 'errors': list([ ]), 'floor_demand': 1, @@ -73,9 +75,11 @@ }), dict({ 'air_demand': 0, + 'battery': 35, 'coldStage': 1, 'coldStages': 1, 'coldangle': 0, + 'coverage': 60, 'errors': list([ ]), 'floor_demand': 0, @@ -100,9 +104,11 @@ }), dict({ 'air_demand': 0, + 'battery': 25, 'coldStage': 1, 'coldStages': 1, 'coldangle': 0, + 'coverage': 88, 'errors': list([ dict({ 'Zone': 'Low battery', @@ -130,9 +136,11 @@ }), dict({ 'air_demand': 0, + 'battery': 80, 'coldStage': 1, 'coldStages': 1, 'coldangle': 0, + 'coverage': 66, 'errors': list([ ]), 'floor_demand': 0, @@ -497,9 +505,11 @@ 'temp-set': 19.2, 'temp-step': 0.5, 'temp-unit': 0, + 'thermostat-battery': 99, 'thermostat-fw': '3.33', 'thermostat-model': 'Think (Radio)', 'thermostat-radio': True, + 'thermostat-signal': 72, }), '1:3': dict({ 'absolute-temp-max': 30.0, @@ -546,9 +556,11 @@ 'temp-set': 19.3, 'temp-step': 0.5, 'temp-unit': 0, + 'thermostat-battery': 35, 'thermostat-fw': '3.33', 'thermostat-model': 'Think (Radio)', 'thermostat-radio': True, + 'thermostat-signal': 60, }), '1:4': dict({ 'absolute-temp-max': 86.0, @@ -597,9 +609,11 @@ 'temp-set': 66.9, 'temp-step': 1.0, 'temp-unit': 1, + 'thermostat-battery': 25, 'thermostat-fw': '3.33', 'thermostat-model': 'Think (Radio)', 'thermostat-radio': True, + 'thermostat-signal': 88, }), '1:5': dict({ 'absolute-temp-max': 30.0, @@ -645,9 +659,11 @@ 'temp-set': 19.5, 'temp-step': 0.5, 'temp-unit': 0, + 'thermostat-battery': 80, 'thermostat-fw': '3.33', 'thermostat-model': 'Think (Radio)', 'thermostat-radio': True, + 'thermostat-signal': 66, }), '2:1': dict({ 'absolute-temp-max': 30.0, diff --git a/tests/components/airzone/util.py b/tests/components/airzone/util.py index 50d1964924d..944ca83d053 100644 --- a/tests/components/airzone/util.py +++ b/tests/components/airzone/util.py @@ -11,12 +11,14 @@ from aioairzone.const import ( API_ACS_SET_POINT, API_ACS_TEMP, API_AIR_DEMAND, + API_BATTERY, API_COLD_ANGLE, API_COLD_STAGE, API_COLD_STAGES, API_COOL_MAX_TEMP, API_COOL_MIN_TEMP, API_COOL_SET_POINT, + API_COVERAGE, API_DATA, API_ERRORS, API_FLOOR_DEMAND, @@ -119,6 +121,8 @@ HVAC_MOCK = { API_THERMOS_TYPE: 4, API_THERMOS_FIRMWARE: "3.33", API_THERMOS_RADIO: 1, + API_BATTERY: 99, + API_COVERAGE: 72, API_ON: 1, API_MAX_TEMP: 30, API_MIN_TEMP: 15, @@ -147,6 +151,8 @@ HVAC_MOCK = { API_THERMOS_TYPE: 4, API_THERMOS_FIRMWARE: "3.33", API_THERMOS_RADIO: 1, + API_BATTERY: 35, + API_COVERAGE: 60, API_ON: 1, API_MAX_TEMP: 30, API_MIN_TEMP: 15, @@ -173,6 +179,8 @@ HVAC_MOCK = { API_THERMOS_TYPE: 4, API_THERMOS_FIRMWARE: "3.33", API_THERMOS_RADIO: 1, + API_BATTERY: 25, + API_COVERAGE: 88, API_ON: 0, API_MAX_TEMP: 86, API_MIN_TEMP: 59, @@ -203,6 +211,8 @@ HVAC_MOCK = { API_THERMOS_TYPE: 4, API_THERMOS_FIRMWARE: "3.33", API_THERMOS_RADIO: 1, + API_BATTERY: 80, + API_COVERAGE: 66, API_ON: 0, API_MAX_TEMP: 30, API_MIN_TEMP: 15, From 638b88c61c2974424f1675dce4ea8af8efdde21b Mon Sep 17 00:00:00 2001 From: Jan-Philipp Benecke Date: Sun, 6 Apr 2025 10:04:18 +0200 Subject: [PATCH 320/505] Only load files ending `.metadata.json` in WebDAV (#142388) --- homeassistant/components/webdav/backup.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/webdav/backup.py b/homeassistant/components/webdav/backup.py index fb2927a58bb..a9afb5fe930 100644 --- a/homeassistant/components/webdav/backup.py +++ b/homeassistant/components/webdav/backup.py @@ -231,7 +231,7 @@ class WebDavBackupAgent(BackupAgent): return { metadata_content.backup_id: metadata_content for file_name in files - if file_name.endswith(".json") + if file_name.endswith(".metadata.json") if (metadata_content := await _download_metadata(file_name)) } From d7ca168b77756232407da7df4967ae48bf5e7ea3 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 6 Apr 2025 00:09:11 -1000 Subject: [PATCH 321/505] Fix flapping logger test (#142367) The websocket_api logger might get adjusted from other tests so we cannot be sure its set at debug in this test --- tests/components/logger/test_websocket_api.py | 1 - 1 file changed, 1 deletion(-) diff --git a/tests/components/logger/test_websocket_api.py b/tests/components/logger/test_websocket_api.py index 5bc280535f9..8fcafcd05a4 100644 --- a/tests/components/logger/test_websocket_api.py +++ b/tests/components/logger/test_websocket_api.py @@ -31,7 +31,6 @@ async def test_integration_log_info( assert msg["type"] == TYPE_RESULT assert msg["success"] assert {"domain": "http", "level": logging.DEBUG} in msg["result"] - assert {"domain": "websocket_api", "level": logging.DEBUG} in msg["result"] async def test_integration_log_level_logger_not_loaded( From 8aee79085ab159ffe90c866f1d7e402f31d00c3c Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 6 Apr 2025 01:00:41 -1000 Subject: [PATCH 322/505] Bump aioesphomeapi to 29.9.0 (#142393) changelog: https://github.com/esphome/aioesphomeapi/compare/v29.8.0...v29.9.0 fixes #142381 --- homeassistant/components/esphome/manager.py | 4 ++-- homeassistant/components/esphome/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 5 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/esphome/manager.py b/homeassistant/components/esphome/manager.py index 7ce96a0f510..56c2998a3cc 100644 --- a/homeassistant/components/esphome/manager.py +++ b/homeassistant/components/esphome/manager.py @@ -13,7 +13,7 @@ from aioesphomeapi import ( APIConnectionError, APIVersion, DeviceInfo as EsphomeDeviceInfo, - EncryptionHelloAPIError, + EncryptionPlaintextAPIError, EntityInfo, HomeassistantServiceCall, InvalidAuthAPIError, @@ -571,7 +571,7 @@ class ESPHomeManager: if isinstance( err, ( - EncryptionHelloAPIError, + EncryptionPlaintextAPIError, RequiresEncryptionAPIError, InvalidEncryptionKeyAPIError, InvalidAuthAPIError, diff --git a/homeassistant/components/esphome/manifest.json b/homeassistant/components/esphome/manifest.json index bd81e122981..9f6431c940f 100644 --- a/homeassistant/components/esphome/manifest.json +++ b/homeassistant/components/esphome/manifest.json @@ -16,7 +16,7 @@ "loggers": ["aioesphomeapi", "noiseprotocol", "bleak_esphome"], "mqtt": ["esphome/discover/#"], "requirements": [ - "aioesphomeapi==29.8.0", + "aioesphomeapi==29.9.0", "esphome-dashboard-api==1.2.3", "bleak-esphome==2.13.1" ], diff --git a/requirements_all.txt b/requirements_all.txt index a51b5a132ef..658b20f6245 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -243,7 +243,7 @@ aioelectricitymaps==0.4.0 aioemonitor==1.0.5 # homeassistant.components.esphome -aioesphomeapi==29.8.0 +aioesphomeapi==29.9.0 # homeassistant.components.flo aioflo==2021.11.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 6e8aa17b5fe..7a3e632559c 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -231,7 +231,7 @@ aioelectricitymaps==0.4.0 aioemonitor==1.0.5 # homeassistant.components.esphome -aioesphomeapi==29.8.0 +aioesphomeapi==29.9.0 # homeassistant.components.flo aioflo==2021.11.0 From b35a44a0e07b75b225d81a1a4b79c573dbe17249 Mon Sep 17 00:00:00 2001 From: Sid <27780930+autinerd@users.noreply.github.com> Date: Sun, 6 Apr 2025 14:46:19 +0200 Subject: [PATCH 323/505] Add sensor platform to eheimdigital (#138809) * Add fan platform to eheimdigital * Fix pylint * Convert fan to sensor platform * Remove unnecessary changes * Add state update test * Review * Review * Review --- .../components/eheimdigital/__init__.py | 2 +- .../components/eheimdigital/icons.json | 18 ++ .../components/eheimdigital/sensor.py | 114 +++++++++++++ .../components/eheimdigital/strings.json | 16 ++ tests/components/eheimdigital/conftest.py | 32 +++- .../eheimdigital/snapshots/test_sensor.ambr | 160 ++++++++++++++++++ tests/components/eheimdigital/test_sensor.py | 77 +++++++++ 7 files changed, 416 insertions(+), 3 deletions(-) create mode 100644 homeassistant/components/eheimdigital/icons.json create mode 100644 homeassistant/components/eheimdigital/sensor.py create mode 100644 tests/components/eheimdigital/snapshots/test_sensor.ambr create mode 100644 tests/components/eheimdigital/test_sensor.py diff --git a/homeassistant/components/eheimdigital/__init__.py b/homeassistant/components/eheimdigital/__init__.py index 26e6bea4d4a..e4fb7989931 100644 --- a/homeassistant/components/eheimdigital/__init__.py +++ b/homeassistant/components/eheimdigital/__init__.py @@ -9,7 +9,7 @@ from homeassistant.helpers.device_registry import DeviceEntry from .const import DOMAIN from .coordinator import EheimDigitalConfigEntry, EheimDigitalUpdateCoordinator -PLATFORMS = [Platform.CLIMATE, Platform.LIGHT] +PLATFORMS = [Platform.CLIMATE, Platform.LIGHT, Platform.SENSOR] async def async_setup_entry( diff --git a/homeassistant/components/eheimdigital/icons.json b/homeassistant/components/eheimdigital/icons.json new file mode 100644 index 00000000000..32f3f1eee9c --- /dev/null +++ b/homeassistant/components/eheimdigital/icons.json @@ -0,0 +1,18 @@ +{ + "entity": { + "sensor": { + "current_speed": { + "default": "mdi:pump" + }, + "service_hours": { + "default": "mdi:wrench-clock" + }, + "error_code": { + "default": "mdi:alert-octagon", + "state": { + "no_error": "mdi:check-circle" + } + } + } + } +} diff --git a/homeassistant/components/eheimdigital/sensor.py b/homeassistant/components/eheimdigital/sensor.py new file mode 100644 index 00000000000..3d809cc14dc --- /dev/null +++ b/homeassistant/components/eheimdigital/sensor.py @@ -0,0 +1,114 @@ +"""EHEIM Digital sensors.""" + +from collections.abc import Callable +from dataclasses import dataclass +from typing import Generic, TypeVar, override + +from eheimdigital.classic_vario import EheimDigitalClassicVario +from eheimdigital.device import EheimDigitalDevice +from eheimdigital.types import FilterErrorCode + +from homeassistant.components.sensor import SensorEntity, SensorEntityDescription +from homeassistant.components.sensor.const import SensorDeviceClass +from homeassistant.const import PERCENTAGE, EntityCategory, UnitOfTime +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from .coordinator import EheimDigitalConfigEntry, EheimDigitalUpdateCoordinator +from .entity import EheimDigitalEntity + +# Coordinator is used to centralize the data updates +PARALLEL_UPDATES = 0 + +_DeviceT_co = TypeVar("_DeviceT_co", bound=EheimDigitalDevice, covariant=True) + + +@dataclass(frozen=True, kw_only=True) +class EheimDigitalSensorDescription(SensorEntityDescription, Generic[_DeviceT_co]): + """Class describing EHEIM Digital sensor entities.""" + + value_fn: Callable[[_DeviceT_co], float | str | None] + + +CLASSICVARIO_DESCRIPTIONS: tuple[ + EheimDigitalSensorDescription[EheimDigitalClassicVario], ... +] = ( + EheimDigitalSensorDescription[EheimDigitalClassicVario]( + key="current_speed", + translation_key="current_speed", + value_fn=lambda device: device.current_speed, + native_unit_of_measurement=PERCENTAGE, + ), + EheimDigitalSensorDescription[EheimDigitalClassicVario]( + key="service_hours", + translation_key="service_hours", + value_fn=lambda device: device.service_hours, + device_class=SensorDeviceClass.DURATION, + native_unit_of_measurement=UnitOfTime.HOURS, + suggested_unit_of_measurement=UnitOfTime.DAYS, + entity_category=EntityCategory.DIAGNOSTIC, + ), + EheimDigitalSensorDescription[EheimDigitalClassicVario]( + key="error_code", + translation_key="error_code", + value_fn=( + lambda device: device.error_code.name.lower() + if device.error_code is not None + else None + ), + device_class=SensorDeviceClass.ENUM, + options=[name.lower() for name in FilterErrorCode._member_names_], + entity_category=EntityCategory.DIAGNOSTIC, + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, + entry: EheimDigitalConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up the callbacks for the coordinator so lights can be added as devices are found.""" + coordinator = entry.runtime_data + + def async_setup_device_entities( + device_address: dict[str, EheimDigitalDevice], + ) -> None: + """Set up the light entities for one or multiple devices.""" + entities: list[EheimDigitalSensor[EheimDigitalDevice]] = [] + for device in device_address.values(): + if isinstance(device, EheimDigitalClassicVario): + entities += [ + EheimDigitalSensor[EheimDigitalClassicVario]( + coordinator, device, description + ) + for description in CLASSICVARIO_DESCRIPTIONS + ] + + async_add_entities(entities) + + coordinator.add_platform_callback(async_setup_device_entities) + async_setup_device_entities(coordinator.hub.devices) + + +class EheimDigitalSensor( + EheimDigitalEntity[_DeviceT_co], SensorEntity, Generic[_DeviceT_co] +): + """Represent a EHEIM Digital sensor entity.""" + + entity_description: EheimDigitalSensorDescription[_DeviceT_co] + + def __init__( + self, + coordinator: EheimDigitalUpdateCoordinator, + device: _DeviceT_co, + description: EheimDigitalSensorDescription[_DeviceT_co], + ) -> None: + """Initialize an EHEIM Digital number entity.""" + super().__init__(coordinator, device) + self.entity_description = description + self._attr_unique_id = f"{self._device_address}_{description.key}" + + @override + def _async_update_attrs(self) -> None: + self._attr_native_value = self.entity_description.value_fn(self._device) diff --git a/homeassistant/components/eheimdigital/strings.json b/homeassistant/components/eheimdigital/strings.json index ef6f6b10d0a..81fa521bbaf 100644 --- a/homeassistant/components/eheimdigital/strings.json +++ b/homeassistant/components/eheimdigital/strings.json @@ -46,6 +46,22 @@ } } } + }, + "sensor": { + "current_speed": { + "name": "Current speed" + }, + "service_hours": { + "name": "Remaining hours until service" + }, + "error_code": { + "name": "Error code", + "state": { + "no_error": "No error", + "rotor_stuck": "Rotor stuck", + "air_in_filter": "Air in filter" + } + } } } } diff --git a/tests/components/eheimdigital/conftest.py b/tests/components/eheimdigital/conftest.py index ae1bc74df90..2c4af207642 100644 --- a/tests/components/eheimdigital/conftest.py +++ b/tests/components/eheimdigital/conftest.py @@ -4,9 +4,17 @@ from collections.abc import Generator from unittest.mock import AsyncMock, MagicMock, patch from eheimdigital.classic_led_ctrl import EheimDigitalClassicLEDControl +from eheimdigital.classic_vario import EheimDigitalClassicVario from eheimdigital.heater import EheimDigitalHeater from eheimdigital.hub import EheimDigitalHub -from eheimdigital.types import EheimDeviceType, HeaterMode, HeaterUnit, LightMode +from eheimdigital.types import ( + EheimDeviceType, + FilterErrorCode, + FilterMode, + HeaterMode, + HeaterUnit, + LightMode, +) import pytest from homeassistant.components.eheimdigital.const import DOMAIN @@ -59,9 +67,28 @@ def heater_mock(): return heater_mock +@pytest.fixture +def classic_vario_mock(): + """Mock a classicVARIO device.""" + classic_vario_mock = MagicMock(spec=EheimDigitalClassicVario) + classic_vario_mock.mac_address = "00:00:00:00:00:03" + classic_vario_mock.device_type = EheimDeviceType.VERSION_EHEIM_CLASSIC_VARIO + classic_vario_mock.name = "Mock classicVARIO" + classic_vario_mock.aquarium_name = "Mock Aquarium" + classic_vario_mock.sw_version = "1.0.0_1.0.0" + classic_vario_mock.current_speed = 75 + classic_vario_mock.is_active = True + classic_vario_mock.filter_mode = FilterMode.MANUAL + classic_vario_mock.error_code = FilterErrorCode.NO_ERROR + classic_vario_mock.service_hours = 360 + return classic_vario_mock + + @pytest.fixture def eheimdigital_hub_mock( - classic_led_ctrl_mock: MagicMock, heater_mock: MagicMock + classic_led_ctrl_mock: MagicMock, + heater_mock: MagicMock, + classic_vario_mock: MagicMock, ) -> Generator[AsyncMock]: """Mock eheimdigital hub.""" with ( @@ -77,6 +104,7 @@ def eheimdigital_hub_mock( eheimdigital_hub_mock.return_value.devices = { "00:00:00:00:00:01": classic_led_ctrl_mock, "00:00:00:00:00:02": heater_mock, + "00:00:00:00:00:03": classic_vario_mock, } eheimdigital_hub_mock.return_value.main = classic_led_ctrl_mock yield eheimdigital_hub_mock diff --git a/tests/components/eheimdigital/snapshots/test_sensor.ambr b/tests/components/eheimdigital/snapshots/test_sensor.ambr new file mode 100644 index 00000000000..c5a3d700331 --- /dev/null +++ b/tests/components/eheimdigital/snapshots/test_sensor.ambr @@ -0,0 +1,160 @@ +# serializer version: 1 +# name: test_setup_classic_vario[sensor.mock_classicvario_current_speed-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.mock_classicvario_current_speed', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Current speed', + 'platform': 'eheimdigital', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'current_speed', + 'unique_id': '00:00:00:00:00:03_current_speed', + 'unit_of_measurement': '%', + }) +# --- +# name: test_setup_classic_vario[sensor.mock_classicvario_current_speed-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Mock classicVARIO Current speed', + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.mock_classicvario_current_speed', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_setup_classic_vario[sensor.mock_classicvario_error_code-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'no_error', + 'rotor_stuck', + 'air_in_filter', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.mock_classicvario_error_code', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Error code', + 'platform': 'eheimdigital', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'error_code', + 'unique_id': '00:00:00:00:00:03_error_code', + 'unit_of_measurement': None, + }) +# --- +# name: test_setup_classic_vario[sensor.mock_classicvario_error_code-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'enum', + 'friendly_name': 'Mock classicVARIO Error code', + 'options': list([ + 'no_error', + 'rotor_stuck', + 'air_in_filter', + ]), + }), + 'context': , + 'entity_id': 'sensor.mock_classicvario_error_code', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_setup_classic_vario[sensor.mock_classicvario_remaining_hours_until_service-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.mock_classicvario_remaining_hours_until_service', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Remaining hours until service', + 'platform': 'eheimdigital', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'service_hours', + 'unique_id': '00:00:00:00:00:03_service_hours', + 'unit_of_measurement': , + }) +# --- +# name: test_setup_classic_vario[sensor.mock_classicvario_remaining_hours_until_service-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'duration', + 'friendly_name': 'Mock classicVARIO Remaining hours until service', + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.mock_classicvario_remaining_hours_until_service', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- diff --git a/tests/components/eheimdigital/test_sensor.py b/tests/components/eheimdigital/test_sensor.py new file mode 100644 index 00000000000..ece4d3eb241 --- /dev/null +++ b/tests/components/eheimdigital/test_sensor.py @@ -0,0 +1,77 @@ +"""Tests for the sensor module.""" + +from unittest.mock import AsyncMock, MagicMock, patch + +from eheimdigital.types import EheimDeviceType, FilterErrorCode +import pytest +from syrupy.assertion import SnapshotAssertion + +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from .conftest import init_integration + +from tests.common import MockConfigEntry, snapshot_platform + + +@pytest.mark.usefixtures("classic_vario_mock") +async def test_setup_classic_vario( + hass: HomeAssistant, + eheimdigital_hub_mock: MagicMock, + mock_config_entry: MockConfigEntry, + entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, +) -> None: + """Test sensor platform setup for the filter.""" + mock_config_entry.add_to_hass(hass) + + with ( + patch("homeassistant.components.eheimdigital.PLATFORMS", [Platform.SENSOR]), + patch( + "homeassistant.components.eheimdigital.coordinator.asyncio.Event", + new=AsyncMock, + ), + ): + await hass.config_entries.async_setup(mock_config_entry.entry_id) + + await eheimdigital_hub_mock.call_args.kwargs["device_found_callback"]( + "00:00:00:00:00:03", EheimDeviceType.VERSION_EHEIM_CLASSIC_VARIO + ) + await hass.async_block_till_done() + + await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) + + +async def test_state_update( + hass: HomeAssistant, + eheimdigital_hub_mock: MagicMock, + mock_config_entry: MockConfigEntry, + classic_vario_mock: MagicMock, +) -> None: + """Test the sensor state update.""" + await init_integration(hass, mock_config_entry) + + await eheimdigital_hub_mock.call_args.kwargs["device_found_callback"]( + "00:00:00:00:00:03", EheimDeviceType.VERSION_EHEIM_CLASSIC_VARIO + ) + await hass.async_block_till_done() + + classic_vario_mock.current_speed = 10 + classic_vario_mock.error_code = FilterErrorCode.ROTOR_STUCK + classic_vario_mock.service_hours = 100 + + await eheimdigital_hub_mock.call_args.kwargs["receive_callback"]() + + assert (state := hass.states.get("sensor.mock_classicvario_current_speed")) + assert state.state == "10" + + assert (state := hass.states.get("sensor.mock_classicvario_error_code")) + assert state.state == "rotor_stuck" + + assert ( + state := hass.states.get( + "sensor.mock_classicvario_remaining_hours_until_service" + ) + ) + assert state.state == str(round(100 / 24, 1)) From 9a897d5e12417ab065c1255522c94a290740f884 Mon Sep 17 00:00:00 2001 From: Simone Chemelli Date: Sun, 6 Apr 2025 17:04:34 +0200 Subject: [PATCH 324/505] Update Fritz quality scale (#142411) --- homeassistant/components/fritz/quality_scale.yaml | 10 +++------- 1 file changed, 3 insertions(+), 7 deletions(-) diff --git a/homeassistant/components/fritz/quality_scale.yaml b/homeassistant/components/fritz/quality_scale.yaml index 805705eb4b4..40cf518d114 100644 --- a/homeassistant/components/fritz/quality_scale.yaml +++ b/homeassistant/components/fritz/quality_scale.yaml @@ -14,9 +14,7 @@ rules: docs-actions: done docs-high-level-description: done docs-installation-instructions: done - docs-removal-instructions: - status: todo - comment: include the proper docs snippet + docs-removal-instructions: done entity-event-setup: done entity-unique-id: done has-entity-name: @@ -31,9 +29,7 @@ rules: action-exceptions: done config-entry-unloading: done docs-configuration-parameters: done - docs-installation-parameters: - status: todo - comment: add the proper configuration_basic block + docs-installation-parameters: done entity-unavailable: done integration-owner: done log-when-unavailable: done @@ -50,7 +46,7 @@ rules: diagnostics: done discovery-update-info: todo discovery: done - docs-data-update: todo + docs-data-update: done docs-examples: done docs-known-limitations: status: exempt From e96f2f06fb796691dafc9b5db14bc9e5c95cb085 Mon Sep 17 00:00:00 2001 From: Simone Chemelli Date: Sun, 6 Apr 2025 17:28:51 +0200 Subject: [PATCH 325/505] Add parallel updates to Fritz (#142409) * Add parallel updates to Fritz * apply review comment * tweak --- homeassistant/components/fritz/binary_sensor.py | 3 +++ homeassistant/components/fritz/button.py | 3 +++ homeassistant/components/fritz/device_tracker.py | 3 +++ homeassistant/components/fritz/image.py | 3 +++ homeassistant/components/fritz/quality_scale.yaml | 4 +--- homeassistant/components/fritz/sensor.py | 3 +++ homeassistant/components/fritz/switch.py | 3 +++ homeassistant/components/fritz/update.py | 3 +++ 8 files changed, 22 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/fritz/binary_sensor.py b/homeassistant/components/fritz/binary_sensor.py index 6bc8bb571d4..2a4eb8c82b5 100644 --- a/homeassistant/components/fritz/binary_sensor.py +++ b/homeassistant/components/fritz/binary_sensor.py @@ -20,6 +20,9 @@ from .entity import FritzBoxBaseCoordinatorEntity, FritzEntityDescription _LOGGER = logging.getLogger(__name__) +# Coordinator is used to centralize the data updates +PARALLEL_UPDATES = 0 + @dataclass(frozen=True, kw_only=True) class FritzBinarySensorEntityDescription( diff --git a/homeassistant/components/fritz/button.py b/homeassistant/components/fritz/button.py index 74e8ab5e43e..4a5f7e5a443 100644 --- a/homeassistant/components/fritz/button.py +++ b/homeassistant/components/fritz/button.py @@ -31,6 +31,9 @@ from .entity import FritzDeviceBase _LOGGER = logging.getLogger(__name__) +# Set a sane value to avoid too many updates +PARALLEL_UPDATES = 5 + @dataclass(frozen=True, kw_only=True) class FritzButtonDescription(ButtonEntityDescription): diff --git a/homeassistant/components/fritz/device_tracker.py b/homeassistant/components/fritz/device_tracker.py index e066219342e..618214a1c55 100644 --- a/homeassistant/components/fritz/device_tracker.py +++ b/homeassistant/components/fritz/device_tracker.py @@ -22,6 +22,9 @@ from .entity import FritzDeviceBase _LOGGER = logging.getLogger(__name__) +# Coordinator is used to centralize the data updates +PARALLEL_UPDATES = 0 + async def async_setup_entry( hass: HomeAssistant, diff --git a/homeassistant/components/fritz/image.py b/homeassistant/components/fritz/image.py index d329ec318c5..1fc70dedc6c 100644 --- a/homeassistant/components/fritz/image.py +++ b/homeassistant/components/fritz/image.py @@ -18,6 +18,9 @@ from .entity import FritzBoxBaseEntity _LOGGER = logging.getLogger(__name__) +# Coordinator is used to centralize the data updates +PARALLEL_UPDATES = 0 + async def async_setup_entry( hass: HomeAssistant, diff --git a/homeassistant/components/fritz/quality_scale.yaml b/homeassistant/components/fritz/quality_scale.yaml index 40cf518d114..29e46b3a0c9 100644 --- a/homeassistant/components/fritz/quality_scale.yaml +++ b/homeassistant/components/fritz/quality_scale.yaml @@ -33,9 +33,7 @@ rules: entity-unavailable: done integration-owner: done log-when-unavailable: done - parallel-updates: - status: todo - comment: not set at the moment, we use a coordinator + parallel-updates: done reauthentication-flow: done test-coverage: status: todo diff --git a/homeassistant/components/fritz/sensor.py b/homeassistant/components/fritz/sensor.py index 243b3b5eb4c..65a776b9ad5 100644 --- a/homeassistant/components/fritz/sensor.py +++ b/homeassistant/components/fritz/sensor.py @@ -32,6 +32,9 @@ from .entity import FritzBoxBaseCoordinatorEntity, FritzEntityDescription _LOGGER = logging.getLogger(__name__) +# Coordinator is used to centralize the data updates +PARALLEL_UPDATES = 0 + def _uptime_calculation(seconds_uptime: float, last_value: datetime | None) -> datetime: """Calculate uptime with deviation.""" diff --git a/homeassistant/components/fritz/switch.py b/homeassistant/components/fritz/switch.py index 8b4816f7451..c00849c5240 100644 --- a/homeassistant/components/fritz/switch.py +++ b/homeassistant/components/fritz/switch.py @@ -38,6 +38,9 @@ from .entity import FritzBoxBaseEntity, FritzDeviceBase _LOGGER = logging.getLogger(__name__) +# Set a sane value to avoid too many updates +PARALLEL_UPDATES = 5 + async def _async_deflection_entities_list( avm_wrapper: AvmWrapper, device_friendly_name: str diff --git a/homeassistant/components/fritz/update.py b/homeassistant/components/fritz/update.py index 5d064dc3035..4e54f4c28d3 100644 --- a/homeassistant/components/fritz/update.py +++ b/homeassistant/components/fritz/update.py @@ -20,6 +20,9 @@ from .entity import FritzBoxBaseCoordinatorEntity, FritzEntityDescription _LOGGER = logging.getLogger(__name__) +# Set a sane value to avoid too many updates +PARALLEL_UPDATES = 5 + @dataclass(frozen=True, kw_only=True) class FritzUpdateEntityDescription(UpdateEntityDescription, FritzEntityDescription): From bea389eed74e9031165f93185bf336c95dde3d19 Mon Sep 17 00:00:00 2001 From: Simone Chemelli Date: Sun, 6 Apr 2025 18:25:57 +0200 Subject: [PATCH 326/505] Add parallel updates to SamsungTV (#142403) --- homeassistant/components/samsungtv/media_player.py | 3 +++ homeassistant/components/samsungtv/remote.py | 3 +++ 2 files changed, 6 insertions(+) diff --git a/homeassistant/components/samsungtv/media_player.py b/homeassistant/components/samsungtv/media_player.py index 4e6ecfd3593..1c475ee6c25 100644 --- a/homeassistant/components/samsungtv/media_player.py +++ b/homeassistant/components/samsungtv/media_player.py @@ -59,6 +59,9 @@ SUPPORT_SAMSUNGTV = ( # Max delay waiting for app_list to return, as some TVs simply ignore the request APP_LIST_DELAY = 3 +# Coordinator is used to centralize the data updates +PARALLEL_UPDATES = 0 + async def async_setup_entry( hass: HomeAssistant, diff --git a/homeassistant/components/samsungtv/remote.py b/homeassistant/components/samsungtv/remote.py index d6fef262d91..2c6b46c8bb2 100644 --- a/homeassistant/components/samsungtv/remote.py +++ b/homeassistant/components/samsungtv/remote.py @@ -13,6 +13,9 @@ from .const import LOGGER from .coordinator import SamsungTVConfigEntry from .entity import SamsungTVEntity +# Coordinator is used to centralize the data updates +PARALLEL_UPDATES = 0 + async def async_setup_entry( hass: HomeAssistant, From 7c488f1e54508c49a555aba59b7a62841a0e7b1f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=81lvaro=20Fern=C3=A1ndez=20Rojas?= Date: Sun, 6 Apr 2025 20:07:46 +0200 Subject: [PATCH 327/505] Add thermostat battery and signal sensors for Airzone integration (#142390) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * airzone: add thermostat battery/signal sensors Signed-off-by: Álvaro Fernández Rojas * tests: airzone: use snapshot_platform for sensors Signed-off-by: Álvaro Fernández Rojas * airzone: rename sensor strength Signed-off-by: Álvaro Fernández Rojas --------- Signed-off-by: Álvaro Fernández Rojas --- homeassistant/components/airzone/sensor.py | 16 + homeassistant/components/airzone/strings.json | 3 + .../airzone/snapshots/test_sensor.ambr | 1245 +++++++++++++++++ tests/components/airzone/test_sensor.py | 70 +- tests/components/airzone/util.py | 4 +- 5 files changed, 1286 insertions(+), 52 deletions(-) create mode 100644 tests/components/airzone/snapshots/test_sensor.ambr diff --git a/homeassistant/components/airzone/sensor.py b/homeassistant/components/airzone/sensor.py index f76eb1466a3..66657836b74 100644 --- a/homeassistant/components/airzone/sensor.py +++ b/homeassistant/components/airzone/sensor.py @@ -9,6 +9,8 @@ from aioairzone.const import ( AZD_HUMIDITY, AZD_TEMP, AZD_TEMP_UNIT, + AZD_THERMOSTAT_BATTERY, + AZD_THERMOSTAT_SIGNAL, AZD_WEBSERVER, AZD_WIFI_RSSI, AZD_ZONES, @@ -73,6 +75,20 @@ ZONE_SENSOR_TYPES: Final[tuple[SensorEntityDescription, ...]] = ( native_unit_of_measurement=PERCENTAGE, state_class=SensorStateClass.MEASUREMENT, ), + SensorEntityDescription( + device_class=SensorDeviceClass.BATTERY, + key=AZD_THERMOSTAT_BATTERY, + native_unit_of_measurement=PERCENTAGE, + state_class=SensorStateClass.MEASUREMENT, + ), + SensorEntityDescription( + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + key=AZD_THERMOSTAT_SIGNAL, + native_unit_of_measurement=PERCENTAGE, + state_class=SensorStateClass.MEASUREMENT, + translation_key="thermostat_signal", + ), ) diff --git a/homeassistant/components/airzone/strings.json b/homeassistant/components/airzone/strings.json index cd313b821aa..c7d9701aa83 100644 --- a/homeassistant/components/airzone/strings.json +++ b/homeassistant/components/airzone/strings.json @@ -76,6 +76,9 @@ "sensor": { "rssi": { "name": "RSSI" + }, + "thermostat_signal": { + "name": "Signal strength" } } } diff --git a/tests/components/airzone/snapshots/test_sensor.ambr b/tests/components/airzone/snapshots/test_sensor.ambr new file mode 100644 index 00000000000..01ebf35b282 --- /dev/null +++ b/tests/components/airzone/snapshots/test_sensor.ambr @@ -0,0 +1,1245 @@ +# serializer version: 1 +# name: test_airzone_create_sensors[sensor.airzone_2_1_humidity-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.airzone_2_1_humidity', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Humidity', + 'platform': 'airzone', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'airzone_unique_id_2:1_humidity', + 'unit_of_measurement': '%', + }) +# --- +# name: test_airzone_create_sensors[sensor.airzone_2_1_humidity-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'humidity', + 'friendly_name': 'Airzone 2:1 Humidity', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.airzone_2_1_humidity', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '62', + }) +# --- +# name: test_airzone_create_sensors[sensor.airzone_2_1_temperature-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.airzone_2_1_temperature', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Temperature', + 'platform': 'airzone', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'airzone_unique_id_2:1_temp', + 'unit_of_measurement': , + }) +# --- +# name: test_airzone_create_sensors[sensor.airzone_2_1_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'Airzone 2:1 Temperature', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.airzone_2_1_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '22.3', + }) +# --- +# name: test_airzone_create_sensors[sensor.airzone_dhw_temperature-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.airzone_dhw_temperature', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Temperature', + 'platform': 'airzone', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'airzone_unique_id_dhw_temp', + 'unit_of_measurement': , + }) +# --- +# name: test_airzone_create_sensors[sensor.airzone_dhw_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'Airzone DHW Temperature', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.airzone_dhw_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '43', + }) +# --- +# name: test_airzone_create_sensors[sensor.airzone_webserver_rssi-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.airzone_webserver_rssi', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'RSSI', + 'platform': 'airzone', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'rssi', + 'unique_id': 'airzone_unique_id_ws_wifi-rssi', + 'unit_of_measurement': 'dBm', + }) +# --- +# name: test_airzone_create_sensors[sensor.airzone_webserver_rssi-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'signal_strength', + 'friendly_name': 'Airzone WebServer RSSI', + 'state_class': , + 'unit_of_measurement': 'dBm', + }), + 'context': , + 'entity_id': 'sensor.airzone_webserver_rssi', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '-42', + }) +# --- +# name: test_airzone_create_sensors[sensor.aux_heat_temperature-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.aux_heat_temperature', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Temperature', + 'platform': 'airzone', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'airzone_unique_id_4:1_temp', + 'unit_of_measurement': , + }) +# --- +# name: test_airzone_create_sensors[sensor.aux_heat_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'Aux Heat Temperature', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.aux_heat_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '22.0', + }) +# --- +# name: test_airzone_create_sensors[sensor.despacho_battery-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.despacho_battery', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Battery', + 'platform': 'airzone', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'airzone_unique_id_1:4_thermostat-battery', + 'unit_of_measurement': '%', + }) +# --- +# name: test_airzone_create_sensors[sensor.despacho_battery-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'battery', + 'friendly_name': 'Despacho Battery', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.despacho_battery', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '25', + }) +# --- +# name: test_airzone_create_sensors[sensor.despacho_humidity-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.despacho_humidity', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Humidity', + 'platform': 'airzone', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'airzone_unique_id_1:4_humidity', + 'unit_of_measurement': '%', + }) +# --- +# name: test_airzone_create_sensors[sensor.despacho_humidity-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'humidity', + 'friendly_name': 'Despacho Humidity', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.despacho_humidity', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '36', + }) +# --- +# name: test_airzone_create_sensors[sensor.despacho_signal_strength-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.despacho_signal_strength', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Signal strength', + 'platform': 'airzone', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'thermostat_signal', + 'unique_id': 'airzone_unique_id_1:4_thermostat-signal', + 'unit_of_measurement': '%', + }) +# --- +# name: test_airzone_create_sensors[sensor.despacho_signal_strength-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Despacho Signal strength', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.despacho_signal_strength', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '88', + }) +# --- +# name: test_airzone_create_sensors[sensor.despacho_temperature-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.despacho_temperature', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Temperature', + 'platform': 'airzone', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'airzone_unique_id_1:4_temp', + 'unit_of_measurement': , + }) +# --- +# name: test_airzone_create_sensors[sensor.despacho_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'Despacho Temperature', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.despacho_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '21.20', + }) +# --- +# name: test_airzone_create_sensors[sensor.dkn_plus_temperature-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.dkn_plus_temperature', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Temperature', + 'platform': 'airzone', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'airzone_unique_id_3:1_temp', + 'unit_of_measurement': , + }) +# --- +# name: test_airzone_create_sensors[sensor.dkn_plus_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'DKN Plus Temperature', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.dkn_plus_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '21.7', + }) +# --- +# name: test_airzone_create_sensors[sensor.dorm_1_battery-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.dorm_1_battery', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Battery', + 'platform': 'airzone', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'airzone_unique_id_1:3_thermostat-battery', + 'unit_of_measurement': '%', + }) +# --- +# name: test_airzone_create_sensors[sensor.dorm_1_battery-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'battery', + 'friendly_name': 'Dorm #1 Battery', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.dorm_1_battery', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '35', + }) +# --- +# name: test_airzone_create_sensors[sensor.dorm_1_humidity-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.dorm_1_humidity', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Humidity', + 'platform': 'airzone', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'airzone_unique_id_1:3_humidity', + 'unit_of_measurement': '%', + }) +# --- +# name: test_airzone_create_sensors[sensor.dorm_1_humidity-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'humidity', + 'friendly_name': 'Dorm #1 Humidity', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.dorm_1_humidity', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '35', + }) +# --- +# name: test_airzone_create_sensors[sensor.dorm_1_signal_strength-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.dorm_1_signal_strength', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Signal strength', + 'platform': 'airzone', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'thermostat_signal', + 'unique_id': 'airzone_unique_id_1:3_thermostat-signal', + 'unit_of_measurement': '%', + }) +# --- +# name: test_airzone_create_sensors[sensor.dorm_1_signal_strength-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Dorm #1 Signal strength', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.dorm_1_signal_strength', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '60', + }) +# --- +# name: test_airzone_create_sensors[sensor.dorm_1_temperature-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.dorm_1_temperature', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Temperature', + 'platform': 'airzone', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'airzone_unique_id_1:3_temp', + 'unit_of_measurement': , + }) +# --- +# name: test_airzone_create_sensors[sensor.dorm_1_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'Dorm #1 Temperature', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.dorm_1_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '20.8', + }) +# --- +# name: test_airzone_create_sensors[sensor.dorm_2_battery-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.dorm_2_battery', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Battery', + 'platform': 'airzone', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'airzone_unique_id_1:5_thermostat-battery', + 'unit_of_measurement': '%', + }) +# --- +# name: test_airzone_create_sensors[sensor.dorm_2_battery-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'battery', + 'friendly_name': 'Dorm #2 Battery', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.dorm_2_battery', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '80', + }) +# --- +# name: test_airzone_create_sensors[sensor.dorm_2_humidity-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.dorm_2_humidity', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Humidity', + 'platform': 'airzone', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'airzone_unique_id_1:5_humidity', + 'unit_of_measurement': '%', + }) +# --- +# name: test_airzone_create_sensors[sensor.dorm_2_humidity-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'humidity', + 'friendly_name': 'Dorm #2 Humidity', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.dorm_2_humidity', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '40', + }) +# --- +# name: test_airzone_create_sensors[sensor.dorm_2_signal_strength-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.dorm_2_signal_strength', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Signal strength', + 'platform': 'airzone', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'thermostat_signal', + 'unique_id': 'airzone_unique_id_1:5_thermostat-signal', + 'unit_of_measurement': '%', + }) +# --- +# name: test_airzone_create_sensors[sensor.dorm_2_signal_strength-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Dorm #2 Signal strength', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.dorm_2_signal_strength', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '66', + }) +# --- +# name: test_airzone_create_sensors[sensor.dorm_2_temperature-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.dorm_2_temperature', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Temperature', + 'platform': 'airzone', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'airzone_unique_id_1:5_temp', + 'unit_of_measurement': , + }) +# --- +# name: test_airzone_create_sensors[sensor.dorm_2_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'Dorm #2 Temperature', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.dorm_2_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '20.5', + }) +# --- +# name: test_airzone_create_sensors[sensor.dorm_ppal_battery-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.dorm_ppal_battery', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Battery', + 'platform': 'airzone', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'airzone_unique_id_1:2_thermostat-battery', + 'unit_of_measurement': '%', + }) +# --- +# name: test_airzone_create_sensors[sensor.dorm_ppal_battery-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'battery', + 'friendly_name': 'Dorm Ppal Battery', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.dorm_ppal_battery', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '99', + }) +# --- +# name: test_airzone_create_sensors[sensor.dorm_ppal_humidity-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.dorm_ppal_humidity', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Humidity', + 'platform': 'airzone', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'airzone_unique_id_1:2_humidity', + 'unit_of_measurement': '%', + }) +# --- +# name: test_airzone_create_sensors[sensor.dorm_ppal_humidity-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'humidity', + 'friendly_name': 'Dorm Ppal Humidity', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.dorm_ppal_humidity', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '39', + }) +# --- +# name: test_airzone_create_sensors[sensor.dorm_ppal_signal_strength-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.dorm_ppal_signal_strength', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Signal strength', + 'platform': 'airzone', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'thermostat_signal', + 'unique_id': 'airzone_unique_id_1:2_thermostat-signal', + 'unit_of_measurement': '%', + }) +# --- +# name: test_airzone_create_sensors[sensor.dorm_ppal_signal_strength-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Dorm Ppal Signal strength', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.dorm_ppal_signal_strength', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '72', + }) +# --- +# name: test_airzone_create_sensors[sensor.dorm_ppal_temperature-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.dorm_ppal_temperature', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Temperature', + 'platform': 'airzone', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'airzone_unique_id_1:2_temp', + 'unit_of_measurement': , + }) +# --- +# name: test_airzone_create_sensors[sensor.dorm_ppal_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'Dorm Ppal Temperature', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.dorm_ppal_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '21.1', + }) +# --- +# name: test_airzone_create_sensors[sensor.salon_humidity-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.salon_humidity', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Humidity', + 'platform': 'airzone', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'airzone_unique_id_1:1_humidity', + 'unit_of_measurement': '%', + }) +# --- +# name: test_airzone_create_sensors[sensor.salon_humidity-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'humidity', + 'friendly_name': 'Salon Humidity', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.salon_humidity', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '34', + }) +# --- +# name: test_airzone_create_sensors[sensor.salon_temperature-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.salon_temperature', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Temperature', + 'platform': 'airzone', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'airzone_unique_id_1:1_temp', + 'unit_of_measurement': , + }) +# --- +# name: test_airzone_create_sensors[sensor.salon_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'Salon Temperature', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.salon_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '19.6', + }) +# --- diff --git a/tests/components/airzone/test_sensor.py b/tests/components/airzone/test_sensor.py index 352994d6313..b226be8ac78 100644 --- a/tests/components/airzone/test_sensor.py +++ b/tests/components/airzone/test_sensor.py @@ -1,14 +1,17 @@ """The sensor tests for the Airzone platform.""" +from collections.abc import Generator import copy from unittest.mock import patch from aioairzone.const import API_DATA, API_SYSTEMS import pytest +from syrupy.assertion import SnapshotAssertion from homeassistant.components.airzone.coordinator import SCAN_INTERVAL -from homeassistant.const import STATE_UNAVAILABLE +from homeassistant.const import STATE_UNAVAILABLE, Platform from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er from homeassistant.util.dt import utcnow from .util import ( @@ -20,62 +23,27 @@ from .util import ( async_init_integration, ) -from tests.common import async_fire_time_changed +from tests.common import async_fire_time_changed, snapshot_platform + + +@pytest.fixture(autouse=True) +def override_platforms() -> Generator[None]: + """Override PLATFORMS.""" + with patch("homeassistant.components.airzone.PLATFORMS", [Platform.SENSOR]): + yield @pytest.mark.usefixtures("entity_registry_enabled_by_default") -async def test_airzone_create_sensors(hass: HomeAssistant) -> None: +async def test_airzone_create_sensors( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, +) -> None: """Test creation of sensors.""" - await async_init_integration(hass) + config_entry = await async_init_integration(hass) - # Hot Water - state = hass.states.get("sensor.airzone_dhw_temperature") - assert state.state == "43" - - # WebServer - state = hass.states.get("sensor.airzone_webserver_rssi") - assert state.state == "-42" - - # Zones - state = hass.states.get("sensor.despacho_temperature") - assert state.state == "21.20" - - state = hass.states.get("sensor.despacho_humidity") - assert state.state == "36" - - state = hass.states.get("sensor.dorm_1_temperature") - assert state.state == "20.8" - - state = hass.states.get("sensor.dorm_1_humidity") - assert state.state == "35" - - state = hass.states.get("sensor.dorm_2_temperature") - assert state.state == "20.5" - - state = hass.states.get("sensor.dorm_2_humidity") - assert state.state == "40" - - state = hass.states.get("sensor.dorm_ppal_temperature") - assert state.state == "21.1" - - state = hass.states.get("sensor.dorm_ppal_humidity") - assert state.state == "39" - - state = hass.states.get("sensor.salon_temperature") - assert state.state == "19.6" - - state = hass.states.get("sensor.salon_humidity") - assert state.state == "34" - - state = hass.states.get("sensor.airzone_2_1_temperature") - assert state.state == "22.3" - - state = hass.states.get("sensor.airzone_2_1_humidity") - assert state.state == "62" - - state = hass.states.get("sensor.dkn_plus_temperature") - assert state.state == "21.7" + await snapshot_platform(hass, entity_registry, snapshot, config_entry.entry_id) state = hass.states.get("sensor.dkn_plus_humidity") assert state is None diff --git a/tests/components/airzone/util.py b/tests/components/airzone/util.py index 944ca83d053..55cb32b67a5 100644 --- a/tests/components/airzone/util.py +++ b/tests/components/airzone/util.py @@ -371,7 +371,7 @@ HVAC_WEBSERVER_MOCK = { async def async_init_integration( hass: HomeAssistant, -) -> None: +) -> MockConfigEntry: """Set up the Airzone integration in Home Assistant.""" config_entry = MockConfigEntry( @@ -407,3 +407,5 @@ async def async_init_integration( ): await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() + + return config_entry From a44adf2e6f49c12dfa3b104e7eacad2678e413e1 Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Mon, 7 Apr 2025 09:17:21 +0200 Subject: [PATCH 328/505] Use common states for `battery_critical` in `nuki` (#142349) Replace "on": "Low" and "off": "Normal" with common states. This will allow us to use the common states in the `binary_sensor` class, too. --- homeassistant/components/nuki/strings.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/nuki/strings.json b/homeassistant/components/nuki/strings.json index daf47bc7de1..84e66c3db96 100644 --- a/homeassistant/components/nuki/strings.json +++ b/homeassistant/components/nuki/strings.json @@ -48,8 +48,8 @@ "state_attributes": { "battery_critical": { "state": { - "on": "[%key:component::binary_sensor::entity_component::battery::state::on%]", - "off": "[%key:component::binary_sensor::entity_component::battery::state::off%]" + "on": "[%key:common::state::low%]", + "off": "[%key:common::state::normal%]" } } } From 3e4a077862bd9ad62c70bde9130fd87e75fa8087 Mon Sep 17 00:00:00 2001 From: starkillerOG Date: Mon, 7 Apr 2025 09:35:44 +0200 Subject: [PATCH 329/505] Fix Reolink smart AI sensors (#142454) --- homeassistant/components/reolink/binary_sensor.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/reolink/binary_sensor.py b/homeassistant/components/reolink/binary_sensor.py index 39910bbc52a..95c5f1982c3 100644 --- a/homeassistant/components/reolink/binary_sensor.py +++ b/homeassistant/components/reolink/binary_sensor.py @@ -301,7 +301,7 @@ async def async_setup_entry( ) for entity_description in BINARY_SMART_AI_SENSORS for location in api.baichuan.smart_location_list( - channel, entity_description.key + channel, entity_description.smart_type ) if entity_description.supported(api, channel, location) ) From 056d26f13c5800312088287d23021733c7a060f8 Mon Sep 17 00:00:00 2001 From: TimL Date: Mon, 7 Apr 2025 17:38:50 +1000 Subject: [PATCH 330/505] Set parallel updates for SMLIGHT entities (#142455) Set parallel updates for entities --- homeassistant/components/smlight/binary_sensor.py | 1 + homeassistant/components/smlight/button.py | 2 ++ homeassistant/components/smlight/sensor.py | 2 ++ homeassistant/components/smlight/switch.py | 2 ++ homeassistant/components/smlight/update.py | 2 ++ 5 files changed, 9 insertions(+) diff --git a/homeassistant/components/smlight/binary_sensor.py b/homeassistant/components/smlight/binary_sensor.py index ce3457ae81b..aaba15e19f2 100644 --- a/homeassistant/components/smlight/binary_sensor.py +++ b/homeassistant/components/smlight/binary_sensor.py @@ -22,6 +22,7 @@ from .const import SCAN_INTERNET_INTERVAL from .coordinator import SmConfigEntry, SmDataUpdateCoordinator from .entity import SmEntity +PARALLEL_UPDATES = 0 SCAN_INTERVAL = SCAN_INTERNET_INTERVAL diff --git a/homeassistant/components/smlight/button.py b/homeassistant/components/smlight/button.py index 5caf43b7cba..f834392ea13 100644 --- a/homeassistant/components/smlight/button.py +++ b/homeassistant/components/smlight/button.py @@ -23,6 +23,8 @@ from .const import DOMAIN from .coordinator import SmConfigEntry, SmDataUpdateCoordinator from .entity import SmEntity +PARALLEL_UPDATES = 1 + _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/smlight/sensor.py b/homeassistant/components/smlight/sensor.py index 2f57843b5eb..f045d009a00 100644 --- a/homeassistant/components/smlight/sensor.py +++ b/homeassistant/components/smlight/sensor.py @@ -25,6 +25,8 @@ from .const import UPTIME_DEVIATION from .coordinator import SmConfigEntry, SmDataUpdateCoordinator from .entity import SmEntity +PARALLEL_UPDATES = 0 + @dataclass(frozen=True, kw_only=True) class SmSensorEntityDescription(SensorEntityDescription): diff --git a/homeassistant/components/smlight/switch.py b/homeassistant/components/smlight/switch.py index 09d2714956c..5cd187c009c 100644 --- a/homeassistant/components/smlight/switch.py +++ b/homeassistant/components/smlight/switch.py @@ -22,6 +22,8 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .coordinator import SmConfigEntry, SmDataUpdateCoordinator from .entity import SmEntity +PARALLEL_UPDATES = 1 + _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/smlight/update.py b/homeassistant/components/smlight/update.py index 3143f2f4290..48f9149645c 100644 --- a/homeassistant/components/smlight/update.py +++ b/homeassistant/components/smlight/update.py @@ -26,6 +26,8 @@ from .const import LOGGER from .coordinator import SmConfigEntry, SmFirmwareUpdateCoordinator, SmFwData from .entity import SmEntity +PARALLEL_UPDATES = 1 + def zigbee_latest_version(data: SmFwData, idx: int) -> Firmware | None: """Get the latest Zigbee firmware version.""" From 1e104ba40b100f978c4486f30d1f57b693cf89e6 Mon Sep 17 00:00:00 2001 From: Simone Chemelli Date: Mon, 7 Apr 2025 09:40:06 +0200 Subject: [PATCH 331/505] Add missing strings to SamsungTV (#142405) --- .../components/samsungtv/strings.json | 19 ++++++++++++++++--- 1 file changed, 16 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/samsungtv/strings.json b/homeassistant/components/samsungtv/strings.json index c9d08f756d0..d08e2a843ba 100644 --- a/homeassistant/components/samsungtv/strings.json +++ b/homeassistant/components/samsungtv/strings.json @@ -9,7 +9,8 @@ "name": "[%key:common::config_flow::data::name%]" }, "data_description": { - "host": "The hostname or IP address of your TV." + "host": "The hostname or IP address of your TV.", + "name": "The name of your TV. This will be used to identify the device in Home Assistant." } }, "confirm": { @@ -22,10 +23,22 @@ "description": "After submitting, accept the popup on {device} requesting authorization within 30 seconds or input PIN." }, "encrypted_pairing": { - "description": "Please enter the PIN displayed on {device}." + "description": "Please enter the PIN displayed on {device}.", + "data": { + "pin": "[%key:common::config_flow::data::pin%]" + }, + "data_description": { + "pin": "The PIN displayed on your TV." + } }, "reauth_confirm_encrypted": { - "description": "[%key:component::samsungtv::config::step::encrypted_pairing::description%]" + "description": "[%key:component::samsungtv::config::step::encrypted_pairing::description%]", + "data": { + "pin": "[%key:common::config_flow::data::pin%]" + }, + "data_description": { + "pin": "[%key:component::samsungtv::config::step::encrypted_pairing::data_description::pin%]" + } } }, "error": { From 43f93c74daf461a9f582cf31c1e7557512efc917 Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Mon, 7 Apr 2025 09:54:57 +0200 Subject: [PATCH 332/505] Use common state for "Normal" in `matter` (#142452) --- homeassistant/components/matter/strings.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/matter/strings.json b/homeassistant/components/matter/strings.json index 4fa49f887d9..54db8c695e6 100644 --- a/homeassistant/components/matter/strings.json +++ b/homeassistant/components/matter/strings.json @@ -222,7 +222,7 @@ "name": "Number of rinses", "state": { "off": "[%key:common::state::off%]", - "normal": "Normal", + "normal": "[%key:common::state::normal%]", "extra": "Extra", "max": "Max" } @@ -238,7 +238,7 @@ "contamination_state": { "name": "Contamination state", "state": { - "normal": "Normal", + "normal": "[%key:common::state::normal%]", "low": "[%key:common::state::low%]", "warning": "Warning", "critical": "Critical" From 8d82ef8e3654568b22b26432160822784feec71b Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 6 Apr 2025 22:11:15 -1000 Subject: [PATCH 333/505] Fix HKC showing hvac_action as idle when fan is active and heat cool target is off (#142443) * Fix HKC showing hvac_action as idle when fan is active and heat cool target is off fixes #142442 * comment relocation --- .../components/homekit_controller/climate.py | 12 +- .../fixtures/ecobee3_lite.json | 3436 ++++++++++++++++ .../snapshots/test_init.ambr | 3458 +++++++++++++++++ .../homekit_controller/test_climate.py | 23 +- 4 files changed, 6921 insertions(+), 8 deletions(-) create mode 100644 tests/components/homekit_controller/fixtures/ecobee3_lite.json diff --git a/homeassistant/components/homekit_controller/climate.py b/homeassistant/components/homekit_controller/climate.py index 7341bbd3a4a..4c8bf8517be 100644 --- a/homeassistant/components/homekit_controller/climate.py +++ b/homeassistant/components/homekit_controller/climate.py @@ -659,13 +659,7 @@ class HomeKitClimateEntity(HomeKitBaseClimateEntity): # e.g. a thermostat is "heating" a room to 75 degrees Fahrenheit. # Can be 0 - 2 (Off, Heat, Cool) - # If the HVAC is switched off, it must be idle - # This works around a bug in some devices (like Eve radiator valves) that - # return they are heating when they are not. target = self.service.value(CharacteristicsTypes.HEATING_COOLING_TARGET) - if target == HeatingCoolingTargetValues.OFF: - return HVACAction.IDLE - value = self.service.value(CharacteristicsTypes.HEATING_COOLING_CURRENT) current_hass_value = CURRENT_MODE_HOMEKIT_TO_HASS.get(value) @@ -679,6 +673,12 @@ class HomeKitClimateEntity(HomeKitBaseClimateEntity): ): return HVACAction.FAN + # If the HVAC is switched off, it must be idle + # This works around a bug in some devices (like Eve radiator valves) that + # return they are heating when they are not. + if target == HeatingCoolingTargetValues.OFF: + return HVACAction.IDLE + return current_hass_value @property diff --git a/tests/components/homekit_controller/fixtures/ecobee3_lite.json b/tests/components/homekit_controller/fixtures/ecobee3_lite.json new file mode 100644 index 00000000000..0656ed20fdb --- /dev/null +++ b/tests/components/homekit_controller/fixtures/ecobee3_lite.json @@ -0,0 +1,3436 @@ +[ + { + "aid": 1, + "services": [ + { + "iid": 1, + "type": "0000003E-0000-1000-8000-0026BB765291", + "characteristics": [ + { + "type": "00000014-0000-1000-8000-0026BB765291", + "iid": 6, + "perms": ["pw"], + "format": "bool", + "description": "Identify" + }, + { + "type": "00000020-0000-1000-8000-0026BB765291", + "iid": 3, + "perms": ["pr"], + "format": "string", + "value": "ecobee Inc.", + "description": "Manufacturer", + "maxLen": 64 + }, + { + "type": "00000021-0000-1000-8000-0026BB765291", + "iid": 5, + "perms": ["pr"], + "format": "string", + "value": "ecobee3 lite", + "description": "Model", + "maxLen": 64 + }, + { + "type": "00000023-0000-1000-8000-0026BB765291", + "iid": 2, + "perms": ["pr"], + "format": "string", + "value": "Thermostat", + "description": "Name", + "maxLen": 64 + }, + { + "type": "00000030-0000-1000-8000-0026BB765291", + "iid": 4, + "perms": ["pr"], + "format": "string", + "value": "**REDACTED**", + "description": "Serial Number", + "maxLen": 64 + }, + { + "type": "00000052-0000-1000-8000-0026BB765291", + "iid": 8, + "perms": ["pr"], + "format": "string", + "value": "4.8.70226", + "description": "Firmware Revision", + "maxLen": 64 + }, + { + "type": "34AB8811-AC7F-4340-BAC3-FD6A85F9943B", + "iid": 11, + "perms": ["pr", "hd"], + "format": "string", + "value": "4.1;3fac0fb4", + "maxLen": 64 + }, + { + "type": "00000220-0000-1000-8000-0026BB765291", + "iid": 10, + "perms": ["pr", "hd"], + "format": "data", + "value": "u4qz9YgSXzQ=" + }, + { + "type": "000000A6-0000-1000-8000-0026BB765291", + "iid": 9, + "perms": ["pr", "ev"], + "format": "uint32", + "value": 0, + "description": "Accessory Flags" + } + ] + }, + { + "iid": 30, + "type": "000000A2-0000-1000-8000-0026BB765291", + "characteristics": [ + { + "type": "00000037-0000-1000-8000-0026BB765291", + "iid": 31, + "perms": ["pr"], + "format": "string", + "value": "1.1.0", + "description": "Version", + "maxLen": 64 + } + ] + }, + { + "iid": 16, + "type": "0000004A-0000-1000-8000-0026BB765291", + "characteristics": [ + { + "type": "0000000F-0000-1000-8000-0026BB765291", + "iid": 17, + "perms": ["pr", "ev"], + "format": "uint8", + "value": 0, + "description": "Current Heating Cooling State", + "minValue": 0, + "maxValue": 2, + "minStep": 1, + "valid-values": [0, 1, 2] + }, + { + "type": "00000033-0000-1000-8000-0026BB765291", + "iid": 18, + "perms": ["pr", "pw", "ev"], + "format": "uint8", + "value": 0, + "description": "Target Heating Cooling State", + "minValue": 0, + "maxValue": 3, + "minStep": 1, + "valid-values": [0, 1, 2, 3] + }, + { + "type": "00000011-0000-1000-8000-0026BB765291", + "iid": 19, + "perms": ["pr", "ev"], + "format": "float", + "value": 21.2, + "description": "Current Temperature", + "unit": "celsius", + "minValue": 0, + "maxValue": 40.0, + "minStep": 0.1 + }, + { + "type": "00000035-0000-1000-8000-0026BB765291", + "iid": 20, + "perms": ["pr", "pw", "ev"], + "format": "float", + "value": 22.2, + "description": "Target Temperature", + "unit": "celsius", + "minValue": 7.2, + "maxValue": 33.3, + "minStep": 0.1 + }, + { + "type": "00000036-0000-1000-8000-0026BB765291", + "iid": 21, + "perms": ["pr", "pw", "ev"], + "format": "uint8", + "value": 1, + "description": "Temperature Display Units", + "minValue": 0, + "maxValue": 1, + "minStep": 1, + "valid-values": [0, 1] + }, + { + "type": "0000000D-0000-1000-8000-0026BB765291", + "iid": 22, + "perms": ["pr", "pw", "ev"], + "format": "float", + "value": 25.0, + "description": "Cooling Threshold Temperature", + "unit": "celsius", + "minValue": 18.3, + "maxValue": 33.3, + "minStep": 0.1 + }, + { + "type": "00000012-0000-1000-8000-0026BB765291", + "iid": 23, + "perms": ["pr", "pw", "ev"], + "format": "float", + "value": 22.2, + "description": "Heating Threshold Temperature", + "unit": "celsius", + "minValue": 7.2, + "maxValue": 26.1, + "minStep": 0.1 + }, + { + "type": "00000010-0000-1000-8000-0026BB765291", + "iid": 24, + "perms": ["pr", "ev"], + "format": "float", + "value": 45.0, + "description": "Current Relative Humidity", + "unit": "percentage", + "minValue": 0, + "maxValue": 100.0, + "minStep": 1.0 + }, + { + "type": "00000023-0000-1000-8000-0026BB765291", + "iid": 27, + "perms": ["pr"], + "format": "string", + "value": "Thermostat", + "description": "Name", + "maxLen": 64 + }, + { + "type": "000000BF-0000-1000-8000-0026BB765291", + "iid": 75, + "perms": ["pr", "pw", "ev"], + "format": "uint8", + "value": 0, + "description": "Target Fan State", + "minValue": 0, + "maxValue": 1, + "minStep": 1 + }, + { + "type": "000000AF-0000-1000-8000-0026BB765291", + "iid": 76, + "perms": ["pr", "ev"], + "format": "uint8", + "value": 2, + "description": "Current Fan State", + "minValue": 0, + "maxValue": 2, + "minStep": 1 + }, + { + "type": "B7DDB9A3-54BB-4572-91D2-F1F5B0510F8C", + "iid": 33, + "perms": ["pr"], + "format": "uint8", + "value": 3, + "minValue": 0, + "maxValue": 3, + "minStep": 1 + }, + { + "type": "E4489BBC-5227-4569-93E5-B345E3E5508F", + "iid": 34, + "perms": ["pr", "pw"], + "format": "float", + "value": 22.2, + "unit": "celsius", + "minValue": 7.2, + "maxValue": 26.1, + "minStep": 0.1 + }, + { + "type": "7D381BAA-20F9-40E5-9BE9-AEB92D4BECEF", + "iid": 35, + "perms": ["pr", "pw"], + "format": "float", + "value": 25.0, + "unit": "celsius", + "minValue": 18.3, + "maxValue": 33.3, + "minStep": 0.1 + }, + { + "type": "73AAB542-892A-4439-879A-D2A883724B69", + "iid": 36, + "perms": ["pr", "pw"], + "format": "float", + "value": 17.8, + "unit": "celsius", + "minValue": 7.2, + "maxValue": 26.1, + "minStep": 0.1 + }, + { + "type": "5DA985F0-898A-4850-B987-B76C6C78D670", + "iid": 37, + "perms": ["pr", "pw"], + "format": "float", + "value": 25.6, + "unit": "celsius", + "minValue": 18.3, + "maxValue": 33.3, + "minStep": 0.1 + }, + { + "type": "05B97374-6DC0-439B-A0FA-CA33F612D425", + "iid": 38, + "perms": ["pr", "pw"], + "format": "float", + "value": 20.0, + "unit": "celsius", + "minValue": 7.2, + "maxValue": 26.1, + "minStep": 0.1 + }, + { + "type": "A251F6E7-AC46-4190-9C5D-3D06277BDF9F", + "iid": 39, + "perms": ["pr", "pw"], + "format": "float", + "value": 24.4, + "unit": "celsius", + "minValue": 18.3, + "maxValue": 33.3, + "minStep": 0.1 + }, + { + "type": "1B300BC2-CFFC-47FF-89F9-BD6CCF5F2853", + "iid": 40, + "perms": ["pw"], + "format": "uint8", + "minValue": 0, + "maxValue": 3, + "minStep": 1 + }, + { + "type": "1621F556-1367-443C-AF19-82AF018E99DE", + "iid": 41, + "perms": ["pr", "pw"], + "format": "string", + "value": "2025-04-06T23:30:00-05:00R", + "maxLen": 64 + }, + { + "type": "FA128DE6-9D7D-49A4-B6D8-4E4E234DEE38", + "iid": 48, + "perms": ["pw"], + "format": "bool" + }, + { + "type": "4A6AE4F6-036C-495D-87CC-B3702B437741", + "iid": 49, + "perms": ["pr"], + "format": "uint8", + "value": 1, + "minValue": 0, + "maxValue": 4, + "minStep": 1 + }, + { + "type": "DB7BF261-7042-4194-8BD1-3AA22830AEDD", + "iid": 50, + "perms": ["pr"], + "format": "uint8", + "value": 0, + "minValue": 0, + "maxValue": 3, + "minStep": 1 + }, + { + "type": "41935E3E-B54D-42E9-B8B9-D33C6319F0AF", + "iid": 51, + "perms": ["pr"], + "format": "bool", + "value": false + }, + { + "type": "C35DA3C0-E004-40E3-B153-46655CDD9214", + "iid": 52, + "perms": ["pr", "pw"], + "format": "uint8", + "value": 100, + "unit": "percentage", + "minValue": 0, + "maxValue": 100, + "minStep": 1 + }, + { + "type": "48F62AEC-4171-4B4A-8F0E-1EEB6708B3FB", + "iid": 53, + "perms": ["pr"], + "format": "uint8", + "value": 100, + "unit": "percentage", + "minValue": 0, + "maxValue": 100, + "minStep": 1 + }, + { + "type": "1B1515F2-CC45-409F-991F-C480987F92C3", + "iid": 54, + "perms": ["pr"], + "format": "string", + "value": "The Hive is humming along. You have no pending alerts or reminders.", + "maxLen": 64 + } + ] + } + ] + }, + { + "aid": 4295608971, + "services": [ + { + "iid": 1, + "type": "0000003E-0000-1000-8000-0026BB765291", + "characteristics": [ + { + "type": "00000023-0000-1000-8000-0026BB765291", + "iid": 2, + "perms": ["pr"], + "format": "string", + "value": "Master BR", + "description": "Name", + "maxLen": 64 + }, + { + "type": "00000020-0000-1000-8000-0026BB765291", + "iid": 3, + "perms": ["pr"], + "format": "string", + "value": "ecobee Inc.", + "description": "Manufacturer", + "maxLen": 64 + }, + { + "type": "00000030-0000-1000-8000-0026BB765291", + "iid": 4, + "perms": ["pr"], + "format": "string", + "value": "**REDACTED**", + "description": "Serial Number", + "maxLen": 64 + }, + { + "type": "00000021-0000-1000-8000-0026BB765291", + "iid": 5, + "perms": ["pr"], + "format": "string", + "value": "EBRSE4", + "description": "Model", + "maxLen": 64 + }, + { + "type": "00000052-0000-1000-8000-0026BB765291", + "iid": 8, + "perms": ["pr"], + "format": "string", + "value": "1.0.0", + "description": "Firmware Revision", + "maxLen": 64 + }, + { + "type": "00000014-0000-1000-8000-0026BB765291", + "iid": 6, + "perms": ["pw"], + "format": "bool", + "description": "Identify" + } + ] + }, + { + "iid": 192, + "type": "00000096-0000-1000-8000-0026BB765291", + "characteristics": [ + { + "type": "0000008F-0000-1000-8000-0026BB765291", + "iid": 193, + "perms": ["pr", "ev"], + "format": "uint8", + "value": 2, + "description": "Charging State", + "minValue": 0, + "maxValue": 2, + "minStep": 1 + }, + { + "type": "00000068-0000-1000-8000-0026BB765291", + "iid": 194, + "perms": ["pr", "ev"], + "format": "uint8", + "value": 100, + "description": "Battery Level", + "unit": "percentage", + "minValue": 0, + "maxValue": 100, + "minStep": 1 + }, + { + "type": "00000079-0000-1000-8000-0026BB765291", + "iid": 195, + "perms": ["pr", "ev"], + "format": "uint8", + "value": 0, + "description": "Status Low Battery", + "minValue": 0, + "maxValue": 1, + "minStep": 1 + }, + { + "type": "00000023-0000-1000-8000-0026BB765291", + "iid": 196, + "perms": ["pr"], + "format": "string", + "value": "Master BR", + "description": "Name", + "maxLen": 64 + } + ] + }, + { + "iid": 208, + "type": "0000008A-0000-1000-8000-0026BB765291", + "characteristics": [ + { + "type": "00000011-0000-1000-8000-0026BB765291", + "iid": 209, + "perms": ["pr", "ev"], + "format": "float", + "value": 22.4, + "description": "Current Temperature", + "unit": "celsius", + "minValue": 0, + "maxValue": 100.0, + "minStep": 0.1 + }, + { + "type": "00000023-0000-1000-8000-0026BB765291", + "iid": 210, + "perms": ["pr"], + "format": "string", + "value": "Master BR Temperature", + "description": "Name", + "maxLen": 64 + }, + { + "type": "00000075-0000-1000-8000-0026BB765291", + "iid": 211, + "perms": ["pr", "ev"], + "format": "bool", + "value": true, + "description": "Status Active" + }, + { + "type": "00000079-0000-1000-8000-0026BB765291", + "iid": 212, + "perms": ["pr", "ev"], + "format": "uint8", + "value": 0, + "description": "Status Low Battery", + "minValue": 0, + "maxValue": 1, + "minStep": 1 + } + ] + }, + { + "iid": 57, + "type": "00000086-0000-1000-8000-0026BB765291", + "characteristics": [ + { + "type": "00000071-0000-1000-8000-0026BB765291", + "iid": 65, + "perms": ["pr", "ev"], + "format": "uint8", + "value": 1, + "description": "Occupancy Detected", + "minValue": 0, + "maxValue": 1, + "minStep": 1 + }, + { + "type": "00000023-0000-1000-8000-0026BB765291", + "iid": 29, + "perms": ["pr"], + "format": "string", + "value": "Master BR Occupancy", + "description": "Name", + "maxLen": 64 + }, + { + "type": "00000075-0000-1000-8000-0026BB765291", + "iid": 117, + "perms": ["pr", "ev"], + "format": "bool", + "value": true, + "description": "Status Active" + }, + { + "type": "00000079-0000-1000-8000-0026BB765291", + "iid": 116, + "perms": ["pr", "ev"], + "format": "uint8", + "value": 0, + "description": "Status Low Battery", + "minValue": 0, + "maxValue": 1, + "minStep": 1 + }, + { + "type": "A8F798E0-4A40-11E6-BDF4-0800200C9A66", + "iid": 68, + "perms": ["pr"], + "format": "int", + "value": 691, + "unit": "seconds", + "minValue": -1, + "maxValue": 86400, + "minStep": 1 + } + ] + }, + { + "iid": 56, + "type": "00000085-0000-1000-8000-0026BB765291", + "characteristics": [ + { + "type": "00000022-0000-1000-8000-0026BB765291", + "iid": 66, + "perms": ["pr", "ev"], + "format": "bool", + "value": false, + "description": "Motion Detected" + }, + { + "type": "00000023-0000-1000-8000-0026BB765291", + "iid": 28, + "perms": ["pr"], + "format": "string", + "value": "Master BR Motion", + "description": "Name", + "maxLen": 64 + }, + { + "type": "00000075-0000-1000-8000-0026BB765291", + "iid": 101, + "perms": ["pr", "ev"], + "format": "bool", + "value": true, + "description": "Status Active" + }, + { + "type": "00000079-0000-1000-8000-0026BB765291", + "iid": 100, + "perms": ["pr", "ev"], + "format": "uint8", + "value": 0, + "description": "Status Low Battery", + "minValue": 0, + "maxValue": 1, + "minStep": 1 + }, + { + "type": "BFE61C70-4A40-11E6-BDF4-0800200C9A66", + "iid": 67, + "perms": ["pr"], + "format": "int", + "value": 691, + "unit": "seconds", + "minValue": -1, + "maxValue": 86400, + "minStep": 1 + } + ] + } + ] + }, + { + "aid": 4295608960, + "services": [ + { + "iid": 1, + "type": "0000003E-0000-1000-8000-0026BB765291", + "characteristics": [ + { + "type": "00000023-0000-1000-8000-0026BB765291", + "iid": 2, + "perms": ["pr"], + "format": "string", + "value": "Basement", + "description": "Name", + "maxLen": 64 + }, + { + "type": "00000020-0000-1000-8000-0026BB765291", + "iid": 3, + "perms": ["pr"], + "format": "string", + "value": "ecobee Inc.", + "description": "Manufacturer", + "maxLen": 64 + }, + { + "type": "00000030-0000-1000-8000-0026BB765291", + "iid": 4, + "perms": ["pr"], + "format": "string", + "value": "**REDACTED**", + "description": "Serial Number", + "maxLen": 64 + }, + { + "type": "00000021-0000-1000-8000-0026BB765291", + "iid": 5, + "perms": ["pr"], + "format": "string", + "value": "EBRSE4", + "description": "Model", + "maxLen": 64 + }, + { + "type": "00000052-0000-1000-8000-0026BB765291", + "iid": 8, + "perms": ["pr"], + "format": "string", + "value": "1.0.0", + "description": "Firmware Revision", + "maxLen": 64 + }, + { + "type": "00000014-0000-1000-8000-0026BB765291", + "iid": 6, + "perms": ["pw"], + "format": "bool", + "description": "Identify" + } + ] + }, + { + "iid": 192, + "type": "00000096-0000-1000-8000-0026BB765291", + "characteristics": [ + { + "type": "0000008F-0000-1000-8000-0026BB765291", + "iid": 193, + "perms": ["pr", "ev"], + "format": "uint8", + "value": 2, + "description": "Charging State", + "minValue": 0, + "maxValue": 2, + "minStep": 1 + }, + { + "type": "00000068-0000-1000-8000-0026BB765291", + "iid": 194, + "perms": ["pr", "ev"], + "format": "uint8", + "value": 100, + "description": "Battery Level", + "unit": "percentage", + "minValue": 0, + "maxValue": 100, + "minStep": 1 + }, + { + "type": "00000079-0000-1000-8000-0026BB765291", + "iid": 195, + "perms": ["pr", "ev"], + "format": "uint8", + "value": 0, + "description": "Status Low Battery", + "minValue": 0, + "maxValue": 1, + "minStep": 1 + }, + { + "type": "00000023-0000-1000-8000-0026BB765291", + "iid": 196, + "perms": ["pr"], + "format": "string", + "value": "Basement", + "description": "Name", + "maxLen": 64 + } + ] + }, + { + "iid": 208, + "type": "0000008A-0000-1000-8000-0026BB765291", + "characteristics": [ + { + "type": "00000011-0000-1000-8000-0026BB765291", + "iid": 209, + "perms": ["pr", "ev"], + "format": "float", + "value": 20.3, + "description": "Current Temperature", + "unit": "celsius", + "minValue": 0, + "maxValue": 100.0, + "minStep": 0.1 + }, + { + "type": "00000023-0000-1000-8000-0026BB765291", + "iid": 210, + "perms": ["pr"], + "format": "string", + "value": "Basement Temperature", + "description": "Name", + "maxLen": 64 + }, + { + "type": "00000075-0000-1000-8000-0026BB765291", + "iid": 211, + "perms": ["pr", "ev"], + "format": "bool", + "value": true, + "description": "Status Active" + }, + { + "type": "00000079-0000-1000-8000-0026BB765291", + "iid": 212, + "perms": ["pr", "ev"], + "format": "uint8", + "value": 0, + "description": "Status Low Battery", + "minValue": 0, + "maxValue": 1, + "minStep": 1 + } + ] + }, + { + "iid": 57, + "type": "00000086-0000-1000-8000-0026BB765291", + "characteristics": [ + { + "type": "00000071-0000-1000-8000-0026BB765291", + "iid": 65, + "perms": ["pr", "ev"], + "format": "uint8", + "value": 0, + "description": "Occupancy Detected", + "minValue": 0, + "maxValue": 1, + "minStep": 1 + }, + { + "type": "00000023-0000-1000-8000-0026BB765291", + "iid": 29, + "perms": ["pr"], + "format": "string", + "value": "Basement Occupancy", + "description": "Name", + "maxLen": 64 + }, + { + "type": "00000075-0000-1000-8000-0026BB765291", + "iid": 117, + "perms": ["pr", "ev"], + "format": "bool", + "value": true, + "description": "Status Active" + }, + { + "type": "00000079-0000-1000-8000-0026BB765291", + "iid": 116, + "perms": ["pr", "ev"], + "format": "uint8", + "value": 0, + "description": "Status Low Battery", + "minValue": 0, + "maxValue": 1, + "minStep": 1 + }, + { + "type": "A8F798E0-4A40-11E6-BDF4-0800200C9A66", + "iid": 68, + "perms": ["pr"], + "format": "int", + "value": 9158, + "unit": "seconds", + "minValue": -1, + "maxValue": 86400, + "minStep": 1 + } + ] + }, + { + "iid": 56, + "type": "00000085-0000-1000-8000-0026BB765291", + "characteristics": [ + { + "type": "00000022-0000-1000-8000-0026BB765291", + "iid": 66, + "perms": ["pr", "ev"], + "format": "bool", + "value": false, + "description": "Motion Detected" + }, + { + "type": "00000023-0000-1000-8000-0026BB765291", + "iid": 28, + "perms": ["pr"], + "format": "string", + "value": "Basement Motion", + "description": "Name", + "maxLen": 64 + }, + { + "type": "00000075-0000-1000-8000-0026BB765291", + "iid": 101, + "perms": ["pr", "ev"], + "format": "bool", + "value": true, + "description": "Status Active" + }, + { + "type": "00000079-0000-1000-8000-0026BB765291", + "iid": 100, + "perms": ["pr", "ev"], + "format": "uint8", + "value": 0, + "description": "Status Low Battery", + "minValue": 0, + "maxValue": 1, + "minStep": 1 + }, + { + "type": "BFE61C70-4A40-11E6-BDF4-0800200C9A66", + "iid": 67, + "perms": ["pr"], + "format": "int", + "value": 9158, + "unit": "seconds", + "minValue": -1, + "maxValue": 86400, + "minStep": 1 + } + ] + } + ] + }, + { + "aid": 4295016858, + "services": [ + { + "iid": 1, + "type": "0000003E-0000-1000-8000-0026BB765291", + "characteristics": [ + { + "type": "00000023-0000-1000-8000-0026BB765291", + "iid": 2, + "perms": ["pr"], + "format": "string", + "value": "Living Room", + "description": "Name", + "maxLen": 64 + }, + { + "type": "00000020-0000-1000-8000-0026BB765291", + "iid": 3, + "perms": ["pr"], + "format": "string", + "value": "ecobee Inc.", + "description": "Manufacturer", + "maxLen": 64 + }, + { + "type": "00000030-0000-1000-8000-0026BB765291", + "iid": 4, + "perms": ["pr"], + "format": "string", + "value": "**REDACTED**", + "description": "Serial Number", + "maxLen": 64 + }, + { + "type": "00000021-0000-1000-8000-0026BB765291", + "iid": 5, + "perms": ["pr"], + "format": "string", + "value": "EBRSE4", + "description": "Model", + "maxLen": 64 + }, + { + "type": "00000052-0000-1000-8000-0026BB765291", + "iid": 8, + "perms": ["pr"], + "format": "string", + "value": "1.0.0", + "description": "Firmware Revision", + "maxLen": 64 + }, + { + "type": "00000014-0000-1000-8000-0026BB765291", + "iid": 6, + "perms": ["pw"], + "format": "bool", + "description": "Identify" + } + ] + }, + { + "iid": 192, + "type": "00000096-0000-1000-8000-0026BB765291", + "characteristics": [ + { + "type": "0000008F-0000-1000-8000-0026BB765291", + "iid": 193, + "perms": ["pr", "ev"], + "format": "uint8", + "value": 2, + "description": "Charging State", + "minValue": 0, + "maxValue": 2, + "minStep": 1 + }, + { + "type": "00000068-0000-1000-8000-0026BB765291", + "iid": 194, + "perms": ["pr", "ev"], + "format": "uint8", + "value": 100, + "description": "Battery Level", + "unit": "percentage", + "minValue": 0, + "maxValue": 100, + "minStep": 1 + }, + { + "type": "00000079-0000-1000-8000-0026BB765291", + "iid": 195, + "perms": ["pr", "ev"], + "format": "uint8", + "value": 0, + "description": "Status Low Battery", + "minValue": 0, + "maxValue": 1, + "minStep": 1 + }, + { + "type": "00000023-0000-1000-8000-0026BB765291", + "iid": 196, + "perms": ["pr"], + "format": "string", + "value": "Living Room", + "description": "Name", + "maxLen": 64 + } + ] + }, + { + "iid": 208, + "type": "0000008A-0000-1000-8000-0026BB765291", + "characteristics": [ + { + "type": "00000011-0000-1000-8000-0026BB765291", + "iid": 209, + "perms": ["pr", "ev"], + "format": "float", + "value": 21.0, + "description": "Current Temperature", + "unit": "celsius", + "minValue": 0, + "maxValue": 100.0, + "minStep": 0.1 + }, + { + "type": "00000023-0000-1000-8000-0026BB765291", + "iid": 210, + "perms": ["pr"], + "format": "string", + "value": "Living Room Temperature", + "description": "Name", + "maxLen": 64 + }, + { + "type": "00000075-0000-1000-8000-0026BB765291", + "iid": 211, + "perms": ["pr", "ev"], + "format": "bool", + "value": true, + "description": "Status Active" + }, + { + "type": "00000079-0000-1000-8000-0026BB765291", + "iid": 212, + "perms": ["pr", "ev"], + "format": "uint8", + "value": 0, + "description": "Status Low Battery", + "minValue": 0, + "maxValue": 1, + "minStep": 1 + } + ] + }, + { + "iid": 57, + "type": "00000086-0000-1000-8000-0026BB765291", + "characteristics": [ + { + "type": "00000071-0000-1000-8000-0026BB765291", + "iid": 65, + "perms": ["pr", "ev"], + "format": "uint8", + "value": 0, + "description": "Occupancy Detected", + "minValue": 0, + "maxValue": 1, + "minStep": 1 + }, + { + "type": "00000023-0000-1000-8000-0026BB765291", + "iid": 29, + "perms": ["pr"], + "format": "string", + "value": "Living Room Occupancy", + "description": "Name", + "maxLen": 64 + }, + { + "type": "00000075-0000-1000-8000-0026BB765291", + "iid": 117, + "perms": ["pr", "ev"], + "format": "bool", + "value": true, + "description": "Status Active" + }, + { + "type": "00000079-0000-1000-8000-0026BB765291", + "iid": 116, + "perms": ["pr", "ev"], + "format": "uint8", + "value": 0, + "description": "Status Low Battery", + "minValue": 0, + "maxValue": 1, + "minStep": 1 + }, + { + "type": "A8F798E0-4A40-11E6-BDF4-0800200C9A66", + "iid": 68, + "perms": ["pr"], + "format": "int", + "value": -1, + "unit": "seconds", + "minValue": -1, + "maxValue": 86400, + "minStep": 1 + } + ] + }, + { + "iid": 56, + "type": "00000085-0000-1000-8000-0026BB765291", + "characteristics": [ + { + "type": "00000022-0000-1000-8000-0026BB765291", + "iid": 66, + "perms": ["pr", "ev"], + "format": "bool", + "value": false, + "description": "Motion Detected" + }, + { + "type": "00000023-0000-1000-8000-0026BB765291", + "iid": 28, + "perms": ["pr"], + "format": "string", + "value": "Living Room Motion", + "description": "Name", + "maxLen": 64 + }, + { + "type": "00000075-0000-1000-8000-0026BB765291", + "iid": 101, + "perms": ["pr", "ev"], + "format": "bool", + "value": true, + "description": "Status Active" + }, + { + "type": "00000079-0000-1000-8000-0026BB765291", + "iid": 100, + "perms": ["pr", "ev"], + "format": "uint8", + "value": 0, + "description": "Status Low Battery", + "minValue": 0, + "maxValue": 1, + "minStep": 1 + }, + { + "type": "BFE61C70-4A40-11E6-BDF4-0800200C9A66", + "iid": 67, + "perms": ["pr"], + "format": "int", + "value": -1, + "unit": "seconds", + "minValue": -1, + "maxValue": 86400, + "minStep": 1 + } + ] + } + ] + }, + { + "aid": 4295016969, + "services": [ + { + "iid": 1, + "type": "0000003E-0000-1000-8000-0026BB765291", + "characteristics": [ + { + "type": "00000023-0000-1000-8000-0026BB765291", + "iid": 2, + "perms": ["pr"], + "format": "string", + "value": "Upstairs BR", + "description": "Name", + "maxLen": 64 + }, + { + "type": "00000020-0000-1000-8000-0026BB765291", + "iid": 3, + "perms": ["pr"], + "format": "string", + "value": "ecobee Inc.", + "description": "Manufacturer", + "maxLen": 64 + }, + { + "type": "00000030-0000-1000-8000-0026BB765291", + "iid": 4, + "perms": ["pr"], + "format": "string", + "value": "**REDACTED**", + "description": "Serial Number", + "maxLen": 64 + }, + { + "type": "00000021-0000-1000-8000-0026BB765291", + "iid": 5, + "perms": ["pr"], + "format": "string", + "value": "EBRSE4", + "description": "Model", + "maxLen": 64 + }, + { + "type": "00000052-0000-1000-8000-0026BB765291", + "iid": 8, + "perms": ["pr"], + "format": "string", + "value": "1.0.0", + "description": "Firmware Revision", + "maxLen": 64 + }, + { + "type": "00000014-0000-1000-8000-0026BB765291", + "iid": 6, + "perms": ["pw"], + "format": "bool", + "description": "Identify" + } + ] + }, + { + "iid": 192, + "type": "00000096-0000-1000-8000-0026BB765291", + "characteristics": [ + { + "type": "0000008F-0000-1000-8000-0026BB765291", + "iid": 193, + "perms": ["pr", "ev"], + "format": "uint8", + "value": 2, + "description": "Charging State", + "minValue": 0, + "maxValue": 2, + "minStep": 1 + }, + { + "type": "00000068-0000-1000-8000-0026BB765291", + "iid": 194, + "perms": ["pr", "ev"], + "format": "uint8", + "value": 100, + "description": "Battery Level", + "unit": "percentage", + "minValue": 0, + "maxValue": 100, + "minStep": 1 + }, + { + "type": "00000079-0000-1000-8000-0026BB765291", + "iid": 195, + "perms": ["pr", "ev"], + "format": "uint8", + "value": 0, + "description": "Status Low Battery", + "minValue": 0, + "maxValue": 1, + "minStep": 1 + }, + { + "type": "00000023-0000-1000-8000-0026BB765291", + "iid": 196, + "perms": ["pr"], + "format": "string", + "value": "Upstairs BR", + "description": "Name", + "maxLen": 64 + } + ] + }, + { + "iid": 208, + "type": "0000008A-0000-1000-8000-0026BB765291", + "characteristics": [ + { + "type": "00000011-0000-1000-8000-0026BB765291", + "iid": 209, + "perms": ["pr", "ev"], + "format": "float", + "value": 21.6, + "description": "Current Temperature", + "unit": "celsius", + "minValue": 0, + "maxValue": 100.0, + "minStep": 0.1 + }, + { + "type": "00000023-0000-1000-8000-0026BB765291", + "iid": 210, + "perms": ["pr"], + "format": "string", + "value": "Upstairs BR Temperature", + "description": "Name", + "maxLen": 64 + }, + { + "type": "00000075-0000-1000-8000-0026BB765291", + "iid": 211, + "perms": ["pr", "ev"], + "format": "bool", + "value": true, + "description": "Status Active" + }, + { + "type": "00000079-0000-1000-8000-0026BB765291", + "iid": 212, + "perms": ["pr", "ev"], + "format": "uint8", + "value": 0, + "description": "Status Low Battery", + "minValue": 0, + "maxValue": 1, + "minStep": 1 + } + ] + }, + { + "iid": 57, + "type": "00000086-0000-1000-8000-0026BB765291", + "characteristics": [ + { + "type": "00000071-0000-1000-8000-0026BB765291", + "iid": 65, + "perms": ["pr", "ev"], + "format": "uint8", + "value": 0, + "description": "Occupancy Detected", + "minValue": 0, + "maxValue": 1, + "minStep": 1 + }, + { + "type": "00000023-0000-1000-8000-0026BB765291", + "iid": 29, + "perms": ["pr"], + "format": "string", + "value": "Upstairs BR Occupancy", + "description": "Name", + "maxLen": 64 + }, + { + "type": "00000075-0000-1000-8000-0026BB765291", + "iid": 117, + "perms": ["pr", "ev"], + "format": "bool", + "value": true, + "description": "Status Active" + }, + { + "type": "00000079-0000-1000-8000-0026BB765291", + "iid": 116, + "perms": ["pr", "ev"], + "format": "uint8", + "value": 0, + "description": "Status Low Battery", + "minValue": 0, + "maxValue": 1, + "minStep": 1 + }, + { + "type": "A8F798E0-4A40-11E6-BDF4-0800200C9A66", + "iid": 68, + "perms": ["pr"], + "format": "int", + "value": -1, + "unit": "seconds", + "minValue": -1, + "maxValue": 86400, + "minStep": 1 + } + ] + }, + { + "iid": 56, + "type": "00000085-0000-1000-8000-0026BB765291", + "characteristics": [ + { + "type": "00000022-0000-1000-8000-0026BB765291", + "iid": 66, + "perms": ["pr", "ev"], + "format": "bool", + "value": false, + "description": "Motion Detected" + }, + { + "type": "00000023-0000-1000-8000-0026BB765291", + "iid": 28, + "perms": ["pr"], + "format": "string", + "value": "Upstairs BR Motion", + "description": "Name", + "maxLen": 64 + }, + { + "type": "00000075-0000-1000-8000-0026BB765291", + "iid": 101, + "perms": ["pr", "ev"], + "format": "bool", + "value": true, + "description": "Status Active" + }, + { + "type": "00000079-0000-1000-8000-0026BB765291", + "iid": 100, + "perms": ["pr", "ev"], + "format": "uint8", + "value": 0, + "description": "Status Low Battery", + "minValue": 0, + "maxValue": 1, + "minStep": 1 + }, + { + "type": "BFE61C70-4A40-11E6-BDF4-0800200C9A66", + "iid": 67, + "perms": ["pr"], + "format": "int", + "value": -1, + "unit": "seconds", + "minValue": -1, + "maxValue": 86400, + "minStep": 1 + } + ] + } + ] + }, + { + "aid": 4298584118, + "services": [ + { + "iid": 1, + "type": "0000003E-0000-1000-8000-0026BB765291", + "characteristics": [ + { + "type": "00000023-0000-1000-8000-0026BB765291", + "iid": 2, + "perms": ["pr"], + "format": "string", + "value": "Master BR Window", + "description": "Name", + "maxLen": 64 + }, + { + "type": "00000020-0000-1000-8000-0026BB765291", + "iid": 3, + "perms": ["pr"], + "format": "string", + "value": "ecobee Inc.", + "description": "Manufacturer", + "maxLen": 64 + }, + { + "type": "00000030-0000-1000-8000-0026BB765291", + "iid": 4, + "perms": ["pr"], + "format": "string", + "value": "**REDACTED**", + "description": "Serial Number", + "maxLen": 64 + }, + { + "type": "00000021-0000-1000-8000-0026BB765291", + "iid": 5, + "perms": ["pr"], + "format": "string", + "value": "EBDWC01", + "description": "Model", + "maxLen": 64 + }, + { + "type": "00000052-0000-1000-8000-0026BB765291", + "iid": 8, + "perms": ["pr"], + "format": "string", + "value": "1.0.0", + "description": "Firmware Revision", + "maxLen": 64 + }, + { + "type": "00000014-0000-1000-8000-0026BB765291", + "iid": 6, + "perms": ["pw"], + "format": "bool", + "description": "Identify" + } + ] + }, + { + "iid": 192, + "type": "00000096-0000-1000-8000-0026BB765291", + "characteristics": [ + { + "type": "0000008F-0000-1000-8000-0026BB765291", + "iid": 193, + "perms": ["pr", "ev"], + "format": "uint8", + "value": 2, + "description": "Charging State", + "minValue": 0, + "maxValue": 2, + "minStep": 1 + }, + { + "type": "00000068-0000-1000-8000-0026BB765291", + "iid": 194, + "perms": ["pr", "ev"], + "format": "uint8", + "value": 100, + "description": "Battery Level", + "unit": "percentage", + "minValue": 0, + "maxValue": 100, + "minStep": 1 + }, + { + "type": "00000079-0000-1000-8000-0026BB765291", + "iid": 195, + "perms": ["pr", "ev"], + "format": "uint8", + "value": 0, + "description": "Status Low Battery", + "minValue": 0, + "maxValue": 1, + "minStep": 1 + }, + { + "type": "00000023-0000-1000-8000-0026BB765291", + "iid": 196, + "perms": ["pr"], + "format": "string", + "value": "Master BR Window", + "description": "Name", + "maxLen": 64 + } + ] + }, + { + "iid": 57, + "type": "00000086-0000-1000-8000-0026BB765291", + "characteristics": [ + { + "type": "00000071-0000-1000-8000-0026BB765291", + "iid": 65, + "perms": ["pr", "ev"], + "format": "uint8", + "value": 0, + "description": "Occupancy Detected", + "minValue": 0, + "maxValue": 1, + "minStep": 1 + }, + { + "type": "00000023-0000-1000-8000-0026BB765291", + "iid": 29, + "perms": ["pr"], + "format": "string", + "value": "Master BR Window Occupancy", + "description": "Name", + "maxLen": 64 + }, + { + "type": "00000075-0000-1000-8000-0026BB765291", + "iid": 117, + "perms": ["pr", "ev"], + "format": "bool", + "value": true, + "description": "Status Active" + }, + { + "type": "00000079-0000-1000-8000-0026BB765291", + "iid": 116, + "perms": ["pr", "ev"], + "format": "uint8", + "value": 0, + "description": "Status Low Battery", + "minValue": 0, + "maxValue": 1, + "minStep": 1 + }, + { + "type": "A8F798E0-4A40-11E6-BDF4-0800200C9A66", + "iid": 68, + "perms": ["pr"], + "format": "int", + "value": 1421, + "unit": "seconds", + "minValue": -1, + "maxValue": 86400, + "minStep": 1 + } + ] + }, + { + "iid": 56, + "type": "00000085-0000-1000-8000-0026BB765291", + "characteristics": [ + { + "type": "00000022-0000-1000-8000-0026BB765291", + "iid": 66, + "perms": ["pr", "ev"], + "format": "bool", + "value": false, + "description": "Motion Detected" + }, + { + "type": "00000023-0000-1000-8000-0026BB765291", + "iid": 28, + "perms": ["pr"], + "format": "string", + "value": "Master BR Window Motion", + "description": "Name", + "maxLen": 64 + }, + { + "type": "00000075-0000-1000-8000-0026BB765291", + "iid": 101, + "perms": ["pr", "ev"], + "format": "bool", + "value": true, + "description": "Status Active" + }, + { + "type": "00000079-0000-1000-8000-0026BB765291", + "iid": 100, + "perms": ["pr", "ev"], + "format": "uint8", + "value": 0, + "description": "Status Low Battery", + "minValue": 0, + "maxValue": 1, + "minStep": 1 + }, + { + "type": "BFE61C70-4A40-11E6-BDF4-0800200C9A66", + "iid": 67, + "perms": ["pr"], + "format": "int", + "value": 821, + "unit": "seconds", + "minValue": -1, + "maxValue": 86400, + "minStep": 1 + } + ] + }, + { + "iid": 224, + "type": "00000080-0000-1000-8000-0026BB765291", + "characteristics": [ + { + "type": "0000006A-0000-1000-8000-0026BB765291", + "iid": 225, + "perms": ["pr", "ev"], + "format": "uint8", + "value": 0, + "description": "Contact Sensor State", + "minValue": 0, + "maxValue": 1, + "minStep": 1 + }, + { + "type": "00000023-0000-1000-8000-0026BB765291", + "iid": 226, + "perms": ["pr"], + "format": "string", + "value": "Master BR Window Contact", + "description": "Name", + "maxLen": 64 + } + ] + } + ] + }, + { + "aid": 4298649931, + "services": [ + { + "iid": 1, + "type": "0000003E-0000-1000-8000-0026BB765291", + "characteristics": [ + { + "type": "00000023-0000-1000-8000-0026BB765291", + "iid": 2, + "perms": ["pr"], + "format": "string", + "value": "Loft window", + "description": "Name", + "maxLen": 64 + }, + { + "type": "00000020-0000-1000-8000-0026BB765291", + "iid": 3, + "perms": ["pr"], + "format": "string", + "value": "ecobee Inc.", + "description": "Manufacturer", + "maxLen": 64 + }, + { + "type": "00000030-0000-1000-8000-0026BB765291", + "iid": 4, + "perms": ["pr"], + "format": "string", + "value": "**REDACTED**", + "description": "Serial Number", + "maxLen": 64 + }, + { + "type": "00000021-0000-1000-8000-0026BB765291", + "iid": 5, + "perms": ["pr"], + "format": "string", + "value": "EBDWC01", + "description": "Model", + "maxLen": 64 + }, + { + "type": "00000052-0000-1000-8000-0026BB765291", + "iid": 8, + "perms": ["pr"], + "format": "string", + "value": "1.0.0", + "description": "Firmware Revision", + "maxLen": 64 + }, + { + "type": "00000014-0000-1000-8000-0026BB765291", + "iid": 6, + "perms": ["pw"], + "format": "bool", + "description": "Identify" + } + ] + }, + { + "iid": 192, + "type": "00000096-0000-1000-8000-0026BB765291", + "characteristics": [ + { + "type": "0000008F-0000-1000-8000-0026BB765291", + "iid": 193, + "perms": ["pr", "ev"], + "format": "uint8", + "value": 2, + "description": "Charging State", + "minValue": 0, + "maxValue": 2, + "minStep": 1 + }, + { + "type": "00000068-0000-1000-8000-0026BB765291", + "iid": 194, + "perms": ["pr", "ev"], + "format": "uint8", + "value": 100, + "description": "Battery Level", + "unit": "percentage", + "minValue": 0, + "maxValue": 100, + "minStep": 1 + }, + { + "type": "00000079-0000-1000-8000-0026BB765291", + "iid": 195, + "perms": ["pr", "ev"], + "format": "uint8", + "value": 0, + "description": "Status Low Battery", + "minValue": 0, + "maxValue": 1, + "minStep": 1 + }, + { + "type": "00000023-0000-1000-8000-0026BB765291", + "iid": 196, + "perms": ["pr"], + "format": "string", + "value": "Loft window", + "description": "Name", + "maxLen": 64 + } + ] + }, + { + "iid": 57, + "type": "00000086-0000-1000-8000-0026BB765291", + "characteristics": [ + { + "type": "00000071-0000-1000-8000-0026BB765291", + "iid": 65, + "perms": ["pr", "ev"], + "format": "uint8", + "value": 0, + "description": "Occupancy Detected", + "minValue": 0, + "maxValue": 1, + "minStep": 1 + }, + { + "type": "00000023-0000-1000-8000-0026BB765291", + "iid": 29, + "perms": ["pr"], + "format": "string", + "value": "Loft window Occupancy", + "description": "Name", + "maxLen": 64 + }, + { + "type": "00000075-0000-1000-8000-0026BB765291", + "iid": 117, + "perms": ["pr", "ev"], + "format": "bool", + "value": true, + "description": "Status Active" + }, + { + "type": "00000079-0000-1000-8000-0026BB765291", + "iid": 116, + "perms": ["pr", "ev"], + "format": "uint8", + "value": 0, + "description": "Status Low Battery", + "minValue": 0, + "maxValue": 1, + "minStep": 1 + }, + { + "type": "A8F798E0-4A40-11E6-BDF4-0800200C9A66", + "iid": 68, + "perms": ["pr"], + "format": "int", + "value": 327, + "unit": "seconds", + "minValue": -1, + "maxValue": 86400, + "minStep": 1 + } + ] + }, + { + "iid": 56, + "type": "00000085-0000-1000-8000-0026BB765291", + "characteristics": [ + { + "type": "00000022-0000-1000-8000-0026BB765291", + "iid": 66, + "perms": ["pr", "ev"], + "format": "bool", + "value": false, + "description": "Motion Detected" + }, + { + "type": "00000023-0000-1000-8000-0026BB765291", + "iid": 28, + "perms": ["pr"], + "format": "string", + "value": "Loft window Motion", + "description": "Name", + "maxLen": 64 + }, + { + "type": "00000075-0000-1000-8000-0026BB765291", + "iid": 101, + "perms": ["pr", "ev"], + "format": "bool", + "value": true, + "description": "Status Active" + }, + { + "type": "00000079-0000-1000-8000-0026BB765291", + "iid": 100, + "perms": ["pr", "ev"], + "format": "uint8", + "value": 0, + "description": "Status Low Battery", + "minValue": 0, + "maxValue": 1, + "minStep": 1 + }, + { + "type": "BFE61C70-4A40-11E6-BDF4-0800200C9A66", + "iid": 67, + "perms": ["pr"], + "format": "int", + "value": 328, + "unit": "seconds", + "minValue": -1, + "maxValue": 86400, + "minStep": 1 + } + ] + }, + { + "iid": 224, + "type": "00000080-0000-1000-8000-0026BB765291", + "characteristics": [ + { + "type": "0000006A-0000-1000-8000-0026BB765291", + "iid": 225, + "perms": ["pr", "ev"], + "format": "uint8", + "value": 0, + "description": "Contact Sensor State", + "minValue": 0, + "maxValue": 1, + "minStep": 1 + }, + { + "type": "00000023-0000-1000-8000-0026BB765291", + "iid": 226, + "perms": ["pr"], + "format": "string", + "value": "Loft window Contact", + "description": "Name", + "maxLen": 64 + } + ] + } + ] + }, + { + "aid": 4298527970, + "services": [ + { + "iid": 1, + "type": "0000003E-0000-1000-8000-0026BB765291", + "characteristics": [ + { + "type": "00000023-0000-1000-8000-0026BB765291", + "iid": 2, + "perms": ["pr"], + "format": "string", + "value": "Front Door", + "description": "Name", + "maxLen": 64 + }, + { + "type": "00000020-0000-1000-8000-0026BB765291", + "iid": 3, + "perms": ["pr"], + "format": "string", + "value": "ecobee Inc.", + "description": "Manufacturer", + "maxLen": 64 + }, + { + "type": "00000030-0000-1000-8000-0026BB765291", + "iid": 4, + "perms": ["pr"], + "format": "string", + "value": "**REDACTED**", + "description": "Serial Number", + "maxLen": 64 + }, + { + "type": "00000021-0000-1000-8000-0026BB765291", + "iid": 5, + "perms": ["pr"], + "format": "string", + "value": "EBDWC01", + "description": "Model", + "maxLen": 64 + }, + { + "type": "00000052-0000-1000-8000-0026BB765291", + "iid": 8, + "perms": ["pr"], + "format": "string", + "value": "1.0.0", + "description": "Firmware Revision", + "maxLen": 64 + }, + { + "type": "00000014-0000-1000-8000-0026BB765291", + "iid": 6, + "perms": ["pw"], + "format": "bool", + "description": "Identify" + } + ] + }, + { + "iid": 192, + "type": "00000096-0000-1000-8000-0026BB765291", + "characteristics": [ + { + "type": "0000008F-0000-1000-8000-0026BB765291", + "iid": 193, + "perms": ["pr", "ev"], + "format": "uint8", + "value": 2, + "description": "Charging State", + "minValue": 0, + "maxValue": 2, + "minStep": 1 + }, + { + "type": "00000068-0000-1000-8000-0026BB765291", + "iid": 194, + "perms": ["pr", "ev"], + "format": "uint8", + "value": 100, + "description": "Battery Level", + "unit": "percentage", + "minValue": 0, + "maxValue": 100, + "minStep": 1 + }, + { + "type": "00000079-0000-1000-8000-0026BB765291", + "iid": 195, + "perms": ["pr", "ev"], + "format": "uint8", + "value": 0, + "description": "Status Low Battery", + "minValue": 0, + "maxValue": 1, + "minStep": 1 + }, + { + "type": "00000023-0000-1000-8000-0026BB765291", + "iid": 196, + "perms": ["pr"], + "format": "string", + "value": "Front Door", + "description": "Name", + "maxLen": 64 + } + ] + }, + { + "iid": 57, + "type": "00000086-0000-1000-8000-0026BB765291", + "characteristics": [ + { + "type": "00000071-0000-1000-8000-0026BB765291", + "iid": 65, + "perms": ["pr", "ev"], + "format": "uint8", + "value": 1, + "description": "Occupancy Detected", + "minValue": 0, + "maxValue": 1, + "minStep": 1 + }, + { + "type": "00000023-0000-1000-8000-0026BB765291", + "iid": 29, + "perms": ["pr"], + "format": "string", + "value": "Front Door Occupancy", + "description": "Name", + "maxLen": 64 + }, + { + "type": "00000075-0000-1000-8000-0026BB765291", + "iid": 117, + "perms": ["pr", "ev"], + "format": "bool", + "value": true, + "description": "Status Active" + }, + { + "type": "00000079-0000-1000-8000-0026BB765291", + "iid": 116, + "perms": ["pr", "ev"], + "format": "uint8", + "value": 0, + "description": "Status Low Battery", + "minValue": 0, + "maxValue": 1, + "minStep": 1 + }, + { + "type": "A8F798E0-4A40-11E6-BDF4-0800200C9A66", + "iid": 68, + "perms": ["pr"], + "format": "int", + "value": 1473, + "unit": "seconds", + "minValue": -1, + "maxValue": 86400, + "minStep": 1 + } + ] + }, + { + "iid": 56, + "type": "00000085-0000-1000-8000-0026BB765291", + "characteristics": [ + { + "type": "00000022-0000-1000-8000-0026BB765291", + "iid": 66, + "perms": ["pr", "ev"], + "format": "bool", + "value": true, + "description": "Motion Detected" + }, + { + "type": "00000023-0000-1000-8000-0026BB765291", + "iid": 28, + "perms": ["pr"], + "format": "string", + "value": "Front Door Motion", + "description": "Name", + "maxLen": 64 + }, + { + "type": "00000075-0000-1000-8000-0026BB765291", + "iid": 101, + "perms": ["pr", "ev"], + "format": "bool", + "value": true, + "description": "Status Active" + }, + { + "type": "00000079-0000-1000-8000-0026BB765291", + "iid": 100, + "perms": ["pr", "ev"], + "format": "uint8", + "value": 0, + "description": "Status Low Battery", + "minValue": 0, + "maxValue": 1, + "minStep": 1 + }, + { + "type": "BFE61C70-4A40-11E6-BDF4-0800200C9A66", + "iid": 67, + "perms": ["pr"], + "format": "int", + "value": 873, + "unit": "seconds", + "minValue": -1, + "maxValue": 86400, + "minStep": 1 + } + ] + }, + { + "iid": 224, + "type": "00000080-0000-1000-8000-0026BB765291", + "characteristics": [ + { + "type": "0000006A-0000-1000-8000-0026BB765291", + "iid": 225, + "perms": ["pr", "ev"], + "format": "uint8", + "value": 0, + "description": "Contact Sensor State", + "minValue": 0, + "maxValue": 1, + "minStep": 1 + }, + { + "type": "00000023-0000-1000-8000-0026BB765291", + "iid": 226, + "perms": ["pr"], + "format": "string", + "value": "Front Door Contact", + "description": "Name", + "maxLen": 64 + } + ] + } + ] + }, + { + "aid": 4298527962, + "services": [ + { + "iid": 1, + "type": "0000003E-0000-1000-8000-0026BB765291", + "characteristics": [ + { + "type": "00000023-0000-1000-8000-0026BB765291", + "iid": 2, + "perms": ["pr"], + "format": "string", + "value": "Garage Door", + "description": "Name", + "maxLen": 64 + }, + { + "type": "00000020-0000-1000-8000-0026BB765291", + "iid": 3, + "perms": ["pr"], + "format": "string", + "value": "ecobee Inc.", + "description": "Manufacturer", + "maxLen": 64 + }, + { + "type": "00000030-0000-1000-8000-0026BB765291", + "iid": 4, + "perms": ["pr"], + "format": "string", + "value": "**REDACTED**", + "description": "Serial Number", + "maxLen": 64 + }, + { + "type": "00000021-0000-1000-8000-0026BB765291", + "iid": 5, + "perms": ["pr"], + "format": "string", + "value": "EBDWC01", + "description": "Model", + "maxLen": 64 + }, + { + "type": "00000052-0000-1000-8000-0026BB765291", + "iid": 8, + "perms": ["pr"], + "format": "string", + "value": "1.0.0", + "description": "Firmware Revision", + "maxLen": 64 + }, + { + "type": "00000014-0000-1000-8000-0026BB765291", + "iid": 6, + "perms": ["pw"], + "format": "bool", + "description": "Identify" + } + ] + }, + { + "iid": 192, + "type": "00000096-0000-1000-8000-0026BB765291", + "characteristics": [ + { + "type": "0000008F-0000-1000-8000-0026BB765291", + "iid": 193, + "perms": ["pr", "ev"], + "format": "uint8", + "value": 2, + "description": "Charging State", + "minValue": 0, + "maxValue": 2, + "minStep": 1 + }, + { + "type": "00000068-0000-1000-8000-0026BB765291", + "iid": 194, + "perms": ["pr", "ev"], + "format": "uint8", + "value": 100, + "description": "Battery Level", + "unit": "percentage", + "minValue": 0, + "maxValue": 100, + "minStep": 1 + }, + { + "type": "00000079-0000-1000-8000-0026BB765291", + "iid": 195, + "perms": ["pr", "ev"], + "format": "uint8", + "value": 0, + "description": "Status Low Battery", + "minValue": 0, + "maxValue": 1, + "minStep": 1 + }, + { + "type": "00000023-0000-1000-8000-0026BB765291", + "iid": 196, + "perms": ["pr"], + "format": "string", + "value": "Garage Door", + "description": "Name", + "maxLen": 64 + } + ] + }, + { + "iid": 57, + "type": "00000086-0000-1000-8000-0026BB765291", + "characteristics": [ + { + "type": "00000071-0000-1000-8000-0026BB765291", + "iid": 65, + "perms": ["pr", "ev"], + "format": "uint8", + "value": 1, + "description": "Occupancy Detected", + "minValue": 0, + "maxValue": 1, + "minStep": 1 + }, + { + "type": "00000023-0000-1000-8000-0026BB765291", + "iid": 29, + "perms": ["pr"], + "format": "string", + "value": "Garage Door Occupancy", + "description": "Name", + "maxLen": 64 + }, + { + "type": "00000075-0000-1000-8000-0026BB765291", + "iid": 117, + "perms": ["pr", "ev"], + "format": "bool", + "value": true, + "description": "Status Active" + }, + { + "type": "00000079-0000-1000-8000-0026BB765291", + "iid": 116, + "perms": ["pr", "ev"], + "format": "uint8", + "value": 0, + "description": "Status Low Battery", + "minValue": 0, + "maxValue": 1, + "minStep": 1 + }, + { + "type": "A8F798E0-4A40-11E6-BDF4-0800200C9A66", + "iid": 68, + "perms": ["pr"], + "format": "int", + "value": 1189, + "unit": "seconds", + "minValue": -1, + "maxValue": 86400, + "minStep": 1 + } + ] + }, + { + "iid": 56, + "type": "00000085-0000-1000-8000-0026BB765291", + "characteristics": [ + { + "type": "00000022-0000-1000-8000-0026BB765291", + "iid": 66, + "perms": ["pr", "ev"], + "format": "bool", + "value": false, + "description": "Motion Detected" + }, + { + "type": "00000023-0000-1000-8000-0026BB765291", + "iid": 28, + "perms": ["pr"], + "format": "string", + "value": "Garage Door Motion", + "description": "Name", + "maxLen": 64 + }, + { + "type": "00000075-0000-1000-8000-0026BB765291", + "iid": 101, + "perms": ["pr", "ev"], + "format": "bool", + "value": true, + "description": "Status Active" + }, + { + "type": "00000079-0000-1000-8000-0026BB765291", + "iid": 100, + "perms": ["pr", "ev"], + "format": "uint8", + "value": 0, + "description": "Status Low Battery", + "minValue": 0, + "maxValue": 1, + "minStep": 1 + }, + { + "type": "BFE61C70-4A40-11E6-BDF4-0800200C9A66", + "iid": 67, + "perms": ["pr"], + "format": "int", + "value": 888, + "unit": "seconds", + "minValue": -1, + "maxValue": 86400, + "minStep": 1 + } + ] + }, + { + "iid": 224, + "type": "00000080-0000-1000-8000-0026BB765291", + "characteristics": [ + { + "type": "0000006A-0000-1000-8000-0026BB765291", + "iid": 225, + "perms": ["pr", "ev"], + "format": "uint8", + "value": 0, + "description": "Contact Sensor State", + "minValue": 0, + "maxValue": 1, + "minStep": 1 + }, + { + "type": "00000023-0000-1000-8000-0026BB765291", + "iid": 226, + "perms": ["pr"], + "format": "string", + "value": "Garage Door Contact", + "description": "Name", + "maxLen": 64 + } + ] + } + ] + }, + { + "aid": 4298360914, + "services": [ + { + "iid": 1, + "type": "0000003E-0000-1000-8000-0026BB765291", + "characteristics": [ + { + "type": "00000023-0000-1000-8000-0026BB765291", + "iid": 2, + "perms": ["pr"], + "format": "string", + "value": "Basement Window 1", + "description": "Name", + "maxLen": 64 + }, + { + "type": "00000020-0000-1000-8000-0026BB765291", + "iid": 3, + "perms": ["pr"], + "format": "string", + "value": "ecobee Inc.", + "description": "Manufacturer", + "maxLen": 64 + }, + { + "type": "00000030-0000-1000-8000-0026BB765291", + "iid": 4, + "perms": ["pr"], + "format": "string", + "value": "**REDACTED**", + "description": "Serial Number", + "maxLen": 64 + }, + { + "type": "00000021-0000-1000-8000-0026BB765291", + "iid": 5, + "perms": ["pr"], + "format": "string", + "value": "EBDWC01", + "description": "Model", + "maxLen": 64 + }, + { + "type": "00000052-0000-1000-8000-0026BB765291", + "iid": 8, + "perms": ["pr"], + "format": "string", + "value": "1.0.0", + "description": "Firmware Revision", + "maxLen": 64 + }, + { + "type": "00000014-0000-1000-8000-0026BB765291", + "iid": 6, + "perms": ["pw"], + "format": "bool", + "description": "Identify" + } + ] + }, + { + "iid": 192, + "type": "00000096-0000-1000-8000-0026BB765291", + "characteristics": [ + { + "type": "0000008F-0000-1000-8000-0026BB765291", + "iid": 193, + "perms": ["pr", "ev"], + "format": "uint8", + "value": 2, + "description": "Charging State", + "minValue": 0, + "maxValue": 2, + "minStep": 1 + }, + { + "type": "00000068-0000-1000-8000-0026BB765291", + "iid": 194, + "perms": ["pr", "ev"], + "format": "uint8", + "value": 100, + "description": "Battery Level", + "unit": "percentage", + "minValue": 0, + "maxValue": 100, + "minStep": 1 + }, + { + "type": "00000079-0000-1000-8000-0026BB765291", + "iid": 195, + "perms": ["pr", "ev"], + "format": "uint8", + "value": 0, + "description": "Status Low Battery", + "minValue": 0, + "maxValue": 1, + "minStep": 1 + }, + { + "type": "00000023-0000-1000-8000-0026BB765291", + "iid": 196, + "perms": ["pr"], + "format": "string", + "value": "Basement Window 1", + "description": "Name", + "maxLen": 64 + } + ] + }, + { + "iid": 57, + "type": "00000086-0000-1000-8000-0026BB765291", + "characteristics": [ + { + "type": "00000071-0000-1000-8000-0026BB765291", + "iid": 65, + "perms": ["pr", "ev"], + "format": "uint8", + "value": 0, + "description": "Occupancy Detected", + "minValue": 0, + "maxValue": 1, + "minStep": 1 + }, + { + "type": "00000023-0000-1000-8000-0026BB765291", + "iid": 29, + "perms": ["pr"], + "format": "string", + "value": "Basement Window 1 Occupancy", + "description": "Name", + "maxLen": 64 + }, + { + "type": "00000075-0000-1000-8000-0026BB765291", + "iid": 117, + "perms": ["pr", "ev"], + "format": "bool", + "value": true, + "description": "Status Active" + }, + { + "type": "00000079-0000-1000-8000-0026BB765291", + "iid": 116, + "perms": ["pr", "ev"], + "format": "uint8", + "value": 0, + "description": "Status Low Battery", + "minValue": 0, + "maxValue": 1, + "minStep": 1 + }, + { + "type": "A8F798E0-4A40-11E6-BDF4-0800200C9A66", + "iid": 68, + "perms": ["pr"], + "format": "int", + "value": -1, + "unit": "seconds", + "minValue": -1, + "maxValue": 86400, + "minStep": 1 + } + ] + }, + { + "iid": 56, + "type": "00000085-0000-1000-8000-0026BB765291", + "characteristics": [ + { + "type": "00000022-0000-1000-8000-0026BB765291", + "iid": 66, + "perms": ["pr", "ev"], + "format": "bool", + "value": false, + "description": "Motion Detected" + }, + { + "type": "00000023-0000-1000-8000-0026BB765291", + "iid": 28, + "perms": ["pr"], + "format": "string", + "value": "Basement Window 1 Motion", + "description": "Name", + "maxLen": 64 + }, + { + "type": "00000075-0000-1000-8000-0026BB765291", + "iid": 101, + "perms": ["pr", "ev"], + "format": "bool", + "value": true, + "description": "Status Active" + }, + { + "type": "00000079-0000-1000-8000-0026BB765291", + "iid": 100, + "perms": ["pr", "ev"], + "format": "uint8", + "value": 0, + "description": "Status Low Battery", + "minValue": 0, + "maxValue": 1, + "minStep": 1 + }, + { + "type": "BFE61C70-4A40-11E6-BDF4-0800200C9A66", + "iid": 67, + "perms": ["pr"], + "format": "int", + "value": -1, + "unit": "seconds", + "minValue": -1, + "maxValue": 86400, + "minStep": 1 + } + ] + }, + { + "iid": 224, + "type": "00000080-0000-1000-8000-0026BB765291", + "characteristics": [ + { + "type": "0000006A-0000-1000-8000-0026BB765291", + "iid": 225, + "perms": ["pr", "ev"], + "format": "uint8", + "value": 0, + "description": "Contact Sensor State", + "minValue": 0, + "maxValue": 1, + "minStep": 1 + }, + { + "type": "00000023-0000-1000-8000-0026BB765291", + "iid": 226, + "perms": ["pr"], + "format": "string", + "value": "Basement Window 1 Contact", + "description": "Name", + "maxLen": 64 + } + ] + } + ] + }, + { + "aid": 4298360921, + "services": [ + { + "iid": 1, + "type": "0000003E-0000-1000-8000-0026BB765291", + "characteristics": [ + { + "type": "00000023-0000-1000-8000-0026BB765291", + "iid": 2, + "perms": ["pr"], + "format": "string", + "value": "Deck Door", + "description": "Name", + "maxLen": 64 + }, + { + "type": "00000020-0000-1000-8000-0026BB765291", + "iid": 3, + "perms": ["pr"], + "format": "string", + "value": "ecobee Inc.", + "description": "Manufacturer", + "maxLen": 64 + }, + { + "type": "00000030-0000-1000-8000-0026BB765291", + "iid": 4, + "perms": ["pr"], + "format": "string", + "value": "**REDACTED**", + "description": "Serial Number", + "maxLen": 64 + }, + { + "type": "00000021-0000-1000-8000-0026BB765291", + "iid": 5, + "perms": ["pr"], + "format": "string", + "value": "EBDWC01", + "description": "Model", + "maxLen": 64 + }, + { + "type": "00000052-0000-1000-8000-0026BB765291", + "iid": 8, + "perms": ["pr"], + "format": "string", + "value": "1.0.0", + "description": "Firmware Revision", + "maxLen": 64 + }, + { + "type": "00000014-0000-1000-8000-0026BB765291", + "iid": 6, + "perms": ["pw"], + "format": "bool", + "description": "Identify" + } + ] + }, + { + "iid": 192, + "type": "00000096-0000-1000-8000-0026BB765291", + "characteristics": [ + { + "type": "0000008F-0000-1000-8000-0026BB765291", + "iid": 193, + "perms": ["pr", "ev"], + "format": "uint8", + "value": 2, + "description": "Charging State", + "minValue": 0, + "maxValue": 2, + "minStep": 1 + }, + { + "type": "00000068-0000-1000-8000-0026BB765291", + "iid": 194, + "perms": ["pr", "ev"], + "format": "uint8", + "value": 100, + "description": "Battery Level", + "unit": "percentage", + "minValue": 0, + "maxValue": 100, + "minStep": 1 + }, + { + "type": "00000079-0000-1000-8000-0026BB765291", + "iid": 195, + "perms": ["pr", "ev"], + "format": "uint8", + "value": 0, + "description": "Status Low Battery", + "minValue": 0, + "maxValue": 1, + "minStep": 1 + }, + { + "type": "00000023-0000-1000-8000-0026BB765291", + "iid": 196, + "perms": ["pr"], + "format": "string", + "value": "Deck Door", + "description": "Name", + "maxLen": 64 + } + ] + }, + { + "iid": 57, + "type": "00000086-0000-1000-8000-0026BB765291", + "characteristics": [ + { + "type": "00000071-0000-1000-8000-0026BB765291", + "iid": 65, + "perms": ["pr", "ev"], + "format": "uint8", + "value": 0, + "description": "Occupancy Detected", + "minValue": 0, + "maxValue": 1, + "minStep": 1 + }, + { + "type": "00000023-0000-1000-8000-0026BB765291", + "iid": 29, + "perms": ["pr"], + "format": "string", + "value": "Deck Door Occupancy", + "description": "Name", + "maxLen": 64 + }, + { + "type": "00000075-0000-1000-8000-0026BB765291", + "iid": 117, + "perms": ["pr", "ev"], + "format": "bool", + "value": true, + "description": "Status Active" + }, + { + "type": "00000079-0000-1000-8000-0026BB765291", + "iid": 116, + "perms": ["pr", "ev"], + "format": "uint8", + "value": 0, + "description": "Status Low Battery", + "minValue": 0, + "maxValue": 1, + "minStep": 1 + }, + { + "type": "A8F798E0-4A40-11E6-BDF4-0800200C9A66", + "iid": 68, + "perms": ["pr"], + "format": "int", + "value": 944, + "unit": "seconds", + "minValue": -1, + "maxValue": 86400, + "minStep": 1 + } + ] + }, + { + "iid": 56, + "type": "00000085-0000-1000-8000-0026BB765291", + "characteristics": [ + { + "type": "00000022-0000-1000-8000-0026BB765291", + "iid": 66, + "perms": ["pr", "ev"], + "format": "bool", + "value": false, + "description": "Motion Detected" + }, + { + "type": "00000023-0000-1000-8000-0026BB765291", + "iid": 28, + "perms": ["pr"], + "format": "string", + "value": "Deck Door Motion", + "description": "Name", + "maxLen": 64 + }, + { + "type": "00000075-0000-1000-8000-0026BB765291", + "iid": 101, + "perms": ["pr", "ev"], + "format": "bool", + "value": true, + "description": "Status Active" + }, + { + "type": "00000079-0000-1000-8000-0026BB765291", + "iid": 100, + "perms": ["pr", "ev"], + "format": "uint8", + "value": 0, + "description": "Status Low Battery", + "minValue": 0, + "maxValue": 1, + "minStep": 1 + }, + { + "type": "BFE61C70-4A40-11E6-BDF4-0800200C9A66", + "iid": 67, + "perms": ["pr"], + "format": "int", + "value": 884, + "unit": "seconds", + "minValue": -1, + "maxValue": 86400, + "minStep": 1 + } + ] + }, + { + "iid": 224, + "type": "00000080-0000-1000-8000-0026BB765291", + "characteristics": [ + { + "type": "0000006A-0000-1000-8000-0026BB765291", + "iid": 225, + "perms": ["pr", "ev"], + "format": "uint8", + "value": 0, + "description": "Contact Sensor State", + "minValue": 0, + "maxValue": 1, + "minStep": 1 + }, + { + "type": "00000023-0000-1000-8000-0026BB765291", + "iid": 226, + "perms": ["pr"], + "format": "string", + "value": "Deck Door Contact", + "description": "Name", + "maxLen": 64 + } + ] + } + ] + }, + { + "aid": 4298360712, + "services": [ + { + "iid": 1, + "type": "0000003E-0000-1000-8000-0026BB765291", + "characteristics": [ + { + "type": "00000023-0000-1000-8000-0026BB765291", + "iid": 2, + "perms": ["pr"], + "format": "string", + "value": "Living Room Window 1", + "description": "Name", + "maxLen": 64 + }, + { + "type": "00000020-0000-1000-8000-0026BB765291", + "iid": 3, + "perms": ["pr"], + "format": "string", + "value": "ecobee Inc.", + "description": "Manufacturer", + "maxLen": 64 + }, + { + "type": "00000030-0000-1000-8000-0026BB765291", + "iid": 4, + "perms": ["pr"], + "format": "string", + "value": "**REDACTED**", + "description": "Serial Number", + "maxLen": 64 + }, + { + "type": "00000021-0000-1000-8000-0026BB765291", + "iid": 5, + "perms": ["pr"], + "format": "string", + "value": "EBDWC01", + "description": "Model", + "maxLen": 64 + }, + { + "type": "00000052-0000-1000-8000-0026BB765291", + "iid": 8, + "perms": ["pr"], + "format": "string", + "value": "1.0.0", + "description": "Firmware Revision", + "maxLen": 64 + }, + { + "type": "00000014-0000-1000-8000-0026BB765291", + "iid": 6, + "perms": ["pw"], + "format": "bool", + "description": "Identify" + } + ] + }, + { + "iid": 192, + "type": "00000096-0000-1000-8000-0026BB765291", + "characteristics": [ + { + "type": "0000008F-0000-1000-8000-0026BB765291", + "iid": 193, + "perms": ["pr", "ev"], + "format": "uint8", + "value": 2, + "description": "Charging State", + "minValue": 0, + "maxValue": 2, + "minStep": 1 + }, + { + "type": "00000068-0000-1000-8000-0026BB765291", + "iid": 194, + "perms": ["pr", "ev"], + "format": "uint8", + "value": 100, + "description": "Battery Level", + "unit": "percentage", + "minValue": 0, + "maxValue": 100, + "minStep": 1 + }, + { + "type": "00000079-0000-1000-8000-0026BB765291", + "iid": 195, + "perms": ["pr", "ev"], + "format": "uint8", + "value": 0, + "description": "Status Low Battery", + "minValue": 0, + "maxValue": 1, + "minStep": 1 + }, + { + "type": "00000023-0000-1000-8000-0026BB765291", + "iid": 196, + "perms": ["pr"], + "format": "string", + "value": "Living Room Window 1", + "description": "Name", + "maxLen": 64 + } + ] + }, + { + "iid": 57, + "type": "00000086-0000-1000-8000-0026BB765291", + "characteristics": [ + { + "type": "00000071-0000-1000-8000-0026BB765291", + "iid": 65, + "perms": ["pr", "ev"], + "format": "uint8", + "value": 1, + "description": "Occupancy Detected", + "minValue": 0, + "maxValue": 1, + "minStep": 1 + }, + { + "type": "00000023-0000-1000-8000-0026BB765291", + "iid": 29, + "perms": ["pr"], + "format": "string", + "value": "Living Room Window 1 Occupancy", + "description": "Name", + "maxLen": 64 + }, + { + "type": "00000075-0000-1000-8000-0026BB765291", + "iid": 117, + "perms": ["pr", "ev"], + "format": "bool", + "value": true, + "description": "Status Active" + }, + { + "type": "00000079-0000-1000-8000-0026BB765291", + "iid": 116, + "perms": ["pr", "ev"], + "format": "uint8", + "value": 0, + "description": "Status Low Battery", + "minValue": 0, + "maxValue": 1, + "minStep": 1 + }, + { + "type": "A8F798E0-4A40-11E6-BDF4-0800200C9A66", + "iid": 68, + "perms": ["pr"], + "format": "int", + "value": 1923, + "unit": "seconds", + "minValue": -1, + "maxValue": 86400, + "minStep": 1 + } + ] + }, + { + "iid": 56, + "type": "00000085-0000-1000-8000-0026BB765291", + "characteristics": [ + { + "type": "00000022-0000-1000-8000-0026BB765291", + "iid": 66, + "perms": ["pr", "ev"], + "format": "bool", + "value": true, + "description": "Motion Detected" + }, + { + "type": "00000023-0000-1000-8000-0026BB765291", + "iid": 28, + "perms": ["pr"], + "format": "string", + "value": "Living Room Window 1 Motion", + "description": "Name", + "maxLen": 64 + }, + { + "type": "00000075-0000-1000-8000-0026BB765291", + "iid": 101, + "perms": ["pr", "ev"], + "format": "bool", + "value": true, + "description": "Status Active" + }, + { + "type": "00000079-0000-1000-8000-0026BB765291", + "iid": 100, + "perms": ["pr", "ev"], + "format": "uint8", + "value": 0, + "description": "Status Low Battery", + "minValue": 0, + "maxValue": 1, + "minStep": 1 + }, + { + "type": "BFE61C70-4A40-11E6-BDF4-0800200C9A66", + "iid": 67, + "perms": ["pr"], + "format": "int", + "value": 625, + "unit": "seconds", + "minValue": -1, + "maxValue": 86400, + "minStep": 1 + } + ] + }, + { + "iid": 224, + "type": "00000080-0000-1000-8000-0026BB765291", + "characteristics": [ + { + "type": "0000006A-0000-1000-8000-0026BB765291", + "iid": 225, + "perms": ["pr", "ev"], + "format": "uint8", + "value": 0, + "description": "Contact Sensor State", + "minValue": 0, + "maxValue": 1, + "minStep": 1 + }, + { + "type": "00000023-0000-1000-8000-0026BB765291", + "iid": 226, + "perms": ["pr"], + "format": "string", + "value": "Living Room Window 1 Contact", + "description": "Name", + "maxLen": 64 + } + ] + } + ] + }, + { + "aid": 4298568508, + "services": [ + { + "iid": 1, + "type": "0000003E-0000-1000-8000-0026BB765291", + "characteristics": [ + { + "type": "00000023-0000-1000-8000-0026BB765291", + "iid": 2, + "perms": ["pr"], + "format": "string", + "value": "Upstairs BR Window", + "description": "Name", + "maxLen": 64 + }, + { + "type": "00000020-0000-1000-8000-0026BB765291", + "iid": 3, + "perms": ["pr"], + "format": "string", + "value": "ecobee Inc.", + "description": "Manufacturer", + "maxLen": 64 + }, + { + "type": "00000030-0000-1000-8000-0026BB765291", + "iid": 4, + "perms": ["pr"], + "format": "string", + "value": "**REDACTED**", + "description": "Serial Number", + "maxLen": 64 + }, + { + "type": "00000021-0000-1000-8000-0026BB765291", + "iid": 5, + "perms": ["pr"], + "format": "string", + "value": "EBDWC01", + "description": "Model", + "maxLen": 64 + }, + { + "type": "00000052-0000-1000-8000-0026BB765291", + "iid": 8, + "perms": ["pr"], + "format": "string", + "value": "1.0.0", + "description": "Firmware Revision", + "maxLen": 64 + }, + { + "type": "00000014-0000-1000-8000-0026BB765291", + "iid": 6, + "perms": ["pw"], + "format": "bool", + "description": "Identify" + } + ] + }, + { + "iid": 192, + "type": "00000096-0000-1000-8000-0026BB765291", + "characteristics": [ + { + "type": "0000008F-0000-1000-8000-0026BB765291", + "iid": 193, + "perms": ["pr", "ev"], + "format": "uint8", + "value": 2, + "description": "Charging State", + "minValue": 0, + "maxValue": 2, + "minStep": 1 + }, + { + "type": "00000068-0000-1000-8000-0026BB765291", + "iid": 194, + "perms": ["pr", "ev"], + "format": "uint8", + "value": 100, + "description": "Battery Level", + "unit": "percentage", + "minValue": 0, + "maxValue": 100, + "minStep": 1 + }, + { + "type": "00000079-0000-1000-8000-0026BB765291", + "iid": 195, + "perms": ["pr", "ev"], + "format": "uint8", + "value": 0, + "description": "Status Low Battery", + "minValue": 0, + "maxValue": 1, + "minStep": 1 + }, + { + "type": "00000023-0000-1000-8000-0026BB765291", + "iid": 196, + "perms": ["pr"], + "format": "string", + "value": "Upstairs BR Window", + "description": "Name", + "maxLen": 64 + } + ] + }, + { + "iid": 57, + "type": "00000086-0000-1000-8000-0026BB765291", + "characteristics": [ + { + "type": "00000071-0000-1000-8000-0026BB765291", + "iid": 65, + "perms": ["pr", "ev"], + "format": "uint8", + "value": 0, + "description": "Occupancy Detected", + "minValue": 0, + "maxValue": 1, + "minStep": 1 + }, + { + "type": "00000023-0000-1000-8000-0026BB765291", + "iid": 29, + "perms": ["pr"], + "format": "string", + "value": "Upstairs BR Window Occupancy", + "description": "Name", + "maxLen": 64 + }, + { + "type": "00000075-0000-1000-8000-0026BB765291", + "iid": 117, + "perms": ["pr", "ev"], + "format": "bool", + "value": true, + "description": "Status Active" + }, + { + "type": "00000079-0000-1000-8000-0026BB765291", + "iid": 116, + "perms": ["pr", "ev"], + "format": "uint8", + "value": 0, + "description": "Status Low Battery", + "minValue": 0, + "maxValue": 1, + "minStep": 1 + }, + { + "type": "A8F798E0-4A40-11E6-BDF4-0800200C9A66", + "iid": 68, + "perms": ["pr"], + "format": "int", + "value": 9060, + "unit": "seconds", + "minValue": -1, + "maxValue": 86400, + "minStep": 1 + } + ] + }, + { + "iid": 56, + "type": "00000085-0000-1000-8000-0026BB765291", + "characteristics": [ + { + "type": "00000022-0000-1000-8000-0026BB765291", + "iid": 66, + "perms": ["pr", "ev"], + "format": "bool", + "value": false, + "description": "Motion Detected" + }, + { + "type": "00000023-0000-1000-8000-0026BB765291", + "iid": 28, + "perms": ["pr"], + "format": "string", + "value": "Upstairs BR Window Motion", + "description": "Name", + "maxLen": 64 + }, + { + "type": "00000075-0000-1000-8000-0026BB765291", + "iid": 101, + "perms": ["pr", "ev"], + "format": "bool", + "value": true, + "description": "Status Active" + }, + { + "type": "00000079-0000-1000-8000-0026BB765291", + "iid": 100, + "perms": ["pr", "ev"], + "format": "uint8", + "value": 0, + "description": "Status Low Battery", + "minValue": 0, + "maxValue": 1, + "minStep": 1 + }, + { + "type": "BFE61C70-4A40-11E6-BDF4-0800200C9A66", + "iid": 67, + "perms": ["pr"], + "format": "int", + "value": 9060, + "unit": "seconds", + "minValue": -1, + "maxValue": 86400, + "minStep": 1 + } + ] + }, + { + "iid": 224, + "type": "00000080-0000-1000-8000-0026BB765291", + "characteristics": [ + { + "type": "0000006A-0000-1000-8000-0026BB765291", + "iid": 225, + "perms": ["pr", "ev"], + "format": "uint8", + "value": 0, + "description": "Contact Sensor State", + "minValue": 0, + "maxValue": 1, + "minStep": 1 + }, + { + "type": "00000023-0000-1000-8000-0026BB765291", + "iid": 226, + "perms": ["pr"], + "format": "string", + "value": "Upstairs BR Window Contact", + "description": "Name", + "maxLen": 64 + } + ] + } + ] + } +] diff --git a/tests/components/homekit_controller/snapshots/test_init.ambr b/tests/components/homekit_controller/snapshots/test_init.ambr index 62b53df33f2..3bb9eb48106 100644 --- a/tests/components/homekit_controller/snapshots/test_init.ambr +++ b/tests/components/homekit_controller/snapshots/test_init.ambr @@ -4195,6 +4195,3464 @@ }), ]) # --- +# name: test_snapshots[ecobee3_lite] + list([ + dict({ + 'device': dict({ + 'area_id': None, + 'config_entries': list([ + 'TestData', + ]), + 'config_entries_subentries': dict({ + 'TestData': list([ + None, + ]), + }), + 'configuration_url': None, + 'connections': list([ + ]), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': '', + 'identifiers': list([ + list([ + 'homekit_controller:accessory-id', + '00:00:00:00:00:00:aid:4295608960', + ]), + ]), + 'is_new': False, + 'labels': list([ + ]), + 'manufacturer': 'ecobee Inc.', + 'model': 'EBRSE4', + 'model_id': None, + 'name': 'Basement', + 'name_by_user': None, + 'primary_config_entry': 'TestData', + 'serial_number': '**REDACTED**', + 'suggested_area': None, + 'sw_version': '1.0.0', + }), + 'entities': list([ + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': None, + 'categories': dict({ + }), + 'config_entry_id': 'TestData', + 'config_subentry_id': None, + 'device_class': None, + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.basement_motion', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'labels': list([ + ]), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Basement Motion', + 'platform': 'homekit_controller', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_4295608960_56', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'device_class': 'motion', + 'friendly_name': 'Basement Motion', + }), + 'entity_id': 'binary_sensor.basement_motion', + 'state': 'off', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': None, + 'categories': dict({ + }), + 'config_entry_id': 'TestData', + 'config_subentry_id': None, + 'device_class': None, + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.basement_occupancy', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'labels': list([ + ]), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Basement Occupancy', + 'platform': 'homekit_controller', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_4295608960_57', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'device_class': 'occupancy', + 'friendly_name': 'Basement Occupancy', + }), + 'entity_id': 'binary_sensor.basement_occupancy', + 'state': 'off', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': None, + 'categories': dict({ + }), + 'config_entry_id': 'TestData', + 'config_subentry_id': None, + 'device_class': None, + 'disabled_by': None, + 'domain': 'button', + 'entity_category': , + 'entity_id': 'button.basement_identify', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'labels': list([ + ]), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Basement Identify', + 'platform': 'homekit_controller', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_4295608960_1_6', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'device_class': 'identify', + 'friendly_name': 'Basement Identify', + }), + 'entity_id': 'button.basement_identify', + 'state': 'unknown', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'categories': dict({ + }), + 'config_entry_id': 'TestData', + 'config_subentry_id': None, + 'device_class': None, + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.basement_battery', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'labels': list([ + ]), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': 'mdi:battery-unknown', + 'original_name': 'Basement Battery', + 'platform': 'homekit_controller', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_4295608960_192', + 'unit_of_measurement': '%', + }), + 'state': dict({ + 'attributes': dict({ + 'device_class': 'battery', + 'friendly_name': 'Basement Battery', + 'icon': 'mdi:battery', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'entity_id': 'sensor.basement_battery', + 'state': '100', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'categories': dict({ + }), + 'config_entry_id': 'TestData', + 'config_subentry_id': None, + 'device_class': None, + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.basement_temperature', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'labels': list([ + ]), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Basement Temperature', + 'platform': 'homekit_controller', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_4295608960_208', + 'unit_of_measurement': , + }), + 'state': dict({ + 'attributes': dict({ + 'device_class': 'temperature', + 'friendly_name': 'Basement Temperature', + 'state_class': , + 'unit_of_measurement': , + }), + 'entity_id': 'sensor.basement_temperature', + 'state': '20.3', + }), + }), + ]), + }), + dict({ + 'device': dict({ + 'area_id': None, + 'config_entries': list([ + 'TestData', + ]), + 'config_entries_subentries': dict({ + 'TestData': list([ + None, + ]), + }), + 'configuration_url': None, + 'connections': list([ + ]), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': '', + 'identifiers': list([ + list([ + 'homekit_controller:accessory-id', + '00:00:00:00:00:00:aid:4298360914', + ]), + ]), + 'is_new': False, + 'labels': list([ + ]), + 'manufacturer': 'ecobee Inc.', + 'model': 'EBDWC01', + 'model_id': None, + 'name': 'Basement Window 1', + 'name_by_user': None, + 'primary_config_entry': 'TestData', + 'serial_number': '**REDACTED**', + 'suggested_area': None, + 'sw_version': '1.0.0', + }), + 'entities': list([ + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': None, + 'categories': dict({ + }), + 'config_entry_id': 'TestData', + 'config_subentry_id': None, + 'device_class': None, + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.basement_window_1_contact', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'labels': list([ + ]), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Basement Window 1 Contact', + 'platform': 'homekit_controller', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_4298360914_224', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'device_class': 'opening', + 'friendly_name': 'Basement Window 1 Contact', + }), + 'entity_id': 'binary_sensor.basement_window_1_contact', + 'state': 'off', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': None, + 'categories': dict({ + }), + 'config_entry_id': 'TestData', + 'config_subentry_id': None, + 'device_class': None, + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.basement_window_1_motion', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'labels': list([ + ]), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Basement Window 1 Motion', + 'platform': 'homekit_controller', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_4298360914_56', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'device_class': 'motion', + 'friendly_name': 'Basement Window 1 Motion', + }), + 'entity_id': 'binary_sensor.basement_window_1_motion', + 'state': 'off', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': None, + 'categories': dict({ + }), + 'config_entry_id': 'TestData', + 'config_subentry_id': None, + 'device_class': None, + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.basement_window_1_occupancy', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'labels': list([ + ]), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Basement Window 1 Occupancy', + 'platform': 'homekit_controller', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_4298360914_57', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'device_class': 'occupancy', + 'friendly_name': 'Basement Window 1 Occupancy', + }), + 'entity_id': 'binary_sensor.basement_window_1_occupancy', + 'state': 'off', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': None, + 'categories': dict({ + }), + 'config_entry_id': 'TestData', + 'config_subentry_id': None, + 'device_class': None, + 'disabled_by': None, + 'domain': 'button', + 'entity_category': , + 'entity_id': 'button.basement_window_1_identify', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'labels': list([ + ]), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Basement Window 1 Identify', + 'platform': 'homekit_controller', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_4298360914_1_6', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'device_class': 'identify', + 'friendly_name': 'Basement Window 1 Identify', + }), + 'entity_id': 'button.basement_window_1_identify', + 'state': 'unknown', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'categories': dict({ + }), + 'config_entry_id': 'TestData', + 'config_subentry_id': None, + 'device_class': None, + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.basement_window_1_battery', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'labels': list([ + ]), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': 'mdi:battery-unknown', + 'original_name': 'Basement Window 1 Battery', + 'platform': 'homekit_controller', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_4298360914_192', + 'unit_of_measurement': '%', + }), + 'state': dict({ + 'attributes': dict({ + 'device_class': 'battery', + 'friendly_name': 'Basement Window 1 Battery', + 'icon': 'mdi:battery', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'entity_id': 'sensor.basement_window_1_battery', + 'state': '100', + }), + }), + ]), + }), + dict({ + 'device': dict({ + 'area_id': None, + 'config_entries': list([ + 'TestData', + ]), + 'config_entries_subentries': dict({ + 'TestData': list([ + None, + ]), + }), + 'configuration_url': None, + 'connections': list([ + ]), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': '', + 'identifiers': list([ + list([ + 'homekit_controller:accessory-id', + '00:00:00:00:00:00:aid:4298360921', + ]), + ]), + 'is_new': False, + 'labels': list([ + ]), + 'manufacturer': 'ecobee Inc.', + 'model': 'EBDWC01', + 'model_id': None, + 'name': 'Deck Door', + 'name_by_user': None, + 'primary_config_entry': 'TestData', + 'serial_number': '**REDACTED**', + 'suggested_area': None, + 'sw_version': '1.0.0', + }), + 'entities': list([ + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': None, + 'categories': dict({ + }), + 'config_entry_id': 'TestData', + 'config_subentry_id': None, + 'device_class': None, + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.deck_door_contact', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'labels': list([ + ]), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Deck Door Contact', + 'platform': 'homekit_controller', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_4298360921_224', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'device_class': 'opening', + 'friendly_name': 'Deck Door Contact', + }), + 'entity_id': 'binary_sensor.deck_door_contact', + 'state': 'off', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': None, + 'categories': dict({ + }), + 'config_entry_id': 'TestData', + 'config_subentry_id': None, + 'device_class': None, + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.deck_door_motion', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'labels': list([ + ]), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Deck Door Motion', + 'platform': 'homekit_controller', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_4298360921_56', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'device_class': 'motion', + 'friendly_name': 'Deck Door Motion', + }), + 'entity_id': 'binary_sensor.deck_door_motion', + 'state': 'off', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': None, + 'categories': dict({ + }), + 'config_entry_id': 'TestData', + 'config_subentry_id': None, + 'device_class': None, + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.deck_door_occupancy', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'labels': list([ + ]), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Deck Door Occupancy', + 'platform': 'homekit_controller', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_4298360921_57', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'device_class': 'occupancy', + 'friendly_name': 'Deck Door Occupancy', + }), + 'entity_id': 'binary_sensor.deck_door_occupancy', + 'state': 'off', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': None, + 'categories': dict({ + }), + 'config_entry_id': 'TestData', + 'config_subentry_id': None, + 'device_class': None, + 'disabled_by': None, + 'domain': 'button', + 'entity_category': , + 'entity_id': 'button.deck_door_identify', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'labels': list([ + ]), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Deck Door Identify', + 'platform': 'homekit_controller', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_4298360921_1_6', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'device_class': 'identify', + 'friendly_name': 'Deck Door Identify', + }), + 'entity_id': 'button.deck_door_identify', + 'state': 'unknown', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'categories': dict({ + }), + 'config_entry_id': 'TestData', + 'config_subentry_id': None, + 'device_class': None, + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.deck_door_battery', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'labels': list([ + ]), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': 'mdi:battery-unknown', + 'original_name': 'Deck Door Battery', + 'platform': 'homekit_controller', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_4298360921_192', + 'unit_of_measurement': '%', + }), + 'state': dict({ + 'attributes': dict({ + 'device_class': 'battery', + 'friendly_name': 'Deck Door Battery', + 'icon': 'mdi:battery', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'entity_id': 'sensor.deck_door_battery', + 'state': '100', + }), + }), + ]), + }), + dict({ + 'device': dict({ + 'area_id': None, + 'config_entries': list([ + 'TestData', + ]), + 'config_entries_subentries': dict({ + 'TestData': list([ + None, + ]), + }), + 'configuration_url': None, + 'connections': list([ + ]), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': '', + 'identifiers': list([ + list([ + 'homekit_controller:accessory-id', + '00:00:00:00:00:00:aid:4298527970', + ]), + ]), + 'is_new': False, + 'labels': list([ + ]), + 'manufacturer': 'ecobee Inc.', + 'model': 'EBDWC01', + 'model_id': None, + 'name': 'Front Door', + 'name_by_user': None, + 'primary_config_entry': 'TestData', + 'serial_number': '**REDACTED**', + 'suggested_area': None, + 'sw_version': '1.0.0', + }), + 'entities': list([ + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': None, + 'categories': dict({ + }), + 'config_entry_id': 'TestData', + 'config_subentry_id': None, + 'device_class': None, + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.front_door_contact', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'labels': list([ + ]), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Front Door Contact', + 'platform': 'homekit_controller', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_4298527970_224', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'device_class': 'opening', + 'friendly_name': 'Front Door Contact', + }), + 'entity_id': 'binary_sensor.front_door_contact', + 'state': 'off', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': None, + 'categories': dict({ + }), + 'config_entry_id': 'TestData', + 'config_subentry_id': None, + 'device_class': None, + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.front_door_motion', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'labels': list([ + ]), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Front Door Motion', + 'platform': 'homekit_controller', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_4298527970_56', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'device_class': 'motion', + 'friendly_name': 'Front Door Motion', + }), + 'entity_id': 'binary_sensor.front_door_motion', + 'state': 'on', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': None, + 'categories': dict({ + }), + 'config_entry_id': 'TestData', + 'config_subentry_id': None, + 'device_class': None, + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.front_door_occupancy', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'labels': list([ + ]), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Front Door Occupancy', + 'platform': 'homekit_controller', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_4298527970_57', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'device_class': 'occupancy', + 'friendly_name': 'Front Door Occupancy', + }), + 'entity_id': 'binary_sensor.front_door_occupancy', + 'state': 'on', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': None, + 'categories': dict({ + }), + 'config_entry_id': 'TestData', + 'config_subentry_id': None, + 'device_class': None, + 'disabled_by': None, + 'domain': 'button', + 'entity_category': , + 'entity_id': 'button.front_door_identify', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'labels': list([ + ]), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Front Door Identify', + 'platform': 'homekit_controller', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_4298527970_1_6', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'device_class': 'identify', + 'friendly_name': 'Front Door Identify', + }), + 'entity_id': 'button.front_door_identify', + 'state': 'unknown', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'categories': dict({ + }), + 'config_entry_id': 'TestData', + 'config_subentry_id': None, + 'device_class': None, + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.front_door_battery', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'labels': list([ + ]), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': 'mdi:battery-unknown', + 'original_name': 'Front Door Battery', + 'platform': 'homekit_controller', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_4298527970_192', + 'unit_of_measurement': '%', + }), + 'state': dict({ + 'attributes': dict({ + 'device_class': 'battery', + 'friendly_name': 'Front Door Battery', + 'icon': 'mdi:battery', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'entity_id': 'sensor.front_door_battery', + 'state': '100', + }), + }), + ]), + }), + dict({ + 'device': dict({ + 'area_id': None, + 'config_entries': list([ + 'TestData', + ]), + 'config_entries_subentries': dict({ + 'TestData': list([ + None, + ]), + }), + 'configuration_url': None, + 'connections': list([ + ]), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': '', + 'identifiers': list([ + list([ + 'homekit_controller:accessory-id', + '00:00:00:00:00:00:aid:4298527962', + ]), + ]), + 'is_new': False, + 'labels': list([ + ]), + 'manufacturer': 'ecobee Inc.', + 'model': 'EBDWC01', + 'model_id': None, + 'name': 'Garage Door', + 'name_by_user': None, + 'primary_config_entry': 'TestData', + 'serial_number': '**REDACTED**', + 'suggested_area': None, + 'sw_version': '1.0.0', + }), + 'entities': list([ + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': None, + 'categories': dict({ + }), + 'config_entry_id': 'TestData', + 'config_subentry_id': None, + 'device_class': None, + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.garage_door_contact', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'labels': list([ + ]), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Garage Door Contact', + 'platform': 'homekit_controller', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_4298527962_224', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'device_class': 'opening', + 'friendly_name': 'Garage Door Contact', + }), + 'entity_id': 'binary_sensor.garage_door_contact', + 'state': 'off', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': None, + 'categories': dict({ + }), + 'config_entry_id': 'TestData', + 'config_subentry_id': None, + 'device_class': None, + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.garage_door_motion', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'labels': list([ + ]), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Garage Door Motion', + 'platform': 'homekit_controller', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_4298527962_56', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'device_class': 'motion', + 'friendly_name': 'Garage Door Motion', + }), + 'entity_id': 'binary_sensor.garage_door_motion', + 'state': 'off', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': None, + 'categories': dict({ + }), + 'config_entry_id': 'TestData', + 'config_subentry_id': None, + 'device_class': None, + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.garage_door_occupancy', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'labels': list([ + ]), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Garage Door Occupancy', + 'platform': 'homekit_controller', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_4298527962_57', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'device_class': 'occupancy', + 'friendly_name': 'Garage Door Occupancy', + }), + 'entity_id': 'binary_sensor.garage_door_occupancy', + 'state': 'on', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': None, + 'categories': dict({ + }), + 'config_entry_id': 'TestData', + 'config_subentry_id': None, + 'device_class': None, + 'disabled_by': None, + 'domain': 'button', + 'entity_category': , + 'entity_id': 'button.garage_door_identify', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'labels': list([ + ]), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Garage Door Identify', + 'platform': 'homekit_controller', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_4298527962_1_6', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'device_class': 'identify', + 'friendly_name': 'Garage Door Identify', + }), + 'entity_id': 'button.garage_door_identify', + 'state': 'unknown', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'categories': dict({ + }), + 'config_entry_id': 'TestData', + 'config_subentry_id': None, + 'device_class': None, + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.garage_door_battery', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'labels': list([ + ]), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': 'mdi:battery-unknown', + 'original_name': 'Garage Door Battery', + 'platform': 'homekit_controller', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_4298527962_192', + 'unit_of_measurement': '%', + }), + 'state': dict({ + 'attributes': dict({ + 'device_class': 'battery', + 'friendly_name': 'Garage Door Battery', + 'icon': 'mdi:battery', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'entity_id': 'sensor.garage_door_battery', + 'state': '100', + }), + }), + ]), + }), + dict({ + 'device': dict({ + 'area_id': None, + 'config_entries': list([ + 'TestData', + ]), + 'config_entries_subentries': dict({ + 'TestData': list([ + None, + ]), + }), + 'configuration_url': None, + 'connections': list([ + ]), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': '', + 'identifiers': list([ + list([ + 'homekit_controller:accessory-id', + '00:00:00:00:00:00:aid:4295016858', + ]), + ]), + 'is_new': False, + 'labels': list([ + ]), + 'manufacturer': 'ecobee Inc.', + 'model': 'EBRSE4', + 'model_id': None, + 'name': 'Living Room', + 'name_by_user': None, + 'primary_config_entry': 'TestData', + 'serial_number': '**REDACTED**', + 'suggested_area': None, + 'sw_version': '1.0.0', + }), + 'entities': list([ + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': None, + 'categories': dict({ + }), + 'config_entry_id': 'TestData', + 'config_subentry_id': None, + 'device_class': None, + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.living_room_motion', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'labels': list([ + ]), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Living Room Motion', + 'platform': 'homekit_controller', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_4295016858_56', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'device_class': 'motion', + 'friendly_name': 'Living Room Motion', + }), + 'entity_id': 'binary_sensor.living_room_motion', + 'state': 'off', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': None, + 'categories': dict({ + }), + 'config_entry_id': 'TestData', + 'config_subentry_id': None, + 'device_class': None, + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.living_room_occupancy', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'labels': list([ + ]), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Living Room Occupancy', + 'platform': 'homekit_controller', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_4295016858_57', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'device_class': 'occupancy', + 'friendly_name': 'Living Room Occupancy', + }), + 'entity_id': 'binary_sensor.living_room_occupancy', + 'state': 'off', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': None, + 'categories': dict({ + }), + 'config_entry_id': 'TestData', + 'config_subentry_id': None, + 'device_class': None, + 'disabled_by': None, + 'domain': 'button', + 'entity_category': , + 'entity_id': 'button.living_room_identify', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'labels': list([ + ]), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Living Room Identify', + 'platform': 'homekit_controller', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_4295016858_1_6', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'device_class': 'identify', + 'friendly_name': 'Living Room Identify', + }), + 'entity_id': 'button.living_room_identify', + 'state': 'unknown', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'categories': dict({ + }), + 'config_entry_id': 'TestData', + 'config_subentry_id': None, + 'device_class': None, + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.living_room_battery', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'labels': list([ + ]), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': 'mdi:battery-unknown', + 'original_name': 'Living Room Battery', + 'platform': 'homekit_controller', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_4295016858_192', + 'unit_of_measurement': '%', + }), + 'state': dict({ + 'attributes': dict({ + 'device_class': 'battery', + 'friendly_name': 'Living Room Battery', + 'icon': 'mdi:battery', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'entity_id': 'sensor.living_room_battery', + 'state': '100', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'categories': dict({ + }), + 'config_entry_id': 'TestData', + 'config_subentry_id': None, + 'device_class': None, + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.living_room_temperature', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'labels': list([ + ]), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Living Room Temperature', + 'platform': 'homekit_controller', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_4295016858_208', + 'unit_of_measurement': , + }), + 'state': dict({ + 'attributes': dict({ + 'device_class': 'temperature', + 'friendly_name': 'Living Room Temperature', + 'state_class': , + 'unit_of_measurement': , + }), + 'entity_id': 'sensor.living_room_temperature', + 'state': '21.0', + }), + }), + ]), + }), + dict({ + 'device': dict({ + 'area_id': None, + 'config_entries': list([ + 'TestData', + ]), + 'config_entries_subentries': dict({ + 'TestData': list([ + None, + ]), + }), + 'configuration_url': None, + 'connections': list([ + ]), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': '', + 'identifiers': list([ + list([ + 'homekit_controller:accessory-id', + '00:00:00:00:00:00:aid:4298360712', + ]), + ]), + 'is_new': False, + 'labels': list([ + ]), + 'manufacturer': 'ecobee Inc.', + 'model': 'EBDWC01', + 'model_id': None, + 'name': 'Living Room Window 1', + 'name_by_user': None, + 'primary_config_entry': 'TestData', + 'serial_number': '**REDACTED**', + 'suggested_area': None, + 'sw_version': '1.0.0', + }), + 'entities': list([ + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': None, + 'categories': dict({ + }), + 'config_entry_id': 'TestData', + 'config_subentry_id': None, + 'device_class': None, + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.living_room_window_1_contact', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'labels': list([ + ]), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Living Room Window 1 Contact', + 'platform': 'homekit_controller', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_4298360712_224', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'device_class': 'opening', + 'friendly_name': 'Living Room Window 1 Contact', + }), + 'entity_id': 'binary_sensor.living_room_window_1_contact', + 'state': 'off', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': None, + 'categories': dict({ + }), + 'config_entry_id': 'TestData', + 'config_subentry_id': None, + 'device_class': None, + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.living_room_window_1_motion', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'labels': list([ + ]), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Living Room Window 1 Motion', + 'platform': 'homekit_controller', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_4298360712_56', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'device_class': 'motion', + 'friendly_name': 'Living Room Window 1 Motion', + }), + 'entity_id': 'binary_sensor.living_room_window_1_motion', + 'state': 'on', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': None, + 'categories': dict({ + }), + 'config_entry_id': 'TestData', + 'config_subentry_id': None, + 'device_class': None, + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.living_room_window_1_occupancy', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'labels': list([ + ]), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Living Room Window 1 Occupancy', + 'platform': 'homekit_controller', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_4298360712_57', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'device_class': 'occupancy', + 'friendly_name': 'Living Room Window 1 Occupancy', + }), + 'entity_id': 'binary_sensor.living_room_window_1_occupancy', + 'state': 'on', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': None, + 'categories': dict({ + }), + 'config_entry_id': 'TestData', + 'config_subentry_id': None, + 'device_class': None, + 'disabled_by': None, + 'domain': 'button', + 'entity_category': , + 'entity_id': 'button.living_room_window_1_identify', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'labels': list([ + ]), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Living Room Window 1 Identify', + 'platform': 'homekit_controller', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_4298360712_1_6', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'device_class': 'identify', + 'friendly_name': 'Living Room Window 1 Identify', + }), + 'entity_id': 'button.living_room_window_1_identify', + 'state': 'unknown', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'categories': dict({ + }), + 'config_entry_id': 'TestData', + 'config_subentry_id': None, + 'device_class': None, + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.living_room_window_1_battery', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'labels': list([ + ]), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': 'mdi:battery-unknown', + 'original_name': 'Living Room Window 1 Battery', + 'platform': 'homekit_controller', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_4298360712_192', + 'unit_of_measurement': '%', + }), + 'state': dict({ + 'attributes': dict({ + 'device_class': 'battery', + 'friendly_name': 'Living Room Window 1 Battery', + 'icon': 'mdi:battery', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'entity_id': 'sensor.living_room_window_1_battery', + 'state': '100', + }), + }), + ]), + }), + dict({ + 'device': dict({ + 'area_id': None, + 'config_entries': list([ + 'TestData', + ]), + 'config_entries_subentries': dict({ + 'TestData': list([ + None, + ]), + }), + 'configuration_url': None, + 'connections': list([ + ]), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': '', + 'identifiers': list([ + list([ + 'homekit_controller:accessory-id', + '00:00:00:00:00:00:aid:4298649931', + ]), + ]), + 'is_new': False, + 'labels': list([ + ]), + 'manufacturer': 'ecobee Inc.', + 'model': 'EBDWC01', + 'model_id': None, + 'name': 'Loft window', + 'name_by_user': None, + 'primary_config_entry': 'TestData', + 'serial_number': '**REDACTED**', + 'suggested_area': None, + 'sw_version': '1.0.0', + }), + 'entities': list([ + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': None, + 'categories': dict({ + }), + 'config_entry_id': 'TestData', + 'config_subentry_id': None, + 'device_class': None, + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.loft_window_contact', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'labels': list([ + ]), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Loft window Contact', + 'platform': 'homekit_controller', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_4298649931_224', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'device_class': 'opening', + 'friendly_name': 'Loft window Contact', + }), + 'entity_id': 'binary_sensor.loft_window_contact', + 'state': 'off', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': None, + 'categories': dict({ + }), + 'config_entry_id': 'TestData', + 'config_subentry_id': None, + 'device_class': None, + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.loft_window_motion', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'labels': list([ + ]), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Loft window Motion', + 'platform': 'homekit_controller', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_4298649931_56', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'device_class': 'motion', + 'friendly_name': 'Loft window Motion', + }), + 'entity_id': 'binary_sensor.loft_window_motion', + 'state': 'off', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': None, + 'categories': dict({ + }), + 'config_entry_id': 'TestData', + 'config_subentry_id': None, + 'device_class': None, + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.loft_window_occupancy', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'labels': list([ + ]), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Loft window Occupancy', + 'platform': 'homekit_controller', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_4298649931_57', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'device_class': 'occupancy', + 'friendly_name': 'Loft window Occupancy', + }), + 'entity_id': 'binary_sensor.loft_window_occupancy', + 'state': 'off', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': None, + 'categories': dict({ + }), + 'config_entry_id': 'TestData', + 'config_subentry_id': None, + 'device_class': None, + 'disabled_by': None, + 'domain': 'button', + 'entity_category': , + 'entity_id': 'button.loft_window_identify', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'labels': list([ + ]), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Loft window Identify', + 'platform': 'homekit_controller', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_4298649931_1_6', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'device_class': 'identify', + 'friendly_name': 'Loft window Identify', + }), + 'entity_id': 'button.loft_window_identify', + 'state': 'unknown', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'categories': dict({ + }), + 'config_entry_id': 'TestData', + 'config_subentry_id': None, + 'device_class': None, + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.loft_window_battery', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'labels': list([ + ]), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': 'mdi:battery-unknown', + 'original_name': 'Loft window Battery', + 'platform': 'homekit_controller', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_4298649931_192', + 'unit_of_measurement': '%', + }), + 'state': dict({ + 'attributes': dict({ + 'device_class': 'battery', + 'friendly_name': 'Loft window Battery', + 'icon': 'mdi:battery', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'entity_id': 'sensor.loft_window_battery', + 'state': '100', + }), + }), + ]), + }), + dict({ + 'device': dict({ + 'area_id': None, + 'config_entries': list([ + 'TestData', + ]), + 'config_entries_subentries': dict({ + 'TestData': list([ + None, + ]), + }), + 'configuration_url': None, + 'connections': list([ + ]), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': '', + 'identifiers': list([ + list([ + 'homekit_controller:accessory-id', + '00:00:00:00:00:00:aid:4295608971', + ]), + ]), + 'is_new': False, + 'labels': list([ + ]), + 'manufacturer': 'ecobee Inc.', + 'model': 'EBRSE4', + 'model_id': None, + 'name': 'Master BR', + 'name_by_user': None, + 'primary_config_entry': 'TestData', + 'serial_number': '**REDACTED**', + 'suggested_area': None, + 'sw_version': '1.0.0', + }), + 'entities': list([ + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': None, + 'categories': dict({ + }), + 'config_entry_id': 'TestData', + 'config_subentry_id': None, + 'device_class': None, + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.master_br_motion', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'labels': list([ + ]), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Master BR Motion', + 'platform': 'homekit_controller', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_4295608971_56', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'device_class': 'motion', + 'friendly_name': 'Master BR Motion', + }), + 'entity_id': 'binary_sensor.master_br_motion', + 'state': 'off', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': None, + 'categories': dict({ + }), + 'config_entry_id': 'TestData', + 'config_subentry_id': None, + 'device_class': None, + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.master_br_occupancy', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'labels': list([ + ]), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Master BR Occupancy', + 'platform': 'homekit_controller', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_4295608971_57', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'device_class': 'occupancy', + 'friendly_name': 'Master BR Occupancy', + }), + 'entity_id': 'binary_sensor.master_br_occupancy', + 'state': 'on', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': None, + 'categories': dict({ + }), + 'config_entry_id': 'TestData', + 'config_subentry_id': None, + 'device_class': None, + 'disabled_by': None, + 'domain': 'button', + 'entity_category': , + 'entity_id': 'button.master_br_identify', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'labels': list([ + ]), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Master BR Identify', + 'platform': 'homekit_controller', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_4295608971_1_6', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'device_class': 'identify', + 'friendly_name': 'Master BR Identify', + }), + 'entity_id': 'button.master_br_identify', + 'state': 'unknown', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'categories': dict({ + }), + 'config_entry_id': 'TestData', + 'config_subentry_id': None, + 'device_class': None, + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.master_br_battery', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'labels': list([ + ]), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': 'mdi:battery-unknown', + 'original_name': 'Master BR Battery', + 'platform': 'homekit_controller', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_4295608971_192', + 'unit_of_measurement': '%', + }), + 'state': dict({ + 'attributes': dict({ + 'device_class': 'battery', + 'friendly_name': 'Master BR Battery', + 'icon': 'mdi:battery', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'entity_id': 'sensor.master_br_battery', + 'state': '100', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'categories': dict({ + }), + 'config_entry_id': 'TestData', + 'config_subentry_id': None, + 'device_class': None, + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.master_br_temperature', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'labels': list([ + ]), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Master BR Temperature', + 'platform': 'homekit_controller', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_4295608971_208', + 'unit_of_measurement': , + }), + 'state': dict({ + 'attributes': dict({ + 'device_class': 'temperature', + 'friendly_name': 'Master BR Temperature', + 'state_class': , + 'unit_of_measurement': , + }), + 'entity_id': 'sensor.master_br_temperature', + 'state': '22.4', + }), + }), + ]), + }), + dict({ + 'device': dict({ + 'area_id': None, + 'config_entries': list([ + 'TestData', + ]), + 'config_entries_subentries': dict({ + 'TestData': list([ + None, + ]), + }), + 'configuration_url': None, + 'connections': list([ + ]), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': '', + 'identifiers': list([ + list([ + 'homekit_controller:accessory-id', + '00:00:00:00:00:00:aid:4298584118', + ]), + ]), + 'is_new': False, + 'labels': list([ + ]), + 'manufacturer': 'ecobee Inc.', + 'model': 'EBDWC01', + 'model_id': None, + 'name': 'Master BR Window', + 'name_by_user': None, + 'primary_config_entry': 'TestData', + 'serial_number': '**REDACTED**', + 'suggested_area': None, + 'sw_version': '1.0.0', + }), + 'entities': list([ + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': None, + 'categories': dict({ + }), + 'config_entry_id': 'TestData', + 'config_subentry_id': None, + 'device_class': None, + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.master_br_window_contact', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'labels': list([ + ]), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Master BR Window Contact', + 'platform': 'homekit_controller', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_4298584118_224', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'device_class': 'opening', + 'friendly_name': 'Master BR Window Contact', + }), + 'entity_id': 'binary_sensor.master_br_window_contact', + 'state': 'off', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': None, + 'categories': dict({ + }), + 'config_entry_id': 'TestData', + 'config_subentry_id': None, + 'device_class': None, + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.master_br_window_motion', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'labels': list([ + ]), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Master BR Window Motion', + 'platform': 'homekit_controller', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_4298584118_56', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'device_class': 'motion', + 'friendly_name': 'Master BR Window Motion', + }), + 'entity_id': 'binary_sensor.master_br_window_motion', + 'state': 'off', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': None, + 'categories': dict({ + }), + 'config_entry_id': 'TestData', + 'config_subentry_id': None, + 'device_class': None, + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.master_br_window_occupancy', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'labels': list([ + ]), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Master BR Window Occupancy', + 'platform': 'homekit_controller', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_4298584118_57', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'device_class': 'occupancy', + 'friendly_name': 'Master BR Window Occupancy', + }), + 'entity_id': 'binary_sensor.master_br_window_occupancy', + 'state': 'off', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': None, + 'categories': dict({ + }), + 'config_entry_id': 'TestData', + 'config_subentry_id': None, + 'device_class': None, + 'disabled_by': None, + 'domain': 'button', + 'entity_category': , + 'entity_id': 'button.master_br_window_identify', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'labels': list([ + ]), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Master BR Window Identify', + 'platform': 'homekit_controller', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_4298584118_1_6', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'device_class': 'identify', + 'friendly_name': 'Master BR Window Identify', + }), + 'entity_id': 'button.master_br_window_identify', + 'state': 'unknown', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'categories': dict({ + }), + 'config_entry_id': 'TestData', + 'config_subentry_id': None, + 'device_class': None, + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.master_br_window_battery', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'labels': list([ + ]), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': 'mdi:battery-unknown', + 'original_name': 'Master BR Window Battery', + 'platform': 'homekit_controller', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_4298584118_192', + 'unit_of_measurement': '%', + }), + 'state': dict({ + 'attributes': dict({ + 'device_class': 'battery', + 'friendly_name': 'Master BR Window Battery', + 'icon': 'mdi:battery', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'entity_id': 'sensor.master_br_window_battery', + 'state': '100', + }), + }), + ]), + }), + dict({ + 'device': dict({ + 'area_id': None, + 'config_entries': list([ + 'TestData', + ]), + 'config_entries_subentries': dict({ + 'TestData': list([ + None, + ]), + }), + 'configuration_url': None, + 'connections': list([ + ]), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': '', + 'identifiers': list([ + list([ + 'homekit_controller:accessory-id', + '00:00:00:00:00:00:aid:1', + ]), + ]), + 'is_new': False, + 'labels': list([ + ]), + 'manufacturer': 'ecobee Inc.', + 'model': 'ecobee3 lite', + 'model_id': None, + 'name': 'Thermostat', + 'name_by_user': None, + 'primary_config_entry': 'TestData', + 'serial_number': '**REDACTED**', + 'suggested_area': None, + 'sw_version': '4.8.70226', + }), + 'entities': list([ + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': None, + 'categories': dict({ + }), + 'config_entry_id': 'TestData', + 'config_subentry_id': None, + 'device_class': None, + 'disabled_by': None, + 'domain': 'button', + 'entity_category': None, + 'entity_id': 'button.thermostat_clear_hold', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'labels': list([ + ]), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Thermostat Clear Hold', + 'platform': 'homekit_controller', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_1_16_48', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'friendly_name': 'Thermostat Clear Hold', + }), + 'entity_id': 'button.thermostat_clear_hold', + 'state': 'unknown', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': None, + 'categories': dict({ + }), + 'config_entry_id': 'TestData', + 'config_subentry_id': None, + 'device_class': None, + 'disabled_by': None, + 'domain': 'button', + 'entity_category': , + 'entity_id': 'button.thermostat_identify', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'labels': list([ + ]), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Thermostat Identify', + 'platform': 'homekit_controller', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_1_1_6', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'device_class': 'identify', + 'friendly_name': 'Thermostat Identify', + }), + 'entity_id': 'button.thermostat_identify', + 'state': 'unknown', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': dict({ + 'fan_modes': list([ + 'on', + 'auto', + ]), + 'hvac_modes': list([ + , + , + , + , + ]), + 'max_temp': 35, + 'min_temp': 7, + }), + 'categories': dict({ + }), + 'config_entry_id': 'TestData', + 'config_subentry_id': None, + 'device_class': None, + 'disabled_by': None, + 'domain': 'climate', + 'entity_category': None, + 'entity_id': 'climate.thermostat', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'labels': list([ + ]), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Thermostat', + 'platform': 'homekit_controller', + 'previous_unique_id': None, + 'supported_features': , + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_1_16', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'current_humidity': 45.0, + 'current_temperature': 21.2, + 'fan_mode': 'on', + 'fan_modes': list([ + 'on', + 'auto', + ]), + 'friendly_name': 'Thermostat', + 'hvac_action': , + 'hvac_modes': list([ + , + , + , + , + ]), + 'max_temp': 35, + 'min_temp': 7, + 'supported_features': , + 'target_temp_high': None, + 'target_temp_low': None, + 'temperature': None, + }), + 'entity_id': 'climate.thermostat', + 'state': 'off', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'home', + 'sleep', + 'away', + ]), + }), + 'categories': dict({ + }), + 'config_entry_id': 'TestData', + 'config_subentry_id': None, + 'device_class': None, + 'disabled_by': None, + 'domain': 'select', + 'entity_category': None, + 'entity_id': 'select.thermostat_current_mode', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'labels': list([ + ]), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Thermostat Current Mode', + 'platform': 'homekit_controller', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'ecobee_mode', + 'unique_id': '00:00:00:00:00:00_1_16_33', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'friendly_name': 'Thermostat Current Mode', + 'options': list([ + 'home', + 'sleep', + 'away', + ]), + }), + 'entity_id': 'select.thermostat_current_mode', + 'state': 'unknown', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'celsius', + 'fahrenheit', + ]), + }), + 'categories': dict({ + }), + 'config_entry_id': 'TestData', + 'config_subentry_id': None, + 'device_class': None, + 'disabled_by': None, + 'domain': 'select', + 'entity_category': , + 'entity_id': 'select.thermostat_temperature_display_units', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'labels': list([ + ]), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Thermostat Temperature Display Units', + 'platform': 'homekit_controller', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'temperature_display_units', + 'unique_id': '00:00:00:00:00:00_1_16_21', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'friendly_name': 'Thermostat Temperature Display Units', + 'options': list([ + 'celsius', + 'fahrenheit', + ]), + }), + 'entity_id': 'select.thermostat_temperature_display_units', + 'state': 'fahrenheit', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'categories': dict({ + }), + 'config_entry_id': 'TestData', + 'config_subentry_id': None, + 'device_class': None, + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.thermostat_current_humidity', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'labels': list([ + ]), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Thermostat Current Humidity', + 'platform': 'homekit_controller', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_1_16_24', + 'unit_of_measurement': '%', + }), + 'state': dict({ + 'attributes': dict({ + 'device_class': 'humidity', + 'friendly_name': 'Thermostat Current Humidity', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'entity_id': 'sensor.thermostat_current_humidity', + 'state': '45.0', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'categories': dict({ + }), + 'config_entry_id': 'TestData', + 'config_subentry_id': None, + 'device_class': None, + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.thermostat_current_temperature', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'labels': list([ + ]), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Thermostat Current Temperature', + 'platform': 'homekit_controller', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_1_16_19', + 'unit_of_measurement': , + }), + 'state': dict({ + 'attributes': dict({ + 'device_class': 'temperature', + 'friendly_name': 'Thermostat Current Temperature', + 'state_class': , + 'unit_of_measurement': , + }), + 'entity_id': 'sensor.thermostat_current_temperature', + 'state': '21.2', + }), + }), + ]), + }), + dict({ + 'device': dict({ + 'area_id': None, + 'config_entries': list([ + 'TestData', + ]), + 'config_entries_subentries': dict({ + 'TestData': list([ + None, + ]), + }), + 'configuration_url': None, + 'connections': list([ + ]), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': '', + 'identifiers': list([ + list([ + 'homekit_controller:accessory-id', + '00:00:00:00:00:00:aid:4295016969', + ]), + ]), + 'is_new': False, + 'labels': list([ + ]), + 'manufacturer': 'ecobee Inc.', + 'model': 'EBRSE4', + 'model_id': None, + 'name': 'Upstairs BR', + 'name_by_user': None, + 'primary_config_entry': 'TestData', + 'serial_number': '**REDACTED**', + 'suggested_area': None, + 'sw_version': '1.0.0', + }), + 'entities': list([ + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': None, + 'categories': dict({ + }), + 'config_entry_id': 'TestData', + 'config_subentry_id': None, + 'device_class': None, + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.upstairs_br_motion', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'labels': list([ + ]), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Upstairs BR Motion', + 'platform': 'homekit_controller', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_4295016969_56', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'device_class': 'motion', + 'friendly_name': 'Upstairs BR Motion', + }), + 'entity_id': 'binary_sensor.upstairs_br_motion', + 'state': 'off', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': None, + 'categories': dict({ + }), + 'config_entry_id': 'TestData', + 'config_subentry_id': None, + 'device_class': None, + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.upstairs_br_occupancy', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'labels': list([ + ]), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Upstairs BR Occupancy', + 'platform': 'homekit_controller', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_4295016969_57', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'device_class': 'occupancy', + 'friendly_name': 'Upstairs BR Occupancy', + }), + 'entity_id': 'binary_sensor.upstairs_br_occupancy', + 'state': 'off', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': None, + 'categories': dict({ + }), + 'config_entry_id': 'TestData', + 'config_subentry_id': None, + 'device_class': None, + 'disabled_by': None, + 'domain': 'button', + 'entity_category': , + 'entity_id': 'button.upstairs_br_identify', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'labels': list([ + ]), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Upstairs BR Identify', + 'platform': 'homekit_controller', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_4295016969_1_6', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'device_class': 'identify', + 'friendly_name': 'Upstairs BR Identify', + }), + 'entity_id': 'button.upstairs_br_identify', + 'state': 'unknown', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'categories': dict({ + }), + 'config_entry_id': 'TestData', + 'config_subentry_id': None, + 'device_class': None, + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.upstairs_br_battery', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'labels': list([ + ]), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': 'mdi:battery-unknown', + 'original_name': 'Upstairs BR Battery', + 'platform': 'homekit_controller', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_4295016969_192', + 'unit_of_measurement': '%', + }), + 'state': dict({ + 'attributes': dict({ + 'device_class': 'battery', + 'friendly_name': 'Upstairs BR Battery', + 'icon': 'mdi:battery', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'entity_id': 'sensor.upstairs_br_battery', + 'state': '100', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'categories': dict({ + }), + 'config_entry_id': 'TestData', + 'config_subentry_id': None, + 'device_class': None, + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.upstairs_br_temperature', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'labels': list([ + ]), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Upstairs BR Temperature', + 'platform': 'homekit_controller', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_4295016969_208', + 'unit_of_measurement': , + }), + 'state': dict({ + 'attributes': dict({ + 'device_class': 'temperature', + 'friendly_name': 'Upstairs BR Temperature', + 'state_class': , + 'unit_of_measurement': , + }), + 'entity_id': 'sensor.upstairs_br_temperature', + 'state': '21.6', + }), + }), + ]), + }), + dict({ + 'device': dict({ + 'area_id': None, + 'config_entries': list([ + 'TestData', + ]), + 'config_entries_subentries': dict({ + 'TestData': list([ + None, + ]), + }), + 'configuration_url': None, + 'connections': list([ + ]), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': '', + 'identifiers': list([ + list([ + 'homekit_controller:accessory-id', + '00:00:00:00:00:00:aid:4298568508', + ]), + ]), + 'is_new': False, + 'labels': list([ + ]), + 'manufacturer': 'ecobee Inc.', + 'model': 'EBDWC01', + 'model_id': None, + 'name': 'Upstairs BR Window', + 'name_by_user': None, + 'primary_config_entry': 'TestData', + 'serial_number': '**REDACTED**', + 'suggested_area': None, + 'sw_version': '1.0.0', + }), + 'entities': list([ + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': None, + 'categories': dict({ + }), + 'config_entry_id': 'TestData', + 'config_subentry_id': None, + 'device_class': None, + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.upstairs_br_window_contact', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'labels': list([ + ]), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Upstairs BR Window Contact', + 'platform': 'homekit_controller', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_4298568508_224', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'device_class': 'opening', + 'friendly_name': 'Upstairs BR Window Contact', + }), + 'entity_id': 'binary_sensor.upstairs_br_window_contact', + 'state': 'off', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': None, + 'categories': dict({ + }), + 'config_entry_id': 'TestData', + 'config_subentry_id': None, + 'device_class': None, + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.upstairs_br_window_motion', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'labels': list([ + ]), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Upstairs BR Window Motion', + 'platform': 'homekit_controller', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_4298568508_56', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'device_class': 'motion', + 'friendly_name': 'Upstairs BR Window Motion', + }), + 'entity_id': 'binary_sensor.upstairs_br_window_motion', + 'state': 'off', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': None, + 'categories': dict({ + }), + 'config_entry_id': 'TestData', + 'config_subentry_id': None, + 'device_class': None, + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.upstairs_br_window_occupancy', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'labels': list([ + ]), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Upstairs BR Window Occupancy', + 'platform': 'homekit_controller', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_4298568508_57', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'device_class': 'occupancy', + 'friendly_name': 'Upstairs BR Window Occupancy', + }), + 'entity_id': 'binary_sensor.upstairs_br_window_occupancy', + 'state': 'off', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': None, + 'categories': dict({ + }), + 'config_entry_id': 'TestData', + 'config_subentry_id': None, + 'device_class': None, + 'disabled_by': None, + 'domain': 'button', + 'entity_category': , + 'entity_id': 'button.upstairs_br_window_identify', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'labels': list([ + ]), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Upstairs BR Window Identify', + 'platform': 'homekit_controller', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_4298568508_1_6', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'device_class': 'identify', + 'friendly_name': 'Upstairs BR Window Identify', + }), + 'entity_id': 'button.upstairs_br_window_identify', + 'state': 'unknown', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'categories': dict({ + }), + 'config_entry_id': 'TestData', + 'config_subentry_id': None, + 'device_class': None, + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.upstairs_br_window_battery', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'labels': list([ + ]), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': 'mdi:battery-unknown', + 'original_name': 'Upstairs BR Window Battery', + 'platform': 'homekit_controller', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_4298568508_192', + 'unit_of_measurement': '%', + }), + 'state': dict({ + 'attributes': dict({ + 'device_class': 'battery', + 'friendly_name': 'Upstairs BR Window Battery', + 'icon': 'mdi:battery', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'entity_id': 'sensor.upstairs_br_window_battery', + 'state': '100', + }), + }), + ]), + }), + ]) +# --- # name: test_snapshots[ecobee3_no_sensors] list([ dict({ diff --git a/tests/components/homekit_controller/test_climate.py b/tests/components/homekit_controller/test_climate.py index 62c73af9977..b119b5f7b80 100644 --- a/tests/components/homekit_controller/test_climate.py +++ b/tests/components/homekit_controller/test_climate.py @@ -8,6 +8,8 @@ from aiohomekit.model.characteristics import ( CharacteristicsTypes, CurrentFanStateValues, CurrentHeaterCoolerStateValues, + HeatingCoolingCurrentValues, + HeatingCoolingTargetValues, SwingModeValues, TargetHeaterCoolerStateValues, ) @@ -20,6 +22,7 @@ from homeassistant.components.climate import ( SERVICE_SET_HVAC_MODE, SERVICE_SET_SWING_MODE, SERVICE_SET_TEMPERATURE, + HVACAction, HVACMode, ) from homeassistant.core import HomeAssistant @@ -662,7 +665,7 @@ async def test_hvac_mode_vs_hvac_action( state = await helper.poll_and_get_state() assert state.state == "heat" - assert state.attributes["hvac_action"] == "fan" + assert state.attributes["hvac_action"] == HVACAction.FAN # Simulate that current temperature is below target temp # Heating might be on and hvac_action currently 'heat' @@ -676,7 +679,23 @@ async def test_hvac_mode_vs_hvac_action( state = await helper.poll_and_get_state() assert state.state == "heat" - assert state.attributes["hvac_action"] == "heating" + assert state.attributes["hvac_action"] == HVACAction.HEATING + + # If the fan is active, and the heating is off, the hvac_action should be 'fan' + # and not 'idle' or 'heating' + await helper.async_update( + ServicesTypes.THERMOSTAT, + { + CharacteristicsTypes.FAN_STATE_CURRENT: CurrentFanStateValues.ACTIVE, + CharacteristicsTypes.HEATING_COOLING_CURRENT: HeatingCoolingCurrentValues.IDLE, + CharacteristicsTypes.HEATING_COOLING_TARGET: HeatingCoolingTargetValues.OFF, + CharacteristicsTypes.FAN_STATE_CURRENT: CurrentFanStateValues.ACTIVE, + }, + ) + + state = await helper.poll_and_get_state() + assert state.state == HVACMode.OFF + assert state.attributes["hvac_action"] == HVACAction.FAN async def test_hvac_mode_vs_hvac_action_current_mode_wrong( From 04dfa45db0c4e000ef889545bb7e61a6692f862e Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 6 Apr 2025 22:18:46 -1000 Subject: [PATCH 334/505] Add GATT polling support to INKBird (#142307) * Add GATT polling support to INKBird * reduce * fixes * coverage * dry * reduce * reduce --- homeassistant/components/inkbird/__init__.py | 107 ++++++++++++++----- tests/components/inkbird/__init__.py | 12 +++ tests/components/inkbird/test_sensor.py | 98 ++++++++++++++++- 3 files changed, 190 insertions(+), 27 deletions(-) diff --git a/homeassistant/components/inkbird/__init__.py b/homeassistant/components/inkbird/__init__.py index 9dd058e841a..467fa2445e8 100644 --- a/homeassistant/components/inkbird/__init__.py +++ b/homeassistant/components/inkbird/__init__.py @@ -2,6 +2,7 @@ from __future__ import annotations +from datetime import datetime, timedelta import logging from inkbird_ble import INKBIRDBluetoothDeviceData, SensorUpdate @@ -9,13 +10,16 @@ from inkbird_ble import INKBIRDBluetoothDeviceData, SensorUpdate from homeassistant.components.bluetooth import ( BluetoothScanningMode, BluetoothServiceInfo, + BluetoothServiceInfoBleak, + async_ble_device_from_address, ) -from homeassistant.components.bluetooth.passive_update_processor import ( - PassiveBluetoothProcessorCoordinator, +from homeassistant.components.bluetooth.active_update_processor import ( + ActiveBluetoothProcessorCoordinator, ) from homeassistant.config_entries import ConfigEntry from homeassistant.const import Platform from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.event import async_track_time_interval from .const import CONF_DEVICE_TYPE, DOMAIN @@ -23,34 +27,87 @@ PLATFORMS: list[Platform] = [Platform.SENSOR] _LOGGER = logging.getLogger(__name__) +FALLBACK_POLL_INTERVAL = timedelta(seconds=180) + + +class INKBIRDActiveBluetoothProcessorCoordinator(ActiveBluetoothProcessorCoordinator): + """Coordinator for INKBIRD Bluetooth devices.""" + + def __init__( + self, + hass: HomeAssistant, + entry: ConfigEntry, + data: INKBIRDBluetoothDeviceData, + ) -> None: + """Initialize the INKBIRD Bluetooth processor coordinator.""" + self._data = data + self._entry = entry + address = entry.unique_id + assert address is not None + entry.async_on_unload( + async_track_time_interval( + hass, self._async_schedule_poll, FALLBACK_POLL_INTERVAL + ) + ) + super().__init__( + hass=hass, + logger=_LOGGER, + address=address, + mode=BluetoothScanningMode.ACTIVE, + update_method=self._async_on_update, + needs_poll_method=self._async_needs_poll, + poll_method=self._async_poll_data, + ) + + async def _async_poll_data( + self, last_service_info: BluetoothServiceInfoBleak + ) -> SensorUpdate: + """Poll the device.""" + return await self._data.async_poll(last_service_info.device) + + @callback + def _async_needs_poll( + self, service_info: BluetoothServiceInfoBleak, last_poll: float | None + ) -> bool: + return ( + not self.hass.is_stopping + and self._data.poll_needed(service_info, last_poll) + and bool( + async_ble_device_from_address( + self.hass, service_info.device.address, connectable=True + ) + ) + ) + + @callback + def _async_on_update(self, service_info: BluetoothServiceInfo) -> SensorUpdate: + """Handle update callback from the passive BLE processor.""" + update = self._data.update(service_info) + if ( + self._entry.data.get(CONF_DEVICE_TYPE) is None + and self._data.device_type is not None + ): + device_type_str = str(self._data.device_type) + self.hass.config_entries.async_update_entry( + self._entry, + data={**self._entry.data, CONF_DEVICE_TYPE: device_type_str}, + ) + return update + + @callback + def _async_schedule_poll(self, _: datetime) -> None: + """Schedule a poll of the device.""" + if self._last_service_info and self._async_needs_poll( + self._last_service_info, self._last_poll + ): + self._debounced_poll.async_schedule_call() + async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up INKBIRD BLE device from a config entry.""" - address = entry.unique_id - assert address is not None device_type: str | None = entry.data.get(CONF_DEVICE_TYPE) data = INKBIRDBluetoothDeviceData(device_type) - - @callback - def _async_on_update(service_info: BluetoothServiceInfo) -> SensorUpdate: - """Handle update callback from the passive BLE processor.""" - nonlocal device_type - update = data.update(service_info) - if device_type is None and data.device_type is not None: - device_type_str = str(data.device_type) - hass.config_entries.async_update_entry( - entry, data={**entry.data, CONF_DEVICE_TYPE: device_type_str} - ) - device_type = device_type_str - return update - - coordinator = PassiveBluetoothProcessorCoordinator( - hass, - _LOGGER, - address=address, - mode=BluetoothScanningMode.ACTIVE, - update_method=_async_on_update, - ) + coordinator = INKBIRDActiveBluetoothProcessorCoordinator(hass, entry, data) hass.data.setdefault(DOMAIN, {})[entry.entry_id] = coordinator await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) # only start after all platforms have had a chance to subscribe diff --git a/tests/components/inkbird/__init__.py b/tests/components/inkbird/__init__.py index 01ae0bf8efc..e285e1cbf2d 100644 --- a/tests/components/inkbird/__init__.py +++ b/tests/components/inkbird/__init__.py @@ -22,6 +22,18 @@ SPS_SERVICE_INFO = BluetoothServiceInfo( source="local", ) + +SPS_PASSIVE_SERVICE_INFO = BluetoothServiceInfo( + name="sps", + address="AA:BB:CC:DD:EE:FF", + rssi=-63, + service_data={}, + manufacturer_data={}, + service_uuids=["0000fff0-0000-1000-8000-00805f9b34fb"], + source="local", +) + + SPS_WITH_CORRUPT_NAME_SERVICE_INFO = BluetoothServiceInfo( name="XXXXcorruptXXXX", address="AA:BB:CC:DD:EE:FF", diff --git a/tests/components/inkbird/test_sensor.py b/tests/components/inkbird/test_sensor.py index 0f3d6497c2b..00b76366b48 100644 --- a/tests/components/inkbird/test_sensor.py +++ b/tests/components/inkbird/test_sensor.py @@ -1,16 +1,63 @@ """Test the INKBIRD config flow.""" +from unittest.mock import patch + +from inkbird_ble import ( + DeviceKey, + SensorDescription, + SensorDeviceInfo, + SensorUpdate, + SensorValue, + Units, +) +from sensor_state_data import SensorDeviceClass + +from homeassistant.components.inkbird import FALLBACK_POLL_INTERVAL from homeassistant.components.inkbird.const import CONF_DEVICE_TYPE, DOMAIN from homeassistant.components.sensor import ATTR_STATE_CLASS from homeassistant.const import ATTR_FRIENDLY_NAME, ATTR_UNIT_OF_MEASUREMENT from homeassistant.core import HomeAssistant +from homeassistant.util import dt as dt_util -from . import SPS_SERVICE_INFO, SPS_WITH_CORRUPT_NAME_SERVICE_INFO +from . import ( + SPS_PASSIVE_SERVICE_INFO, + SPS_SERVICE_INFO, + SPS_WITH_CORRUPT_NAME_SERVICE_INFO, +) -from tests.common import MockConfigEntry +from tests.common import MockConfigEntry, async_fire_time_changed from tests.components.bluetooth import inject_bluetooth_service_info +def _make_sensor_update(humidity: float) -> SensorUpdate: + return SensorUpdate( + title=None, + devices={ + None: SensorDeviceInfo( + name="IBS-TH EEFF", + model="IBS-TH", + manufacturer="INKBIRD", + sw_version=None, + hw_version=None, + ) + }, + entity_descriptions={ + DeviceKey(key="humidity", device_id=None): SensorDescription( + device_key=DeviceKey(key="humidity", device_id=None), + device_class=SensorDeviceClass.HUMIDITY, + native_unit_of_measurement=Units.PERCENTAGE, + ), + }, + entity_values={ + DeviceKey(key="humidity", device_id=None): SensorValue( + device_key=DeviceKey(key="humidity", device_id=None), + name="Humidity", + native_value=humidity, + ), + }, + ) + + async def test_sensors(hass: HomeAssistant) -> None: """Test setting up creates the sensors.""" entry = MockConfigEntry( @@ -68,3 +115,50 @@ async def test_device_with_corrupt_name(hass: HomeAssistant) -> None: assert entry.data[CONF_DEVICE_TYPE] == "IBS-TH" assert await hass.config_entries.async_unload(entry.entry_id) await hass.async_block_till_done() + + +async def test_polling_sensor(hass: HomeAssistant) -> None: + """Test setting up a device that needs polling.""" + entry = MockConfigEntry( + domain=DOMAIN, + unique_id="AA:BB:CC:DD:EE:FF", + data={CONF_DEVICE_TYPE: "IBS-TH"}, + ) + entry.add_to_hass(hass) + + assert await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + assert len(hass.states.async_all()) == 0 + + with patch( + "homeassistant.components.inkbird.INKBIRDBluetoothDeviceData.async_poll", + return_value=_make_sensor_update(10.24), + ): + inject_bluetooth_service_info(hass, SPS_PASSIVE_SERVICE_INFO) + await hass.async_block_till_done() + assert len(hass.states.async_all()) == 1 + + temp_sensor = hass.states.get("sensor.ibs_th_eeff_humidity") + temp_sensor_attribtes = temp_sensor.attributes + assert temp_sensor.state == "10.24" + assert temp_sensor_attribtes[ATTR_FRIENDLY_NAME] == "IBS-TH EEFF Humidity" + assert temp_sensor_attribtes[ATTR_UNIT_OF_MEASUREMENT] == "%" + assert temp_sensor_attribtes[ATTR_STATE_CLASS] == "measurement" + + assert entry.data[CONF_DEVICE_TYPE] == "IBS-TH" + + with patch( + "homeassistant.components.inkbird.INKBIRDBluetoothDeviceData.async_poll", + return_value=_make_sensor_update(20.24), + ): + async_fire_time_changed(hass, dt_util.utcnow() + FALLBACK_POLL_INTERVAL) + inject_bluetooth_service_info(hass, SPS_PASSIVE_SERVICE_INFO) + await hass.async_block_till_done() + + temp_sensor = hass.states.get("sensor.ibs_th_eeff_humidity") + temp_sensor_attribtes = temp_sensor.attributes + assert temp_sensor.state == "20.24" + + assert await hass.config_entries.async_unload(entry.entry_id) + await hass.async_block_till_done() From 2ed70ef24111d1f1edb4874dcab8b096d53f3029 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Mon, 7 Apr 2025 12:27:15 +0200 Subject: [PATCH 335/505] Use mock_config_flow test helper in config tests (#142461) --- .../components/config/test_config_entries.py | 63 ++++++++----------- 1 file changed, 27 insertions(+), 36 deletions(-) diff --git a/tests/components/config/test_config_entries.py b/tests/components/config/test_config_entries.py index ce10a36c42c..c6e65c312bb 100644 --- a/tests/components/config/test_config_entries.py +++ b/tests/components/config/test_config_entries.py @@ -12,7 +12,7 @@ import voluptuous as vol from homeassistant import config_entries as core_ce, data_entry_flow, loader from homeassistant.components.config import config_entries -from homeassistant.config_entries import HANDLERS, ConfigFlow, ConfigFlowResult +from homeassistant.config_entries import ConfigFlow, ConfigFlowResult from homeassistant.const import CONF_LATITUDE, CONF_LONGITUDE, CONF_RADIUS from homeassistant.core import HomeAssistant, callback from homeassistant.data_entry_flow import FlowResultType @@ -34,13 +34,6 @@ from tests.common import ( from tests.typing import ClientSessionGenerator, WebSocketGenerator -@pytest.fixture -def clear_handlers() -> Generator[None]: - """Clear config entry handlers.""" - with patch.dict(HANDLERS, clear=True): - yield - - @pytest.fixture(autouse=True) def mock_test_component(hass: HomeAssistant) -> None: """Ensure a component called 'test' exists.""" @@ -74,7 +67,7 @@ def mock_flow() -> Generator[None]: @pytest.mark.usefixtures("freezer") -@pytest.mark.usefixtures("clear_handlers", "mock_flow") +@pytest.mark.usefixtures("mock_flow") async def test_get_entries(hass: HomeAssistant, client: TestClient) -> None: """Test get entries.""" mock_integration(hass, MockModule("comp1")) @@ -358,7 +351,7 @@ async def test_reload_entry_in_setup_retry( entry.add_to_hass(hass) hass.config.components.add("comp") - with patch.dict(HANDLERS, {"comp": ConfigFlow, "test": ConfigFlow}): + with mock_config_flow("comp", ConfigFlow), mock_config_flow("test", ConfigFlow): resp = await client.post( f"/api/config/config_entries/entry/{entry.entry_id}/reload" ) @@ -422,7 +415,7 @@ async def test_initialize_flow(hass: HomeAssistant, client: TestClient) -> None: errors={"username": "Should be unique."}, ) - with patch.dict(HANDLERS, {"test": TestFlow}): + with mock_config_flow("test", TestFlow): resp = await client.post( "/api/config/config_entries/flow", json={"handler": "test", "show_advanced_options": True}, @@ -471,7 +464,7 @@ async def test_initialize_flow_unmet_dependency( async def async_step_user(self, user_input=None): pass - with patch.dict(HANDLERS, {"test2": TestFlow}): + with mock_config_flow("test2", TestFlow): resp = await client.post( "/api/config/config_entries/flow", json={"handler": "test2", "show_advanced_options": True}, @@ -502,7 +495,7 @@ async def test_initialize_flow_unauth( errors={"username": "Should be unique."}, ) - with patch.dict(HANDLERS, {"test": TestFlow}): + with mock_config_flow("test", TestFlow): resp = await client.post( "/api/config/config_entries/flow", json={"handler": "test"} ) @@ -519,7 +512,7 @@ async def test_abort(hass: HomeAssistant, client: TestClient) -> None: async def async_step_user(self, user_input=None): return self.async_abort(reason="bla") - with patch.dict(HANDLERS, {"test": TestFlow}): + with mock_config_flow("test", TestFlow): resp = await client.post( "/api/config/config_entries/flow", json={"handler": "test"} ) @@ -552,7 +545,7 @@ async def test_create_account(hass: HomeAssistant, client: TestClient) -> None: title="Test Entry", data={"secret": "account_token"} ) - with patch.dict(HANDLERS, {"test": TestFlow}): + with mock_config_flow("test", TestFlow): resp = await client.post( "/api/config/config_entries/flow", json={"handler": "test"} ) @@ -620,7 +613,7 @@ async def test_two_step_flow(hass: HomeAssistant, client: TestClient) -> None: title=user_input["user_title"], data={"secret": "account_token"} ) - with patch.dict(HANDLERS, {"test": TestFlow}): + with mock_config_flow("test", TestFlow): resp = await client.post( "/api/config/config_entries/flow", json={"handler": "test"} ) @@ -638,7 +631,7 @@ async def test_two_step_flow(hass: HomeAssistant, client: TestClient) -> None: "preview": None, } - with patch.dict(HANDLERS, {"test": TestFlow}): + with mock_config_flow("test", TestFlow): resp = await client.post( f"/api/config/config_entries/flow/{flow_id}", json={"user_title": "user-title"}, @@ -707,7 +700,7 @@ async def test_continue_flow_unauth( title=user_input["user_title"], data={"secret": "account_token"} ) - with patch.dict(HANDLERS, {"test": TestFlow}): + with mock_config_flow("test", TestFlow): resp = await client.post( "/api/config/config_entries/flow", json={"handler": "test"} ) @@ -774,7 +767,7 @@ async def test_get_progress_index( assert self._get_reconfigure_entry() is entry return await self.async_step_account() - with patch.dict(HANDLERS, {"test": TestFlow}): + with mock_config_flow("test", TestFlow): form_hassio = await hass.config_entries.flow.async_init( "test", context={"source": core_ce.SOURCE_HASSIO} ) @@ -838,7 +831,7 @@ async def test_get_progress_flow(hass: HomeAssistant, client: TestClient) -> Non errors={"username": "Should be unique."}, ) - with patch.dict(HANDLERS, {"test": TestFlow}): + with mock_config_flow("test", TestFlow): resp = await client.post( "/api/config/config_entries/flow", json={"handler": "test"} ) @@ -874,7 +867,7 @@ async def test_get_progress_flow_unauth( errors={"username": "Should be unique."}, ) - with patch.dict(HANDLERS, {"test": TestFlow}): + with mock_config_flow("test", TestFlow): resp = await client.post( "/api/config/config_entries/flow", json={"handler": "test"} ) @@ -918,7 +911,7 @@ async def test_options_flow(hass: HomeAssistant, client: TestClient) -> None: ).add_to_hass(hass) entry = hass.config_entries.async_entries()[0] - with patch.dict(HANDLERS, {"test": TestFlow}): + with mock_config_flow("test", TestFlow): url = "/api/config/config_entries/options/flow" resp = await client.post(url, json={"handler": entry.entry_id}) @@ -980,7 +973,7 @@ async def test_options_flow_unauth( hass_admin_user.groups = [] - with patch.dict(HANDLERS, {"test": TestFlow}): + with mock_config_flow("test", TestFlow): resp = await getattr(client, method)(endpoint, json={"handler": entry.entry_id}) assert resp.status == HTTPStatus.UNAUTHORIZED @@ -1017,7 +1010,7 @@ async def test_two_step_options_flow(hass: HomeAssistant, client: TestClient) -> ).add_to_hass(hass) entry = hass.config_entries.async_entries()[0] - with patch.dict(HANDLERS, {"test": TestFlow}): + with mock_config_flow("test", TestFlow): url = "/api/config/config_entries/options/flow" resp = await client.post(url, json={"handler": entry.entry_id}) @@ -1035,7 +1028,7 @@ async def test_two_step_options_flow(hass: HomeAssistant, client: TestClient) -> "preview": None, } - with patch.dict(HANDLERS, {"test": TestFlow}): + with mock_config_flow("test", TestFlow): resp = await client.post( f"/api/config/config_entries/options/flow/{flow_id}", json={"enabled": True}, @@ -1092,7 +1085,7 @@ async def test_options_flow_with_invalid_data( ).add_to_hass(hass) entry = hass.config_entries.async_entries()[0] - with patch.dict(HANDLERS, {"test": TestFlow}): + with mock_config_flow("test", TestFlow): url = "/api/config/config_entries/options/flow" resp = await client.post(url, json={"handler": entry.entry_id}) @@ -1118,7 +1111,7 @@ async def test_options_flow_with_invalid_data( "preview": None, } - with patch.dict(HANDLERS, {"test": TestFlow}): + with mock_config_flow("test", TestFlow): resp = await client.post( f"/api/config/config_entries/options/flow/{flow_id}", json={"choices": ["valid", "invalid"]}, @@ -1812,7 +1805,7 @@ async def test_ignore_flow( ws_client = await hass_ws_client(hass) - with patch.dict(HANDLERS, {"test": TestFlow}): + with mock_config_flow("test", TestFlow): result = await hass.config_entries.flow.async_init( "test", context={"source": core_ce.SOURCE_USER} | flow_context ) @@ -1861,7 +1854,7 @@ async def test_ignore_flow_nonexisting( assert response["error"]["code"] == "not_found" -@pytest.mark.usefixtures("clear_handlers", "freezer") +@pytest.mark.usefixtures("freezer") async def test_get_matching_entries_ws( hass: HomeAssistant, hass_ws_client: WebSocketGenerator ) -> None: @@ -2313,7 +2306,6 @@ async def test_get_matching_entries_ws( assert response["success"] is False -@pytest.mark.usefixtures("clear_handlers") async def test_subscribe_entries_ws( hass: HomeAssistant, hass_ws_client: WebSocketGenerator, @@ -2532,7 +2524,6 @@ async def test_subscribe_entries_ws( ] -@pytest.mark.usefixtures("clear_handlers") async def test_subscribe_entries_ws_filtered( hass: HomeAssistant, hass_ws_client: WebSocketGenerator, @@ -2792,7 +2783,7 @@ async def test_flow_with_multiple_schema_errors( ), ) - with patch.dict(HANDLERS, {"test": TestFlow}): + with mock_config_flow("test", TestFlow): resp = await client.post( "/api/config/config_entries/flow", json={"handler": "test"} ) @@ -2834,7 +2825,7 @@ async def test_flow_with_multiple_schema_errors_base( ), ) - with patch.dict(HANDLERS, {"test": TestFlow}): + with mock_config_flow("test", TestFlow): resp = await client.post( "/api/config/config_entries/flow", json={"handler": "test"} ) @@ -2893,7 +2884,7 @@ async def test_supports_reconfigure( data={"secret": "account_token"}, ) - with patch.dict(HANDLERS, {"test": TestFlow}): + with mock_config_flow("test", TestFlow): resp = await client.post( "/api/config/config_entries/flow", json={"handler": "test", "entry_id": "1"}, @@ -2915,7 +2906,7 @@ async def test_supports_reconfigure( "errors": None, } - with patch.dict(HANDLERS, {"test": TestFlow}): + with mock_config_flow("test", TestFlow): resp = await client.post( f"/api/config/config_entries/flow/{flow_id}", json={}, @@ -2953,7 +2944,7 @@ async def test_does_not_support_reconfigure( title="Test Entry", data={"secret": "account_token"} ) - with patch.dict(HANDLERS, {"test": TestFlow}): + with mock_config_flow("test", TestFlow): resp = await client.post( "/api/config/config_entries/flow", json={"handler": "test", "entry_id": "1"}, From 33fa8df73e966f9f2e39bf35332e4a703c8b2646 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Mon, 7 Apr 2025 13:28:09 +0200 Subject: [PATCH 336/505] Remove `ConfigEntriesFlowManager.async_post_init` (#142463) Remove ConfigEntriesFlowManager.async_post_init --- homeassistant/config_entries.py | 30 +++++++++++------------------- 1 file changed, 11 insertions(+), 19 deletions(-) diff --git a/homeassistant/config_entries.py b/homeassistant/config_entries.py index 016b199744c..ef1865da4be 100644 --- a/homeassistant/config_entries.py +++ b/homeassistant/config_entries.py @@ -1367,12 +1367,12 @@ class ConfigEntriesFlowManager( self._initialize_futures: defaultdict[str, set[asyncio.Future[None]]] = ( defaultdict(set) ) - self._discovery_debouncer = Debouncer[None]( + self._discovery_event_debouncer = Debouncer[None]( hass, _LOGGER, cooldown=DISCOVERY_COOLDOWN, immediate=True, - function=self._async_discovery, + function=self._async_fire_discovery_event, background=True, ) @@ -1454,8 +1454,12 @@ class ConfigEntriesFlowManager( if not self._pending_import_flows[handler]: del self._pending_import_flows[handler] - if result["type"] != data_entry_flow.FlowResultType.ABORT: - await self.async_post_init(flow, result) + if ( + result["type"] != data_entry_flow.FlowResultType.ABORT + and source in DISCOVERY_SOURCES + ): + # Fire discovery event + await self._discovery_event_debouncer.async_call() return result @@ -1497,7 +1501,7 @@ class ConfigEntriesFlowManager( for future_list in self._initialize_futures.values(): for future in future_list: future.set_result(None) - self._discovery_debouncer.async_shutdown() + self._discovery_event_debouncer.async_shutdown() async def async_finish_flow( self, @@ -1691,21 +1695,9 @@ class ConfigEntriesFlowManager( flow.init_step = context["source"] return flow - async def async_post_init( - self, - flow: data_entry_flow.FlowHandler[ConfigFlowContext, ConfigFlowResult], - result: ConfigFlowResult, - ) -> None: - """After a flow is initialised trigger new flow notifications.""" - source = flow.context["source"] - - # Create notification. - if source in DISCOVERY_SOURCES: - await self._discovery_debouncer.async_call() - @callback - def _async_discovery(self) -> None: - """Handle discovery.""" + def _async_fire_discovery_event(self) -> None: + """Fire discovery event.""" # async_fire_internal is used here because this is only # called from the Debouncer so we know the usage is safe self.hass.bus.async_fire_internal(EVENT_FLOW_DISCOVERED) From a026820483e5569304c3d3f83d5ed205b68d9c0e Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Mon, 7 Apr 2025 13:28:27 +0200 Subject: [PATCH 337/505] Remove FlowManager.async_post_init (#142462) --- homeassistant/data_entry_flow.py | 14 +------------- 1 file changed, 1 insertion(+), 13 deletions(-) diff --git a/homeassistant/data_entry_flow.py b/homeassistant/data_entry_flow.py index f7be891b61b..511bab25a7f 100644 --- a/homeassistant/data_entry_flow.py +++ b/homeassistant/data_entry_flow.py @@ -219,13 +219,6 @@ class FlowManager(abc.ABC, Generic[_FlowContextT, _FlowResultT, _HandlerT]): FlowResultType.CREATE_ENTRY. """ - async def async_post_init( - self, - flow: FlowHandler[_FlowContextT, _FlowResultT, _HandlerT], - result: _FlowResultT, - ) -> None: - """Entry has finished executing its first step asynchronously.""" - @callback def async_get(self, flow_id: str) -> _FlowResultT: """Return a flow in progress as a partial FlowResult.""" @@ -312,12 +305,7 @@ class FlowManager(abc.ABC, Generic[_FlowContextT, _FlowResultT, _HandlerT]): flow.init_data = data self._async_add_flow_progress(flow) - result = await self._async_handle_step(flow, flow.init_step, data) - - if result["type"] != FlowResultType.ABORT: - await self.async_post_init(flow, result) - - return result + return await self._async_handle_step(flow, flow.init_step, data) async def async_configure( self, flow_id: str, user_input: dict | None = None From 2818f746344d73f4c25b36eb9bb2bd69242f63c6 Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Mon, 7 Apr 2025 14:05:28 +0200 Subject: [PATCH 338/505] Use common states for "Normal" and "Low" in `binary_sensor` (#142465) * Use common state for "Normal" in `binary_sensor` Replace the "Normal" string for `battery` and the two references to it from `heat` and `cold` to it with the common state. * Use common state for "Low" in `binary_sensor` --- homeassistant/components/binary_sensor/strings.json | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/binary_sensor/strings.json b/homeassistant/components/binary_sensor/strings.json index 9fac758e168..ea897ed1c49 100644 --- a/homeassistant/components/binary_sensor/strings.json +++ b/homeassistant/components/binary_sensor/strings.json @@ -124,8 +124,8 @@ "battery": { "name": "Battery", "state": { - "off": "Normal", - "on": "Low" + "off": "[%key:common::state::normal%]", + "on": "[%key:common::state::low%]" } }, "battery_charging": { @@ -145,7 +145,7 @@ "cold": { "name": "Cold", "state": { - "off": "[%key:component::binary_sensor::entity_component::battery::state::off%]", + "off": "[%key:common::state::normal%]", "on": "Cold" } }, @@ -180,7 +180,7 @@ "heat": { "name": "Heat", "state": { - "off": "[%key:component::binary_sensor::entity_component::battery::state::off%]", + "off": "[%key:common::state::normal%]", "on": "Hot" } }, From 4020c987b51f9b62fb25412d438cc4e8de4a11c3 Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Mon, 7 Apr 2025 14:06:52 +0200 Subject: [PATCH 339/505] Use common state for "Normal" in `lg_thinq` (#142453) * Use common state for "Normal" in lg_thinq` * Replace internal references with common ones --- homeassistant/components/lg_thinq/strings.json | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/lg_thinq/strings.json b/homeassistant/components/lg_thinq/strings.json index 767c984da3a..525a594f748 100644 --- a/homeassistant/components/lg_thinq/strings.json +++ b/homeassistant/components/lg_thinq/strings.json @@ -303,7 +303,7 @@ "state": { "invalid": "Invalid", "weak": "Weak", - "normal": "Normal", + "normal": "[%key:common::state::normal%]", "strong": "Strong", "very_strong": "Very strong" } @@ -607,7 +607,7 @@ "intensive_dry": "Spot", "macro": "Custom mode", "mop": "Mop", - "normal": "Normal", + "normal": "[%key:common::state::normal%]", "off": "[%key:common::state::off%]", "quiet_humidity": "Silent", "rapid_humidity": "Jet", @@ -626,7 +626,7 @@ "auto": "Low power", "high": "[%key:component::lg_thinq::entity::sensor::current_job_mode::state::high%]", "mop": "[%key:component::lg_thinq::entity::sensor::current_job_mode::state::mop%]", - "normal": "[%key:component::lg_thinq::entity::sensor::current_job_mode::state::normal%]", + "normal": "[%key:common::state::normal%]", "off": "[%key:common::state::off%]", "turbo": "[%key:component::lg_thinq::entity::select::wind_strength::state::power%]" } @@ -653,7 +653,7 @@ "heavy": "Intensive", "delicate": "Delicate", "turbo": "[%key:component::lg_thinq::entity::select::wind_strength::state::power%]", - "normal": "Normal", + "normal": "[%key:common::state::normal%]", "rinse": "Rinse", "refresh": "Refresh", "express": "Express", From 04fa69949890b6f678d86515f9e217f0999acd74 Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Mon, 7 Apr 2025 15:37:17 +0200 Subject: [PATCH 340/505] Use common states for "Low" and "High" in `fyta` (#142472) --- homeassistant/components/fyta/strings.json | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/homeassistant/components/fyta/strings.json b/homeassistant/components/fyta/strings.json index 1a25f654e19..f595b66ee37 100644 --- a/homeassistant/components/fyta/strings.json +++ b/homeassistant/components/fyta/strings.json @@ -79,9 +79,9 @@ "state": { "no_data": "No data", "too_low": "Too low", - "low": "Low", + "low": "[%key:common::state::low%]", "perfect": "Perfect", - "high": "High", + "high": "[%key:common::state::high%]", "too_high": "Too high" } }, @@ -90,9 +90,9 @@ "state": { "no_data": "[%key:component::fyta::entity::sensor::temperature_status::state::no_data%]", "too_low": "[%key:component::fyta::entity::sensor::temperature_status::state::too_low%]", - "low": "[%key:component::fyta::entity::sensor::temperature_status::state::low%]", + "low": "[%key:common::state::low%]", "perfect": "[%key:component::fyta::entity::sensor::temperature_status::state::perfect%]", - "high": "[%key:component::fyta::entity::sensor::temperature_status::state::high%]", + "high": "[%key:common::state::high%]", "too_high": "[%key:component::fyta::entity::sensor::temperature_status::state::too_high%]" } }, @@ -101,9 +101,9 @@ "state": { "no_data": "[%key:component::fyta::entity::sensor::temperature_status::state::no_data%]", "too_low": "[%key:component::fyta::entity::sensor::temperature_status::state::too_low%]", - "low": "[%key:component::fyta::entity::sensor::temperature_status::state::low%]", + "low": "[%key:common::state::low%]", "perfect": "[%key:component::fyta::entity::sensor::temperature_status::state::perfect%]", - "high": "[%key:component::fyta::entity::sensor::temperature_status::state::high%]", + "high": "[%key:common::state::high%]", "too_high": "[%key:component::fyta::entity::sensor::temperature_status::state::too_high%]" } }, @@ -112,9 +112,9 @@ "state": { "no_data": "[%key:component::fyta::entity::sensor::temperature_status::state::no_data%]", "too_low": "[%key:component::fyta::entity::sensor::temperature_status::state::too_low%]", - "low": "[%key:component::fyta::entity::sensor::temperature_status::state::low%]", + "low": "[%key:common::state::low%]", "perfect": "[%key:component::fyta::entity::sensor::temperature_status::state::perfect%]", - "high": "[%key:component::fyta::entity::sensor::temperature_status::state::high%]", + "high": "[%key:common::state::high%]", "too_high": "[%key:component::fyta::entity::sensor::temperature_status::state::too_high%]" } }, @@ -123,9 +123,9 @@ "state": { "no_data": "[%key:component::fyta::entity::sensor::temperature_status::state::no_data%]", "too_low": "[%key:component::fyta::entity::sensor::temperature_status::state::too_low%]", - "low": "[%key:component::fyta::entity::sensor::temperature_status::state::low%]", + "low": "[%key:common::state::low%]", "perfect": "[%key:component::fyta::entity::sensor::temperature_status::state::perfect%]", - "high": "[%key:component::fyta::entity::sensor::temperature_status::state::high%]", + "high": "[%key:common::state::high%]", "too_high": "[%key:component::fyta::entity::sensor::temperature_status::state::too_high%]" } }, From bf003d643cec2222b9cc96b11db8be369c01f935 Mon Sep 17 00:00:00 2001 From: Wilfred Ketelaar Date: Mon, 7 Apr 2025 15:54:08 +0200 Subject: [PATCH 341/505] Fixed Renault charge state icon (#142478) Fixed charge state icon (duplicate mdi prefix) --- homeassistant/components/renault/icons.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/renault/icons.json b/homeassistant/components/renault/icons.json index 8b9c4885eaa..aa9175052fb 100644 --- a/homeassistant/components/renault/icons.json +++ b/homeassistant/components/renault/icons.json @@ -35,7 +35,7 @@ }, "sensor": { "charge_state": { - "default": "mdi:mdi:flash-off", + "default": "mdi:flash-off", "state": { "charge_in_progress": "mdi:flash" } From 79b984d612f3ef74c3044653a121801eac56749b Mon Sep 17 00:00:00 2001 From: Retha Runolfsson <137745329+zerzhang@users.noreply.github.com> Date: Mon, 7 Apr 2025 22:25:00 +0800 Subject: [PATCH 342/505] Add switchbot roller shade and hubmini matter support (#142168) * Add roller shade and hubmini matter support * add unit tests * fix adv data --- .../components/switchbot/__init__.py | 7 + homeassistant/components/switchbot/const.py | 4 + homeassistant/components/switchbot/cover.py | 84 +++++++++ tests/components/switchbot/__init__.py | 50 ++++++ tests/components/switchbot/test_cover.py | 167 +++++++++++++++++- tests/components/switchbot/test_sensor.py | 47 +++++ 6 files changed, 358 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/switchbot/__init__.py b/homeassistant/components/switchbot/__init__.py index 09bc157d4d2..73b7307aa2d 100644 --- a/homeassistant/components/switchbot/__init__.py +++ b/homeassistant/components/switchbot/__init__.py @@ -66,6 +66,12 @@ PLATFORMS_BY_TYPE = { SupportedModels.RELAY_SWITCH_1.value: [Platform.SWITCH], SupportedModels.LEAK.value: [Platform.BINARY_SENSOR, Platform.SENSOR], SupportedModels.REMOTE.value: [Platform.SENSOR], + SupportedModels.ROLLER_SHADE.value: [ + Platform.COVER, + Platform.BINARY_SENSOR, + Platform.SENSOR, + ], + SupportedModels.HUBMINI_MATTER.value: [Platform.SENSOR], } CLASS_BY_DEVICE = { SupportedModels.CEILING_LIGHT.value: switchbot.SwitchbotCeilingLight, @@ -80,6 +86,7 @@ CLASS_BY_DEVICE = { SupportedModels.BLIND_TILT.value: switchbot.SwitchbotBlindTilt, SupportedModels.RELAY_SWITCH_1PM.value: switchbot.SwitchbotRelaySwitch, SupportedModels.RELAY_SWITCH_1.value: switchbot.SwitchbotRelaySwitch, + SupportedModels.ROLLER_SHADE.value: switchbot.SwitchbotRollerShade, } diff --git a/homeassistant/components/switchbot/const.py b/homeassistant/components/switchbot/const.py index 16b41d75541..787c1fa720b 100644 --- a/homeassistant/components/switchbot/const.py +++ b/homeassistant/components/switchbot/const.py @@ -35,6 +35,8 @@ class SupportedModels(StrEnum): RELAY_SWITCH_1 = "relay_switch_1" LEAK = "leak" REMOTE = "remote" + ROLLER_SHADE = "roller_shade" + HUBMINI_MATTER = "hubmini_matter" CONNECTABLE_SUPPORTED_MODEL_TYPES = { @@ -51,6 +53,7 @@ CONNECTABLE_SUPPORTED_MODEL_TYPES = { SwitchbotModel.HUB2: SupportedModels.HUB2, SwitchbotModel.RELAY_SWITCH_1PM: SupportedModels.RELAY_SWITCH_1PM, SwitchbotModel.RELAY_SWITCH_1: SupportedModels.RELAY_SWITCH_1, + SwitchbotModel.ROLLER_SHADE: SupportedModels.ROLLER_SHADE, } NON_CONNECTABLE_SUPPORTED_MODEL_TYPES = { @@ -62,6 +65,7 @@ NON_CONNECTABLE_SUPPORTED_MODEL_TYPES = { SwitchbotModel.MOTION_SENSOR: SupportedModels.MOTION, SwitchbotModel.LEAK: SupportedModels.LEAK, SwitchbotModel.REMOTE: SupportedModels.REMOTE, + SwitchbotModel.HUBMINI_MATTER: SupportedModels.HUBMINI_MATTER, } SUPPORTED_MODEL_TYPES = ( diff --git a/homeassistant/components/switchbot/cover.py b/homeassistant/components/switchbot/cover.py index 5a9613ab2a2..bb73339aa05 100644 --- a/homeassistant/components/switchbot/cover.py +++ b/homeassistant/components/switchbot/cover.py @@ -37,6 +37,8 @@ async def async_setup_entry( coordinator = entry.runtime_data if isinstance(coordinator.device, switchbot.SwitchbotBlindTilt): async_add_entities([SwitchBotBlindTiltEntity(coordinator)]) + elif isinstance(coordinator.device, switchbot.SwitchbotRollerShade): + async_add_entities([SwitchBotRollerShadeEntity(coordinator)]) else: async_add_entities([SwitchBotCurtainEntity(coordinator)]) @@ -199,3 +201,85 @@ class SwitchBotBlindTiltEntity(SwitchbotEntity, CoverEntity, RestoreEntity): self._attr_is_opening = self.parsed_data["motionDirection"]["opening"] self._attr_is_closing = self.parsed_data["motionDirection"]["closing"] self.async_write_ha_state() + + +class SwitchBotRollerShadeEntity(SwitchbotEntity, CoverEntity, RestoreEntity): + """Representation of a Switchbot.""" + + _device: switchbot.SwitchbotRollerShade + _attr_device_class = CoverDeviceClass.SHADE + _attr_supported_features = ( + CoverEntityFeature.OPEN + | CoverEntityFeature.CLOSE + | CoverEntityFeature.STOP + | CoverEntityFeature.SET_POSITION + ) + + _attr_translation_key = "cover" + _attr_name = None + + def __init__(self, coordinator: SwitchbotDataUpdateCoordinator) -> None: + """Initialize the switchbot.""" + super().__init__(coordinator) + self._attr_is_closed = None + + async def async_added_to_hass(self) -> None: + """Run when entity about to be added.""" + await super().async_added_to_hass() + last_state = await self.async_get_last_state() + if not last_state or ATTR_CURRENT_POSITION not in last_state.attributes: + return + + self._attr_current_cover_position = last_state.attributes.get( + ATTR_CURRENT_POSITION + ) + self._last_run_success = last_state.attributes.get("last_run_success") + if self._attr_current_cover_position is not None: + self._attr_is_closed = self._attr_current_cover_position <= 20 + + async def async_open_cover(self, **kwargs: Any) -> None: + """Open the roller shade.""" + + _LOGGER.debug("Switchbot to open roller shade %s", self._address) + self._last_run_success = bool(await self._device.open()) + self._attr_is_opening = self._device.is_opening() + self._attr_is_closing = self._device.is_closing() + self.async_write_ha_state() + + async def async_close_cover(self, **kwargs: Any) -> None: + """Close the roller shade.""" + + _LOGGER.debug("Switchbot to close roller shade %s", self._address) + self._last_run_success = bool(await self._device.close()) + self._attr_is_opening = self._device.is_opening() + self._attr_is_closing = self._device.is_closing() + self.async_write_ha_state() + + async def async_stop_cover(self, **kwargs: Any) -> None: + """Stop the moving of roller shade.""" + + _LOGGER.debug("Switchbot to stop roller shade %s", self._address) + self._last_run_success = bool(await self._device.stop()) + self._attr_is_opening = self._device.is_opening() + self._attr_is_closing = self._device.is_closing() + self.async_write_ha_state() + + async def async_set_cover_position(self, **kwargs: Any) -> None: + """Move the cover to a specific position.""" + + position = kwargs.get(ATTR_POSITION) + _LOGGER.debug("Switchbot to move at %d %s", position, self._address) + self._last_run_success = bool(await self._device.set_position(position)) + self._attr_is_opening = self._device.is_opening() + self._attr_is_closing = self._device.is_closing() + self.async_write_ha_state() + + @callback + def _handle_coordinator_update(self) -> None: + """Handle updated data from the coordinator.""" + self._attr_is_closing = self._device.is_closing() + self._attr_is_opening = self._device.is_opening() + self._attr_current_cover_position = self.parsed_data["position"] + self._attr_is_closed = self.parsed_data["position"] <= 20 + + self.async_write_ha_state() diff --git a/tests/components/switchbot/__init__.py b/tests/components/switchbot/__init__.py index 715073aa891..f57c8c107b2 100644 --- a/tests/components/switchbot/__init__.py +++ b/tests/components/switchbot/__init__.py @@ -386,3 +386,53 @@ def make_advertisement( connectable=True, tx_power=-127, ) + + +HUBMINI_MATTER_SERVICE_INFO = BluetoothServiceInfoBleak( + name="HubMini Matter", + manufacturer_data={ + 2409: b"\xe6\xa1\xcd\x1f[e\x00\x00\x00\x00\x00\x00\x14\x01\x985\x00", + }, + service_data={"0000fd3d-0000-1000-8000-00805f9b34fb": b"%\x00"}, + service_uuids=["cba20d00-224d-11e6-9fb8-0002a5d5c51b"], + address="AA:BB:CC:DD:EE:FF", + rssi=-60, + source="local", + advertisement=generate_advertisement_data( + local_name="HubMini Matter", + manufacturer_data={ + 2409: b"\xe6\xa1\xcd\x1f[e\x00\x00\x00\x00\x00\x00\x14\x01\x985\x00", + }, + service_data={"0000fd3d-0000-1000-8000-00805f9b34fb": b"v\x00"}, + service_uuids=["cba20d00-224d-11e6-9fb8-0002a5d5c51b"], + ), + device=generate_ble_device("AA:BB:CC:DD:EE:FF", "HubMini Matter"), + time=0, + connectable=True, + tx_power=-127, +) + + +ROLLER_SHADE_SERVICE_INFO = BluetoothServiceInfoBleak( + name="RollerShade", + manufacturer_data={ + 2409: b"\xb0\xe9\xfeT\x90\x1b,\x08\x9f\x11\x04'\x00", + }, + service_data={"0000fd3d-0000-1000-8000-00805f9b34fb": b",\x00'\x9f\x11\x04"}, + service_uuids=["cba20d00-224d-11e6-9fb8-0002a5d5c51b"], + address="AA:BB:CC:DD:EE:FF", + rssi=-60, + source="local", + advertisement=generate_advertisement_data( + local_name="RollerShade", + manufacturer_data={ + 2409: b"\xb0\xe9\xfeT\x90\x1b,\x08\x9f\x11\x04'\x00", + }, + service_data={"0000fd3d-0000-1000-8000-00805f9b34fb": b",\x00'\x9f\x11\x04"}, + service_uuids=["cba20d00-224d-11e6-9fb8-0002a5d5c51b"], + ), + device=generate_ble_device("AA:BB:CC:DD:EE:FF", "RollerShade"), + time=0, + connectable=True, + tx_power=-127, +) diff --git a/tests/components/switchbot/test_cover.py b/tests/components/switchbot/test_cover.py index 8810963f63d..b52436f1932 100644 --- a/tests/components/switchbot/test_cover.py +++ b/tests/components/switchbot/test_cover.py @@ -24,7 +24,12 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant, State -from . import WOBLINDTILT_SERVICE_INFO, WOCURTAIN3_SERVICE_INFO, make_advertisement +from . import ( + ROLLER_SHADE_SERVICE_INFO, + WOBLINDTILT_SERVICE_INFO, + WOCURTAIN3_SERVICE_INFO, + make_advertisement, +) from tests.common import MockConfigEntry, mock_restore_cache from tests.components.bluetooth import inject_bluetooth_service_info @@ -325,3 +330,163 @@ async def test_blindtilt_controlling( state = hass.states.get(entity_id) assert state.state == CoverState.OPEN assert state.attributes[ATTR_CURRENT_TILT_POSITION] == 50 + + +async def test_roller_shade_setup( + hass: HomeAssistant, mock_entry_factory: Callable[[str], MockConfigEntry] +) -> None: + """Test setting up the RollerShade.""" + inject_bluetooth_service_info(hass, WOCURTAIN3_SERVICE_INFO) + + entry = mock_entry_factory(sensor_type="roller_shade") + + entity_id = "cover.test_name" + mock_restore_cache( + hass, + [ + State( + entity_id, + CoverState.OPEN, + {ATTR_CURRENT_POSITION: 60}, + ) + ], + ) + + entry.add_to_hass(hass) + with patch( + "homeassistant.components.switchbot.cover.switchbot.SwitchbotRollerShade.update", + new=AsyncMock(return_value=True), + ): + assert await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + state = hass.states.get(entity_id) + assert state.state == CoverState.OPEN + assert state.attributes[ATTR_CURRENT_POSITION] == 60 + + +async def test_roller_shade_controlling( + hass: HomeAssistant, mock_entry_factory: Callable[[str], MockConfigEntry] +) -> None: + """Test Roller Shade controlling.""" + inject_bluetooth_service_info(hass, ROLLER_SHADE_SERVICE_INFO) + + entry = mock_entry_factory(sensor_type="roller_shade") + entry.add_to_hass(hass) + info = {"battery": 39} + with ( + patch( + "homeassistant.components.switchbot.cover.switchbot.SwitchbotRollerShade.get_basic_info", + new=AsyncMock(return_value=info), + ), + patch( + "homeassistant.components.switchbot.cover.switchbot.SwitchbotRollerShade.open", + new=AsyncMock(return_value=True), + ) as mock_open, + patch( + "homeassistant.components.switchbot.cover.switchbot.SwitchbotRollerShade.close", + new=AsyncMock(return_value=True), + ) as mock_close, + patch( + "homeassistant.components.switchbot.cover.switchbot.SwitchbotRollerShade.stop", + new=AsyncMock(return_value=True), + ) as mock_stop, + patch( + "homeassistant.components.switchbot.cover.switchbot.SwitchbotRollerShade.set_position", + new=AsyncMock(return_value=True), + ) as mock_set_position, + ): + assert await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + entity_id = "cover.test_name" + address = "AA:BB:CC:DD:EE:FF" + service_data = b",\x00'\x9f\x11\x04" + + # Test open + manufacturer_data = b"\xb0\xe9\xfeT\x90\x1b,\x08\xa0\x11\x04'\x00" + await hass.services.async_call( + COVER_DOMAIN, + SERVICE_OPEN_COVER, + {ATTR_ENTITY_ID: entity_id}, + blocking=True, + ) + with patch( + "homeassistant.components.switchbot.cover.switchbot.SwitchbotRollerShade.get_basic_info", + new=AsyncMock(return_value=info), + ): + inject_bluetooth_service_info( + hass, make_advertisement(address, manufacturer_data, service_data) + ) + await hass.async_block_till_done() + + mock_open.assert_awaited_once() + state = hass.states.get(entity_id) + assert state.state == CoverState.OPEN + assert state.attributes[ATTR_CURRENT_POSITION] == 68 + + # Test close + manufacturer_data = b"\xb0\xe9\xfeT\x90\x1b,\x08\x5a\x11\x04'\x00" + await hass.services.async_call( + COVER_DOMAIN, + SERVICE_CLOSE_COVER, + {ATTR_ENTITY_ID: entity_id}, + blocking=True, + ) + with patch( + "homeassistant.components.switchbot.cover.switchbot.SwitchbotRollerShade.get_basic_info", + return_value=info, + ): + inject_bluetooth_service_info( + hass, make_advertisement(address, manufacturer_data, service_data) + ) + await hass.async_block_till_done() + + mock_close.assert_awaited_once() + state = hass.states.get(entity_id) + assert state.state == CoverState.CLOSED + assert state.attributes[ATTR_CURRENT_POSITION] == 10 + + # Test stop + manufacturer_data = b"\xb0\xe9\xfeT\x90\x1b,\x08\x5f\x11\x04'\x00" + await hass.services.async_call( + COVER_DOMAIN, + SERVICE_STOP_COVER, + {ATTR_ENTITY_ID: entity_id}, + blocking=True, + ) + with patch( + "homeassistant.components.switchbot.cover.switchbot.SwitchbotRollerShade.get_basic_info", + return_value=info, + ): + inject_bluetooth_service_info( + hass, make_advertisement(address, manufacturer_data, service_data) + ) + await hass.async_block_till_done() + + mock_stop.assert_awaited_once() + state = hass.states.get(entity_id) + assert state.state == CoverState.CLOSED + assert state.attributes[ATTR_CURRENT_POSITION] == 5 + + # Test set position + manufacturer_data = b"\xb0\xe9\xfeT\x90\x1b,\x08\x32\x11\x04'\x00" + await hass.services.async_call( + COVER_DOMAIN, + SERVICE_SET_COVER_POSITION, + {ATTR_ENTITY_ID: entity_id, ATTR_POSITION: 50}, + blocking=True, + ) + with patch( + "homeassistant.components.switchbot.cover.switchbot.SwitchbotRollerShade.get_basic_info", + return_value=info, + ): + inject_bluetooth_service_info( + hass, make_advertisement(address, manufacturer_data, service_data) + ) + await hass.async_block_till_done() + + mock_set_position.assert_awaited_once() + state = hass.states.get(entity_id) + assert state.state == CoverState.OPEN + assert state.attributes[ATTR_CURRENT_POSITION] == 50 diff --git a/tests/components/switchbot/test_sensor.py b/tests/components/switchbot/test_sensor.py index 5fd270b3393..72ec3a8c727 100644 --- a/tests/components/switchbot/test_sensor.py +++ b/tests/components/switchbot/test_sensor.py @@ -22,6 +22,7 @@ from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component from . import ( + HUBMINI_MATTER_SERVICE_INFO, LEAK_SERVICE_INFO, REMOTE_SERVICE_INFO, WOHAND_SERVICE_INFO, @@ -293,3 +294,49 @@ async def test_hub2_sensor(hass: HomeAssistant) -> None: assert await hass.config_entries.async_unload(entry.entry_id) await hass.async_block_till_done() + + +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +async def test_hubmini_matter_sensor(hass: HomeAssistant) -> None: + """Test setting up creates the sensor for HubMini Matter.""" + await async_setup_component(hass, DOMAIN, {}) + inject_bluetooth_service_info(hass, HUBMINI_MATTER_SERVICE_INFO) + + entry = MockConfigEntry( + domain=DOMAIN, + data={ + CONF_ADDRESS: "AA:BB:CC:DD:EE:FF", + CONF_NAME: "test-name", + CONF_SENSOR_TYPE: "hubmini_matter", + }, + unique_id="aabbccddeeff", + ) + entry.add_to_hass(hass) + + assert await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + assert len(hass.states.async_all("sensor")) == 3 + + temperature_sensor = hass.states.get("sensor.test_name_temperature") + temperature_sensor_attrs = temperature_sensor.attributes + assert temperature_sensor.state == "24.1" + assert temperature_sensor_attrs[ATTR_FRIENDLY_NAME] == "test-name Temperature" + assert temperature_sensor_attrs[ATTR_UNIT_OF_MEASUREMENT] == "°C" + assert temperature_sensor_attrs[ATTR_STATE_CLASS] == "measurement" + + humidity_sensor = hass.states.get("sensor.test_name_humidity") + humidity_sensor_attrs = humidity_sensor.attributes + assert humidity_sensor.state == "53" + assert humidity_sensor_attrs[ATTR_FRIENDLY_NAME] == "test-name Humidity" + assert humidity_sensor_attrs[ATTR_UNIT_OF_MEASUREMENT] == "%" + assert humidity_sensor_attrs[ATTR_STATE_CLASS] == "measurement" + + rssi_sensor = hass.states.get("sensor.test_name_bluetooth_signal") + rssi_sensor_attrs = rssi_sensor.attributes + assert rssi_sensor.state == "-60" + assert rssi_sensor_attrs[ATTR_FRIENDLY_NAME] == "test-name Bluetooth signal" + assert rssi_sensor_attrs[ATTR_UNIT_OF_MEASUREMENT] == "dBm" + + assert await hass.config_entries.async_unload(entry.entry_id) + await hass.async_block_till_done() From cd2313d2ca87bff8da6b05fe8163cf5b82e87206 Mon Sep 17 00:00:00 2001 From: RJPoelstra <36924801+RJPoelstra@users.noreply.github.com> Date: Mon, 7 Apr 2025 18:16:44 +0200 Subject: [PATCH 343/505] Add tests to MotionMount integration (#137540) * Add entity tests * Add __init__ tests * Cleanup * Rename mock_motionmount_config_flow to mock_motionmount * Remove unneeded PropertyMock * Set defaults on mock_motionmount * Test proper device is created * Check whether proper device is created from test_init.py, also without mac * Find callback and use that to update name --- .../components/motionmount/select.py | 1 + tests/components/motionmount/__init__.py | 1 + tests/components/motionmount/conftest.py | 21 +- .../motionmount/test_config_flow.py | 220 +++++++----------- tests/components/motionmount/test_entity.py | 47 ++++ tests/components/motionmount/test_init.py | 129 ++++++++++ tests/components/motionmount/test_sensor.py | 4 +- 7 files changed, 280 insertions(+), 143 deletions(-) create mode 100644 tests/components/motionmount/test_entity.py create mode 100644 tests/components/motionmount/test_init.py diff --git a/homeassistant/components/motionmount/select.py b/homeassistant/components/motionmount/select.py index a8fcc84f2ec..861faa319cd 100644 --- a/homeassistant/components/motionmount/select.py +++ b/homeassistant/components/motionmount/select.py @@ -46,6 +46,7 @@ class MotionMountPresets(MotionMountEntity, SelectEntity): super().__init__(mm, config_entry) self._attr_unique_id = f"{self._base_unique_id}-preset" self._presets: list[motionmount.Preset] = [] + self._attr_current_option = None def _update_options(self, presets: list[motionmount.Preset]) -> None: """Convert presets to select options.""" diff --git a/tests/components/motionmount/__init__.py b/tests/components/motionmount/__init__.py index 3b97c8aa7fe..b56b2c92678 100644 --- a/tests/components/motionmount/__init__.py +++ b/tests/components/motionmount/__init__.py @@ -7,6 +7,7 @@ from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo HOST = "192.168.1.31" PORT = 23 +MAC = bytes.fromhex("c4dd57f8a55f") TVM_ZEROCONF_SERVICE_TYPE = "_tvm._tcp.local." diff --git a/tests/components/motionmount/conftest.py b/tests/components/motionmount/conftest.py index 49f624b5266..795495f4457 100644 --- a/tests/components/motionmount/conftest.py +++ b/tests/components/motionmount/conftest.py @@ -6,9 +6,9 @@ from unittest.mock import AsyncMock, MagicMock, patch import pytest from homeassistant.components.motionmount.const import DOMAIN -from homeassistant.const import CONF_HOST, CONF_PORT +from homeassistant.const import CONF_HOST, CONF_PIN, CONF_PORT -from . import HOST, PORT, ZEROCONF_MAC, ZEROCONF_NAME +from . import HOST, MAC, PORT, ZEROCONF_MAC, ZEROCONF_NAME from tests.common import MockConfigEntry @@ -24,6 +24,17 @@ def mock_config_entry() -> MockConfigEntry: ) +@pytest.fixture +def mock_config_entry_with_pin() -> MockConfigEntry: + """Return the default mocked config entry.""" + return MockConfigEntry( + title=ZEROCONF_NAME, + domain=DOMAIN, + data={CONF_HOST: HOST, CONF_PORT: PORT, CONF_PIN: 1234}, + unique_id=ZEROCONF_MAC, + ) + + @pytest.fixture def mock_setup_entry() -> Generator[AsyncMock]: """Mock setting up a config entry.""" @@ -34,12 +45,14 @@ def mock_setup_entry() -> Generator[AsyncMock]: @pytest.fixture -def mock_motionmount_config_flow() -> Generator[MagicMock]: +def mock_motionmount() -> Generator[MagicMock]: """Return a mocked MotionMount config flow.""" with patch( - "homeassistant.components.motionmount.config_flow.motionmount.MotionMount", + "homeassistant.components.motionmount.motionmount.MotionMount", autospec=True, ) as motionmount_mock: client = motionmount_mock.return_value + client.name = ZEROCONF_NAME + client.mac = MAC yield client diff --git a/tests/components/motionmount/test_config_flow.py b/tests/components/motionmount/test_config_flow.py index 1fa2715595d..f6c5e8d8cc3 100644 --- a/tests/components/motionmount/test_config_flow.py +++ b/tests/components/motionmount/test_config_flow.py @@ -35,10 +35,10 @@ pytestmark = pytest.mark.usefixtures("mock_setup_entry") async def test_user_connection_error( hass: HomeAssistant, - mock_motionmount_config_flow: MagicMock, + mock_motionmount: MagicMock, ) -> None: """Test that the flow is aborted when there is an connection error.""" - mock_motionmount_config_flow.connect.side_effect = ConnectionRefusedError() + mock_motionmount.connect.side_effect = ConnectionRefusedError() user_input = MOCK_USER_INPUT.copy() @@ -54,10 +54,10 @@ async def test_user_connection_error( async def test_user_connection_error_invalid_hostname( hass: HomeAssistant, - mock_motionmount_config_flow: MagicMock, + mock_motionmount: MagicMock, ) -> None: """Test that the flow is aborted when an invalid hostname is provided.""" - mock_motionmount_config_flow.connect.side_effect = socket.gaierror() + mock_motionmount.connect.side_effect = socket.gaierror() user_input = MOCK_USER_INPUT.copy() @@ -73,10 +73,10 @@ async def test_user_connection_error_invalid_hostname( async def test_user_timeout_error( hass: HomeAssistant, - mock_motionmount_config_flow: MagicMock, + mock_motionmount: MagicMock, ) -> None: """Test that the flow is aborted when there is a timeout error.""" - mock_motionmount_config_flow.connect.side_effect = TimeoutError() + mock_motionmount.connect.side_effect = TimeoutError() user_input = MOCK_USER_INPUT.copy() @@ -92,10 +92,10 @@ async def test_user_timeout_error( async def test_user_not_connected_error( hass: HomeAssistant, - mock_motionmount_config_flow: MagicMock, + mock_motionmount: MagicMock, ) -> None: """Test that the flow is aborted when there is a not connected error.""" - mock_motionmount_config_flow.connect.side_effect = motionmount.NotConnectedError() + mock_motionmount.connect.side_effect = motionmount.NotConnectedError() user_input = MOCK_USER_INPUT.copy() @@ -111,13 +111,11 @@ async def test_user_not_connected_error( async def test_user_response_error_single_device_new_ce_old_pro( hass: HomeAssistant, - mock_motionmount_config_flow: MagicMock, + mock_motionmount: MagicMock, ) -> None: """Test that the flow creates an entry when there is a response error.""" - type(mock_motionmount_config_flow).name = PropertyMock(return_value=ZEROCONF_NAME) - type(mock_motionmount_config_flow).mac = PropertyMock( - return_value=b"\x00\x00\x00\x00\x00\x00" - ) + type(mock_motionmount).name = PropertyMock(return_value=ZEROCONF_NAME) + type(mock_motionmount).mac = PropertyMock(return_value=b"\x00\x00\x00\x00\x00\x00") user_input = MOCK_USER_INPUT.copy() @@ -139,11 +137,11 @@ async def test_user_response_error_single_device_new_ce_old_pro( async def test_user_response_error_single_device_new_ce_new_pro( hass: HomeAssistant, - mock_motionmount_config_flow: MagicMock, + mock_motionmount: MagicMock, ) -> None: """Test that the flow creates an entry when there is a response error.""" - type(mock_motionmount_config_flow).name = PropertyMock(return_value=ZEROCONF_NAME) - type(mock_motionmount_config_flow).mac = PropertyMock(return_value=MAC) + type(mock_motionmount).name = PropertyMock(return_value=ZEROCONF_NAME) + type(mock_motionmount).mac = PropertyMock(return_value=MAC) user_input = MOCK_USER_INPUT.copy() @@ -167,13 +165,13 @@ async def test_user_response_error_single_device_new_ce_new_pro( async def test_user_response_error_multi_device_new_ce_new_pro( hass: HomeAssistant, mock_config_entry: MockConfigEntry, - mock_motionmount_config_flow: MagicMock, + mock_motionmount: MagicMock, ) -> None: """Test that the flow is aborted when there are multiple devices.""" mock_config_entry.add_to_hass(hass) - type(mock_motionmount_config_flow).name = PropertyMock(return_value=ZEROCONF_NAME) - type(mock_motionmount_config_flow).mac = PropertyMock(return_value=MAC) + type(mock_motionmount).name = PropertyMock(return_value=ZEROCONF_NAME) + type(mock_motionmount).mac = PropertyMock(return_value=MAC) user_input = MOCK_USER_INPUT.copy() @@ -190,14 +188,12 @@ async def test_user_response_error_multi_device_new_ce_new_pro( async def test_user_response_authentication_needed( hass: HomeAssistant, mock_config_entry: MockConfigEntry, - mock_motionmount_config_flow: MagicMock, + mock_motionmount: MagicMock, ) -> None: """Test that authentication is requested when needed.""" - type(mock_motionmount_config_flow).name = PropertyMock(return_value=ZEROCONF_NAME) - type(mock_motionmount_config_flow).mac = PropertyMock(return_value=MAC) - type(mock_motionmount_config_flow).is_authenticated = PropertyMock( - return_value=False - ) + type(mock_motionmount).name = PropertyMock(return_value=ZEROCONF_NAME) + type(mock_motionmount).mac = PropertyMock(return_value=MAC) + type(mock_motionmount).is_authenticated = PropertyMock(return_value=False) user_input = MOCK_USER_INPUT.copy() @@ -211,12 +207,8 @@ async def test_user_response_authentication_needed( assert result["step_id"] == "auth" # Now simulate the user entered the correct pin to finalize the test - type(mock_motionmount_config_flow).is_authenticated = PropertyMock( - return_value=True - ) - type(mock_motionmount_config_flow).can_authenticate = PropertyMock( - return_value=True - ) + type(mock_motionmount).is_authenticated = PropertyMock(return_value=True) + type(mock_motionmount).can_authenticate = PropertyMock(return_value=True) result = await hass.config_entries.flow.async_configure( result["flow_id"], @@ -236,10 +228,10 @@ async def test_user_response_authentication_needed( async def test_zeroconf_connection_error( hass: HomeAssistant, - mock_motionmount_config_flow: MagicMock, + mock_motionmount: MagicMock, ) -> None: """Test that the flow is aborted when there is an connection error.""" - mock_motionmount_config_flow.connect.side_effect = ConnectionRefusedError() + mock_motionmount.connect.side_effect = ConnectionRefusedError() discovery_info = dataclasses.replace(MOCK_ZEROCONF_TVM_SERVICE_INFO_V1) @@ -255,10 +247,10 @@ async def test_zeroconf_connection_error( async def test_zeroconf_connection_error_invalid_hostname( hass: HomeAssistant, - mock_motionmount_config_flow: MagicMock, + mock_motionmount: MagicMock, ) -> None: """Test that the flow is aborted when there is an connection error.""" - mock_motionmount_config_flow.connect.side_effect = socket.gaierror() + mock_motionmount.connect.side_effect = socket.gaierror() discovery_info = dataclasses.replace(MOCK_ZEROCONF_TVM_SERVICE_INFO_V1) @@ -274,10 +266,10 @@ async def test_zeroconf_connection_error_invalid_hostname( async def test_zeroconf_timout_error( hass: HomeAssistant, - mock_motionmount_config_flow: MagicMock, + mock_motionmount: MagicMock, ) -> None: """Test that the flow is aborted when there is a timeout error.""" - mock_motionmount_config_flow.connect.side_effect = TimeoutError() + mock_motionmount.connect.side_effect = TimeoutError() discovery_info = dataclasses.replace(MOCK_ZEROCONF_TVM_SERVICE_INFO_V1) @@ -293,10 +285,10 @@ async def test_zeroconf_timout_error( async def test_zeroconf_not_connected_error( hass: HomeAssistant, - mock_motionmount_config_flow: MagicMock, + mock_motionmount: MagicMock, ) -> None: """Test that the flow is aborted when there is a not connected error.""" - mock_motionmount_config_flow.connect.side_effect = motionmount.NotConnectedError() + mock_motionmount.connect.side_effect = motionmount.NotConnectedError() discovery_info = dataclasses.replace(MOCK_ZEROCONF_TVM_SERVICE_INFO_V1) @@ -312,12 +304,10 @@ async def test_zeroconf_not_connected_error( async def test_show_zeroconf_form_new_ce_old_pro( hass: HomeAssistant, - mock_motionmount_config_flow: MagicMock, + mock_motionmount: MagicMock, ) -> None: """Test that the zeroconf confirmation form is served.""" - type(mock_motionmount_config_flow).mac = PropertyMock( - return_value=b"\x00\x00\x00\x00\x00\x00" - ) + type(mock_motionmount).mac = PropertyMock(return_value=b"\x00\x00\x00\x00\x00\x00") discovery_info = dataclasses.replace(MOCK_ZEROCONF_TVM_SERVICE_INFO_V1) result = await hass.config_entries.flow.async_init( @@ -348,10 +338,10 @@ async def test_show_zeroconf_form_new_ce_old_pro( async def test_show_zeroconf_form_new_ce_new_pro( hass: HomeAssistant, - mock_motionmount_config_flow: MagicMock, + mock_motionmount: MagicMock, ) -> None: """Test that the zeroconf confirmation form is served.""" - type(mock_motionmount_config_flow).mac = PropertyMock(return_value=MAC) + type(mock_motionmount).mac = PropertyMock(return_value=MAC) discovery_info = dataclasses.replace(MOCK_ZEROCONF_TVM_SERVICE_INFO_V2) result = await hass.config_entries.flow.async_init( @@ -383,7 +373,7 @@ async def test_show_zeroconf_form_new_ce_new_pro( async def test_zeroconf_device_exists_abort( hass: HomeAssistant, mock_config_entry: MockConfigEntry, - mock_motionmount_config_flow: MagicMock, + mock_motionmount: MagicMock, ) -> None: """Test we abort zeroconf flow if device already configured.""" mock_config_entry.add_to_hass(hass) @@ -402,13 +392,11 @@ async def test_zeroconf_device_exists_abort( async def test_zeroconf_authentication_needed( hass: HomeAssistant, mock_config_entry: MockConfigEntry, - mock_motionmount_config_flow: MagicMock, + mock_motionmount: MagicMock, ) -> None: """Test that authentication is requested when needed.""" - type(mock_motionmount_config_flow).mac = PropertyMock(return_value=MAC) - type(mock_motionmount_config_flow).is_authenticated = PropertyMock( - return_value=False - ) + type(mock_motionmount).mac = PropertyMock(return_value=MAC) + type(mock_motionmount).is_authenticated = PropertyMock(return_value=False) discovery_info = dataclasses.replace(MOCK_ZEROCONF_TVM_SERVICE_INFO_V2) result = await hass.config_entries.flow.async_init( @@ -421,12 +409,8 @@ async def test_zeroconf_authentication_needed( assert result["step_id"] == "auth" # Now simulate the user entered the correct pin to finalize the test - type(mock_motionmount_config_flow).is_authenticated = PropertyMock( - return_value=True - ) - type(mock_motionmount_config_flow).can_authenticate = PropertyMock( - return_value=True - ) + type(mock_motionmount).is_authenticated = PropertyMock(return_value=True) + type(mock_motionmount).can_authenticate = PropertyMock(return_value=True) result = await hass.config_entries.flow.async_configure( result["flow_id"], @@ -448,17 +432,13 @@ async def test_zeroconf_authentication_needed( async def test_authentication_incorrect_then_correct_pin( hass: HomeAssistant, mock_config_entry: MockConfigEntry, - mock_motionmount_config_flow: MagicMock, + mock_motionmount: MagicMock, ) -> None: """Test that authentication is requested when needed.""" - type(mock_motionmount_config_flow).name = PropertyMock(return_value=ZEROCONF_NAME) - type(mock_motionmount_config_flow).mac = PropertyMock(return_value=MAC) - type(mock_motionmount_config_flow).is_authenticated = PropertyMock( - return_value=False - ) - type(mock_motionmount_config_flow).can_authenticate = PropertyMock( - return_value=True - ) + type(mock_motionmount).name = PropertyMock(return_value=ZEROCONF_NAME) + type(mock_motionmount).mac = PropertyMock(return_value=MAC) + type(mock_motionmount).is_authenticated = PropertyMock(return_value=False) + type(mock_motionmount).can_authenticate = PropertyMock(return_value=True) user_input = MOCK_USER_INPUT.copy() @@ -483,9 +463,7 @@ async def test_authentication_incorrect_then_correct_pin( assert result["errors"][CONF_PIN] == CONF_PIN # Now simulate the user entered the correct pin to finalize the test - type(mock_motionmount_config_flow).is_authenticated = PropertyMock( - return_value=True - ) + type(mock_motionmount).is_authenticated = PropertyMock(return_value=True) result = await hass.config_entries.flow.async_configure( result["flow_id"], user_input=MOCK_PIN_INPUT.copy(), @@ -505,18 +483,14 @@ async def test_authentication_incorrect_then_correct_pin( async def test_authentication_first_incorrect_pin_to_backoff( hass: HomeAssistant, mock_config_entry: MockConfigEntry, - mock_motionmount_config_flow: MagicMock, + mock_motionmount: MagicMock, freezer: FrozenDateTimeFactory, ) -> None: """Test that authentication is requested when needed.""" - type(mock_motionmount_config_flow).name = PropertyMock(return_value=ZEROCONF_NAME) - type(mock_motionmount_config_flow).mac = PropertyMock(return_value=MAC) - type(mock_motionmount_config_flow).is_authenticated = PropertyMock( - return_value=False - ) - type(mock_motionmount_config_flow).can_authenticate = PropertyMock( - side_effect=[True, 1] - ) + type(mock_motionmount).name = PropertyMock(return_value=ZEROCONF_NAME) + type(mock_motionmount).mac = PropertyMock(return_value=MAC) + type(mock_motionmount).is_authenticated = PropertyMock(return_value=False) + type(mock_motionmount).can_authenticate = PropertyMock(side_effect=[True, 1]) result = await hass.config_entries.flow.async_init( DOMAIN, @@ -532,7 +506,7 @@ async def test_authentication_first_incorrect_pin_to_backoff( user_input=MOCK_PIN_INPUT.copy(), ) - assert mock_motionmount_config_flow.authenticate.called + assert mock_motionmount.authenticate.called assert result["type"] is FlowResultType.SHOW_PROGRESS assert result["step_id"] == "backoff" @@ -541,12 +515,8 @@ async def test_authentication_first_incorrect_pin_to_backoff( await hass.async_block_till_done() # Now simulate the user entered the correct pin to finalize the test - type(mock_motionmount_config_flow).is_authenticated = PropertyMock( - return_value=True - ) - type(mock_motionmount_config_flow).can_authenticate = PropertyMock( - return_value=True - ) + type(mock_motionmount).is_authenticated = PropertyMock(return_value=True) + type(mock_motionmount).can_authenticate = PropertyMock(return_value=True) result = await hass.config_entries.flow.async_configure( result["flow_id"], @@ -567,16 +537,14 @@ async def test_authentication_first_incorrect_pin_to_backoff( async def test_authentication_multiple_incorrect_pins( hass: HomeAssistant, mock_config_entry: MockConfigEntry, - mock_motionmount_config_flow: MagicMock, + mock_motionmount: MagicMock, freezer: FrozenDateTimeFactory, ) -> None: """Test that authentication is requested when needed.""" - type(mock_motionmount_config_flow).name = PropertyMock(return_value=ZEROCONF_NAME) - type(mock_motionmount_config_flow).mac = PropertyMock(return_value=MAC) - type(mock_motionmount_config_flow).is_authenticated = PropertyMock( - return_value=False - ) - type(mock_motionmount_config_flow).can_authenticate = PropertyMock(return_value=1) + type(mock_motionmount).name = PropertyMock(return_value=ZEROCONF_NAME) + type(mock_motionmount).mac = PropertyMock(return_value=MAC) + type(mock_motionmount).is_authenticated = PropertyMock(return_value=False) + type(mock_motionmount).can_authenticate = PropertyMock(return_value=1) user_input = MOCK_USER_INPUT.copy() @@ -602,12 +570,8 @@ async def test_authentication_multiple_incorrect_pins( await hass.async_block_till_done() # Now simulate the user entered the correct pin to finalize the test - type(mock_motionmount_config_flow).is_authenticated = PropertyMock( - return_value=True - ) - type(mock_motionmount_config_flow).can_authenticate = PropertyMock( - return_value=True - ) + type(mock_motionmount).is_authenticated = PropertyMock(return_value=True) + type(mock_motionmount).can_authenticate = PropertyMock(return_value=True) result = await hass.config_entries.flow.async_configure( result["flow_id"], @@ -628,16 +592,14 @@ async def test_authentication_multiple_incorrect_pins( async def test_authentication_show_backoff_when_still_running( hass: HomeAssistant, mock_config_entry: MockConfigEntry, - mock_motionmount_config_flow: MagicMock, + mock_motionmount: MagicMock, freezer: FrozenDateTimeFactory, ) -> None: """Test that authentication is requested when needed.""" - type(mock_motionmount_config_flow).name = PropertyMock(return_value=ZEROCONF_NAME) - type(mock_motionmount_config_flow).mac = PropertyMock(return_value=MAC) - type(mock_motionmount_config_flow).is_authenticated = PropertyMock( - return_value=False - ) - type(mock_motionmount_config_flow).can_authenticate = PropertyMock(return_value=1) + type(mock_motionmount).name = PropertyMock(return_value=ZEROCONF_NAME) + type(mock_motionmount).mac = PropertyMock(return_value=MAC) + type(mock_motionmount).is_authenticated = PropertyMock(return_value=False) + type(mock_motionmount).can_authenticate = PropertyMock(return_value=1) result = await hass.config_entries.flow.async_init( DOMAIN, @@ -671,12 +633,8 @@ async def test_authentication_show_backoff_when_still_running( await hass.async_block_till_done() # Now simulate the user entered the correct pin to finalize the test - type(mock_motionmount_config_flow).is_authenticated = PropertyMock( - return_value=True - ) - type(mock_motionmount_config_flow).can_authenticate = PropertyMock( - return_value=True - ) + type(mock_motionmount).is_authenticated = PropertyMock(return_value=True) + type(mock_motionmount).can_authenticate = PropertyMock(return_value=True) result = await hass.config_entries.flow.async_configure( result["flow_id"], @@ -697,17 +655,13 @@ async def test_authentication_show_backoff_when_still_running( async def test_authentication_correct_pin( hass: HomeAssistant, mock_config_entry: MockConfigEntry, - mock_motionmount_config_flow: MagicMock, + mock_motionmount: MagicMock, ) -> None: """Test that authentication is requested when needed.""" - type(mock_motionmount_config_flow).name = PropertyMock(return_value=ZEROCONF_NAME) - type(mock_motionmount_config_flow).mac = PropertyMock(return_value=MAC) - type(mock_motionmount_config_flow).is_authenticated = PropertyMock( - return_value=False - ) - type(mock_motionmount_config_flow).can_authenticate = PropertyMock( - return_value=True - ) + type(mock_motionmount).name = PropertyMock(return_value=ZEROCONF_NAME) + type(mock_motionmount).mac = PropertyMock(return_value=MAC) + type(mock_motionmount).is_authenticated = PropertyMock(return_value=False) + type(mock_motionmount).can_authenticate = PropertyMock(return_value=True) user_input = MOCK_USER_INPUT.copy() @@ -720,9 +674,7 @@ async def test_authentication_correct_pin( assert result["type"] is FlowResultType.FORM assert result["step_id"] == "auth" - type(mock_motionmount_config_flow).is_authenticated = PropertyMock( - return_value=True - ) + type(mock_motionmount).is_authenticated = PropertyMock(return_value=True) result = await hass.config_entries.flow.async_configure( result["flow_id"], user_input=MOCK_PIN_INPUT.copy(), @@ -741,11 +693,11 @@ async def test_authentication_correct_pin( async def test_full_user_flow_implementation( hass: HomeAssistant, - mock_motionmount_config_flow: MagicMock, + mock_motionmount: MagicMock, ) -> None: """Test the full manual user flow from start to finish.""" - type(mock_motionmount_config_flow).name = PropertyMock(return_value=ZEROCONF_NAME) - type(mock_motionmount_config_flow).mac = PropertyMock(return_value=MAC) + type(mock_motionmount).name = PropertyMock(return_value=ZEROCONF_NAME) + type(mock_motionmount).mac = PropertyMock(return_value=MAC) result = await hass.config_entries.flow.async_init( DOMAIN, @@ -773,11 +725,11 @@ async def test_full_user_flow_implementation( async def test_full_zeroconf_flow_implementation( hass: HomeAssistant, - mock_motionmount_config_flow: MagicMock, + mock_motionmount: MagicMock, ) -> None: """Test the full zeroconf flow from start to finish.""" - type(mock_motionmount_config_flow).name = PropertyMock(return_value=ZEROCONF_NAME) - type(mock_motionmount_config_flow).mac = PropertyMock(return_value=MAC) + type(mock_motionmount).name = PropertyMock(return_value=ZEROCONF_NAME) + type(mock_motionmount).mac = PropertyMock(return_value=MAC) discovery_info = dataclasses.replace(MOCK_ZEROCONF_TVM_SERVICE_INFO_V2) result = await hass.config_entries.flow.async_init( @@ -808,7 +760,7 @@ async def test_full_zeroconf_flow_implementation( async def test_full_reauth_flow_implementation( hass: HomeAssistant, mock_config_entry: MockConfigEntry, - mock_motionmount_config_flow: MagicMock, + mock_motionmount: MagicMock, ) -> None: """Test reauthentication.""" mock_config_entry.add_to_hass(hass) @@ -824,12 +776,8 @@ async def test_full_reauth_flow_implementation( assert result["type"] is FlowResultType.FORM assert result["step_id"] == "auth" - type(mock_motionmount_config_flow).can_authenticate = PropertyMock( - return_value=True - ) - type(mock_motionmount_config_flow).is_authenticated = PropertyMock( - return_value=True - ) + type(mock_motionmount).can_authenticate = PropertyMock(return_value=True) + type(mock_motionmount).is_authenticated = PropertyMock(return_value=True) result = await hass.config_entries.flow.async_configure( result["flow_id"], user_input=MOCK_PIN_INPUT.copy(), diff --git a/tests/components/motionmount/test_entity.py b/tests/components/motionmount/test_entity.py new file mode 100644 index 00000000000..e335c3a913b --- /dev/null +++ b/tests/components/motionmount/test_entity.py @@ -0,0 +1,47 @@ +"""Tests for the MotionMount Entity base.""" + +from unittest.mock import MagicMock + +from homeassistant.core import HomeAssistant +from homeassistant.helpers import device_registry as dr, entity_registry as er +from homeassistant.helpers.device_registry import format_mac + +from . import ZEROCONF_NAME + +from tests.common import MockConfigEntry + + +async def test_entity_rename( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, + mock_config_entry: MockConfigEntry, + mock_motionmount: MagicMock, +) -> None: + """Tests the state attributes.""" + mock_config_entry.add_to_hass(hass) + + mock_motionmount.is_authenticated = True + assert await hass.config_entries.async_setup(mock_config_entry.entry_id) + + await hass.async_block_till_done() + + mac = format_mac(mock_motionmount.mac.hex()) + device = device_registry.async_get_device( + connections={(dr.CONNECTION_NETWORK_MAC, mac)} + ) + assert device + assert device.name == ZEROCONF_NAME + + # Simulate the user changed the name of the device + mock_motionmount.name = "Blub" + + for callback in mock_motionmount.add_listener.call_args_list: + callback[0][0]() + await hass.async_block_till_done() + + device = device_registry.async_get_device( + connections={(dr.CONNECTION_NETWORK_MAC, mac)} + ) + assert device + assert device.name == "Blub" diff --git a/tests/components/motionmount/test_init.py b/tests/components/motionmount/test_init.py new file mode 100644 index 00000000000..e307945d0d0 --- /dev/null +++ b/tests/components/motionmount/test_init.py @@ -0,0 +1,129 @@ +"""Tests for the MotionMount init.""" + +from unittest.mock import MagicMock + +from homeassistant.components.motionmount import DOMAIN +from homeassistant.config_entries import SOURCE_REAUTH, ConfigEntryState +from homeassistant.core import HomeAssistant +from homeassistant.helpers import device_registry as dr +from homeassistant.helpers.device_registry import format_mac + +from tests.common import MockConfigEntry + + +async def test_setup_entry_with_mac( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + mock_config_entry: MockConfigEntry, + mock_motionmount: MagicMock, +) -> None: + """Tests the state attributes.""" + mock_config_entry.add_to_hass(hass) + + assert await hass.config_entries.async_setup(mock_config_entry.entry_id) + + assert mock_config_entry.state is ConfigEntryState.LOADED + + mac = format_mac(mock_motionmount.mac.hex()) + device = device_registry.async_get_device( + connections={(dr.CONNECTION_NETWORK_MAC, mac)} + ) + assert device + assert device.name == mock_config_entry.title + + +async def test_setup_entry_without_mac( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + mock_config_entry: MockConfigEntry, + mock_motionmount: MagicMock, +) -> None: + """Tests the state attributes.""" + mock_config_entry.add_to_hass(hass) + + mock_motionmount.mac = b"\x00\x00\x00\x00\x00\x00" + + assert await hass.config_entries.async_setup(mock_config_entry.entry_id) + + assert mock_config_entry.state is ConfigEntryState.LOADED + + device = device_registry.async_get_device( + identifiers={(DOMAIN, mock_config_entry.entry_id)} + ) + assert device + assert device.name == mock_config_entry.title + + +async def test_setup_entry_failed_connect( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_motionmount: MagicMock, +) -> None: + """Tests the state attributes.""" + mock_config_entry.add_to_hass(hass) + + mock_motionmount.connect.side_effect = TimeoutError() + assert not await hass.config_entries.async_setup(mock_config_entry.entry_id) + + assert mock_config_entry.state is ConfigEntryState.SETUP_RETRY + + +async def test_setup_entry_wrong_device( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_motionmount: MagicMock, +) -> None: + """Tests the state attributes.""" + mock_config_entry.add_to_hass(hass) + + mock_motionmount.mac = b"\x00\x00\x00\x00\x00\x01" + assert not await hass.config_entries.async_setup(mock_config_entry.entry_id) + + assert mock_config_entry.state is ConfigEntryState.SETUP_RETRY + + +async def test_setup_entry_no_pin( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_motionmount: MagicMock, +) -> None: + """Tests the state attributes.""" + mock_config_entry.add_to_hass(hass) + + mock_motionmount.is_authenticated = False + assert not await hass.config_entries.async_setup(mock_config_entry.entry_id) + + assert mock_config_entry.state is ConfigEntryState.SETUP_ERROR + assert any(mock_config_entry.async_get_active_flows(hass, sources={SOURCE_REAUTH})) + + +async def test_setup_entry_wrong_pin( + hass: HomeAssistant, + mock_config_entry_with_pin: MockConfigEntry, + mock_motionmount: MagicMock, +) -> None: + """Tests the state attributes.""" + mock_config_entry_with_pin.add_to_hass(hass) + + mock_motionmount.is_authenticated = False + assert not await hass.config_entries.async_setup( + mock_config_entry_with_pin.entry_id + ) + + assert mock_config_entry_with_pin.state is ConfigEntryState.SETUP_ERROR + assert any( + mock_config_entry_with_pin.async_get_active_flows(hass, sources={SOURCE_REAUTH}) + ) + + +async def test_unload_entry( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_motionmount: MagicMock, +) -> None: + """Test entries are unloaded correctly.""" + mock_config_entry.add_to_hass(hass) + + assert await hass.config_entries.async_setup(mock_config_entry.entry_id) + assert await hass.config_entries.async_unload(mock_config_entry.entry_id) + assert mock_motionmount.disconnect.call_count == 1 diff --git a/tests/components/motionmount/test_sensor.py b/tests/components/motionmount/test_sensor.py index 0320e62d640..0132860727f 100644 --- a/tests/components/motionmount/test_sensor.py +++ b/tests/components/motionmount/test_sensor.py @@ -7,12 +7,10 @@ import pytest from homeassistant.core import HomeAssistant -from . import ZEROCONF_NAME +from . import MAC, ZEROCONF_NAME from tests.common import MockConfigEntry -MAC = bytes.fromhex("c4dd57f8a55f") - @pytest.mark.usefixtures("entity_registry_enabled_by_default") @pytest.mark.parametrize( From f2e4bcea1911578f13ded85025d8a62b27d6b022 Mon Sep 17 00:00:00 2001 From: G Johansson Date: Mon, 7 Apr 2025 18:24:07 +0200 Subject: [PATCH 344/505] Add subdiv aliases to workday (#133608) * Add subdiv aliases to workday * Fix * Add lib test --- .../components/workday/config_flow.py | 12 ++++- tests/components/workday/test_config_flow.py | 45 ++++++++++++++++++- tests/components/workday/test_init.py | 16 +++++++ 3 files changed, 70 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/workday/config_flow.py b/homeassistant/components/workday/config_flow.py index 895c7cd50e2..b0b1e9fcc02 100644 --- a/homeassistant/components/workday/config_flow.py +++ b/homeassistant/components/workday/config_flow.py @@ -26,6 +26,7 @@ from homeassistant.helpers.selector import ( NumberSelector, NumberSelectorConfig, NumberSelectorMode, + SelectOptionDict, SelectSelector, SelectSelectorConfig, SelectSelectorMode, @@ -79,10 +80,19 @@ def add_province_and_language_to_schema( } if provinces := all_countries.get(country): + if _country.subdivisions_aliases and ( + subdiv_aliases := _country.get_subdivision_aliases() + ): + province_options: list[Any] = [ + SelectOptionDict(value=k, label=", ".join(v)) + for k, v in subdiv_aliases.items() + ] + else: + province_options = provinces province_schema = { vol.Optional(CONF_PROVINCE): SelectSelector( SelectSelectorConfig( - options=provinces, + options=province_options, mode=SelectSelectorMode.DROPDOWN, translation_key=CONF_PROVINCE, ) diff --git a/tests/components/workday/test_config_flow.py b/tests/components/workday/test_config_flow.py index 51d4b899d25..c05da654f96 100644 --- a/tests/components/workday/test_config_flow.py +++ b/tests/components/workday/test_config_flow.py @@ -55,7 +55,7 @@ async def test_form(hass: HomeAssistant) -> None: CONF_WORKDAYS: DEFAULT_WORKDAYS, CONF_ADD_HOLIDAYS: [], CONF_REMOVE_HOLIDAYS: [], - CONF_LANGUAGE: "de", + CONF_LANGUAGE: "en_US", }, ) await hass.async_block_till_done() @@ -70,7 +70,48 @@ async def test_form(hass: HomeAssistant) -> None: "workdays": ["mon", "tue", "wed", "thu", "fri"], "add_holidays": [], "remove_holidays": [], - "language": "de", + "language": "en_US", + } + + +async def test_form_province_no_alias(hass: HomeAssistant) -> None: + """Test we get the forms.""" + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] is FlowResultType.FORM + + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_NAME: "Workday Sensor", + CONF_COUNTRY: "US", + }, + ) + await hass.async_block_till_done() + result3 = await hass.config_entries.flow.async_configure( + result2["flow_id"], + { + CONF_EXCLUDES: DEFAULT_EXCLUDES, + CONF_OFFSET: DEFAULT_OFFSET, + CONF_WORKDAYS: DEFAULT_WORKDAYS, + CONF_ADD_HOLIDAYS: [], + CONF_REMOVE_HOLIDAYS: [], + }, + ) + await hass.async_block_till_done() + + assert result3["type"] is FlowResultType.CREATE_ENTRY + assert result3["title"] == "Workday Sensor" + assert result3["options"] == { + "name": "Workday Sensor", + "country": "US", + "excludes": ["sat", "sun", "holiday"], + "days_offset": 0, + "workdays": ["mon", "tue", "wed", "thu", "fri"], + "add_holidays": [], + "remove_holidays": [], } diff --git a/tests/components/workday/test_init.py b/tests/components/workday/test_init.py index 1e0c9cbebc6..2735175b49b 100644 --- a/tests/components/workday/test_init.py +++ b/tests/components/workday/test_init.py @@ -5,6 +5,7 @@ from __future__ import annotations from datetime import datetime from freezegun.api import FrozenDateTimeFactory +from holidays.utils import country_holidays from homeassistant.config_entries import ConfigEntryState from homeassistant.core import HomeAssistant @@ -50,3 +51,18 @@ async def test_update_options( assert entry_check.state is ConfigEntryState.LOADED state = hass.states.get("binary_sensor.workday_sensor") assert state.state == "off" + + +async def test_workday_subdiv_aliases() -> None: + """Test subdiv aliases in holidays library.""" + + country = country_holidays( + country="FR", + years=2025, + ) + subdiv_aliases = country.get_subdivision_aliases() + assert subdiv_aliases["GES"] == [ # codespell:ignore + "Alsace", + "Champagne-Ardenne", + "Lorraine", + ] From a787c6a31e3690cef45e14542401699957436f03 Mon Sep 17 00:00:00 2001 From: rappenze Date: Mon, 7 Apr 2025 18:53:35 +0200 Subject: [PATCH 345/505] Add state multiplexer in fibaro integration (#139649) * Add state multiplexer in fibaro integration * Add unload test * Adjust code comments * Add event entity test * . --- homeassistant/components/fibaro/__init__.py | 86 ++++----------------- homeassistant/components/fibaro/entity.py | 8 +- homeassistant/components/fibaro/event.py | 11 ++- tests/components/fibaro/conftest.py | 21 +++++ tests/components/fibaro/test_event.py | 35 +++++++++ tests/components/fibaro/test_init.py | 31 ++++++++ 6 files changed, 118 insertions(+), 74 deletions(-) create mode 100644 tests/components/fibaro/test_event.py create mode 100644 tests/components/fibaro/test_init.py diff --git a/homeassistant/components/fibaro/__init__.py b/homeassistant/components/fibaro/__init__.py index a4f59d8ab76..88288a86b59 100644 --- a/homeassistant/components/fibaro/__init__.py +++ b/homeassistant/components/fibaro/__init__.py @@ -14,9 +14,10 @@ from pyfibaro.fibaro_client import ( ) from pyfibaro.fibaro_data_helper import read_rooms from pyfibaro.fibaro_device import DeviceModel +from pyfibaro.fibaro_device_manager import FibaroDeviceManager from pyfibaro.fibaro_info import InfoModel from pyfibaro.fibaro_scene import SceneModel -from pyfibaro.fibaro_state_resolver import FibaroEvent, FibaroStateResolver +from pyfibaro.fibaro_state_resolver import FibaroEvent from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_PASSWORD, CONF_URL, CONF_USERNAME, Platform @@ -81,8 +82,8 @@ class FibaroController: self._client = fibaro_client self._fibaro_info = info - # Whether to import devices from plugins - self._import_plugins = import_plugins + # The fibaro device manager exposes higher level API to access fibaro devices + self._fibaro_device_manager = FibaroDeviceManager(fibaro_client, import_plugins) # Mapping roomId to room object self._room_map = read_rooms(fibaro_client) self._device_map: dict[int, DeviceModel] # Mapping deviceId to device object @@ -91,79 +92,30 @@ class FibaroController: ) # List of devices by entity platform # All scenes self._scenes = self._client.read_scenes() - self._callbacks: dict[int, list[Any]] = {} # Update value callbacks by deviceId - # Event callbacks by device id - self._event_callbacks: dict[int, list[Callable[[FibaroEvent], None]]] = {} # Unique serial number of the hub self.hub_serial = info.serial_number # Device infos by fibaro device id self._device_infos: dict[int, DeviceInfo] = {} self._read_devices() - def enable_state_handler(self) -> None: - """Start StateHandler thread for monitoring updates.""" - self._client.register_update_handler(self._on_state_change) + def disconnect(self) -> None: + """Close push channel.""" + self._fibaro_device_manager.close() - def disable_state_handler(self) -> None: - """Stop StateHandler thread used for monitoring updates.""" - self._client.unregister_update_handler() - - def _on_state_change(self, state: Any) -> None: - """Handle change report received from the HomeCenter.""" - callback_set = set() - for change in state.get("changes", []): - try: - dev_id = change.pop("id") - if dev_id not in self._device_map: - continue - device = self._device_map[dev_id] - for property_name, value in change.items(): - if property_name == "log": - if value and value != "transfer OK": - _LOGGER.debug("LOG %s: %s", device.friendly_name, value) - continue - if property_name == "logTemp": - continue - if property_name in device.properties: - device.properties[property_name] = value - _LOGGER.debug( - "<- %s.%s = %s", device.ha_id, property_name, str(value) - ) - else: - _LOGGER.warning("%s.%s not found", device.ha_id, property_name) - if dev_id in self._callbacks: - callback_set.add(dev_id) - except (ValueError, KeyError): - pass - for item in callback_set: - for callback in self._callbacks[item]: - callback() - - resolver = FibaroStateResolver(state) - for event in resolver.get_events(): - # event does not always have a fibaro id, therefore it is - # essential that we first check for relevant event type - if ( - event.event_type.lower() == "centralsceneevent" - and event.fibaro_id in self._event_callbacks - ): - for callback in self._event_callbacks[event.fibaro_id]: - callback(event) - - def register(self, device_id: int, callback: Any) -> None: + def register( + self, device_id: int, callback: Callable[[DeviceModel], None] + ) -> Callable[[], None]: """Register device with a callback for updates.""" - device_callbacks = self._callbacks.setdefault(device_id, []) - device_callbacks.append(callback) + return self._fibaro_device_manager.add_change_listener(device_id, callback) def register_event( self, device_id: int, callback: Callable[[FibaroEvent], None] - ) -> None: + ) -> Callable[[], None]: """Register device with a callback for central scene events. The callback receives one parameter with the event. """ - device_callbacks = self._event_callbacks.setdefault(device_id, []) - device_callbacks.append(callback) + return self._fibaro_device_manager.add_event_listener(device_id, callback) def get_children(self, device_id: int) -> list[DeviceModel]: """Get a list of child devices.""" @@ -286,7 +238,7 @@ class FibaroController: def _read_devices(self) -> None: """Read and process the device list.""" - devices = self._client.read_devices() + devices = self._fibaro_device_manager.get_devices() self._device_map = {} last_climate_parent = None last_endpoint = None @@ -301,9 +253,8 @@ class FibaroController: device.ha_id = ( f"{slugify(room_name)}_{slugify(device.name)}_{device.fibaro_id}" ) - platform = None - if device.enabled and (not device.is_plugin or self._import_plugins): - platform = self._map_device_to_platform(device) + + platform = self._map_device_to_platform(device) if platform is None: continue device.unique_id_str = f"{slugify(self.hub_serial)}.{device.fibaro_id}" @@ -393,8 +344,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: FibaroConfigEntry) -> bo await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) - controller.enable_state_handler() - return True @@ -403,8 +352,7 @@ async def async_unload_entry(hass: HomeAssistant, entry: FibaroConfigEntry) -> b _LOGGER.debug("Shutting down Fibaro connection") unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) - entry.runtime_data.disable_state_handler() - + entry.runtime_data.disconnect() return unload_ok diff --git a/homeassistant/components/fibaro/entity.py b/homeassistant/components/fibaro/entity.py index 5375b058315..e8ed5afc500 100644 --- a/homeassistant/components/fibaro/entity.py +++ b/homeassistant/components/fibaro/entity.py @@ -36,9 +36,13 @@ class FibaroEntity(Entity): async def async_added_to_hass(self) -> None: """Call when entity is added to hass.""" - self.controller.register(self.fibaro_device.fibaro_id, self._update_callback) + self.async_on_remove( + self.controller.register( + self.fibaro_device.fibaro_id, self._update_callback + ) + ) - def _update_callback(self) -> None: + def _update_callback(self, fibaro_device: DeviceModel) -> None: """Update the state.""" self.schedule_update_ha_state(True) diff --git a/homeassistant/components/fibaro/event.py b/homeassistant/components/fibaro/event.py index 0beea2e336e..ad44719c8be 100644 --- a/homeassistant/components/fibaro/event.py +++ b/homeassistant/components/fibaro/event.py @@ -60,11 +60,16 @@ class FibaroEventEntity(FibaroEntity, EventEntity): await super().async_added_to_hass() # Register event callback - self.controller.register_event( - self.fibaro_device.fibaro_id, self._event_callback + self.async_on_remove( + self.controller.register_event( + self.fibaro_device.fibaro_id, self._event_callback + ) ) def _event_callback(self, event: FibaroEvent) -> None: - if event.key_id == self._button: + if ( + event.event_type.lower() == "centralsceneevent" + and event.key_id == self._button + ): self._trigger_event(event.key_event_type) self.schedule_update_ha_state() diff --git a/tests/components/fibaro/conftest.py b/tests/components/fibaro/conftest.py index 55b7e35132c..9e7c2f6c003 100644 --- a/tests/components/fibaro/conftest.py +++ b/tests/components/fibaro/conftest.py @@ -3,6 +3,7 @@ from collections.abc import Generator from unittest.mock import AsyncMock, Mock, patch +from pyfibaro.fibaro_device import SceneEvent import pytest from homeassistant.components.fibaro import CONF_IMPORT_PLUGINS, DOMAIN @@ -231,6 +232,26 @@ def mock_fan_device() -> Mock: return climate +@pytest.fixture +def mock_button_device() -> Mock: + """Fixture for a button device.""" + climate = Mock() + climate.fibaro_id = 8 + climate.parent_fibaro_id = 0 + climate.name = "Test button" + climate.room_id = 1 + climate.dead = False + climate.visible = True + climate.enabled = True + climate.type = "com.fibaro.remoteController" + climate.base_type = "com.fibaro.actor" + climate.properties = {"manufacturer": ""} + climate.central_scene_event = [SceneEvent(1, "Pressed")] + climate.actions = {} + climate.interfaces = ["zwaveCentralScene"] + return climate + + @pytest.fixture def mock_config_entry(hass: HomeAssistant) -> MockConfigEntry: """Return the default mocked config entry.""" diff --git a/tests/components/fibaro/test_event.py b/tests/components/fibaro/test_event.py new file mode 100644 index 00000000000..ced39b71197 --- /dev/null +++ b/tests/components/fibaro/test_event.py @@ -0,0 +1,35 @@ +"""Test the Fibaro event platform.""" + +from unittest.mock import Mock, patch + +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from .conftest import init_integration + +from tests.common import MockConfigEntry + + +async def test_entity_setup( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + mock_fibaro_client: Mock, + mock_config_entry: MockConfigEntry, + mock_button_device: Mock, + mock_room: Mock, +) -> None: + """Test that the button device creates an entity.""" + + # Arrange + mock_fibaro_client.read_rooms.return_value = [mock_room] + mock_fibaro_client.read_devices.return_value = [mock_button_device] + + with patch("homeassistant.components.fibaro.PLATFORMS", [Platform.EVENT]): + # Act + await init_integration(hass, mock_config_entry) + # Assert + entry = entity_registry.async_get("event.room_1_test_button_8_button_1") + assert entry + assert entry.unique_id == "hc2_111111.8.1" + assert entry.original_name == "Room 1 Test button Button 1" diff --git a/tests/components/fibaro/test_init.py b/tests/components/fibaro/test_init.py new file mode 100644 index 00000000000..330de74d6af --- /dev/null +++ b/tests/components/fibaro/test_init.py @@ -0,0 +1,31 @@ +"""Test init methods.""" + +from unittest.mock import Mock, patch + +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant + +from .conftest import init_integration + +from tests.common import MockConfigEntry + + +async def test_unload_integration( + hass: HomeAssistant, + mock_fibaro_client: Mock, + mock_config_entry: MockConfigEntry, + mock_light: Mock, + mock_room: Mock, +) -> None: + """Test unload integration stops state listener.""" + # Arrange + mock_fibaro_client.read_rooms.return_value = [mock_room] + mock_fibaro_client.read_devices.return_value = [mock_light] + + with patch("homeassistant.components.fibaro.PLATFORMS", [Platform.LIGHT]): + await init_integration(hass, mock_config_entry) + # Act + await hass.config_entries.async_unload(mock_config_entry.entry_id) + await hass.async_block_till_done() + # Assert + assert mock_fibaro_client.unregister_update_handler.call_count == 1 From 4813b5c88251e1f173cb2ed6d53ec0c309cc46a7 Mon Sep 17 00:00:00 2001 From: Artur Pragacz <49985303+arturpragacz@users.noreply.github.com> Date: Mon, 7 Apr 2025 20:16:48 +0200 Subject: [PATCH 346/505] Fix wait for a dependency with config entries (#142318) * Fix wait for dependency with config entries * test types * test coverage --------- Co-authored-by: J. Nick Koston --- homeassistant/setup.py | 37 ++++++++++++---------- tests/test_setup.py | 70 ++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 91 insertions(+), 16 deletions(-) diff --git a/homeassistant/setup.py b/homeassistant/setup.py index aeaea1146a1..76061b72b73 100644 --- a/homeassistant/setup.py +++ b/homeassistant/setup.py @@ -202,16 +202,19 @@ async def _async_process_dependencies( """ setup_futures = hass.data.setdefault(DATA_SETUP, {}) - dependencies_tasks = { - dep: setup_futures.get(dep) - or create_eager_task( - async_setup_component(hass, dep, config), - name=f"setup {dep} as dependency of {integration.domain}", - loop=hass.loop, - ) - for dep in integration.dependencies - if dep not in hass.config.components - } + dependencies_tasks: dict[str, asyncio.Future[bool]] = {} + + for dep in integration.dependencies: + fut = setup_futures.get(dep) + if fut is None: + if dep in hass.config.components: + continue + fut = create_eager_task( + async_setup_component(hass, dep, config), + name=f"setup {dep} as dependency of {integration.domain}", + loop=hass.loop, + ) + dependencies_tasks[dep] = fut to_be_loaded = hass.data.get(DATA_SETUP_DONE, {}) # We don't want to just wait for the futures from `to_be_loaded` here. @@ -219,16 +222,18 @@ async def _async_process_dependencies( # scheduled to be set up, as if for whatever reason they had not been, # we would deadlock waiting for them here. for dep in integration.after_dependencies: - if ( - dep not in dependencies_tasks - and dep in to_be_loaded - and dep not in hass.config.components - ): - dependencies_tasks[dep] = setup_futures.get(dep) or create_eager_task( + if dep not in to_be_loaded or dep in dependencies_tasks: + continue + fut = setup_futures.get(dep) + if fut is None: + if dep in hass.config.components: + continue + fut = create_eager_task( async_setup_component(hass, dep, config), name=f"setup {dep} as after dependency of {integration.domain}", loop=hass.loop, ) + dependencies_tasks[dep] = fut if not dependencies_tasks: return [] diff --git a/tests/test_setup.py b/tests/test_setup.py index 1f0e668d4e2..084b657a2f2 100644 --- a/tests/test_setup.py +++ b/tests/test_setup.py @@ -353,6 +353,76 @@ async def test_component_not_setup_missing_dependencies(hass: HomeAssistant) -> assert await setup.async_setup_component(hass, "comp2", {}) +async def test_component_not_setup_already_setup_dependencies( + hass: HomeAssistant, +) -> None: + """Test we do not set up component dependencies if they are already set up.""" + mock_integration( + hass, + MockModule( + "comp", + dependencies=["dep1"], + partial_manifest={"after_dependencies": ["dep2"]}, + ), + ) + mock_integration(hass, MockModule("dep1")) + mock_integration(hass, MockModule("dep2")) + + setup.async_set_domains_to_be_loaded(hass, {"comp", "dep2"}) + + hass.config.components.add("dep1") + hass.config.components.add("dep2") + + with patch( + "homeassistant.setup.async_setup_component", + side_effect=setup.async_setup_component, + ) as mock_setup: + await mock_setup(hass, "comp", {}) + + assert mock_setup.call_count == 1 + + +@pytest.mark.usefixtures("mock_handlers") +async def test_component_setup_dependencies_with_config_entry( + hass: HomeAssistant, +) -> None: + """Test we wait for a dependency with config entry.""" + calls: list[str] = [] + + async def mock_async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + await asyncio.sleep(0) + calls.append("entry") + return True + + mock_integration(hass, MockModule("comp", async_setup_entry=mock_async_setup_entry)) + mock_platform(hass, "comp.config_flow", None) + MockConfigEntry(domain="comp").add_to_hass(hass) + + async def mock_async_setup(hass: HomeAssistant, config: ConfigType) -> bool: + calls.append("comp") + return True + + mock_integration( + hass, + MockModule("comp2", dependencies=["comp"], async_setup=mock_async_setup), + ) + mock_integration( + hass, + MockModule("comp3", dependencies=["comp"], async_setup=mock_async_setup), + ) + + await asyncio.gather( + setup.async_setup_component(hass, "comp2", {}), + setup.async_setup_component(hass, "comp3", {}), + ) + + assert "comp" in hass.config.components + assert "comp2" in hass.config.components + assert "comp3" in hass.config.components + + assert calls == ["entry", "comp", "comp"] + + async def test_component_failing_setup(hass: HomeAssistant) -> None: """Test component that fails setup.""" mock_integration(hass, MockModule("comp", setup=lambda hass, config: False)) From 7ad13c8897eee0c09ab2111a61483cb598cdc2f5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=2E=20Diego=20Rodr=C3=ADguez=20Royo?= Date: Mon, 7 Apr 2025 20:23:38 +0200 Subject: [PATCH 347/505] Delete Home Connect deprecated binary door sensor (#142490) --- .../components/home_connect/binary_sensor.py | 105 +-------- .../home_connect/test_binary_sensor.py | 200 ------------------ 2 files changed, 2 insertions(+), 303 deletions(-) diff --git a/homeassistant/components/home_connect/binary_sensor.py b/homeassistant/components/home_connect/binary_sensor.py index a28b4ff2b49..7e4523201f9 100644 --- a/homeassistant/components/home_connect/binary_sensor.py +++ b/homeassistant/components/home_connect/binary_sensor.py @@ -5,37 +5,18 @@ from typing import cast from aiohomeconnect.model import EventKey, StatusKey -from homeassistant.components.automation import automations_with_entity from homeassistant.components.binary_sensor import ( BinarySensorDeviceClass, BinarySensorEntity, BinarySensorEntityDescription, ) -from homeassistant.components.script import scripts_with_entity from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant -from homeassistant.helpers import entity_registry as er from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from homeassistant.helpers.issue_registry import ( - IssueSeverity, - async_create_issue, - async_delete_issue, -) from .common import setup_home_connect_entry -from .const import ( - BSH_DOOR_STATE_CLOSED, - BSH_DOOR_STATE_LOCKED, - BSH_DOOR_STATE_OPEN, - DOMAIN, - REFRIGERATION_STATUS_DOOR_CLOSED, - REFRIGERATION_STATUS_DOOR_OPEN, -) -from .coordinator import ( - HomeConnectApplianceData, - HomeConnectConfigEntry, - HomeConnectCoordinator, -) +from .const import REFRIGERATION_STATUS_DOOR_CLOSED, REFRIGERATION_STATUS_DOOR_OPEN +from .coordinator import HomeConnectApplianceData, HomeConnectConfigEntry from .entity import HomeConnectEntity PARALLEL_UPDATES = 0 @@ -173,8 +154,6 @@ def _get_entities_for_appliance( for description in BINARY_SENSORS if description.key in appliance.status ) - if StatusKey.BSH_COMMON_DOOR_STATE in appliance.status: - entities.append(HomeConnectDoorBinarySensor(entry.runtime_data, appliance)) return entities @@ -220,83 +199,3 @@ class HomeConnectConnectivityBinarySensor(HomeConnectEntity, BinarySensorEntity) def available(self) -> bool: """Return the availability.""" return self.coordinator.last_update_success - - -class HomeConnectDoorBinarySensor(HomeConnectBinarySensor): - """Binary sensor for Home Connect Generic Door.""" - - _attr_has_entity_name = False - - def __init__( - self, - coordinator: HomeConnectCoordinator, - appliance: HomeConnectApplianceData, - ) -> None: - """Initialize the entity.""" - super().__init__( - coordinator, - appliance, - HomeConnectBinarySensorEntityDescription( - key=StatusKey.BSH_COMMON_DOOR_STATE, - device_class=BinarySensorDeviceClass.DOOR, - boolean_map={ - BSH_DOOR_STATE_CLOSED: False, - BSH_DOOR_STATE_LOCKED: False, - BSH_DOOR_STATE_OPEN: True, - }, - entity_registry_enabled_default=False, - ), - ) - self._attr_unique_id = f"{appliance.info.ha_id}-Door" - self._attr_name = f"{appliance.info.name} Door" - - async def async_added_to_hass(self) -> None: - """Call when entity is added to hass.""" - await super().async_added_to_hass() - automations = automations_with_entity(self.hass, self.entity_id) - scripts = scripts_with_entity(self.hass, self.entity_id) - items = automations + scripts - if not items: - return - - entity_reg: er.EntityRegistry = er.async_get(self.hass) - entity_automations = [ - automation_entity - for automation_id in automations - if (automation_entity := entity_reg.async_get(automation_id)) - ] - entity_scripts = [ - script_entity - for script_id in scripts - if (script_entity := entity_reg.async_get(script_id)) - ] - - items_list = [ - f"- [{item.original_name}](/config/automation/edit/{item.unique_id})" - for item in entity_automations - ] + [ - f"- [{item.original_name}](/config/script/edit/{item.unique_id})" - for item in entity_scripts - ] - - async_create_issue( - self.hass, - DOMAIN, - f"deprecated_binary_common_door_sensor_{self.entity_id}", - breaks_in_ha_version="2025.5.0", - is_fixable=True, - is_persistent=True, - severity=IssueSeverity.WARNING, - translation_key="deprecated_binary_common_door_sensor", - translation_placeholders={ - "entity": self.entity_id, - "items": "\n".join(items_list), - }, - ) - - async def async_will_remove_from_hass(self) -> None: - """Call when entity will be removed from hass.""" - await super().async_will_remove_from_hass() - async_delete_issue( - self.hass, DOMAIN, f"deprecated_binary_common_door_sensor_{self.entity_id}" - ) diff --git a/tests/components/home_connect/test_binary_sensor.py b/tests/components/home_connect/test_binary_sensor.py index ce879a38de5..a245372c247 100644 --- a/tests/components/home_connect/test_binary_sensor.py +++ b/tests/components/home_connect/test_binary_sensor.py @@ -1,7 +1,6 @@ """Tests for home_connect binary_sensor entities.""" from collections.abc import Awaitable, Callable -from http import HTTPStatus from unittest.mock import AsyncMock, MagicMock from aiohomeconnect.model import ( @@ -15,17 +14,11 @@ from aiohomeconnect.model import ( from aiohomeconnect.model.error import HomeConnectApiError import pytest -from homeassistant.components import automation, script -from homeassistant.components.automation import automations_with_entity from homeassistant.components.home_connect.const import ( - BSH_DOOR_STATE_CLOSED, - BSH_DOOR_STATE_LOCKED, - BSH_DOOR_STATE_OPEN, DOMAIN, REFRIGERATION_STATUS_DOOR_CLOSED, REFRIGERATION_STATUS_DOOR_OPEN, ) -from homeassistant.components.script import scripts_with_entity from homeassistant.config_entries import ConfigEntryState from homeassistant.const import ( STATE_OFF, @@ -36,11 +29,8 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr, entity_registry as er -import homeassistant.helpers.issue_registry as ir -from homeassistant.setup import async_setup_component from tests.common import MockConfigEntry -from tests.typing import ClientSessionGenerator @pytest.fixture @@ -179,7 +169,6 @@ async def test_binary_sensors_entity_availability( ) -> None: """Test if binary sensor entities availability are based on the appliance connection state.""" entity_ids = [ - "binary_sensor.washer_door", "binary_sensor.washer_remote_control", ] assert config_entry.state == ConfigEntryState.NOT_LOADED @@ -222,57 +211,6 @@ async def test_binary_sensors_entity_availability( assert state.state != STATE_UNAVAILABLE -@pytest.mark.usefixtures("entity_registry_enabled_by_default") -@pytest.mark.parametrize("appliance", ["Washer"], indirect=True) -@pytest.mark.parametrize( - ("value", "expected"), - [ - (BSH_DOOR_STATE_CLOSED, "off"), - (BSH_DOOR_STATE_LOCKED, "off"), - (BSH_DOOR_STATE_OPEN, "on"), - ("", STATE_UNKNOWN), - ], -) -async def test_binary_sensors_door_states( - appliance: HomeAppliance, - expected: str, - value: str, - hass: HomeAssistant, - config_entry: MockConfigEntry, - integration_setup: Callable[[MagicMock], Awaitable[bool]], - setup_credentials: None, - client: MagicMock, -) -> None: - """Tests for Appliance door states.""" - entity_id = "binary_sensor.washer_door" - assert config_entry.state == ConfigEntryState.NOT_LOADED - assert await integration_setup(client) - assert config_entry.state == ConfigEntryState.LOADED - - await client.add_events( - [ - EventMessage( - appliance.ha_id, - EventType.STATUS, - ArrayOfEvents( - [ - Event( - key=EventKey.BSH_COMMON_STATUS_DOOR_STATE, - raw_key=EventKey.BSH_COMMON_STATUS_DOOR_STATE.value, - timestamp=0, - level="", - handling="", - value=value, - ) - ], - ), - ) - ] - ) - await hass.async_block_till_done() - assert hass.states.is_state(entity_id, expected) - - @pytest.mark.parametrize( ("entity_id", "event_key", "event_value_update", "expected", "appliance"), [ @@ -403,141 +341,3 @@ async def test_connected_sensor_functionality( await hass.async_block_till_done() assert hass.states.is_state(entity_id, STATE_ON) - - -@pytest.mark.usefixtures("entity_registry_enabled_by_default") -async def test_create_door_binary_sensor_deprecation_issue( - hass: HomeAssistant, - config_entry: MockConfigEntry, - integration_setup: Callable[[MagicMock], Awaitable[bool]], - setup_credentials: None, - client: MagicMock, - issue_registry: ir.IssueRegistry, -) -> None: - """Test that we create an issue when an automation or script is using a door binary sensor entity.""" - entity_id = "binary_sensor.washer_door" - issue_id = f"deprecated_binary_common_door_sensor_{entity_id}" - - assert await async_setup_component( - hass, - automation.DOMAIN, - { - automation.DOMAIN: { - "alias": "test", - "trigger": {"platform": "state", "entity_id": entity_id}, - "action": { - "action": "automation.turn_on", - "target": { - "entity_id": "automation.test", - }, - }, - } - }, - ) - assert await async_setup_component( - hass, - script.DOMAIN, - { - script.DOMAIN: { - "test": { - "sequence": [ - { - "condition": "state", - "entity_id": entity_id, - "state": "on", - }, - ], - } - } - }, - ) - - assert config_entry.state == ConfigEntryState.NOT_LOADED - assert await integration_setup(client) - assert config_entry.state == ConfigEntryState.LOADED - - assert automations_with_entity(hass, entity_id)[0] == "automation.test" - assert scripts_with_entity(hass, entity_id)[0] == "script.test" - - assert len(issue_registry.issues) == 1 - assert issue_registry.async_get_issue(DOMAIN, issue_id) - - await hass.config_entries.async_unload(config_entry.entry_id) - await hass.async_block_till_done() - - # Assert the issue is no longer present - assert not issue_registry.async_get_issue(DOMAIN, issue_id) - assert len(issue_registry.issues) == 0 - - -@pytest.mark.usefixtures("entity_registry_enabled_by_default") -async def test_door_binary_sensor_deprecation_issue_fix( - hass: HomeAssistant, - config_entry: MockConfigEntry, - integration_setup: Callable[[MagicMock], Awaitable[bool]], - setup_credentials: None, - client: MagicMock, - issue_registry: ir.IssueRegistry, - hass_client: ClientSessionGenerator, -) -> None: - """Test that we create an issue when an automation or script is using a door binary sensor entity.""" - entity_id = "binary_sensor.washer_door" - issue_id = f"deprecated_binary_common_door_sensor_{entity_id}" - - assert await async_setup_component( - hass, - automation.DOMAIN, - { - automation.DOMAIN: { - "alias": "test", - "trigger": {"platform": "state", "entity_id": entity_id}, - "action": { - "action": "automation.turn_on", - "target": { - "entity_id": "automation.test", - }, - }, - } - }, - ) - assert await async_setup_component( - hass, - script.DOMAIN, - { - script.DOMAIN: { - "test": { - "sequence": [ - { - "condition": "state", - "entity_id": entity_id, - "state": "on", - }, - ], - } - } - }, - ) - - assert config_entry.state == ConfigEntryState.NOT_LOADED - assert await integration_setup(client) - assert config_entry.state == ConfigEntryState.LOADED - - assert automations_with_entity(hass, entity_id)[0] == "automation.test" - assert scripts_with_entity(hass, entity_id)[0] == "script.test" - - assert len(issue_registry.issues) == 1 - issue = issue_registry.async_get_issue(DOMAIN, issue_id) - assert issue - - _client = await hass_client() - resp = await _client.post( - "/api/repairs/issues/fix", - json={"handler": DOMAIN, "issue_id": issue.issue_id}, - ) - assert resp.status == HTTPStatus.OK - flow_id = (await resp.json())["flow_id"] - resp = await _client.post(f"/api/repairs/issues/fix/{flow_id}") - - # Assert the issue is no longer present - assert not issue_registry.async_get_issue(DOMAIN, issue_id) - assert len(issue_registry.issues) == 0 From 4ccd30865b1700b4e37b97f126276be14dfa71f9 Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Mon, 7 Apr 2025 20:24:18 +0200 Subject: [PATCH 348/505] Use common state for "Normal" in `humidifier` (#142479) --- homeassistant/components/humidifier/strings.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/humidifier/strings.json b/homeassistant/components/humidifier/strings.json index abd9ca5757b..361636eadc6 100644 --- a/homeassistant/components/humidifier/strings.json +++ b/homeassistant/components/humidifier/strings.json @@ -62,7 +62,7 @@ "mode": { "name": "Mode", "state": { - "normal": "Normal", + "normal": "[%key:common::state::normal%]", "home": "[%key:common::state::home%]", "away": "[%key:common::state::not_home%]", "auto": "Auto", From 5c2f19de8807a69605f9e34018b6f57aaae53753 Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Mon, 7 Apr 2025 20:24:43 +0200 Subject: [PATCH 349/505] Use common states for "Normal" and "High" in `romy` (#142485) Also reordered the lines a bit for grouping the common states. --- homeassistant/components/romy/strings.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/romy/strings.json b/homeassistant/components/romy/strings.json index 78721da17ba..b8725624ac7 100644 --- a/homeassistant/components/romy/strings.json +++ b/homeassistant/components/romy/strings.json @@ -36,11 +36,11 @@ "fan_speed": { "state": { "default": "Default", - "normal": "Normal", - "silent": "Silent", + "normal": "[%key:common::state::normal%]", + "high": "[%key:common::state::high%]", "intensive": "Intensive", + "silent": "Silent", "super_silent": "Super silent", - "high": "High", "auto": "Auto" } } From 19a39a3647d2535c28d520e934c4ee8e7455a2ed Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Mon, 7 Apr 2025 20:27:01 +0200 Subject: [PATCH 350/505] Use common state for "Normal" in `homee` (#142450) * Use common state for "Normal" in `homee` Also capitalize the brand name in one string. * Change all occurrences of "homee" to lower-case --- homeassistant/components/homee/strings.json | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/homee/strings.json b/homeassistant/components/homee/strings.json index 623a4e93895..806a21556cb 100644 --- a/homeassistant/components/homee/strings.json +++ b/homeassistant/components/homee/strings.json @@ -1,6 +1,6 @@ { "config": { - "flow_title": "Homee {name} ({host})", + "flow_title": "homee {name} ({host})", "abort": { "already_configured": "[%key:common::config_flow::abort::already_configured_device%]" }, @@ -18,9 +18,9 @@ "username": "[%key:common::config_flow::data::username%]" }, "data_description": { - "host": "The IP address of your Homee.", - "username": "The username for your Homee.", - "password": "The password for your Homee." + "host": "The IP address of your homee.", + "username": "The username for your homee.", + "password": "The password for your homee." } } } @@ -45,7 +45,7 @@ "load_alarm": { "name": "Load", "state": { - "off": "Normal", + "off": "[%key:common::state::normal%]", "on": "Overload" } }, @@ -352,7 +352,7 @@ }, "exceptions": { "connection_closed": { - "message": "Could not connect to Homee while setting attribute." + "message": "Could not connect to homee while setting attribute." } } } From 1cedacc39500bd092ae798abe976d390f8a9a177 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=2E=20Diego=20Rodr=C3=ADguez=20Royo?= Date: Mon, 7 Apr 2025 21:03:01 +0200 Subject: [PATCH 351/505] Delete deprecated strings related to Home Connect binary door sensor (#142495) Delete deprecated strings related to binary door sensor --- homeassistant/components/home_connect/strings.json | 11 ----------- 1 file changed, 11 deletions(-) diff --git a/homeassistant/components/home_connect/strings.json b/homeassistant/components/home_connect/strings.json index dfbe1ca26fe..5b52183fccf 100644 --- a/homeassistant/components/home_connect/strings.json +++ b/homeassistant/components/home_connect/strings.json @@ -132,17 +132,6 @@ } } }, - "deprecated_binary_common_door_sensor": { - "title": "Deprecated binary door sensor detected in some automations or scripts", - "fix_flow": { - "step": { - "confirm": { - "title": "[%key:component::home_connect::issues::deprecated_binary_common_door_sensor::title%]", - "description": "The binary door sensor `{entity}`, which is deprecated, is used in the following automations or scripts:\n{items}\n\nA sensor entity with additional possible states is available and should be used going forward; Please use it on the above automations or scripts to fix this issue." - } - } - } - }, "deprecated_command_actions": { "title": "The command related actions are deprecated in favor of the new buttons", "fix_flow": { From 8f3f8fa35fa6d811163a33e8d5c857a5a2c6f937 Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Mon, 7 Apr 2025 21:06:11 +0200 Subject: [PATCH 352/505] Make spelling of "ecobee" consistent, matching official branding (#142496) --- homeassistant/components/ecobee/strings.json | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/homeassistant/components/ecobee/strings.json b/homeassistant/components/ecobee/strings.json index 078643ee789..bc61cb444c1 100644 --- a/homeassistant/components/ecobee/strings.json +++ b/homeassistant/components/ecobee/strings.json @@ -55,7 +55,7 @@ "fields": { "entity_id": { "name": "Entity", - "description": "Ecobee thermostat on which to create the vacation." + "description": "ecobee thermostat on which to create the vacation." }, "vacation_name": { "name": "Vacation name", @@ -101,7 +101,7 @@ "fields": { "entity_id": { "name": "Entity", - "description": "Ecobee thermostat on which to delete the vacation." + "description": "ecobee thermostat on which to delete the vacation." }, "vacation_name": { "name": "[%key:component::ecobee::services::create_vacation::fields::vacation_name::name%]", @@ -149,7 +149,7 @@ }, "set_mic_mode": { "name": "Set mic mode", - "description": "Enables/disables Alexa microphone (only for Ecobee 4).", + "description": "Enables/disables Alexa microphone (only for ecobee 4).", "fields": { "mic_enabled": { "name": "Mic enabled", @@ -177,7 +177,7 @@ "fields": { "entity_id": { "name": "Entity", - "description": "Ecobee thermostat on which to set active sensors." + "description": "ecobee thermostat on which to set active sensors." }, "preset_mode": { "name": "Climate Name", @@ -203,12 +203,12 @@ }, "issues": { "migrate_aux_heat": { - "title": "Migration of Ecobee set_aux_heat action", + "title": "Migration of ecobee set_aux_heat action", "fix_flow": { "step": { "confirm": { - "description": "The Ecobee `set_aux_heat` action has been migrated. A new `aux_heat_only` switch entity is available for each thermostat that supports a Heat Pump.\n\nUpdate any automations to use the new `aux_heat_only` switch entity. When this is done, fix this issue and restart Home Assistant.", - "title": "Disable legacy Ecobee set_aux_heat action" + "description": "The ecobee `set_aux_heat` action has been migrated. A new `aux_heat_only` switch entity is available for each thermostat that supports a Heat Pump.\n\nUpdate any automations to use the new `aux_heat_only` switch entity. When this is done, fix this issue and restart Home Assistant.", + "title": "Disable legacy ecobee set_aux_heat action" } } } From c6ac8780ca0a0c1d6f61c24260d91dee72e09090 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Mon, 7 Apr 2025 22:56:21 +0200 Subject: [PATCH 353/505] Fix kelvin parameter in light action specifications (#142456) --- homeassistant/components/light/services.yaml | 4 ++-- homeassistant/components/light/strings.json | 16 ++++++++-------- 2 files changed, 10 insertions(+), 10 deletions(-) diff --git a/homeassistant/components/light/services.yaml b/homeassistant/components/light/services.yaml index c59d9e22483..2cd5921d794 100644 --- a/homeassistant/components/light/services.yaml +++ b/homeassistant/components/light/services.yaml @@ -199,7 +199,7 @@ turn_on: example: "[255, 100, 100]" selector: color_rgb: - kelvin: &kelvin + color_temp_kelvin: &color_temp_kelvin filter: *color_temp_support selector: color_temp: @@ -316,7 +316,7 @@ toggle: fields: transition: *transition rgb_color: *rgb_color - kelvin: *kelvin + color_temp_kelvin: *color_temp_kelvin brightness_pct: *brightness_pct effect: *effect advanced_fields: diff --git a/homeassistant/components/light/strings.json b/homeassistant/components/light/strings.json index d4b709f65aa..7a53f2569e7 100644 --- a/homeassistant/components/light/strings.json +++ b/homeassistant/components/light/strings.json @@ -19,8 +19,8 @@ "field_flash_name": "Flash", "field_hs_color_description": "Color in hue/sat format. A list of two integers. Hue is 0-360 and Sat is 0-100.", "field_hs_color_name": "Hue/Sat color", - "field_kelvin_description": "Color temperature in Kelvin.", - "field_kelvin_name": "Color temperature", + "field_color_temp_kelvin_description": "Color temperature in Kelvin.", + "field_color_temp_kelvin_name": "Color temperature", "field_profile_description": "Name of a light profile to use.", "field_profile_name": "Profile", "field_rgb_color_description": "The color in RGB format. A list of three integers between 0 and 255 representing the values of red, green, and blue.", @@ -328,9 +328,9 @@ "name": "[%key:component::light::common::field_color_temp_name%]", "description": "[%key:component::light::common::field_color_temp_description%]" }, - "kelvin": { - "name": "[%key:component::light::common::field_kelvin_name%]", - "description": "[%key:component::light::common::field_kelvin_description%]" + "color_temp_kelvin": { + "name": "[%key:component::light::common::field_color_temp_kelvin_name%]", + "description": "[%key:component::light::common::field_color_temp_kelvin_description%]" }, "brightness": { "name": "[%key:component::light::common::field_brightness_name%]", @@ -426,9 +426,9 @@ "name": "[%key:component::light::common::field_color_temp_name%]", "description": "[%key:component::light::common::field_color_temp_description%]" }, - "kelvin": { - "name": "[%key:component::light::common::field_kelvin_name%]", - "description": "[%key:component::light::common::field_kelvin_description%]" + "color_temp_kelvin": { + "name": "[%key:component::light::common::field_color_temp_kelvin_name%]", + "description": "[%key:component::light::common::field_color_temp_kelvin_description%]" }, "brightness": { "name": "[%key:component::light::common::field_brightness_name%]", From c14380247b05020f110e5f0b3942e663d34b0657 Mon Sep 17 00:00:00 2001 From: Ivan Lopez Hernandez Date: Mon, 7 Apr 2025 21:20:54 -0700 Subject: [PATCH 354/505] Handle None on the response candidates in Google Generative AI (#142497) * Added type checking on the candidates list * Made error message a constant --- .../conversation.py | 14 +++++++-- .../test_conversation.py | 29 ++++++++++++++++++- 2 files changed, 39 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/google_generative_ai_conversation/conversation.py b/homeassistant/components/google_generative_ai_conversation/conversation.py index 7c19c5445a7..73a82b98664 100644 --- a/homeassistant/components/google_generative_ai_conversation/conversation.py +++ b/homeassistant/components/google_generative_ai_conversation/conversation.py @@ -55,6 +55,10 @@ from .const import ( # Max number of back and forth with the LLM to generate a response MAX_TOOL_ITERATIONS = 10 +ERROR_GETTING_RESPONSE = ( + "Sorry, I had a problem getting a response from Google Generative AI." +) + async def async_setup_entry( hass: HomeAssistant, @@ -429,6 +433,12 @@ class GoogleGenerativeAIConversationEntity( raise HomeAssistantError( f"The message got blocked due to content violations, reason: {chat_response.prompt_feedback.block_reason_message}" ) + if not chat_response.candidates: + LOGGER.error( + "No candidates found in the response: %s", + chat_response, + ) + raise HomeAssistantError(ERROR_GETTING_RESPONSE) except ( APIError, @@ -452,9 +462,7 @@ class GoogleGenerativeAIConversationEntity( response_parts = chat_response.candidates[0].content.parts if not response_parts: - raise HomeAssistantError( - "Sorry, I had a problem getting a response from Google Generative AI." - ) + raise HomeAssistantError(ERROR_GETTING_RESPONSE) content = " ".join( [part.text.strip() for part in response_parts if part.text] ) diff --git a/tests/components/google_generative_ai_conversation/test_conversation.py b/tests/components/google_generative_ai_conversation/test_conversation.py index 9c4ecc4f9a4..75cb308d5de 100644 --- a/tests/components/google_generative_ai_conversation/test_conversation.py +++ b/tests/components/google_generative_ai_conversation/test_conversation.py @@ -12,6 +12,7 @@ import voluptuous as vol from homeassistant.components import conversation from homeassistant.components.conversation import UserContent, async_get_chat_log, trace from homeassistant.components.google_generative_ai_conversation.conversation import ( + ERROR_GETTING_RESPONSE, _escape_decode, _format_schema, ) @@ -492,7 +493,33 @@ async def test_empty_response( assert result.response.response_type == intent.IntentResponseType.ERROR, result assert result.response.error_code == "unknown", result assert result.response.as_dict()["speech"]["plain"]["speech"] == ( - "Sorry, I had a problem getting a response from Google Generative AI." + ERROR_GETTING_RESPONSE + ) + + +@pytest.mark.usefixtures("mock_init_component") +async def test_none_response( + hass: HomeAssistant, mock_config_entry: MockConfigEntry +) -> None: + """Test empty response.""" + with patch("google.genai.chats.AsyncChats.create") as mock_create: + mock_chat = AsyncMock() + mock_create.return_value.send_message = mock_chat + chat_response = Mock(prompt_feedback=None) + mock_chat.return_value = chat_response + chat_response.candidates = None + result = await conversation.async_converse( + hass, + "hello", + None, + Context(), + agent_id="conversation.google_generative_ai_conversation", + ) + + assert result.response.response_type == intent.IntentResponseType.ERROR, result + assert result.response.error_code == "unknown", result + assert result.response.as_dict()["speech"]["plain"]["speech"] == ( + ERROR_GETTING_RESPONSE ) From 8dee5851d201dbde70632f2c05e7abecfec658d7 Mon Sep 17 00:00:00 2001 From: Sanjay Govind Date: Tue, 8 Apr 2025 17:50:36 +1200 Subject: [PATCH 355/505] Add jaraco.itertools license exception as the classifier was removed but no SPDX expression was added (#142439) --- script/licenses.py | 1 + 1 file changed, 1 insertion(+) diff --git a/script/licenses.py b/script/licenses.py index 448e9dd2a67..62e1845b911 100644 --- a/script/licenses.py +++ b/script/licenses.py @@ -190,6 +190,7 @@ EXCEPTIONS = { "enocean", # https://github.com/kipe/enocean/pull/142 "imutils", # https://github.com/PyImageSearch/imutils/pull/292 "iso4217", # Public domain + "jaraco.itertools", # MIT - https://github.com/jaraco/jaraco.itertools/issues/21 "kiwiki_client", # https://github.com/c7h/kiwiki_client/pull/6 "ld2410-ble", # https://github.com/930913/ld2410-ble/pull/7 "maxcube-api", # https://github.com/uebelack/python-maxcube-api/pull/48 From cb07e64b47efd54f8ccc9c5cdd0c53d57dd29653 Mon Sep 17 00:00:00 2001 From: Sanjay Govind Date: Tue, 8 Apr 2025 18:22:39 +1200 Subject: [PATCH 356/505] Add reconfig flow to bosch_alarm (#142451) * add reconfig flow to bosch_alarm * change translation string key * change translation string key * cleanup * cleanup * Update homeassistant/components/bosch_alarm/config_flow.py Co-authored-by: Josef Zweck * fix linting --------- Co-authored-by: Josef Zweck --- .../components/bosch_alarm/config_flow.py | 40 +++++++-- .../components/bosch_alarm/strings.json | 4 +- .../bosch_alarm/test_config_flow.py | 82 +++++++++++++++++++ 3 files changed, 120 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/bosch_alarm/config_flow.py b/homeassistant/components/bosch_alarm/config_flow.py index 4b1e3e511fc..9e664e49ca9 100644 --- a/homeassistant/components/bosch_alarm/config_flow.py +++ b/homeassistant/components/bosch_alarm/config_flow.py @@ -11,7 +11,12 @@ from typing import Any from bosch_alarm_mode2 import Panel import voluptuous as vol -from homeassistant.config_entries import ConfigFlow, ConfigFlowResult +from homeassistant.config_entries import ( + SOURCE_RECONFIGURE, + SOURCE_USER, + ConfigFlow, + ConfigFlowResult, +) from homeassistant.const import ( CONF_CODE, CONF_HOST, @@ -108,6 +113,13 @@ class BoschAlarmConfigFlow(ConfigFlow, domain=DOMAIN): else: self._data = user_input self._data[CONF_MODEL] = model + + if self.source == SOURCE_RECONFIGURE: + if ( + self._get_reconfigure_entry().data[CONF_MODEL] + != self._data[CONF_MODEL] + ): + return self.async_abort(reason="device_mismatch") return await self.async_step_auth() return self.async_show_form( step_id="user", @@ -117,6 +129,12 @@ class BoschAlarmConfigFlow(ConfigFlow, domain=DOMAIN): errors=errors, ) + async def async_step_reconfigure( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle the reconfigure step.""" + return await self.async_step_user() + async def async_step_auth( self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: @@ -154,10 +172,22 @@ class BoschAlarmConfigFlow(ConfigFlow, domain=DOMAIN): else: if serial_number: await self.async_set_unique_id(str(serial_number)) - self._abort_if_unique_id_configured() - else: - self._async_abort_entries_match({CONF_HOST: self._data[CONF_HOST]}) - return self.async_create_entry(title=f"Bosch {model}", data=self._data) + if self.source == SOURCE_USER: + if serial_number: + self._abort_if_unique_id_configured() + else: + self._async_abort_entries_match( + {CONF_HOST: self._data[CONF_HOST]} + ) + return self.async_create_entry( + title=f"Bosch {model}", data=self._data + ) + if serial_number: + self._abort_if_unique_id_mismatch(reason="device_mismatch") + return self.async_update_reload_and_abort( + self._get_reconfigure_entry(), + data=self._data, + ) return self.async_show_form( step_id="auth", diff --git a/homeassistant/components/bosch_alarm/strings.json b/homeassistant/components/bosch_alarm/strings.json index 3123c1697f3..aad55eb04b1 100644 --- a/homeassistant/components/bosch_alarm/strings.json +++ b/homeassistant/components/bosch_alarm/strings.json @@ -43,7 +43,9 @@ }, "abort": { "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", - "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]" + "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]", + "reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]", + "device_mismatch": "Please ensure you reconfigure against the same device." } }, "exceptions": { diff --git a/tests/components/bosch_alarm/test_config_flow.py b/tests/components/bosch_alarm/test_config_flow.py index 4a1c9dad3ea..9e79d1c1f5f 100644 --- a/tests/components/bosch_alarm/test_config_flow.py +++ b/tests/components/bosch_alarm/test_config_flow.py @@ -283,3 +283,85 @@ async def test_reauth_flow_error( assert result["reason"] == "reauth_successful" compare = {**mock_config_entry.data, **config_flow_data} assert compare == mock_config_entry.data + + +async def test_reconfig_flow( + hass: HomeAssistant, + mock_setup_entry: AsyncMock, + mock_config_entry: MockConfigEntry, + mock_panel: AsyncMock, + model_name: str, + serial_number: str, + config_flow_data: dict[str, Any], +) -> None: + """Test reconfig auth.""" + await setup_integration(hass, mock_config_entry) + + config_flow_data = {k: f"{v}2" for k, v in config_flow_data.items()} + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={ + "source": config_entries.SOURCE_RECONFIGURE, + "entry_id": mock_config_entry.entry_id, + }, + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + assert result["errors"] == {} + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_HOST: "1.1.1.1", CONF_PORT: 7700}, + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "auth" + assert result["errors"] == {} + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + config_flow_data, + ) + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reconfigure_successful" + assert mock_config_entry.data == { + CONF_HOST: "1.1.1.1", + CONF_PORT: 7700, + CONF_MODEL: model_name, + **config_flow_data, + } + + +@pytest.mark.parametrize("model", ["b5512"]) +async def test_reconfig_flow_incorrect_model( + hass: HomeAssistant, + mock_setup_entry: AsyncMock, + mock_config_entry: MockConfigEntry, + mock_panel: AsyncMock, + model_name: str, + serial_number: str, + config_flow_data: dict[str, Any], +) -> None: + """Test reconfig fails with a different device.""" + await setup_integration(hass, mock_config_entry) + + config_flow_data = {k: f"{v}2" for k, v in config_flow_data.items()} + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={ + "source": config_entries.SOURCE_RECONFIGURE, + "entry_id": mock_config_entry.entry_id, + }, + ) + + mock_panel.model = "Solution 3000" + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + assert result["errors"] == {} + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_HOST: "0.0.0.0", CONF_PORT: 7700}, + ) + + await hass.async_block_till_done() + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "device_mismatch" From dacc4c230dcac5c03ac907e67b8ef71be416e78a Mon Sep 17 00:00:00 2001 From: Martin Hjelmare Date: Tue, 8 Apr 2025 08:30:43 +0200 Subject: [PATCH 357/505] Add more Z-Wave USB discovery (#142460) --- .../components/zwave_js/config_flow.py | 25 ++++++++------- .../components/zwave_js/manifest.json | 7 ++++ homeassistant/generated/usb.py | 7 ++++ tests/components/zwave_js/test_config_flow.py | 32 +++++++++++++++++-- 4 files changed, 57 insertions(+), 14 deletions(-) diff --git a/homeassistant/components/zwave_js/config_flow.py b/homeassistant/components/zwave_js/config_flow.py index d95f3208e17..20ebe94c00e 100644 --- a/homeassistant/components/zwave_js/config_flow.py +++ b/homeassistant/components/zwave_js/config_flow.py @@ -420,17 +420,20 @@ class ZWaveJSConfigFlow(BaseZwaveJSFlow, ConfigFlow, domain=DOMAIN): self._abort_if_unique_id_configured() dev_path = discovery_info.device self.usb_path = dev_path - self._title = usb.human_readable_device_name( - dev_path, - serial_number, - manufacturer, - description, - vid, - pid, - ) - self.context["title_placeholders"] = { - CONF_NAME: self._title.split(" - ")[0].strip() - } + if manufacturer == "Nabu Casa" and description == "ZWA-2 - Nabu Casa ZWA-2": + title = "Home Assistant Connect ZWA-2" + else: + human_name = usb.human_readable_device_name( + dev_path, + serial_number, + manufacturer, + description, + vid, + pid, + ) + title = human_name.split(" - ")[0].strip() + self.context["title_placeholders"] = {CONF_NAME: title} + self._title = title return await self.async_step_usb_confirm() async def async_step_usb_confirm( diff --git a/homeassistant/components/zwave_js/manifest.json b/homeassistant/components/zwave_js/manifest.json index 7e8b473922f..6f415ce257d 100644 --- a/homeassistant/components/zwave_js/manifest.json +++ b/homeassistant/components/zwave_js/manifest.json @@ -21,6 +21,13 @@ "pid": "8A2A", "description": "*z-wave*", "known_devices": ["Nortek HUSBZB-1"] + }, + { + "vid": "303A", + "pid": "4001", + "description": "*nabu casa zwa-2*", + "manufacturer": "nabu casa", + "known_devices": ["Nabu Casa Connect ZWA-2"] } ], "zeroconf": ["_zwave-js-server._tcp.local."] diff --git a/homeassistant/generated/usb.py b/homeassistant/generated/usb.py index e66a5861d18..8aea15df283 100644 --- a/homeassistant/generated/usb.py +++ b/homeassistant/generated/usb.py @@ -148,4 +148,11 @@ USB = [ "pid": "8A2A", "vid": "10C4", }, + { + "description": "*nabu casa zwa-2*", + "domain": "zwave_js", + "manufacturer": "nabu casa", + "pid": "4001", + "vid": "303A", + }, ] diff --git a/tests/components/zwave_js/test_config_flow.py b/tests/components/zwave_js/test_config_flow.py index e7239c23de6..f62ae9c740b 100644 --- a/tests/components/zwave_js/test_config_flow.py +++ b/tests/components/zwave_js/test_config_flow.py @@ -556,6 +556,28 @@ async def test_abort_hassio_discovery_for_other_addon( assert result2["reason"] == "not_zwave_js_addon" +@pytest.mark.parametrize( + ("usb_discovery_info", "device", "discovery_name"), + [ + ( + USB_DISCOVERY_INFO, + USB_DISCOVERY_INFO.device, + "zwave radio", + ), + ( + UsbServiceInfo( + device="/dev/zwa2", + pid="303A", + vid="4001", + serial_number="1234", + description="ZWA-2 - Nabu Casa ZWA-2", + manufacturer="Nabu Casa", + ), + "/dev/zwa2", + "Home Assistant Connect ZWA-2", + ), + ], +) @pytest.mark.parametrize( "discovery_info", [ @@ -578,15 +600,19 @@ async def test_usb_discovery( get_addon_discovery_info, set_addon_options, start_addon, + usb_discovery_info: UsbServiceInfo, + device: str, + discovery_name: str, ) -> None: """Test usb discovery success path.""" result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USB}, - data=USB_DISCOVERY_INFO, + data=usb_discovery_info, ) assert result["type"] is FlowResultType.FORM assert result["step_id"] == "usb_confirm" + assert result["description_placeholders"] == {"name": discovery_name} result = await hass.config_entries.flow.async_configure(result["flow_id"], {}) @@ -619,7 +645,7 @@ async def test_usb_discovery( "core_zwave_js", AddonsOptions( config={ - "device": USB_DISCOVERY_INFO.device, + "device": device, "s0_legacy_key": "new123", "s2_access_control_key": "new456", "s2_authenticated_key": "new789", @@ -652,7 +678,7 @@ async def test_usb_discovery( assert result["title"] == TITLE assert result["data"] == { "url": "ws://host1:3001", - "usb_path": USB_DISCOVERY_INFO.device, + "usb_path": device, "s0_legacy_key": "new123", "s2_access_control_key": "new456", "s2_authenticated_key": "new789", From 553091e95eca1b134db9af6ed3e85f3458dd89e9 Mon Sep 17 00:00:00 2001 From: John Hillery <34005807+jrhillery@users.noreply.github.com> Date: Tue, 8 Apr 2025 02:57:53 -0400 Subject: [PATCH 358/505] Bump nexia to 2.7.0 (#142429) Co-authored-by: J. Nick Koston --- homeassistant/components/nexia/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/nexia/manifest.json b/homeassistant/components/nexia/manifest.json index e7ab63d4712..e8a1b53cc08 100644 --- a/homeassistant/components/nexia/manifest.json +++ b/homeassistant/components/nexia/manifest.json @@ -12,5 +12,5 @@ "documentation": "https://www.home-assistant.io/integrations/nexia", "iot_class": "cloud_polling", "loggers": ["nexia"], - "requirements": ["nexia==2.4.0"] + "requirements": ["nexia==2.7.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index 658b20f6245..07969d90583 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1489,7 +1489,7 @@ nettigo-air-monitor==4.1.0 neurio==0.3.1 # homeassistant.components.nexia -nexia==2.4.0 +nexia==2.7.0 # homeassistant.components.nextcloud nextcloudmonitor==1.5.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 7a3e632559c..b788c105c77 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1253,7 +1253,7 @@ netmap==0.7.0.2 nettigo-air-monitor==4.1.0 # homeassistant.components.nexia -nexia==2.4.0 +nexia==2.7.0 # homeassistant.components.nextcloud nextcloudmonitor==1.5.1 From 480d645650b162b192fa05c18c1ec7534c675c47 Mon Sep 17 00:00:00 2001 From: Maciej Bieniek Date: Tue, 8 Apr 2025 08:58:08 +0200 Subject: [PATCH 359/505] Bump aioshelly to version 13.4.1 (#142477) * Bymp aioshelly to 13.4.1 * Catch InvalidHostError --------- Co-authored-by: J. Nick Koston --- homeassistant/components/shelly/config_flow.py | 3 +++ homeassistant/components/shelly/manifest.json | 2 +- homeassistant/components/shelly/strings.json | 1 + requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/shelly/test_config_flow.py | 2 ++ 6 files changed, 9 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/shelly/config_flow.py b/homeassistant/components/shelly/config_flow.py index 200a88ea24c..6e41df282ef 100644 --- a/homeassistant/components/shelly/config_flow.py +++ b/homeassistant/components/shelly/config_flow.py @@ -12,6 +12,7 @@ from aioshelly.exceptions import ( CustomPortNotSupported, DeviceConnectionError, InvalidAuthError, + InvalidHostError, MacAddressMismatchError, ) from aioshelly.rpc_device import RpcDevice @@ -157,6 +158,8 @@ class ShellyConfigFlow(ConfigFlow, domain=DOMAIN): self.info = await self._async_get_info(host, port) except DeviceConnectionError: errors["base"] = "cannot_connect" + except InvalidHostError: + errors["base"] = "invalid_host" except Exception: # noqa: BLE001 LOGGER.exception("Unexpected exception") errors["base"] = "unknown" diff --git a/homeassistant/components/shelly/manifest.json b/homeassistant/components/shelly/manifest.json index e863720e476..19ccd1354a7 100644 --- a/homeassistant/components/shelly/manifest.json +++ b/homeassistant/components/shelly/manifest.json @@ -8,7 +8,7 @@ "integration_type": "device", "iot_class": "local_push", "loggers": ["aioshelly"], - "requirements": ["aioshelly==13.4.0"], + "requirements": ["aioshelly==13.4.1"], "zeroconf": [ { "type": "_http._tcp.local.", diff --git a/homeassistant/components/shelly/strings.json b/homeassistant/components/shelly/strings.json index 3465891dc68..43c709f4641 100644 --- a/homeassistant/components/shelly/strings.json +++ b/homeassistant/components/shelly/strings.json @@ -51,6 +51,7 @@ "error": { "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", + "invalid_host": "[%key:common::config_flow::error::invalid_host%]", "unknown": "[%key:common::config_flow::error::unknown%]", "firmware_not_fully_provisioned": "Device not fully provisioned. Please contact Shelly support", "custom_port_not_supported": "Gen1 device does not support custom port.", diff --git a/requirements_all.txt b/requirements_all.txt index 07969d90583..64d50112ce2 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -371,7 +371,7 @@ aioruuvigateway==0.1.0 aiosenz==1.0.0 # homeassistant.components.shelly -aioshelly==13.4.0 +aioshelly==13.4.1 # homeassistant.components.skybell aioskybell==22.7.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index b788c105c77..e18403bd106 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -353,7 +353,7 @@ aioruuvigateway==0.1.0 aiosenz==1.0.0 # homeassistant.components.shelly -aioshelly==13.4.0 +aioshelly==13.4.1 # homeassistant.components.skybell aioskybell==22.7.0 diff --git a/tests/components/shelly/test_config_flow.py b/tests/components/shelly/test_config_flow.py index fffffc21cae..60883ebf5bd 100644 --- a/tests/components/shelly/test_config_flow.py +++ b/tests/components/shelly/test_config_flow.py @@ -11,6 +11,7 @@ from aioshelly.exceptions import ( CustomPortNotSupported, DeviceConnectionError, InvalidAuthError, + InvalidHostError, ) import pytest @@ -308,6 +309,7 @@ async def test_form_auth( ("exc", "base_error"), [ (DeviceConnectionError, "cannot_connect"), + (InvalidHostError, "invalid_host"), (ValueError, "unknown"), ], ) From 89c928870641fab5894b81984d883976fd83713e Mon Sep 17 00:00:00 2001 From: tronikos Date: Mon, 7 Apr 2025 23:58:43 -0700 Subject: [PATCH 360/505] Bump opower to 0.11.1 (#142395) * Bump opower to 0.10.1 * opower==0.11.0 * opower==0.11.1 --------- Co-authored-by: J. Nick Koston --- homeassistant/components/opower/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/opower/manifest.json b/homeassistant/components/opower/manifest.json index e691d01257a..2cc942363cf 100644 --- a/homeassistant/components/opower/manifest.json +++ b/homeassistant/components/opower/manifest.json @@ -7,5 +7,5 @@ "documentation": "https://www.home-assistant.io/integrations/opower", "iot_class": "cloud_polling", "loggers": ["opower"], - "requirements": ["opower==0.10.0"] + "requirements": ["opower==0.11.1"] } diff --git a/requirements_all.txt b/requirements_all.txt index 64d50112ce2..f84a634c9ac 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1607,7 +1607,7 @@ openwrt-luci-rpc==1.1.17 openwrt-ubus-rpc==0.0.2 # homeassistant.components.opower -opower==0.10.0 +opower==0.11.1 # homeassistant.components.oralb oralb-ble==0.17.6 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index e18403bd106..470c0a25757 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1344,7 +1344,7 @@ openhomedevice==2.2.0 openwebifpy==4.3.1 # homeassistant.components.opower -opower==0.10.0 +opower==0.11.1 # homeassistant.components.oralb oralb-ble==0.17.6 From 323c459442dd17dc042935f8d7c4dc711c537328 Mon Sep 17 00:00:00 2001 From: Sanjay Govind Date: Tue, 8 Apr 2025 18:58:58 +1200 Subject: [PATCH 361/505] bump bosch_alarm_mode2 to 0.4.6 (#142436) * bump bosch_alarm_mode2 to 0.4.5 * bump bosch_alarm_mode2 to 0.4.6 --------- Co-authored-by: Joost Lekkerkerker --- homeassistant/components/bosch_alarm/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/bosch_alarm/manifest.json b/homeassistant/components/bosch_alarm/manifest.json index a54ace71782..eefcc400ee7 100644 --- a/homeassistant/components/bosch_alarm/manifest.json +++ b/homeassistant/components/bosch_alarm/manifest.json @@ -7,5 +7,5 @@ "integration_type": "device", "iot_class": "local_push", "quality_scale": "bronze", - "requirements": ["bosch-alarm-mode2==0.4.3"] + "requirements": ["bosch-alarm-mode2==0.4.6"] } diff --git a/requirements_all.txt b/requirements_all.txt index f84a634c9ac..e6f4e98e168 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -645,7 +645,7 @@ bluetooth-data-tools==1.27.0 bond-async==0.2.1 # homeassistant.components.bosch_alarm -bosch-alarm-mode2==0.4.3 +bosch-alarm-mode2==0.4.6 # homeassistant.components.bosch_shc boschshcpy==0.2.91 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 470c0a25757..854e397e5ca 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -570,7 +570,7 @@ bluetooth-data-tools==1.27.0 bond-async==0.2.1 # homeassistant.components.bosch_alarm -bosch-alarm-mode2==0.4.3 +bosch-alarm-mode2==0.4.6 # homeassistant.components.bosch_shc boschshcpy==0.2.91 From 08304ca5f37e0f42bf116e3e4ab0f0fab62bb2ca Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 7 Apr 2025 21:59:39 -1000 Subject: [PATCH 362/505] Small improvements to the repairs testing helpers (#142511) - Fix incorrect type on flow_id and issue_id - Show the error when something goes wrong --- tests/components/repairs/__init__.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/tests/components/repairs/__init__.py b/tests/components/repairs/__init__.py index e787d657e5c..7d5e4a43cd8 100644 --- a/tests/components/repairs/__init__.py +++ b/tests/components/repairs/__init__.py @@ -42,20 +42,20 @@ async def get_repairs( async def start_repair_fix_flow( - client: TestClient, handler: str, issue_id: int + client: TestClient, handler: str, issue_id: str ) -> dict[str, Any]: """Start a flow from an issue.""" url = RepairsFlowIndexView.url resp = await client.post(url, json={"handler": handler, "issue_id": issue_id}) - assert resp.status == HTTPStatus.OK + assert resp.status == HTTPStatus.OK, f"Error: {resp.status}, {await resp.text()}" return await resp.json() async def process_repair_fix_flow( - client: TestClient, flow_id: int, json: dict[str, Any] | None = None + client: TestClient, flow_id: str, json: dict[str, Any] | None = None ) -> dict[str, Any]: """Return the repairs list of issues.""" url = RepairsFlowResourceView.url.format(flow_id=flow_id) resp = await client.post(url, json=json) - assert resp.status == HTTPStatus.OK + assert resp.status == HTTPStatus.OK, f"Error: {resp.status}, {await resp.text()}" return await resp.json() From 167e7668116df531eccfdfb217ab4b5f5603b62c Mon Sep 17 00:00:00 2001 From: tronikos Date: Tue, 8 Apr 2025 01:10:23 -0700 Subject: [PATCH 363/505] Add translations for connection closed errors in Android TV Remote (#142523) --- homeassistant/components/androidtv_remote/entity.py | 4 ++-- homeassistant/components/androidtv_remote/media_player.py | 4 ++-- homeassistant/components/androidtv_remote/strings.json | 5 +++++ tests/components/androidtv_remote/test_media_player.py | 8 ++++++-- tests/components/androidtv_remote/test_remote.py | 8 ++++++-- 5 files changed, 21 insertions(+), 8 deletions(-) diff --git a/homeassistant/components/androidtv_remote/entity.py b/homeassistant/components/androidtv_remote/entity.py index 44b2d2a5f20..bf146a11e13 100644 --- a/homeassistant/components/androidtv_remote/entity.py +++ b/homeassistant/components/androidtv_remote/entity.py @@ -73,7 +73,7 @@ class AndroidTVRemoteBaseEntity(Entity): self._api.send_key_command(key_code, direction) except ConnectionClosed as exc: raise HomeAssistantError( - "Connection to Android TV device is closed" + translation_domain=DOMAIN, translation_key="connection_closed" ) from exc def _send_launch_app_command(self, app_link: str) -> None: @@ -85,5 +85,5 @@ class AndroidTVRemoteBaseEntity(Entity): self._api.send_launch_app_command(app_link) except ConnectionClosed as exc: raise HomeAssistantError( - "Connection to Android TV device is closed" + translation_domain=DOMAIN, translation_key="connection_closed" ) from exc diff --git a/homeassistant/components/androidtv_remote/media_player.py b/homeassistant/components/androidtv_remote/media_player.py index 3d3a97092bc..5bc205b32df 100644 --- a/homeassistant/components/androidtv_remote/media_player.py +++ b/homeassistant/components/androidtv_remote/media_player.py @@ -21,7 +21,7 @@ from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import AndroidTVRemoteConfigEntry -from .const import CONF_APP_ICON, CONF_APP_NAME +from .const import CONF_APP_ICON, CONF_APP_NAME, DOMAIN from .entity import AndroidTVRemoteBaseEntity PARALLEL_UPDATES = 0 @@ -233,5 +233,5 @@ class AndroidTVRemoteMediaPlayerEntity(AndroidTVRemoteBaseEntity, MediaPlayerEnt await asyncio.sleep(delay_secs) except ConnectionClosed as exc: raise HomeAssistantError( - "Connection to Android TV device is closed" + translation_domain=DOMAIN, translation_key="connection_closed" ) from exc diff --git a/homeassistant/components/androidtv_remote/strings.json b/homeassistant/components/androidtv_remote/strings.json index e41cbcf9a76..106cac3a63d 100644 --- a/homeassistant/components/androidtv_remote/strings.json +++ b/homeassistant/components/androidtv_remote/strings.json @@ -54,5 +54,10 @@ } } } + }, + "exceptions": { + "connection_closed": { + "message": "Connection to the Android TV device is closed" + } } } diff --git a/tests/components/androidtv_remote/test_media_player.py b/tests/components/androidtv_remote/test_media_player.py index e292a5b273f..0ca8a3045fb 100644 --- a/tests/components/androidtv_remote/test_media_player.py +++ b/tests/components/androidtv_remote/test_media_player.py @@ -391,7 +391,9 @@ async def test_media_player_connection_closed( assert mock_config_entry.state is ConfigEntryState.LOADED mock_api.send_key_command.side_effect = ConnectionClosed() - with pytest.raises(HomeAssistantError): + with pytest.raises( + HomeAssistantError, match="Connection to the Android TV device is closed" + ): await hass.services.async_call( "media_player", "media_pause", @@ -400,7 +402,9 @@ async def test_media_player_connection_closed( ) mock_api.send_launch_app_command.side_effect = ConnectionClosed() - with pytest.raises(HomeAssistantError): + with pytest.raises( + HomeAssistantError, match="Connection to the Android TV device is closed" + ): await hass.services.async_call( "media_player", "play_media", diff --git a/tests/components/androidtv_remote/test_remote.py b/tests/components/androidtv_remote/test_remote.py index b3c3ce1c283..9bd86bb3d85 100644 --- a/tests/components/androidtv_remote/test_remote.py +++ b/tests/components/androidtv_remote/test_remote.py @@ -183,7 +183,9 @@ async def test_remote_connection_closed( assert mock_config_entry.state is ConfigEntryState.LOADED mock_api.send_key_command.side_effect = ConnectionClosed() - with pytest.raises(HomeAssistantError): + with pytest.raises( + HomeAssistantError, match="Connection to the Android TV device is closed" + ): await hass.services.async_call( "remote", "send_command", @@ -197,7 +199,9 @@ async def test_remote_connection_closed( assert mock_api.send_key_command.mock_calls == [call("DPAD_LEFT", "SHORT")] mock_api.send_launch_app_command.side_effect = ConnectionClosed() - with pytest.raises(HomeAssistantError): + with pytest.raises( + HomeAssistantError, match="Connection to the Android TV device is closed" + ): await hass.services.async_call( "remote", "turn_on", From 26663756a5d3a601e221ac63ce8354e106470d35 Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Tue, 8 Apr 2025 12:00:05 +0200 Subject: [PATCH 364/505] Allow max to be equal with min for mqtt number config validation (#142522) --- homeassistant/components/mqtt/number.py | 4 +- tests/components/mqtt/test_number.py | 59 ++++++++++++++++++------- 2 files changed, 44 insertions(+), 19 deletions(-) diff --git a/homeassistant/components/mqtt/number.py b/homeassistant/components/mqtt/number.py index 5ee93cfba07..c3cc31bf04f 100644 --- a/homeassistant/components/mqtt/number.py +++ b/homeassistant/components/mqtt/number.py @@ -70,8 +70,8 @@ MQTT_NUMBER_ATTRIBUTES_BLOCKED = frozenset( def validate_config(config: ConfigType) -> ConfigType: """Validate that the configuration is valid, throws if it isn't.""" - if config[CONF_MIN] >= config[CONF_MAX]: - raise vol.Invalid(f"'{CONF_MAX}' must be > '{CONF_MIN}'") + if config[CONF_MIN] > config[CONF_MAX]: + raise vol.Invalid(f"{CONF_MAX} must be >= {CONF_MIN}") return config diff --git a/tests/components/mqtt/test_number.py b/tests/components/mqtt/test_number.py index f391236aca4..fd54e5f0643 100644 --- a/tests/components/mqtt/test_number.py +++ b/tests/components/mqtt/test_number.py @@ -835,32 +835,57 @@ async def test_entity_debug_info_message( @pytest.mark.parametrize( - "hass_config", + ("hass_config", "min_number", "max_number", "step"), [ - { - mqtt.DOMAIN: { - number.DOMAIN: { - "state_topic": "test/state_number", - "command_topic": "test/cmd_number", - "name": "Test Number", - "min": 5, - "max": 110, - "step": 20, + ( + { + mqtt.DOMAIN: { + number.DOMAIN: { + "state_topic": "test/state_number", + "command_topic": "test/cmd_number", + "name": "Test Number", + "min": 5, + "max": 110, + "step": 20, + } } - } - } + }, + 5, + 110, + 20, + ), + ( + { + mqtt.DOMAIN: { + number.DOMAIN: { + "state_topic": "test/state_number", + "command_topic": "test/cmd_number", + "name": "Test Number", + "min": 100, + "max": 100, + } + } + }, + 100, + 100, + 1, + ), ], ) async def test_min_max_step_attributes( - hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator + hass: HomeAssistant, + mqtt_mock_entry: MqttMockHAClientGenerator, + min_number: float, + max_number: float, + step: float, ) -> None: """Test min/max/step attributes.""" await mqtt_mock_entry() state = hass.states.get("number.test_number") - assert state.attributes.get(ATTR_MIN) == 5 - assert state.attributes.get(ATTR_MAX) == 110 - assert state.attributes.get(ATTR_STEP) == 20 + assert state.attributes.get(ATTR_MIN) == min_number + assert state.attributes.get(ATTR_MAX) == max_number + assert state.attributes.get(ATTR_STEP) == step @pytest.mark.parametrize( @@ -885,7 +910,7 @@ async def test_invalid_min_max_attributes( ) -> None: """Test invalid min/max attributes.""" assert await mqtt_mock_entry() - assert f"'{CONF_MAX}' must be > '{CONF_MIN}'" in caplog.text + assert f"{CONF_MAX} must be >= {CONF_MIN}" in caplog.text @pytest.mark.parametrize( From 36192ebc3a6c063a72f0a75925c054acbba2ee32 Mon Sep 17 00:00:00 2001 From: "Glenn Vandeuren (aka Iondependent)" Date: Tue, 8 Apr 2025 12:03:50 +0200 Subject: [PATCH 365/505] Add niko_home_control quality scale (#134000) * Add quality scale * Update quality_scale.yaml * Update quality_scale.yaml * Apply suggestions from code review --------- Co-authored-by: Joost Lekkerkerker --- .../niko_home_control/quality_scale.yaml | 84 +++++++++++++++++++ script/hassfest/quality_scale.py | 1 - 2 files changed, 84 insertions(+), 1 deletion(-) create mode 100644 homeassistant/components/niko_home_control/quality_scale.yaml diff --git a/homeassistant/components/niko_home_control/quality_scale.yaml b/homeassistant/components/niko_home_control/quality_scale.yaml new file mode 100644 index 00000000000..390efb8fc90 --- /dev/null +++ b/homeassistant/components/niko_home_control/quality_scale.yaml @@ -0,0 +1,84 @@ +rules: + # Bronze + action-setup: + status: exempt + comment: | + This integration does not provide additional actions. + appropriate-polling: + status: exempt + comment: | + This integration does not require polling. + brands: done + common-modules: done + config-flow-test-coverage: done + config-flow: + status: todo + comment: | + Be more specific in the config flow with catching exceptions. + dependency-transparency: done + docs-actions: + status: exempt + comment: | + This integration does not provide additional actions. + docs-high-level-description: todo + docs-installation-instructions: done + docs-removal-instructions: todo + entity-event-setup: done + entity-unique-id: done + has-entity-name: done + runtime-data: done + test-before-configure: done + test-before-setup: done + unique-config-entry: done + + # Silver + action-exceptions: + status: exempt + comment: | + This integration does not provide additional actions. + config-entry-unloading: done + docs-configuration-parameters: + status: exempt + comment: No options to configure + docs-installation-parameters: + status: exempt + comment: No options to configure + entity-unavailable: todo + integration-owner: done + log-when-unavailable: todo + parallel-updates: todo + reauthentication-flow: + status: exempt + comment: | + This integration does not require authentication. + test-coverage: todo + # Gold + devices: done + diagnostics: todo + discovery-update-info: todo + discovery: todo + docs-data-update: todo + docs-examples: todo + docs-known-limitations: todo + docs-supported-devices: todo + docs-supported-functions: todo + docs-troubleshooting: todo + docs-use-cases: todo + dynamic-devices: todo + entity-category: todo + entity-device-class: done + entity-disabled-by-default: todo + entity-translations: todo + exception-translations: todo + icon-translations: todo + reconfiguration-flow: todo + repair-issues: done + stale-devices: todo + + # Platinum + async-dependency: done + inject-websession: + status: exempt + comment: | + This integration does not require a websession. + strict-typing: todo diff --git a/script/hassfest/quality_scale.py b/script/hassfest/quality_scale.py index 49da98f5872..5c33743699c 100644 --- a/script/hassfest/quality_scale.py +++ b/script/hassfest/quality_scale.py @@ -703,7 +703,6 @@ INTEGRATIONS_WITHOUT_QUALITY_SCALE_FILE = [ "nibe_heatpump", "nice_go", "nightscout", - "niko_home_control", "nilu", "nina", "nissan_leaf", From 894cc7cc4dd95ff846278c407069892c345406ca Mon Sep 17 00:00:00 2001 From: Sanjay Govind Date: Tue, 8 Apr 2025 23:55:43 +1200 Subject: [PATCH 366/505] Add sensor platform to bosch_alarm (#142151) * add sensor platform to bosch_alarm * add icon translations for sensors * translate entity names * translate entity names * translate entity names * update snapshots * translate ready to arm sensor * translate ready to arm sensor * update tests * update translations * remove history sensor, we will replace it with an events sensor later * fix tests * fix tests * fix tests * update tests * fix sensor links * only call async_add_entities once * convert area alarms to sensors based on type * add sensor for alarms * add icons * cleanup area sensor * add available * loop over dict * use entity description * use entity description * clean up entity descriptions * observe_alarms and observe_ready * refactor alarm_control_panel to use base entity * remove more old sensors * add unit of measurement * update test snapshots * use correct observer --- .../components/bosch_alarm/__init__.py | 2 +- .../bosch_alarm/alarm_control_panel.py | 37 +---- .../components/bosch_alarm/entity.py | 88 +++++++++++ .../components/bosch_alarm/icons.json | 9 ++ .../components/bosch_alarm/quality_scale.yaml | 4 +- .../components/bosch_alarm/sensor.py | 86 +++++++++++ .../components/bosch_alarm/strings.json | 8 + tests/components/bosch_alarm/conftest.py | 3 +- .../snapshots/test_diagnostics.ambr | 9 +- .../bosch_alarm/snapshots/test_sensor.ambr | 145 ++++++++++++++++++ tests/components/bosch_alarm/test_sensor.py | 52 +++++++ 11 files changed, 400 insertions(+), 43 deletions(-) create mode 100644 homeassistant/components/bosch_alarm/entity.py create mode 100644 homeassistant/components/bosch_alarm/icons.json create mode 100644 homeassistant/components/bosch_alarm/sensor.py create mode 100644 tests/components/bosch_alarm/snapshots/test_sensor.ambr create mode 100644 tests/components/bosch_alarm/test_sensor.py diff --git a/homeassistant/components/bosch_alarm/__init__.py b/homeassistant/components/bosch_alarm/__init__.py index ddd736b47c0..602c801701d 100644 --- a/homeassistant/components/bosch_alarm/__init__.py +++ b/homeassistant/components/bosch_alarm/__init__.py @@ -14,7 +14,7 @@ from homeassistant.helpers import device_registry as dr from .const import CONF_INSTALLER_CODE, CONF_USER_CODE, DOMAIN -PLATFORMS: list[Platform] = [Platform.ALARM_CONTROL_PANEL] +PLATFORMS: list[Platform] = [Platform.ALARM_CONTROL_PANEL, Platform.SENSOR] type BoschAlarmConfigEntry = ConfigEntry[Panel] diff --git a/homeassistant/components/bosch_alarm/alarm_control_panel.py b/homeassistant/components/bosch_alarm/alarm_control_panel.py index a1d8a7b90f4..2854298f815 100644 --- a/homeassistant/components/bosch_alarm/alarm_control_panel.py +++ b/homeassistant/components/bosch_alarm/alarm_control_panel.py @@ -10,11 +10,10 @@ from homeassistant.components.alarm_control_panel import ( AlarmControlPanelState, ) from homeassistant.core import HomeAssistant -from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import BoschAlarmConfigEntry -from .const import DOMAIN +from .entity import BoschAlarmAreaEntity async def async_setup_entry( @@ -35,7 +34,7 @@ async def async_setup_entry( ) -class AreaAlarmControlPanel(AlarmControlPanelEntity): +class AreaAlarmControlPanel(BoschAlarmAreaEntity, AlarmControlPanelEntity): """An alarm control panel entity for a bosch alarm panel.""" _attr_has_entity_name = True @@ -48,19 +47,8 @@ class AreaAlarmControlPanel(AlarmControlPanelEntity): def __init__(self, panel: Panel, area_id: int, unique_id: str) -> None: """Initialise a Bosch Alarm control panel entity.""" - self.panel = panel - self._area = panel.areas[area_id] - self._area_id = area_id - self._attr_unique_id = f"{unique_id}_area_{area_id}" - self._attr_device_info = DeviceInfo( - identifiers={(DOMAIN, self._attr_unique_id)}, - name=self._area.name, - manufacturer="Bosch Security Systems", - via_device=( - DOMAIN, - unique_id, - ), - ) + super().__init__(panel, area_id, unique_id, False, False, True) + self._attr_unique_id = self._area_unique_id @property def alarm_state(self) -> AlarmControlPanelState | None: @@ -90,20 +78,3 @@ class AreaAlarmControlPanel(AlarmControlPanelEntity): async def async_alarm_arm_away(self, code: str | None = None) -> None: """Send arm away command.""" await self.panel.area_arm_all(self._area_id) - - @property - def available(self) -> bool: - """Return True if entity is available.""" - return self.panel.connection_status() - - async def async_added_to_hass(self) -> None: - """Run when entity attached to hass.""" - await super().async_added_to_hass() - self._area.status_observer.attach(self.schedule_update_ha_state) - self.panel.connection_status_observer.attach(self.schedule_update_ha_state) - - async def async_will_remove_from_hass(self) -> None: - """Run when entity removed from hass.""" - await super().async_will_remove_from_hass() - self._area.status_observer.detach(self.schedule_update_ha_state) - self.panel.connection_status_observer.detach(self.schedule_update_ha_state) diff --git a/homeassistant/components/bosch_alarm/entity.py b/homeassistant/components/bosch_alarm/entity.py new file mode 100644 index 00000000000..f74634125c4 --- /dev/null +++ b/homeassistant/components/bosch_alarm/entity.py @@ -0,0 +1,88 @@ +"""Support for Bosch Alarm Panel History as a sensor.""" + +from __future__ import annotations + +from bosch_alarm_mode2 import Panel + +from homeassistant.components.sensor import Entity +from homeassistant.helpers.device_registry import DeviceInfo + +from .const import DOMAIN + +PARALLEL_UPDATES = 0 + + +class BoschAlarmEntity(Entity): + """A base entity for a bosch alarm panel.""" + + _attr_has_entity_name = True + + def __init__(self, panel: Panel, unique_id: str) -> None: + """Set up a entity for a bosch alarm panel.""" + self.panel = panel + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, unique_id)}, + name=f"Bosch {panel.model}", + manufacturer="Bosch Security Systems", + ) + + @property + def available(self) -> bool: + """Return True if entity is available.""" + return self.panel.connection_status() + + async def async_added_to_hass(self) -> None: + """Observe state changes.""" + self.panel.connection_status_observer.attach(self.schedule_update_ha_state) + + async def async_will_remove_from_hass(self) -> None: + """Stop observing state changes.""" + self.panel.connection_status_observer.detach(self.schedule_update_ha_state) + + +class BoschAlarmAreaEntity(BoschAlarmEntity): + """A base entity for area related entities within a bosch alarm panel.""" + + def __init__( + self, + panel: Panel, + area_id: int, + unique_id: str, + observe_alarms: bool, + observe_ready: bool, + observe_status: bool, + ) -> None: + """Set up a area related entity for a bosch alarm panel.""" + super().__init__(panel, unique_id) + self._area_id = area_id + self._area_unique_id = f"{unique_id}_area_{area_id}" + self._observe_alarms = observe_alarms + self._observe_ready = observe_ready + self._observe_status = observe_status + self._area = panel.areas[area_id] + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, self._area_unique_id)}, + name=self._area.name, + manufacturer="Bosch Security Systems", + via_device=(DOMAIN, unique_id), + ) + + async def async_added_to_hass(self) -> None: + """Observe state changes.""" + await super().async_added_to_hass() + if self._observe_alarms: + self._area.alarm_observer.attach(self.schedule_update_ha_state) + if self._observe_ready: + self._area.ready_observer.attach(self.schedule_update_ha_state) + if self._observe_status: + self._area.status_observer.attach(self.schedule_update_ha_state) + + async def async_will_remove_from_hass(self) -> None: + """Stop observing state changes.""" + await super().async_added_to_hass() + if self._observe_alarms: + self._area.alarm_observer.detach(self.schedule_update_ha_state) + if self._observe_ready: + self._area.ready_observer.detach(self.schedule_update_ha_state) + if self._observe_status: + self._area.status_observer.detach(self.schedule_update_ha_state) diff --git a/homeassistant/components/bosch_alarm/icons.json b/homeassistant/components/bosch_alarm/icons.json new file mode 100644 index 00000000000..1e207310713 --- /dev/null +++ b/homeassistant/components/bosch_alarm/icons.json @@ -0,0 +1,9 @@ +{ + "entity": { + "sensor": { + "faulting_points": { + "default": "mdi:alert-circle-outline" + } + } + } +} diff --git a/homeassistant/components/bosch_alarm/quality_scale.yaml b/homeassistant/components/bosch_alarm/quality_scale.yaml index 75c331ede40..3a64667a407 100644 --- a/homeassistant/components/bosch_alarm/quality_scale.yaml +++ b/homeassistant/components/bosch_alarm/quality_scale.yaml @@ -62,9 +62,9 @@ rules: entity-category: todo entity-device-class: todo entity-disabled-by-default: todo - entity-translations: todo + entity-translations: done exception-translations: todo - icon-translations: todo + icon-translations: done reconfiguration-flow: todo repair-issues: status: exempt diff --git a/homeassistant/components/bosch_alarm/sensor.py b/homeassistant/components/bosch_alarm/sensor.py new file mode 100644 index 00000000000..3d61c72a883 --- /dev/null +++ b/homeassistant/components/bosch_alarm/sensor.py @@ -0,0 +1,86 @@ +"""Support for Bosch Alarm Panel History as a sensor.""" + +from __future__ import annotations + +from collections.abc import Callable +from dataclasses import dataclass + +from bosch_alarm_mode2 import Panel +from bosch_alarm_mode2.panel import Area + +from homeassistant.components.sensor import SensorEntity, SensorEntityDescription +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from . import BoschAlarmConfigEntry +from .entity import BoschAlarmAreaEntity + + +@dataclass(kw_only=True, frozen=True) +class BoschAlarmSensorEntityDescription(SensorEntityDescription): + """Describes Bosch Alarm sensor entity.""" + + value_fn: Callable[[Area], int] + observe_alarms: bool = False + observe_ready: bool = False + observe_status: bool = False + + +SENSOR_TYPES: list[BoschAlarmSensorEntityDescription] = [ + BoschAlarmSensorEntityDescription( + key="faulting_points", + translation_key="faulting_points", + value_fn=lambda area: area.faults, + observe_ready=True, + ), +] + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: BoschAlarmConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up bosch alarm sensors.""" + + panel = config_entry.runtime_data + unique_id = config_entry.unique_id or config_entry.entry_id + + async_add_entities( + BoschAreaSensor(panel, area_id, unique_id, template) + for area_id in panel.areas + for template in SENSOR_TYPES + ) + + +PARALLEL_UPDATES = 0 + + +class BoschAreaSensor(BoschAlarmAreaEntity, SensorEntity): + """An area sensor entity for a bosch alarm panel.""" + + entity_description: BoschAlarmSensorEntityDescription + + def __init__( + self, + panel: Panel, + area_id: int, + unique_id: str, + entity_description: BoschAlarmSensorEntityDescription, + ) -> None: + """Set up an area sensor entity for a bosch alarm panel.""" + super().__init__( + panel, + area_id, + unique_id, + entity_description.observe_alarms, + entity_description.observe_ready, + entity_description.observe_status, + ) + self.entity_description = entity_description + self._attr_unique_id = f"{self._area_unique_id}_{entity_description.key}" + + @property + def native_value(self) -> int: + """Return the state of the sensor.""" + return self.entity_description.value_fn(self._area) diff --git a/homeassistant/components/bosch_alarm/strings.json b/homeassistant/components/bosch_alarm/strings.json index aad55eb04b1..6b916dad4fa 100644 --- a/homeassistant/components/bosch_alarm/strings.json +++ b/homeassistant/components/bosch_alarm/strings.json @@ -55,5 +55,13 @@ "authentication_failed": { "message": "Incorrect credentials for panel." } + }, + "entity": { + "sensor": { + "faulting_points": { + "name": "Faulting points", + "unit_of_measurement": "points" + } + } } } diff --git a/tests/components/bosch_alarm/conftest.py b/tests/components/bosch_alarm/conftest.py index 8358624b003..02ec592d061 100644 --- a/tests/components/bosch_alarm/conftest.py +++ b/tests/components/bosch_alarm/conftest.py @@ -131,7 +131,8 @@ def area() -> Generator[Area]: mock.alarm_observer = AsyncMock(spec=Observable) mock.ready_observer = AsyncMock(spec=Observable) mock.alarms = [] - mock.faults = [] + mock.alarms_ids = [] + mock.faults = 0 mock.all_ready = True mock.part_ready = True mock.is_triggered.return_value = False diff --git a/tests/components/bosch_alarm/snapshots/test_diagnostics.ambr b/tests/components/bosch_alarm/snapshots/test_diagnostics.ambr index 23ea722325f..459ddf7a213 100644 --- a/tests/components/bosch_alarm/snapshots/test_diagnostics.ambr +++ b/tests/components/bosch_alarm/snapshots/test_diagnostics.ambr @@ -11,8 +11,7 @@ 'armed': False, 'arming': False, 'disarmed': True, - 'faults': list([ - ]), + 'faults': 0, 'id': 1, 'name': 'Area1', 'part_armed': False, @@ -108,8 +107,7 @@ 'armed': False, 'arming': False, 'disarmed': True, - 'faults': list([ - ]), + 'faults': 0, 'id': 1, 'name': 'Area1', 'part_armed': False, @@ -204,8 +202,7 @@ 'armed': False, 'arming': False, 'disarmed': True, - 'faults': list([ - ]), + 'faults': 0, 'id': 1, 'name': 'Area1', 'part_armed': False, diff --git a/tests/components/bosch_alarm/snapshots/test_sensor.ambr b/tests/components/bosch_alarm/snapshots/test_sensor.ambr new file mode 100644 index 00000000000..def2c503a6a --- /dev/null +++ b/tests/components/bosch_alarm/snapshots/test_sensor.ambr @@ -0,0 +1,145 @@ +# serializer version: 1 +# name: test_sensor[amax_3000][sensor.area1_faulting_points-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.area1_faulting_points', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Faulting points', + 'platform': 'bosch_alarm', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'faulting_points', + 'unique_id': '01JQ917ACKQ33HHM7YCFXYZX51_area_1_faulting_points', + 'unit_of_measurement': 'points', + }) +# --- +# name: test_sensor[amax_3000][sensor.area1_faulting_points-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Area1 Faulting points', + 'unit_of_measurement': 'points', + }), + 'context': , + 'entity_id': 'sensor.area1_faulting_points', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0', + }) +# --- +# name: test_sensor[b5512][sensor.area1_faulting_points-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.area1_faulting_points', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Faulting points', + 'platform': 'bosch_alarm', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'faulting_points', + 'unique_id': '01JQ917ACKQ33HHM7YCFXYZX51_area_1_faulting_points', + 'unit_of_measurement': 'points', + }) +# --- +# name: test_sensor[b5512][sensor.area1_faulting_points-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Area1 Faulting points', + 'unit_of_measurement': 'points', + }), + 'context': , + 'entity_id': 'sensor.area1_faulting_points', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0', + }) +# --- +# name: test_sensor[solution_3000][sensor.area1_faulting_points-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.area1_faulting_points', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Faulting points', + 'platform': 'bosch_alarm', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'faulting_points', + 'unique_id': '1234567890_area_1_faulting_points', + 'unit_of_measurement': 'points', + }) +# --- +# name: test_sensor[solution_3000][sensor.area1_faulting_points-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Area1 Faulting points', + 'unit_of_measurement': 'points', + }), + 'context': , + 'entity_id': 'sensor.area1_faulting_points', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0', + }) +# --- diff --git a/tests/components/bosch_alarm/test_sensor.py b/tests/components/bosch_alarm/test_sensor.py new file mode 100644 index 00000000000..02153a9656e --- /dev/null +++ b/tests/components/bosch_alarm/test_sensor.py @@ -0,0 +1,52 @@ +"""Tests for Bosch Alarm component.""" + +from collections.abc import AsyncGenerator +from unittest.mock import AsyncMock, patch + +import pytest +from syrupy.assertion import SnapshotAssertion + +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from . import call_observable, setup_integration + +from tests.common import MockConfigEntry, snapshot_platform + + +@pytest.fixture(autouse=True) +async def platforms() -> AsyncGenerator[None]: + """Return the platforms to be loaded for this test.""" + with patch("homeassistant.components.bosch_alarm.PLATFORMS", [Platform.SENSOR]): + yield + + +async def test_sensor( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, + mock_panel: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test the sensor state.""" + await setup_integration(hass, mock_config_entry) + + await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) + + +async def test_faulting_points( + hass: HomeAssistant, + mock_panel: AsyncMock, + area: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test that area faulting point count changes after arming the panel.""" + await setup_integration(hass, mock_config_entry) + entity_id = "sensor.area1_faulting_points" + assert hass.states.get(entity_id).state == "0" + + area.faults = 1 + await call_observable(hass, area.ready_observer) + + assert hass.states.get(entity_id).state == "1" From cb09207cd755f99e449382bd031f8eccde69ed5f Mon Sep 17 00:00:00 2001 From: Martin Hjelmare Date: Tue, 8 Apr 2025 14:03:16 +0200 Subject: [PATCH 367/505] Improve Supervisor addon_running test fixture (#142525) --- tests/components/hassio/common.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/tests/components/hassio/common.py b/tests/components/hassio/common.py index 82d3564440b..5cf7e80b191 100644 --- a/tests/components/hassio/common.py +++ b/tests/components/hassio/common.py @@ -151,8 +151,7 @@ def mock_addon_installed( def mock_addon_running(addon_store_info: AsyncMock, addon_info: AsyncMock) -> AsyncMock: """Mock add-on already running.""" - addon_store_info.return_value.available = True - addon_store_info.return_value.installed = True + mock_addon_installed(addon_store_info, addon_info) addon_info.return_value.state = "started" return addon_info From 74141c39ea847bf9b34d372f7209aec17849ca2b Mon Sep 17 00:00:00 2001 From: Arie Catsman <120491684+catsmanac@users.noreply.github.com> Date: Tue, 8 Apr 2025 14:22:52 +0200 Subject: [PATCH 368/505] Remember prior config flow user entries for enphase_envoy (#142457) * Remember prior config flow user entries for enphase_envoy * Do not reflect password in config userforms * de-duplicate avoid reflect key code --- .../components/enphase_envoy/config_flow.py | 30 +++++++++++++++---- 1 file changed, 24 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/enphase_envoy/config_flow.py b/homeassistant/components/enphase_envoy/config_flow.py index 654e2262730..5ee81dd8315 100644 --- a/homeassistant/components/enphase_envoy/config_flow.py +++ b/homeassistant/components/enphase_envoy/config_flow.py @@ -16,7 +16,13 @@ from homeassistant.config_entries import ( ConfigFlowResult, OptionsFlow, ) -from homeassistant.const import CONF_HOST, CONF_NAME, CONF_PASSWORD, CONF_USERNAME +from homeassistant.const import ( + CONF_HOST, + CONF_NAME, + CONF_PASSWORD, + CONF_TOKEN, + CONF_USERNAME, +) from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.httpx_client import get_async_client from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo @@ -40,6 +46,13 @@ CONF_SERIAL = "serial" INSTALLER_AUTH_USERNAME = "installer" +AVOID_REFLECT_KEYS = {CONF_PASSWORD, CONF_TOKEN} + + +def without_avoid_reflect_keys(dictionary: Mapping[str, Any]) -> dict[str, Any]: + """Return a dictionary without AVOID_REFLECT_KEYS.""" + return {k: v for k, v in dictionary.items() if k not in AVOID_REFLECT_KEYS} + async def validate_input( hass: HomeAssistant, @@ -205,7 +218,10 @@ class EnphaseConfigFlow(ConfigFlow, domain=DOMAIN): description_placeholders["serial"] = serial return self.async_show_form( step_id="reauth_confirm", - data_schema=self._async_generate_schema(), + data_schema=self.add_suggested_values_to_schema( + self._async_generate_schema(), + without_avoid_reflect_keys(user_input or reauth_entry.data), + ), description_placeholders=description_placeholders, errors=errors, ) @@ -259,10 +275,12 @@ class EnphaseConfigFlow(ConfigFlow, domain=DOMAIN): CONF_SERIAL: self.unique_id, CONF_HOST: host, } - return self.async_show_form( step_id="user", - data_schema=self._async_generate_schema(), + data_schema=self.add_suggested_values_to_schema( + self._async_generate_schema(), + without_avoid_reflect_keys(user_input or {}), + ), description_placeholders=description_placeholders, errors=errors, ) @@ -306,11 +324,11 @@ class EnphaseConfigFlow(ConfigFlow, domain=DOMAIN): } description_placeholders["serial"] = serial - suggested_values: Mapping[str, Any] = user_input or reconfigure_entry.data return self.async_show_form( step_id="reconfigure", data_schema=self.add_suggested_values_to_schema( - self._async_generate_schema(), suggested_values + self._async_generate_schema(), + without_avoid_reflect_keys(user_input or reconfigure_entry.data), ), description_placeholders=description_placeholders, errors=errors, From 67e75547029f258150a856e14667b9a3b214d047 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ville=20Skytt=C3=A4?= Date: Tue, 8 Apr 2025 15:57:07 +0300 Subject: [PATCH 369/505] Increase huawei_lte scan interval to 30 seconds (#142533) To follow what other similar integrations do, namely at least asuswrt and netgear. Refs https://developers.home-assistant.io/docs/core/integration-quality-scale/rules/appropriate-polling --- homeassistant/components/huawei_lte/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/huawei_lte/__init__.py b/homeassistant/components/huawei_lte/__init__.py index a5a60d8406d..be9d02e45fd 100644 --- a/homeassistant/components/huawei_lte/__init__.py +++ b/homeassistant/components/huawei_lte/__init__.py @@ -88,7 +88,7 @@ from .utils import get_device_macs, non_verifying_requests_session _LOGGER = logging.getLogger(__name__) -SCAN_INTERVAL = timedelta(seconds=10) +SCAN_INTERVAL = timedelta(seconds=30) NOTIFY_SCHEMA = vol.Any( None, From 0ed7348d2d0566c6b7197c6898eddc6760289074 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Tue, 8 Apr 2025 15:05:19 +0200 Subject: [PATCH 370/505] Fix typos in hassio (#142529) --- homeassistant/components/hassio/websocket_api.py | 2 +- tests/components/hassio/test_websocket_api.py | 4 +--- 2 files changed, 2 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/hassio/websocket_api.py b/homeassistant/components/hassio/websocket_api.py index c046e20feab..6714d5782e1 100644 --- a/homeassistant/components/hassio/websocket_api.py +++ b/homeassistant/components/hassio/websocket_api.py @@ -182,6 +182,6 @@ async def websocket_update_addon( async def websocket_update_core( hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any] ) -> None: - """Websocket handler to update an addon.""" + """Websocket handler to update Home Assistant Core.""" await update_core(hass, None, msg["backup"]) connection.send_result(msg[WS_ID]) diff --git a/tests/components/hassio/test_websocket_api.py b/tests/components/hassio/test_websocket_api.py index 6334fb096a2..497b961c80f 100644 --- a/tests/components/hassio/test_websocket_api.py +++ b/tests/components/hassio/test_websocket_api.py @@ -849,9 +849,7 @@ async def test_update_core_with_backup_and_error( side_effect=BackupManagerError, ), ): - await client.send_json_auto_id( - {"type": "hassio/update/addon", "addon": "test", "backup": True} - ) + await client.send_json_auto_id({"type": "hassio/update/core", "backup": True}) result = await client.receive_json() assert not result["success"] assert result["error"] == { From 12fc458abb85035d599222725149cd8678363b92 Mon Sep 17 00:00:00 2001 From: Marcel van der Veldt Date: Tue, 8 Apr 2025 15:44:35 +0200 Subject: [PATCH 371/505] Fix small typo in Music Assistant integration causing unavailable players (#142535) Fix small typo in Music Assistant integration causing issues with adding players --- homeassistant/components/music_assistant/media_player.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/homeassistant/components/music_assistant/media_player.py b/homeassistant/components/music_assistant/media_player.py index 08176307829..11cc48f28a3 100644 --- a/homeassistant/components/music_assistant/media_player.py +++ b/homeassistant/components/music_assistant/media_player.py @@ -151,6 +151,9 @@ async def async_setup_entry( assert event.object_id is not None if event.object_id in added_ids: return + player = mass.players.get(event.object_id) + if TYPE_CHECKING: + assert player is not None if not player.expose_to_ha: return added_ids.add(event.object_id) From 38bf06e17924bddf8266ce341a645ae95cc174b0 Mon Sep 17 00:00:00 2001 From: Martin Hjelmare Date: Tue, 8 Apr 2025 16:18:22 +0200 Subject: [PATCH 372/505] Improve parameters in Z-Wave init tests (#142532) --- tests/components/zwave_js/test_init.py | 265 +++++++++++++------------ 1 file changed, 143 insertions(+), 122 deletions(-) diff --git a/tests/components/zwave_js/test_init.py b/tests/components/zwave_js/test_init.py index 91e333f7c7d..5afdc7e1b56 100644 --- a/tests/components/zwave_js/test_init.py +++ b/tests/components/zwave_js/test_init.py @@ -1,6 +1,7 @@ """Test the Z-Wave JS init module.""" import asyncio +from collections.abc import Generator from copy import deepcopy import logging from typing import Any @@ -16,7 +17,7 @@ from zwave_js_server.exceptions import ( InvalidServerVersion, NotConnected, ) -from zwave_js_server.model.node import Node +from zwave_js_server.model.node import Node, NodeDataType from zwave_js_server.model.version import VersionInfo from homeassistant.components.hassio import HassioAPIError @@ -46,13 +47,17 @@ from tests.typing import WebSocketGenerator @pytest.fixture(name="connect_timeout") -def connect_timeout_fixture(): +def connect_timeout_fixture() -> Generator[int]: """Mock the connect timeout.""" with patch("homeassistant.components.zwave_js.CONNECT_TIMEOUT", new=0) as timeout: yield timeout -async def test_entry_setup_unload(hass: HomeAssistant, client, integration) -> None: +async def test_entry_setup_unload( + hass: HomeAssistant, + client: MagicMock, + integration: MockConfigEntry, +) -> None: """Test the integration set up and unload.""" entry = integration @@ -65,16 +70,19 @@ async def test_entry_setup_unload(hass: HomeAssistant, client, integration) -> N assert entry.state is ConfigEntryState.NOT_LOADED -async def test_home_assistant_stop(hass: HomeAssistant, client, integration) -> None: +@pytest.mark.usefixtures("integration") +async def test_home_assistant_stop( + hass: HomeAssistant, + client: MagicMock, +) -> None: """Test we clean up on home assistant stop.""" await hass.async_stop() assert client.disconnect.call_count == 1 -async def test_initialized_timeout( - hass: HomeAssistant, client, connect_timeout -) -> None: +@pytest.mark.usefixtures("client", "connect_timeout") +async def test_initialized_timeout(hass: HomeAssistant) -> None: """Test we handle a timeout during client initialization.""" entry = MockConfigEntry(domain="zwave_js", data={"url": "ws://test.org"}) entry.add_to_hass(hass) @@ -85,7 +93,8 @@ async def test_initialized_timeout( assert entry.state is ConfigEntryState.SETUP_RETRY -async def test_enabled_statistics(hass: HomeAssistant, client) -> None: +@pytest.mark.usefixtures("client") +async def test_enabled_statistics(hass: HomeAssistant) -> None: """Test that we enabled statistics if the entry is opted in.""" entry = MockConfigEntry( domain="zwave_js", @@ -101,8 +110,9 @@ async def test_enabled_statistics(hass: HomeAssistant, client) -> None: assert mock_cmd.called -async def test_disabled_statistics(hass: HomeAssistant, client) -> None: - """Test that we diisabled statistics if the entry is opted out.""" +@pytest.mark.usefixtures("client") +async def test_disabled_statistics(hass: HomeAssistant) -> None: + """Test that we disabled statistics if the entry is opted out.""" entry = MockConfigEntry( domain="zwave_js", data={"url": "ws://test.org", "data_collection_opted_in": False}, @@ -117,7 +127,8 @@ async def test_disabled_statistics(hass: HomeAssistant, client) -> None: assert mock_cmd.called -async def test_noop_statistics(hass: HomeAssistant, client) -> None: +@pytest.mark.usefixtures("client") +async def test_noop_statistics(hass: HomeAssistant) -> None: """Test that we don't make statistics calls if user hasn't set preference.""" entry = MockConfigEntry(domain="zwave_js", data={"url": "ws://test.org"}) entry.add_to_hass(hass) @@ -347,8 +358,11 @@ async def test_listen_done_after_setup( assert client.disconnect.call_count == disconnect_call_count +@pytest.mark.usefixtures("client") async def test_new_entity_on_value_added( - hass: HomeAssistant, multisensor_6, client, integration + hass: HomeAssistant, + multisensor_6: Node, + integration: MockConfigEntry, ) -> None: """Test we create a new entity if a value is added after the fact.""" node: Node = multisensor_6 @@ -382,12 +396,12 @@ async def test_new_entity_on_value_added( assert hass.states.get("sensor.multisensor_6_ultraviolet_10") is not None +@pytest.mark.usefixtures("integration") async def test_on_node_added_ready( hass: HomeAssistant, device_registry: dr.DeviceRegistry, - multisensor_6_state, - client, - integration, + multisensor_6_state: NodeDataType, + client: MagicMock, ) -> None: """Test we handle a node added event with a ready node.""" node = Node(client, deepcopy(multisensor_6_state)) @@ -413,13 +427,13 @@ async def test_on_node_added_ready( ) +@pytest.mark.usefixtures("integration") async def test_on_node_added_not_ready( hass: HomeAssistant, device_registry: dr.DeviceRegistry, entity_registry: er.EntityRegistry, - zp3111_not_ready_state, - client, - integration, + zp3111_not_ready_state: NodeDataType, + client: MagicMock, ) -> None: """Test we handle a node added event with a non-ready node.""" device_id = f"{client.driver.controller.home_id}-{zp3111_not_ready_state['nodeId']}" @@ -455,9 +469,9 @@ async def test_on_node_added_not_ready( async def test_existing_node_ready( hass: HomeAssistant, device_registry: dr.DeviceRegistry, - client, - multisensor_6, - integration, + client: MagicMock, + multisensor_6: Node, + integration: MockConfigEntry, ) -> None: """Test we handle a ready node that exists during integration setup.""" node = multisensor_6 @@ -485,7 +499,7 @@ async def test_existing_node_reinterview( hass: HomeAssistant, device_registry: dr.DeviceRegistry, client: Client, - multisensor_6_state: dict, + multisensor_6_state: NodeDataType, multisensor_6: Node, integration: MockConfigEntry, ) -> None: @@ -544,15 +558,16 @@ async def test_existing_node_not_ready( hass: HomeAssistant, device_registry: dr.DeviceRegistry, entity_registry: er.EntityRegistry, - zp3111_not_ready, - client, - integration, + client: MagicMock, + zp3111_not_ready: Node, + integration: MockConfigEntry, ) -> None: """Test we handle a non-ready node that exists during integration setup.""" node = zp3111_not_ready device_id = f"{client.driver.controller.home_id}-{node.node_id}" device = device_registry.async_get_device(identifiers={(DOMAIN, device_id)}) + assert device assert device.name == f"Node {node.node_id}" assert not device.manufacturer assert not device.model @@ -573,11 +588,11 @@ async def test_existing_node_not_replaced_when_not_ready( area_registry: ar.AreaRegistry, device_registry: dr.DeviceRegistry, entity_registry: er.EntityRegistry, - zp3111, - zp3111_not_ready_state, - zp3111_state, - client, - integration, + client: MagicMock, + zp3111: Node, + zp3111_not_ready_state: NodeDataType, + zp3111_state: NodeDataType, + integration: MockConfigEntry, ) -> None: """Test when a node added event with a non-ready node is received. @@ -699,21 +714,23 @@ async def test_existing_node_not_replaced_when_not_ready( assert state.name == "Custom Entity Name" +@pytest.mark.usefixtures("client") async def test_null_name( - hass: HomeAssistant, client, null_name_check, integration + hass: HomeAssistant, + null_name_check: Node, + integration: MockConfigEntry, ) -> None: """Test that node without a name gets a generic node name.""" node = null_name_check assert hass.states.get(f"switch.node_{node.node_id}") +@pytest.mark.usefixtures("addon_installed", "addon_info") async def test_start_addon( hass: HomeAssistant, - addon_installed, - install_addon, - addon_options, - set_addon_options, - start_addon, + install_addon: AsyncMock, + set_addon_options: AsyncMock, + start_addon: AsyncMock, ) -> None: """Test start the Z-Wave JS add-on during entry setup.""" device = "/test" @@ -761,13 +778,12 @@ async def test_start_addon( assert start_addon.call_args == call("core_zwave_js") +@pytest.mark.usefixtures("addon_not_installed", "addon_info") async def test_install_addon( hass: HomeAssistant, - addon_not_installed, - install_addon, - addon_options, - set_addon_options, - start_addon, + install_addon: AsyncMock, + set_addon_options: AsyncMock, + start_addon: AsyncMock, ) -> None: """Test install and start the Z-Wave JS add-on during entry setup.""" device = "/test" @@ -810,14 +826,12 @@ async def test_install_addon( assert start_addon.call_args == call("core_zwave_js") +@pytest.mark.usefixtures("addon_installed", "addon_info", "set_addon_options") @pytest.mark.parametrize("addon_info_side_effect", [SupervisorError("Boom")]) async def test_addon_info_failure( hass: HomeAssistant, - addon_installed, - install_addon, - addon_options, - set_addon_options, - start_addon, + install_addon: AsyncMock, + start_addon: AsyncMock, ) -> None: """Test failure to get add-on info for Z-Wave JS add-on during entry setup.""" device = "/test" @@ -837,6 +851,7 @@ async def test_addon_info_failure( assert start_addon.call_count == 0 +@pytest.mark.usefixtures("addon_running", "addon_info", "client") @pytest.mark.parametrize( ( "old_device", @@ -875,26 +890,23 @@ async def test_addon_info_failure( ) async def test_addon_options_changed( hass: HomeAssistant, - client, - addon_installed, - addon_running, - install_addon, - addon_options, - start_addon, - old_device, - new_device, - old_s0_legacy_key, - new_s0_legacy_key, - old_s2_access_control_key, - new_s2_access_control_key, - old_s2_authenticated_key, - new_s2_authenticated_key, - old_s2_unauthenticated_key, - new_s2_unauthenticated_key, - old_lr_s2_access_control_key, - new_lr_s2_access_control_key, - old_lr_s2_authenticated_key, - new_lr_s2_authenticated_key, + install_addon: AsyncMock, + addon_options: dict[str, Any], + start_addon: AsyncMock, + old_device: str, + new_device: str, + old_s0_legacy_key: str, + new_s0_legacy_key: str, + old_s2_access_control_key: str, + new_s2_access_control_key: str, + old_s2_authenticated_key: str, + new_s2_authenticated_key: str, + old_s2_unauthenticated_key: str, + new_s2_unauthenticated_key: str, + old_lr_s2_access_control_key: str, + new_lr_s2_access_control_key: str, + old_lr_s2_authenticated_key: str, + new_lr_s2_authenticated_key: str, ) -> None: """Test update config entry data on entry setup if add-on options changed.""" addon_options["device"] = new_device @@ -936,6 +948,7 @@ async def test_addon_options_changed( assert start_addon.call_count == 0 +@pytest.mark.usefixtures("addon_running") @pytest.mark.parametrize( ( "addon_version", @@ -954,20 +967,17 @@ async def test_addon_options_changed( ) async def test_update_addon( hass: HomeAssistant, - client, - addon_info, - addon_installed, - addon_running, - create_backup, - update_addon, - addon_options, - addon_version, - update_available, - update_calls, - backup_calls, - update_addon_side_effect, - create_backup_side_effect, - version_state, + client: MagicMock, + addon_info: AsyncMock, + create_backup: AsyncMock, + update_addon: AsyncMock, + addon_options: dict[str, Any], + addon_version: str, + update_available: bool, + update_calls: int, + backup_calls: int, + update_addon_side_effect: Exception | None, + create_backup_side_effect: Exception | None, ) -> None: """Test update the Z-Wave JS add-on during entry setup.""" device = "/test" @@ -1002,7 +1012,9 @@ async def test_update_addon( async def test_issue_registry( - hass: HomeAssistant, client, version_state, issue_registry: ir.IssueRegistry + hass: HomeAssistant, + client: MagicMock, + issue_registry: ir.IssueRegistry, ) -> None: """Test issue registry.""" device = "/test" @@ -1043,6 +1055,7 @@ async def test_issue_registry( assert not issue_registry.async_get_issue(DOMAIN, "invalid_server_version") +@pytest.mark.usefixtures("addon_running", "client") @pytest.mark.parametrize( ("stop_addon_side_effect", "entry_state"), [ @@ -1052,13 +1065,10 @@ async def test_issue_registry( ) async def test_stop_addon( hass: HomeAssistant, - client, - addon_installed, - addon_running, - addon_options, - stop_addon, - stop_addon_side_effect, - entry_state, + addon_options: dict[str, Any], + stop_addon: AsyncMock, + stop_addon_side_effect: Exception | None, + entry_state: ConfigEntryState, ) -> None: """Test stop the Z-Wave JS add-on on entry unload if entry is disabled.""" stop_addon.side_effect = stop_addon_side_effect @@ -1093,12 +1103,12 @@ async def test_stop_addon( assert stop_addon.call_args == call("core_zwave_js") +@pytest.mark.usefixtures("addon_installed") async def test_remove_entry( hass: HomeAssistant, - addon_installed, - stop_addon, - create_backup, - uninstall_addon, + stop_addon: AsyncMock, + create_backup: AsyncMock, + uninstall_addon: AsyncMock, caplog: pytest.LogCaptureFixture, ) -> None: """Test remove the config entry.""" @@ -1209,13 +1219,12 @@ async def test_remove_entry( assert "Failed to uninstall the Z-Wave JS add-on" in caplog.text +@pytest.mark.usefixtures("climate_radio_thermostat_ct100_plus", "lock_schlage_be469") async def test_removed_device( hass: HomeAssistant, device_registry: dr.DeviceRegistry, - client, - climate_radio_thermostat_ct100_plus, - lock_schlage_be469, - integration, + client: MagicMock, + integration: MockConfigEntry, ) -> None: """Test that the device registry gets updated when a device gets removed.""" driver = client.driver @@ -1245,12 +1254,11 @@ async def test_removed_device( ) +@pytest.mark.usefixtures("client", "eaton_rf9640_dimmer") async def test_suggested_area( hass: HomeAssistant, device_registry: dr.DeviceRegistry, entity_registry: er.EntityRegistry, - client, - eaton_rf9640_dimmer, ) -> None: """Test that suggested area works.""" entry = MockConfigEntry(domain="zwave_js", data={"url": "ws://test.org"}) @@ -1258,16 +1266,20 @@ async def test_suggested_area( await hass.config_entries.async_setup(entry.entry_id) await hass.async_block_till_done() - entity = entity_registry.async_get(EATON_RF9640_ENTITY) - assert device_registry.async_get(entity.device_id).area_id is not None + entity_entry = entity_registry.async_get(EATON_RF9640_ENTITY) + assert entity_entry + assert entity_entry.device_id is not None + device = device_registry.async_get(entity_entry.device_id) + assert device + assert device.area_id is not None async def test_node_removed( hass: HomeAssistant, device_registry: dr.DeviceRegistry, multisensor_6_state, - client, - integration, + client: MagicMock, + integration: MockConfigEntry, ) -> None: """Test that device gets removed when node gets removed.""" node = Node(client, deepcopy(multisensor_6_state)) @@ -1296,10 +1308,10 @@ async def test_node_removed( async def test_replace_same_node( hass: HomeAssistant, device_registry: dr.DeviceRegistry, - multisensor_6, - multisensor_6_state, - client, - integration, + multisensor_6: Node, + multisensor_6_state: NodeDataType, + client: MagicMock, + integration: MockConfigEntry, ) -> None: """Test when a node is replaced with itself that the device remains.""" node_id = multisensor_6.node_id @@ -1406,11 +1418,11 @@ async def test_replace_same_node( async def test_replace_different_node( hass: HomeAssistant, device_registry: dr.DeviceRegistry, - multisensor_6, - multisensor_6_state, - hank_binary_switch_state, - client, - integration, + multisensor_6: Node, + multisensor_6_state: NodeDataType, + hank_binary_switch_state: NodeDataType, + client: MagicMock, + integration: MockConfigEntry, hass_ws_client: WebSocketGenerator, ) -> None: """Test when a node is replaced with a different node.""" @@ -1659,9 +1671,9 @@ async def test_node_model_change( hass: HomeAssistant, device_registry: dr.DeviceRegistry, entity_registry: er.EntityRegistry, - zp3111, - client, - integration, + zp3111: Node, + client: MagicMock, + integration: MockConfigEntry, ) -> None: """Test when a node's model is changed due to an updated device config file. @@ -1745,8 +1757,11 @@ async def test_node_model_change( assert state.name == "Custom Entity Name" +@pytest.mark.usefixtures("zp3111", "integration") async def test_disabled_node_status_entity_on_node_replaced( - hass: HomeAssistant, zp3111_state, zp3111, client, integration + hass: HomeAssistant, + zp3111_state: NodeDataType, + client: MagicMock, ) -> None: """Test when node replacement event is received, node status sensor is removed.""" node_status_entity = "sensor.4_in_1_sensor_node_status" @@ -1772,7 +1787,10 @@ async def test_disabled_node_status_entity_on_node_replaced( async def test_disabled_entity_on_value_removed( - hass: HomeAssistant, entity_registry: er.EntityRegistry, zp3111, client, integration + hass: HomeAssistant, + zp3111: Node, + client: MagicMock, + integration: MockConfigEntry, ) -> None: """Test that when entity primary values are removed the entity is removed.""" idle_cover_status_button_entity = ( @@ -1903,7 +1921,10 @@ async def test_disabled_entity_on_value_removed( async def test_identify_event( - hass: HomeAssistant, client, multisensor_6, integration + hass: HomeAssistant, + client: MagicMock, + multisensor_6: Node, + integration: MockConfigEntry, ) -> None: """Test controller identify event.""" # One config entry scenario @@ -1950,7 +1971,7 @@ async def test_identify_event( assert "network with the home ID `3245146787`" in notifications[msg_id]["message"] -async def test_server_logging(hass: HomeAssistant, client) -> None: +async def test_server_logging(hass: HomeAssistant, client: MagicMock) -> None: """Test automatic server logging functionality.""" def _reset_mocks(): @@ -2044,10 +2065,10 @@ async def test_server_logging(hass: HomeAssistant, client) -> None: async def test_factory_reset_node( hass: HomeAssistant, device_registry: dr.DeviceRegistry, - client, - multisensor_6, - multisensor_6_state, - integration, + client: MagicMock, + multisensor_6: Node, + multisensor_6_state: NodeDataType, + integration: MockConfigEntry, ) -> None: """Test when a node is removed because it was reset.""" # One config entry scenario From 3f2975e93f1ea8d68dbaeea135492990def12451 Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Tue, 8 Apr 2025 16:57:30 +0200 Subject: [PATCH 373/505] Use common state for "Normal" in `tessie` / `teslemetry` / `tesla_fleet` (#142515) * Use common state for "Normal" in `tessie` * Use common state for "Normal" in `teslemetry` * Use common state for "Normal" in `tesla_fleet` --- homeassistant/components/tesla_fleet/strings.json | 2 +- homeassistant/components/teslemetry/strings.json | 2 +- homeassistant/components/tessie/strings.json | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/tesla_fleet/strings.json b/homeassistant/components/tesla_fleet/strings.json index e4da161c63d..fcd2e07306f 100644 --- a/homeassistant/components/tesla_fleet/strings.json +++ b/homeassistant/components/tesla_fleet/strings.json @@ -141,7 +141,7 @@ "state_attributes": { "preset_mode": { "state": { - "off": "Normal", + "off": "[%key:common::state::normal%]", "keep": "Keep mode", "dog": "Dog mode", "camp": "Camp mode" diff --git a/homeassistant/components/teslemetry/strings.json b/homeassistant/components/teslemetry/strings.json index 4ff78781c7f..69b1551a561 100644 --- a/homeassistant/components/teslemetry/strings.json +++ b/homeassistant/components/teslemetry/strings.json @@ -221,7 +221,7 @@ "state_attributes": { "preset_mode": { "state": { - "off": "Normal", + "off": "[%key:common::state::normal%]", "keep": "Keep mode", "dog": "Dog mode", "camp": "Camp mode" diff --git a/homeassistant/components/tessie/strings.json b/homeassistant/components/tessie/strings.json index 5de18f13140..1c0ec7ecc80 100644 --- a/homeassistant/components/tessie/strings.json +++ b/homeassistant/components/tessie/strings.json @@ -48,7 +48,7 @@ "state_attributes": { "preset_mode": { "state": { - "off": "Normal", + "off": "[%key:common::state::normal%]", "on": "Keep mode", "dog": "Dog mode", "camp": "Camp mode" From 626935ee14b85ca23ab6be32d566eaf2347a2334 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 8 Apr 2025 04:59:01 -1000 Subject: [PATCH 374/505] Move inkbird coordinator logic into coordinator.py (#142517) * Move inkbird coordinator logic into coordinator.py Not a functional change, one to one relocation * Move inkbird coordinator logic into coordinator.py Not a functional change, one to one copy * Move inkbird coordinator logic into coordinator.py Not a functional change, one to one copy --- homeassistant/components/inkbird/__init__.py | 95 +---------------- .../components/inkbird/coordinator.py | 100 ++++++++++++++++++ tests/components/inkbird/test_sensor.py | 2 +- 3 files changed, 104 insertions(+), 93 deletions(-) create mode 100644 homeassistant/components/inkbird/coordinator.py diff --git a/homeassistant/components/inkbird/__init__.py b/homeassistant/components/inkbird/__init__.py index 467fa2445e8..738d412d849 100644 --- a/homeassistant/components/inkbird/__init__.py +++ b/homeassistant/components/inkbird/__init__.py @@ -2,106 +2,17 @@ from __future__ import annotations -from datetime import datetime, timedelta -import logging +from inkbird_ble import INKBIRDBluetoothDeviceData -from inkbird_ble import INKBIRDBluetoothDeviceData, SensorUpdate - -from homeassistant.components.bluetooth import ( - BluetoothScanningMode, - BluetoothServiceInfo, - BluetoothServiceInfoBleak, - async_ble_device_from_address, -) -from homeassistant.components.bluetooth.active_update_processor import ( - ActiveBluetoothProcessorCoordinator, -) from homeassistant.config_entries import ConfigEntry from homeassistant.const import Platform -from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers.event import async_track_time_interval +from homeassistant.core import HomeAssistant from .const import CONF_DEVICE_TYPE, DOMAIN +from .coordinator import INKBIRDActiveBluetoothProcessorCoordinator PLATFORMS: list[Platform] = [Platform.SENSOR] -_LOGGER = logging.getLogger(__name__) - -FALLBACK_POLL_INTERVAL = timedelta(seconds=180) - - -class INKBIRDActiveBluetoothProcessorCoordinator(ActiveBluetoothProcessorCoordinator): - """Coordinator for INKBIRD Bluetooth devices.""" - - def __init__( - self, - hass: HomeAssistant, - entry: ConfigEntry, - data: INKBIRDBluetoothDeviceData, - ) -> None: - """Initialize the INKBIRD Bluetooth processor coordinator.""" - self._data = data - self._entry = entry - address = entry.unique_id - assert address is not None - entry.async_on_unload( - async_track_time_interval( - hass, self._async_schedule_poll, FALLBACK_POLL_INTERVAL - ) - ) - super().__init__( - hass=hass, - logger=_LOGGER, - address=address, - mode=BluetoothScanningMode.ACTIVE, - update_method=self._async_on_update, - needs_poll_method=self._async_needs_poll, - poll_method=self._async_poll_data, - ) - - async def _async_poll_data( - self, last_service_info: BluetoothServiceInfoBleak - ) -> SensorUpdate: - """Poll the device.""" - return await self._data.async_poll(last_service_info.device) - - @callback - def _async_needs_poll( - self, service_info: BluetoothServiceInfoBleak, last_poll: float | None - ) -> bool: - return ( - not self.hass.is_stopping - and self._data.poll_needed(service_info, last_poll) - and bool( - async_ble_device_from_address( - self.hass, service_info.device.address, connectable=True - ) - ) - ) - - @callback - def _async_on_update(self, service_info: BluetoothServiceInfo) -> SensorUpdate: - """Handle update callback from the passive BLE processor.""" - update = self._data.update(service_info) - if ( - self._entry.data.get(CONF_DEVICE_TYPE) is None - and self._data.device_type is not None - ): - device_type_str = str(self._data.device_type) - self.hass.config_entries.async_update_entry( - self._entry, - data={**self._entry.data, CONF_DEVICE_TYPE: device_type_str}, - ) - return update - - @callback - def _async_schedule_poll(self, _: datetime) -> None: - """Schedule a poll of the device.""" - if self._last_service_info and self._async_needs_poll( - self._last_service_info, self._last_poll - ): - self._debounced_poll.async_schedule_call() - async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up INKBIRD BLE device from a config entry.""" diff --git a/homeassistant/components/inkbird/coordinator.py b/homeassistant/components/inkbird/coordinator.py new file mode 100644 index 00000000000..bcd519b32aa --- /dev/null +++ b/homeassistant/components/inkbird/coordinator.py @@ -0,0 +1,100 @@ +"""The INKBIRD Bluetooth integration.""" + +from __future__ import annotations + +from datetime import datetime, timedelta +import logging + +from inkbird_ble import INKBIRDBluetoothDeviceData, SensorUpdate + +from homeassistant.components.bluetooth import ( + BluetoothScanningMode, + BluetoothServiceInfo, + BluetoothServiceInfoBleak, + async_ble_device_from_address, +) +from homeassistant.components.bluetooth.active_update_processor import ( + ActiveBluetoothProcessorCoordinator, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.event import async_track_time_interval + +from .const import CONF_DEVICE_TYPE + +_LOGGER = logging.getLogger(__name__) + +FALLBACK_POLL_INTERVAL = timedelta(seconds=180) + + +class INKBIRDActiveBluetoothProcessorCoordinator(ActiveBluetoothProcessorCoordinator): + """Coordinator for INKBIRD Bluetooth devices.""" + + def __init__( + self, + hass: HomeAssistant, + entry: ConfigEntry, + data: INKBIRDBluetoothDeviceData, + ) -> None: + """Initialize the INKBIRD Bluetooth processor coordinator.""" + self._data = data + self._entry = entry + address = entry.unique_id + assert address is not None + entry.async_on_unload( + async_track_time_interval( + hass, self._async_schedule_poll, FALLBACK_POLL_INTERVAL + ) + ) + super().__init__( + hass=hass, + logger=_LOGGER, + address=address, + mode=BluetoothScanningMode.ACTIVE, + update_method=self._async_on_update, + needs_poll_method=self._async_needs_poll, + poll_method=self._async_poll_data, + ) + + async def _async_poll_data( + self, last_service_info: BluetoothServiceInfoBleak + ) -> SensorUpdate: + """Poll the device.""" + return await self._data.async_poll(last_service_info.device) + + @callback + def _async_needs_poll( + self, service_info: BluetoothServiceInfoBleak, last_poll: float | None + ) -> bool: + return ( + not self.hass.is_stopping + and self._data.poll_needed(service_info, last_poll) + and bool( + async_ble_device_from_address( + self.hass, service_info.device.address, connectable=True + ) + ) + ) + + @callback + def _async_on_update(self, service_info: BluetoothServiceInfo) -> SensorUpdate: + """Handle update callback from the passive BLE processor.""" + update = self._data.update(service_info) + if ( + self._entry.data.get(CONF_DEVICE_TYPE) is None + and self._data.device_type is not None + ): + device_type_str = str(self._data.device_type) + self.hass.config_entries.async_update_entry( + self._entry, + data={**self._entry.data, CONF_DEVICE_TYPE: device_type_str}, + ) + return update + + @callback + def _async_schedule_poll(self, _: datetime) -> None: + """Schedule a poll of the device.""" + if self._last_service_info and self._async_needs_poll( + self._last_service_info, self._last_poll + ): + self._debounced_poll.async_schedule_call() diff --git a/tests/components/inkbird/test_sensor.py b/tests/components/inkbird/test_sensor.py index 00b76366b48..67e08396c79 100644 --- a/tests/components/inkbird/test_sensor.py +++ b/tests/components/inkbird/test_sensor.py @@ -12,8 +12,8 @@ from inkbird_ble import ( ) from sensor_state_data import SensorDeviceClass -from homeassistant.components.inkbird import FALLBACK_POLL_INTERVAL from homeassistant.components.inkbird.const import CONF_DEVICE_TYPE, DOMAIN +from homeassistant.components.inkbird.coordinator import FALLBACK_POLL_INTERVAL from homeassistant.components.sensor import ATTR_STATE_CLASS from homeassistant.const import ATTR_FRIENDLY_NAME, ATTR_UNIT_OF_MEASUREMENT from homeassistant.core import HomeAssistant From a114ecfb73d9a5dfec0a17687d97c7d43c424a30 Mon Sep 17 00:00:00 2001 From: "Barry vd. Heuvel" Date: Tue, 8 Apr 2025 17:18:43 +0200 Subject: [PATCH 375/505] Bump weheat to 2025.3.7 (#142539) --- homeassistant/components/weheat/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/weheat/manifest.json b/homeassistant/components/weheat/manifest.json index 7297c601213..3a4cff6f295 100644 --- a/homeassistant/components/weheat/manifest.json +++ b/homeassistant/components/weheat/manifest.json @@ -6,5 +6,5 @@ "dependencies": ["application_credentials"], "documentation": "https://www.home-assistant.io/integrations/weheat", "iot_class": "cloud_polling", - "requirements": ["weheat==2025.2.26"] + "requirements": ["weheat==2025.3.7"] } diff --git a/requirements_all.txt b/requirements_all.txt index e6f4e98e168..9c658abba0a 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -3064,7 +3064,7 @@ webio-api==0.1.11 webmin-xmlrpc==0.0.2 # homeassistant.components.weheat -weheat==2025.2.26 +weheat==2025.3.7 # homeassistant.components.whirlpool whirlpool-sixth-sense==0.20.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 854e397e5ca..54e105805bb 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2472,7 +2472,7 @@ webio-api==0.1.11 webmin-xmlrpc==0.0.2 # homeassistant.components.weheat -weheat==2025.2.26 +weheat==2025.3.7 # homeassistant.components.whirlpool whirlpool-sixth-sense==0.20.0 From 3a670e74f7cddd6469448dcff09a377138aca291 Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Tue, 8 Apr 2025 18:15:05 +0200 Subject: [PATCH 376/505] Use common state for "Normal" in `yolink` (#142544) Also reordered the three states alphabetically which groups the common ones, too. --- homeassistant/components/yolink/strings.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/yolink/strings.json b/homeassistant/components/yolink/strings.json index b4cfe80f287..8867457342f 100644 --- a/homeassistant/components/yolink/strings.json +++ b/homeassistant/components/yolink/strings.json @@ -61,8 +61,8 @@ "power_failure_alarm": { "name": "Power failure alarm", "state": { - "normal": "Normal", "alert": "Alert", + "normal": "[%key:common::state::normal%]", "off": "[%key:common::state::off%]" } }, From a957db7c27c2f7823bfc60311dd31d2b2edd6a86 Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Tue, 8 Apr 2025 18:15:57 +0200 Subject: [PATCH 377/505] Use common states for "Low" and "High" in `tuya` (#142491) --- homeassistant/components/tuya/strings.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/tuya/strings.json b/homeassistant/components/tuya/strings.json index c86e60c22ef..55fd9b18b1e 100644 --- a/homeassistant/components/tuya/strings.json +++ b/homeassistant/components/tuya/strings.json @@ -321,9 +321,9 @@ "vacuum_cistern": { "name": "Water tank adjustment", "state": { - "low": "Low", + "low": "[%key:common::state::low%]", "middle": "Middle", - "high": "High", + "high": "[%key:common::state::high%]", "closed": "[%key:common::state::closed%]" } }, From 6c1f9e39c44847bf7919f11af748c613065ba9d5 Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Tue, 8 Apr 2025 18:16:20 +0200 Subject: [PATCH 378/505] Improve friendly names of `rf_strength` and `wifi_strength` in `netatmo` (#141673) * Improve friendly names of `rf_strength` and `wifi_strength` in `netatmo` - Replace "Radio" with "RF strength" for `rf_strength` - Replace "Wi-Fi" with "Wi-Fi strength" for `wifi_strength` * Update test_sensor.ambr * Update test_sensor.py * Update test_sensor.py * Update test_sensor.ambr --- homeassistant/components/netatmo/strings.json | 4 +- .../netatmo/snapshots/test_sensor.ambr | 132 +++++++++--------- tests/components/netatmo/test_sensor.py | 4 +- 3 files changed, 70 insertions(+), 70 deletions(-) diff --git a/homeassistant/components/netatmo/strings.json b/homeassistant/components/netatmo/strings.json index 23b800e460d..afa8a670704 100644 --- a/homeassistant/components/netatmo/strings.json +++ b/homeassistant/components/netatmo/strings.json @@ -241,10 +241,10 @@ "name": "Reachability" }, "rf_strength": { - "name": "Radio" + "name": "RF strength" }, "wifi_strength": { - "name": "Wi-Fi" + "name": "Wi-Fi strength" }, "health_idx": { "name": "Health index", diff --git a/tests/components/netatmo/snapshots/test_sensor.ambr b/tests/components/netatmo/snapshots/test_sensor.ambr index 00285f565a6..c0532d62b2d 100644 --- a/tests/components/netatmo/snapshots/test_sensor.ambr +++ b/tests/components/netatmo/snapshots/test_sensor.ambr @@ -499,7 +499,7 @@ 'state': 'unknown', }) # --- -# name: test_entity[sensor.baby_bedroom_wi_fi-entry] +# name: test_entity[sensor.baby_bedroom_wi_fi_strength-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -512,7 +512,7 @@ 'disabled_by': None, 'domain': 'sensor', 'entity_category': , - 'entity_id': 'sensor.baby_bedroom_wi_fi', + 'entity_id': 'sensor.baby_bedroom_wi_fi_strength', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -524,7 +524,7 @@ }), 'original_device_class': None, 'original_icon': None, - 'original_name': 'Wi-Fi', + 'original_name': 'Wi-Fi strength', 'platform': 'netatmo', 'previous_unique_id': None, 'supported_features': 0, @@ -533,16 +533,16 @@ 'unit_of_measurement': None, }) # --- -# name: test_entity[sensor.baby_bedroom_wi_fi-state] +# name: test_entity[sensor.baby_bedroom_wi_fi_strength-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'attribution': 'Data provided by Netatmo', - 'friendly_name': 'Baby Bedroom Wi-Fi', + 'friendly_name': 'Baby Bedroom Wi-Fi strength', 'latitude': 13.377726, 'longitude': 52.516263, }), 'context': , - 'entity_id': 'sensor.baby_bedroom_wi_fi', + 'entity_id': 'sensor.baby_bedroom_wi_fi_strength', 'last_changed': , 'last_reported': , 'last_updated': , @@ -1033,7 +1033,7 @@ 'state': 'unavailable', }) # --- -# name: test_entity[sensor.bedroom_wi_fi-entry] +# name: test_entity[sensor.bedroom_wi_fi_strength-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -1046,7 +1046,7 @@ 'disabled_by': None, 'domain': 'sensor', 'entity_category': , - 'entity_id': 'sensor.bedroom_wi_fi', + 'entity_id': 'sensor.bedroom_wi_fi_strength', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -1058,7 +1058,7 @@ }), 'original_device_class': None, 'original_icon': None, - 'original_name': 'Wi-Fi', + 'original_name': 'Wi-Fi strength', 'platform': 'netatmo', 'previous_unique_id': None, 'supported_features': 0, @@ -1067,16 +1067,16 @@ 'unit_of_measurement': None, }) # --- -# name: test_entity[sensor.bedroom_wi_fi-state] +# name: test_entity[sensor.bedroom_wi_fi_strength-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'attribution': 'Data provided by Netatmo', - 'friendly_name': 'Bedroom Wi-Fi', + 'friendly_name': 'Bedroom Wi-Fi strength', 'latitude': 13.377726, 'longitude': 52.516263, }), 'context': , - 'entity_id': 'sensor.bedroom_wi_fi', + 'entity_id': 'sensor.bedroom_wi_fi_strength', 'last_changed': , 'last_reported': , 'last_updated': , @@ -3668,7 +3668,7 @@ 'state': 'unknown', }) # --- -# name: test_entity[sensor.kitchen_wi_fi-entry] +# name: test_entity[sensor.kitchen_wi_fi_strength-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -3681,7 +3681,7 @@ 'disabled_by': None, 'domain': 'sensor', 'entity_category': , - 'entity_id': 'sensor.kitchen_wi_fi', + 'entity_id': 'sensor.kitchen_wi_fi_strength', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -3693,7 +3693,7 @@ }), 'original_device_class': None, 'original_icon': None, - 'original_name': 'Wi-Fi', + 'original_name': 'Wi-Fi strength', 'platform': 'netatmo', 'previous_unique_id': None, 'supported_features': 0, @@ -3702,16 +3702,16 @@ 'unit_of_measurement': None, }) # --- -# name: test_entity[sensor.kitchen_wi_fi-state] +# name: test_entity[sensor.kitchen_wi_fi_strength-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'attribution': 'Data provided by Netatmo', - 'friendly_name': 'Kitchen Wi-Fi', + 'friendly_name': 'Kitchen Wi-Fi strength', 'latitude': 13.377726, 'longitude': 52.516263, }), 'context': , - 'entity_id': 'sensor.kitchen_wi_fi', + 'entity_id': 'sensor.kitchen_wi_fi_strength', 'last_changed': , 'last_reported': , 'last_updated': , @@ -4511,7 +4511,7 @@ 'state': 'unknown', }) # --- -# name: test_entity[sensor.livingroom_wi_fi-entry] +# name: test_entity[sensor.livingroom_wi_fi_strength-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -4524,7 +4524,7 @@ 'disabled_by': None, 'domain': 'sensor', 'entity_category': , - 'entity_id': 'sensor.livingroom_wi_fi', + 'entity_id': 'sensor.livingroom_wi_fi_strength', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -4536,7 +4536,7 @@ }), 'original_device_class': None, 'original_icon': None, - 'original_name': 'Wi-Fi', + 'original_name': 'Wi-Fi strength', 'platform': 'netatmo', 'previous_unique_id': None, 'supported_features': 0, @@ -4545,16 +4545,16 @@ 'unit_of_measurement': None, }) # --- -# name: test_entity[sensor.livingroom_wi_fi-state] +# name: test_entity[sensor.livingroom_wi_fi_strength-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'attribution': 'Data provided by Netatmo', - 'friendly_name': 'Livingroom Wi-Fi', + 'friendly_name': 'Livingroom Wi-Fi strength', 'latitude': 13.377726, 'longitude': 52.516263, }), 'context': , - 'entity_id': 'sensor.livingroom_wi_fi', + 'entity_id': 'sensor.livingroom_wi_fi_strength', 'last_changed': , 'last_reported': , 'last_updated': , @@ -5061,7 +5061,7 @@ 'state': 'unknown', }) # --- -# name: test_entity[sensor.parents_bedroom_wi_fi-entry] +# name: test_entity[sensor.parents_bedroom_wi_fi_strength-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -5074,7 +5074,7 @@ 'disabled_by': None, 'domain': 'sensor', 'entity_category': , - 'entity_id': 'sensor.parents_bedroom_wi_fi', + 'entity_id': 'sensor.parents_bedroom_wi_fi_strength', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -5086,7 +5086,7 @@ }), 'original_device_class': None, 'original_icon': None, - 'original_name': 'Wi-Fi', + 'original_name': 'Wi-Fi strength', 'platform': 'netatmo', 'previous_unique_id': None, 'supported_features': 0, @@ -5095,16 +5095,16 @@ 'unit_of_measurement': None, }) # --- -# name: test_entity[sensor.parents_bedroom_wi_fi-state] +# name: test_entity[sensor.parents_bedroom_wi_fi_strength-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'attribution': 'Data provided by Netatmo', - 'friendly_name': 'Parents Bedroom Wi-Fi', + 'friendly_name': 'Parents Bedroom Wi-Fi strength', 'latitude': 13.377726, 'longitude': 52.516263, }), 'context': , - 'entity_id': 'sensor.parents_bedroom_wi_fi', + 'entity_id': 'sensor.parents_bedroom_wi_fi_strength', 'last_changed': , 'last_reported': , 'last_updated': , @@ -5586,7 +5586,7 @@ 'state': '55', }) # --- -# name: test_entity[sensor.villa_bathroom_radio-entry] +# name: test_entity[sensor.villa_bathroom_rf_strength-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -5599,7 +5599,7 @@ 'disabled_by': None, 'domain': 'sensor', 'entity_category': , - 'entity_id': 'sensor.villa_bathroom_radio', + 'entity_id': 'sensor.villa_bathroom_rf_strength', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -5611,7 +5611,7 @@ }), 'original_device_class': None, 'original_icon': None, - 'original_name': 'Radio', + 'original_name': 'RF strength', 'platform': 'netatmo', 'previous_unique_id': None, 'supported_features': 0, @@ -5620,14 +5620,14 @@ 'unit_of_measurement': None, }) # --- -# name: test_entity[sensor.villa_bathroom_radio-state] +# name: test_entity[sensor.villa_bathroom_rf_strength-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'attribution': 'Data provided by Netatmo', - 'friendly_name': 'Villa Bathroom Radio', + 'friendly_name': 'Villa Bathroom RF strength', }), 'context': , - 'entity_id': 'sensor.villa_bathroom_radio', + 'entity_id': 'sensor.villa_bathroom_rf_strength', 'last_changed': , 'last_reported': , 'last_updated': , @@ -5945,7 +5945,7 @@ 'state': '53', }) # --- -# name: test_entity[sensor.villa_bedroom_radio-entry] +# name: test_entity[sensor.villa_bedroom_rf_strength-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -5958,7 +5958,7 @@ 'disabled_by': None, 'domain': 'sensor', 'entity_category': , - 'entity_id': 'sensor.villa_bedroom_radio', + 'entity_id': 'sensor.villa_bedroom_rf_strength', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -5970,7 +5970,7 @@ }), 'original_device_class': None, 'original_icon': None, - 'original_name': 'Radio', + 'original_name': 'RF strength', 'platform': 'netatmo', 'previous_unique_id': None, 'supported_features': 0, @@ -5979,14 +5979,14 @@ 'unit_of_measurement': None, }) # --- -# name: test_entity[sensor.villa_bedroom_radio-state] +# name: test_entity[sensor.villa_bedroom_rf_strength-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'attribution': 'Data provided by Netatmo', - 'friendly_name': 'Villa Bedroom Radio', + 'friendly_name': 'Villa Bedroom RF strength', }), 'context': , - 'entity_id': 'sensor.villa_bedroom_radio', + 'entity_id': 'sensor.villa_bedroom_rf_strength', 'last_changed': , 'last_reported': , 'last_updated': , @@ -6429,7 +6429,7 @@ 'state': '9', }) # --- -# name: test_entity[sensor.villa_garden_radio-entry] +# name: test_entity[sensor.villa_garden_rf_strength-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -6442,7 +6442,7 @@ 'disabled_by': None, 'domain': 'sensor', 'entity_category': , - 'entity_id': 'sensor.villa_garden_radio', + 'entity_id': 'sensor.villa_garden_rf_strength', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -6454,7 +6454,7 @@ }), 'original_device_class': None, 'original_icon': None, - 'original_name': 'Radio', + 'original_name': 'RF strength', 'platform': 'netatmo', 'previous_unique_id': None, 'supported_features': 0, @@ -6463,14 +6463,14 @@ 'unit_of_measurement': None, }) # --- -# name: test_entity[sensor.villa_garden_radio-state] +# name: test_entity[sensor.villa_garden_rf_strength-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'attribution': 'Data provided by Netatmo', - 'friendly_name': 'Villa Garden Radio', + 'friendly_name': 'Villa Garden RF strength', }), 'context': , - 'entity_id': 'sensor.villa_garden_radio', + 'entity_id': 'sensor.villa_garden_rf_strength', 'last_changed': , 'last_reported': , 'last_updated': , @@ -6917,7 +6917,7 @@ 'state': 'unavailable', }) # --- -# name: test_entity[sensor.villa_outdoor_radio-entry] +# name: test_entity[sensor.villa_outdoor_rf_strength-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -6930,7 +6930,7 @@ 'disabled_by': None, 'domain': 'sensor', 'entity_category': , - 'entity_id': 'sensor.villa_outdoor_radio', + 'entity_id': 'sensor.villa_outdoor_rf_strength', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -6942,7 +6942,7 @@ }), 'original_device_class': None, 'original_icon': None, - 'original_name': 'Radio', + 'original_name': 'RF strength', 'platform': 'netatmo', 'previous_unique_id': None, 'supported_features': 0, @@ -6951,14 +6951,14 @@ 'unit_of_measurement': None, }) # --- -# name: test_entity[sensor.villa_outdoor_radio-state] +# name: test_entity[sensor.villa_outdoor_rf_strength-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'attribution': 'Data provided by Netatmo', - 'friendly_name': 'Villa Outdoor Radio', + 'friendly_name': 'Villa Outdoor RF strength', }), 'context': , - 'entity_id': 'sensor.villa_outdoor_radio', + 'entity_id': 'sensor.villa_outdoor_rf_strength', 'last_changed': , 'last_reported': , 'last_updated': , @@ -7382,7 +7382,7 @@ 'state': '6.9', }) # --- -# name: test_entity[sensor.villa_rain_radio-entry] +# name: test_entity[sensor.villa_rain_rf_strength-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -7395,7 +7395,7 @@ 'disabled_by': None, 'domain': 'sensor', 'entity_category': , - 'entity_id': 'sensor.villa_rain_radio', + 'entity_id': 'sensor.villa_rain_rf_strength', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -7407,7 +7407,7 @@ }), 'original_device_class': None, 'original_icon': None, - 'original_name': 'Radio', + 'original_name': 'RF strength', 'platform': 'netatmo', 'previous_unique_id': None, 'supported_features': 0, @@ -7416,14 +7416,14 @@ 'unit_of_measurement': None, }) # --- -# name: test_entity[sensor.villa_rain_radio-state] +# name: test_entity[sensor.villa_rain_rf_strength-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'attribution': 'Data provided by Netatmo', - 'friendly_name': 'Villa Rain Radio', + 'friendly_name': 'Villa Rain RF strength', }), 'context': , - 'entity_id': 'sensor.villa_rain_radio', + 'entity_id': 'sensor.villa_rain_rf_strength', 'last_changed': , 'last_reported': , 'last_updated': , @@ -7636,7 +7636,7 @@ 'state': 'stable', }) # --- -# name: test_entity[sensor.villa_wi_fi-entry] +# name: test_entity[sensor.villa_wi_fi_strength-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -7649,7 +7649,7 @@ 'disabled_by': None, 'domain': 'sensor', 'entity_category': , - 'entity_id': 'sensor.villa_wi_fi', + 'entity_id': 'sensor.villa_wi_fi_strength', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -7661,7 +7661,7 @@ }), 'original_device_class': None, 'original_icon': None, - 'original_name': 'Wi-Fi', + 'original_name': 'Wi-Fi strength', 'platform': 'netatmo', 'previous_unique_id': None, 'supported_features': 0, @@ -7670,16 +7670,16 @@ 'unit_of_measurement': None, }) # --- -# name: test_entity[sensor.villa_wi_fi-state] +# name: test_entity[sensor.villa_wi_fi_strength-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'attribution': 'Data provided by Netatmo', - 'friendly_name': 'Villa Wi-Fi', + 'friendly_name': 'Villa Wi-Fi strength', 'latitude': 46.123456, 'longitude': 6.1234567, }), 'context': , - 'entity_id': 'sensor.villa_wi_fi', + 'entity_id': 'sensor.villa_wi_fi_strength', 'last_changed': , 'last_reported': , 'last_updated': , diff --git a/tests/components/netatmo/test_sensor.py b/tests/components/netatmo/test_sensor.py index 2c47cdefa60..e9e1ff4739e 100644 --- a/tests/components/netatmo/test_sensor.py +++ b/tests/components/netatmo/test_sensor.py @@ -153,7 +153,7 @@ async def test_process_health(health: int, expected: str) -> None: ("uid", "name", "expected"), [ ("12:34:56:03:1b:e4-reachable", "villa_garden_reachable", "True"), - ("12:34:56:03:1b:e4-rf_status", "villa_garden_radio", "Full"), + ("12:34:56:03:1b:e4-rf_status", "villa_garden_rf_strength", "Full"), ( "12:34:56:80:bb:26-wifi_status", "villa_wifi_strength", @@ -205,7 +205,7 @@ async def test_process_health(health: int, expected: str) -> None: ), ( "12:34:56:26:68:92-wifi_status", - "baby_bedroom_wifi", + "baby_bedroom_wifi_strength", "High", ), ("Home-max-windangle_value", "home_max_wind_angle", "17"), From 3aae280de50a92cdfcf5c04b088fe9178fba0b2e Mon Sep 17 00:00:00 2001 From: elmurato <1382097+elmurato@users.noreply.github.com> Date: Tue, 8 Apr 2025 18:52:26 +0200 Subject: [PATCH 379/505] Fix blocking call in Pterodactyl (#142518) * Fix blocking call * Group blocking calls into a single executor job, catch StopIteration --- homeassistant/components/pterodactyl/api.py | 18 +++++++++++++----- 1 file changed, 13 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/pterodactyl/api.py b/homeassistant/components/pterodactyl/api.py index 40ede9de103..2aac359a5c6 100644 --- a/homeassistant/components/pterodactyl/api.py +++ b/homeassistant/components/pterodactyl/api.py @@ -63,15 +63,24 @@ class PterodactylAPI: self.pterodactyl = None self.identifiers = [] + def get_game_servers(self) -> list[str]: + """Get all game servers.""" + paginated_response = self.pterodactyl.client.servers.list_servers() # type: ignore[union-attr] + + return paginated_response.collect() + async def async_init(self): """Initialize the Pterodactyl API.""" self.pterodactyl = PterodactylClient(self.host, self.api_key) try: - paginated_response = await self.hass.async_add_executor_job( - self.pterodactyl.client.servers.list_servers - ) - except (BadRequestError, PterodactylApiError, ConnectionError) as error: + game_servers = await self.hass.async_add_executor_job(self.get_game_servers) + except ( + BadRequestError, + PterodactylApiError, + ConnectionError, + StopIteration, + ) as error: raise PterodactylConnectionError(error) from error except HTTPError as error: if error.response.status_code == 401: @@ -79,7 +88,6 @@ class PterodactylAPI: raise PterodactylConnectionError(error) from error else: - game_servers = paginated_response.collect() for game_server in game_servers: self.identifiers.append(game_server["attributes"]["identifier"]) From f6b55c7eb9ca4a4e03299e2bf97c8c759bc4b40d Mon Sep 17 00:00:00 2001 From: Thomas55555 <59625598+Thomas55555@users.noreply.github.com> Date: Tue, 8 Apr 2025 20:40:13 +0200 Subject: [PATCH 380/505] Fix adding devices in Husqvarna Automower (#142549) --- homeassistant/components/husqvarna_automower/coordinator.py | 1 + 1 file changed, 1 insertion(+) diff --git a/homeassistant/components/husqvarna_automower/coordinator.py b/homeassistant/components/husqvarna_automower/coordinator.py index 9456074596a..c23ca508916 100644 --- a/homeassistant/components/husqvarna_automower/coordinator.py +++ b/homeassistant/components/husqvarna_automower/coordinator.py @@ -136,6 +136,7 @@ class AutomowerDataUpdateCoordinator(DataUpdateCoordinator[MowerDictionary]): # Process new device new_devices = current_devices - self._devices_last_update if new_devices: + self.data = data _LOGGER.debug("New devices found: %s", ", ".join(map(str, new_devices))) self._add_new_devices(new_devices) From ff8b96a19fd917726a7c5654206b4f6817d02cc0 Mon Sep 17 00:00:00 2001 From: tronikos Date: Tue, 8 Apr 2025 11:46:09 -0700 Subject: [PATCH 381/505] Fix range of Google Generative AI temperature (#142513) --- .../components/google_generative_ai_conversation/config_flow.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/google_generative_ai_conversation/config_flow.py b/homeassistant/components/google_generative_ai_conversation/config_flow.py index ac6cb696a7d..ee980c9bf48 100644 --- a/homeassistant/components/google_generative_ai_conversation/config_flow.py +++ b/homeassistant/components/google_generative_ai_conversation/config_flow.py @@ -303,7 +303,7 @@ async def google_generative_ai_config_option_schema( CONF_TEMPERATURE, description={"suggested_value": options.get(CONF_TEMPERATURE)}, default=RECOMMENDED_TEMPERATURE, - ): NumberSelector(NumberSelectorConfig(min=0, max=1, step=0.05)), + ): NumberSelector(NumberSelectorConfig(min=0, max=2, step=0.05)), vol.Optional( CONF_TOP_P, description={"suggested_value": options.get(CONF_TOP_P)}, From ec520b8cf59785ce5e3c817616133f2639c29a45 Mon Sep 17 00:00:00 2001 From: Andrew Sayre <6730289+andrewsayre@users.noreply.github.com> Date: Tue, 8 Apr 2025 16:38:48 -0500 Subject: [PATCH 382/505] Bump pyheos to v1.0.5 (#142554) Update pyheos --- homeassistant/components/heos/manifest.json | 2 +- homeassistant/components/heos/media_player.py | 1 + requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 4 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/heos/manifest.json b/homeassistant/components/heos/manifest.json index cbac9f20574..8a88913456d 100644 --- a/homeassistant/components/heos/manifest.json +++ b/homeassistant/components/heos/manifest.json @@ -8,7 +8,7 @@ "iot_class": "local_push", "loggers": ["pyheos"], "quality_scale": "platinum", - "requirements": ["pyheos==1.0.4"], + "requirements": ["pyheos==1.0.5"], "ssdp": [ { "st": "urn:schemas-denon-com:device:ACT-Denon:1" diff --git a/homeassistant/components/heos/media_player.py b/homeassistant/components/heos/media_player.py index 294da492e31..810244a815a 100644 --- a/homeassistant/components/heos/media_player.py +++ b/homeassistant/components/heos/media_player.py @@ -71,6 +71,7 @@ BASE_SUPPORTED_FEATURES = ( PLAY_STATE_TO_STATE = { None: MediaPlayerState.IDLE, + PlayState.UNKNOWN: MediaPlayerState.IDLE, PlayState.PLAY: MediaPlayerState.PLAYING, PlayState.STOP: MediaPlayerState.IDLE, PlayState.PAUSE: MediaPlayerState.PAUSED, diff --git a/requirements_all.txt b/requirements_all.txt index 9c658abba0a..b59eb1a20fd 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2005,7 +2005,7 @@ pygti==0.9.4 pyhaversion==22.8.0 # homeassistant.components.heos -pyheos==1.0.4 +pyheos==1.0.5 # homeassistant.components.hive pyhive-integration==1.0.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 54e105805bb..531e624107a 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1635,7 +1635,7 @@ pygti==0.9.4 pyhaversion==22.8.0 # homeassistant.components.heos -pyheos==1.0.4 +pyheos==1.0.5 # homeassistant.components.hive pyhive-integration==1.0.2 From f872dc8948cfffcfdf5b56c66a4f0cad7d640c12 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ab=C3=ADlio=20Costa?= Date: Tue, 8 Apr 2025 22:39:45 +0100 Subject: [PATCH 383/505] Use base entity class for Whirlpool climate (#142548) * Use base entity class for Whirlpool climate * Set model_id instead of model --- homeassistant/components/whirlpool/climate.py | 32 +++---------------- homeassistant/components/whirlpool/entity.py | 3 +- tests/components/whirlpool/test_climate.py | 10 +++--- 3 files changed, 11 insertions(+), 34 deletions(-) diff --git a/homeassistant/components/whirlpool/climate.py b/homeassistant/components/whirlpool/climate.py index 84a2c0d52ca..eb9e63efd44 100644 --- a/homeassistant/components/whirlpool/climate.py +++ b/homeassistant/components/whirlpool/climate.py @@ -8,7 +8,6 @@ from typing import Any from whirlpool.aircon import Aircon, FanSpeed as AirconFanSpeed, Mode as AirconMode from homeassistant.components.climate import ( - ENTITY_ID_FORMAT, FAN_AUTO, FAN_HIGH, FAN_LOW, @@ -22,12 +21,10 @@ from homeassistant.components.climate import ( ) from homeassistant.const import ATTR_TEMPERATURE, UnitOfTemperature from homeassistant.core import HomeAssistant -from homeassistant.helpers.device_registry import DeviceInfo -from homeassistant.helpers.entity import generate_entity_id from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import WhirlpoolConfigEntry -from .const import DOMAIN +from .entity import WhirlpoolEntity _LOGGER = logging.getLogger(__name__) @@ -71,10 +68,10 @@ async def async_setup_entry( """Set up entry.""" appliances_manager = config_entry.runtime_data aircons = [AirConEntity(hass, aircon) for aircon in appliances_manager.aircons] - async_add_entities(aircons, True) + async_add_entities(aircons) -class AirConEntity(ClimateEntity): +class AirConEntity(WhirlpoolEntity, ClimateEntity): """Representation of an air conditioner.""" _attr_fan_modes = SUPPORTED_FAN_MODES @@ -97,29 +94,8 @@ class AirConEntity(ClimateEntity): def __init__(self, hass: HomeAssistant, aircon: Aircon) -> None: """Initialize the entity.""" + super().__init__(aircon) self._aircon = aircon - self.entity_id = generate_entity_id(ENTITY_ID_FORMAT, aircon.said, hass=hass) - self._attr_unique_id = aircon.said - - self._attr_device_info = DeviceInfo( - identifiers={(DOMAIN, aircon.said)}, - name=aircon.name if aircon.name is not None else aircon.said, - manufacturer="Whirlpool", - model="Sixth Sense", - ) - - async def async_added_to_hass(self) -> None: - """Register updates callback.""" - self._aircon.register_attr_callback(self.async_write_ha_state) - - async def async_will_remove_from_hass(self) -> None: - """Unregister updates callback.""" - self._aircon.unregister_attr_callback(self.async_write_ha_state) - - @property - def available(self) -> bool: - """Return True if entity is available.""" - return self._aircon.get_online() @property def current_temperature(self) -> float: diff --git a/homeassistant/components/whirlpool/entity.py b/homeassistant/components/whirlpool/entity.py index e74ed596e1e..3f2fc81d358 100644 --- a/homeassistant/components/whirlpool/entity.py +++ b/homeassistant/components/whirlpool/entity.py @@ -19,8 +19,9 @@ class WhirlpoolEntity(Entity): self._attr_device_info = DeviceInfo( identifiers={(DOMAIN, appliance.said)}, - name=appliance.name.capitalize(), + name=appliance.name.capitalize() if appliance.name else appliance.said, manufacturer="Whirlpool", + model_id=appliance.appliance_info.model_number, ) self._attr_unique_id = f"{appliance.said}{unique_id_suffix}" diff --git a/tests/components/whirlpool/test_climate.py b/tests/components/whirlpool/test_climate.py index 0586d654f7f..1a076b76637 100644 --- a/tests/components/whirlpool/test_climate.py +++ b/tests/components/whirlpool/test_climate.py @@ -81,7 +81,7 @@ async def test_static_attributes( await init_integration(hass) for said in ("said1", "said2"): - entity_id = f"climate.{said}" + entity_id = f"climate.aircon_{said}" entry = entity_registry.async_get(entity_id) assert entry assert entry.unique_id == said @@ -138,8 +138,8 @@ async def test_dynamic_attributes( mock_instance_idx: int for clim_test_instance in ( - ClimateTestInstance("climate.said1", mock_aircon1_api, 0), - ClimateTestInstance("climate.said2", mock_aircon2_api, 1), + ClimateTestInstance("climate.aircon_said1", mock_aircon1_api, 0), + ClimateTestInstance("climate.aircon_said2", mock_aircon2_api, 1), ): entity_id = clim_test_instance.entity_id mock_instance = clim_test_instance.mock_instance @@ -225,8 +225,8 @@ async def test_service_calls( mock_instance: MagicMock for clim_test_instance in ( - ClimateInstancesData("climate.said1", mock_aircon1_api), - ClimateInstancesData("climate.said2", mock_aircon2_api), + ClimateInstancesData("climate.aircon_said1", mock_aircon1_api), + ClimateInstancesData("climate.aircon_said2", mock_aircon2_api), ): mock_instance = clim_test_instance.mock_instance entity_id = clim_test_instance.entity_id From 5d8c90ae0de5701ca52fcff51ec5b32e65f380d6 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 9 Apr 2025 00:42:12 +0300 Subject: [PATCH 384/505] Bump github/codeql-action from 3.28.13 to 3.28.15 (#142516) Bumps [github/codeql-action](https://github.com/github/codeql-action) from 3.28.13 to 3.28.15. - [Release notes](https://github.com/github/codeql-action/releases) - [Changelog](https://github.com/github/codeql-action/blob/main/CHANGELOG.md) - [Commits](https://github.com/github/codeql-action/compare/v3.28.13...v3.28.15) --- updated-dependencies: - dependency-name: github/codeql-action dependency-version: 3.28.15 dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/codeql.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index bd072752d16..9a926c18d76 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -24,11 +24,11 @@ jobs: uses: actions/checkout@v4.2.2 - name: Initialize CodeQL - uses: github/codeql-action/init@v3.28.13 + uses: github/codeql-action/init@v3.28.15 with: languages: python - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@v3.28.13 + uses: github/codeql-action/analyze@v3.28.15 with: category: "/language:python" From 528ca4936891b7399f06e3dc19bf867280e3261b Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Tue, 8 Apr 2025 23:55:00 +0200 Subject: [PATCH 385/505] Improve Syncthru tests (#142338) --- tests/components/syncthru/conftest.py | 29 +++ tests/components/syncthru/fixtures/state.json | 182 ++++++++++++++++++ tests/components/syncthru/test_config_flow.py | 72 ++----- 3 files changed, 232 insertions(+), 51 deletions(-) create mode 100644 tests/components/syncthru/conftest.py create mode 100644 tests/components/syncthru/fixtures/state.json diff --git a/tests/components/syncthru/conftest.py b/tests/components/syncthru/conftest.py new file mode 100644 index 00000000000..e21a859ed98 --- /dev/null +++ b/tests/components/syncthru/conftest.py @@ -0,0 +1,29 @@ +"""Conftest for the SyncThru integration tests.""" + +from collections.abc import Generator +from unittest.mock import AsyncMock, patch + +import pytest + +from homeassistant.components.syncthru import DOMAIN + +from tests.common import load_json_object_fixture + + +@pytest.fixture +def mock_syncthru() -> Generator[AsyncMock]: + """Mock the SyncThru class.""" + with ( + patch( + "homeassistant.components.syncthru.SyncThru", + autospec=True, + ) as mock_syncthru, + patch( + "homeassistant.components.syncthru.config_flow.SyncThru", new=mock_syncthru + ), + ): + client = mock_syncthru.return_value + client.model.return_value = "C430W" + client.is_unknown_state.return_value = False + client.raw.return_value = load_json_object_fixture("state.json", DOMAIN) + yield client diff --git a/tests/components/syncthru/fixtures/state.json b/tests/components/syncthru/fixtures/state.json new file mode 100644 index 00000000000..2e4a6202700 --- /dev/null +++ b/tests/components/syncthru/fixtures/state.json @@ -0,0 +1,182 @@ +{ + "status": { + "hrDeviceStatus": 3, + "status1": "", + "status2": "", + "status3": "", + "status4": "" + }, + "identity": { + "model_name": "C430W", + "device_name": "Samsung C430W", + "host_name": "SEC84251907C415", + "location": "Living room", + "serial_num": "08HRB8GJ3F019DD", + "ip_addr": "192.168.0.251", + "ipv6_link_addr": "", + "mac_addr": "84:25:19:07:C4:15", + "admin_email": "", + "admin_name": "", + "admin_phone": "", + "customer_support": "" + }, + "toner_black": { + "opt": 1, + "remaining": 8, + "cnt": 1176, + "newError": "C1-5110" + }, + "toner_cyan": { + "opt": 1, + "remaining": 98, + "cnt": 25, + "newError": "" + }, + "toner_magenta": { + "opt": 1, + "remaining": 98, + "cnt": 25, + "newError": "" + }, + "toner_yellow": { + "opt": 1, + "remaining": 97, + "cnt": 27, + "newError": "" + }, + "drum_black": { + "opt": 0, + "remaining": 44, + "newError": "" + }, + "drum_cyan": { + "opt": 0, + "remaining": 100, + "newError": "" + }, + "drum_magenta": { + "opt": 0, + "remaining": 100, + "newError": "" + }, + "drum_yellow": { + "opt": 0, + "remaining": 100, + "newError": "" + }, + "drum_color": { + "opt": 1, + "remaining": 44, + "newError": "" + }, + "tray1": { + "opt": 1, + "paper_size1": 4, + "paper_size2": 0, + "paper_type1": 2, + "paper_type2": 0, + "paper_level": 0, + "capa": 150, + "newError": "" + }, + "tray2": { + "opt": 0, + "paper_size1": 0, + "paper_size2": 0, + "paper_type1": 2, + "paper_type2": 0, + "paper_level": 0, + "capa": 0, + "newError": "" + }, + "tray3": { + "opt": 0, + "paper_size1": 0, + "paper_size2": 0, + "paper_type1": 2, + "paper_type2": 0, + "paper_level": 0, + "capa": 0, + "newError": "" + }, + "tray4": { + "opt": 0, + "paper_size1": 0, + "paper_size2": 0, + "paper_type1": 2, + "paper_type2": 0, + "paper_level": 0, + "capa": 0, + "newError": "" + }, + "tray5": { + "opt": 0, + "paper_size1": 0, + "paper_size2": 0, + "paper_type1": 2, + "paper_type2": 0, + "paper_level": 0, + "capa": 0, + "newError": "0" + }, + "mp": { + "opt": 0, + "paper_size1": 0, + "paper_size2": 0, + "paper_type1": 2, + "paper_type2": 0, + "paper_level": 0, + "capa": 0, + "newError": "" + }, + "manual": { + "opt": 0, + "paper_size1": 0, + "paper_size2": 0, + "paper_type1": 2, + "paper_type2": 0, + "capa": 0, + "newError": "" + }, + "GXI_INTRAY_MANUALFEEDING_TRAY_SUPPORT": 0, + "GXI_INSTALL_OPTION_MULTIBIN": 0, + "multibin": [0], + "outputTray": [[1, 50, ""]], + "capability": { + "hdd": { + "opt": 2, + "capa": 40 + }, + "ram": { + "opt": 65536, + "capa": 65536 + }, + "scanner": { + "opt": 0, + "capa": 0 + } + }, + "options": { + "hdd": 0, + "wlan": 1 + }, + "GXI_ACTIVE_ALERT_TOTAL": 2, + "GXI_ADMIN_WUI_HAS_DEFAULT_PASS": 0, + "GXI_SUPPORT_COLOR": 1, + "GXI_SYS_LUI_SUPPORT": 0, + "GXI_A3_SUPPORT": 0, + "GXI_TRAY2_MANDATORY_SUPPORT": 0, + "GXI_SWS_ADMIN_USE_AAA": 0, + "GXI_TONER_BLACK_VALID": 1, + "GXI_TONER_CYAN_VALID": 1, + "GXI_TONER_MAGENTA_VALID": 1, + "GXI_TONER_YELLOW_VALID": 1, + "GXI_IMAGING_BLACK_VALID": 1, + "GXI_IMAGING_CYAN_VALID": 1, + "GXI_IMAGING_MAGENTA_VALID": 1, + "GXI_IMAGING_YELLOW_VALID": 1, + "GXI_IMAGING_COLOR_VALID": 1, + "GXI_SUPPORT_PAPER_SETTING": 1, + "GXI_SUPPORT_PAPER_LEVEL": 0, + "GXI_SUPPORT_MULTI_PASS": 1 +} diff --git a/tests/components/syncthru/test_config_flow.py b/tests/components/syncthru/test_config_flow.py index 727b95563cc..c551c94506e 100644 --- a/tests/components/syncthru/test_config_flow.py +++ b/tests/components/syncthru/test_config_flow.py @@ -1,12 +1,10 @@ """Tests for syncthru config flow.""" -import re -from unittest.mock import patch +from unittest.mock import AsyncMock, patch from pysyncthru import SyncThruAPINotSupported from homeassistant import config_entries -from homeassistant.components.syncthru.config_flow import SyncThru from homeassistant.components.syncthru.const import DOMAIN from homeassistant.const import CONF_NAME, CONF_URL from homeassistant.core import HomeAssistant @@ -21,7 +19,6 @@ from homeassistant.helpers.service_info.ssdp import ( ) from tests.common import MockConfigEntry -from tests.test_util.aiohttp import AiohttpClientMocker FIXTURE_USER_INPUT = { CONF_URL: "http://192.168.1.2/", @@ -29,25 +26,7 @@ FIXTURE_USER_INPUT = { } -def mock_connection(aioclient_mock): - """Mock syncthru connection.""" - aioclient_mock.get( - re.compile("."), - text=""" -{ -\tstatus: { -\thrDeviceStatus: 2, -\tstatus1: " Sleeping... " -\t}, -\tidentity: { -\tserial_num: "000000000000000", -\t} -} - """, - ) - - -async def test_show_setup_form(hass: HomeAssistant) -> None: +async def test_show_setup_form(hass: HomeAssistant, mock_syncthru: AsyncMock) -> None: """Test that the setup form is served.""" result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER}, data=None @@ -58,7 +37,7 @@ async def test_show_setup_form(hass: HomeAssistant) -> None: async def test_already_configured_by_url( - hass: HomeAssistant, aioclient_mock: AiohttpClientMocker + hass: HomeAssistant, mock_syncthru: AsyncMock ) -> None: """Test we match and update already configured devices by URL.""" @@ -69,7 +48,6 @@ async def test_already_configured_by_url( title="Already configured", unique_id=udn, ).add_to_hass(hass) - mock_connection(aioclient_mock) result = await hass.config_entries.flow.async_init( DOMAIN, @@ -83,44 +61,39 @@ async def test_already_configured_by_url( assert result["result"].unique_id == udn -async def test_syncthru_not_supported(hass: HomeAssistant) -> None: +async def test_syncthru_not_supported( + hass: HomeAssistant, mock_syncthru: AsyncMock +) -> None: """Test we show user form on unsupported device.""" - with patch.object(SyncThru, "update", side_effect=SyncThruAPINotSupported): - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": config_entries.SOURCE_USER}, - data=FIXTURE_USER_INPUT, - ) + mock_syncthru.update.side_effect = SyncThruAPINotSupported + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_USER}, + data=FIXTURE_USER_INPUT, + ) assert result["type"] is FlowResultType.FORM assert result["step_id"] == "user" assert result["errors"] == {CONF_URL: "syncthru_not_supported"} -async def test_unknown_state(hass: HomeAssistant) -> None: +async def test_unknown_state(hass: HomeAssistant, mock_syncthru: AsyncMock) -> None: """Test we show user form on unsupported device.""" - with ( - patch.object(SyncThru, "update"), - patch.object(SyncThru, "is_unknown_state", return_value=True), - ): - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": config_entries.SOURCE_USER}, - data=FIXTURE_USER_INPUT, - ) + mock_syncthru.is_unknown_state.return_value = True + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_USER}, + data=FIXTURE_USER_INPUT, + ) assert result["type"] is FlowResultType.FORM assert result["step_id"] == "user" assert result["errors"] == {CONF_URL: "unknown_state"} -async def test_success( - hass: HomeAssistant, aioclient_mock: AiohttpClientMocker -) -> None: +async def test_success(hass: HomeAssistant, mock_syncthru: AsyncMock) -> None: """Test successful flow provides entry creation data.""" - mock_connection(aioclient_mock) - with patch( "homeassistant.components.syncthru.async_setup_entry", return_value=True ) as mock_setup_entry: @@ -129,18 +102,15 @@ async def test_success( context={"source": config_entries.SOURCE_USER}, data=FIXTURE_USER_INPUT, ) - await hass.async_block_till_done() assert result["type"] is FlowResultType.CREATE_ENTRY assert result["data"][CONF_URL] == FIXTURE_USER_INPUT[CONF_URL] assert len(mock_setup_entry.mock_calls) == 1 -async def test_ssdp(hass: HomeAssistant, aioclient_mock: AiohttpClientMocker) -> None: +async def test_ssdp(hass: HomeAssistant, mock_syncthru: AsyncMock) -> None: """Test SSDP discovery initiates config properly.""" - mock_connection(aioclient_mock) - url = "http://192.168.1.2/" result = await hass.config_entries.flow.async_init( DOMAIN, From 271a4ba7c875eaaaa28c15ecf1f0c623f12ef884 Mon Sep 17 00:00:00 2001 From: puddly <32534428+puddly@users.noreply.github.com> Date: Tue, 8 Apr 2025 22:02:51 -0400 Subject: [PATCH 386/505] Fix Core deadlock by ensuring only one ZHA log queue handler thread is running at a time (#142568) Ensure only one log queue handler is running at a time --- homeassistant/components/zha/helpers.py | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/zha/helpers.py b/homeassistant/components/zha/helpers.py index 700e2833705..c819f94ceba 100644 --- a/homeassistant/components/zha/helpers.py +++ b/homeassistant/components/zha/helpers.py @@ -514,6 +514,7 @@ class ZHAGatewayProxy(EventBase): self._log_queue_handler.listener = logging.handlers.QueueListener( log_simple_queue, log_relay_handler ) + self._log_queue_handler_count: int = 0 self._unsubs: list[Callable[[], None]] = [] self._unsubs.append(self.gateway.on_all_events(self._handle_event_protocol)) @@ -747,7 +748,10 @@ class ZHAGatewayProxy(EventBase): if filterer: self._log_queue_handler.addFilter(filterer) - if self._log_queue_handler.listener: + # Only start a new log queue handler if the old one is no longer running + self._log_queue_handler_count += 1 + + if self._log_queue_handler.listener and self._log_queue_handler_count == 1: self._log_queue_handler.listener.start() for logger_name in DEBUG_RELAY_LOGGERS: @@ -763,7 +767,10 @@ class ZHAGatewayProxy(EventBase): for logger_name in DEBUG_RELAY_LOGGERS: logging.getLogger(logger_name).removeHandler(self._log_queue_handler) - if self._log_queue_handler.listener: + # Only stop the log queue handler if nothing else is using it + self._log_queue_handler_count -= 1 + + if self._log_queue_handler.listener and self._log_queue_handler_count == 0: self._log_queue_handler.listener.stop() if filterer: From d4f47bfc6b1e36f30f4b8d9dd562de3d975cb0d5 Mon Sep 17 00:00:00 2001 From: Fredrik Erlandsson Date: Wed, 9 Apr 2025 08:51:44 +0200 Subject: [PATCH 387/505] Fix ssl_cert load from config_flow (#142570) fix ssl_cert load from config_flow --- homeassistant/components/daikin/config_flow.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/homeassistant/components/daikin/config_flow.py b/homeassistant/components/daikin/config_flow.py index cc25a88ae39..f5febafc4dc 100644 --- a/homeassistant/components/daikin/config_flow.py +++ b/homeassistant/components/daikin/config_flow.py @@ -18,6 +18,7 @@ from homeassistant.config_entries import ConfigFlow, ConfigFlowResult from homeassistant.const import CONF_API_KEY, CONF_HOST, CONF_PASSWORD, CONF_UUID from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo +from homeassistant.util.ssl import client_context_no_verify from .const import DOMAIN, KEY_MAC, TIMEOUT @@ -90,6 +91,7 @@ class FlowHandler(ConfigFlow, domain=DOMAIN): key=key, uuid=uuid, password=password, + ssl_context=client_context_no_verify(), ) except (TimeoutError, ClientError): self.host = None From 06a2de4d1cf9f07f089a5b1232b2e9ab6379304f Mon Sep 17 00:00:00 2001 From: Maciej Bieniek Date: Wed, 9 Apr 2025 08:53:44 +0200 Subject: [PATCH 388/505] Fix Shelly initialization if device runs large script (#142487) * Don't check the whole script to see if it generates events * Fix tests --------- Co-authored-by: Shay Levy --- homeassistant/components/shelly/const.py | 4 ++++ homeassistant/components/shelly/utils.py | 3 ++- tests/components/shelly/conftest.py | 8 ++++++-- 3 files changed, 12 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/shelly/const.py b/homeassistant/components/shelly/const.py index 43fb6df18d0..0c64df52409 100644 --- a/homeassistant/components/shelly/const.py +++ b/homeassistant/components/shelly/const.py @@ -277,3 +277,7 @@ ROLE_TO_DEVICE_CLASS_MAP = { "current_humidity": SensorDeviceClass.HUMIDITY, "current_temperature": SensorDeviceClass.TEMPERATURE, } + +# We want to check only the first 5 KB of the script if it contains emitEvent() +# so that the integration startup remains fast. +MAX_SCRIPT_SIZE = 5120 diff --git a/homeassistant/components/shelly/utils.py b/homeassistant/components/shelly/utils.py index 474e2bb9410..a5e08faf0e0 100644 --- a/homeassistant/components/shelly/utils.py +++ b/homeassistant/components/shelly/utils.py @@ -58,6 +58,7 @@ from .const import ( GEN2_BETA_RELEASE_URL, GEN2_RELEASE_URL, LOGGER, + MAX_SCRIPT_SIZE, RPC_INPUTS_EVENTS_TYPES, SHAIR_MAX_WORK_HOURS, SHBTN_INPUTS_EVENTS_TYPES, @@ -642,7 +643,7 @@ def get_rpc_ws_url(hass: HomeAssistant) -> str | None: async def get_rpc_script_event_types(device: RpcDevice, id: int) -> list[str]: """Return a list of event types for a specific script.""" - code_response = await device.script_getcode(id) + code_response = await device.script_getcode(id, bytes_to_read=MAX_SCRIPT_SIZE) matches = SHELLY_EMIT_EVENT_PATTERN.finditer(code_response["data"]) return sorted([*{str(event_type.group(1)) for event_type in matches}]) diff --git a/tests/components/shelly/conftest.py b/tests/components/shelly/conftest.py index 8f8255235be..2a386a1628c 100644 --- a/tests/components/shelly/conftest.py +++ b/tests/components/shelly/conftest.py @@ -492,7 +492,9 @@ def _mock_rpc_device(version: str | None = None): initialized=True, connected=True, script_getcode=AsyncMock( - side_effect=lambda script_id: {"data": MOCK_SCRIPTS[script_id - 1]} + side_effect=lambda script_id, bytes_to_read: { + "data": MOCK_SCRIPTS[script_id - 1] + } ), xmod_info={}, ) @@ -514,7 +516,9 @@ def _mock_blu_rtv_device(version: str | None = None): initialized=True, connected=True, script_getcode=AsyncMock( - side_effect=lambda script_id: {"data": MOCK_SCRIPTS[script_id - 1]} + side_effect=lambda script_id, bytes_to_read: { + "data": MOCK_SCRIPTS[script_id - 1] + } ), xmod_info={}, ) From 762c752918d371bafaf8d3f30d336cf1d49d0488 Mon Sep 17 00:00:00 2001 From: TimL Date: Wed, 9 Apr 2025 16:55:09 +1000 Subject: [PATCH 389/505] Set quality scale to silver for SMLIGHT integration (#142448) * Add quality scale for SMLIGHT * Review and update all rules * Add missing data_description strings as detected by CI * update for a few merged docs PR's * Parallel updates done https://github.com/home-assistant/core/pull/142455 * Set quality scale to silver * Update homeassistant/components/smlight/quality_scale.yaml * Update homeassistant/components/smlight/quality_scale.yaml --------- Co-authored-by: Josef Zweck --- .../components/smlight/manifest.json | 1 + .../components/smlight/quality_scale.yaml | 85 +++++++++++++++++++ homeassistant/components/smlight/strings.json | 8 ++ script/hassfest/quality_scale.py | 2 - 4 files changed, 94 insertions(+), 2 deletions(-) create mode 100644 homeassistant/components/smlight/quality_scale.yaml diff --git a/homeassistant/components/smlight/manifest.json b/homeassistant/components/smlight/manifest.json index e9025203b8c..b2a03a737fc 100644 --- a/homeassistant/components/smlight/manifest.json +++ b/homeassistant/components/smlight/manifest.json @@ -11,6 +11,7 @@ "documentation": "https://www.home-assistant.io/integrations/smlight", "integration_type": "device", "iot_class": "local_push", + "quality_scale": "silver", "requirements": ["pysmlight==0.2.4"], "zeroconf": [ { diff --git a/homeassistant/components/smlight/quality_scale.yaml b/homeassistant/components/smlight/quality_scale.yaml new file mode 100644 index 00000000000..0e1d4616d2a --- /dev/null +++ b/homeassistant/components/smlight/quality_scale.yaml @@ -0,0 +1,85 @@ +rules: + # Bronze + action-setup: + status: exempt + comment: | + This integration does not provide additional actions. + appropriate-polling: done + brands: done + common-modules: done + config-flow-test-coverage: done + config-flow: done + dependency-transparency: done + docs-actions: + status: exempt + comment: | + This integration does not provide additional actions. + docs-high-level-description: done + docs-installation-instructions: done + docs-removal-instructions: done + entity-event-setup: + status: done + comment: | + Entities subscribe to SSE events from pysmlight library. + entity-unique-id: done + has-entity-name: done + runtime-data: done + test-before-configure: done + test-before-setup: + status: done + comment: Handled implicitly within coordinator + unique-config-entry: done + + # Silver + action-exceptions: + status: exempt + comment: | + This integration does not provide actions. + config-entry-unloading: done + docs-configuration-parameters: + status: exempt + comment: | + This integration does not provide an option flow. + docs-installation-parameters: done + entity-unavailable: done + integration-owner: done + log-when-unavailable: + status: done + comment: Handled by coordinator + parallel-updates: done + reauthentication-flow: done + test-coverage: done + + # Gold + devices: done + diagnostics: done + discovery-update-info: done + discovery: done + docs-data-update: done + docs-examples: todo + docs-known-limitations: todo + docs-supported-devices: done + docs-supported-functions: done + docs-troubleshooting: todo + docs-use-cases: todo + dynamic-devices: + status: exempt + comment: | + Device type integration. + entity-category: done + entity-device-class: done + entity-disabled-by-default: done + entity-translations: done + exception-translations: todo + icon-translations: done + reconfiguration-flow: todo + repair-issues: done + stale-devices: + status: exempt + comment: | + Device type integration. + + # Platinum + async-dependency: done + inject-websession: done + strict-typing: done diff --git a/homeassistant/components/smlight/strings.json b/homeassistant/components/smlight/strings.json index ca52f6fea38..b74dab791de 100644 --- a/homeassistant/components/smlight/strings.json +++ b/homeassistant/components/smlight/strings.json @@ -15,6 +15,10 @@ "data": { "username": "[%key:common::config_flow::data::username%]", "password": "[%key:common::config_flow::data::password%]" + }, + "data_description": { + "username": "Username for the device's web login.", + "password": "Password for the device's web login." } }, "reauth_confirm": { @@ -23,6 +27,10 @@ "data": { "username": "[%key:common::config_flow::data::username%]", "password": "[%key:common::config_flow::data::password%]" + }, + "data_description": { + "username": "[%key:component::smlight::config::step::auth::data_description::username%]", + "password": "[%key:component::smlight::config::step::auth::data_description::password%]" } }, "confirm_discovery": { diff --git a/script/hassfest/quality_scale.py b/script/hassfest/quality_scale.py index 5c33743699c..c122856ab5c 100644 --- a/script/hassfest/quality_scale.py +++ b/script/hassfest/quality_scale.py @@ -920,7 +920,6 @@ INTEGRATIONS_WITHOUT_QUALITY_SCALE_FILE = [ "smarttub", "smarty", "smhi", - "smlight", "sms", "smtp", "snapcast", @@ -1993,7 +1992,6 @@ INTEGRATIONS_WITHOUT_SCALE = [ "smarttub", "smarty", "smhi", - "smlight", "sms", "smtp", "snapcast", From 3ca1f07cc44c741d5c3b5f37048b43804b5ed5f1 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Wed, 9 Apr 2025 12:13:56 +0200 Subject: [PATCH 390/505] Remove meaningless asserts in some hassio tests (#142583) --- tests/components/hassio/test_update.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/tests/components/hassio/test_update.py b/tests/components/hassio/test_update.py index a3718454538..d41954b2ab7 100644 --- a/tests/components/hassio/test_update.py +++ b/tests/components/hassio/test_update.py @@ -663,7 +663,7 @@ async def test_update_addon_with_error( update_addon.side_effect = SupervisorError with pytest.raises(HomeAssistantError, match=r"^Error updating test:"): - assert not await hass.services.async_call( + await hass.services.async_call( "update", "install", {"entity_id": "update.test_update"}, @@ -711,7 +711,7 @@ async def test_update_addon_with_backup_and_error( ), pytest.raises(HomeAssistantError, match=message), ): - assert not await hass.services.async_call( + await hass.services.async_call( "update", "install", {"entity_id": "update.test_update", "backup": True}, @@ -738,7 +738,7 @@ async def test_update_os_with_error( with pytest.raises( HomeAssistantError, match=r"^Error updating Home Assistant Operating System:" ): - assert not await hass.services.async_call( + await hass.services.async_call( "update", "install", {"entity_id": "update.home_assistant_operating_system_update"}, @@ -765,7 +765,7 @@ async def test_update_supervisor_with_error( with pytest.raises( HomeAssistantError, match=r"^Error updating Home Assistant Supervisor:" ): - assert not await hass.services.async_call( + await hass.services.async_call( "update", "install", {"entity_id": "update.home_assistant_supervisor_update"}, @@ -792,7 +792,7 @@ async def test_update_core_with_error( with pytest.raises( HomeAssistantError, match=r"^Error updating Home Assistant Core:" ): - assert not await hass.services.async_call( + await hass.services.async_call( "update", "install", {"entity_id": "update.home_assistant_core_update"}, @@ -826,7 +826,7 @@ async def test_update_core_with_backup_and_error( ), pytest.raises(HomeAssistantError, match=r"^Error creating backup:"), ): - assert not await hass.services.async_call( + await hass.services.async_call( "update", "install", {"entity_id": "update.home_assistant_core_update", "backup": True}, From e7c2e86c939688156d9d667a8fc46342b08e849f Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Wed, 9 Apr 2025 13:37:21 +0200 Subject: [PATCH 391/505] Attempt to fix flaky bootstrap test (#142536) --- tests/test_bootstrap.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/test_bootstrap.py b/tests/test_bootstrap.py index ca75dc51c56..7a4f9fda257 100644 --- a/tests/test_bootstrap.py +++ b/tests/test_bootstrap.py @@ -703,8 +703,8 @@ async def test_setup_hass_takes_longer_than_log_slow_startup( return True with ( - patch.object(bootstrap, "LOG_SLOW_STARTUP_INTERVAL", 0.1), - patch.object(bootstrap, "SLOW_STARTUP_CHECK_INTERVAL", 0.05), + patch.object(bootstrap, "LOG_SLOW_STARTUP_INTERVAL", 0.005), + patch.object(bootstrap, "SLOW_STARTUP_CHECK_INTERVAL", 0.005), patch( "homeassistant.components.frontend.async_setup", side_effect=_async_setup_that_blocks_startup, From 075a0ad7801a2b49850a30a4218c708828609de4 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Wed, 9 Apr 2025 15:17:54 +0200 Subject: [PATCH 392/505] Add tests of behavior when completing an aborted data entry flow (#142590) --- tests/test_data_entry_flow.py | 46 +++++++++++++++++++++++++++++++++++ 1 file changed, 46 insertions(+) diff --git a/tests/test_data_entry_flow.py b/tests/test_data_entry_flow.py index 86ba5257001..994d37dcd65 100644 --- a/tests/test_data_entry_flow.py +++ b/tests/test_data_entry_flow.py @@ -210,6 +210,21 @@ async def test_abort_removes_instance(manager: MockFlowManager) -> None: assert len(manager.mock_created_entries) == 0 +async def test_abort_aborted_flow(manager: MockFlowManager) -> None: + """Test return abort from aborted flow.""" + + @manager.mock_reg_handler("test") + class TestFlow(data_entry_flow.FlowHandler): + async def async_step_init(self, user_input=None): + manager.async_abort(self.flow_id) + return self.async_abort(reason="blah") + + with pytest.raises(data_entry_flow.UnknownFlow): + await manager.async_init("test") + assert len(manager.async_progress()) == 0 + assert len(manager.mock_created_entries) == 0 + + async def test_abort_calls_async_remove(manager: MockFlowManager) -> None: """Test abort calling the async_remove FlowHandler method.""" @@ -272,6 +287,37 @@ async def test_create_saves_data(manager: MockFlowManager) -> None: assert entry["source"] is None +async def test_create_aborted_flow(manager: MockFlowManager) -> None: + """Test return create_entry from aborted flow. + + Note: The entry is created even if the flow is already aborted, then the + flow raises an UnknownFlow exception. This behavior is not logical, and + we should consider changing it to not create the entry if the flow is + aborted. + """ + + @manager.mock_reg_handler("test") + class TestFlow(data_entry_flow.FlowHandler): + VERSION = 5 + + async def async_step_init(self, user_input=None): + manager.async_abort(self.flow_id) + return self.async_create_entry(title="Test Title", data="Test Data") + + with pytest.raises(data_entry_flow.UnknownFlow): + await manager.async_init("test") + assert len(manager.async_progress()) == 0 + + # The entry is created even if the flow is aborted + assert len(manager.mock_created_entries) == 1 + + entry = manager.mock_created_entries[0] + assert entry["handler"] == "test" + assert entry["title"] == "Test Title" + assert entry["data"] == "Test Data" + assert entry["source"] is None + + async def test_discovery_init_flow(manager: MockFlowManager) -> None: """Test a flow initialized by discovery.""" From 170e6bdcab30e416fcaa990ba2e6a34e7d91a81c Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Wed, 9 Apr 2025 15:27:52 +0200 Subject: [PATCH 393/505] Protect hass data keys in setup.py (#142589) --- homeassistant/config_entries.py | 8 +-- homeassistant/setup.py | 62 ++++++++++-------- tests/test_setup.py | 107 ++++++++++++++++++++++---------- 3 files changed, 110 insertions(+), 67 deletions(-) diff --git a/homeassistant/config_entries.py b/homeassistant/config_entries.py index ef1865da4be..b47815c9aa9 100644 --- a/homeassistant/config_entries.py +++ b/homeassistant/config_entries.py @@ -72,12 +72,12 @@ from .helpers.json import json_bytes, json_bytes_sorted, json_fragment from .helpers.typing import UNDEFINED, ConfigType, DiscoveryInfoType, UndefinedType from .loader import async_suggest_report_issue from .setup import ( - DATA_SETUP_DONE, SetupPhases, async_pause_setup, async_process_deps_reqs, async_setup_component, async_start_setup, + async_wait_component, ) from .util import ulid as ulid_util from .util.async_ import create_eager_task @@ -2701,11 +2701,7 @@ class ConfigEntries: Config entries which are created after Home Assistant is started can't be waited for, the function will just return if the config entry is loaded or not. """ - setup_done = self.hass.data.get(DATA_SETUP_DONE, {}) - if setup_future := setup_done.get(entry.domain): - await setup_future - # The component was not loaded. - if entry.domain not in self.hass.config.components: + if not await async_wait_component(self.hass, entry.domain): return False return entry.state is ConfigEntryState.LOADED diff --git a/homeassistant/setup.py b/homeassistant/setup.py index 76061b72b73..39f0a7656f3 100644 --- a/homeassistant/setup.py +++ b/homeassistant/setup.py @@ -45,36 +45,36 @@ _LOGGER = logging.getLogger(__name__) ATTR_COMPONENT: Final = "component" -# DATA_SETUP is a dict, indicating domains which are currently +# _DATA_SETUP is a dict, indicating domains which are currently # being setup or which failed to setup: -# - Tasks are added to DATA_SETUP by `async_setup_component`, the key is the domain +# - Tasks are added to _DATA_SETUP by `async_setup_component`, the key is the domain # being setup and the Task is the `_async_setup_component` helper. -# - Tasks are removed from DATA_SETUP if setup was successful, that is, +# - Tasks are removed from _DATA_SETUP if setup was successful, that is, # the task returned True. -DATA_SETUP: HassKey[dict[str, asyncio.Future[bool]]] = HassKey("setup_tasks") +_DATA_SETUP: HassKey[dict[str, asyncio.Future[bool]]] = HassKey("setup_tasks") -# DATA_SETUP_DONE is a dict, indicating components which will be setup: -# - Events are added to DATA_SETUP_DONE during bootstrap by +# _DATA_SETUP_DONE is a dict, indicating components which will be setup: +# - Events are added to _DATA_SETUP_DONE during bootstrap by # async_set_domains_to_be_loaded, the key is the domain which will be loaded. -# - Events are set and removed from DATA_SETUP_DONE when async_setup_component +# - Events are set and removed from _DATA_SETUP_DONE when async_setup_component # is finished, regardless of if the setup was successful or not. -DATA_SETUP_DONE: HassKey[dict[str, asyncio.Future[bool]]] = HassKey("setup_done") +_DATA_SETUP_DONE: HassKey[dict[str, asyncio.Future[bool]]] = HassKey("setup_done") -# DATA_SETUP_STARTED is a dict, indicating when an attempt +# _DATA_SETUP_STARTED is a dict, indicating when an attempt # to setup a component started. -DATA_SETUP_STARTED: HassKey[dict[tuple[str, str | None], float]] = HassKey( +_DATA_SETUP_STARTED: HassKey[dict[tuple[str, str | None], float]] = HassKey( "setup_started" ) -# DATA_SETUP_TIME is a defaultdict, indicating how time was spent +# _DATA_SETUP_TIME is a defaultdict, indicating how time was spent # setting up a component. -DATA_SETUP_TIME: HassKey[ +_DATA_SETUP_TIME: HassKey[ defaultdict[str, defaultdict[str | None, defaultdict[SetupPhases, float]]] ] = HassKey("setup_time") -DATA_DEPS_REQS: HassKey[set[str]] = HassKey("deps_reqs_processed") +_DATA_DEPS_REQS: HassKey[set[str]] = HassKey("deps_reqs_processed") -DATA_PERSISTENT_ERRORS: HassKey[dict[str, str | None]] = HassKey( +_DATA_PERSISTENT_ERRORS: HassKey[dict[str, str | None]] = HassKey( "bootstrap_persistent_errors" ) @@ -104,8 +104,8 @@ def async_notify_setup_error( # pylint: disable-next=import-outside-toplevel from .components import persistent_notification - if (errors := hass.data.get(DATA_PERSISTENT_ERRORS)) is None: - errors = hass.data[DATA_PERSISTENT_ERRORS] = {} + if (errors := hass.data.get(_DATA_PERSISTENT_ERRORS)) is None: + errors = hass.data[_DATA_PERSISTENT_ERRORS] = {} errors[component] = errors.get(component) or display_link @@ -131,8 +131,8 @@ def async_set_domains_to_be_loaded(hass: core.HomeAssistant, domains: set[str]) - Properly handle after_dependencies. - Keep track of domains which will load but have not yet finished loading """ - setup_done_futures = hass.data.setdefault(DATA_SETUP_DONE, {}) - setup_futures = hass.data.setdefault(DATA_SETUP, {}) + setup_done_futures = hass.data.setdefault(_DATA_SETUP_DONE, {}) + setup_futures = hass.data.setdefault(_DATA_SETUP, {}) old_domains = set(setup_futures) | set(setup_done_futures) | hass.config.components if overlap := old_domains & domains: _LOGGER.debug("Domains to be loaded %s already loaded or pending", overlap) @@ -158,8 +158,8 @@ async def async_setup_component( if domain in hass.config.components: return True - setup_futures = hass.data.setdefault(DATA_SETUP, {}) - setup_done_futures = hass.data.setdefault(DATA_SETUP_DONE, {}) + setup_futures = hass.data.setdefault(_DATA_SETUP, {}) + setup_done_futures = hass.data.setdefault(_DATA_SETUP_DONE, {}) if existing_setup_future := setup_futures.get(domain): return await existing_setup_future @@ -200,7 +200,7 @@ async def _async_process_dependencies( Returns a list of dependencies which failed to set up. """ - setup_futures = hass.data.setdefault(DATA_SETUP, {}) + setup_futures = hass.data.setdefault(_DATA_SETUP, {}) dependencies_tasks: dict[str, asyncio.Future[bool]] = {} @@ -216,7 +216,7 @@ async def _async_process_dependencies( ) dependencies_tasks[dep] = fut - to_be_loaded = hass.data.get(DATA_SETUP_DONE, {}) + to_be_loaded = hass.data.get(_DATA_SETUP_DONE, {}) # We don't want to just wait for the futures from `to_be_loaded` here. # We want to ensure that our after_dependencies are always actually # scheduled to be set up, as if for whatever reason they had not been, @@ -483,7 +483,7 @@ async def _async_setup_component( ) # Cleanup - hass.data[DATA_SETUP].pop(domain, None) + hass.data[_DATA_SETUP].pop(domain, None) hass.bus.async_fire_internal( EVENT_COMPONENT_LOADED, EventComponentLoaded(component=domain) @@ -573,8 +573,8 @@ async def async_process_deps_reqs( Module is a Python module of either a component or platform. """ - if (processed := hass.data.get(DATA_DEPS_REQS)) is None: - processed = hass.data[DATA_DEPS_REQS] = set() + if (processed := hass.data.get(_DATA_DEPS_REQS)) is None: + processed = hass.data[_DATA_DEPS_REQS] = set() elif integration.domain in processed: return @@ -689,7 +689,7 @@ class SetupPhases(StrEnum): """Wait time for the packages to import.""" -@singleton.singleton(DATA_SETUP_STARTED) +@singleton.singleton(_DATA_SETUP_STARTED) def _setup_started( hass: core.HomeAssistant, ) -> dict[tuple[str, str | None], float]: @@ -732,7 +732,7 @@ def async_pause_setup(hass: core.HomeAssistant, phase: SetupPhases) -> Generator ) -@singleton.singleton(DATA_SETUP_TIME) +@singleton.singleton(_DATA_SETUP_TIME) def _setup_times( hass: core.HomeAssistant, ) -> defaultdict[str, defaultdict[str | None, defaultdict[SetupPhases, float]]]: @@ -832,3 +832,11 @@ def async_get_domain_setup_times( ) -> Mapping[str | None, dict[SetupPhases, float]]: """Return timing data for each integration.""" return _setup_times(hass).get(domain, {}) + + +async def async_wait_component(hass: HomeAssistant, domain: str) -> bool: + """Wait until a component is set up if pending, then return if it is set up.""" + setup_done = hass.data.get(_DATA_SETUP_DONE, {}) + if setup_future := setup_done.get(domain): + await setup_future + return domain in hass.config.components diff --git a/tests/test_setup.py b/tests/test_setup.py index 084b657a2f2..96a13017430 100644 --- a/tests/test_setup.py +++ b/tests/test_setup.py @@ -57,21 +57,21 @@ async def test_validate_component_config(hass: HomeAssistant) -> None: with assert_setup_component(0): assert not await setup.async_setup_component(hass, "comp_conf", {}) - hass.data.pop(setup.DATA_SETUP) + hass.data.pop(setup._DATA_SETUP) with assert_setup_component(0): assert not await setup.async_setup_component( hass, "comp_conf", {"comp_conf": None} ) - hass.data.pop(setup.DATA_SETUP) + hass.data.pop(setup._DATA_SETUP) with assert_setup_component(0): assert not await setup.async_setup_component( hass, "comp_conf", {"comp_conf": {}} ) - hass.data.pop(setup.DATA_SETUP) + hass.data.pop(setup._DATA_SETUP) with assert_setup_component(0): assert not await setup.async_setup_component( @@ -80,7 +80,7 @@ async def test_validate_component_config(hass: HomeAssistant) -> None: {"comp_conf": {"hello": "world", "invalid": "extra"}}, ) - hass.data.pop(setup.DATA_SETUP) + hass.data.pop(setup._DATA_SETUP) with assert_setup_component(1): assert await setup.async_setup_component( @@ -111,7 +111,7 @@ async def test_validate_platform_config( {"platform_conf": {"platform": "not_existing", "hello": "world"}}, ) - hass.data.pop(setup.DATA_SETUP) + hass.data.pop(setup._DATA_SETUP) hass.config.components.remove("platform_conf") with assert_setup_component(1): @@ -121,7 +121,7 @@ async def test_validate_platform_config( {"platform_conf": {"platform": "whatever", "hello": "world"}}, ) - hass.data.pop(setup.DATA_SETUP) + hass.data.pop(setup._DATA_SETUP) hass.config.components.remove("platform_conf") with assert_setup_component(1): @@ -131,7 +131,7 @@ async def test_validate_platform_config( {"platform_conf": [{"platform": "whatever", "hello": "world"}]}, ) - hass.data.pop(setup.DATA_SETUP) + hass.data.pop(setup._DATA_SETUP) hass.config.components.remove("platform_conf") # Any falsey platform config will be ignored (None, {}, etc) @@ -240,7 +240,7 @@ async def test_validate_platform_config_4(hass: HomeAssistant) -> None: }, ) - hass.data.pop(setup.DATA_SETUP) + hass.data.pop(setup._DATA_SETUP) hass.config.components.remove("platform_conf") @@ -345,7 +345,7 @@ async def test_component_not_setup_missing_dependencies(hass: HomeAssistant) -> assert not await setup.async_setup_component(hass, "comp", {}) assert "comp" not in hass.config.components - hass.data.pop(setup.DATA_SETUP) + hass.data.pop(setup._DATA_SETUP) mock_integration(hass, MockModule("comp2", dependencies=deps)) mock_integration(hass, MockModule("maybe_existing")) @@ -443,8 +443,8 @@ async def test_component_exception_setup(hass: HomeAssistant) -> None: mock_integration(hass, MockModule(domain, setup=exception_setup)) assert not await setup.async_setup_component(hass, domain, {}) - assert domain in hass.data[setup.DATA_SETUP] - assert domain not in hass.data[setup.DATA_SETUP_DONE] + assert domain in hass.data[setup._DATA_SETUP] + assert domain not in hass.data[setup._DATA_SETUP_DONE] assert domain not in hass.config.components @@ -463,8 +463,8 @@ async def test_component_base_exception_setup(hass: HomeAssistant) -> None: await setup.async_setup_component(hass, "comp", {}) assert str(exc_info.value) == "fail!" - assert domain in hass.data[setup.DATA_SETUP] - assert domain not in hass.data[setup.DATA_SETUP_DONE] + assert domain in hass.data[setup._DATA_SETUP] + assert domain not in hass.data[setup._DATA_SETUP_DONE] assert domain not in hass.config.components @@ -477,12 +477,12 @@ async def test_set_domains_to_be_loaded(hass: HomeAssistant) -> None: domains = {domain_good, domain_bad, domain_exception, domain_base_exception} setup.async_set_domains_to_be_loaded(hass, domains) - assert set(hass.data[setup.DATA_SETUP_DONE]) == domains - setup_done = dict(hass.data[setup.DATA_SETUP_DONE]) + assert set(hass.data[setup._DATA_SETUP_DONE]) == domains + setup_done = dict(hass.data[setup._DATA_SETUP_DONE]) # Calling async_set_domains_to_be_loaded again should not create new futures setup.async_set_domains_to_be_loaded(hass, domains) - assert setup_done == hass.data[setup.DATA_SETUP_DONE] + assert setup_done == hass.data[setup._DATA_SETUP_DONE] def good_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Success.""" @@ -515,8 +515,8 @@ async def test_set_domains_to_be_loaded(hass: HomeAssistant) -> None: await setup.async_setup_component(hass, domain_base_exception, {}) # Check the result of the setup - assert not hass.data[setup.DATA_SETUP_DONE] - assert set(hass.data[setup.DATA_SETUP]) == { + assert not hass.data[setup._DATA_SETUP_DONE] + assert set(hass.data[setup._DATA_SETUP]) == { domain_bad, domain_exception, domain_base_exception, @@ -525,7 +525,7 @@ async def test_set_domains_to_be_loaded(hass: HomeAssistant) -> None: # Calling async_set_domains_to_be_loaded again should not create any new futures setup.async_set_domains_to_be_loaded(hass, domains) - assert not hass.data[setup.DATA_SETUP_DONE] + assert not hass.data[setup._DATA_SETUP_DONE] async def test_component_setup_after_dependencies(hass: HomeAssistant) -> None: @@ -608,7 +608,7 @@ async def test_platform_specific_config_validation(hass: HomeAssistant) -> None: assert mock_setup.call_count == 0 assert len(mock_notify.mock_calls) == 1 - hass.data.pop(setup.DATA_SETUP) + hass.data.pop(setup._DATA_SETUP) hass.config.components.remove("switch") with ( @@ -630,7 +630,7 @@ async def test_platform_specific_config_validation(hass: HomeAssistant) -> None: assert mock_setup.call_count == 0 assert len(mock_notify.mock_calls) == 1 - hass.data.pop(setup.DATA_SETUP) + hass.data.pop(setup._DATA_SETUP) hass.config.components.remove("switch") with ( @@ -656,7 +656,7 @@ async def test_disable_component_if_invalid_return(hass: HomeAssistant) -> None: assert not await setup.async_setup_component(hass, "disabled_component", {}) assert "disabled_component" not in hass.config.components - hass.data.pop(setup.DATA_SETUP) + hass.data.pop(setup._DATA_SETUP) mock_integration( hass, MockModule("disabled_component", setup=lambda hass, config: False), @@ -665,7 +665,7 @@ async def test_disable_component_if_invalid_return(hass: HomeAssistant) -> None: assert not await setup.async_setup_component(hass, "disabled_component", {}) assert "disabled_component" not in hass.config.components - hass.data.pop(setup.DATA_SETUP) + hass.data.pop(setup._DATA_SETUP) mock_integration( hass, MockModule("disabled_component", setup=lambda hass, config: True) ) @@ -939,7 +939,7 @@ async def test_integration_only_setup_entry(hass: HomeAssistant) -> None: async def test_async_start_setup_running(hass: HomeAssistant) -> None: """Test setup started context manager does nothing when running.""" assert hass.state is CoreState.running - setup_started = hass.data.setdefault(setup.DATA_SETUP_STARTED, {}) + setup_started = hass.data.setdefault(setup._DATA_SETUP_STARTED, {}) with setup.async_start_setup( hass, integration="august", phase=setup.SetupPhases.SETUP @@ -952,7 +952,7 @@ async def test_async_start_setup_config_entry( ) -> None: """Test setup started keeps track of setup times with a config entry.""" hass.set_state(CoreState.not_running) - setup_started = hass.data.setdefault(setup.DATA_SETUP_STARTED, {}) + setup_started = hass.data.setdefault(setup._DATA_SETUP_STARTED, {}) setup_time = setup._setup_times(hass) with setup.async_start_setup( @@ -1062,7 +1062,7 @@ async def test_async_start_setup_config_entry_late_platform( ) -> None: """Test setup started tracks config entry time with a late platform load.""" hass.set_state(CoreState.not_running) - setup_started = hass.data.setdefault(setup.DATA_SETUP_STARTED, {}) + setup_started = hass.data.setdefault(setup._DATA_SETUP_STARTED, {}) setup_time = setup._setup_times(hass) with setup.async_start_setup( @@ -1116,7 +1116,7 @@ async def test_async_start_setup_config_entry_platform_wait( ) -> None: """Test setup started tracks wait time when a platform loads inside of config entry setup.""" hass.set_state(CoreState.not_running) - setup_started = hass.data.setdefault(setup.DATA_SETUP_STARTED, {}) + setup_started = hass.data.setdefault(setup._DATA_SETUP_STARTED, {}) setup_time = setup._setup_times(hass) with setup.async_start_setup( @@ -1158,7 +1158,7 @@ async def test_async_start_setup_config_entry_platform_wait( async def test_async_start_setup_top_level_yaml(hass: HomeAssistant) -> None: """Test setup started context manager keeps track of setup times with modern yaml.""" hass.set_state(CoreState.not_running) - setup_started = hass.data.setdefault(setup.DATA_SETUP_STARTED, {}) + setup_started = hass.data.setdefault(setup._DATA_SETUP_STARTED, {}) setup_time = setup._setup_times(hass) with setup.async_start_setup( @@ -1174,7 +1174,7 @@ async def test_async_start_setup_top_level_yaml(hass: HomeAssistant) -> None: async def test_async_start_setup_platform_integration(hass: HomeAssistant) -> None: """Test setup started keeps track of setup times a platform integration.""" hass.set_state(CoreState.not_running) - setup_started = hass.data.setdefault(setup.DATA_SETUP_STARTED, {}) + setup_started = hass.data.setdefault(setup._DATA_SETUP_STARTED, {}) setup_time = setup._setup_times(hass) with setup.async_start_setup( @@ -1208,7 +1208,7 @@ async def test_async_start_setup_legacy_platform_integration( ) -> None: """Test setup started keeps track of setup times for a legacy platform integration.""" hass.set_state(CoreState.not_running) - setup_started = hass.data.setdefault(setup.DATA_SETUP_STARTED, {}) + setup_started = hass.data.setdefault(setup._DATA_SETUP_STARTED, {}) setup_time = setup._setup_times(hass) with setup.async_start_setup( @@ -1330,7 +1330,7 @@ async def test_setup_config_entry_from_yaml( assert await setup.async_setup_component(hass, "test_integration_only_entry", {}) assert expected_warning not in caplog.text caplog.clear() - hass.data.pop(setup.DATA_SETUP) + hass.data.pop(setup._DATA_SETUP) hass.config.components.remove("test_integration_only_entry") # There should be a warning, but setup should not fail @@ -1339,7 +1339,7 @@ async def test_setup_config_entry_from_yaml( ) assert expected_warning in caplog.text caplog.clear() - hass.data.pop(setup.DATA_SETUP) + hass.data.pop(setup._DATA_SETUP) hass.config.components.remove("test_integration_only_entry") # There should be a warning, but setup should not fail @@ -1348,7 +1348,7 @@ async def test_setup_config_entry_from_yaml( ) assert expected_warning in caplog.text caplog.clear() - hass.data.pop(setup.DATA_SETUP) + hass.data.pop(setup._DATA_SETUP) hass.config.components.remove("test_integration_only_entry") # There should be a warning, but setup should not fail @@ -1359,7 +1359,7 @@ async def test_setup_config_entry_from_yaml( ) assert expected_warning in caplog.text caplog.clear() - hass.data.pop(setup.DATA_SETUP) + hass.data.pop(setup._DATA_SETUP) hass.config.components.remove("test_integration_only_entry") @@ -1408,3 +1408,42 @@ async def test_async_prepare_setup_platform( await setup.async_prepare_setup_platform(hass, {}, "button", "test") is None ) assert button_platform is not None + + +async def test_async_wait_component(hass: HomeAssistant) -> None: + """Test async_wait_component.""" + setup_stall = asyncio.Event() + setup_started = asyncio.Event() + + async def mock_setup(hass: HomeAssistant, _) -> bool: + setup_started.set() + await setup_stall.wait() + return True + + mock_integration(hass, MockModule("test", async_setup=mock_setup)) + + # The integration not loaded, and is also not scheduled to load + assert await setup.async_wait_component(hass, "test") is False + + # Mark the component as scheduled to be loaded + setup.async_set_domains_to_be_loaded(hass, {"test"}) + + # Start loading the component, including its config entries + hass.async_create_task(setup.async_setup_component(hass, "test", {})) + await setup_started.wait() + + # The component is not yet loaded + assert "test" not in hass.config.components + + # Allow setup to proceed + setup_stall.set() + + # The component is scheduled to load, this will block until the config entry is loaded + assert await setup.async_wait_component(hass, "test") is True + + # The component has been loaded + assert "test" in hass.config.components + + # Clear the event, then call again to make sure we don't block + setup_stall.clear() + assert await setup.async_wait_component(hass, "test") is True From b058b2574f823372fdcd7ae17fa92d2e814042cc Mon Sep 17 00:00:00 2001 From: Erwin Douna Date: Wed, 9 Apr 2025 16:24:30 +0200 Subject: [PATCH 394/505] SMA add DHCP discovery (#135843) Co-authored-by: Joost Lekkerkerker --- homeassistant/components/sma/__init__.py | 9 + homeassistant/components/sma/config_flow.py | 133 +++++++++--- homeassistant/components/sma/manifest.json | 7 + homeassistant/generated/dhcp.py | 9 + tests/components/sma/__init__.py | 40 +++- tests/components/sma/conftest.py | 6 +- tests/components/sma/test_config_flow.py | 219 +++++++++++++------- 7 files changed, 318 insertions(+), 105 deletions(-) diff --git a/homeassistant/components/sma/__init__.py b/homeassistant/components/sma/__init__.py index 6aae74922e4..27fa54e46dd 100644 --- a/homeassistant/components/sma/__init__.py +++ b/homeassistant/components/sma/__init__.py @@ -10,7 +10,9 @@ import pysma from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( + ATTR_CONNECTIONS, CONF_HOST, + CONF_MAC, CONF_PASSWORD, CONF_SCAN_INTERVAL, CONF_SSL, @@ -19,6 +21,7 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady +from homeassistant.helpers import device_registry as dr from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed @@ -75,6 +78,12 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: serial_number=sma_device_info["serial"], ) + # Add the MAC address to connections, if it comes via DHCP + if CONF_MAC in entry.data: + device_info[ATTR_CONNECTIONS] = { + (dr.CONNECTION_NETWORK_MAC, entry.data[CONF_MAC]) + } + # Define the coordinator async def async_update_data(): """Update the used SMA sensors.""" diff --git a/homeassistant/components/sma/config_flow.py b/homeassistant/components/sma/config_flow.py index 3f5eb635989..3210d904b6b 100644 --- a/homeassistant/components/sma/config_flow.py +++ b/homeassistant/components/sma/config_flow.py @@ -7,26 +7,43 @@ from typing import Any import pysma import voluptuous as vol +from yarl import URL from homeassistant.config_entries import ConfigFlow, ConfigFlowResult -from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_SSL, CONF_VERIFY_SSL +from homeassistant.const import ( + CONF_HOST, + CONF_MAC, + CONF_NAME, + CONF_PASSWORD, + CONF_SSL, + CONF_VERIFY_SSL, +) from homeassistant.core import HomeAssistant from homeassistant.helpers import config_validation as cv from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.helpers.device_registry import format_mac +from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo from .const import CONF_GROUP, DOMAIN, GROUPS _LOGGER = logging.getLogger(__name__) -async def validate_input(hass: HomeAssistant, data: dict[str, Any]) -> dict[str, Any]: +async def validate_input( + hass: HomeAssistant, + user_input: dict[str, Any], + data: dict[str, Any] | None = None, +) -> dict[str, Any]: """Validate the user input allows us to connect.""" - session = async_get_clientsession(hass, verify_ssl=data[CONF_VERIFY_SSL]) + session = async_get_clientsession(hass, verify_ssl=user_input[CONF_VERIFY_SSL]) - protocol = "https" if data[CONF_SSL] else "http" - url = f"{protocol}://{data[CONF_HOST]}" + protocol = "https" if user_input[CONF_SSL] else "http" + host = data[CONF_HOST] if data is not None else user_input[CONF_HOST] + url = URL.build(scheme=protocol, host=host) - sma = pysma.SMA(session, url, data[CONF_PASSWORD], group=data[CONF_GROUP]) + sma = pysma.SMA( + session, str(url), user_input[CONF_PASSWORD], group=user_input[CONF_GROUP] + ) # new_session raises SmaAuthenticationException on failure await sma.new_session() @@ -51,34 +68,53 @@ class SmaConfigFlow(ConfigFlow, domain=DOMAIN): CONF_GROUP: GROUPS[0], CONF_PASSWORD: vol.UNDEFINED, } + self._discovery_data: dict[str, Any] = {} + + async def _handle_user_input( + self, user_input: dict[str, Any], discovery: bool = False + ) -> tuple[dict[str, str], dict[str, str]]: + """Handle the user input.""" + errors: dict[str, str] = {} + device_info: dict[str, str] = {} + + if not discovery: + self._data[CONF_HOST] = user_input[CONF_HOST] + + self._data[CONF_SSL] = user_input[CONF_SSL] + self._data[CONF_VERIFY_SSL] = user_input[CONF_VERIFY_SSL] + self._data[CONF_GROUP] = user_input[CONF_GROUP] + self._data[CONF_PASSWORD] = user_input[CONF_PASSWORD] + + try: + device_info = await validate_input( + self.hass, user_input=user_input, data=self._data + ) + except pysma.exceptions.SmaConnectionException: + errors["base"] = "cannot_connect" + except pysma.exceptions.SmaAuthenticationException: + errors["base"] = "invalid_auth" + except pysma.exceptions.SmaReadException: + errors["base"] = "cannot_retrieve_device_info" + except Exception: + _LOGGER.exception("Unexpected exception") + errors["base"] = "unknown" + + return errors, device_info async def async_step_user( self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: """First step in config flow.""" - errors = {} + errors: dict[str, str] = {} if user_input is not None: - self._data[CONF_HOST] = user_input[CONF_HOST] - self._data[CONF_SSL] = user_input[CONF_SSL] - self._data[CONF_VERIFY_SSL] = user_input[CONF_VERIFY_SSL] - self._data[CONF_GROUP] = user_input[CONF_GROUP] - self._data[CONF_PASSWORD] = user_input[CONF_PASSWORD] - - try: - device_info = await validate_input(self.hass, user_input) - except pysma.exceptions.SmaConnectionException: - errors["base"] = "cannot_connect" - except pysma.exceptions.SmaAuthenticationException: - errors["base"] = "invalid_auth" - except pysma.exceptions.SmaReadException: - errors["base"] = "cannot_retrieve_device_info" - except Exception: - _LOGGER.exception("Unexpected exception") - errors["base"] = "unknown" + errors, device_info = await self._handle_user_input(user_input=user_input) if not errors: - await self.async_set_unique_id(str(device_info["serial"])) + await self.async_set_unique_id( + str(device_info["serial"]), raise_on_progress=False + ) self._abort_if_unique_id_configured(updates=self._data) + return self.async_create_entry( title=self._data[CONF_HOST], data=self._data ) @@ -100,3 +136,50 @@ class SmaConfigFlow(ConfigFlow, domain=DOMAIN): ), errors=errors, ) + + async def async_step_dhcp( + self, discovery_info: DhcpServiceInfo + ) -> ConfigFlowResult: + """Handle DHCP discovery.""" + self._discovery_data[CONF_HOST] = discovery_info.ip + self._discovery_data[CONF_MAC] = format_mac(discovery_info.macaddress) + self._discovery_data[CONF_NAME] = discovery_info.hostname + self._data[CONF_HOST] = discovery_info.ip + self._data[CONF_MAC] = format_mac(self._discovery_data[CONF_MAC]) + + await self.async_set_unique_id(discovery_info.hostname.replace("SMA", "")) + self._abort_if_unique_id_configured() + + return await self.async_step_discovery_confirm() + + async def async_step_discovery_confirm( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Confirm discovery.""" + errors: dict[str, str] = {} + if user_input is not None: + errors, device_info = await self._handle_user_input( + user_input=user_input, discovery=True + ) + + if not errors: + return self.async_create_entry( + title=self._data[CONF_HOST], data=self._data + ) + + return self.async_show_form( + step_id="discovery_confirm", + data_schema=vol.Schema( + { + vol.Optional(CONF_SSL, default=self._data[CONF_SSL]): cv.boolean, + vol.Optional( + CONF_VERIFY_SSL, default=self._data[CONF_VERIFY_SSL] + ): cv.boolean, + vol.Optional(CONF_GROUP, default=self._data[CONF_GROUP]): vol.In( + GROUPS + ), + vol.Required(CONF_PASSWORD): cv.string, + } + ), + errors=errors, + ) diff --git a/homeassistant/components/sma/manifest.json b/homeassistant/components/sma/manifest.json index 8024aad82d6..bb3f5318280 100644 --- a/homeassistant/components/sma/manifest.json +++ b/homeassistant/components/sma/manifest.json @@ -3,6 +3,13 @@ "name": "SMA Solar", "codeowners": ["@kellerza", "@rklomp", "@erwindouna"], "config_flow": true, + "dhcp": [ + { + "hostname": "sma*", + "macaddress": "0015BB*" + }, + { "registered_devices": true } + ], "documentation": "https://www.home-assistant.io/integrations/sma", "iot_class": "local_polling", "loggers": ["pysma"], diff --git a/homeassistant/generated/dhcp.py b/homeassistant/generated/dhcp.py index 9a8fd349a8b..39854ff0af6 100644 --- a/homeassistant/generated/dhcp.py +++ b/homeassistant/generated/dhcp.py @@ -613,6 +613,15 @@ DHCP: Final[list[dict[str, str | bool]]] = [ "domain": "sleepiq", "macaddress": "64DBA0*", }, + { + "domain": "sma", + "hostname": "sma*", + "macaddress": "0015BB*", + }, + { + "domain": "sma", + "registered_devices": True, + }, { "domain": "smartthings", "hostname": "st*", diff --git a/tests/components/sma/__init__.py b/tests/components/sma/__init__.py index 80837c718a9..4a9e462501e 100644 --- a/tests/components/sma/__init__.py +++ b/tests/components/sma/__init__.py @@ -1,7 +1,17 @@ """Tests for the sma integration.""" +import unittest from unittest.mock import patch +from homeassistant.components.sma.const import CONF_GROUP +from homeassistant.const import ( + CONF_HOST, + CONF_MAC, + CONF_PASSWORD, + CONF_SSL, + CONF_VERIFY_SSL, +) + MOCK_DEVICE = { "manufacturer": "SMA", "name": "SMA Device Name", @@ -10,15 +20,33 @@ MOCK_DEVICE = { } MOCK_USER_INPUT = { - "host": "1.1.1.1", - "ssl": True, - "verify_ssl": False, - "group": "user", - "password": "password", + CONF_HOST: "1.1.1.1", + CONF_SSL: True, + CONF_VERIFY_SSL: False, + CONF_GROUP: "user", + CONF_PASSWORD: "password", +} + +MOCK_DHCP_DISCOVERY_INPUT = { + # CONF_HOST: "1.1.1.2", + CONF_SSL: True, + CONF_VERIFY_SSL: False, + CONF_GROUP: "user", + CONF_PASSWORD: "password", +} + +MOCK_DHCP_DISCOVERY = { + CONF_HOST: "1.1.1.1", + CONF_SSL: True, + CONF_VERIFY_SSL: False, + CONF_GROUP: "user", + CONF_PASSWORD: "password", + CONF_MAC: "00:15:bb:00:ab:cd", } -def _patch_async_setup_entry(return_value=True): +def _patch_async_setup_entry(return_value=True) -> unittest.mock._patch: + """Patch async_setup_entry.""" return patch( "homeassistant.components.sma.async_setup_entry", return_value=return_value, diff --git a/tests/components/sma/conftest.py b/tests/components/sma/conftest.py index dd47a0f1055..2b4c157175b 100644 --- a/tests/components/sma/conftest.py +++ b/tests/components/sma/conftest.py @@ -17,9 +17,9 @@ from tests.common import MockConfigEntry @pytest.fixture -def mock_config_entry() -> MockConfigEntry: +def mock_config_entry(hass: HomeAssistant) -> MockConfigEntry: """Return the default mocked config entry.""" - return MockConfigEntry( + entry = MockConfigEntry( domain=DOMAIN, title=MOCK_DEVICE["name"], unique_id=str(MOCK_DEVICE["serial"]), @@ -27,6 +27,8 @@ def mock_config_entry() -> MockConfigEntry: source=config_entries.SOURCE_IMPORT, minor_version=2, ) + entry.add_to_hass(hass) + return entry @pytest.fixture diff --git a/tests/components/sma/test_config_flow.py b/tests/components/sma/test_config_flow.py index 93ac1783e09..5033462d0a6 100644 --- a/tests/components/sma/test_config_flow.py +++ b/tests/components/sma/test_config_flow.py @@ -7,13 +7,35 @@ from pysma.exceptions import ( SmaConnectionException, SmaReadException, ) +import pytest from homeassistant.components.sma.const import DOMAIN -from homeassistant.config_entries import SOURCE_USER +from homeassistant.config_entries import SOURCE_DHCP, SOURCE_USER from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType +from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo -from . import MOCK_DEVICE, MOCK_USER_INPUT, _patch_async_setup_entry +from . import ( + MOCK_DEVICE, + MOCK_DHCP_DISCOVERY, + MOCK_DHCP_DISCOVERY_INPUT, + MOCK_USER_INPUT, + _patch_async_setup_entry, +) + +from tests.conftest import MockConfigEntry + +DHCP_DISCOVERY = DhcpServiceInfo( + ip="1.1.1.1", + hostname="SMA123456", + macaddress="0015BB00abcd", +) + +DHCP_DISCOVERY_DUPLICATE = DhcpServiceInfo( + ip="1.1.1.1", + hostname="SMA123456789", + macaddress="0015BB00abcd", +) async def test_form(hass: HomeAssistant) -> None: @@ -43,14 +65,27 @@ async def test_form(hass: HomeAssistant) -> None: assert len(mock_setup_entry.mock_calls) == 1 -async def test_form_cannot_connect(hass: HomeAssistant) -> None: +@pytest.mark.parametrize( + ("exception", "error"), + [ + (SmaConnectionException, "cannot_connect"), + (SmaAuthenticationException, "invalid_auth"), + (SmaReadException, "cannot_retrieve_device_info"), + (Exception, "unknown"), + ], +) +async def test_form_exceptions( + hass: HomeAssistant, exception: Exception, error: str +) -> None: """Test we handle cannot connect error.""" result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_USER} ) with ( - patch("pysma.SMA.new_session", side_effect=SmaConnectionException), + patch( + "homeassistant.components.sma.pysma.SMA.new_session", side_effect=exception + ), _patch_async_setup_entry() as mock_setup_entry, ): result = await hass.config_entries.flow.async_configure( @@ -59,83 +94,27 @@ async def test_form_cannot_connect(hass: HomeAssistant) -> None: ) assert result["type"] is FlowResultType.FORM - assert result["errors"] == {"base": "cannot_connect"} + assert result["errors"] == {"base": error} assert len(mock_setup_entry.mock_calls) == 0 -async def test_form_invalid_auth(hass: HomeAssistant) -> None: - """Test we handle invalid auth error.""" - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": SOURCE_USER} - ) - - with ( - patch("pysma.SMA.new_session", side_effect=SmaAuthenticationException), - _patch_async_setup_entry() as mock_setup_entry, - ): - result = await hass.config_entries.flow.async_configure( - result["flow_id"], - MOCK_USER_INPUT, - ) - - assert result["type"] is FlowResultType.FORM - assert result["errors"] == {"base": "invalid_auth"} - assert len(mock_setup_entry.mock_calls) == 0 - - -async def test_form_cannot_retrieve_device_info(hass: HomeAssistant) -> None: - """Test we handle cannot retrieve device info error.""" - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": SOURCE_USER} - ) - - with ( - patch("pysma.SMA.new_session", return_value=True), - patch("pysma.SMA.read", side_effect=SmaReadException), - _patch_async_setup_entry() as mock_setup_entry, - ): - result = await hass.config_entries.flow.async_configure( - result["flow_id"], - MOCK_USER_INPUT, - ) - - assert result["type"] is FlowResultType.FORM - assert result["errors"] == {"base": "cannot_retrieve_device_info"} - assert len(mock_setup_entry.mock_calls) == 0 - - -async def test_form_unexpected_exception(hass: HomeAssistant) -> None: - """Test we handle unexpected exception.""" - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": SOURCE_USER} - ) - - with ( - patch("pysma.SMA.new_session", side_effect=Exception), - _patch_async_setup_entry() as mock_setup_entry, - ): - result = await hass.config_entries.flow.async_configure( - result["flow_id"], - MOCK_USER_INPUT, - ) - - assert result["type"] is FlowResultType.FORM - assert result["errors"] == {"base": "unknown"} - assert len(mock_setup_entry.mock_calls) == 0 - - -async def test_form_already_configured(hass: HomeAssistant, mock_config_entry) -> None: +async def test_form_already_configured( + hass: HomeAssistant, mock_config_entry: MockConfigEntry +) -> None: """Test starting a flow by user when already configured.""" - mock_config_entry.add_to_hass(hass) - result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_USER} ) with ( - patch("pysma.SMA.new_session", return_value=True), - patch("pysma.SMA.device_info", return_value=MOCK_DEVICE), - patch("pysma.SMA.close_session", return_value=True), + patch("homeassistant.components.sma.pysma.SMA.new_session", return_value=True), + patch( + "homeassistant.components.sma.pysma.SMA.device_info", + return_value=MOCK_DEVICE, + ), + patch( + "homeassistant.components.sma.pysma.SMA.close_session", return_value=True + ), _patch_async_setup_entry() as mock_setup_entry, ): result = await hass.config_entries.flow.async_configure( @@ -146,3 +125,99 @@ async def test_form_already_configured(hass: HomeAssistant, mock_config_entry) - assert result["type"] is FlowResultType.ABORT assert result["reason"] == "already_configured" assert len(mock_setup_entry.mock_calls) == 0 + + +async def test_dhcp_discovery(hass: HomeAssistant) -> None: + """Test we can setup from dhcp discovery.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_DHCP}, + data=DHCP_DISCOVERY, + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "discovery_confirm" + + with ( + patch("homeassistant.components.sma.pysma.SMA.new_session", return_value=True), + patch( + "homeassistant.components.sma.pysma.SMA.device_info", + return_value=MOCK_DEVICE, + ), + _patch_async_setup_entry() as mock_setup_entry, + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + MOCK_DHCP_DISCOVERY_INPUT, + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == MOCK_DHCP_DISCOVERY["host"] + assert result["data"] == MOCK_DHCP_DISCOVERY + assert result["result"].unique_id == DHCP_DISCOVERY.hostname.replace("SMA", "") + + assert len(mock_setup_entry.mock_calls) == 1 + + +async def test_dhcp_already_configured( + hass: HomeAssistant, mock_config_entry: MockConfigEntry +) -> None: + """Test starting a flow by dhcp when already configured.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_DHCP}, data=DHCP_DISCOVERY_DUPLICATE + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" + + +@pytest.mark.parametrize( + ("exception", "error"), + [ + (SmaConnectionException, "cannot_connect"), + (SmaAuthenticationException, "invalid_auth"), + (SmaReadException, "cannot_retrieve_device_info"), + (Exception, "unknown"), + ], +) +async def test_dhcp_exceptions( + hass: HomeAssistant, exception: Exception, error: str +) -> None: + """Test we handle cannot connect error.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_DHCP}, + data=DHCP_DISCOVERY, + ) + + with patch( + "homeassistant.components.sma.pysma.SMA.new_session", side_effect=exception + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + MOCK_DHCP_DISCOVERY_INPUT, + ) + + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {"base": error} + + with ( + patch("homeassistant.components.sma.pysma.SMA.new_session", return_value=True), + patch( + "homeassistant.components.sma.pysma.SMA.device_info", + return_value=MOCK_DEVICE, + ), + patch( + "homeassistant.components.sma.pysma.SMA.close_session", return_value=True + ), + _patch_async_setup_entry(), + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + MOCK_DHCP_DISCOVERY_INPUT, + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == MOCK_DHCP_DISCOVERY["host"] + assert result["data"] == MOCK_DHCP_DISCOVERY + assert result["result"].unique_id == DHCP_DISCOVERY.hostname.replace("SMA", "") From 002f5b5ee65593fa2fe44433b1bb467a14475e6d Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Wed, 9 Apr 2025 16:26:12 +0200 Subject: [PATCH 395/505] Replace typo "to login to" with "to log in to" in `bring` (#142579) --- homeassistant/components/bring/strings.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/bring/strings.json b/homeassistant/components/bring/strings.json index 1dbe0adbf6c..2c30af5adce 100644 --- a/homeassistant/components/bring/strings.json +++ b/homeassistant/components/bring/strings.json @@ -13,7 +13,7 @@ }, "data_description": { "email": "The email address associated with your Bring! account.", - "password": "The password to login to your Bring! account." + "password": "The password to log in to your Bring! account." } }, "reauth_confirm": { From 8625a36d1dc08b565e0f6b46fb7e0ea8814ec738 Mon Sep 17 00:00:00 2001 From: Simone Chemelli Date: Wed, 9 Apr 2025 16:44:36 +0200 Subject: [PATCH 396/505] Add missing strings to Fritz (#142413) * Add missing strings to Fritz * update quality scale * add common section this avoids later re-structuring and re-translating * fix strings * fix strings * apply review comment --------- Co-authored-by: Michael <35783820+mib1185@users.noreply.github.com> --- .../components/fritz/quality_scale.yaml | 4 +- homeassistant/components/fritz/strings.json | 40 ++++++++++++++++--- 2 files changed, 35 insertions(+), 9 deletions(-) diff --git a/homeassistant/components/fritz/quality_scale.yaml b/homeassistant/components/fritz/quality_scale.yaml index 29e46b3a0c9..a959a413c4b 100644 --- a/homeassistant/components/fritz/quality_scale.yaml +++ b/homeassistant/components/fritz/quality_scale.yaml @@ -7,9 +7,7 @@ rules: config-flow-test-coverage: status: todo comment: one coverage miss in line 110 - config-flow: - status: todo - comment: data_description are missing + config-flow: done dependency-transparency: done docs-actions: done docs-high-level-description: done diff --git a/homeassistant/components/fritz/strings.json b/homeassistant/components/fritz/strings.json index 06a07cba79e..6191fc524dd 100644 --- a/homeassistant/components/fritz/strings.json +++ b/homeassistant/components/fritz/strings.json @@ -1,4 +1,11 @@ { + "common": { + "data_description_host": "The hostname or IP address of your FRITZ!Box router.", + "data_description_port": "Leave empty to use the default port.", + "data_description_username": "Username for the FRITZ!Box.", + "data_description_password": "Password for the FRITZ!Box.", + "data_description_ssl": "Use SSL to connect to the FRITZ!Box." + }, "config": { "flow_title": "{name}", "step": { @@ -9,6 +16,11 @@ "username": "[%key:common::config_flow::data::username%]", "password": "[%key:common::config_flow::data::password%]", "ssl": "[%key:common::config_flow::data::ssl%]" + }, + "data_description": { + "username": "[%key:component::fritz::common::data_description_username%]", + "password": "[%key:component::fritz::common::data_description_password%]", + "ssl": "[%key:component::fritz::common::data_description_ssl%]" } }, "reauth_confirm": { @@ -17,6 +29,10 @@ "data": { "username": "[%key:common::config_flow::data::username%]", "password": "[%key:common::config_flow::data::password%]" + }, + "data_description": { + "username": "[%key:component::fritz::common::data_description_username%]", + "password": "[%key:component::fritz::common::data_description_password%]" } }, "reconfigure": { @@ -28,8 +44,9 @@ "ssl": "[%key:common::config_flow::data::ssl%]" }, "data_description": { - "host": "The hostname or IP address of your FRITZ!Box router.", - "port": "Leave it empty to use the default port." + "host": "[%key:component::fritz::common::data_description_host%]", + "port": "[%key:component::fritz::common::data_description_port%]", + "ssl": "[%key:component::fritz::common::data_description_ssl%]" } }, "user": { @@ -43,8 +60,11 @@ "ssl": "[%key:common::config_flow::data::ssl%]" }, "data_description": { - "host": "The hostname or IP address of your FRITZ!Box router.", - "port": "Leave it empty to use the default port." + "host": "[%key:component::fritz::common::data_description_host%]", + "port": "[%key:component::fritz::common::data_description_port%]", + "username": "[%key:component::fritz::common::data_description_username%]", + "password": "[%key:component::fritz::common::data_description_password%]", + "ssl": "[%key:component::fritz::common::data_description_ssl%]" } } }, @@ -70,6 +90,10 @@ "data": { "consider_home": "Seconds to consider a device at 'home'", "old_discovery": "Enable old discovery method" + }, + "data_description": { + "consider_home": "Time in seconds to consider a device at home. Default is 180 seconds.", + "old_discovery": "Enable old discovery method. This is needed for some scenarios." } } } @@ -169,8 +193,12 @@ "config_entry_not_found": { "message": "Failed to perform action \"{service}\". Config entry for target not found" }, - "service_parameter_unknown": { "message": "Action or parameter unknown" }, - "service_not_supported": { "message": "Action not supported" }, + "service_parameter_unknown": { + "message": "Action or parameter unknown" + }, + "service_not_supported": { + "message": "Action not supported" + }, "error_refresh_hosts_info": { "message": "Error refreshing hosts info" }, From 70aacfce9867a53beef02324d8cdd9d75e1fa577 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Wed, 9 Apr 2025 16:47:04 +0200 Subject: [PATCH 397/505] Improve tests of clean up when reauth flow aborts (#142592) --- tests/test_config_entries.py | 40 +++++++++++++++++++++++++++++++----- 1 file changed, 35 insertions(+), 5 deletions(-) diff --git a/tests/test_config_entries.py b/tests/test_config_entries.py index 2d9d18a067d..8f1591cec3b 100644 --- a/tests/test_config_entries.py +++ b/tests/test_config_entries.py @@ -1367,11 +1367,44 @@ async def test_async_forward_entry_setup_deprecated( ) in caplog.text -async def test_reauth_issue( +async def test_reauth_issue_flow_returns_abort( hass: HomeAssistant, manager: config_entries.ConfigEntries, issue_registry: ir.IssueRegistry, ) -> None: + """Test that we create/delete an issue when source is reauth. + + In this test, the reauth flow returns abort. + """ + issue = await _test_reauth_issue(hass, manager, issue_registry) + + result = await manager.flow.async_configure(issue.data["flow_id"], {}) + assert result["type"] == FlowResultType.ABORT + assert len(issue_registry.issues) == 0 + + +async def test_reauth_issue_flow_aborted( + hass: HomeAssistant, + manager: config_entries.ConfigEntries, + issue_registry: ir.IssueRegistry, +) -> None: + """Test that we create/delete an issue when source is reauth. + + In this test, the reauth flow is aborted. + """ + issue = await _test_reauth_issue(hass, manager, issue_registry) + + manager.flow.async_abort(issue.data["flow_id"]) + # This can be considered a bug, we should make sure the issue is always + # removed when the reauth flow is aborted. + assert len(issue_registry.issues) == 1 + + +async def _test_reauth_issue( + hass: HomeAssistant, + manager: config_entries.ConfigEntries, + issue_registry: ir.IssueRegistry, +) -> ir.IssueEntry: """Test that we create/delete an issue when source is reauth.""" assert len(issue_registry.issues) == 0 @@ -1407,10 +1440,7 @@ async def test_reauth_issue( translation_key="config_entry_reauth", translation_placeholders={"name": "test_title"}, ) - - result = await hass.config_entries.flow.async_configure(issue.data["flow_id"], {}) - assert result["type"] == FlowResultType.ABORT - assert len(issue_registry.issues) == 0 + return issue async def test_loading_default_config(hass: HomeAssistant) -> None: From 157c7760191650364479ab9c0f0ca482db1cc299 Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Wed, 9 Apr 2025 18:41:46 +0200 Subject: [PATCH 398/505] Replace typo "to login to" with "to log in to" in `mqtt` (#142575) Fix typo "to login to" with "to log in to" in `mqtt` --- homeassistant/components/mqtt/strings.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/mqtt/strings.json b/homeassistant/components/mqtt/strings.json index cedf120def1..542b16bab80 100644 --- a/homeassistant/components/mqtt/strings.json +++ b/homeassistant/components/mqtt/strings.json @@ -43,8 +43,8 @@ "data_description": { "broker": "The hostname or IP address of your MQTT broker.", "port": "The port your MQTT broker listens to. For example 1883.", - "username": "The username to login to your MQTT broker.", - "password": "The password to login to your MQTT broker.", + "username": "The username to log in to your MQTT broker.", + "password": "The password to log in to your MQTT broker.", "advanced_options": "Enable and select **Next** to set advanced options.", "certificate": "The custom CA certificate file to validate your MQTT brokers certificate.", "client_id": "The unique ID to identify the Home Assistant MQTT API as MQTT client. It is recommended to leave this option blank.", From ba629fbddb4da0cb2b7b8ae50f51492f8b73cdfe Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Wed, 9 Apr 2025 19:00:56 +0200 Subject: [PATCH 399/505] Add Syncthru platform tests (#142596) --- tests/components/syncthru/__init__.py | 12 + tests/components/syncthru/conftest.py | 43 +- .../snapshots/test_binary_sensor.ambr | 97 ++++ .../syncthru/snapshots/test_sensor.ambr | 417 ++++++++++++++++++ .../components/syncthru/test_binary_sensor.py | 27 ++ tests/components/syncthru/test_sensor.py | 29 ++ 6 files changed, 624 insertions(+), 1 deletion(-) create mode 100644 tests/components/syncthru/snapshots/test_binary_sensor.ambr create mode 100644 tests/components/syncthru/snapshots/test_sensor.ambr create mode 100644 tests/components/syncthru/test_binary_sensor.py create mode 100644 tests/components/syncthru/test_sensor.py diff --git a/tests/components/syncthru/__init__.py b/tests/components/syncthru/__init__.py index d113c11fc19..c9105c6f2b5 100644 --- a/tests/components/syncthru/__init__.py +++ b/tests/components/syncthru/__init__.py @@ -1 +1,13 @@ """Tests for the syncthru integration.""" + +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry + + +async def setup_integration(hass: HomeAssistant, config_entry: MockConfigEntry) -> None: + """Fixture for setting up the component.""" + config_entry.add_to_hass(hass) + + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() diff --git a/tests/components/syncthru/conftest.py b/tests/components/syncthru/conftest.py index e21a859ed98..6563e0f7b41 100644 --- a/tests/components/syncthru/conftest.py +++ b/tests/components/syncthru/conftest.py @@ -3,11 +3,13 @@ from collections.abc import Generator from unittest.mock import AsyncMock, patch +from pysyncthru import SyncthruState import pytest from homeassistant.components.syncthru import DOMAIN +from homeassistant.const import CONF_NAME, CONF_URL -from tests.common import load_json_object_fixture +from tests.common import MockConfigEntry, load_json_object_fixture @pytest.fixture @@ -25,5 +27,44 @@ def mock_syncthru() -> Generator[AsyncMock]: client = mock_syncthru.return_value client.model.return_value = "C430W" client.is_unknown_state.return_value = False + client.url = "http://192.168.1.2" + client.model.return_value = "C430W" + client.hostname.return_value = "SEC84251907C415" + client.serial_number.return_value = "08HRB8GJ3F019DD" + client.device_status.return_value = SyncthruState(3) + client.device_status_details.return_value = "" + client.is_online.return_value = True + client.toner_status.return_value = { + "black": {"opt": 1, "remaining": 8, "cnt": 1176, "newError": "C1-5110"}, + "cyan": {"opt": 1, "remaining": 98, "cnt": 25, "newError": ""}, + "magenta": {"opt": 1, "remaining": 98, "cnt": 25, "newError": ""}, + "yellow": {"opt": 1, "remaining": 97, "cnt": 27, "newError": ""}, + } + client.drum_status.return_value = {} + client.input_tray_status.return_value = { + "tray_1": { + "opt": 1, + "paper_size1": 4, + "paper_size2": 0, + "paper_type1": 2, + "paper_type2": 0, + "paper_level": 0, + "capa": 150, + "newError": "", + } + } + client.output_tray_status.return_value = { + 1: {"name": 1, "capacity": 50, "status": ""} + } client.raw.return_value = load_json_object_fixture("state.json", DOMAIN) yield client + + +@pytest.fixture +def mock_config_entry() -> MockConfigEntry: + """Mock a config entry.""" + return MockConfigEntry( + domain=DOMAIN, + title="C430W", + data={CONF_URL: "http://192.168.1.2/", CONF_NAME: "My Printer"}, + ) diff --git a/tests/components/syncthru/snapshots/test_binary_sensor.ambr b/tests/components/syncthru/snapshots/test_binary_sensor.ambr new file mode 100644 index 00000000000..82b62394a63 --- /dev/null +++ b/tests/components/syncthru/snapshots/test_binary_sensor.ambr @@ -0,0 +1,97 @@ +# serializer version: 1 +# name: test_all_entities[binary_sensor.my_printer-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.my_printer', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'My Printer', + 'platform': 'syncthru', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '08HRB8GJ3F019DD_online', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[binary_sensor.my_printer-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'connectivity', + 'friendly_name': 'My Printer', + }), + 'context': , + 'entity_id': 'binary_sensor.my_printer', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_all_entities[binary_sensor.my_printer_2-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.my_printer_2', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'My Printer', + 'platform': 'syncthru', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '08HRB8GJ3F019DD_problem', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[binary_sensor.my_printer_2-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'problem', + 'friendly_name': 'My Printer', + }), + 'context': , + 'entity_id': 'binary_sensor.my_printer_2', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- diff --git a/tests/components/syncthru/snapshots/test_sensor.ambr b/tests/components/syncthru/snapshots/test_sensor.ambr new file mode 100644 index 00000000000..50d892b5343 --- /dev/null +++ b/tests/components/syncthru/snapshots/test_sensor.ambr @@ -0,0 +1,417 @@ +# serializer version: 1 +# name: test_all_entities[sensor.my_printer-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.my_printer', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:printer', + 'original_name': 'My Printer', + 'platform': 'syncthru', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '08HRB8GJ3F019DD_main', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[sensor.my_printer-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'display_text': '', + 'friendly_name': 'My Printer', + 'icon': 'mdi:printer', + }), + 'context': , + 'entity_id': 'sensor.my_printer', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'warning', + }) +# --- +# name: test_all_entities[sensor.my_printer_active_alerts-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.my_printer_active_alerts', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:printer', + 'original_name': 'My Printer Active Alerts', + 'platform': 'syncthru', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '08HRB8GJ3F019DD_active_alerts', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[sensor.my_printer_active_alerts-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'My Printer Active Alerts', + 'icon': 'mdi:printer', + }), + 'context': , + 'entity_id': 'sensor.my_printer_active_alerts', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '2', + }) +# --- +# name: test_all_entities[sensor.my_printer_output_tray_1-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.my_printer_output_tray_1', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:printer', + 'original_name': 'My Printer Output Tray 1', + 'platform': 'syncthru', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '08HRB8GJ3F019DD_output_tray_1', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[sensor.my_printer_output_tray_1-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'capacity': 50, + 'friendly_name': 'My Printer Output Tray 1', + 'icon': 'mdi:printer', + 'name': 1, + 'status': '', + }), + 'context': , + 'entity_id': 'sensor.my_printer_output_tray_1', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'Ready', + }) +# --- +# name: test_all_entities[sensor.my_printer_toner_black-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.my_printer_toner_black', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:printer', + 'original_name': 'My Printer Toner black', + 'platform': 'syncthru', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '08HRB8GJ3F019DD_toner_black', + 'unit_of_measurement': '%', + }) +# --- +# name: test_all_entities[sensor.my_printer_toner_black-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'cnt': 1176, + 'friendly_name': 'My Printer Toner black', + 'icon': 'mdi:printer', + 'newError': 'C1-5110', + 'opt': 1, + 'remaining': 8, + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.my_printer_toner_black', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '8', + }) +# --- +# name: test_all_entities[sensor.my_printer_toner_cyan-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.my_printer_toner_cyan', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:printer', + 'original_name': 'My Printer Toner cyan', + 'platform': 'syncthru', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '08HRB8GJ3F019DD_toner_cyan', + 'unit_of_measurement': '%', + }) +# --- +# name: test_all_entities[sensor.my_printer_toner_cyan-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'cnt': 25, + 'friendly_name': 'My Printer Toner cyan', + 'icon': 'mdi:printer', + 'newError': '', + 'opt': 1, + 'remaining': 98, + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.my_printer_toner_cyan', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '98', + }) +# --- +# name: test_all_entities[sensor.my_printer_toner_magenta-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.my_printer_toner_magenta', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:printer', + 'original_name': 'My Printer Toner magenta', + 'platform': 'syncthru', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '08HRB8GJ3F019DD_toner_magenta', + 'unit_of_measurement': '%', + }) +# --- +# name: test_all_entities[sensor.my_printer_toner_magenta-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'cnt': 25, + 'friendly_name': 'My Printer Toner magenta', + 'icon': 'mdi:printer', + 'newError': '', + 'opt': 1, + 'remaining': 98, + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.my_printer_toner_magenta', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '98', + }) +# --- +# name: test_all_entities[sensor.my_printer_toner_yellow-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.my_printer_toner_yellow', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:printer', + 'original_name': 'My Printer Toner yellow', + 'platform': 'syncthru', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '08HRB8GJ3F019DD_toner_yellow', + 'unit_of_measurement': '%', + }) +# --- +# name: test_all_entities[sensor.my_printer_toner_yellow-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'cnt': 27, + 'friendly_name': 'My Printer Toner yellow', + 'icon': 'mdi:printer', + 'newError': '', + 'opt': 1, + 'remaining': 97, + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.my_printer_toner_yellow', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '97', + }) +# --- +# name: test_all_entities[sensor.my_printer_tray_tray_1-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.my_printer_tray_tray_1', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:printer', + 'original_name': 'My Printer Tray tray_1', + 'platform': 'syncthru', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '08HRB8GJ3F019DD_tray_tray_1', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[sensor.my_printer_tray_tray_1-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'capa': 150, + 'friendly_name': 'My Printer Tray tray_1', + 'icon': 'mdi:printer', + 'newError': '', + 'opt': 1, + 'paper_level': 0, + 'paper_size1': 4, + 'paper_size2': 0, + 'paper_type1': 2, + 'paper_type2': 0, + }), + 'context': , + 'entity_id': 'sensor.my_printer_tray_tray_1', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'Ready', + }) +# --- diff --git a/tests/components/syncthru/test_binary_sensor.py b/tests/components/syncthru/test_binary_sensor.py new file mode 100644 index 00000000000..ae5f0b6a90c --- /dev/null +++ b/tests/components/syncthru/test_binary_sensor.py @@ -0,0 +1,27 @@ +"""Tests for the Syncthru binary sensor platform.""" + +from unittest.mock import AsyncMock, patch + +from syrupy import SnapshotAssertion + +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from . import setup_integration + +from tests.common import MockConfigEntry, snapshot_platform + + +async def test_all_entities( + hass: HomeAssistant, + snapshot: SnapshotAssertion, + mock_syncthru: AsyncMock, + mock_config_entry: MockConfigEntry, + entity_registry: er.EntityRegistry, +) -> None: + """Test all entities.""" + with patch("homeassistant.components.syncthru.PLATFORMS", [Platform.BINARY_SENSOR]): + await setup_integration(hass, mock_config_entry) + + await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) diff --git a/tests/components/syncthru/test_sensor.py b/tests/components/syncthru/test_sensor.py new file mode 100644 index 00000000000..600e2962730 --- /dev/null +++ b/tests/components/syncthru/test_sensor.py @@ -0,0 +1,29 @@ +"""Tests for the Syncthru sensor platform.""" + +from unittest.mock import AsyncMock, patch + +import pytest +from syrupy import SnapshotAssertion + +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from . import setup_integration + +from tests.common import MockConfigEntry, snapshot_platform + + +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +async def test_all_entities( + hass: HomeAssistant, + snapshot: SnapshotAssertion, + mock_syncthru: AsyncMock, + mock_config_entry: MockConfigEntry, + entity_registry: er.EntityRegistry, +) -> None: + """Test all entities.""" + with patch("homeassistant.components.syncthru.PLATFORMS", [Platform.SENSOR]): + await setup_integration(hass, mock_config_entry) + + await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) From 7f4d178781fd77fddbfb8f13d3e7bb901fd19804 Mon Sep 17 00:00:00 2001 From: TimL Date: Thu, 10 Apr 2025 03:04:19 +1000 Subject: [PATCH 400/505] Make exceptions translatable for SMLIGHT (#142587) * Exceptions translations * check off quality scale * translate another exception --- homeassistant/components/smlight/coordinator.py | 6 +++++- homeassistant/components/smlight/quality_scale.yaml | 2 +- homeassistant/components/smlight/strings.json | 8 ++++++++ homeassistant/components/smlight/update.py | 10 ++++++++-- 4 files changed, 22 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/smlight/coordinator.py b/homeassistant/components/smlight/coordinator.py index 5a118e7de15..8a8dcd74b8f 100644 --- a/homeassistant/components/smlight/coordinator.py +++ b/homeassistant/components/smlight/coordinator.py @@ -111,7 +111,11 @@ class SmBaseDataUpdateCoordinator[_DataT](DataUpdateCoordinator[_DataT]): raise ConfigEntryAuthFailed from err except SmlightConnectionError as err: - raise UpdateFailed(err) from err + raise UpdateFailed( + translation_domain=DOMAIN, + translation_key="cannot_connect_device", + translation_placeholders={"error": str(err)}, + ) from err @abstractmethod async def _internal_update_data(self) -> _DataT: diff --git a/homeassistant/components/smlight/quality_scale.yaml b/homeassistant/components/smlight/quality_scale.yaml index 0e1d4616d2a..5c6d7364704 100644 --- a/homeassistant/components/smlight/quality_scale.yaml +++ b/homeassistant/components/smlight/quality_scale.yaml @@ -70,7 +70,7 @@ rules: entity-device-class: done entity-disabled-by-default: done entity-translations: done - exception-translations: todo + exception-translations: done icon-translations: done reconfiguration-flow: todo repair-issues: done diff --git a/homeassistant/components/smlight/strings.json b/homeassistant/components/smlight/strings.json index b74dab791de..4abc6349d1e 100644 --- a/homeassistant/components/smlight/strings.json +++ b/homeassistant/components/smlight/strings.json @@ -145,6 +145,14 @@ } } }, + "exceptions": { + "firmware_update_failed": { + "message": "Firmware update failed for {device_name}." + }, + "cannot_connect_device": { + "message": "An error occurred while connecting to the SMLIGHT device: {error}." + } + }, "issues": { "unsupported_firmware": { "title": "SLZB core firmware update required", diff --git a/homeassistant/components/smlight/update.py b/homeassistant/components/smlight/update.py index 48f9149645c..d7aed0ecb4d 100644 --- a/homeassistant/components/smlight/update.py +++ b/homeassistant/components/smlight/update.py @@ -22,7 +22,7 @@ from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from .const import LOGGER +from .const import DOMAIN, LOGGER from .coordinator import SmConfigEntry, SmFirmwareUpdateCoordinator, SmFwData from .entity import SmEntity @@ -210,7 +210,13 @@ class SmUpdateEntity(SmEntity, UpdateEntity): def _update_failed(self, event: MessageEvent) -> None: self._update_done() self.coordinator.in_progress = False - raise HomeAssistantError(f"Update failed for {self.name}") + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="firmware_update_failed", + translation_placeholders={ + "device_name": str(self.name), + }, + ) async def async_install( self, version: str | None, backup: bool, **kwargs: Any From f34431476211475b86707e829ab344c52987833f Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Wed, 9 Apr 2025 19:04:41 +0200 Subject: [PATCH 401/505] Abort if a flow is removed during a step (#142138) * Abort if a flow is removed during a step * Reorganize code * Only call _set_pending_import_done if an entry is created * Try a new approach * Add tests * Update tests --- homeassistant/config_entries.py | 36 +++++++++------ homeassistant/data_entry_flow.py | 12 +++++ tests/test_config_entries.py | 4 +- tests/test_data_entry_flow.py | 76 +++++++++++++++++++++++++------- 4 files changed, 95 insertions(+), 33 deletions(-) diff --git a/homeassistant/config_entries.py b/homeassistant/config_entries.py index b47815c9aa9..705cc01061b 100644 --- a/homeassistant/config_entries.py +++ b/homeassistant/config_entries.py @@ -1503,6 +1503,22 @@ class ConfigEntriesFlowManager( future.set_result(None) self._discovery_event_debouncer.async_shutdown() + @callback + def async_flow_removed( + self, + flow: data_entry_flow.FlowHandler[ConfigFlowContext, ConfigFlowResult], + ) -> None: + """Handle a removed config flow.""" + flow = cast(ConfigFlow, flow) + + # Clean up issue if this is a reauth flow + if flow.context["source"] == SOURCE_REAUTH: + if (entry_id := flow.context.get("entry_id")) is not None and ( + entry := self.config_entries.async_get_entry(entry_id) + ) is not None: + issue_id = f"config_entry_reauth_{entry.domain}_{entry.entry_id}" + ir.async_delete_issue(self.hass, HOMEASSISTANT_DOMAIN, issue_id) + async def async_finish_flow( self, flow: data_entry_flow.FlowHandler[ConfigFlowContext, ConfigFlowResult], @@ -1515,20 +1531,6 @@ class ConfigEntriesFlowManager( """ flow = cast(ConfigFlow, flow) - # Mark the step as done. - # We do this to avoid a circular dependency where async_finish_flow sets up a - # new entry, which needs the integration to be set up, which is waiting for - # init to be done. - self._set_pending_import_done(flow) - - # Clean up issue if this is a reauth flow - if flow.context["source"] == SOURCE_REAUTH: - if (entry_id := flow.context.get("entry_id")) is not None and ( - entry := self.config_entries.async_get_entry(entry_id) - ) is not None: - issue_id = f"config_entry_reauth_{entry.domain}_{entry.entry_id}" - ir.async_delete_issue(self.hass, HOMEASSISTANT_DOMAIN, issue_id) - if result["type"] != data_entry_flow.FlowResultType.CREATE_ENTRY: # If there's a config entry with a matching unique ID, # update the discovery key. @@ -1567,6 +1569,12 @@ class ConfigEntriesFlowManager( ) return result + # Mark the step as done. + # We do this to avoid a circular dependency where async_finish_flow sets up a + # new entry, which needs the integration to be set up, which is waiting for + # init to be done. + self._set_pending_import_done(flow) + # Avoid adding a config entry for a integration # that only supports a single config entry, but already has an entry if ( diff --git a/homeassistant/data_entry_flow.py b/homeassistant/data_entry_flow.py index 511bab25a7f..6a288380cd0 100644 --- a/homeassistant/data_entry_flow.py +++ b/homeassistant/data_entry_flow.py @@ -207,6 +207,13 @@ class FlowManager(abc.ABC, Generic[_FlowContextT, _FlowResultT, _HandlerT]): Handler key is the domain of the component that we want to set up. """ + @callback + def async_flow_removed( + self, + flow: FlowHandler[_FlowContextT, _FlowResultT, _HandlerT], + ) -> None: + """Handle a removed data entry flow.""" + @abc.abstractmethod async def async_finish_flow( self, @@ -457,6 +464,7 @@ class FlowManager(abc.ABC, Generic[_FlowContextT, _FlowResultT, _HandlerT]): """Remove a flow from in progress.""" if (flow := self._progress.pop(flow_id, None)) is None: raise UnknownFlow + self.async_flow_removed(flow) self._async_remove_flow_from_index(flow) flow.async_cancel_progress_task() try: @@ -485,6 +493,10 @@ class FlowManager(abc.ABC, Generic[_FlowContextT, _FlowResultT, _HandlerT]): description_placeholders=err.description_placeholders, ) + if flow.flow_id not in self._progress: + # The flow was removed during the step + raise UnknownFlow + # Setup the flow handler's preview if needed if result.get("preview") is not None: await self._async_setup_preview(flow) diff --git a/tests/test_config_entries.py b/tests/test_config_entries.py index 8f1591cec3b..5c2e2aea215 100644 --- a/tests/test_config_entries.py +++ b/tests/test_config_entries.py @@ -1395,9 +1395,7 @@ async def test_reauth_issue_flow_aborted( issue = await _test_reauth_issue(hass, manager, issue_registry) manager.flow.async_abort(issue.data["flow_id"]) - # This can be considered a bug, we should make sure the issue is always - # removed when the reauth flow is aborted. - assert len(issue_registry.issues) == 1 + assert len(issue_registry.issues) == 0 async def _test_reauth_issue( diff --git a/tests/test_data_entry_flow.py b/tests/test_data_entry_flow.py index 994d37dcd65..bcc40251bad 100644 --- a/tests/test_data_entry_flow.py +++ b/tests/test_data_entry_flow.py @@ -243,6 +243,23 @@ async def test_abort_calls_async_remove(manager: MockFlowManager) -> None: assert len(manager.mock_created_entries) == 0 +async def test_abort_calls_async_flow_removed(manager: MockFlowManager) -> None: + """Test abort calling the async_flow_removed FlowManager method.""" + + @manager.mock_reg_handler("test") + class TestFlow(data_entry_flow.FlowHandler): + async def async_step_init(self, user_input=None): + return self.async_abort(reason="reason") + + manager.async_flow_removed = Mock() + await manager.async_init("test") + + manager.async_flow_removed.assert_called_once() + + assert len(manager.async_progress()) == 0 + assert len(manager.mock_created_entries) == 0 + + async def test_abort_calls_async_remove_with_exception( manager: MockFlowManager, caplog: pytest.LogCaptureFixture ) -> None: @@ -288,13 +305,7 @@ async def test_create_saves_data(manager: MockFlowManager) -> None: async def test_create_aborted_flow(manager: MockFlowManager) -> None: - """Test return create_entry from aborted flow. - - Note: The entry is created even if the flow is already aborted, then the - flow raises an UnknownFlow exception. This behavior is not logical, and - we should consider changing it to not create the entry if the flow is - aborted. - """ + """Test return create_entry from aborted flow.""" @manager.mock_reg_handler("test") class TestFlow(data_entry_flow.FlowHandler): @@ -308,14 +319,25 @@ async def test_create_aborted_flow(manager: MockFlowManager) -> None: await manager.async_init("test") assert len(manager.async_progress()) == 0 - # The entry is created even if the flow is aborted - assert len(manager.mock_created_entries) == 1 + # No entry should be created if the flow is aborted + assert len(manager.mock_created_entries) == 0 - entry = manager.mock_created_entries[0] - assert entry["handler"] == "test" - assert entry["title"] == "Test Title" - assert entry["data"] == "Test Data" - assert entry["source"] is None + +async def test_create_calls_async_flow_removed(manager: MockFlowManager) -> None: + """Test create calling the async_flow_removed FlowManager method.""" + + @manager.mock_reg_handler("test") + class TestFlow(data_entry_flow.FlowHandler): + async def async_step_init(self, user_input=None): + return self.async_create_entry(title="Test Title", data="Test Data") + + manager.async_flow_removed = Mock() + await manager.async_init("test") + + manager.async_flow_removed.assert_called_once() + + assert len(manager.async_progress()) == 0 + assert len(manager.mock_created_entries) == 1 async def test_discovery_init_flow(manager: MockFlowManager) -> None: @@ -930,12 +952,34 @@ async def test_configure_raises_unknown_flow_if_not_in_progress( await manager.async_configure("wrong_flow_id") -async def test_abort_raises_unknown_flow_if_not_in_progress( +async def test_manager_abort_raises_unknown_flow_if_not_in_progress( manager: MockFlowManager, ) -> None: """Test abort raises UnknownFlow if the flow is not in progress.""" with pytest.raises(data_entry_flow.UnknownFlow): - await manager.async_abort("wrong_flow_id") + manager.async_abort("wrong_flow_id") + + +async def test_manager_abort_calls_async_flow_removed(manager: MockFlowManager) -> None: + """Test abort calling the async_flow_removed FlowManager method.""" + + @manager.mock_reg_handler("test") + class TestFlow(data_entry_flow.FlowHandler): + async def async_step_init(self, user_input=None): + return self.async_show_form(step_id="init") + + manager.async_flow_removed = Mock() + result = await manager.async_init("test") + assert result["type"] == data_entry_flow.FlowResultType.FORM + assert result["step_id"] == "init" + + manager.async_flow_removed.assert_not_called() + + manager.async_abort(result["flow_id"]) + manager.async_flow_removed.assert_called_once() + + assert len(manager.async_progress()) == 0 + assert len(manager.mock_created_entries) == 0 @pytest.mark.parametrize( From 1663756983e32dd4521159afa30bc4adf461b032 Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Wed, 9 Apr 2025 19:06:00 +0200 Subject: [PATCH 402/505] Replace typo "to login to" with "to log in to" in `fyta` (#142576) --- homeassistant/components/fyta/strings.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/fyta/strings.json b/homeassistant/components/fyta/strings.json index f595b66ee37..a10fa5bfc47 100644 --- a/homeassistant/components/fyta/strings.json +++ b/homeassistant/components/fyta/strings.json @@ -9,8 +9,8 @@ "password": "[%key:common::config_flow::data::password%]" }, "data_description": { - "username": "The email address to login to your FYTA account.", - "password": "The password to login to your FYTA account." + "username": "The email address to log in to your FYTA account.", + "password": "The password to log in to your FYTA account." } }, "reauth_confirm": { From b5083ce97334791665ee0499900d599a49e0cbd3 Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Wed, 9 Apr 2025 19:12:26 +0200 Subject: [PATCH 403/505] Replace typo "to login to" with "to log in to" in `ohme` (#142578) --- homeassistant/components/ohme/strings.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/ohme/strings.json b/homeassistant/components/ohme/strings.json index fa19adbede8..bcd9cfd17fe 100644 --- a/homeassistant/components/ohme/strings.json +++ b/homeassistant/components/ohme/strings.json @@ -140,7 +140,7 @@ }, "exceptions": { "auth_failed": { - "message": "Unable to login to Ohme" + "message": "Unable to log in to Ohme" }, "device_info_failed": { "message": "Unable to get Ohme device information" From 46d6241f587a4014a3ec360c2f0833fe3f1bf7d9 Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Wed, 9 Apr 2025 19:12:47 +0200 Subject: [PATCH 404/505] Replace typo "to login to" with "to log in to" in `traccar_server` (#142599) --- homeassistant/components/traccar_server/strings.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/traccar_server/strings.json b/homeassistant/components/traccar_server/strings.json index 3487f41efaa..a4b57562388 100644 --- a/homeassistant/components/traccar_server/strings.json +++ b/homeassistant/components/traccar_server/strings.json @@ -12,7 +12,7 @@ }, "data_description": { "host": "The hostname or IP address of your Traccar Server", - "username": "The username (email) you use to login to your Traccar Server" + "username": "The username (email) you use to log in to your Traccar Server" } } }, From 82c688e3beafb161164a20bfeb7035e131f8595b Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Wed, 9 Apr 2025 19:13:06 +0200 Subject: [PATCH 405/505] Replace typo "to login" with "to log in" in `smarttub` (#142600) --- homeassistant/components/smarttub/strings.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/smarttub/strings.json b/homeassistant/components/smarttub/strings.json index 79fa7a4820f..8391aaa4d47 100644 --- a/homeassistant/components/smarttub/strings.json +++ b/homeassistant/components/smarttub/strings.json @@ -3,7 +3,7 @@ "step": { "user": { "title": "Login", - "description": "Enter your SmartTub email address and password to login", + "description": "Enter your SmartTub email address and password to log in", "data": { "email": "[%key:common::config_flow::data::email%]", "password": "[%key:common::config_flow::data::password%]" From 816edb66c7167fc8c72f93a183cc92dbf44a6f75 Mon Sep 17 00:00:00 2001 From: Simone Chemelli Date: Wed, 9 Apr 2025 20:22:26 +0200 Subject: [PATCH 406/505] Add full test coverage for Fritz config_flow (#142418) --- .../components/fritz/quality_scale.yaml | 4 +- tests/components/fritz/const.py | 1 + .../fritz/snapshots/test_diagnostics.ambr | 1 + tests/components/fritz/test_config_flow.py | 55 +++++++++++++++++++ 4 files changed, 58 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/fritz/quality_scale.yaml b/homeassistant/components/fritz/quality_scale.yaml index a959a413c4b..c2d18a0be84 100644 --- a/homeassistant/components/fritz/quality_scale.yaml +++ b/homeassistant/components/fritz/quality_scale.yaml @@ -4,9 +4,7 @@ rules: appropriate-polling: done brands: done common-modules: done - config-flow-test-coverage: - status: todo - comment: one coverage miss in line 110 + config-flow-test-coverage: done config-flow: done dependency-transparency: done docs-actions: done diff --git a/tests/components/fritz/const.py b/tests/components/fritz/const.py index 1e292ed22bb..c1908c12a14 100644 --- a/tests/components/fritz/const.py +++ b/tests/components/fritz/const.py @@ -200,6 +200,7 @@ MOCK_FB_SERVICES: dict[str, dict] = { MOCK_IPS["printer"]: {"NewDisallow": False, "NewWANAccess": "granted"} } }, + "X_AVM-DE_UPnP1": {"GetInfo": {"NewEnable": True}}, } MOCK_MESH_DATA = { diff --git a/tests/components/fritz/snapshots/test_diagnostics.ambr b/tests/components/fritz/snapshots/test_diagnostics.ambr index 9b5b8c9353a..c2ca866ceb6 100644 --- a/tests/components/fritz/snapshots/test_diagnostics.ambr +++ b/tests/components/fritz/snapshots/test_diagnostics.ambr @@ -27,6 +27,7 @@ 'WLANConfiguration1', 'X_AVM-DE_Homeauto1', 'X_AVM-DE_HostFilter1', + 'X_AVM-DE_UPnP1', ]), 'is_router': True, 'last_exception': None, diff --git a/tests/components/fritz/test_config_flow.py b/tests/components/fritz/test_config_flow.py index f4c4229af74..ee3ae881b2c 100644 --- a/tests/components/fritz/test_config_flow.py +++ b/tests/components/fritz/test_config_flow.py @@ -1,5 +1,6 @@ """Tests for Fritz!Tools config flow.""" +from copy import deepcopy import dataclasses from unittest.mock import patch @@ -20,6 +21,7 @@ from homeassistant.components.fritz.const import ( ERROR_AUTH_INVALID, ERROR_CANNOT_CONNECT, ERROR_UNKNOWN, + ERROR_UPNP_NOT_CONFIGURED, FRITZ_AUTH_EXCEPTIONS, ) from homeassistant.config_entries import SOURCE_SSDP, SOURCE_USER @@ -38,7 +40,9 @@ from homeassistant.helpers.service_info.ssdp import ( SsdpServiceInfo, ) +from .conftest import FritzConnectionMock from .const import ( + MOCK_FB_SERVICES, MOCK_FIRMWARE_INFO, MOCK_IPS, MOCK_REQUEST, @@ -761,3 +765,54 @@ async def test_ssdp_ipv6_link_local(hass: HomeAssistant) -> None: ) assert result["type"] is FlowResultType.ABORT assert result["reason"] == "ignore_ip6_link_local" + + +async def test_upnp_not_enabled(hass: HomeAssistant) -> None: + """Test if UPNP service is enabled on the router.""" + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_USER}, + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + + # Disable UPnP + services = deepcopy(MOCK_FB_SERVICES) + services["X_AVM-DE_UPnP1"]["GetInfo"]["NewEnable"] = False + + with patch( + "homeassistant.components.fritz.config_flow.FritzConnection", + return_value=FritzConnectionMock(services), + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input=MOCK_USER_INPUT_SIMPLE + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + assert result["errors"]["base"] == ERROR_UPNP_NOT_CONFIGURED + + # Enable UPnP + services["X_AVM-DE_UPnP1"]["GetInfo"]["NewEnable"] = True + + with ( + patch( + "homeassistant.components.fritz.config_flow.FritzConnection", + return_value=FritzConnectionMock(services), + ), + patch( + "homeassistant.components.fritz.config_flow.socket.gethostbyname", + return_value=MOCK_IPS["fritz.box"], + ), + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input=MOCK_USER_INPUT_SIMPLE + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["data"][CONF_HOST] == "fake_host" + assert result["data"][CONF_PASSWORD] == "fake_pass" + assert result["data"][CONF_USERNAME] == "fake_user" + assert result["data"][CONF_PORT] == 49000 + assert result["data"][CONF_SSL] is False From 76015740f88046431e28d67649658061a1a6a3f1 Mon Sep 17 00:00:00 2001 From: Christopher Fenner <9592452+CFenner@users.noreply.github.com> Date: Wed, 9 Apr 2025 20:36:41 +0200 Subject: [PATCH 407/505] Fix Quickmode handling in ViCare integration (#142561) * only check quickmode if supported * update snapshot * revert --- homeassistant/components/vicare/fan.py | 27 +++++++++++-------- .../components/vicare/snapshots/test_fan.ambr | 19 +++++++++++-- 2 files changed, 33 insertions(+), 13 deletions(-) diff --git a/homeassistant/components/vicare/fan.py b/homeassistant/components/vicare/fan.py index d84b2038dde..88d42503a03 100644 --- a/homeassistant/components/vicare/fan.py +++ b/homeassistant/components/vicare/fan.py @@ -127,6 +127,7 @@ class ViCareFan(ViCareEntity, FanEntity): _attr_speed_count = len(ORDERED_NAMED_FAN_SPEEDS) _attr_translation_key = "ventilation" + _attributes: dict[str, Any] = {} def __init__( self, @@ -155,7 +156,7 @@ class ViCareFan(ViCareEntity, FanEntity): self._attr_supported_features |= FanEntityFeature.SET_SPEED # evaluate quickmodes - quickmodes: list[str] = ( + self._attributes["vicare_quickmodes"] = quickmodes = list[str]( device.getVentilationQuickmodes() if is_supported( "getVentilationQuickmodes", @@ -196,26 +197,23 @@ class ViCareFan(ViCareEntity, FanEntity): @property def is_on(self) -> bool | None: """Return true if the entity is on.""" - if ( - self._attr_supported_features & FanEntityFeature.TURN_OFF - and self._api.getVentilationQuickmode(VentilationQuickmode.STANDBY) - ): + if VentilationQuickmode.STANDBY in self._attributes[ + "vicare_quickmodes" + ] and self._api.getVentilationQuickmode(VentilationQuickmode.STANDBY): return False return self.percentage is not None and self.percentage > 0 def turn_off(self, **kwargs: Any) -> None: """Turn the entity off.""" - self._api.activateVentilationQuickmode(str(VentilationQuickmode.STANDBY)) @property def icon(self) -> str | None: """Return the icon to use in the frontend.""" - if ( - self._attr_supported_features & FanEntityFeature.TURN_OFF - and self._api.getVentilationQuickmode(VentilationQuickmode.STANDBY) - ): + if VentilationQuickmode.STANDBY in self._attributes[ + "vicare_quickmodes" + ] and self._api.getVentilationQuickmode(VentilationQuickmode.STANDBY): return "mdi:fan-off" if hasattr(self, "_attr_preset_mode"): if self._attr_preset_mode == VentilationMode.VENTILATION: @@ -242,7 +240,9 @@ class ViCareFan(ViCareEntity, FanEntity): """Set the speed of the fan, as a percentage.""" if self._attr_preset_mode != str(VentilationMode.PERMANENT): self.set_preset_mode(VentilationMode.PERMANENT) - elif self._api.getVentilationQuickmode(VentilationQuickmode.STANDBY): + elif VentilationQuickmode.STANDBY in self._attributes[ + "vicare_quickmodes" + ] and self._api.getVentilationQuickmode(VentilationQuickmode.STANDBY): self._api.deactivateVentilationQuickmode(str(VentilationQuickmode.STANDBY)) level = percentage_to_ordered_list_item(ORDERED_NAMED_FAN_SPEEDS, percentage) @@ -254,3 +254,8 @@ class ViCareFan(ViCareEntity, FanEntity): target_mode = VentilationMode.to_vicare_mode(preset_mode) _LOGGER.debug("changing ventilation mode to %s", target_mode) self._api.activateVentilationMode(target_mode) + + @property + def extra_state_attributes(self) -> dict[str, Any]: + """Show Device Attributes.""" + return self._attributes diff --git a/tests/components/vicare/snapshots/test_fan.ambr b/tests/components/vicare/snapshots/test_fan.ambr index 2c9e815f7bf..2a44fb87b65 100644 --- a/tests/components/vicare/snapshots/test_fan.ambr +++ b/tests/components/vicare/snapshots/test_fan.ambr @@ -55,6 +55,11 @@ , ]), 'supported_features': , + 'vicare_quickmodes': list([ + 'comfort', + 'eco', + 'holiday', + ]), }), 'context': , 'entity_id': 'fan.model0_ventilation', @@ -94,7 +99,7 @@ 'options': dict({ }), 'original_device_class': None, - 'original_icon': 'mdi:fan-off', + 'original_icon': 'mdi:fan', 'original_name': 'Ventilation', 'platform': 'vicare', 'previous_unique_id': None, @@ -108,7 +113,7 @@ StateSnapshot({ 'attributes': ReadOnlyDict({ 'friendly_name': 'model1 Ventilation', - 'icon': 'mdi:fan-off', + 'icon': 'mdi:fan', 'percentage': 0, 'percentage_step': 25.0, 'preset_mode': None, @@ -118,6 +123,11 @@ , ]), 'supported_features': , + 'vicare_quickmodes': list([ + 'comfort', + 'eco', + 'holiday', + ]), }), 'context': , 'entity_id': 'fan.model1_ventilation', @@ -179,6 +189,11 @@ , ]), 'supported_features': , + 'vicare_quickmodes': list([ + 'comfort', + 'eco', + 'holiday', + ]), }), 'context': , 'entity_id': 'fan.model2_ventilation', From 1b66278a68f50501b93f5bc70943b34b92e46fcf Mon Sep 17 00:00:00 2001 From: skrynklarn <20681457+skrynklarn@users.noreply.github.com> Date: Wed, 9 Apr 2025 21:22:02 +0200 Subject: [PATCH 408/505] Extend UnitOfReactivePower with 'kvar' (#142558) --- homeassistant/components/number/const.py | 4 ++-- homeassistant/components/sensor/const.py | 4 ++-- homeassistant/const.py | 1 + 3 files changed, 5 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/number/const.py b/homeassistant/components/number/const.py index f44a510b1c0..280edb819d4 100644 --- a/homeassistant/components/number/const.py +++ b/homeassistant/components/number/const.py @@ -323,7 +323,7 @@ class NumberDeviceClass(StrEnum): REACTIVE_POWER = "reactive_power" """Reactive power. - Unit of measurement: `var` + Unit of measurement: `var`, `kvar` """ SIGNAL_STRENGTH = "signal_strength" @@ -497,7 +497,7 @@ DEVICE_CLASS_UNITS: dict[NumberDeviceClass, set[type[StrEnum] | str | None]] = { NumberDeviceClass.PRECIPITATION: set(UnitOfPrecipitationDepth), NumberDeviceClass.PRECIPITATION_INTENSITY: set(UnitOfVolumetricFlux), NumberDeviceClass.PRESSURE: set(UnitOfPressure), - NumberDeviceClass.REACTIVE_POWER: {UnitOfReactivePower.VOLT_AMPERE_REACTIVE}, + NumberDeviceClass.REACTIVE_POWER: set(UnitOfReactivePower), NumberDeviceClass.SIGNAL_STRENGTH: { SIGNAL_STRENGTH_DECIBELS, SIGNAL_STRENGTH_DECIBELS_MILLIWATT, diff --git a/homeassistant/components/sensor/const.py b/homeassistant/components/sensor/const.py index 63af8e5bf52..c845980e9df 100644 --- a/homeassistant/components/sensor/const.py +++ b/homeassistant/components/sensor/const.py @@ -352,7 +352,7 @@ class SensorDeviceClass(StrEnum): REACTIVE_POWER = "reactive_power" """Reactive power. - Unit of measurement: `var` + Unit of measurement: `var`, `kvar` """ SIGNAL_STRENGTH = "signal_strength" @@ -596,7 +596,7 @@ DEVICE_CLASS_UNITS: dict[SensorDeviceClass, set[type[StrEnum] | str | None]] = { SensorDeviceClass.PRECIPITATION: set(UnitOfPrecipitationDepth), SensorDeviceClass.PRECIPITATION_INTENSITY: set(UnitOfVolumetricFlux), SensorDeviceClass.PRESSURE: set(UnitOfPressure), - SensorDeviceClass.REACTIVE_POWER: {UnitOfReactivePower.VOLT_AMPERE_REACTIVE}, + SensorDeviceClass.REACTIVE_POWER: set(UnitOfReactivePower), SensorDeviceClass.SIGNAL_STRENGTH: { SIGNAL_STRENGTH_DECIBELS, SIGNAL_STRENGTH_DECIBELS_MILLIWATT, diff --git a/homeassistant/const.py b/homeassistant/const.py index a6f39db8532..db0af10fba3 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -603,6 +603,7 @@ class UnitOfReactivePower(StrEnum): """Reactive power units.""" VOLT_AMPERE_REACTIVE = "var" + KILO_VOLT_AMPERE_REACTIVE = "kvar" _DEPRECATED_POWER_VOLT_AMPERE_REACTIVE: Final = DeprecatedConstantEnum( From 9fe306f0561af86a154bbc50aeffc6ad8f8da58d Mon Sep 17 00:00:00 2001 From: Maarten Staa Date: Wed, 9 Apr 2025 22:20:21 +0200 Subject: [PATCH 409/505] Add support for air purifiers in HomeKit (#142467) * Add support for air purifier type in HomeKit. Any fan and PM2.5 in the same device will be treated as an air purifier. type_air_purifiers.py heavily based on type_fans.py - I tried extending type_fans.py but this looked better to me. * Refactor to make AirPurifier class extend Fan. * Ensure all chars are added before creating service * Add support for switching automatic mode. * Add test for auto/manual switch * Add support for air purifier type in HomeKit. Any fan and PM2.5 in the same device will be treated as an air purifier. type_air_purifiers.py heavily based on type_fans.py - I tried extending type_fans.py but this looked better to me. * Add support for air purifier type in HomeKit. Any fan and PM2.5 in the same device will be treated as an air purifier. type_air_purifiers.py heavily based on type_fans.py - I tried extending type_fans.py but this looked better to me. * Refactor to make AirPurifier class extend Fan. * Ensure all chars are added before creating service * Add support for switching automatic mode. * Add test for auto/manual switch * Add support for air purifier type in HomeKit. Any fan and PM2.5 in the same device will be treated as an air purifier. type_air_purifiers.py heavily based on type_fans.py - I tried extending type_fans.py but this looked better to me. * Improve fan config: allow setting fan type (fan or air purifier) Be more explicit than assuming a fan is an air purifier if it has a PM2.5 sensor. Set defaults based on the presence of sensors. * Fix return type annotation for fan/air purifier create_services * Allow linking air purifier filter level/change indicator * Remove no longer needed if statement in fan init * Fix up types and clean up code * Update homekit tests to account for air purifiers * Fix pylint errors * Fix mypy errors * Improve type annotations * Improve readability of auto preset mode discovery * Test air purifier with 'Auto' preset mode * Handle case with a single preset mode * Test air purifier edge cases: state updates to same value, and removed linked entities * Don't create 'auto mode' switch for air purifiers This is already exposed as a target mode on the air purifier service itself * Handle unavailable states in air purifier Also don't remove device class when updating state in test * Reduce branching in air purifier test * Split up air purifier tests for with and without auto presets, to reduce branching * Handle unavailable states in air purifier more explicitly * Use constant for ignored state values * Use a set for ignored_states * Update tests/components/homekit/test_type_air_purifiers.py --------- Co-authored-by: Andrew Kurowski <62596884+ak6i@users.noreply.github.com> Co-authored-by: J. Nick Koston Co-authored-by: J. Nick Koston --- homeassistant/components/homekit/__init__.py | 23 + .../components/homekit/accessories.py | 11 +- homeassistant/components/homekit/const.py | 13 + .../components/homekit/type_air_purifiers.py | 469 ++++++++++++ homeassistant/components/homekit/type_fans.py | 46 +- homeassistant/components/homekit/util.py | 30 + .../homekit/test_get_accessories.py | 19 + tests/components/homekit/test_homekit.py | 106 +++ .../homekit/test_type_air_purifiers.py | 702 ++++++++++++++++++ tests/components/homekit/test_util.py | 1 + 10 files changed, 1407 insertions(+), 13 deletions(-) create mode 100644 homeassistant/components/homekit/type_air_purifiers.py create mode 100644 tests/components/homekit/test_type_air_purifiers.py diff --git a/homeassistant/components/homekit/__init__.py b/homeassistant/components/homekit/__init__.py index 9bd5711832c..8b526b62302 100644 --- a/homeassistant/components/homekit/__init__.py +++ b/homeassistant/components/homekit/__init__.py @@ -31,6 +31,7 @@ from homeassistant.components.device_automation.trigger import ( async_validate_trigger_config, ) from homeassistant.components.event import DOMAIN as EVENT_DOMAIN, EventDeviceClass +from homeassistant.components.fan import DOMAIN as FAN_DOMAIN from homeassistant.components.http import KEY_HASS, HomeAssistantView from homeassistant.components.humidifier import DOMAIN as HUMIDIFIER_DOMAIN from homeassistant.components.lock import DOMAIN as LOCK_DOMAIN @@ -49,6 +50,7 @@ from homeassistant.const import ( CONF_IP_ADDRESS, CONF_NAME, CONF_PORT, + CONF_TYPE, EVENT_HOMEASSISTANT_STOP, SERVICE_RELOAD, ) @@ -83,6 +85,7 @@ from homeassistant.loader import IntegrationNotFound, async_get_integration from homeassistant.util.async_ import create_eager_task from . import ( # noqa: F401 + type_air_purifiers, type_cameras, type_covers, type_fans, @@ -113,6 +116,8 @@ from .const import ( CONF_LINKED_DOORBELL_SENSOR, CONF_LINKED_HUMIDITY_SENSOR, CONF_LINKED_MOTION_SENSOR, + CONF_LINKED_PM25_SENSOR, + CONF_LINKED_TEMPERATURE_SENSOR, CONFIG_OPTIONS, DEFAULT_EXCLUDE_ACCESSORY_MODE, DEFAULT_HOMEKIT_MODE, @@ -126,6 +131,7 @@ from .const import ( SERVICE_HOMEKIT_UNPAIR, SHUTDOWN_TIMEOUT, SIGNAL_RELOAD_ENTITIES, + TYPE_AIR_PURIFIER, ) from .iidmanager import AccessoryIIDStorage from .models import HomeKitConfigEntry, HomeKitEntryData @@ -169,6 +175,8 @@ MOTION_EVENT_SENSOR = (EVENT_DOMAIN, EventDeviceClass.MOTION) MOTION_SENSOR = (BINARY_SENSOR_DOMAIN, BinarySensorDeviceClass.MOTION) DOORBELL_EVENT_SENSOR = (EVENT_DOMAIN, EventDeviceClass.DOORBELL) HUMIDITY_SENSOR = (SENSOR_DOMAIN, SensorDeviceClass.HUMIDITY) +TEMPERATURE_SENSOR = (SENSOR_DOMAIN, SensorDeviceClass.TEMPERATURE) +PM25_SENSOR = (SENSOR_DOMAIN, SensorDeviceClass.PM25) def _has_all_unique_names_and_ports( @@ -1136,6 +1144,21 @@ class HomeKit: CONF_LINKED_DOORBELL_SENSOR, doorbell_event_entity_id ) + if domain == FAN_DOMAIN: + if current_humidity_sensor_entity_id := lookup.get(HUMIDITY_SENSOR): + config[entity_id].setdefault( + CONF_LINKED_HUMIDITY_SENSOR, current_humidity_sensor_entity_id + ) + if current_pm25_sensor_entity_id := lookup.get(PM25_SENSOR): + config[entity_id].setdefault(CONF_TYPE, TYPE_AIR_PURIFIER) + config[entity_id].setdefault( + CONF_LINKED_PM25_SENSOR, current_pm25_sensor_entity_id + ) + if current_temperature_sensor_entity_id := lookup.get(TEMPERATURE_SENSOR): + config[entity_id].setdefault( + CONF_LINKED_TEMPERATURE_SENSOR, current_temperature_sensor_entity_id + ) + if domain == HUMIDIFIER_DOMAIN and ( current_humidity_sensor_entity_id := lookup.get(HUMIDITY_SENSOR) ): diff --git a/homeassistant/components/homekit/accessories.py b/homeassistant/components/homekit/accessories.py index 0d810d6986d..d680181f5e4 100644 --- a/homeassistant/components/homekit/accessories.py +++ b/homeassistant/components/homekit/accessories.py @@ -85,6 +85,8 @@ from .const import ( SERV_ACCESSORY_INFO, SERV_BATTERY_SERVICE, SIGNAL_RELOAD_ENTITIES, + TYPE_AIR_PURIFIER, + TYPE_FAN, TYPE_FAUCET, TYPE_OUTLET, TYPE_SHOWER, @@ -112,6 +114,10 @@ SWITCH_TYPES = { TYPE_SWITCH: "Switch", TYPE_VALVE: "ValveSwitch", } +FAN_TYPES = { + TYPE_AIR_PURIFIER: "AirPurifier", + TYPE_FAN: "Fan", +} TYPES: Registry[str, type[HomeAccessory]] = Registry() RELOAD_ON_CHANGE_ATTRS = ( @@ -178,7 +184,10 @@ def get_accessory( # noqa: C901 a_type = "WindowCovering" elif state.domain == "fan": - a_type = "Fan" + if fan_type := config.get(CONF_TYPE): + a_type = FAN_TYPES[fan_type] + else: + a_type = "Fan" elif state.domain == "humidifier": a_type = "HumidifierDehumidifier" diff --git a/homeassistant/components/homekit/const.py b/homeassistant/components/homekit/const.py index 00b3de49169..ae682a0ea2d 100644 --- a/homeassistant/components/homekit/const.py +++ b/homeassistant/components/homekit/const.py @@ -49,9 +49,13 @@ CONF_EXCLUDE_ACCESSORY_MODE = "exclude_accessory_mode" CONF_LINKED_BATTERY_SENSOR = "linked_battery_sensor" CONF_LINKED_BATTERY_CHARGING_SENSOR = "linked_battery_charging_sensor" CONF_LINKED_DOORBELL_SENSOR = "linked_doorbell_sensor" +CONF_LINKED_FILTER_CHANGE_INDICATION = "linked_filter_change_indication_binary_sensor" +CONF_LINKED_FILTER_LIFE_LEVEL = "linked_filter_life_level_sensor" CONF_LINKED_MOTION_SENSOR = "linked_motion_sensor" CONF_LINKED_HUMIDITY_SENSOR = "linked_humidity_sensor" CONF_LINKED_OBSTRUCTION_SENSOR = "linked_obstruction_sensor" +CONF_LINKED_PM25_SENSOR = "linked_pm25_sensor" +CONF_LINKED_TEMPERATURE_SENSOR = "linked_temperature_sensor" CONF_LOW_BATTERY_THRESHOLD = "low_battery_threshold" CONF_MAX_FPS = "max_fps" CONF_MAX_HEIGHT = "max_height" @@ -120,12 +124,15 @@ TYPE_SHOWER = "shower" TYPE_SPRINKLER = "sprinkler" TYPE_SWITCH = "switch" TYPE_VALVE = "valve" +TYPE_FAN = "fan" +TYPE_AIR_PURIFIER = "air_purifier" # #### Categories #### CATEGORY_RECEIVER = 34 # #### Services #### SERV_ACCESSORY_INFO = "AccessoryInformation" +SERV_AIR_PURIFIER = "AirPurifier" SERV_AIR_QUALITY_SENSOR = "AirQualitySensor" SERV_BATTERY_SERVICE = "BatteryService" SERV_CAMERA_RTP_STREAM_MANAGEMENT = "CameraRTPStreamManagement" @@ -135,6 +142,7 @@ SERV_CONTACT_SENSOR = "ContactSensor" SERV_DOOR = "Door" SERV_DOORBELL = "Doorbell" SERV_FANV2 = "Fanv2" +SERV_FILTER_MAINTENANCE = "FilterMaintenance" SERV_GARAGE_DOOR_OPENER = "GarageDoorOpener" SERV_HUMIDIFIER_DEHUMIDIFIER = "HumidifierDehumidifier" SERV_HUMIDITY_SENSOR = "HumiditySensor" @@ -181,6 +189,7 @@ CHAR_CONFIGURED_NAME = "ConfiguredName" CHAR_CONTACT_SENSOR_STATE = "ContactSensorState" CHAR_COOLING_THRESHOLD_TEMPERATURE = "CoolingThresholdTemperature" CHAR_CURRENT_AMBIENT_LIGHT_LEVEL = "CurrentAmbientLightLevel" +CHAR_CURRENT_AIR_PURIFIER_STATE = "CurrentAirPurifierState" CHAR_CURRENT_DOOR_STATE = "CurrentDoorState" CHAR_CURRENT_FAN_STATE = "CurrentFanState" CHAR_CURRENT_HEATING_COOLING = "CurrentHeatingCoolingState" @@ -192,6 +201,8 @@ CHAR_CURRENT_TEMPERATURE = "CurrentTemperature" CHAR_CURRENT_TILT_ANGLE = "CurrentHorizontalTiltAngle" CHAR_CURRENT_VISIBILITY_STATE = "CurrentVisibilityState" CHAR_DEHUMIDIFIER_THRESHOLD_HUMIDITY = "RelativeHumidityDehumidifierThreshold" +CHAR_FILTER_CHANGE_INDICATION = "FilterChangeIndication" +CHAR_FILTER_LIFE_LEVEL = "FilterLifeLevel" CHAR_FIRMWARE_REVISION = "FirmwareRevision" CHAR_HARDWARE_REVISION = "HardwareRevision" CHAR_HEATING_THRESHOLD_TEMPERATURE = "HeatingThresholdTemperature" @@ -229,6 +240,7 @@ CHAR_SMOKE_DETECTED = "SmokeDetected" CHAR_STATUS_LOW_BATTERY = "StatusLowBattery" CHAR_STREAMING_STRATUS = "StreamingStatus" CHAR_SWING_MODE = "SwingMode" +CHAR_TARGET_AIR_PURIFIER_STATE = "TargetAirPurifierState" CHAR_TARGET_DOOR_STATE = "TargetDoorState" CHAR_TARGET_HEATING_COOLING = "TargetHeatingCoolingState" CHAR_TARGET_POSITION = "TargetPosition" @@ -256,6 +268,7 @@ PROP_VALID_VALUES = "ValidValues" # #### Thresholds #### THRESHOLD_CO = 25 THRESHOLD_CO2 = 1000 +THRESHOLD_FILTER_CHANGE_NEEDED = 10 # #### Default values #### DEFAULT_MIN_TEMP_WATER_HEATER = 40 # °C diff --git a/homeassistant/components/homekit/type_air_purifiers.py b/homeassistant/components/homekit/type_air_purifiers.py new file mode 100644 index 00000000000..25d305a0aa9 --- /dev/null +++ b/homeassistant/components/homekit/type_air_purifiers.py @@ -0,0 +1,469 @@ +"""Class to hold all air purifier accessories.""" + +import logging +from typing import Any + +from pyhap.characteristic import Characteristic +from pyhap.const import CATEGORY_AIR_PURIFIER +from pyhap.service import Service +from pyhap.util import callback as pyhap_callback + +from homeassistant.const import STATE_ON, STATE_UNAVAILABLE, STATE_UNKNOWN +from homeassistant.core import ( + Event, + EventStateChangedData, + HassJobType, + State, + callback, +) +from homeassistant.helpers.event import async_track_state_change_event + +from .accessories import TYPES +from .const import ( + CHAR_ACTIVE, + CHAR_AIR_QUALITY, + CHAR_CURRENT_AIR_PURIFIER_STATE, + CHAR_CURRENT_HUMIDITY, + CHAR_CURRENT_TEMPERATURE, + CHAR_FILTER_CHANGE_INDICATION, + CHAR_FILTER_LIFE_LEVEL, + CHAR_NAME, + CHAR_PM25_DENSITY, + CHAR_TARGET_AIR_PURIFIER_STATE, + CONF_LINKED_FILTER_CHANGE_INDICATION, + CONF_LINKED_FILTER_LIFE_LEVEL, + CONF_LINKED_HUMIDITY_SENSOR, + CONF_LINKED_PM25_SENSOR, + CONF_LINKED_TEMPERATURE_SENSOR, + SERV_AIR_PURIFIER, + SERV_AIR_QUALITY_SENSOR, + SERV_FILTER_MAINTENANCE, + SERV_HUMIDITY_SENSOR, + SERV_TEMPERATURE_SENSOR, + THRESHOLD_FILTER_CHANGE_NEEDED, +) +from .type_fans import ATTR_PRESET_MODE, CHAR_ROTATION_SPEED, Fan +from .util import cleanup_name_for_homekit, convert_to_float, density_to_air_quality + +_LOGGER = logging.getLogger(__name__) + +CURRENT_STATE_INACTIVE = 0 +CURRENT_STATE_IDLE = 1 +CURRENT_STATE_PURIFYING_AIR = 2 +TARGET_STATE_MANUAL = 0 +TARGET_STATE_AUTO = 1 +FILTER_CHANGE_FILTER = 1 +FILTER_OK = 0 + +IGNORED_STATES = {STATE_UNAVAILABLE, STATE_UNKNOWN} + + +@TYPES.register("AirPurifier") +class AirPurifier(Fan): + """Generate an AirPurifier accessory for an air purifier entity. + + Currently supports, in addition to Fan properties: + temperature; humidity; PM2.5; auto mode. + """ + + def __init__(self, *args: Any) -> None: + """Initialize a new AirPurifier accessory object.""" + super().__init__(*args, category=CATEGORY_AIR_PURIFIER) + + self.auto_preset: str | None = None + if self.preset_modes is not None: + for preset in self.preset_modes: + if str(preset).lower() == "auto": + self.auto_preset = preset + break + + def create_services(self) -> Service: + """Create and configure the primary service for this accessory.""" + self.chars.append(CHAR_ACTIVE) + self.chars.append(CHAR_CURRENT_AIR_PURIFIER_STATE) + self.chars.append(CHAR_TARGET_AIR_PURIFIER_STATE) + serv_air_purifier = self.add_preload_service(SERV_AIR_PURIFIER, self.chars) + self.set_primary_service(serv_air_purifier) + + self.char_active: Characteristic = serv_air_purifier.configure_char( + CHAR_ACTIVE, value=0 + ) + + self.preset_mode_chars: dict[str, Characteristic] + self.char_current_humidity: Characteristic | None = None + self.char_pm25_density: Characteristic | None = None + self.char_current_temperature: Characteristic | None = None + self.char_filter_change_indication: Characteristic | None = None + self.char_filter_life_level: Characteristic | None = None + + self.char_target_air_purifier_state: Characteristic = ( + serv_air_purifier.configure_char( + CHAR_TARGET_AIR_PURIFIER_STATE, + value=0, + ) + ) + + self.char_current_air_purifier_state: Characteristic = ( + serv_air_purifier.configure_char( + CHAR_CURRENT_AIR_PURIFIER_STATE, + value=0, + ) + ) + + self.linked_humidity_sensor = self.config.get(CONF_LINKED_HUMIDITY_SENSOR) + if self.linked_humidity_sensor: + humidity_serv = self.add_preload_service(SERV_HUMIDITY_SENSOR, CHAR_NAME) + serv_air_purifier.add_linked_service(humidity_serv) + self.char_current_humidity = humidity_serv.configure_char( + CHAR_CURRENT_HUMIDITY, value=0 + ) + + humidity_state = self.hass.states.get(self.linked_humidity_sensor) + if humidity_state: + self._async_update_current_humidity(humidity_state) + + self.linked_pm25_sensor = self.config.get(CONF_LINKED_PM25_SENSOR) + if self.linked_pm25_sensor: + pm25_serv = self.add_preload_service( + SERV_AIR_QUALITY_SENSOR, + [CHAR_AIR_QUALITY, CHAR_NAME, CHAR_PM25_DENSITY], + ) + serv_air_purifier.add_linked_service(pm25_serv) + self.char_pm25_density = pm25_serv.configure_char( + CHAR_PM25_DENSITY, value=0 + ) + + self.char_air_quality = pm25_serv.configure_char(CHAR_AIR_QUALITY) + + pm25_state = self.hass.states.get(self.linked_pm25_sensor) + if pm25_state: + self._async_update_current_pm25(pm25_state) + + self.linked_temperature_sensor = self.config.get(CONF_LINKED_TEMPERATURE_SENSOR) + if self.linked_temperature_sensor: + temperature_serv = self.add_preload_service( + SERV_TEMPERATURE_SENSOR, [CHAR_NAME, CHAR_CURRENT_TEMPERATURE] + ) + serv_air_purifier.add_linked_service(temperature_serv) + self.char_current_temperature = temperature_serv.configure_char( + CHAR_CURRENT_TEMPERATURE, value=0 + ) + + temperature_state = self.hass.states.get(self.linked_temperature_sensor) + if temperature_state: + self._async_update_current_temperature(temperature_state) + + self.linked_filter_change_indicator_binary_sensor = self.config.get( + CONF_LINKED_FILTER_CHANGE_INDICATION + ) + self.linked_filter_life_level_sensor = self.config.get( + CONF_LINKED_FILTER_LIFE_LEVEL + ) + if ( + self.linked_filter_change_indicator_binary_sensor + or self.linked_filter_life_level_sensor + ): + chars = [CHAR_NAME, CHAR_FILTER_CHANGE_INDICATION] + if self.linked_filter_life_level_sensor: + chars.append(CHAR_FILTER_LIFE_LEVEL) + serv_filter_maintenance = self.add_preload_service( + SERV_FILTER_MAINTENANCE, chars + ) + serv_air_purifier.add_linked_service(serv_filter_maintenance) + serv_filter_maintenance.configure_char( + CHAR_NAME, + value=cleanup_name_for_homekit(f"{self.display_name} Filter"), + ) + + self.char_filter_change_indication = serv_filter_maintenance.configure_char( + CHAR_FILTER_CHANGE_INDICATION, + value=0, + ) + + if self.linked_filter_change_indicator_binary_sensor: + filter_change_indicator_state = self.hass.states.get( + self.linked_filter_change_indicator_binary_sensor + ) + if filter_change_indicator_state: + self._async_update_filter_change_indicator( + filter_change_indicator_state + ) + + if self.linked_filter_life_level_sensor: + self.char_filter_life_level = serv_filter_maintenance.configure_char( + CHAR_FILTER_LIFE_LEVEL, + value=0, + ) + + filter_life_level_state = self.hass.states.get( + self.linked_filter_life_level_sensor + ) + if filter_life_level_state: + self._async_update_filter_life_level(filter_life_level_state) + + return serv_air_purifier + + def should_add_preset_mode_switch(self, preset_mode: str) -> bool: + """Check if a preset mode switch should be added.""" + return preset_mode.lower() != "auto" + + @callback + @pyhap_callback # type: ignore[misc] + def run(self) -> None: + """Handle accessory driver started event. + + Run inside the Home Assistant event loop. + """ + if self.linked_humidity_sensor: + self._subscriptions.append( + async_track_state_change_event( + self.hass, + [self.linked_humidity_sensor], + self._async_update_current_humidity_event, + job_type=HassJobType.Callback, + ) + ) + + if self.linked_pm25_sensor: + self._subscriptions.append( + async_track_state_change_event( + self.hass, + [self.linked_pm25_sensor], + self._async_update_current_pm25_event, + job_type=HassJobType.Callback, + ) + ) + + if self.linked_temperature_sensor: + self._subscriptions.append( + async_track_state_change_event( + self.hass, + [self.linked_temperature_sensor], + self._async_update_current_temperature_event, + job_type=HassJobType.Callback, + ) + ) + + if self.linked_filter_change_indicator_binary_sensor: + self._subscriptions.append( + async_track_state_change_event( + self.hass, + [self.linked_filter_change_indicator_binary_sensor], + self._async_update_filter_change_indicator_event, + job_type=HassJobType.Callback, + ) + ) + + if self.linked_filter_life_level_sensor: + self._subscriptions.append( + async_track_state_change_event( + self.hass, + [self.linked_filter_life_level_sensor], + self._async_update_filter_life_level_event, + job_type=HassJobType.Callback, + ) + ) + + super().run() + + @callback + def _async_update_current_humidity_event( + self, event: Event[EventStateChangedData] + ) -> None: + """Handle state change event listener callback.""" + self._async_update_current_humidity(event.data["new_state"]) + + @callback + def _async_update_current_humidity(self, new_state: State | None) -> None: + """Handle linked humidity sensor state change to update HomeKit value.""" + if new_state is None or new_state.state in IGNORED_STATES: + return + + if ( + (current_humidity := convert_to_float(new_state.state)) is None + or not self.char_current_humidity + or self.char_current_humidity.value == current_humidity + ): + return + + _LOGGER.debug( + "%s: Linked humidity sensor %s changed to %d", + self.entity_id, + self.linked_humidity_sensor, + current_humidity, + ) + self.char_current_humidity.set_value(current_humidity) + + @callback + def _async_update_current_pm25_event( + self, event: Event[EventStateChangedData] + ) -> None: + """Handle state change event listener callback.""" + self._async_update_current_pm25(event.data["new_state"]) + + @callback + def _async_update_current_pm25(self, new_state: State | None) -> None: + """Handle linked pm25 sensor state change to update HomeKit value.""" + if new_state is None or new_state.state in IGNORED_STATES: + return + + if ( + (current_pm25 := convert_to_float(new_state.state)) is None + or not self.char_pm25_density + or self.char_pm25_density.value == current_pm25 + ): + return + + _LOGGER.debug( + "%s: Linked pm25 sensor %s changed to %d", + self.entity_id, + self.linked_pm25_sensor, + current_pm25, + ) + self.char_pm25_density.set_value(current_pm25) + air_quality = density_to_air_quality(current_pm25) + self.char_air_quality.set_value(air_quality) + _LOGGER.debug("%s: Set air_quality to %d", self.entity_id, air_quality) + + @callback + def _async_update_current_temperature_event( + self, event: Event[EventStateChangedData] + ) -> None: + """Handle state change event listener callback.""" + self._async_update_current_temperature(event.data["new_state"]) + + @callback + def _async_update_current_temperature(self, new_state: State | None) -> None: + """Handle linked temperature sensor state change to update HomeKit value.""" + if new_state is None or new_state.state in IGNORED_STATES: + return + + if ( + (current_temperature := convert_to_float(new_state.state)) is None + or not self.char_current_temperature + or self.char_current_temperature.value == current_temperature + ): + return + + _LOGGER.debug( + "%s: Linked temperature sensor %s changed to %d", + self.entity_id, + self.linked_temperature_sensor, + current_temperature, + ) + self.char_current_temperature.set_value(current_temperature) + + @callback + def _async_update_filter_change_indicator_event( + self, event: Event[EventStateChangedData] + ) -> None: + """Handle state change event listener callback.""" + self._async_update_filter_change_indicator(event.data.get("new_state")) + + @callback + def _async_update_filter_change_indicator(self, new_state: State | None) -> None: + """Handle linked filter change indicator binary sensor state change to update HomeKit value.""" + if new_state is None or new_state.state in IGNORED_STATES: + return + + current_change_indicator = ( + FILTER_CHANGE_FILTER if new_state.state == "on" else FILTER_OK + ) + if ( + not self.char_filter_change_indication + or self.char_filter_change_indication.value == current_change_indicator + ): + return + + _LOGGER.debug( + "%s: Linked filter change indicator binary sensor %s changed to %d", + self.entity_id, + self.linked_filter_change_indicator_binary_sensor, + current_change_indicator, + ) + self.char_filter_change_indication.set_value(current_change_indicator) + + @callback + def _async_update_filter_life_level_event( + self, event: Event[EventStateChangedData] + ) -> None: + """Handle state change event listener callback.""" + self._async_update_filter_life_level(event.data.get("new_state")) + + @callback + def _async_update_filter_life_level(self, new_state: State | None) -> None: + """Handle linked filter life level sensor state change to update HomeKit value.""" + if new_state is None or new_state.state in IGNORED_STATES: + return + + if ( + (current_life_level := convert_to_float(new_state.state)) is not None + and self.char_filter_life_level + and self.char_filter_life_level.value != current_life_level + ): + _LOGGER.debug( + "%s: Linked filter life level sensor %s changed to %d", + self.entity_id, + self.linked_filter_life_level_sensor, + current_life_level, + ) + self.char_filter_life_level.set_value(current_life_level) + + if self.linked_filter_change_indicator_binary_sensor or not current_life_level: + # Handled by its own event listener + return + + current_change_indicator = ( + FILTER_CHANGE_FILTER + if (current_life_level < THRESHOLD_FILTER_CHANGE_NEEDED) + else FILTER_OK + ) + if ( + not self.char_filter_change_indication + or self.char_filter_change_indication.value == current_change_indicator + ): + return + + _LOGGER.debug( + "%s: Linked filter life level sensor %s changed to %d", + self.entity_id, + self.linked_filter_life_level_sensor, + current_change_indicator, + ) + self.char_filter_change_indication.set_value(current_change_indicator) + + @callback + def async_update_state(self, new_state: State) -> None: + """Update fan after state change.""" + super().async_update_state(new_state) + # Handle State + state = new_state.state + + if self.char_current_air_purifier_state is not None: + self.char_current_air_purifier_state.set_value( + CURRENT_STATE_PURIFYING_AIR + if state == STATE_ON + else CURRENT_STATE_INACTIVE + ) + + # Automatic mode is represented in HASS by a preset called Auto or auto + attributes = new_state.attributes + if ATTR_PRESET_MODE in attributes: + current_preset_mode = attributes.get(ATTR_PRESET_MODE) + self.char_target_air_purifier_state.set_value( + TARGET_STATE_AUTO + if current_preset_mode and current_preset_mode.lower() == "auto" + else TARGET_STATE_MANUAL + ) + + def set_chars(self, char_values: dict[str, Any]) -> None: + """Handle automatic mode after state change.""" + super().set_chars(char_values) + if ( + CHAR_TARGET_AIR_PURIFIER_STATE in char_values + and self.auto_preset is not None + ): + if char_values[CHAR_TARGET_AIR_PURIFIER_STATE] == TARGET_STATE_AUTO: + super().set_preset_mode(True, self.auto_preset) + elif self.char_speed is not None: + super().set_chars({CHAR_ROTATION_SPEED: self.char_speed.get_value()}) diff --git a/homeassistant/components/homekit/type_fans.py b/homeassistant/components/homekit/type_fans.py index 542d4500cbc..595dbc7ded3 100644 --- a/homeassistant/components/homekit/type_fans.py +++ b/homeassistant/components/homekit/type_fans.py @@ -4,6 +4,7 @@ import logging from typing import Any from pyhap.const import CATEGORY_FAN +from pyhap.service import Service from homeassistant.components.fan import ( ATTR_DIRECTION, @@ -56,9 +57,9 @@ class Fan(HomeAccessory): Currently supports: state, speed, oscillate, direction. """ - def __init__(self, *args: Any) -> None: + def __init__(self, *args: Any, category: int = CATEGORY_FAN) -> None: """Initialize a new Fan accessory object.""" - super().__init__(*args, category=CATEGORY_FAN) + super().__init__(*args, category=category) self.chars: list[str] = [] state = self.hass.states.get(self.entity_id) assert state @@ -79,12 +80,8 @@ class Fan(HomeAccessory): self.chars.append(CHAR_SWING_MODE) if features & FanEntityFeature.SET_SPEED: self.chars.append(CHAR_ROTATION_SPEED) - if self.preset_modes and len(self.preset_modes) == 1: - self.chars.append(CHAR_TARGET_FAN_STATE) - serv_fan = self.add_preload_service(SERV_FANV2, self.chars) - self.set_primary_service(serv_fan) - self.char_active = serv_fan.configure_char(CHAR_ACTIVE, value=0) + serv_fan = self.create_services() self.char_direction = None self.char_speed = None @@ -107,13 +104,21 @@ class Fan(HomeAccessory): properties={PROP_MIN_STEP: percentage_step}, ) - if self.preset_modes and len(self.preset_modes) == 1: + if ( + self.preset_modes + and len(self.preset_modes) == 1 + # NOTE: This would be missing for air purifiers + and CHAR_TARGET_FAN_STATE in self.chars + ): self.char_target_fan_state = serv_fan.configure_char( CHAR_TARGET_FAN_STATE, value=0, ) elif self.preset_modes: for preset_mode in self.preset_modes: + if not self.should_add_preset_mode_switch(preset_mode): + continue + preset_serv = self.add_preload_service( SERV_SWITCH, CHAR_NAME, unique_id=preset_mode ) @@ -126,7 +131,7 @@ class Fan(HomeAccessory): ) def setter_callback(value: int, preset_mode: str = preset_mode) -> None: - return self.set_preset_mode(value, preset_mode) + self.set_preset_mode(value, preset_mode) self.preset_mode_chars[preset_mode] = preset_serv.configure_char( CHAR_ON, @@ -137,10 +142,27 @@ class Fan(HomeAccessory): if CHAR_SWING_MODE in self.chars: self.char_swing = serv_fan.configure_char(CHAR_SWING_MODE, value=0) self.async_update_state(state) - serv_fan.setter_callback = self._set_chars + serv_fan.setter_callback = self.set_chars - def _set_chars(self, char_values: dict[str, Any]) -> None: - _LOGGER.debug("Fan _set_chars: %s", char_values) + def create_services(self) -> Service: + """Create and configure the primary service for this accessory.""" + if self.preset_modes and len(self.preset_modes) == 1: + self.chars.append(CHAR_TARGET_FAN_STATE) + serv_fan = self.add_preload_service(SERV_FANV2, self.chars) + self.set_primary_service(serv_fan) + self.char_active = serv_fan.configure_char(CHAR_ACTIVE, value=0) + return serv_fan + + def should_add_preset_mode_switch(self, preset_mode: str) -> bool: + """Check if a preset mode switch should be added. + + Always true for fans, but can be overridden by subclasses. + """ + return True + + def set_chars(self, char_values: dict[str, Any]) -> None: + """Set characteristic values.""" + _LOGGER.debug("Fan set_chars: %s", char_values) if CHAR_ACTIVE in char_values: if char_values[CHAR_ACTIVE]: # If the device supports set speed we diff --git a/homeassistant/components/homekit/util.py b/homeassistant/components/homekit/util.py index 1181ceaa953..bc98f00c15a 100644 --- a/homeassistant/components/homekit/util.py +++ b/homeassistant/components/homekit/util.py @@ -62,9 +62,13 @@ from .const import ( CONF_LINKED_BATTERY_CHARGING_SENSOR, CONF_LINKED_BATTERY_SENSOR, CONF_LINKED_DOORBELL_SENSOR, + CONF_LINKED_FILTER_CHANGE_INDICATION, + CONF_LINKED_FILTER_LIFE_LEVEL, CONF_LINKED_HUMIDITY_SENSOR, CONF_LINKED_MOTION_SENSOR, CONF_LINKED_OBSTRUCTION_SENSOR, + CONF_LINKED_PM25_SENSOR, + CONF_LINKED_TEMPERATURE_SENSOR, CONF_LOW_BATTERY_THRESHOLD, CONF_MAX_FPS, CONF_MAX_HEIGHT, @@ -98,6 +102,8 @@ from .const import ( FEATURE_PLAY_STOP, FEATURE_TOGGLE_MUTE, MAX_NAME_LENGTH, + TYPE_AIR_PURIFIER, + TYPE_FAN, TYPE_FAUCET, TYPE_OUTLET, TYPE_SHOWER, @@ -187,6 +193,27 @@ HUMIDIFIER_SCHEMA = BASIC_INFO_SCHEMA.extend( {vol.Optional(CONF_LINKED_HUMIDITY_SENSOR): cv.entity_domain(sensor.DOMAIN)} ) +FAN_SCHEMA = BASIC_INFO_SCHEMA.extend( + { + vol.Optional(CONF_TYPE, default=TYPE_FAN): vol.All( + cv.string, + vol.In( + ( + TYPE_FAN, + TYPE_AIR_PURIFIER, + ) + ), + ), + vol.Optional(CONF_LINKED_HUMIDITY_SENSOR): cv.entity_domain(sensor.DOMAIN), + vol.Optional(CONF_LINKED_PM25_SENSOR): cv.entity_domain(sensor.DOMAIN), + vol.Optional(CONF_LINKED_TEMPERATURE_SENSOR): cv.entity_domain(sensor.DOMAIN), + vol.Optional(CONF_LINKED_FILTER_CHANGE_INDICATION): cv.entity_domain( + binary_sensor.DOMAIN + ), + vol.Optional(CONF_LINKED_FILTER_LIFE_LEVEL): cv.entity_domain(sensor.DOMAIN), + } +) + COVER_SCHEMA = BASIC_INFO_SCHEMA.extend( { vol.Optional(CONF_LINKED_OBSTRUCTION_SENSOR): cv.entity_domain( @@ -325,6 +352,9 @@ def validate_entity_config(values: dict) -> dict[str, dict]: elif domain == "cover": config = COVER_SCHEMA(config) + elif domain == "fan": + config = FAN_SCHEMA(config) + elif domain == "sensor": config = SENSOR_SCHEMA(config) diff --git a/tests/components/homekit/test_get_accessories.py b/tests/components/homekit/test_get_accessories.py index c4b1cbe98d8..56208961312 100644 --- a/tests/components/homekit/test_get_accessories.py +++ b/tests/components/homekit/test_get_accessories.py @@ -6,11 +6,13 @@ import pytest from homeassistant.components.climate import ClimateEntityFeature from homeassistant.components.cover import CoverEntityFeature +from homeassistant.components.homekit import TYPE_AIR_PURIFIER from homeassistant.components.homekit.accessories import TYPES, get_accessory from homeassistant.components.homekit.const import ( ATTR_INTEGRATION, CONF_FEATURE_LIST, FEATURE_ON_OFF, + TYPE_FAN, TYPE_FAUCET, TYPE_OUTLET, TYPE_SHOWER, @@ -350,6 +352,23 @@ def test_type_switches(type_name, entity_id, state, attrs, config) -> None: assert mock_type.called +@pytest.mark.parametrize( + ("type_name", "entity_id", "state", "attrs", "config"), + [ + ("Fan", "fan.test", "on", {}, {}), + ("Fan", "fan.test", "on", {}, {CONF_TYPE: TYPE_FAN}), + ("AirPurifier", "fan.test", "on", {}, {CONF_TYPE: TYPE_AIR_PURIFIER}), + ], +) +def test_type_fans(type_name, entity_id, state, attrs, config) -> None: + """Test if switch types are associated correctly.""" + mock_type = Mock() + with patch.dict(TYPES, {type_name: mock_type}): + entity_state = State(entity_id, state, attrs) + get_accessory(None, None, entity_state, 2, config) + assert mock_type.called + + @pytest.mark.parametrize( ("type_name", "entity_id", "state", "attrs"), [ diff --git a/tests/components/homekit/test_homekit.py b/tests/components/homekit/test_homekit.py index 0829c96ce1d..f59c5d2778b 100644 --- a/tests/components/homekit/test_homekit.py +++ b/tests/components/homekit/test_homekit.py @@ -21,6 +21,7 @@ from homeassistant.components.homekit import ( STATUS_RUNNING, STATUS_STOPPED, STATUS_WAIT, + TYPE_AIR_PURIFIER, HomeKit, ) from homeassistant.components.homekit.accessories import HomeBridge @@ -51,6 +52,7 @@ from homeassistant.const import ( ATTR_DEVICE_ID, ATTR_ENTITY_ID, ATTR_UNIT_OF_MEASUREMENT, + CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, CONF_NAME, CONF_PORT, EVENT_HOMEASSISTANT_STARTED, @@ -58,6 +60,7 @@ from homeassistant.const import ( SERVICE_RELOAD, STATE_ON, EntityCategory, + UnitOfTemperature, ) from homeassistant.core import HomeAssistant, State from homeassistant.exceptions import HomeAssistantError @@ -2162,6 +2165,109 @@ async def test_homekit_finds_linked_humidity_sensors( ) +@pytest.mark.usefixtures("mock_async_zeroconf") +async def test_homekit_finds_linked_air_purifier_sensors( + hass: HomeAssistant, + hk_driver, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, +) -> None: + """Test HomeKit start method.""" + entry = await async_init_integration(hass) + + homekit = _mock_homekit(hass, entry, HOMEKIT_MODE_BRIDGE) + + homekit.driver = hk_driver + homekit.bridge = HomeBridge(hass, hk_driver, "mock_bridge") + + config_entry = MockConfigEntry(domain="air_purifier", data={}) + config_entry.add_to_hass(hass) + device_entry = device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + sw_version="0.16.1", + model="Smart Air Purifier", + manufacturer="Home Assistant", + connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, + ) + + humidity_sensor = entity_registry.async_get_or_create( + "sensor", + "air_purifier", + "humidity_sensor", + device_id=device_entry.id, + original_device_class=SensorDeviceClass.HUMIDITY, + ) + pm25_sensor = entity_registry.async_get_or_create( + "sensor", + "air_purifier", + "pm25_sensor", + device_id=device_entry.id, + original_device_class=SensorDeviceClass.PM25, + ) + temperature_sensor = entity_registry.async_get_or_create( + "sensor", + "air_purifier", + "temperature_sensor", + device_id=device_entry.id, + original_device_class=SensorDeviceClass.TEMPERATURE, + ) + air_purifier = entity_registry.async_get_or_create( + "fan", "air_purifier", "demo", device_id=device_entry.id + ) + + hass.states.async_set( + humidity_sensor.entity_id, + "42", + { + ATTR_DEVICE_CLASS: SensorDeviceClass.HUMIDITY, + ATTR_UNIT_OF_MEASUREMENT: PERCENTAGE, + }, + ) + hass.states.async_set( + pm25_sensor.entity_id, + 8, + { + ATTR_DEVICE_CLASS: SensorDeviceClass.PM25, + ATTR_UNIT_OF_MEASUREMENT: CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, + }, + ) + hass.states.async_set( + temperature_sensor.entity_id, + 22, + { + ATTR_DEVICE_CLASS: SensorDeviceClass.TEMPERATURE, + ATTR_UNIT_OF_MEASUREMENT: UnitOfTemperature.CELSIUS, + }, + ) + hass.states.async_set(air_purifier.entity_id, STATE_ON) + + with ( + patch.object(homekit.bridge, "add_accessory"), + patch(f"{PATH_HOMEKIT}.async_show_setup_message"), + patch(f"{PATH_HOMEKIT}.get_accessory") as mock_get_acc, + patch("pyhap.accessory_driver.AccessoryDriver.async_start"), + ): + await homekit.async_start() + await hass.async_block_till_done() + + mock_get_acc.assert_called_with( + hass, + ANY, + ANY, + ANY, + { + "manufacturer": "Home Assistant", + "model": "Smart Air Purifier", + "platform": "air_purifier", + "sw_version": "0.16.1", + "type": TYPE_AIR_PURIFIER, + "linked_humidity_sensor": "sensor.air_purifier_humidity_sensor", + "linked_pm25_sensor": "sensor.air_purifier_pm25_sensor", + "linked_temperature_sensor": "sensor.air_purifier_temperature_sensor", + }, + ) + + @pytest.mark.usefixtures("mock_async_zeroconf") async def test_reload(hass: HomeAssistant) -> None: """Test we can reload from yaml.""" diff --git a/tests/components/homekit/test_type_air_purifiers.py b/tests/components/homekit/test_type_air_purifiers.py new file mode 100644 index 00000000000..90b0e0047de --- /dev/null +++ b/tests/components/homekit/test_type_air_purifiers.py @@ -0,0 +1,702 @@ +"""Test different accessory types: Air Purifiers.""" + +from unittest.mock import MagicMock + +from pyhap.const import HAP_REPR_AID, HAP_REPR_CHARS, HAP_REPR_IID, HAP_REPR_VALUE +import pytest + +from homeassistant.components.fan import ( + ATTR_PERCENTAGE, + ATTR_PRESET_MODE, + ATTR_PRESET_MODES, + DOMAIN as FAN_DOMAIN, + FanEntityFeature, +) +from homeassistant.components.homekit import ( + CONF_LINKED_HUMIDITY_SENSOR, + CONF_LINKED_PM25_SENSOR, + CONF_LINKED_TEMPERATURE_SENSOR, +) +from homeassistant.components.homekit.const import ( + CONF_LINKED_FILTER_CHANGE_INDICATION, + CONF_LINKED_FILTER_LIFE_LEVEL, + THRESHOLD_FILTER_CHANGE_NEEDED, +) +from homeassistant.components.homekit.type_air_purifiers import ( + FILTER_CHANGE_FILTER, + FILTER_OK, + TARGET_STATE_AUTO, + TARGET_STATE_MANUAL, + AirPurifier, +) +from homeassistant.components.sensor import SensorDeviceClass +from homeassistant.const import ( + ATTR_DEVICE_CLASS, + ATTR_ENTITY_ID, + ATTR_SUPPORTED_FEATURES, + STATE_OFF, + STATE_ON, + STATE_UNAVAILABLE, +) +from homeassistant.core import Event, HomeAssistant + +from tests.common import async_mock_service + + +@pytest.mark.parametrize( + ("auto_preset", "preset_modes"), + [ + ("auto", ["sleep", "smart", "auto"]), + ("Auto", ["sleep", "smart", "Auto"]), + ], +) +async def test_fan_auto_manual( + hass: HomeAssistant, + hk_driver, + events: list[Event], + auto_preset: str, + preset_modes: list[str], +) -> None: + """Test switching between Auto and Manual.""" + entity_id = "fan.demo" + + hass.states.async_set( + entity_id, + STATE_ON, + { + ATTR_SUPPORTED_FEATURES: FanEntityFeature.PRESET_MODE + | FanEntityFeature.SET_SPEED, + ATTR_PRESET_MODE: auto_preset, + ATTR_PRESET_MODES: preset_modes, + }, + ) + await hass.async_block_till_done() + acc = AirPurifier(hass, hk_driver, "Air Purifier", entity_id, 1, None) + hk_driver.add_accessory(acc) + + assert acc.preset_mode_chars["smart"].value == 0 + assert acc.preset_mode_chars["sleep"].value == 0 + assert acc.auto_preset is not None + + # Auto presets are handled as the target air purifier state, so + # not supposed to be exposed as a separate switch + switches = set() + for service in acc.services: + if service.display_name == "Switch": + switches.add(service.unique_id) + + assert len(switches) == len(preset_modes) - 1 + for preset in preset_modes: + if preset != auto_preset: + assert preset in switches + else: + # Auto preset should not be in switches + assert preset not in switches + + acc.run() + await hass.async_block_till_done() + + assert acc.char_target_air_purifier_state.value == TARGET_STATE_AUTO + + hass.states.async_set( + entity_id, + STATE_ON, + { + ATTR_SUPPORTED_FEATURES: FanEntityFeature.PRESET_MODE + | FanEntityFeature.SET_SPEED, + ATTR_PRESET_MODE: "smart", + ATTR_PRESET_MODES: preset_modes, + }, + ) + await hass.async_block_till_done() + + assert acc.preset_mode_chars["smart"].value == 1 + assert acc.char_target_air_purifier_state.value == TARGET_STATE_MANUAL + + # Set from HomeKit + call_set_preset_mode = async_mock_service(hass, FAN_DOMAIN, "set_preset_mode") + call_set_percentage = async_mock_service(hass, FAN_DOMAIN, "set_percentage") + char_auto_iid = acc.char_target_air_purifier_state.to_HAP()[HAP_REPR_IID] + + hk_driver.set_characteristics( + { + HAP_REPR_CHARS: [ + { + HAP_REPR_AID: acc.aid, + HAP_REPR_IID: char_auto_iid, + HAP_REPR_VALUE: 1, + }, + ] + }, + "mock_addr", + ) + await hass.async_block_till_done() + + assert acc.char_target_air_purifier_state.value == TARGET_STATE_AUTO + assert len(call_set_preset_mode) == 1 + assert call_set_preset_mode[0].data[ATTR_ENTITY_ID] == entity_id + assert call_set_preset_mode[0].data[ATTR_PRESET_MODE] == auto_preset + assert len(events) == 1 + assert events[-1].data["service"] == "set_preset_mode" + + hk_driver.set_characteristics( + { + HAP_REPR_CHARS: [ + { + HAP_REPR_AID: acc.aid, + HAP_REPR_IID: char_auto_iid, + HAP_REPR_VALUE: 0, + }, + ] + }, + "mock_addr", + ) + await hass.async_block_till_done() + assert acc.char_target_air_purifier_state.value == TARGET_STATE_MANUAL + assert len(call_set_percentage) == 1 + assert call_set_percentage[0].data[ATTR_ENTITY_ID] == entity_id + assert events[-1].data["service"] == "set_percentage" + assert len(events) == 2 + + +async def test_presets_no_auto( + hass: HomeAssistant, + hk_driver, + events: list[Event], +) -> None: + """Test preset without an auto mode.""" + entity_id = "fan.demo" + + preset_modes = ["sleep", "smart"] + hass.states.async_set( + entity_id, + STATE_ON, + { + ATTR_SUPPORTED_FEATURES: FanEntityFeature.PRESET_MODE + | FanEntityFeature.SET_SPEED, + ATTR_PRESET_MODE: "smart", + ATTR_PRESET_MODES: preset_modes, + }, + ) + await hass.async_block_till_done() + acc = AirPurifier(hass, hk_driver, "Air Purifier", entity_id, 1, None) + hk_driver.add_accessory(acc) + + assert acc.preset_mode_chars["smart"].value == 1 + assert acc.preset_mode_chars["sleep"].value == 0 + assert acc.auto_preset is None + + # Auto presets are handled as the target air purifier state, so + # not supposed to be exposed as a separate switch + switches = set() + for service in acc.services: + if service.display_name == "Switch": + switches.add(service.unique_id) + + assert len(switches) == len(preset_modes) + for preset in preset_modes: + assert preset in switches + + acc.run() + await hass.async_block_till_done() + + assert acc.char_target_air_purifier_state.value == TARGET_STATE_MANUAL + + hass.states.async_set( + entity_id, + STATE_ON, + { + ATTR_SUPPORTED_FEATURES: FanEntityFeature.PRESET_MODE + | FanEntityFeature.SET_SPEED, + ATTR_PRESET_MODE: "sleep", + ATTR_PRESET_MODES: preset_modes, + }, + ) + await hass.async_block_till_done() + + assert acc.preset_mode_chars["smart"].value == 0 + assert acc.preset_mode_chars["sleep"].value == 1 + assert acc.char_target_air_purifier_state.value == TARGET_STATE_MANUAL + + +async def test_air_purifier_single_preset_mode( + hass: HomeAssistant, hk_driver, events: list[Event] +) -> None: + """Test air purifier with a single preset mode.""" + entity_id = "fan.demo" + + hass.states.async_set( + entity_id, + STATE_ON, + { + ATTR_SUPPORTED_FEATURES: FanEntityFeature.PRESET_MODE + | FanEntityFeature.SET_SPEED, + ATTR_PERCENTAGE: 42, + ATTR_PRESET_MODE: "auto", + ATTR_PRESET_MODES: ["auto"], + }, + ) + await hass.async_block_till_done() + acc = AirPurifier(hass, hk_driver, "Air Purifier", entity_id, 1, None) + hk_driver.add_accessory(acc) + + assert acc.char_target_air_purifier_state.value == TARGET_STATE_AUTO + + acc.run() + await hass.async_block_till_done() + + # Set from HomeKit + call_set_preset_mode = async_mock_service(hass, FAN_DOMAIN, "set_preset_mode") + call_set_percentage = async_mock_service(hass, FAN_DOMAIN, "set_percentage") + + char_target_air_purifier_state_iid = acc.char_target_air_purifier_state.to_HAP()[ + HAP_REPR_IID + ] + + hk_driver.set_characteristics( + { + HAP_REPR_CHARS: [ + { + HAP_REPR_AID: acc.aid, + HAP_REPR_IID: char_target_air_purifier_state_iid, + HAP_REPR_VALUE: TARGET_STATE_MANUAL, + }, + ] + }, + "mock_addr", + ) + await hass.async_block_till_done() + assert call_set_percentage[0] + assert call_set_percentage[0].data[ATTR_ENTITY_ID] == entity_id + assert call_set_percentage[0].data[ATTR_PERCENTAGE] == 42 + assert len(events) == 1 + assert events[-1].data["service"] == "set_percentage" + + hk_driver.set_characteristics( + { + HAP_REPR_CHARS: [ + { + HAP_REPR_AID: acc.aid, + HAP_REPR_IID: char_target_air_purifier_state_iid, + HAP_REPR_VALUE: 1, + }, + ] + }, + "mock_addr", + ) + await hass.async_block_till_done() + assert call_set_preset_mode[0] + assert call_set_preset_mode[0].data[ATTR_ENTITY_ID] == entity_id + assert call_set_preset_mode[0].data[ATTR_PRESET_MODE] == "auto" + assert events[-1].data["service"] == "set_preset_mode" + assert len(events) == 2 + + hass.states.async_set( + entity_id, + STATE_ON, + { + ATTR_SUPPORTED_FEATURES: FanEntityFeature.PRESET_MODE + | FanEntityFeature.SET_SPEED, + ATTR_PERCENTAGE: 42, + ATTR_PRESET_MODE: None, + ATTR_PRESET_MODES: ["auto"], + }, + ) + await hass.async_block_till_done() + assert acc.char_target_air_purifier_state.value == TARGET_STATE_MANUAL + + +async def test_expose_linked_sensors( + hass: HomeAssistant, hk_driver, events: list[Event] +) -> None: + """Test that linked sensors are exposed.""" + entity_id = "fan.demo" + + hass.states.async_set( + entity_id, + STATE_ON, + { + ATTR_SUPPORTED_FEATURES: FanEntityFeature.SET_SPEED, + }, + ) + + humidity_entity_id = "sensor.demo_humidity" + hass.states.async_set( + humidity_entity_id, + 50, + { + ATTR_DEVICE_CLASS: SensorDeviceClass.HUMIDITY, + }, + ) + + pm25_entity_id = "sensor.demo_pm25" + hass.states.async_set( + pm25_entity_id, + 10, + { + ATTR_DEVICE_CLASS: SensorDeviceClass.PM25, + }, + ) + + temperature_entity_id = "sensor.demo_temperature" + hass.states.async_set( + temperature_entity_id, + 25, + { + ATTR_DEVICE_CLASS: SensorDeviceClass.TEMPERATURE, + }, + ) + + await hass.async_block_till_done() + acc = AirPurifier( + hass, + hk_driver, + "Air Purifier", + entity_id, + 1, + { + CONF_LINKED_TEMPERATURE_SENSOR: temperature_entity_id, + CONF_LINKED_PM25_SENSOR: pm25_entity_id, + CONF_LINKED_HUMIDITY_SENSOR: humidity_entity_id, + }, + ) + hk_driver.add_accessory(acc) + + assert acc.linked_humidity_sensor is not None + assert acc.char_current_humidity is not None + assert acc.linked_pm25_sensor is not None + assert acc.char_pm25_density is not None + assert acc.char_air_quality is not None + assert acc.linked_temperature_sensor is not None + assert acc.char_current_temperature is not None + + acc.run() + await hass.async_block_till_done() + + assert acc.char_current_humidity.value == 50 + assert acc.char_pm25_density.value == 10 + assert acc.char_air_quality.value == 2 + assert acc.char_current_temperature.value == 25 + + # Updated humidity should reflect in HomeKit + broker = MagicMock() + acc.char_current_humidity.broker = broker + hass.states.async_set( + humidity_entity_id, + 60, + { + ATTR_DEVICE_CLASS: SensorDeviceClass.HUMIDITY, + }, + ) + await hass.async_block_till_done() + assert acc.char_current_humidity.value == 60 + assert len(broker.mock_calls) == 2 + broker.reset_mock() + + # Change to same state should not trigger update in HomeKit + hass.states.async_set( + humidity_entity_id, + 60, + { + ATTR_DEVICE_CLASS: SensorDeviceClass.HUMIDITY, + }, + force_update=True, + ) + await hass.async_block_till_done() + assert acc.char_current_humidity.value == 60 + assert len(broker.mock_calls) == 0 + + # Updated PM2.5 should reflect in HomeKit + broker = MagicMock() + acc.char_pm25_density.broker = broker + acc.char_air_quality.broker = broker + hass.states.async_set( + pm25_entity_id, + 5, + { + ATTR_DEVICE_CLASS: SensorDeviceClass.PM25, + }, + ) + await hass.async_block_till_done() + assert acc.char_pm25_density.value == 5 + assert acc.char_air_quality.value == 1 + assert len(broker.mock_calls) == 4 + broker.reset_mock() + + # Change to same state should not trigger update in HomeKit + hass.states.async_set( + pm25_entity_id, + 5, + { + ATTR_DEVICE_CLASS: SensorDeviceClass.PM25, + }, + force_update=True, + ) + await hass.async_block_till_done() + assert acc.char_pm25_density.value == 5 + assert acc.char_air_quality.value == 1 + assert len(broker.mock_calls) == 0 + + # Updated temperature should reflect in HomeKit + broker = MagicMock() + acc.char_current_temperature.broker = broker + hass.states.async_set( + temperature_entity_id, + 30, + { + ATTR_DEVICE_CLASS: SensorDeviceClass.TEMPERATURE, + }, + ) + await hass.async_block_till_done() + assert acc.char_current_temperature.value == 30 + assert len(broker.mock_calls) == 2 + broker.reset_mock() + + # Change to same state should not trigger update in HomeKit + hass.states.async_set( + temperature_entity_id, + 30, + { + ATTR_DEVICE_CLASS: SensorDeviceClass.TEMPERATURE, + }, + force_update=True, + ) + await hass.async_block_till_done() + assert acc.char_current_temperature.value == 30 + assert len(broker.mock_calls) == 0 + + # Should handle unavailable state, show last known value + hass.states.async_set( + humidity_entity_id, + STATE_UNAVAILABLE, + { + ATTR_DEVICE_CLASS: SensorDeviceClass.HUMIDITY, + }, + ) + hass.states.async_set( + pm25_entity_id, + STATE_UNAVAILABLE, + { + ATTR_DEVICE_CLASS: SensorDeviceClass.PM25, + }, + ) + hass.states.async_set( + temperature_entity_id, + STATE_UNAVAILABLE, + { + ATTR_DEVICE_CLASS: SensorDeviceClass.TEMPERATURE, + }, + ) + await hass.async_block_till_done() + assert acc.char_current_humidity.value == 60 + assert acc.char_pm25_density.value == 5 + assert acc.char_air_quality.value == 1 + assert acc.char_current_temperature.value == 30 + + # Check that all goes well if we remove the linked sensors + hass.states.async_remove(humidity_entity_id) + hass.states.async_remove(pm25_entity_id) + hass.states.async_remove(temperature_entity_id) + await hass.async_block_till_done() + acc.run() + await hass.async_block_till_done() + assert len(acc.char_current_humidity.broker.mock_calls) == 0 + assert len(acc.char_pm25_density.broker.mock_calls) == 0 + assert len(acc.char_air_quality.broker.mock_calls) == 0 + assert len(acc.char_current_temperature.broker.mock_calls) == 0 + + # HomeKit will show the last known values + assert acc.char_current_humidity.value == 60 + assert acc.char_pm25_density.value == 5 + assert acc.char_air_quality.value == 1 + assert acc.char_current_temperature.value == 30 + + +async def test_filter_maintenance_linked_sensors( + hass: HomeAssistant, hk_driver, events: list[Event] +) -> None: + """Test that a linked filter level and filter change indicator are exposed.""" + entity_id = "fan.demo" + hass.states.async_set( + entity_id, + STATE_ON, + { + ATTR_SUPPORTED_FEATURES: FanEntityFeature.SET_SPEED, + }, + ) + + filter_change_indicator_entity_id = "binary_sensor.demo_filter_change_indicator" + hass.states.async_set(filter_change_indicator_entity_id, STATE_OFF) + + filter_life_level_entity_id = "sensor.demo_filter_life_level" + hass.states.async_set(filter_life_level_entity_id, 50) + + await hass.async_block_till_done() + acc = AirPurifier( + hass, + hk_driver, + "Air Purifier", + entity_id, + 1, + { + CONF_LINKED_FILTER_CHANGE_INDICATION: filter_change_indicator_entity_id, + CONF_LINKED_FILTER_LIFE_LEVEL: filter_life_level_entity_id, + }, + ) + hk_driver.add_accessory(acc) + + assert acc.linked_filter_change_indicator_binary_sensor is not None + assert acc.char_filter_change_indication is not None + assert acc.linked_filter_life_level_sensor is not None + assert acc.char_filter_life_level is not None + + acc.run() + await hass.async_block_till_done() + + assert acc.char_filter_change_indication.value == FILTER_OK + assert acc.char_filter_life_level.value == 50 + + # Updated filter change indicator should reflect in HomeKit + broker = MagicMock() + acc.char_filter_change_indication.broker = broker + hass.states.async_set(filter_change_indicator_entity_id, STATE_ON) + await hass.async_block_till_done() + assert acc.char_filter_change_indication.value == FILTER_CHANGE_FILTER + assert len(broker.mock_calls) == 2 + broker.reset_mock() + + # Change to same state should not trigger update in HomeKit + hass.states.async_set( + filter_change_indicator_entity_id, STATE_ON, force_update=True + ) + await hass.async_block_till_done() + assert acc.char_filter_change_indication.value == FILTER_CHANGE_FILTER + assert len(broker.mock_calls) == 0 + + # Updated filter life level should reflect in HomeKit + broker = MagicMock() + acc.char_filter_life_level.broker = broker + hass.states.async_set(filter_life_level_entity_id, 25) + await hass.async_block_till_done() + assert acc.char_filter_life_level.value == 25 + assert len(broker.mock_calls) == 2 + broker.reset_mock() + + # Change to same state should not trigger update in HomeKit + hass.states.async_set(filter_life_level_entity_id, 25, force_update=True) + await hass.async_block_till_done() + assert acc.char_filter_life_level.value == 25 + assert len(broker.mock_calls) == 0 + + # Should handle unavailable state, show last known value + hass.states.async_set(filter_change_indicator_entity_id, STATE_UNAVAILABLE) + hass.states.async_set(filter_life_level_entity_id, STATE_UNAVAILABLE) + await hass.async_block_till_done() + assert acc.char_filter_change_indication.value == FILTER_CHANGE_FILTER + assert acc.char_filter_life_level.value == 25 + + # Check that all goes well if we remove the linked sensors + hass.states.async_remove(filter_change_indicator_entity_id) + hass.states.async_remove(filter_life_level_entity_id) + await hass.async_block_till_done() + acc.run() + await hass.async_block_till_done() + assert len(acc.char_filter_change_indication.broker.mock_calls) == 0 + assert len(acc.char_filter_life_level.broker.mock_calls) == 0 + + # HomeKit will show the last known values + assert acc.char_filter_change_indication.value == FILTER_CHANGE_FILTER + assert acc.char_filter_life_level.value == 25 + + +async def test_filter_maintenance_only_change_indicator_sensor( + hass: HomeAssistant, hk_driver, events: list[Event] +) -> None: + """Test that a linked filter change indicator is exposed.""" + entity_id = "fan.demo" + hass.states.async_set( + entity_id, + STATE_ON, + { + ATTR_SUPPORTED_FEATURES: FanEntityFeature.SET_SPEED, + }, + ) + + filter_change_indicator_entity_id = "binary_sensor.demo_filter_change_indicator" + hass.states.async_set(filter_change_indicator_entity_id, STATE_OFF) + + await hass.async_block_till_done() + acc = AirPurifier( + hass, + hk_driver, + "Air Purifier", + entity_id, + 1, + { + CONF_LINKED_FILTER_CHANGE_INDICATION: filter_change_indicator_entity_id, + }, + ) + hk_driver.add_accessory(acc) + + assert acc.linked_filter_change_indicator_binary_sensor is not None + assert acc.char_filter_change_indication is not None + assert acc.linked_filter_life_level_sensor is None + + acc.run() + await hass.async_block_till_done() + + assert acc.char_filter_change_indication.value == FILTER_OK + + hass.states.async_set(filter_change_indicator_entity_id, STATE_ON) + await hass.async_block_till_done() + assert acc.char_filter_change_indication.value == FILTER_CHANGE_FILTER + + +async def test_filter_life_level_linked_sensors( + hass: HomeAssistant, hk_driver, events: list[Event] +) -> None: + """Test that a linked filter life level sensor exposed.""" + entity_id = "fan.demo" + hass.states.async_set( + entity_id, + STATE_ON, + { + ATTR_SUPPORTED_FEATURES: FanEntityFeature.SET_SPEED, + }, + ) + + filter_life_level_entity_id = "sensor.demo_filter_life_level" + hass.states.async_set(filter_life_level_entity_id, 50) + + await hass.async_block_till_done() + acc = AirPurifier( + hass, + hk_driver, + "Air Purifier", + entity_id, + 1, + { + CONF_LINKED_FILTER_LIFE_LEVEL: filter_life_level_entity_id, + }, + ) + hk_driver.add_accessory(acc) + + assert acc.linked_filter_change_indicator_binary_sensor is None + assert ( + acc.char_filter_change_indication is not None + ) # calculated based on filter life level + assert acc.linked_filter_life_level_sensor is not None + assert acc.char_filter_life_level is not None + + acc.run() + await hass.async_block_till_done() + + assert acc.char_filter_change_indication.value == FILTER_OK + assert acc.char_filter_life_level.value == 50 + + hass.states.async_set( + filter_life_level_entity_id, THRESHOLD_FILTER_CHANGE_NEEDED - 1 + ) + await hass.async_block_till_done() + assert acc.char_filter_life_level.value == THRESHOLD_FILTER_CHANGE_NEEDED - 1 + assert acc.char_filter_change_indication.value == FILTER_CHANGE_FILTER diff --git a/tests/components/homekit/test_util.py b/tests/components/homekit/test_util.py index 1da12402a56..66906c72266 100644 --- a/tests/components/homekit/test_util.py +++ b/tests/components/homekit/test_util.py @@ -128,6 +128,7 @@ def test_validate_entity_config() -> None: } }, {"switch.test": {CONF_TYPE: "invalid_type"}}, + {"fan.test": {CONF_TYPE: "invalid_type"}}, ] for conf in configs: From b3fccc0de67faab3acf029758e3603d9087800c2 Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Wed, 9 Apr 2025 22:46:02 +0200 Subject: [PATCH 410/505] Replace typo "to login to" with "to log in to" in `reolink` (#142577) --- homeassistant/components/reolink/strings.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/reolink/strings.json b/homeassistant/components/reolink/strings.json index 8bfea1c6910..e478f06b556 100644 --- a/homeassistant/components/reolink/strings.json +++ b/homeassistant/components/reolink/strings.json @@ -17,8 +17,8 @@ "port": "The HTTP(s) port to connect to the Reolink device API. For HTTP normally: '80', for HTTPS normally '443'.", "use_https": "Use an HTTPS (SSL) connection to the Reolink device.", "baichuan_port": "The 'Basic Service Port' to connect to the Reolink device over TCP. Normally '9000' unless manually changed in the Reolink desktop client.", - "username": "Username to login to the Reolink device itself. Not the Reolink cloud account.", - "password": "Password to login to the Reolink device itself. Not the Reolink cloud account." + "username": "Username to log in to the Reolink device itself. Not the Reolink cloud account.", + "password": "Password to log in to the Reolink device itself. Not the Reolink cloud account." } }, "privacy": { @@ -33,7 +33,7 @@ "not_admin": "User needs to be admin, user \"{username}\" has authorisation level \"{userlevel}\"", "password_incompatible": "Password contains incompatible special character or is too long, maximum 31 characters and only these characters are allowed: a-z, A-Z, 0-9 or {special_chars}", "unknown": "[%key:common::config_flow::error::unknown%]", - "update_needed": "Failed to login because of outdated firmware, please update the firmware to version {needed_firmware} using the Reolink Download Center: {download_center_url}, currently version {current_firmware} is installed", + "update_needed": "Failed to log in because of outdated firmware, please update the firmware to version {needed_firmware} using the Reolink Download Center: {download_center_url}, currently version {current_firmware} is installed", "webhook_exception": "Home Assistant URL is not available, go to Settings > System > Network > Home Assistant URL and correct the URLs, see {more_info}" }, "abort": { From dd97d5bc7e9754ec7018a44964e83450e9ff4f36 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ab=C3=ADlio=20Costa?= Date: Wed, 9 Apr 2025 23:59:00 +0100 Subject: [PATCH 411/505] Move Whirlpool test and clean unused code (#142617) --- homeassistant/components/whirlpool/climate.py | 5 ----- tests/components/whirlpool/test_climate.py | 10 ---------- tests/components/whirlpool/test_config_flow.py | 2 +- tests/components/whirlpool/test_init.py | 10 ++++++++++ 4 files changed, 11 insertions(+), 16 deletions(-) diff --git a/homeassistant/components/whirlpool/climate.py b/homeassistant/components/whirlpool/climate.py index eb9e63efd44..6829dca3004 100644 --- a/homeassistant/components/whirlpool/climate.py +++ b/homeassistant/components/whirlpool/climate.py @@ -2,7 +2,6 @@ from __future__ import annotations -import logging from typing import Any from whirlpool.aircon import Aircon, FanSpeed as AirconFanSpeed, Mode as AirconMode @@ -26,9 +25,6 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import WhirlpoolConfigEntry from .entity import WhirlpoolEntity -_LOGGER = logging.getLogger(__name__) - - AIRCON_MODE_MAP = { AirconMode.Cool: HVACMode.COOL, AirconMode.Heat: HVACMode.HEAT, @@ -75,7 +71,6 @@ class AirConEntity(WhirlpoolEntity, ClimateEntity): """Representation of an air conditioner.""" _attr_fan_modes = SUPPORTED_FAN_MODES - _attr_has_entity_name = True _attr_name = None _attr_hvac_modes = SUPPORTED_HVAC_MODES _attr_max_temp = SUPPORTED_MAX_TEMP diff --git a/tests/components/whirlpool/test_climate.py b/tests/components/whirlpool/test_climate.py index 1a076b76637..a273900151b 100644 --- a/tests/components/whirlpool/test_climate.py +++ b/tests/components/whirlpool/test_climate.py @@ -63,16 +63,6 @@ async def update_ac_state( return hass.states.get(entity_id) -async def test_no_appliances( - hass: HomeAssistant, mock_appliances_manager_api: MagicMock -) -> None: - """Test the setup of the climate entities when there are no appliances available.""" - mock_appliances_manager_api.return_value.aircons = [] - mock_appliances_manager_api.return_value.washer_dryers = [] - await init_integration(hass) - assert len(hass.states.async_all()) == 0 - - async def test_static_attributes( hass: HomeAssistant, entity_registry: er.EntityRegistry, diff --git a/tests/components/whirlpool/test_config_flow.py b/tests/components/whirlpool/test_config_flow.py index e01fbc07b51..0e277ee629b 100644 --- a/tests/components/whirlpool/test_config_flow.py +++ b/tests/components/whirlpool/test_config_flow.py @@ -135,7 +135,7 @@ async def test_form_auth_error( @pytest.mark.usefixtures("mock_auth_api", "mock_appliances_manager_api") async def test_form_already_configured(hass: HomeAssistant, region, brand) -> None: - """Test we handle cannot connect error.""" + """Test that configuring the integration twice with the same data fails.""" mock_entry = MockConfigEntry( domain=DOMAIN, data=CONFIG_INPUT | {"region": region[0], "brand": brand[0]}, diff --git a/tests/components/whirlpool/test_init.py b/tests/components/whirlpool/test_init.py index 5f04bf84b9e..06e82b74ba7 100644 --- a/tests/components/whirlpool/test_init.py +++ b/tests/components/whirlpool/test_init.py @@ -75,6 +75,16 @@ async def test_setup_brand_fallback( mock_backend_selector_api.assert_called_once_with(Brand.Whirlpool, region[1]) +async def test_setup_no_appliances( + hass: HomeAssistant, mock_appliances_manager_api: MagicMock +) -> None: + """Test setup when there are no appliances available.""" + mock_appliances_manager_api.return_value.aircons = [] + mock_appliances_manager_api.return_value.washer_dryers = [] + await init_integration(hass) + assert len(hass.states.async_all()) == 0 + + async def test_setup_http_exception( hass: HomeAssistant, mock_auth_api: MagicMock, From fa291c20e5f4d930086db874ea57f4af838372cc Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 9 Apr 2025 15:48:29 -1000 Subject: [PATCH 412/505] Pin multidict to >= 6.4.2 to resolve memory leaks (#142614) * Pin multidict to >= 6.4.1 to resolve memory leaks https://github.com/aio-libs/multidict/issues/1134 https://github.com/aio-libs/multidict/issues/1131 https://github.com/aio-libs/multidict/releases/tag/v6.4.1 https://github.com/aio-libs/multidict/releases/tag/v6.4.0 * Apply suggestions from code review --- homeassistant/package_constraints.txt | 5 +++++ script/gen_requirements_all.py | 5 +++++ 2 files changed, 10 insertions(+) diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index af75218bf7e..ff03cb95f0b 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -212,3 +212,8 @@ async-timeout==4.0.3 # https://github.com/home-assistant/core/issues/122508 # https://github.com/home-assistant/core/issues/118004 aiofiles>=24.1.0 + +# multidict < 6.4.0 has memory leaks +# https://github.com/aio-libs/multidict/issues/1134 +# https://github.com/aio-libs/multidict/issues/1131 +multidict>=6.4.2 diff --git a/script/gen_requirements_all.py b/script/gen_requirements_all.py index acc87ec2731..f002b9b1b7f 100755 --- a/script/gen_requirements_all.py +++ b/script/gen_requirements_all.py @@ -241,6 +241,11 @@ async-timeout==4.0.3 # https://github.com/home-assistant/core/issues/122508 # https://github.com/home-assistant/core/issues/118004 aiofiles>=24.1.0 + +# multidict < 6.4.0 has memory leaks +# https://github.com/aio-libs/multidict/issues/1134 +# https://github.com/aio-libs/multidict/issues/1131 +multidict>=6.4.2 """ GENERATED_MESSAGE = ( From 54f3bb8ddf2c41fe4b20ad292fae8ffb5eaa84a4 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 9 Apr 2025 16:30:26 -1000 Subject: [PATCH 413/505] Bump pydantic to 2.11.13 (#142612) changelog: https://github.com/pydantic/pydantic/compare/v2.11.2...v2.11.3 --- homeassistant/package_constraints.txt | 2 +- requirements_test.txt | 2 +- script/gen_requirements_all.py | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index ff03cb95f0b..3bcad4b8f30 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -130,7 +130,7 @@ multidict>=6.0.2 backoff>=2.0 # ensure pydantic version does not float since it might have breaking changes -pydantic==2.11.2 +pydantic==2.11.3 # Required for Python 3.12.4 compatibility (#119223). mashumaro>=3.13.1 diff --git a/requirements_test.txt b/requirements_test.txt index b53b1fd8840..962a113e1a0 100644 --- a/requirements_test.txt +++ b/requirements_test.txt @@ -14,7 +14,7 @@ license-expression==30.4.1 mock-open==1.4.0 mypy-dev==1.16.0a7 pre-commit==4.0.0 -pydantic==2.11.2 +pydantic==2.11.3 pylint==3.3.6 pylint-per-file-ignores==1.4.0 pipdeptree==2.25.1 diff --git a/script/gen_requirements_all.py b/script/gen_requirements_all.py index f002b9b1b7f..b4e18ea5962 100755 --- a/script/gen_requirements_all.py +++ b/script/gen_requirements_all.py @@ -159,7 +159,7 @@ multidict>=6.0.2 backoff>=2.0 # ensure pydantic version does not float since it might have breaking changes -pydantic==2.11.2 +pydantic==2.11.3 # Required for Python 3.12.4 compatibility (#119223). mashumaro>=3.13.1 From 87e5b024c12ba5ae3fc55f1f5ddb41e23d2f40c9 Mon Sep 17 00:00:00 2001 From: henryptung Date: Wed, 9 Apr 2025 19:52:10 -0700 Subject: [PATCH 414/505] Bump led_ble to 1.1.7 (#142629) changelog: https://github.com/Bluetooth-Devices/led-ble/compare/v1.1.6...v1.1.7 --- homeassistant/components/led_ble/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/led_ble/manifest.json b/homeassistant/components/led_ble/manifest.json index 62ad21eb99a..6fa2c00da9f 100644 --- a/homeassistant/components/led_ble/manifest.json +++ b/homeassistant/components/led_ble/manifest.json @@ -35,5 +35,5 @@ "dependencies": ["bluetooth_adapters"], "documentation": "https://www.home-assistant.io/integrations/led_ble", "iot_class": "local_polling", - "requirements": ["bluetooth-data-tools==1.27.0", "led-ble==1.1.6"] + "requirements": ["bluetooth-data-tools==1.27.0", "led-ble==1.1.7"] } diff --git a/requirements_all.txt b/requirements_all.txt index b59eb1a20fd..8c5f876f1b4 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1314,7 +1314,7 @@ ld2410-ble==0.1.1 leaone-ble==0.1.0 # homeassistant.components.led_ble -led-ble==1.1.6 +led-ble==1.1.7 # homeassistant.components.lektrico lektricowifi==0.0.43 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 531e624107a..13b454e58df 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1114,7 +1114,7 @@ ld2410-ble==0.1.1 leaone-ble==0.1.0 # homeassistant.components.led_ble -led-ble==1.1.6 +led-ble==1.1.7 # homeassistant.components.lektrico lektricowifi==0.0.43 From b51bb668c6e7ad046aa77d1ed9066d6b4f92c105 Mon Sep 17 00:00:00 2001 From: Imeon-Energy Date: Thu, 10 Apr 2025 08:25:35 +0200 Subject: [PATCH 415/505] Add imeon inverter integration (#130958) * Initial commit prototype with empty inverters * Use modern methods and global variable for character strings * Platform that get the value of the meter in an entity * Add check if inverter already configured * Add tests for config_flow * Update "imeon_inverter_api" in manifest.json * Update "imeon_inverter_api" in requirements_all.txt * Remove async_setup, clean comments, use of const PLATFORM * Use of global variable and remove configuration of device name * Use of entry.data instead of user_input variable * Remove services.yaml * No quality scale * Use of common string * Add sensors, use of EntityDescription and '_attr_device_info' * Remove name from config_flow tests * Use sentence case and change integration from hub to device * Check connection before add platform in config_flow * Use of _async_setup and minor changes * Improve sensor description * Add quality_scale.yaml * Update the quality_scale.json * Add tests for host invalid, route invalid, exception and invalid auth * Type more precisely 'DataUpdateCoordinator' * Don't use 'self.data' directly in coordinator and minor corrections * Complete full quality_scale.yaml * Use of fixtures in the tests * Add snapshot tests for sensors * Refactor the try except and use serial as unique id * Change API version * Add test for sensor * Mock the api to generate the snapshot * New type for async_add_entries * Except timeout error for get_serial * Add test for get_serial timeout error * Move store data out of the try * Use sentence case * Use of fixtures * Use separates fixtures * Mock the api * Put sensors fake data in json fixture file * Use of a const interval, remove except timeout, enhance lisibility * Try to use same fixture in test_config_flow * Try use same fixture for all mock of inverter * Modify the fixture in the context manager, correct the tests * Fixture return mock.__aenter__ directly * Adjust code clarity * Bring all tests to either ABORT or CREATE_ENTRY * Make the try except more concise * Synthetize exception tests into one * Add code clarity * Nitpick with the tests * Use unique id sensor * Log an error on unknown error * Remove useless comments, disable always_update and better use of timeout * Adjust units, set the model and software version * Set full name for Battery SOC and use ip instead of url * Use of host instead of IP * Fix the unit of economy factor * Reduce mornitoring data display precision and update snapshots * Remove unused variable HUBs * Fix device info * Set address label 'Host or IP' * Fix the config_flow tests * Re evaluate the quality_scale * Use of 'host' instead of 'address' * Make inverter discoverable by ssdp * Add test ssdp configuration already exist * Add exemption in quality scale * Test abort ssdp if serial is unknown * Handle update error * Raise other exceptions * Handle ClientError and ValueError from the api * Update homeassistant/components/imeon_inverter/quality_scale.yaml --------- Co-authored-by: Franck Nijhof Co-authored-by: Joost Lekkerkerker Co-authored-by: Josef Zweck --- CODEOWNERS | 2 + .../components/imeon_inverter/__init__.py | 31 + .../components/imeon_inverter/config_flow.py | 114 + .../components/imeon_inverter/const.py | 9 + .../components/imeon_inverter/coordinator.py | 97 + .../components/imeon_inverter/icons.json | 159 + .../components/imeon_inverter/manifest.json | 18 + .../imeon_inverter/quality_scale.yaml | 71 + .../components/imeon_inverter/sensor.py | 464 +++ .../components/imeon_inverter/strings.json | 187 ++ homeassistant/generated/config_flows.py | 1 + homeassistant/generated/integrations.json | 6 + homeassistant/generated/ssdp.py | 7 + requirements_all.txt | 3 + requirements_test_all.txt | 3 + tests/components/imeon_inverter/__init__.py | 14 + tests/components/imeon_inverter/conftest.py | 85 + .../imeon_inverter/fixtures/sensor_data.json | 73 + .../imeon_inverter/snapshots/test_sensor.ambr | 2689 +++++++++++++++++ .../imeon_inverter/test_config_flow.py | 205 ++ .../components/imeon_inverter/test_sensor.py | 29 + 21 files changed, 4267 insertions(+) create mode 100644 homeassistant/components/imeon_inverter/__init__.py create mode 100644 homeassistant/components/imeon_inverter/config_flow.py create mode 100644 homeassistant/components/imeon_inverter/const.py create mode 100644 homeassistant/components/imeon_inverter/coordinator.py create mode 100644 homeassistant/components/imeon_inverter/icons.json create mode 100644 homeassistant/components/imeon_inverter/manifest.json create mode 100644 homeassistant/components/imeon_inverter/quality_scale.yaml create mode 100644 homeassistant/components/imeon_inverter/sensor.py create mode 100644 homeassistant/components/imeon_inverter/strings.json create mode 100644 tests/components/imeon_inverter/__init__.py create mode 100644 tests/components/imeon_inverter/conftest.py create mode 100644 tests/components/imeon_inverter/fixtures/sensor_data.json create mode 100644 tests/components/imeon_inverter/snapshots/test_sensor.ambr create mode 100644 tests/components/imeon_inverter/test_config_flow.py create mode 100644 tests/components/imeon_inverter/test_sensor.py diff --git a/CODEOWNERS b/CODEOWNERS index 8afd3bab028..1a1377f4d3f 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -704,6 +704,8 @@ build.json @home-assistant/supervisor /tests/components/image_upload/ @home-assistant/core /homeassistant/components/imap/ @jbouwh /tests/components/imap/ @jbouwh +/homeassistant/components/imeon_inverter/ @Imeon-Energy +/tests/components/imeon_inverter/ @Imeon-Energy /homeassistant/components/imgw_pib/ @bieniu /tests/components/imgw_pib/ @bieniu /homeassistant/components/improv_ble/ @emontnemery diff --git a/homeassistant/components/imeon_inverter/__init__.py b/homeassistant/components/imeon_inverter/__init__.py new file mode 100644 index 00000000000..0676731f375 --- /dev/null +++ b/homeassistant/components/imeon_inverter/__init__.py @@ -0,0 +1,31 @@ +"""Initialize the Imeon component.""" + +from __future__ import annotations + +import logging + +from homeassistant.core import HomeAssistant + +from .const import PLATFORMS +from .coordinator import InverterConfigEntry, InverterCoordinator + +_LOGGER = logging.getLogger(__name__) + + +async def async_setup_entry(hass: HomeAssistant, entry: InverterConfigEntry) -> bool: + """Handle the creation of a new config entry for the integration (asynchronous).""" + + # Create the corresponding HUB + coordinator = InverterCoordinator(hass, entry) + await coordinator.async_config_entry_first_refresh() + entry.runtime_data = coordinator + + # Call for HUB creation then each entity as a List + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + + return True + + +async def async_unload_entry(hass: HomeAssistant, entry: InverterConfigEntry) -> bool: + """Handle entry unloading.""" + return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/homeassistant/components/imeon_inverter/config_flow.py b/homeassistant/components/imeon_inverter/config_flow.py new file mode 100644 index 00000000000..fadb2c65446 --- /dev/null +++ b/homeassistant/components/imeon_inverter/config_flow.py @@ -0,0 +1,114 @@ +"""Config flow for Imeon integration.""" + +import logging +from typing import Any +from urllib.parse import urlparse + +from imeon_inverter_api.inverter import Inverter +import voluptuous as vol + +from homeassistant.config_entries import ConfigFlow, ConfigFlowResult +from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME +from homeassistant.helpers.service_info.ssdp import ( + ATTR_UPNP_MODEL_NUMBER, + ATTR_UPNP_SERIAL, + SsdpServiceInfo, +) +from homeassistant.helpers.typing import VolDictType + +from .const import DOMAIN + +_LOGGER = logging.getLogger(__name__) + + +class ImeonInverterConfigFlow(ConfigFlow, domain=DOMAIN): + """Handle the initial setup flow for Imeon Inverters.""" + + _host: str | None = None + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle the user step for creating a new configuration entry.""" + + errors: dict[str, str] = {} + + if user_input is not None: + # User have to provide the hostname if device is not discovered + host = self._host or user_input[CONF_HOST] + + async with Inverter(host) as client: + try: + # Check connection + if await client.login( + user_input[CONF_USERNAME], user_input[CONF_PASSWORD] + ): + serial = await client.get_serial() + + else: + errors["base"] = "invalid_auth" + + except TimeoutError: + errors["base"] = "cannot_connect" + + except ValueError as e: + if "Host invalid" in str(e): + errors["base"] = "invalid_host" + + elif "Route invalid" in str(e): + errors["base"] = "invalid_route" + + else: + errors["base"] = "unknown" + _LOGGER.exception( + "Unexpected error occurred while connecting to the Imeon" + ) + + if not errors: + # Check if entry already exists + await self.async_set_unique_id(serial, raise_on_progress=False) + self._abort_if_unique_id_configured() + + # Create a new configuration entry if login succeeds + return self.async_create_entry( + title=f"Imeon {serial}", data={CONF_HOST: host, **user_input} + ) + + host_schema: VolDictType = ( + {vol.Required(CONF_HOST): str} if not self._host else {} + ) + + return self.async_show_form( + step_id="user", + data_schema=vol.Schema( + { + **host_schema, + vol.Required(CONF_USERNAME): str, + vol.Required(CONF_PASSWORD): str, + } + ), + errors=errors, + ) + + async def async_step_ssdp( + self, discovery_info: SsdpServiceInfo + ) -> ConfigFlowResult: + """Handle a SSDP discovery.""" + + host = str(urlparse(discovery_info.ssdp_location).hostname) + serial = discovery_info.upnp.get(ATTR_UPNP_SERIAL, "") + + if not serial: + return self.async_abort(reason="cannot_connect") + + await self.async_set_unique_id(serial) + self._abort_if_unique_id_configured(updates={CONF_HOST: host}) + + self._host = host + + self.context["title_placeholders"] = { + "model": discovery_info.upnp.get(ATTR_UPNP_MODEL_NUMBER, ""), + "serial": serial, + } + + return await self.async_step_user() diff --git a/homeassistant/components/imeon_inverter/const.py b/homeassistant/components/imeon_inverter/const.py new file mode 100644 index 00000000000..c71a8c72d11 --- /dev/null +++ b/homeassistant/components/imeon_inverter/const.py @@ -0,0 +1,9 @@ +"""Constant for Imeon component.""" + +from homeassistant.const import Platform + +DOMAIN = "imeon_inverter" +TIMEOUT = 20 +PLATFORMS = [ + Platform.SENSOR, +] diff --git a/homeassistant/components/imeon_inverter/coordinator.py b/homeassistant/components/imeon_inverter/coordinator.py new file mode 100644 index 00000000000..8342240b9ff --- /dev/null +++ b/homeassistant/components/imeon_inverter/coordinator.py @@ -0,0 +1,97 @@ +"""Coordinator for Imeon integration.""" + +from __future__ import annotations + +from asyncio import timeout +from datetime import timedelta +import logging + +from aiohttp import ClientError +from imeon_inverter_api.inverter import Inverter + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME +from homeassistant.core import HomeAssistant +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed + +from .const import TIMEOUT + +HUBNAME = "imeon_inverter_hub" +INTERVAL = timedelta(seconds=60) +_LOGGER = logging.getLogger(__name__) + +type InverterConfigEntry = ConfigEntry[InverterCoordinator] + + +# HUB CREATION # +class InverterCoordinator(DataUpdateCoordinator[dict[str, str | float | int]]): + """Each inverter is it's own HUB, thus it's own data set. + + This allows this integration to handle as many + inverters as possible in parallel. + """ + + config_entry: InverterConfigEntry + + # Implement methods to fetch and update data + def __init__( + self, + hass: HomeAssistant, + entry: InverterConfigEntry, + ) -> None: + """Initialize data update coordinator.""" + super().__init__( + hass, + _LOGGER, + name=HUBNAME, + update_interval=INTERVAL, + config_entry=entry, + ) + + self._api = Inverter(entry.data[CONF_HOST]) + + @property + def api(self) -> Inverter: + """Return the inverter object.""" + return self._api + + async def _async_setup(self) -> None: + """Set up the coordinator.""" + async with timeout(TIMEOUT): + await self._api.login( + self.config_entry.data[CONF_USERNAME], + self.config_entry.data[CONF_PASSWORD], + ) + + await self._api.init() + + async def _async_update_data(self) -> dict[str, str | float | int]: + """Fetch and store newest data from API. + + This is the place to where entities can get their data. + It also includes the login process. + """ + + data: dict[str, str | float | int] = {} + + async with timeout(TIMEOUT): + await self._api.login( + self.config_entry.data[CONF_USERNAME], + self.config_entry.data[CONF_PASSWORD], + ) + + # Fetch data using distant API + try: + await self._api.update() + except (ValueError, ClientError) as e: + raise UpdateFailed(e) from e + + # Store data + for key, val in self._api.storage.items(): + if key == "timeline": + data[key] = val + else: + for sub_key, sub_val in val.items(): + data[f"{key}_{sub_key}"] = sub_val + + return data diff --git a/homeassistant/components/imeon_inverter/icons.json b/homeassistant/components/imeon_inverter/icons.json new file mode 100644 index 00000000000..1c74cf4c745 --- /dev/null +++ b/homeassistant/components/imeon_inverter/icons.json @@ -0,0 +1,159 @@ +{ + "entity": { + "sensor": { + "battery_autonomy": { + "default": "mdi:battery-clock" + }, + "battery_charge_time": { + "default": "mdi:battery-charging" + }, + "battery_power": { + "default": "mdi:battery" + }, + "battery_soc": { + "default": "mdi:battery-charging-100" + }, + "battery_stored": { + "default": "mdi:battery" + }, + "grid_current_l1": { + "default": "mdi:current-ac" + }, + "grid_current_l2": { + "default": "mdi:current-ac" + }, + "grid_current_l3": { + "default": "mdi:current-ac" + }, + "grid_frequency": { + "default": "mdi:sine-wave" + }, + "grid_voltage_l1": { + "default": "mdi:flash" + }, + "grid_voltage_l2": { + "default": "mdi:flash" + }, + "grid_voltage_l3": { + "default": "mdi:flash" + }, + "input_power_l1": { + "default": "mdi:power-socket" + }, + "input_power_l2": { + "default": "mdi:power-socket" + }, + "input_power_l3": { + "default": "mdi:power-socket" + }, + "input_power_total": { + "default": "mdi:power-plug" + }, + "inverter_charging_current_limit": { + "default": "mdi:current-dc" + }, + "inverter_injection_power_limit": { + "default": "mdi:power-socket" + }, + "meter_power": { + "default": "mdi:power-plug" + }, + "meter_power_protocol": { + "default": "mdi:protocol" + }, + "output_current_l1": { + "default": "mdi:current-ac" + }, + "output_current_l2": { + "default": "mdi:current-ac" + }, + "output_current_l3": { + "default": "mdi:current-ac" + }, + "output_frequency": { + "default": "mdi:sine-wave" + }, + "output_power_l1": { + "default": "mdi:power-socket" + }, + "output_power_l2": { + "default": "mdi:power-socket" + }, + "output_power_l3": { + "default": "mdi:power-socket" + }, + "output_power_total": { + "default": "mdi:power-plug" + }, + "output_voltage_l1": { + "default": "mdi:flash" + }, + "output_voltage_l2": { + "default": "mdi:flash" + }, + "output_voltage_l3": { + "default": "mdi:flash" + }, + "pv_consumed": { + "default": "mdi:solar-power" + }, + "pv_injected": { + "default": "mdi:solar-power" + }, + "pv_power_1": { + "default": "mdi:solar-power" + }, + "pv_power_2": { + "default": "mdi:solar-power" + }, + "pv_power_total": { + "default": "mdi:solar-power" + }, + "temp_air_temperature": { + "default": "mdi:thermometer" + }, + "temp_component_temperature": { + "default": "mdi:thermometer" + }, + "monitoring_building_consumption": { + "default": "mdi:home-lightning-bolt" + }, + "monitoring_economy_factor": { + "default": "mdi:chart-bar" + }, + "monitoring_grid_consumption": { + "default": "mdi:transmission-tower" + }, + "monitoring_grid_injection": { + "default": "mdi:transmission-tower-export" + }, + "monitoring_grid_power_flow": { + "default": "mdi:power-plug" + }, + "monitoring_self_consumption": { + "default": "mdi:percent" + }, + "monitoring_self_sufficiency": { + "default": "mdi:percent" + }, + "monitoring_solar_production": { + "default": "mdi:solar-power" + }, + "monitoring_minute_building_consumption": { + "default": "mdi:home-lightning-bolt" + }, + "monitoring_minute_grid_consumption": { + "default": "mdi:transmission-tower" + }, + "monitoring_minute_grid_injection": { + "default": "mdi:transmission-tower-export" + }, + "monitoring_minute_grid_power_flow": { + "default": "mdi:power-plug" + }, + "monitoring_minute_solar_production": { + "default": "mdi:solar-power" + } + } + } +} diff --git a/homeassistant/components/imeon_inverter/manifest.json b/homeassistant/components/imeon_inverter/manifest.json new file mode 100644 index 00000000000..1398521dc45 --- /dev/null +++ b/homeassistant/components/imeon_inverter/manifest.json @@ -0,0 +1,18 @@ +{ + "domain": "imeon_inverter", + "name": "Imeon Inverter", + "codeowners": ["@Imeon-Energy"], + "config_flow": true, + "documentation": "https://www.home-assistant.io/integrations/imeon_inverter", + "integration_type": "device", + "iot_class": "local_polling", + "quality_scale": "bronze", + "requirements": ["imeon_inverter_api==0.3.12"], + "ssdp": [ + { + "manufacturer": "IMEON", + "deviceType": "urn:schemas-upnp-org:device:Basic:1", + "st": "upnp:rootdevice" + } + ] +} diff --git a/homeassistant/components/imeon_inverter/quality_scale.yaml b/homeassistant/components/imeon_inverter/quality_scale.yaml new file mode 100644 index 00000000000..6e364977697 --- /dev/null +++ b/homeassistant/components/imeon_inverter/quality_scale.yaml @@ -0,0 +1,71 @@ +rules: + # Bronze + config-flow: done + test-before-configure: done + unique-config-entry: done + config-flow-test-coverage: done + runtime-data: done + test-before-setup: done + appropriate-polling: done + entity-unique-id: done + has-entity-name: done + entity-event-setup: + status: exempt + comment: This integration doesn't have sensors that subscribe to events. + dependency-transparency: done + action-setup: + status: exempt + comment: This integration does not have any service for now. + common-modules: done + docs-high-level-description: done + docs-installation-instructions: done + docs-removal-instructions: done + docs-actions: + status: exempt + comment: This integration does not have any service for now. + brands: done + # Silver + action-exceptions: + status: exempt + comment: This integration does not have any service for now. + config-entry-unloading: todo + docs-configuration-parameters: todo + docs-installation-parameters: todo + entity-unavailable: todo + integration-owner: done + log-when-unavailable: todo + parallel-updates: todo + reauthentication-flow: todo + test-coverage: done + # Gold + devices: done + diagnostics: todo + discovery-update-info: done + discovery: done + docs-data-update: todo + docs-examples: todo + docs-known-limitations: todo + docs-supported-devices: todo + docs-supported-functions: todo + docs-troubleshooting: todo + docs-use-cases: todo + dynamic-devices: + status: exempt + comment: Device type integration. + entity-category: done + entity-device-class: done + entity-disabled-by-default: todo + entity-translations: done + exception-translations: todo + icon-translations: done + reconfiguration-flow: todo + repair-issues: + status: exempt + comment: Currently no issues. + stale-devices: + status: exempt + comment: Device type integration. + # Platinum + async-dependency: todo + inject-websession: todo + strict-typing: todo diff --git a/homeassistant/components/imeon_inverter/sensor.py b/homeassistant/components/imeon_inverter/sensor.py new file mode 100644 index 00000000000..b7a01c3cf17 --- /dev/null +++ b/homeassistant/components/imeon_inverter/sensor.py @@ -0,0 +1,464 @@ +"""Imeon inverter sensor support.""" + +import logging + +from homeassistant.components.sensor import ( + SensorDeviceClass, + SensorEntity, + SensorEntityDescription, + SensorStateClass, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import ( + PERCENTAGE, + EntityCategory, + UnitOfElectricCurrent, + UnitOfElectricPotential, + UnitOfEnergy, + UnitOfFrequency, + UnitOfPower, + UnitOfTemperature, + UnitOfTime, +) +from homeassistant.core import HomeAssistant +from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback +from homeassistant.helpers.typing import StateType +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from .const import DOMAIN +from .coordinator import InverterCoordinator + +type InverterConfigEntry = ConfigEntry[InverterCoordinator] + +_LOGGER = logging.getLogger(__name__) + + +ENTITY_DESCRIPTIONS = ( + # Battery + SensorEntityDescription( + key="battery_autonomy", + translation_key="battery_autonomy", + native_unit_of_measurement=UnitOfTime.HOURS, + device_class=SensorDeviceClass.DURATION, + state_class=SensorStateClass.MEASUREMENT, + ), + SensorEntityDescription( + key="battery_charge_time", + translation_key="battery_charge_time", + native_unit_of_measurement=UnitOfTime.HOURS, + device_class=SensorDeviceClass.DURATION, + state_class=SensorStateClass.TOTAL, + ), + SensorEntityDescription( + key="battery_power", + translation_key="battery_power", + native_unit_of_measurement=UnitOfPower.WATT, + device_class=SensorDeviceClass.POWER, + state_class=SensorStateClass.MEASUREMENT, + ), + SensorEntityDescription( + key="battery_soc", + translation_key="battery_soc", + native_unit_of_measurement=PERCENTAGE, + device_class=SensorDeviceClass.BATTERY, + state_class=SensorStateClass.MEASUREMENT, + ), + SensorEntityDescription( + key="battery_stored", + translation_key="battery_stored", + native_unit_of_measurement=UnitOfEnergy.WATT_HOUR, + device_class=SensorDeviceClass.ENERGY_STORAGE, + state_class=SensorStateClass.TOTAL, + ), + # Grid + SensorEntityDescription( + key="grid_current_l1", + translation_key="grid_current_l1", + native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, + device_class=SensorDeviceClass.CURRENT, + state_class=SensorStateClass.MEASUREMENT, + ), + SensorEntityDescription( + key="grid_current_l2", + translation_key="grid_current_l2", + native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, + device_class=SensorDeviceClass.CURRENT, + state_class=SensorStateClass.MEASUREMENT, + ), + SensorEntityDescription( + key="grid_current_l3", + translation_key="grid_current_l3", + native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, + device_class=SensorDeviceClass.CURRENT, + state_class=SensorStateClass.MEASUREMENT, + ), + SensorEntityDescription( + key="grid_frequency", + translation_key="grid_frequency", + native_unit_of_measurement=UnitOfFrequency.HERTZ, + device_class=SensorDeviceClass.FREQUENCY, + state_class=SensorStateClass.MEASUREMENT, + ), + SensorEntityDescription( + key="grid_voltage_l1", + translation_key="grid_voltage_l1", + native_unit_of_measurement=UnitOfElectricPotential.VOLT, + device_class=SensorDeviceClass.VOLTAGE, + state_class=SensorStateClass.MEASUREMENT, + ), + SensorEntityDescription( + key="grid_voltage_l2", + translation_key="grid_voltage_l2", + native_unit_of_measurement=UnitOfElectricPotential.VOLT, + device_class=SensorDeviceClass.VOLTAGE, + state_class=SensorStateClass.MEASUREMENT, + ), + SensorEntityDescription( + key="grid_voltage_l3", + translation_key="grid_voltage_l3", + native_unit_of_measurement=UnitOfElectricPotential.VOLT, + device_class=SensorDeviceClass.VOLTAGE, + state_class=SensorStateClass.MEASUREMENT, + ), + # AC Input + SensorEntityDescription( + key="input_power_l1", + translation_key="input_power_l1", + native_unit_of_measurement=UnitOfPower.WATT, + device_class=SensorDeviceClass.POWER, + state_class=SensorStateClass.MEASUREMENT, + ), + SensorEntityDescription( + key="input_power_l2", + translation_key="input_power_l2", + native_unit_of_measurement=UnitOfPower.WATT, + device_class=SensorDeviceClass.POWER, + state_class=SensorStateClass.MEASUREMENT, + ), + SensorEntityDescription( + key="input_power_l3", + translation_key="input_power_l3", + native_unit_of_measurement=UnitOfPower.WATT, + device_class=SensorDeviceClass.POWER, + state_class=SensorStateClass.MEASUREMENT, + ), + SensorEntityDescription( + key="input_power_total", + translation_key="input_power_total", + native_unit_of_measurement=UnitOfPower.WATT, + device_class=SensorDeviceClass.POWER, + state_class=SensorStateClass.MEASUREMENT, + ), + # Inverter settings + SensorEntityDescription( + key="inverter_charging_current_limit", + translation_key="inverter_charging_current_limit", + native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, + device_class=SensorDeviceClass.CURRENT, + state_class=SensorStateClass.MEASUREMENT, + ), + SensorEntityDescription( + key="inverter_injection_power_limit", + translation_key="inverter_injection_power_limit", + native_unit_of_measurement=UnitOfPower.WATT, + device_class=SensorDeviceClass.POWER, + state_class=SensorStateClass.MEASUREMENT, + ), + # Meter + SensorEntityDescription( + key="meter_power", + translation_key="meter_power", + native_unit_of_measurement=UnitOfPower.WATT, + device_class=SensorDeviceClass.POWER, + state_class=SensorStateClass.MEASUREMENT, + ), + SensorEntityDescription( + key="meter_power_protocol", + translation_key="meter_power_protocol", + native_unit_of_measurement=UnitOfPower.WATT, + device_class=SensorDeviceClass.POWER, + state_class=SensorStateClass.MEASUREMENT, + ), + # AC Output + SensorEntityDescription( + key="output_current_l1", + translation_key="output_current_l1", + native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, + device_class=SensorDeviceClass.CURRENT, + state_class=SensorStateClass.MEASUREMENT, + ), + SensorEntityDescription( + key="output_current_l2", + translation_key="output_current_l2", + native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, + device_class=SensorDeviceClass.CURRENT, + state_class=SensorStateClass.MEASUREMENT, + ), + SensorEntityDescription( + key="output_current_l3", + translation_key="output_current_l3", + native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, + device_class=SensorDeviceClass.CURRENT, + state_class=SensorStateClass.MEASUREMENT, + ), + SensorEntityDescription( + key="output_frequency", + translation_key="output_frequency", + native_unit_of_measurement=UnitOfFrequency.HERTZ, + device_class=SensorDeviceClass.FREQUENCY, + state_class=SensorStateClass.MEASUREMENT, + ), + SensorEntityDescription( + key="output_power_l1", + translation_key="output_power_l1", + native_unit_of_measurement=UnitOfPower.WATT, + device_class=SensorDeviceClass.POWER, + state_class=SensorStateClass.MEASUREMENT, + ), + SensorEntityDescription( + key="output_power_l2", + translation_key="output_power_l2", + native_unit_of_measurement=UnitOfPower.WATT, + device_class=SensorDeviceClass.POWER, + state_class=SensorStateClass.MEASUREMENT, + ), + SensorEntityDescription( + key="output_power_l3", + translation_key="output_power_l3", + native_unit_of_measurement=UnitOfPower.WATT, + device_class=SensorDeviceClass.POWER, + state_class=SensorStateClass.MEASUREMENT, + ), + SensorEntityDescription( + key="output_power_total", + translation_key="output_power_total", + native_unit_of_measurement=UnitOfPower.WATT, + device_class=SensorDeviceClass.POWER, + state_class=SensorStateClass.MEASUREMENT, + ), + SensorEntityDescription( + key="output_voltage_l1", + translation_key="output_voltage_l1", + native_unit_of_measurement=UnitOfElectricPotential.VOLT, + device_class=SensorDeviceClass.VOLTAGE, + state_class=SensorStateClass.MEASUREMENT, + ), + SensorEntityDescription( + key="output_voltage_l2", + translation_key="output_voltage_l2", + native_unit_of_measurement=UnitOfElectricPotential.VOLT, + device_class=SensorDeviceClass.VOLTAGE, + state_class=SensorStateClass.MEASUREMENT, + ), + SensorEntityDescription( + key="output_voltage_l3", + translation_key="output_voltage_l3", + native_unit_of_measurement=UnitOfElectricPotential.VOLT, + device_class=SensorDeviceClass.VOLTAGE, + state_class=SensorStateClass.MEASUREMENT, + ), + # Solar Panel + SensorEntityDescription( + key="pv_consumed", + translation_key="pv_consumed", + native_unit_of_measurement=UnitOfEnergy.WATT_HOUR, + device_class=SensorDeviceClass.ENERGY, + state_class=SensorStateClass.TOTAL, + ), + SensorEntityDescription( + key="pv_injected", + translation_key="pv_injected", + native_unit_of_measurement=UnitOfEnergy.WATT_HOUR, + device_class=SensorDeviceClass.ENERGY, + state_class=SensorStateClass.TOTAL, + ), + SensorEntityDescription( + key="pv_power_1", + translation_key="pv_power_1", + native_unit_of_measurement=UnitOfPower.WATT, + device_class=SensorDeviceClass.POWER, + state_class=SensorStateClass.MEASUREMENT, + ), + SensorEntityDescription( + key="pv_power_2", + translation_key="pv_power_2", + native_unit_of_measurement=UnitOfPower.WATT, + device_class=SensorDeviceClass.POWER, + state_class=SensorStateClass.MEASUREMENT, + ), + SensorEntityDescription( + key="pv_power_total", + translation_key="pv_power_total", + native_unit_of_measurement=UnitOfPower.WATT, + device_class=SensorDeviceClass.POWER, + state_class=SensorStateClass.MEASUREMENT, + ), + # Temperature + SensorEntityDescription( + key="temp_air_temperature", + translation_key="temp_air_temperature", + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + device_class=SensorDeviceClass.TEMPERATURE, + state_class=SensorStateClass.MEASUREMENT, + ), + SensorEntityDescription( + key="temp_component_temperature", + translation_key="temp_component_temperature", + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + device_class=SensorDeviceClass.TEMPERATURE, + state_class=SensorStateClass.MEASUREMENT, + ), + # Monitoring (data over the last 24 hours) + SensorEntityDescription( + key="monitoring_building_consumption", + translation_key="monitoring_building_consumption", + native_unit_of_measurement=UnitOfEnergy.WATT_HOUR, + device_class=SensorDeviceClass.ENERGY, + state_class=SensorStateClass.TOTAL, + suggested_display_precision=2, + ), + SensorEntityDescription( + key="monitoring_economy_factor", + translation_key="monitoring_economy_factor", + native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, + state_class=SensorStateClass.TOTAL, + suggested_display_precision=2, + ), + SensorEntityDescription( + key="monitoring_grid_consumption", + translation_key="monitoring_grid_consumption", + native_unit_of_measurement=UnitOfEnergy.WATT_HOUR, + device_class=SensorDeviceClass.ENERGY, + state_class=SensorStateClass.TOTAL, + suggested_display_precision=2, + ), + SensorEntityDescription( + key="monitoring_grid_injection", + translation_key="monitoring_grid_injection", + native_unit_of_measurement=UnitOfEnergy.WATT_HOUR, + device_class=SensorDeviceClass.ENERGY, + state_class=SensorStateClass.TOTAL, + suggested_display_precision=2, + ), + SensorEntityDescription( + key="monitoring_grid_power_flow", + translation_key="monitoring_grid_power_flow", + native_unit_of_measurement=UnitOfEnergy.WATT_HOUR, + device_class=SensorDeviceClass.ENERGY, + state_class=SensorStateClass.TOTAL, + suggested_display_precision=2, + ), + SensorEntityDescription( + key="monitoring_self_consumption", + translation_key="monitoring_self_consumption", + native_unit_of_measurement=PERCENTAGE, + state_class=SensorStateClass.MEASUREMENT, + suggested_display_precision=2, + ), + SensorEntityDescription( + key="monitoring_self_sufficiency", + translation_key="monitoring_self_sufficiency", + native_unit_of_measurement=PERCENTAGE, + state_class=SensorStateClass.MEASUREMENT, + suggested_display_precision=2, + ), + SensorEntityDescription( + key="monitoring_solar_production", + translation_key="monitoring_solar_production", + native_unit_of_measurement=UnitOfEnergy.WATT_HOUR, + device_class=SensorDeviceClass.ENERGY, + state_class=SensorStateClass.TOTAL, + suggested_display_precision=2, + ), + # Monitoring (instant minute data) + SensorEntityDescription( + key="monitoring_minute_building_consumption", + translation_key="monitoring_minute_building_consumption", + native_unit_of_measurement=UnitOfPower.WATT, + device_class=SensorDeviceClass.POWER, + state_class=SensorStateClass.MEASUREMENT, + suggested_display_precision=2, + ), + SensorEntityDescription( + key="monitoring_minute_grid_consumption", + translation_key="monitoring_minute_grid_consumption", + native_unit_of_measurement=UnitOfPower.WATT, + device_class=SensorDeviceClass.POWER, + state_class=SensorStateClass.MEASUREMENT, + suggested_display_precision=2, + ), + SensorEntityDescription( + key="monitoring_minute_grid_injection", + translation_key="monitoring_minute_grid_injection", + native_unit_of_measurement=UnitOfPower.WATT, + device_class=SensorDeviceClass.POWER, + state_class=SensorStateClass.MEASUREMENT, + suggested_display_precision=2, + ), + SensorEntityDescription( + key="monitoring_minute_grid_power_flow", + translation_key="monitoring_minute_grid_power_flow", + native_unit_of_measurement=UnitOfPower.WATT, + device_class=SensorDeviceClass.POWER, + state_class=SensorStateClass.MEASUREMENT, + suggested_display_precision=2, + ), + SensorEntityDescription( + key="monitoring_minute_solar_production", + translation_key="monitoring_minute_solar_production", + native_unit_of_measurement=UnitOfPower.WATT, + device_class=SensorDeviceClass.POWER, + state_class=SensorStateClass.MEASUREMENT, + suggested_display_precision=2, + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, + entry: InverterConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Create each sensor for a given config entry.""" + + coordinator = entry.runtime_data + + # Init sensor entities + async_add_entities( + InverterSensor(coordinator, entry, description) + for description in ENTITY_DESCRIPTIONS + ) + + +class InverterSensor(CoordinatorEntity[InverterCoordinator], SensorEntity): + """A sensor that returns numerical values with units.""" + + _attr_has_entity_name = True + _attr_entity_category = EntityCategory.DIAGNOSTIC + + def __init__( + self, + coordinator: InverterCoordinator, + entry: InverterConfigEntry, + description: SensorEntityDescription, + ) -> None: + """Pass coordinator to CoordinatorEntity.""" + super().__init__(coordinator) + self.entity_description = description + self._inverter = coordinator.api.inverter + self.data_key = description.key + assert entry.unique_id + self._attr_unique_id = f"{entry.unique_id}_{self.data_key}" + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, entry.unique_id)}, + name="Imeon inverter", + manufacturer="Imeon Energy", + model=self._inverter.get("inverter"), + sw_version=self._inverter.get("software"), + ) + + @property + def native_value(self) -> StateType | None: + """Value of the sensor.""" + return self.coordinator.data.get(self.data_key) diff --git a/homeassistant/components/imeon_inverter/strings.json b/homeassistant/components/imeon_inverter/strings.json new file mode 100644 index 00000000000..48604e01273 --- /dev/null +++ b/homeassistant/components/imeon_inverter/strings.json @@ -0,0 +1,187 @@ +{ + "config": { + "flow_title": "Imeon {model} ({serial})", + "step": { + "user": { + "title": "Add Imeon inverter", + "data": { + "host": "[%key:common::config_flow::data::host%]", + "username": "[%key:common::config_flow::data::username%]", + "password": "[%key:common::config_flow::data::password%]" + }, + "data_description": { + "host": "The hostname or IP of your inverter", + "username": "The username of your OS One account", + "password": "The password of your OS One account" + } + } + }, + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_device%]" + }, + "error": { + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", + "invalid_host": "[%key:common::config_flow::error::invalid_host%]", + "invalid_route": "Unable to request the API, make sure 'API Module' is enabled on your device", + "unknown": "[%key:common::config_flow::error::unknown%]" + } + }, + "entity": { + "sensor": { + "battery_autonomy": { + "name": "Battery autonomy" + }, + "battery_charge_time": { + "name": "Battery charge time" + }, + "battery_power": { + "name": "Battery power" + }, + "battery_soc": { + "name": "Battery state of charge" + }, + "battery_stored": { + "name": "Battery stored" + }, + "grid_current_l1": { + "name": "Grid current L1" + }, + "grid_current_l2": { + "name": "Grid current L2" + }, + "grid_current_l3": { + "name": "Grid current L3" + }, + "grid_frequency": { + "name": "Grid frequency" + }, + "grid_voltage_l1": { + "name": "Grid voltage L1" + }, + "grid_voltage_l2": { + "name": "Grid voltage L2" + }, + "grid_voltage_l3": { + "name": "Grid voltage L3" + }, + "input_power_l1": { + "name": "Input power L1" + }, + "input_power_l2": { + "name": "Input power L2" + }, + "input_power_l3": { + "name": "Input power L3" + }, + "input_power_total": { + "name": "Input power total" + }, + "inverter_charging_current_limit": { + "name": "Charging current limit" + }, + "inverter_injection_power_limit": { + "name": "Injection power limit" + }, + "meter_power": { + "name": "Meter power" + }, + "meter_power_protocol": { + "name": "Meter power protocol" + }, + "output_current_l1": { + "name": "Output current L1" + }, + "output_current_l2": { + "name": "Output current L2" + }, + "output_current_l3": { + "name": "Output current L3" + }, + "output_frequency": { + "name": "Output frequency" + }, + "output_power_l1": { + "name": "Output power L1" + }, + "output_power_l2": { + "name": "Output power L2" + }, + "output_power_l3": { + "name": "Output power L3" + }, + "output_power_total": { + "name": "Output power total" + }, + "output_voltage_l1": { + "name": "Output voltage L1" + }, + "output_voltage_l2": { + "name": "Output voltage L2" + }, + "output_voltage_l3": { + "name": "Output voltage L3" + }, + "pv_consumed": { + "name": "PV consumed" + }, + "pv_injected": { + "name": "PV injected" + }, + "pv_power_1": { + "name": "PV power 1" + }, + "pv_power_2": { + "name": "PV power 2" + }, + "pv_power_total": { + "name": "PV power total" + }, + "temp_air_temperature": { + "name": "Air temperature" + }, + "temp_component_temperature": { + "name": "Component temperature" + }, + "monitoring_building_consumption": { + "name": "Monitoring building consumption" + }, + "monitoring_economy_factor": { + "name": "Monitoring economy factor" + }, + "monitoring_grid_consumption": { + "name": "Monitoring grid consumption" + }, + "monitoring_grid_injection": { + "name": "Monitoring grid injection" + }, + "monitoring_grid_power_flow": { + "name": "Monitoring grid power flow" + }, + "monitoring_self_consumption": { + "name": "Monitoring self consumption" + }, + "monitoring_self_sufficiency": { + "name": "Monitoring self sufficiency" + }, + "monitoring_solar_production": { + "name": "Monitoring solar production" + }, + "monitoring_minute_building_consumption": { + "name": "Monitoring building consumption (minute)" + }, + "monitoring_minute_grid_consumption": { + "name": "Monitoring grid consumption (minute)" + }, + "monitoring_minute_grid_injection": { + "name": "Monitoring grid injection (minute)" + }, + "monitoring_minute_grid_power_flow": { + "name": "Monitoring grid power flow (minute)" + }, + "monitoring_minute_solar_production": { + "name": "Monitoring solar production (minute)" + } + } + } +} diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index d192b8fcd13..268d8c35f40 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -285,6 +285,7 @@ FLOWS = { "ifttt", "igloohome", "imap", + "imeon_inverter", "imgw_pib", "improv_ble", "incomfort", diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index d0f0efe8ded..276102d2032 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -2935,6 +2935,12 @@ "config_flow": true, "iot_class": "cloud_push" }, + "imeon_inverter": { + "name": "Imeon Inverter", + "integration_type": "device", + "config_flow": true, + "iot_class": "local_polling" + }, "imgw_pib": { "name": "IMGW-PIB", "integration_type": "hub", diff --git a/homeassistant/generated/ssdp.py b/homeassistant/generated/ssdp.py index 5bbc178ba17..acbb74645a3 100644 --- a/homeassistant/generated/ssdp.py +++ b/homeassistant/generated/ssdp.py @@ -166,6 +166,13 @@ SSDP = { "st": "urn:hyperion-project.org:device:basic:1", }, ], + "imeon_inverter": [ + { + "deviceType": "urn:schemas-upnp-org:device:Basic:1", + "manufacturer": "IMEON", + "st": "upnp:rootdevice", + }, + ], "isy994": [ { "deviceType": "urn:udi-com:device:X_Insteon_Lighting_Device:1", diff --git a/requirements_all.txt b/requirements_all.txt index 8c5f876f1b4..9879eef9fe0 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1219,6 +1219,9 @@ igloohome-api==0.1.0 # homeassistant.components.ihc ihcsdk==2.8.5 +# homeassistant.components.imeon_inverter +imeon_inverter_api==0.3.12 + # homeassistant.components.imgw_pib imgw_pib==1.0.10 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 13b454e58df..992543f4447 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1034,6 +1034,9 @@ ifaddr==0.2.0 # homeassistant.components.igloohome igloohome-api==0.1.0 +# homeassistant.components.imeon_inverter +imeon_inverter_api==0.3.12 + # homeassistant.components.imgw_pib imgw_pib==1.0.10 diff --git a/tests/components/imeon_inverter/__init__.py b/tests/components/imeon_inverter/__init__.py new file mode 100644 index 00000000000..8305be2d901 --- /dev/null +++ b/tests/components/imeon_inverter/__init__.py @@ -0,0 +1,14 @@ +"""Tests for the Imeon Inverter integration.""" + +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry + + +async def setup_integration( + hass: HomeAssistant, mock_config_entry: MockConfigEntry +) -> None: + """Set up the Imeon Inverter integration for testing.""" + mock_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() diff --git a/tests/components/imeon_inverter/conftest.py b/tests/components/imeon_inverter/conftest.py new file mode 100644 index 00000000000..38fb0d90322 --- /dev/null +++ b/tests/components/imeon_inverter/conftest.py @@ -0,0 +1,85 @@ +"""Configuration for the Imeon Inverter integration tests.""" + +from collections.abc import Generator +from unittest.mock import AsyncMock, MagicMock + +import pytest + +from homeassistant.components.imeon_inverter.const import DOMAIN +from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME +from homeassistant.helpers.service_info.ssdp import ( + ATTR_UPNP_DEVICE_TYPE, + ATTR_UPNP_FRIENDLY_NAME, + ATTR_UPNP_MANUFACTURER, + ATTR_UPNP_MODEL_NAME, + ATTR_UPNP_SERIAL, + ATTR_UPNP_UDN, + SsdpServiceInfo, +) + +from tests.common import MockConfigEntry, load_json_object_fixture, patch + +# Sample test data +TEST_USER_INPUT = { + CONF_HOST: "192.168.200.1", + CONF_USERNAME: "user@local", + CONF_PASSWORD: "password", +} + +TEST_SERIAL = "111111111111111" + +TEST_DISCOVER = SsdpServiceInfo( + ssdp_usn="mock_usn", + ssdp_st="mock_st", + ssdp_location=f"http://{TEST_USER_INPUT[CONF_HOST]}:8088/imeon.xml", + upnp={ + ATTR_UPNP_MANUFACTURER: "IMEON", + ATTR_UPNP_MODEL_NAME: "IMEON", + ATTR_UPNP_FRIENDLY_NAME: f"IMEON-{TEST_SERIAL}", + ATTR_UPNP_SERIAL: TEST_SERIAL, + ATTR_UPNP_UDN: "uuid:01234567-89ab-cdef-0123-456789abcdef", + ATTR_UPNP_DEVICE_TYPE: "urn:schemas-upnp-org:device:Basic:1", + }, +) + + +@pytest.fixture(autouse=True) +def mock_imeon_inverter() -> Generator[MagicMock]: + """Mock data from the device.""" + with ( + patch( + "homeassistant.components.imeon_inverter.coordinator.Inverter", + autospec=True, + ) as inverter_mock, + patch( + "homeassistant.components.imeon_inverter.config_flow.Inverter", + new=inverter_mock, + ), + ): + inverter = inverter_mock.return_value + inverter.__aenter__.return_value = inverter + inverter.login.return_value = True + inverter.get_serial.return_value = TEST_SERIAL + inverter.storage = load_json_object_fixture("sensor_data.json", DOMAIN) + yield inverter + + +@pytest.fixture +def mock_async_setup_entry() -> Generator[AsyncMock]: + """Fixture for mocking async_setup_entry.""" + with patch( + "homeassistant.components.imeon_inverter.async_setup_entry", + return_value=True, + ) as mock: + yield mock + + +@pytest.fixture +def mock_config_entry() -> MockConfigEntry: + """Mock a config entry.""" + return MockConfigEntry( + title="Imeon inverter", + domain=DOMAIN, + data=TEST_USER_INPUT, + unique_id=TEST_SERIAL, + ) diff --git a/tests/components/imeon_inverter/fixtures/sensor_data.json b/tests/components/imeon_inverter/fixtures/sensor_data.json new file mode 100644 index 00000000000..566716fe3fa --- /dev/null +++ b/tests/components/imeon_inverter/fixtures/sensor_data.json @@ -0,0 +1,73 @@ +{ + "battery": { + "autonomy": 4.5, + "charge_time": 120, + "power": 2500.0, + "soc": 78.0, + "stored": 10.2 + }, + "grid": { + "current_l1": 12.5, + "current_l2": 10.8, + "current_l3": 11.2, + "frequency": 50.0, + "voltage_l1": 230.0, + "voltage_l2": 229.5, + "voltage_l3": 230.1 + }, + "input": { + "power_l1": 1000.0, + "power_l2": 950.0, + "power_l3": 980.0, + "power_total": 2930.0 + }, + "inverter": { + "charging_current_limit": 50, + "injection_power_limit": 5000.0 + }, + "meter": { + "power": 2000.0, + "power_protocol": 2018.0 + }, + "output": { + "current_l1": 15.0, + "current_l2": 14.5, + "current_l3": 15.2, + "frequency": 49.9, + "power_l1": 1100.0, + "power_l2": 1080.0, + "power_l3": 1120.0, + "power_total": 3300.0, + "voltage_l1": 231.0, + "voltage_l2": 229.8, + "voltage_l3": 230.2 + }, + "pv": { + "consumed": 1500.0, + "injected": 800.0, + "power_1": 1200.0, + "power_2": 1300.0, + "power_total": 2500.0 + }, + "temp": { + "air_temperature": 25.0, + "component_temperature": 45.5 + }, + "monitoring": { + "building_consumption": 3000.0, + "economy_factor": 0.8, + "grid_consumption": 500.0, + "grid_injection": 700.0, + "grid_power_flow": -200.0, + "self_consumption": 85.0, + "self_sufficiency": 90.0, + "solar_production": 2600.0 + }, + "monitoring_minute": { + "building_consumption": 50.0, + "grid_consumption": 8.3, + "grid_injection": 11.7, + "grid_power_flow": -3.4, + "solar_production": 43.3 + } +} diff --git a/tests/components/imeon_inverter/snapshots/test_sensor.ambr b/tests/components/imeon_inverter/snapshots/test_sensor.ambr new file mode 100644 index 00000000000..2d1fe14668f --- /dev/null +++ b/tests/components/imeon_inverter/snapshots/test_sensor.ambr @@ -0,0 +1,2689 @@ +# serializer version: 1 +# name: test_sensors[sensor.imeon_inverter_air_temperature-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.imeon_inverter_air_temperature', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Air temperature', + 'platform': 'imeon_inverter', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'temp_air_temperature', + 'unique_id': '111111111111111_temp_air_temperature', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.imeon_inverter_air_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'Imeon inverter Air temperature', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.imeon_inverter_air_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '25.0', + }) +# --- +# name: test_sensors[sensor.imeon_inverter_battery_autonomy-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.imeon_inverter_battery_autonomy', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Battery autonomy', + 'platform': 'imeon_inverter', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'battery_autonomy', + 'unique_id': '111111111111111_battery_autonomy', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.imeon_inverter_battery_autonomy-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'duration', + 'friendly_name': 'Imeon inverter Battery autonomy', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.imeon_inverter_battery_autonomy', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '4.5', + }) +# --- +# name: test_sensors[sensor.imeon_inverter_battery_charge_time-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.imeon_inverter_battery_charge_time', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Battery charge time', + 'platform': 'imeon_inverter', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'battery_charge_time', + 'unique_id': '111111111111111_battery_charge_time', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.imeon_inverter_battery_charge_time-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'duration', + 'friendly_name': 'Imeon inverter Battery charge time', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.imeon_inverter_battery_charge_time', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '120', + }) +# --- +# name: test_sensors[sensor.imeon_inverter_battery_power-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.imeon_inverter_battery_power', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Battery power', + 'platform': 'imeon_inverter', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'battery_power', + 'unique_id': '111111111111111_battery_power', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.imeon_inverter_battery_power-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'Imeon inverter Battery power', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.imeon_inverter_battery_power', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '2500.0', + }) +# --- +# name: test_sensors[sensor.imeon_inverter_battery_state_of_charge-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.imeon_inverter_battery_state_of_charge', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Battery state of charge', + 'platform': 'imeon_inverter', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'battery_soc', + 'unique_id': '111111111111111_battery_soc', + 'unit_of_measurement': '%', + }) +# --- +# name: test_sensors[sensor.imeon_inverter_battery_state_of_charge-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'battery', + 'friendly_name': 'Imeon inverter Battery state of charge', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.imeon_inverter_battery_state_of_charge', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '78.0', + }) +# --- +# name: test_sensors[sensor.imeon_inverter_battery_stored-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.imeon_inverter_battery_stored', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Battery stored', + 'platform': 'imeon_inverter', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'battery_stored', + 'unique_id': '111111111111111_battery_stored', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.imeon_inverter_battery_stored-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy_storage', + 'friendly_name': 'Imeon inverter Battery stored', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.imeon_inverter_battery_stored', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '10.2', + }) +# --- +# name: test_sensors[sensor.imeon_inverter_charging_current_limit-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.imeon_inverter_charging_current_limit', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Charging current limit', + 'platform': 'imeon_inverter', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'inverter_charging_current_limit', + 'unique_id': '111111111111111_inverter_charging_current_limit', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.imeon_inverter_charging_current_limit-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'current', + 'friendly_name': 'Imeon inverter Charging current limit', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.imeon_inverter_charging_current_limit', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '50', + }) +# --- +# name: test_sensors[sensor.imeon_inverter_component_temperature-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.imeon_inverter_component_temperature', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Component temperature', + 'platform': 'imeon_inverter', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'temp_component_temperature', + 'unique_id': '111111111111111_temp_component_temperature', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.imeon_inverter_component_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'Imeon inverter Component temperature', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.imeon_inverter_component_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '45.5', + }) +# --- +# name: test_sensors[sensor.imeon_inverter_grid_current_l1-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.imeon_inverter_grid_current_l1', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Grid current L1', + 'platform': 'imeon_inverter', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'grid_current_l1', + 'unique_id': '111111111111111_grid_current_l1', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.imeon_inverter_grid_current_l1-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'current', + 'friendly_name': 'Imeon inverter Grid current L1', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.imeon_inverter_grid_current_l1', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '12.5', + }) +# --- +# name: test_sensors[sensor.imeon_inverter_grid_current_l2-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.imeon_inverter_grid_current_l2', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Grid current L2', + 'platform': 'imeon_inverter', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'grid_current_l2', + 'unique_id': '111111111111111_grid_current_l2', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.imeon_inverter_grid_current_l2-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'current', + 'friendly_name': 'Imeon inverter Grid current L2', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.imeon_inverter_grid_current_l2', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '10.8', + }) +# --- +# name: test_sensors[sensor.imeon_inverter_grid_current_l3-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.imeon_inverter_grid_current_l3', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Grid current L3', + 'platform': 'imeon_inverter', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'grid_current_l3', + 'unique_id': '111111111111111_grid_current_l3', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.imeon_inverter_grid_current_l3-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'current', + 'friendly_name': 'Imeon inverter Grid current L3', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.imeon_inverter_grid_current_l3', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '11.2', + }) +# --- +# name: test_sensors[sensor.imeon_inverter_grid_frequency-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.imeon_inverter_grid_frequency', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Grid frequency', + 'platform': 'imeon_inverter', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'grid_frequency', + 'unique_id': '111111111111111_grid_frequency', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.imeon_inverter_grid_frequency-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'frequency', + 'friendly_name': 'Imeon inverter Grid frequency', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.imeon_inverter_grid_frequency', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '50.0', + }) +# --- +# name: test_sensors[sensor.imeon_inverter_grid_voltage_l1-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.imeon_inverter_grid_voltage_l1', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Grid voltage L1', + 'platform': 'imeon_inverter', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'grid_voltage_l1', + 'unique_id': '111111111111111_grid_voltage_l1', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.imeon_inverter_grid_voltage_l1-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'voltage', + 'friendly_name': 'Imeon inverter Grid voltage L1', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.imeon_inverter_grid_voltage_l1', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '230.0', + }) +# --- +# name: test_sensors[sensor.imeon_inverter_grid_voltage_l2-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.imeon_inverter_grid_voltage_l2', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Grid voltage L2', + 'platform': 'imeon_inverter', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'grid_voltage_l2', + 'unique_id': '111111111111111_grid_voltage_l2', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.imeon_inverter_grid_voltage_l2-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'voltage', + 'friendly_name': 'Imeon inverter Grid voltage L2', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.imeon_inverter_grid_voltage_l2', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '229.5', + }) +# --- +# name: test_sensors[sensor.imeon_inverter_grid_voltage_l3-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.imeon_inverter_grid_voltage_l3', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Grid voltage L3', + 'platform': 'imeon_inverter', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'grid_voltage_l3', + 'unique_id': '111111111111111_grid_voltage_l3', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.imeon_inverter_grid_voltage_l3-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'voltage', + 'friendly_name': 'Imeon inverter Grid voltage L3', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.imeon_inverter_grid_voltage_l3', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '230.1', + }) +# --- +# name: test_sensors[sensor.imeon_inverter_injection_power_limit-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.imeon_inverter_injection_power_limit', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Injection power limit', + 'platform': 'imeon_inverter', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'inverter_injection_power_limit', + 'unique_id': '111111111111111_inverter_injection_power_limit', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.imeon_inverter_injection_power_limit-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'Imeon inverter Injection power limit', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.imeon_inverter_injection_power_limit', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '5000.0', + }) +# --- +# name: test_sensors[sensor.imeon_inverter_input_power_l1-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.imeon_inverter_input_power_l1', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Input power L1', + 'platform': 'imeon_inverter', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'input_power_l1', + 'unique_id': '111111111111111_input_power_l1', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.imeon_inverter_input_power_l1-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'Imeon inverter Input power L1', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.imeon_inverter_input_power_l1', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '1000.0', + }) +# --- +# name: test_sensors[sensor.imeon_inverter_input_power_l2-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.imeon_inverter_input_power_l2', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Input power L2', + 'platform': 'imeon_inverter', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'input_power_l2', + 'unique_id': '111111111111111_input_power_l2', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.imeon_inverter_input_power_l2-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'Imeon inverter Input power L2', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.imeon_inverter_input_power_l2', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '950.0', + }) +# --- +# name: test_sensors[sensor.imeon_inverter_input_power_l3-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.imeon_inverter_input_power_l3', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Input power L3', + 'platform': 'imeon_inverter', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'input_power_l3', + 'unique_id': '111111111111111_input_power_l3', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.imeon_inverter_input_power_l3-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'Imeon inverter Input power L3', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.imeon_inverter_input_power_l3', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '980.0', + }) +# --- +# name: test_sensors[sensor.imeon_inverter_input_power_total-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.imeon_inverter_input_power_total', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Input power total', + 'platform': 'imeon_inverter', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'input_power_total', + 'unique_id': '111111111111111_input_power_total', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.imeon_inverter_input_power_total-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'Imeon inverter Input power total', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.imeon_inverter_input_power_total', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '2930.0', + }) +# --- +# name: test_sensors[sensor.imeon_inverter_meter_power-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.imeon_inverter_meter_power', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Meter power', + 'platform': 'imeon_inverter', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'meter_power', + 'unique_id': '111111111111111_meter_power', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.imeon_inverter_meter_power-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'Imeon inverter Meter power', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.imeon_inverter_meter_power', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '2000.0', + }) +# --- +# name: test_sensors[sensor.imeon_inverter_meter_power_protocol-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.imeon_inverter_meter_power_protocol', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Meter power protocol', + 'platform': 'imeon_inverter', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'meter_power_protocol', + 'unique_id': '111111111111111_meter_power_protocol', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.imeon_inverter_meter_power_protocol-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'Imeon inverter Meter power protocol', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.imeon_inverter_meter_power_protocol', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '2018.0', + }) +# --- +# name: test_sensors[sensor.imeon_inverter_monitoring_building_consumption-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.imeon_inverter_monitoring_building_consumption', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Monitoring building consumption', + 'platform': 'imeon_inverter', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'monitoring_building_consumption', + 'unique_id': '111111111111111_monitoring_building_consumption', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.imeon_inverter_monitoring_building_consumption-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Imeon inverter Monitoring building consumption', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.imeon_inverter_monitoring_building_consumption', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '3000.0', + }) +# --- +# name: test_sensors[sensor.imeon_inverter_monitoring_building_consumption_minute-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.imeon_inverter_monitoring_building_consumption_minute', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Monitoring building consumption (minute)', + 'platform': 'imeon_inverter', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'monitoring_minute_building_consumption', + 'unique_id': '111111111111111_monitoring_minute_building_consumption', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.imeon_inverter_monitoring_building_consumption_minute-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'Imeon inverter Monitoring building consumption (minute)', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.imeon_inverter_monitoring_building_consumption_minute', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '50.0', + }) +# --- +# name: test_sensors[sensor.imeon_inverter_monitoring_economy_factor-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.imeon_inverter_monitoring_economy_factor', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Monitoring economy factor', + 'platform': 'imeon_inverter', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'monitoring_economy_factor', + 'unique_id': '111111111111111_monitoring_economy_factor', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.imeon_inverter_monitoring_economy_factor-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Imeon inverter Monitoring economy factor', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.imeon_inverter_monitoring_economy_factor', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.8', + }) +# --- +# name: test_sensors[sensor.imeon_inverter_monitoring_grid_consumption-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.imeon_inverter_monitoring_grid_consumption', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Monitoring grid consumption', + 'platform': 'imeon_inverter', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'monitoring_grid_consumption', + 'unique_id': '111111111111111_monitoring_grid_consumption', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.imeon_inverter_monitoring_grid_consumption-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Imeon inverter Monitoring grid consumption', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.imeon_inverter_monitoring_grid_consumption', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '500.0', + }) +# --- +# name: test_sensors[sensor.imeon_inverter_monitoring_grid_consumption_minute-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.imeon_inverter_monitoring_grid_consumption_minute', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Monitoring grid consumption (minute)', + 'platform': 'imeon_inverter', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'monitoring_minute_grid_consumption', + 'unique_id': '111111111111111_monitoring_minute_grid_consumption', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.imeon_inverter_monitoring_grid_consumption_minute-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'Imeon inverter Monitoring grid consumption (minute)', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.imeon_inverter_monitoring_grid_consumption_minute', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '8.3', + }) +# --- +# name: test_sensors[sensor.imeon_inverter_monitoring_grid_injection-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.imeon_inverter_monitoring_grid_injection', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Monitoring grid injection', + 'platform': 'imeon_inverter', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'monitoring_grid_injection', + 'unique_id': '111111111111111_monitoring_grid_injection', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.imeon_inverter_monitoring_grid_injection-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Imeon inverter Monitoring grid injection', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.imeon_inverter_monitoring_grid_injection', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '700.0', + }) +# --- +# name: test_sensors[sensor.imeon_inverter_monitoring_grid_injection_minute-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.imeon_inverter_monitoring_grid_injection_minute', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Monitoring grid injection (minute)', + 'platform': 'imeon_inverter', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'monitoring_minute_grid_injection', + 'unique_id': '111111111111111_monitoring_minute_grid_injection', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.imeon_inverter_monitoring_grid_injection_minute-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'Imeon inverter Monitoring grid injection (minute)', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.imeon_inverter_monitoring_grid_injection_minute', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '11.7', + }) +# --- +# name: test_sensors[sensor.imeon_inverter_monitoring_grid_power_flow-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.imeon_inverter_monitoring_grid_power_flow', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Monitoring grid power flow', + 'platform': 'imeon_inverter', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'monitoring_grid_power_flow', + 'unique_id': '111111111111111_monitoring_grid_power_flow', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.imeon_inverter_monitoring_grid_power_flow-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Imeon inverter Monitoring grid power flow', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.imeon_inverter_monitoring_grid_power_flow', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '-200.0', + }) +# --- +# name: test_sensors[sensor.imeon_inverter_monitoring_grid_power_flow_minute-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.imeon_inverter_monitoring_grid_power_flow_minute', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Monitoring grid power flow (minute)', + 'platform': 'imeon_inverter', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'monitoring_minute_grid_power_flow', + 'unique_id': '111111111111111_monitoring_minute_grid_power_flow', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.imeon_inverter_monitoring_grid_power_flow_minute-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'Imeon inverter Monitoring grid power flow (minute)', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.imeon_inverter_monitoring_grid_power_flow_minute', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '-3.4', + }) +# --- +# name: test_sensors[sensor.imeon_inverter_monitoring_self_consumption-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.imeon_inverter_monitoring_self_consumption', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Monitoring self consumption', + 'platform': 'imeon_inverter', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'monitoring_self_consumption', + 'unique_id': '111111111111111_monitoring_self_consumption', + 'unit_of_measurement': '%', + }) +# --- +# name: test_sensors[sensor.imeon_inverter_monitoring_self_consumption-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Imeon inverter Monitoring self consumption', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.imeon_inverter_monitoring_self_consumption', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '85.0', + }) +# --- +# name: test_sensors[sensor.imeon_inverter_monitoring_self_sufficiency-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.imeon_inverter_monitoring_self_sufficiency', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Monitoring self sufficiency', + 'platform': 'imeon_inverter', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'monitoring_self_sufficiency', + 'unique_id': '111111111111111_monitoring_self_sufficiency', + 'unit_of_measurement': '%', + }) +# --- +# name: test_sensors[sensor.imeon_inverter_monitoring_self_sufficiency-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Imeon inverter Monitoring self sufficiency', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.imeon_inverter_monitoring_self_sufficiency', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '90.0', + }) +# --- +# name: test_sensors[sensor.imeon_inverter_monitoring_solar_production-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.imeon_inverter_monitoring_solar_production', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Monitoring solar production', + 'platform': 'imeon_inverter', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'monitoring_solar_production', + 'unique_id': '111111111111111_monitoring_solar_production', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.imeon_inverter_monitoring_solar_production-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Imeon inverter Monitoring solar production', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.imeon_inverter_monitoring_solar_production', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '2600.0', + }) +# --- +# name: test_sensors[sensor.imeon_inverter_monitoring_solar_production_minute-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.imeon_inverter_monitoring_solar_production_minute', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Monitoring solar production (minute)', + 'platform': 'imeon_inverter', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'monitoring_minute_solar_production', + 'unique_id': '111111111111111_monitoring_minute_solar_production', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.imeon_inverter_monitoring_solar_production_minute-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'Imeon inverter Monitoring solar production (minute)', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.imeon_inverter_monitoring_solar_production_minute', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '43.3', + }) +# --- +# name: test_sensors[sensor.imeon_inverter_output_current_l1-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.imeon_inverter_output_current_l1', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Output current L1', + 'platform': 'imeon_inverter', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'output_current_l1', + 'unique_id': '111111111111111_output_current_l1', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.imeon_inverter_output_current_l1-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'current', + 'friendly_name': 'Imeon inverter Output current L1', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.imeon_inverter_output_current_l1', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '15.0', + }) +# --- +# name: test_sensors[sensor.imeon_inverter_output_current_l2-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.imeon_inverter_output_current_l2', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Output current L2', + 'platform': 'imeon_inverter', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'output_current_l2', + 'unique_id': '111111111111111_output_current_l2', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.imeon_inverter_output_current_l2-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'current', + 'friendly_name': 'Imeon inverter Output current L2', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.imeon_inverter_output_current_l2', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '14.5', + }) +# --- +# name: test_sensors[sensor.imeon_inverter_output_current_l3-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.imeon_inverter_output_current_l3', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Output current L3', + 'platform': 'imeon_inverter', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'output_current_l3', + 'unique_id': '111111111111111_output_current_l3', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.imeon_inverter_output_current_l3-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'current', + 'friendly_name': 'Imeon inverter Output current L3', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.imeon_inverter_output_current_l3', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '15.2', + }) +# --- +# name: test_sensors[sensor.imeon_inverter_output_frequency-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.imeon_inverter_output_frequency', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Output frequency', + 'platform': 'imeon_inverter', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'output_frequency', + 'unique_id': '111111111111111_output_frequency', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.imeon_inverter_output_frequency-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'frequency', + 'friendly_name': 'Imeon inverter Output frequency', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.imeon_inverter_output_frequency', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '49.9', + }) +# --- +# name: test_sensors[sensor.imeon_inverter_output_power_l1-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.imeon_inverter_output_power_l1', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Output power L1', + 'platform': 'imeon_inverter', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'output_power_l1', + 'unique_id': '111111111111111_output_power_l1', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.imeon_inverter_output_power_l1-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'Imeon inverter Output power L1', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.imeon_inverter_output_power_l1', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '1100.0', + }) +# --- +# name: test_sensors[sensor.imeon_inverter_output_power_l2-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.imeon_inverter_output_power_l2', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Output power L2', + 'platform': 'imeon_inverter', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'output_power_l2', + 'unique_id': '111111111111111_output_power_l2', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.imeon_inverter_output_power_l2-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'Imeon inverter Output power L2', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.imeon_inverter_output_power_l2', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '1080.0', + }) +# --- +# name: test_sensors[sensor.imeon_inverter_output_power_l3-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.imeon_inverter_output_power_l3', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Output power L3', + 'platform': 'imeon_inverter', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'output_power_l3', + 'unique_id': '111111111111111_output_power_l3', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.imeon_inverter_output_power_l3-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'Imeon inverter Output power L3', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.imeon_inverter_output_power_l3', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '1120.0', + }) +# --- +# name: test_sensors[sensor.imeon_inverter_output_power_total-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.imeon_inverter_output_power_total', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Output power total', + 'platform': 'imeon_inverter', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'output_power_total', + 'unique_id': '111111111111111_output_power_total', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.imeon_inverter_output_power_total-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'Imeon inverter Output power total', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.imeon_inverter_output_power_total', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '3300.0', + }) +# --- +# name: test_sensors[sensor.imeon_inverter_output_voltage_l1-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.imeon_inverter_output_voltage_l1', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Output voltage L1', + 'platform': 'imeon_inverter', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'output_voltage_l1', + 'unique_id': '111111111111111_output_voltage_l1', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.imeon_inverter_output_voltage_l1-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'voltage', + 'friendly_name': 'Imeon inverter Output voltage L1', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.imeon_inverter_output_voltage_l1', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '231.0', + }) +# --- +# name: test_sensors[sensor.imeon_inverter_output_voltage_l2-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.imeon_inverter_output_voltage_l2', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Output voltage L2', + 'platform': 'imeon_inverter', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'output_voltage_l2', + 'unique_id': '111111111111111_output_voltage_l2', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.imeon_inverter_output_voltage_l2-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'voltage', + 'friendly_name': 'Imeon inverter Output voltage L2', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.imeon_inverter_output_voltage_l2', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '229.8', + }) +# --- +# name: test_sensors[sensor.imeon_inverter_output_voltage_l3-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.imeon_inverter_output_voltage_l3', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Output voltage L3', + 'platform': 'imeon_inverter', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'output_voltage_l3', + 'unique_id': '111111111111111_output_voltage_l3', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.imeon_inverter_output_voltage_l3-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'voltage', + 'friendly_name': 'Imeon inverter Output voltage L3', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.imeon_inverter_output_voltage_l3', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '230.2', + }) +# --- +# name: test_sensors[sensor.imeon_inverter_pv_consumed-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.imeon_inverter_pv_consumed', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'PV consumed', + 'platform': 'imeon_inverter', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'pv_consumed', + 'unique_id': '111111111111111_pv_consumed', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.imeon_inverter_pv_consumed-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Imeon inverter PV consumed', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.imeon_inverter_pv_consumed', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '1500.0', + }) +# --- +# name: test_sensors[sensor.imeon_inverter_pv_injected-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.imeon_inverter_pv_injected', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'PV injected', + 'platform': 'imeon_inverter', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'pv_injected', + 'unique_id': '111111111111111_pv_injected', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.imeon_inverter_pv_injected-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Imeon inverter PV injected', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.imeon_inverter_pv_injected', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '800.0', + }) +# --- +# name: test_sensors[sensor.imeon_inverter_pv_power_1-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.imeon_inverter_pv_power_1', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'PV power 1', + 'platform': 'imeon_inverter', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'pv_power_1', + 'unique_id': '111111111111111_pv_power_1', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.imeon_inverter_pv_power_1-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'Imeon inverter PV power 1', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.imeon_inverter_pv_power_1', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '1200.0', + }) +# --- +# name: test_sensors[sensor.imeon_inverter_pv_power_2-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.imeon_inverter_pv_power_2', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'PV power 2', + 'platform': 'imeon_inverter', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'pv_power_2', + 'unique_id': '111111111111111_pv_power_2', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.imeon_inverter_pv_power_2-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'Imeon inverter PV power 2', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.imeon_inverter_pv_power_2', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '1300.0', + }) +# --- +# name: test_sensors[sensor.imeon_inverter_pv_power_total-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.imeon_inverter_pv_power_total', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'PV power total', + 'platform': 'imeon_inverter', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'pv_power_total', + 'unique_id': '111111111111111_pv_power_total', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.imeon_inverter_pv_power_total-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'Imeon inverter PV power total', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.imeon_inverter_pv_power_total', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '2500.0', + }) +# --- diff --git a/tests/components/imeon_inverter/test_config_flow.py b/tests/components/imeon_inverter/test_config_flow.py new file mode 100644 index 00000000000..9ebcf3ec80f --- /dev/null +++ b/tests/components/imeon_inverter/test_config_flow.py @@ -0,0 +1,205 @@ +"""Test the Imeon Inverter config flow.""" + +from copy import deepcopy +from unittest.mock import AsyncMock, MagicMock + +import pytest + +from homeassistant.components.imeon_inverter.const import DOMAIN +from homeassistant.config_entries import SOURCE_SSDP, SOURCE_USER +from homeassistant.const import CONF_HOST, CONF_SOURCE +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType +from homeassistant.helpers.service_info.ssdp import ATTR_UPNP_SERIAL + +from .conftest import TEST_DISCOVER, TEST_SERIAL, TEST_USER_INPUT + +from tests.common import MockConfigEntry + +pytestmark = pytest.mark.usefixtures("mock_async_setup_entry") + + +async def test_form_valid( + hass: HomeAssistant, + mock_async_setup_entry: AsyncMock, +) -> None: + """Test we get the form and the config is created with the good entries.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={CONF_SOURCE: SOURCE_USER} + ) + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {} + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], TEST_USER_INPUT + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == f"Imeon {TEST_SERIAL}" + assert result["data"] == TEST_USER_INPUT + assert result["result"].unique_id == TEST_SERIAL + assert mock_async_setup_entry.call_count == 1 + + +async def test_form_invalid_auth( + hass: HomeAssistant, mock_imeon_inverter: MagicMock +) -> None: + """Test we handle invalid auth.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={CONF_SOURCE: SOURCE_USER} + ) + + mock_imeon_inverter.login.return_value = False + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], TEST_USER_INPUT + ) + + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {"base": "invalid_auth"} + + mock_imeon_inverter.login.return_value = True + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], TEST_USER_INPUT + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + + +@pytest.mark.parametrize( + ("error", "expected"), + [ + (TimeoutError, "cannot_connect"), + (ValueError("Host invalid"), "invalid_host"), + (ValueError("Route invalid"), "invalid_route"), + (ValueError, "unknown"), + ], +) +async def test_form_exception( + hass: HomeAssistant, + mock_imeon_inverter: MagicMock, + error: Exception, + expected: str, +) -> None: + """Test we handle cannot connect error.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={CONF_SOURCE: SOURCE_USER} + ) + + mock_imeon_inverter.login.side_effect = error + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], TEST_USER_INPUT + ) + + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {"base": expected} + + mock_imeon_inverter.login.side_effect = None + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], TEST_USER_INPUT + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + + +async def test_manual_setup_already_exists( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, +) -> None: + """Test that a flow with an existing id aborts.""" + mock_config_entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={CONF_SOURCE: SOURCE_USER} + ) + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], TEST_USER_INPUT + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" + + +async def test_get_serial_timeout( + hass: HomeAssistant, mock_imeon_inverter: MagicMock +) -> None: + """Test the timeout error handling of getting the serial number.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={CONF_SOURCE: SOURCE_USER} + ) + + mock_imeon_inverter.get_serial.side_effect = TimeoutError + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], TEST_USER_INPUT + ) + + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {"base": "cannot_connect"} + + mock_imeon_inverter.get_serial.side_effect = None + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], TEST_USER_INPUT + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + + +async def test_ssdp(hass: HomeAssistant) -> None: + """Test a ssdp discovery.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={CONF_SOURCE: SOURCE_SSDP}, + data=TEST_DISCOVER, + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + + user_input = TEST_USER_INPUT.copy() + user_input.pop(CONF_HOST) + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == f"Imeon {TEST_SERIAL}" + assert result["data"] == TEST_USER_INPUT + + +async def test_ssdp_already_exist( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, +) -> None: + """Test that a ssdp discovery flow with an existing id aborts.""" + mock_config_entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={CONF_SOURCE: SOURCE_SSDP}, + data=TEST_DISCOVER, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" + + +async def test_ssdp_abort(hass: HomeAssistant) -> None: + """Test that a ssdp discovery aborts if serial is unknown.""" + data = deepcopy(TEST_DISCOVER) + data.upnp.pop(ATTR_UPNP_SERIAL, None) + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={CONF_SOURCE: SOURCE_SSDP}, + data=data, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "cannot_connect" diff --git a/tests/components/imeon_inverter/test_sensor.py b/tests/components/imeon_inverter/test_sensor.py new file mode 100644 index 00000000000..19e912c1c5c --- /dev/null +++ b/tests/components/imeon_inverter/test_sensor.py @@ -0,0 +1,29 @@ +"""Test the Imeon Inverter sensors.""" + +from unittest.mock import MagicMock, patch + +from syrupy.assertion import SnapshotAssertion + +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from . import setup_integration + +from tests.common import MockConfigEntry, snapshot_platform + + +async def test_sensors( + hass: HomeAssistant, + mock_imeon_inverter: MagicMock, + entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, + mock_config_entry: MockConfigEntry, +) -> None: + """Test the Imeon Inverter sensors.""" + with patch( + "homeassistant.components.imeon_inverter.const.PLATFORMS", [Platform.SENSOR] + ): + await setup_integration(hass, mock_config_entry) + + await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) From 60268e97d4a109a039706fca02e5397ab3c5f58e Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Thu, 10 Apr 2025 09:34:21 +0200 Subject: [PATCH 416/505] Fix sentence-casing and spelling in `touchline_sl` (#142644) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - use sentence-casing for "setup flow" - replace "Login to … " with the verb "Log in to …" --- homeassistant/components/touchline_sl/strings.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/touchline_sl/strings.json b/homeassistant/components/touchline_sl/strings.json index e3a0ef5a741..469fb8a50a6 100644 --- a/homeassistant/components/touchline_sl/strings.json +++ b/homeassistant/components/touchline_sl/strings.json @@ -1,6 +1,6 @@ { "config": { - "flow_title": "Touchline SL Setup Flow", + "flow_title": "Touchline SL setup flow", "error": { "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", @@ -8,7 +8,7 @@ }, "step": { "user": { - "title": "Login to Touchline SL", + "title": "Log in to Touchline SL", "description": "Your credentials for the Roth Touchline SL mobile app/web service", "data": { "username": "[%key:common::config_flow::data::username%]", From 96d1c9ab9105151e74e53b2e8828f4b8c74bee2e Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Thu, 10 Apr 2025 10:02:10 +0200 Subject: [PATCH 417/505] Use common state for "Normal" in `yeelight` (#142641) * Use common state for "Normal" in `yeelight` Also remove one excessive hyphen in "RGB format". * Sentence-case "Color flow" --- homeassistant/components/yeelight/strings.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/yeelight/strings.json b/homeassistant/components/yeelight/strings.json index d53c28cb64a..e01a853a360 100644 --- a/homeassistant/components/yeelight/strings.json +++ b/homeassistant/components/yeelight/strings.json @@ -73,7 +73,7 @@ "fields": { "rgb_color": { "name": "RGB color", - "description": "Color for the light in RGB-format." + "description": "Color for the light in RGB format." }, "brightness": { "name": "Brightness", @@ -173,11 +173,11 @@ "selector": { "mode": { "options": { - "color_flow": "Color Flow", + "normal": "[%key:common::state::normal%]", + "color_flow": "Color flow", "hsv": "HSV", "last": "Last", "moonlight": "Moonlight", - "normal": "Normal", "rgb": "RGB" } }, From 5ff260879453bc43e19c9799cf143ea0148049ed Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Thu, 10 Apr 2025 10:02:30 +0200 Subject: [PATCH 418/505] Use common state for "Normal" in `ecovacs` (#142642) --- homeassistant/components/ecovacs/strings.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/ecovacs/strings.json b/homeassistant/components/ecovacs/strings.json index f74c8b90f00..1be81ab1292 100644 --- a/homeassistant/components/ecovacs/strings.json +++ b/homeassistant/components/ecovacs/strings.json @@ -229,9 +229,9 @@ "state_attributes": { "fan_speed": { "state": { + "normal": "[%key:common::state::normal%]", "max": "Max", "max_plus": "Max+", - "normal": "Normal", "quiet": "Quiet" } }, From e119675100e54bfc7d5872019742fe4e22eb2cb3 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Thu, 10 Apr 2025 10:03:22 +0200 Subject: [PATCH 419/505] Remove deprecated aux heat from econet (#142626) --- homeassistant/components/econet/climate.py | 30 ---------------------- 1 file changed, 30 deletions(-) diff --git a/homeassistant/components/econet/climate.py b/homeassistant/components/econet/climate.py index e7ccec33310..56a98c8d630 100644 --- a/homeassistant/components/econet/climate.py +++ b/homeassistant/components/econet/climate.py @@ -23,10 +23,8 @@ from homeassistant.components.climate import ( from homeassistant.const import ATTR_TEMPERATURE, UnitOfTemperature from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from homeassistant.helpers.issue_registry import IssueSeverity, create_issue from . import EconetConfigEntry -from .const import DOMAIN from .entity import EcoNetEntity ECONET_STATE_TO_HA = { @@ -212,34 +210,6 @@ class EcoNetThermostat(EcoNetEntity[Thermostat], ClimateEntity): """Set the fan mode.""" self._econet.set_fan_mode(HA_FAN_STATE_TO_ECONET[fan_mode]) - def turn_aux_heat_on(self) -> None: - """Turn auxiliary heater on.""" - create_issue( - self.hass, - DOMAIN, - "migrate_aux_heat", - breaks_in_ha_version="2025.4.0", - is_fixable=True, - is_persistent=True, - translation_key="migrate_aux_heat", - severity=IssueSeverity.WARNING, - ) - self._econet.set_mode(ThermostatOperationMode.EMERGENCY_HEAT) - - def turn_aux_heat_off(self) -> None: - """Turn auxiliary heater off.""" - create_issue( - self.hass, - DOMAIN, - "migrate_aux_heat", - breaks_in_ha_version="2025.4.0", - is_fixable=True, - is_persistent=True, - translation_key="migrate_aux_heat", - severity=IssueSeverity.WARNING, - ) - self._econet.set_mode(ThermostatOperationMode.HEATING) - @property def min_temp(self): """Return the minimum temperature.""" From d2bd0e8ca29ce791ff8660df46eef9783390bd54 Mon Sep 17 00:00:00 2001 From: Thomas55555 <59625598+Thomas55555@users.noreply.github.com> Date: Thu, 10 Apr 2025 10:05:38 +0200 Subject: [PATCH 420/505] Bump livisi to 0.0.25 (#142638) --- homeassistant/components/livisi/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/livisi/manifest.json b/homeassistant/components/livisi/manifest.json index 1077cacf2c4..46ffad162f3 100644 --- a/homeassistant/components/livisi/manifest.json +++ b/homeassistant/components/livisi/manifest.json @@ -5,5 +5,5 @@ "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/livisi", "iot_class": "local_polling", - "requirements": ["livisi==0.0.24"] + "requirements": ["livisi==0.0.25"] } diff --git a/requirements_all.txt b/requirements_all.txt index 9879eef9fe0..876a2dc9ef8 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1353,7 +1353,7 @@ linear-garage-door==0.2.9 linode-api==4.1.9b1 # homeassistant.components.livisi -livisi==0.0.24 +livisi==0.0.25 # homeassistant.components.google_maps locationsharinglib==5.0.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 992543f4447..b7754aef3bc 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1138,7 +1138,7 @@ libsoundtouch==0.8 linear-garage-door==0.2.9 # homeassistant.components.livisi -livisi==0.0.24 +livisi==0.0.25 # homeassistant.components.london_underground london-tube-status==0.5 From 4096a8931abe6e1e6f8013b97859fd095f2621b9 Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Thu, 10 Apr 2025 10:08:12 +0200 Subject: [PATCH 421/505] Use common state for "Off" in `nut` (#142643) --- homeassistant/components/nut/strings.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/nut/strings.json b/homeassistant/components/nut/strings.json index 1e6cee786d3..fb49029d69f 100644 --- a/homeassistant/components/nut/strings.json +++ b/homeassistant/components/nut/strings.json @@ -136,7 +136,7 @@ "resting": "Resting", "unknown": "Unknown", "disabled": "[%key:common::state::disabled%]", - "off": "Off" + "off": "[%key:common::state::off%]" } }, "battery_current": { "name": "Battery current" }, From aefadd66847e73b64cd38db7609e1a5b09a32fcb Mon Sep 17 00:00:00 2001 From: Christopher Fenner <9592452+CFenner@users.noreply.github.com> Date: Thu, 10 Apr 2025 10:08:53 +0200 Subject: [PATCH 422/505] Improve config flow title in ViCare integration (#142573) * Update strings.json * Update strings.json --- homeassistant/components/vicare/strings.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/vicare/strings.json b/homeassistant/components/vicare/strings.json index 6ed0a2f018b..dd8d93e609a 100644 --- a/homeassistant/components/vicare/strings.json +++ b/homeassistant/components/vicare/strings.json @@ -1,6 +1,6 @@ { "config": { - "flow_title": "{name} ({host})", + "flow_title": "{name}", "step": { "user": { "description": "Set up ViCare integration. To generate client ID go to https://app.developer.viessmann.com", @@ -11,8 +11,8 @@ "heating_type": "Heating type" }, "data_description": { - "username": "The email address to login to your ViCare account.", - "password": "The password to login to your ViCare account.", + "username": "The email address to log in to your ViCare account.", + "password": "The password to log in to your ViCare account.", "client_id": "The ID of the API client created in the Viessmann developer portal.", "heating_type": "Allows to overrule the device auto detection." } From ea50bbeb11543df18a7e974fee6826bddbddfa00 Mon Sep 17 00:00:00 2001 From: cnico Date: Thu, 10 Apr 2025 10:48:03 +0200 Subject: [PATCH 423/505] Flipr - Removal of obsolete code. (#142504) Removal of obsolete code. --- homeassistant/components/flipr/__init__.py | 51 ----------------- homeassistant/components/flipr/strings.json | 6 -- tests/components/flipr/test_init.py | 61 --------------------- 3 files changed, 118 deletions(-) diff --git a/homeassistant/components/flipr/__init__.py b/homeassistant/components/flipr/__init__.py index 81e61f2554a..4aea43f0bec 100644 --- a/homeassistant/components/flipr/__init__.py +++ b/homeassistant/components/flipr/__init__.py @@ -1,6 +1,5 @@ """The Flipr integration.""" -from collections import Counter import logging from flipr_api import FliprAPIRestClient @@ -8,10 +7,7 @@ from flipr_api import FliprAPIRestClient from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_EMAIL, CONF_PASSWORD, Platform from homeassistant.core import HomeAssistant -from homeassistant.exceptions import ConfigEntryError -from homeassistant.helpers import issue_registry as ir -from .const import DOMAIN from .coordinator import ( FliprConfigEntry, FliprData, @@ -27,9 +23,6 @@ _LOGGER = logging.getLogger(__name__) async def async_setup_entry(hass: HomeAssistant, entry: FliprConfigEntry) -> bool: """Set up flipr from a config entry.""" - # Detect invalid old config entry and raise error if found - detect_invalid_old_configuration(hass, entry) - config = entry.data username = config[CONF_EMAIL] @@ -64,47 +57,3 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Unload a config entry.""" return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) - - -def detect_invalid_old_configuration(hass: HomeAssistant, entry: ConfigEntry): - """Detect invalid old configuration and raise error if found.""" - - def find_duplicate_entries(entries): - values = [e.data["email"] for e in entries] - _LOGGER.debug("Detecting duplicates in values : %s", values) - return any(count > 1 for count in Counter(values).values()) - - entries = hass.config_entries.async_entries(DOMAIN) - - if find_duplicate_entries(entries): - ir.async_create_issue( - hass, - DOMAIN, - "duplicate_config", - breaks_in_ha_version="2025.4.0", - is_fixable=False, - severity=ir.IssueSeverity.ERROR, - translation_key="duplicate_config", - ) - - raise ConfigEntryError( - "Duplicate entries found for flipr with the same user email. Please remove one of it manually. Multiple fliprs will be automatically detected after restart." - ) - - -async def async_migrate_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: - """Migrate config entry.""" - _LOGGER.debug("Migration of flipr config from version %s", entry.version) - - if entry.version == 1: - # In version 1, we have flipr device as config entry unique id - # and one device per config entry. - # We need to migrate to a new config entry that may contain multiple devices. - # So we change the entry data to match config_flow evolution. - login = entry.data[CONF_EMAIL] - - hass.config_entries.async_update_entry(entry, version=2, unique_id=login) - - _LOGGER.debug("Migration of flipr config to version 2 successful") - - return True diff --git a/homeassistant/components/flipr/strings.json b/homeassistant/components/flipr/strings.json index 631b0ce5488..86b1800a473 100644 --- a/homeassistant/components/flipr/strings.json +++ b/homeassistant/components/flipr/strings.json @@ -50,11 +50,5 @@ } } } - }, - "issues": { - "duplicate_config": { - "title": "Multiple flipr configurations with the same account", - "description": "The Flipr integration has been updated to work account based rather than device based. This means that if you have 2 devices, you only need one configuration. For every account you have, please delete all but one configuration and restart Home Assistant for it to set up the devices linked to your account." - } } } diff --git a/tests/components/flipr/test_init.py b/tests/components/flipr/test_init.py index 6e9341b1e06..50a240958f8 100644 --- a/tests/components/flipr/test_init.py +++ b/tests/components/flipr/test_init.py @@ -2,9 +2,7 @@ from unittest.mock import AsyncMock -from homeassistant.components.flipr.const import DOMAIN from homeassistant.config_entries import ConfigEntryState -from homeassistant.const import CONF_EMAIL, CONF_PASSWORD from homeassistant.core import HomeAssistant from . import setup_integration @@ -29,62 +27,3 @@ async def test_unload_entry( await hass.config_entries.async_unload(mock_config_entry.entry_id) assert mock_config_entry.state is ConfigEntryState.NOT_LOADED - - -async def test_duplicate_config_entries( - hass: HomeAssistant, - mock_config_entry: MockConfigEntry, - mock_flipr_client: AsyncMock, -) -> None: - """Test duplicate config entries.""" - - mock_config_entry_dup = MockConfigEntry( - version=2, - domain=DOMAIN, - unique_id="toto@toto.com", - data={ - CONF_EMAIL: "toto@toto.com", - CONF_PASSWORD: "myPassword", - "flipr_id": "myflipr_id_dup", - }, - ) - - mock_config_entry.add_to_hass(hass) - # Initialize the first entry with default mock - await hass.config_entries.async_setup(mock_config_entry.entry_id) - await hass.async_block_till_done() - - # Initialize the second entry with another flipr id - mock_config_entry_dup.add_to_hass(hass) - assert not await hass.config_entries.async_setup(mock_config_entry_dup.entry_id) - await hass.async_block_till_done() - assert mock_config_entry_dup.state is ConfigEntryState.SETUP_ERROR - - -async def test_migrate_entry( - hass: HomeAssistant, - mock_flipr_client: AsyncMock, -) -> None: - """Test migrate config entry from v1 to v2.""" - - mock_config_entry_v1 = MockConfigEntry( - version=1, - domain=DOMAIN, - title="myfliprid", - unique_id="test_entry_unique_id", - data={ - CONF_EMAIL: "toto@toto.com", - CONF_PASSWORD: "myPassword", - "flipr_id": "myfliprid", - }, - ) - - await setup_integration(hass, mock_config_entry_v1) - assert mock_config_entry_v1.state is ConfigEntryState.LOADED - assert mock_config_entry_v1.version == 2 - assert mock_config_entry_v1.unique_id == "toto@toto.com" - assert mock_config_entry_v1.data == { - CONF_EMAIL: "toto@toto.com", - CONF_PASSWORD: "myPassword", - "flipr_id": "myfliprid", - } From 6ed847f49ee71e64a95d3c38082af9c138f2cd03 Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Thu, 10 Apr 2025 11:39:59 +0200 Subject: [PATCH 424/505] =?UTF-8?q?Fix=20typo=20"You=20can=20login=20to=20?= =?UTF-8?q?=E2=80=A6"=20in=20`opensky`=20(#142649)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- homeassistant/components/opensky/strings.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/opensky/strings.json b/homeassistant/components/opensky/strings.json index 4b4dc908b14..c699783551f 100644 --- a/homeassistant/components/opensky/strings.json +++ b/homeassistant/components/opensky/strings.json @@ -15,7 +15,7 @@ "options": { "step": { "init": { - "description": "You can login to your OpenSky account to increase the update frequency.", + "description": "You can log in to your OpenSky account to increase the update frequency.", "data": { "radius": "[%key:component::opensky::config::step::user::data::radius%]", "altitude": "[%key:component::opensky::config::step::user::data::altitude%]", From 954a47d9ef6f9b8f39f9fb2619e81fc869bd6320 Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Thu, 10 Apr 2025 11:43:16 +0200 Subject: [PATCH 425/505] Replace typo "login to" with "log in to" in `fireservicerota` (#142652) Fix typo "login to" with "log in to" in `fireservicerota` --- homeassistant/components/fireservicerota/strings.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/fireservicerota/strings.json b/homeassistant/components/fireservicerota/strings.json index 7b4bd583b63..9a23161b7ec 100644 --- a/homeassistant/components/fireservicerota/strings.json +++ b/homeassistant/components/fireservicerota/strings.json @@ -9,7 +9,7 @@ } }, "reauth_confirm": { - "description": "Authentication tokens became invalid, login to recreate them.", + "description": "Authentication tokens became invalid, log in to recreate them.", "data": { "password": "[%key:common::config_flow::data::password%]" } From 12ae70630f63f095d7be80ec3f3a503566531337 Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Thu, 10 Apr 2025 11:43:46 +0200 Subject: [PATCH 426/505] Fix sentence-casing and typo in `elmax` (#142650) - change a few words to lowercase - replace "login to" with "log in to" --- homeassistant/components/elmax/strings.json | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/elmax/strings.json b/homeassistant/components/elmax/strings.json index 2ba74f5fc8f..5bc7eb292a2 100644 --- a/homeassistant/components/elmax/strings.json +++ b/homeassistant/components/elmax/strings.json @@ -4,12 +4,12 @@ "choose_mode": { "description": "Please choose the connection mode to Elmax panels.", "menu_options": { - "cloud": "Connect to Elmax Panel via Elmax Cloud APIs", - "direct": "Connect to Elmax Panel via local/direct IP" + "cloud": "Connect to Elmax panel via Elmax Cloud APIs", + "direct": "Connect to Elmax panel via local/direct IP" } }, "cloud": { - "description": "Please login to the Elmax cloud using your credentials", + "description": "Please log in to the Elmax cloud using your credentials", "data": { "password": "[%key:common::config_flow::data::password%]", "username": "[%key:common::config_flow::data::username%]" @@ -28,7 +28,7 @@ "direct": { "description": "Specify the Elmax panel connection parameters below.", "data": { - "panel_api_host": "Panel API Hostname or IP", + "panel_api_host": "Panel API hostname or IP", "panel_api_port": "Panel API port", "use_ssl": "Use SSL", "panel_pin": "Panel PIN code" @@ -40,7 +40,7 @@ "panels": { "description": "Select which panel you would like to control with this integration. Please note that the panel must be ON in order to be configured.", "data": { - "panel_name": "Panel Name", + "panel_name": "Panel name", "panel_id": "Panel ID", "panel_pin": "[%key:common::config_flow::data::pin%]" } From d5476a1da1f747f7dd2b9b224776626d8287278c Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Thu, 10 Apr 2025 11:55:07 +0200 Subject: [PATCH 427/505] Store update settings in hassio store (#142526) --- homeassistant/components/hassio/__init__.py | 19 +- homeassistant/components/hassio/backup.py | 16 +- homeassistant/components/hassio/config.py | 148 ++++++++++++++ homeassistant/components/hassio/const.py | 2 + .../components/hassio/websocket_api.py | 45 ++++- .../hassio/snapshots/test_config.ambr | 46 +++++ .../hassio/snapshots/test_websocket_api.ambr | 33 ++++ tests/components/hassio/test_config.py | 182 ++++++++++++++++++ tests/components/hassio/test_init.py | 7 +- tests/components/hassio/test_websocket_api.py | 86 ++++++++- 10 files changed, 568 insertions(+), 16 deletions(-) create mode 100644 homeassistant/components/hassio/config.py create mode 100644 tests/components/hassio/snapshots/test_config.ambr create mode 100644 tests/components/hassio/snapshots/test_websocket_api.ambr create mode 100644 tests/components/hassio/test_config.py diff --git a/homeassistant/components/hassio/__init__.py b/homeassistant/components/hassio/__init__.py index d71b2b85f7b..f160c69bae7 100644 --- a/homeassistant/components/hassio/__init__.py +++ b/homeassistant/components/hassio/__init__.py @@ -55,7 +55,6 @@ from homeassistant.helpers.issue_registry import IssueSeverity, async_create_iss from homeassistant.helpers.service_info.hassio import ( HassioServiceInfo as _HassioServiceInfo, ) -from homeassistant.helpers.storage import Store from homeassistant.helpers.typing import ConfigType from homeassistant.loader import bind_hass from homeassistant.util.async_ import create_eager_task @@ -78,6 +77,7 @@ from . import ( # noqa: F401 from .addon_manager import AddonError, AddonInfo, AddonManager, AddonState # noqa: F401 from .addon_panel import async_setup_addon_panel from .auth import async_setup_auth_view +from .config import HassioConfig from .const import ( ADDONS_COORDINATOR, ATTR_ADDON, @@ -91,6 +91,7 @@ from .const import ( ATTR_PASSWORD, ATTR_SLUG, DATA_COMPONENT, + DATA_CONFIG_STORE, DATA_CORE_INFO, DATA_HOST_INFO, DATA_INFO, @@ -144,8 +145,6 @@ _DEPRECATED_HassioServiceInfo = DeprecatedConstant( "2025.11", ) -STORAGE_KEY = DOMAIN -STORAGE_VERSION = 1 # If new platforms are added, be sure to import them above # so we do not make other components that depend on hassio # wait for the import of the platforms @@ -335,13 +334,14 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: # noqa: except SupervisorError: _LOGGER.warning("Not connected with the supervisor / system too busy!") - store = Store[dict[str, str]](hass, STORAGE_VERSION, STORAGE_KEY) - if (data := await store.async_load()) is None: - data = {} + # Load the store + config_store = HassioConfig(hass) + await config_store.load() + hass.data[DATA_CONFIG_STORE] = config_store refresh_token = None - if "hassio_user" in data: - user = await hass.auth.async_get_user(data["hassio_user"]) + if (hassio_user := config_store.data.hassio_user) is not None: + user = await hass.auth.async_get_user(hassio_user) if user and user.refresh_tokens: refresh_token = list(user.refresh_tokens.values())[0] @@ -358,8 +358,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: # noqa: HASSIO_USER_NAME, group_ids=[GROUP_ID_ADMIN] ) refresh_token = await hass.auth.async_create_refresh_token(user) - data["hassio_user"] = user.id - await store.async_save(data) + config_store.update(hassio_user=user.id) # This overrides the normal API call that would be forwarded development_repo = config.get(DOMAIN, {}).get(CONF_FRONTEND_REPO) diff --git a/homeassistant/components/hassio/backup.py b/homeassistant/components/hassio/backup.py index 20f1ec82a7a..38bf3c82561 100644 --- a/homeassistant/components/hassio/backup.py +++ b/homeassistant/components/hassio/backup.py @@ -57,7 +57,7 @@ from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.util import dt as dt_util from homeassistant.util.enum import try_parse_enum -from .const import DOMAIN, EVENT_SUPERVISOR_EVENT +from .const import DATA_CONFIG_STORE, DOMAIN, EVENT_SUPERVISOR_EVENT from .handler import get_supervisor_client MOUNT_JOBS = ("mount_manager_create_mount", "mount_manager_remove_mount") @@ -729,6 +729,18 @@ async def backup_addon_before_update( if backup.extra_metadata.get(TAG_ADDON_UPDATE) == addon } + def _delete_filter( + backups: dict[str, ManagerBackup], + ) -> dict[str, ManagerBackup]: + """Return oldest backups more numerous than copies to delete.""" + update_config = hass.data[DATA_CONFIG_STORE].data.update_config + return dict( + sorted( + backups.items(), + key=lambda backup_item: backup_item[1].date, + )[: max(len(backups) - update_config.add_on_backup_retain_copies, 0)] + ) + try: await backup_manager.async_create_backup( agent_ids=[await _default_agent(client)], @@ -747,7 +759,7 @@ async def backup_addon_before_update( try: await backup_manager.async_delete_filtered_backups( include_filter=addon_update_backup_filter, - delete_filter=lambda backups: backups, + delete_filter=_delete_filter, ) except BackupManagerError as err: raise HomeAssistantError(f"Error deleting old backups: {err}") from err diff --git a/homeassistant/components/hassio/config.py b/homeassistant/components/hassio/config.py new file mode 100644 index 00000000000..f277249ee94 --- /dev/null +++ b/homeassistant/components/hassio/config.py @@ -0,0 +1,148 @@ +"""Provide persistent configuration for the hassio integration.""" + +from __future__ import annotations + +from dataclasses import dataclass, replace +from typing import Required, Self, TypedDict + +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.storage import Store +from homeassistant.helpers.typing import UNDEFINED, UndefinedType + +from .const import DOMAIN + +STORE_DELAY_SAVE = 30 +STORAGE_KEY = DOMAIN +STORAGE_VERSION = 1 +STORAGE_VERSION_MINOR = 1 + + +class HassioConfig: + """Handle update config.""" + + def __init__(self, hass: HomeAssistant) -> None: + """Initialize update config.""" + self.data = HassioConfigData( + hassio_user=None, + update_config=HassioUpdateConfig(), + ) + self._hass = hass + self._store = HassioConfigStore(hass, self) + + async def load(self) -> None: + """Load config.""" + if not (store_data := await self._store.load()): + return + self.data = HassioConfigData.from_dict(store_data) + + @callback + def update( + self, + *, + hassio_user: str | UndefinedType = UNDEFINED, + update_config: HassioUpdateParametersDict | UndefinedType = UNDEFINED, + ) -> None: + """Update config.""" + if hassio_user is not UNDEFINED: + self.data.hassio_user = hassio_user + if update_config is not UNDEFINED: + self.data.update_config = replace(self.data.update_config, **update_config) + + self._store.save() + + +@dataclass(kw_only=True) +class HassioConfigData: + """Represent loaded update config data.""" + + hassio_user: str | None + update_config: HassioUpdateConfig + + @classmethod + def from_dict(cls, data: StoredHassioConfig) -> Self: + """Initialize update config data from a dict.""" + if update_data := data.get("update_config"): + update_config = HassioUpdateConfig( + add_on_backup_before_update=update_data["add_on_backup_before_update"], + add_on_backup_retain_copies=update_data["add_on_backup_retain_copies"], + core_backup_before_update=update_data["core_backup_before_update"], + ) + else: + update_config = HassioUpdateConfig() + return cls( + hassio_user=data["hassio_user"], + update_config=update_config, + ) + + def to_dict(self) -> StoredHassioConfig: + """Convert update config data to a dict.""" + return StoredHassioConfig( + hassio_user=self.hassio_user, + update_config=self.update_config.to_dict(), + ) + + +@dataclass(kw_only=True) +class HassioUpdateConfig: + """Represent the backup retention configuration.""" + + add_on_backup_before_update: bool = False + add_on_backup_retain_copies: int = 1 + core_backup_before_update: bool = False + + def to_dict(self) -> StoredHassioUpdateConfig: + """Convert backup retention configuration to a dict.""" + return StoredHassioUpdateConfig( + add_on_backup_before_update=self.add_on_backup_before_update, + add_on_backup_retain_copies=self.add_on_backup_retain_copies, + core_backup_before_update=self.core_backup_before_update, + ) + + +class HassioUpdateParametersDict(TypedDict, total=False): + """Represent the parameters for update.""" + + add_on_backup_before_update: bool + add_on_backup_retain_copies: int + core_backup_before_update: bool + + +class HassioConfigStore: + """Store hassio config.""" + + def __init__(self, hass: HomeAssistant, config: HassioConfig) -> None: + """Initialize the hassio config store.""" + self._hass = hass + self._config = config + self._store: Store[StoredHassioConfig] = Store( + hass, STORAGE_VERSION, STORAGE_KEY, minor_version=STORAGE_VERSION_MINOR + ) + + async def load(self) -> StoredHassioConfig | None: + """Load the store.""" + return await self._store.async_load() + + @callback + def save(self) -> None: + """Save config.""" + self._store.async_delay_save(self._data_to_save, STORE_DELAY_SAVE) + + @callback + def _data_to_save(self) -> StoredHassioConfig: + """Return data to save.""" + return self._config.data.to_dict() + + +class StoredHassioConfig(TypedDict, total=False): + """Represent the stored hassio config.""" + + hassio_user: Required[str | None] + update_config: StoredHassioUpdateConfig + + +class StoredHassioUpdateConfig(TypedDict): + """Represent the stored update config.""" + + add_on_backup_before_update: bool + add_on_backup_retain_copies: int + core_backup_before_update: bool diff --git a/homeassistant/components/hassio/const.py b/homeassistant/components/hassio/const.py index d1cda51ec7b..562669f674a 100644 --- a/homeassistant/components/hassio/const.py +++ b/homeassistant/components/hassio/const.py @@ -9,6 +9,7 @@ from typing import TYPE_CHECKING from homeassistant.util.hass_dict import HassKey if TYPE_CHECKING: + from .config import HassioConfig from .handler import HassIO @@ -74,6 +75,7 @@ ADDONS_COORDINATOR = "hassio_addons_coordinator" DATA_COMPONENT: HassKey[HassIO] = HassKey(DOMAIN) +DATA_CONFIG_STORE: HassKey[HassioConfig] = HassKey("hassio_config_store") DATA_CORE_INFO = "hassio_core_info" DATA_CORE_STATS = "hassio_core_stats" DATA_HOST_INFO = "hassio_host_info" diff --git a/homeassistant/components/hassio/websocket_api.py b/homeassistant/components/hassio/websocket_api.py index 6714d5782e1..81f7ab9d0da 100644 --- a/homeassistant/components/hassio/websocket_api.py +++ b/homeassistant/components/hassio/websocket_api.py @@ -3,7 +3,7 @@ import logging from numbers import Number import re -from typing import Any +from typing import Any, cast import voluptuous as vol @@ -19,6 +19,7 @@ from homeassistant.helpers.dispatcher import ( ) from . import HassioAPIError +from .config import HassioUpdateParametersDict from .const import ( ATTR_DATA, ATTR_ENDPOINT, @@ -29,6 +30,7 @@ from .const import ( ATTR_VERSION, ATTR_WS_EVENT, DATA_COMPONENT, + DATA_CONFIG_STORE, EVENT_SUPERVISOR_EVENT, WS_ID, WS_TYPE, @@ -65,6 +67,8 @@ def async_load_websocket_api(hass: HomeAssistant) -> None: websocket_api.async_register_command(hass, websocket_subscribe) websocket_api.async_register_command(hass, websocket_update_addon) websocket_api.async_register_command(hass, websocket_update_core) + websocket_api.async_register_command(hass, websocket_update_config_info) + websocket_api.async_register_command(hass, websocket_update_config_update) @callback @@ -185,3 +189,42 @@ async def websocket_update_core( """Websocket handler to update Home Assistant Core.""" await update_core(hass, None, msg["backup"]) connection.send_result(msg[WS_ID]) + + +@callback +@websocket_api.require_admin +@websocket_api.websocket_command({vol.Required("type"): "hassio/update/config/info"}) +def websocket_update_config_info( + hass: HomeAssistant, + connection: websocket_api.ActiveConnection, + msg: dict[str, Any], +) -> None: + """Send the stored backup config.""" + connection.send_result( + msg["id"], hass.data[DATA_CONFIG_STORE].data.update_config.to_dict() + ) + + +@callback +@websocket_api.require_admin +@websocket_api.websocket_command( + { + vol.Required("type"): "hassio/update/config/update", + vol.Optional("add_on_backup_before_update"): bool, + vol.Optional("add_on_backup_retain_copies"): vol.All(int, vol.Range(min=1)), + vol.Optional("core_backup_before_update"): bool, + } +) +def websocket_update_config_update( + hass: HomeAssistant, + connection: websocket_api.ActiveConnection, + msg: dict[str, Any], +) -> None: + """Update the stored backup config.""" + changes = dict(msg) + changes.pop("id") + changes.pop("type") + hass.data[DATA_CONFIG_STORE].update( + update_config=cast(HassioUpdateParametersDict, changes) + ) + connection.send_result(msg["id"]) diff --git a/tests/components/hassio/snapshots/test_config.ambr b/tests/components/hassio/snapshots/test_config.ambr new file mode 100644 index 00000000000..905c4155184 --- /dev/null +++ b/tests/components/hassio/snapshots/test_config.ambr @@ -0,0 +1,46 @@ +# serializer version: 1 +# name: test_load_config_store[storage_data0] + dict({ + 'hassio_user': '766572795f764572b95f72616e646f6d', + 'update_config': dict({ + 'add_on_backup_before_update': False, + 'add_on_backup_retain_copies': 1, + 'core_backup_before_update': False, + }), + }) +# --- +# name: test_load_config_store[storage_data1] + dict({ + 'hassio_user': '00112233445566778899aabbccddeeff', + 'update_config': dict({ + 'add_on_backup_before_update': False, + 'add_on_backup_retain_copies': 1, + 'core_backup_before_update': False, + }), + }) +# --- +# name: test_load_config_store[storage_data2] + dict({ + 'hassio_user': '00112233445566778899aabbccddeeff', + 'update_config': dict({ + 'add_on_backup_before_update': True, + 'add_on_backup_retain_copies': 2, + 'core_backup_before_update': True, + }), + }) +# --- +# name: test_save_config_store + dict({ + 'data': dict({ + 'hassio_user': '766572795f764572b95f72616e646f6d', + 'update_config': dict({ + 'add_on_backup_before_update': False, + 'add_on_backup_retain_copies': 1, + 'core_backup_before_update': False, + }), + }), + 'key': 'hassio', + 'minor_version': 1, + 'version': 1, + }) +# --- diff --git a/tests/components/hassio/snapshots/test_websocket_api.ambr b/tests/components/hassio/snapshots/test_websocket_api.ambr new file mode 100644 index 00000000000..e3ff6c978c1 --- /dev/null +++ b/tests/components/hassio/snapshots/test_websocket_api.ambr @@ -0,0 +1,33 @@ +# serializer version: 1 +# name: test_read_update_config + dict({ + 'id': 1, + 'result': dict({ + 'add_on_backup_before_update': False, + 'add_on_backup_retain_copies': 1, + 'core_backup_before_update': False, + }), + 'success': True, + 'type': 'result', + }) +# --- +# name: test_read_update_config.1 + dict({ + 'id': 2, + 'result': None, + 'success': True, + 'type': 'result', + }) +# --- +# name: test_read_update_config.2 + dict({ + 'id': 3, + 'result': dict({ + 'add_on_backup_before_update': True, + 'add_on_backup_retain_copies': 2, + 'core_backup_before_update': True, + }), + 'success': True, + 'type': 'result', + }) +# --- diff --git a/tests/components/hassio/test_config.py b/tests/components/hassio/test_config.py new file mode 100644 index 00000000000..86a97cc4a0a --- /dev/null +++ b/tests/components/hassio/test_config.py @@ -0,0 +1,182 @@ +"""Test websocket API.""" + +from typing import Any +from unittest.mock import AsyncMock, patch +from uuid import UUID + +import pytest +from syrupy import SnapshotAssertion + +from homeassistant.auth.const import GROUP_ID_ADMIN +from homeassistant.components.hassio.const import DATA_CONFIG_STORE, DOMAIN +from homeassistant.core import HomeAssistant +from homeassistant.setup import async_setup_component + +from tests.common import MockUser +from tests.test_util.aiohttp import AiohttpClientMocker +from tests.typing import WebSocketGenerator + +MOCK_ENVIRON = {"SUPERVISOR": "127.0.0.1", "SUPERVISOR_TOKEN": "abcdefgh"} + + +@pytest.fixture(autouse=True) +def mock_all( + aioclient_mock: AiohttpClientMocker, + supervisor_is_connected: AsyncMock, + resolution_info: AsyncMock, + addon_info: AsyncMock, +) -> None: + """Mock all setup requests.""" + aioclient_mock.post("http://127.0.0.1/homeassistant/options", json={"result": "ok"}) + aioclient_mock.post("http://127.0.0.1/supervisor/options", json={"result": "ok"}) + aioclient_mock.get( + "http://127.0.0.1/info", + json={ + "result": "ok", + "data": {"supervisor": "222", "homeassistant": "0.110.0", "hassos": None}, + }, + ) + aioclient_mock.get( + "http://127.0.0.1/host/info", + json={ + "result": "ok", + "data": { + "result": "ok", + "data": { + "chassis": "vm", + "operating_system": "Debian GNU/Linux 10 (buster)", + "kernel": "4.19.0-6-amd64", + }, + }, + }, + ) + aioclient_mock.get( + "http://127.0.0.1/core/info", + json={"result": "ok", "data": {"version_latest": "1.0.0", "version": "1.0.0"}}, + ) + aioclient_mock.get( + "http://127.0.0.1/os/info", + json={"result": "ok", "data": {"version_latest": "1.0.0"}}, + ) + aioclient_mock.get( + "http://127.0.0.1/supervisor/info", + json={ + "result": "ok", + "data": { + "version": "1.0.0", + "version_latest": "1.0.0", + "auto_update": True, + "addons": [ + { + "name": "test", + "state": "started", + "slug": "test", + "installed": True, + "update_available": True, + "icon": False, + "version": "2.0.0", + "version_latest": "2.0.1", + "repository": "core", + "url": "https://github.com/home-assistant/addons/test", + }, + ], + }, + }, + ) + aioclient_mock.get( + "http://127.0.0.1/ingress/panels", json={"result": "ok", "data": {"panels": {}}} + ) + aioclient_mock.get( + "http://127.0.0.1/network/info", + json={ + "result": "ok", + "data": { + "host_internet": True, + "supervisor_internet": True, + }, + }, + ) + + +@pytest.mark.usefixtures("hassio_env") +@pytest.mark.parametrize( + "storage_data", + [ + {}, + { + "hassio": { + "data": { + "hassio_user": "00112233445566778899aabbccddeeff", + "update_config": { + "add_on_backup_before_update": False, + "add_on_backup_retain_copies": 1, + "core_backup_before_update": False, + }, + }, + "key": "hassio", + "minor_version": 1, + "version": 1, + } + }, + { + "hassio": { + "data": { + "hassio_user": "00112233445566778899aabbccddeeff", + "update_config": { + "add_on_backup_before_update": True, + "add_on_backup_retain_copies": 2, + "core_backup_before_update": True, + }, + }, + "key": "hassio", + "minor_version": 1, + "version": 1, + } + }, + ], +) +async def test_load_config_store( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + supervisor_client: AsyncMock, + hass_storage: dict[str, Any], + storage_data: dict[str, dict[str, Any]], + snapshot: SnapshotAssertion, +) -> None: + """Test loading the config store.""" + hass_storage.update(storage_data) + + user = MockUser(id="00112233445566778899aabbccddeeff", system_generated=True) + user.add_to_hass(hass) + await hass.auth.async_create_refresh_token(user) + await hass.auth.async_update_user(user, group_ids=[GROUP_ID_ADMIN]) + + with ( + patch("homeassistant.components.hassio.config.STORE_DELAY_SAVE", 0), + patch("uuid.uuid4", return_value=UUID(bytes=b"very_very_random", version=4)), + ): + assert await async_setup_component(hass, "hassio", {}) + await hass.async_block_till_done() + await hass.async_block_till_done() + + assert hass.data[DATA_CONFIG_STORE].data.to_dict() == snapshot + + +@pytest.mark.usefixtures("hassio_env") +async def test_save_config_store( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + supervisor_client: AsyncMock, + hass_storage: dict[str, Any], + snapshot: SnapshotAssertion, +) -> None: + """Test saving the config store.""" + with ( + patch("homeassistant.components.hassio.config.STORE_DELAY_SAVE", 0), + patch("uuid.uuid4", return_value=UUID(bytes=b"very_very_random", version=4)), + ): + assert await async_setup_component(hass, "hassio", {}) + await hass.async_block_till_done() + await hass.async_block_till_done() + + assert hass_storage[DOMAIN] == snapshot diff --git a/tests/components/hassio/test_init.py b/tests/components/hassio/test_init.py index 5c11370ae74..48c09d2feed 100644 --- a/tests/components/hassio/test_init.py +++ b/tests/components/hassio/test_init.py @@ -17,12 +17,12 @@ from homeassistant.components.binary_sensor import DOMAIN as BINARY_SENSOR_DOMAI from homeassistant.components.hassio import ( ADDONS_COORDINATOR, DOMAIN, - STORAGE_KEY, get_core_info, get_supervisor_ip, hostname_from_addon_slug, is_hassio as deprecated_is_hassio, ) +from homeassistant.components.hassio.config import STORAGE_KEY from homeassistant.components.hassio.const import REQUEST_REFRESH_DELAY from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN from homeassistant.core import HomeAssistant @@ -309,7 +309,10 @@ async def test_setup_api_push_api_data_default( supervisor_client: AsyncMock, ) -> None: """Test setup with API push default data.""" - with patch.dict(os.environ, MOCK_ENVIRON): + with ( + patch.dict(os.environ, MOCK_ENVIRON), + patch("homeassistant.components.hassio.config.STORE_DELAY_SAVE", 0), + ): result = await async_setup_component(hass, "hassio", {"http": {}, "hassio": {}}) await hass.async_block_till_done() diff --git a/tests/components/hassio/test_websocket_api.py b/tests/components/hassio/test_websocket_api.py index 497b961c80f..cbf664d0e49 100644 --- a/tests/components/hassio/test_websocket_api.py +++ b/tests/components/hassio/test_websocket_api.py @@ -7,6 +7,7 @@ from unittest.mock import AsyncMock, MagicMock, patch from aiohasupervisor import SupervisorError from aiohasupervisor.models import HomeAssistantUpdateOptions, StoreAddonUpdate import pytest +from syrupy import SnapshotAssertion from homeassistant.components.backup import BackupManagerError, ManagerBackup @@ -469,13 +470,15 @@ async def test_update_addon_with_backup( @pytest.mark.parametrize( - ("backups", "removed_backups"), + ("ws_commands", "backups", "removed_backups"), [ ( + [], {}, [], ), ( + [], { "backup-1": MagicMock( agents={"hassio.local": MagicMock(spec=AgentBackupStatus)}, @@ -520,6 +523,52 @@ async def test_update_addon_with_backup( }, ["backup-5"], ), + ( + [{"type": "hassio/update/config/update", "add_on_backup_retain_copies": 2}], + { + "backup-1": MagicMock( + agents={"hassio.local": MagicMock(spec=AgentBackupStatus)}, + date="2024-11-10T04:45:00+01:00", + with_automatic_settings=True, + spec=ManagerBackup, + ), + "backup-2": MagicMock( + agents={"hassio.local": MagicMock(spec=AgentBackupStatus)}, + date="2024-11-11T04:45:00+01:00", + with_automatic_settings=False, + spec=ManagerBackup, + ), + "backup-3": MagicMock( + agents={"hassio.local": MagicMock(spec=AgentBackupStatus)}, + date="2024-11-11T04:45:00+01:00", + extra_metadata={"supervisor.addon_update": "other"}, + with_automatic_settings=True, + spec=ManagerBackup, + ), + "backup-4": MagicMock( + agents={"hassio.local": MagicMock(spec=AgentBackupStatus)}, + date="2024-11-11T04:45:00+01:00", + extra_metadata={"supervisor.addon_update": "other"}, + with_automatic_settings=True, + spec=ManagerBackup, + ), + "backup-5": MagicMock( + agents={"hassio.local": MagicMock(spec=AgentBackupStatus)}, + date="2024-11-11T04:45:00+01:00", + extra_metadata={"supervisor.addon_update": "test"}, + with_automatic_settings=True, + spec=ManagerBackup, + ), + "backup-6": MagicMock( + agents={"hassio.local": MagicMock(spec=AgentBackupStatus)}, + date="2024-11-12T04:45:00+01:00", + extra_metadata={"supervisor.addon_update": "test"}, + with_automatic_settings=True, + spec=ManagerBackup, + ), + }, + [], + ), ], ) async def test_update_addon_with_backup_removes_old_backups( @@ -527,6 +576,7 @@ async def test_update_addon_with_backup_removes_old_backups( hass_ws_client: WebSocketGenerator, supervisor_client: AsyncMock, update_addon: AsyncMock, + ws_commands: list[dict[str, Any]], backups: dict[str, ManagerBackup], removed_backups: list[str], ) -> None: @@ -544,6 +594,12 @@ async def test_update_addon_with_backup_removes_old_backups( await setup_backup_integration(hass) client = await hass_ws_client(hass) + + for command in ws_commands: + await client.send_json_auto_id(command) + result = await client.receive_json() + assert result["success"] + supervisor_client.mounts.info.return_value.default_backup_mount = None with ( patch( @@ -856,3 +912,31 @@ async def test_update_core_with_backup_and_error( "code": "home_assistant_error", "message": "Error creating backup: ", } + + +@pytest.mark.usefixtures("hassio_env") +async def test_read_update_config( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + supervisor_client: AsyncMock, + snapshot: SnapshotAssertion, +) -> None: + """Test read and update config.""" + assert await async_setup_component(hass, "hassio", {}) + websocket_client = await hass_ws_client(hass) + + await websocket_client.send_json_auto_id({"type": "hassio/update/config/info"}) + assert await websocket_client.receive_json() == snapshot + + await websocket_client.send_json_auto_id( + { + "type": "hassio/update/config/update", + "add_on_backup_before_update": True, + "add_on_backup_retain_copies": 2, + "core_backup_before_update": True, + } + ) + assert await websocket_client.receive_json() == snapshot + + await websocket_client.send_json_auto_id({"type": "hassio/update/config/info"}) + assert await websocket_client.receive_json() == snapshot From 844515787b199ab13b6b13ec333c8a46b4f37d18 Mon Sep 17 00:00:00 2001 From: Yuxin Wang Date: Thu, 10 Apr 2025 10:45:46 -0400 Subject: [PATCH 428/505] Fallback to config entry ID as unique ID when serialno is not available for APCUPSD (#130852) --- .../components/apcupsd/binary_sensor.py | 4 +--- .../components/apcupsd/coordinator.py | 7 ++++++- homeassistant/components/apcupsd/sensor.py | 5 +---- tests/components/apcupsd/__init__.py | 7 +++++-- tests/components/apcupsd/test_init.py | 21 +++++++------------ tests/components/apcupsd/test_sensor.py | 13 +++++++----- 6 files changed, 28 insertions(+), 29 deletions(-) diff --git a/homeassistant/components/apcupsd/binary_sensor.py b/homeassistant/components/apcupsd/binary_sensor.py index f3829b41f61..dfeb56c8d06 100644 --- a/homeassistant/components/apcupsd/binary_sensor.py +++ b/homeassistant/components/apcupsd/binary_sensor.py @@ -53,10 +53,8 @@ class OnlineStatus(CoordinatorEntity[APCUPSdCoordinator], BinarySensorEntity): """Initialize the APCUPSd binary device.""" super().__init__(coordinator, context=description.key.upper()) - # Set up unique id and device info if serial number is available. - if (serial_no := coordinator.data.serial_no) is not None: - self._attr_unique_id = f"{serial_no}_{description.key}" self.entity_description = description + self._attr_unique_id = f"{coordinator.unique_device_id}_{description.key}" self._attr_device_info = coordinator.device_info @property diff --git a/homeassistant/components/apcupsd/coordinator.py b/homeassistant/components/apcupsd/coordinator.py index e2c1af50cee..4e663725303 100644 --- a/homeassistant/components/apcupsd/coordinator.py +++ b/homeassistant/components/apcupsd/coordinator.py @@ -85,11 +85,16 @@ class APCUPSdCoordinator(DataUpdateCoordinator[APCUPSdData]): self._host = host self._port = port + @property + def unique_device_id(self) -> str: + """Return a unique ID of the device, which is the serial number (if available) or the config entry ID.""" + return self.data.serial_no or self.config_entry.entry_id + @property def device_info(self) -> DeviceInfo: """Return the DeviceInfo of this APC UPS, if serial number is available.""" return DeviceInfo( - identifiers={(DOMAIN, self.data.serial_no or self.config_entry.entry_id)}, + identifiers={(DOMAIN, self.unique_device_id)}, model=self.data.model, manufacturer="APC", name=self.data.name or "APC UPS", diff --git a/homeassistant/components/apcupsd/sensor.py b/homeassistant/components/apcupsd/sensor.py index 02016efa4ca..a3faf6b0268 100644 --- a/homeassistant/components/apcupsd/sensor.py +++ b/homeassistant/components/apcupsd/sensor.py @@ -458,11 +458,8 @@ class APCUPSdSensor(CoordinatorEntity[APCUPSdCoordinator], SensorEntity): """Initialize the sensor.""" super().__init__(coordinator=coordinator, context=description.key.upper()) - # Set up unique id and device info if serial number is available. - if (serial_no := coordinator.data.serial_no) is not None: - self._attr_unique_id = f"{serial_no}_{description.key}" - self.entity_description = description + self._attr_unique_id = f"{coordinator.unique_device_id}_{description.key}" self._attr_device_info = coordinator.device_info # Initial update of attributes. diff --git a/tests/components/apcupsd/__init__.py b/tests/components/apcupsd/__init__.py index eb8cd594ad7..5994a7f4c17 100644 --- a/tests/components/apcupsd/__init__.py +++ b/tests/components/apcupsd/__init__.py @@ -1,10 +1,13 @@ """Tests for the APCUPSd component.""" +from __future__ import annotations + from collections import OrderedDict from typing import Final from unittest.mock import patch from homeassistant.components.apcupsd.const import DOMAIN +from homeassistant.components.apcupsd.coordinator import APCUPSdData from homeassistant.config_entries import SOURCE_USER from homeassistant.const import CONF_HOST, CONF_PORT from homeassistant.core import HomeAssistant @@ -79,7 +82,7 @@ MOCK_MINIMAL_STATUS: Final = OrderedDict( async def async_init_integration( - hass: HomeAssistant, host: str = "test", status=None + hass: HomeAssistant, host: str = "test", status: dict[str, str] | None = None ) -> MockConfigEntry: """Set up the APC UPS Daemon integration in HomeAssistant.""" if status is None: @@ -90,7 +93,7 @@ async def async_init_integration( domain=DOMAIN, title="APCUPSd", data=CONF_DATA | {CONF_HOST: host}, - unique_id=status.get("SERIALNO", None), + unique_id=APCUPSdData(status).serial_no, source=SOURCE_USER, ) diff --git a/tests/components/apcupsd/test_init.py b/tests/components/apcupsd/test_init.py index 6bb94ca2948..9edf4d8282f 100644 --- a/tests/components/apcupsd/test_init.py +++ b/tests/components/apcupsd/test_init.py @@ -28,8 +28,8 @@ from tests.common import MockConfigEntry, async_fire_time_changed # Contains "SERIALNO" but no "UPSNAME" field. # We should create devices for the entities and prefix their IDs with default "APC UPS". MOCK_MINIMAL_STATUS | {"SERIALNO": "XXXX"}, - # Does not contain either "SERIALNO" field. - # We should _not_ create devices for the entities and their IDs will not have prefixes. + # Does not contain either "SERIALNO" field or "UPSNAME" field. Our integration should work + # fine without it by falling back to config entry ID as unique ID and "APC UPS" as default name. MOCK_MINIMAL_STATUS, # Some models report "Blank" as SERIALNO, but we should treat it as not reported. MOCK_MINIMAL_STATUS | {"SERIALNO": "Blank"}, @@ -37,14 +37,9 @@ from tests.common import MockConfigEntry, async_fire_time_changed ) async def test_async_setup_entry(hass: HomeAssistant, status: OrderedDict) -> None: """Test a successful setup entry.""" - # Minimal status does not contain "SERIALNO" field, which is used to determine the - # unique ID of this integration. But, the integration should work fine without it. - # In such a case, the device will not be added either await async_init_integration(hass, status=status) - prefix = "" - if "SERIALNO" in status and status["SERIALNO"] != "Blank": - prefix = slugify(status.get("UPSNAME", "APC UPS")) + "_" + prefix = slugify(status.get("UPSNAME", "APC UPS")) + "_" # Verify successful setup by querying the status sensor. state = hass.states.get(f"binary_sensor.{prefix}online_status") @@ -72,15 +67,13 @@ async def test_device_entry( hass: HomeAssistant, status: OrderedDict, device_registry: dr.DeviceRegistry ) -> None: """Test successful setup of device entries.""" - await async_init_integration(hass, status=status) + config_entry = await async_init_integration(hass, status=status) # Verify device info is properly set up. - if "SERIALNO" not in status or status["SERIALNO"] == "Blank": - assert len(device_registry.devices) == 0 - return - assert len(device_registry.devices) == 1 - entry = device_registry.async_get_device({(DOMAIN, status["SERIALNO"])}) + entry = device_registry.async_get_device( + {(DOMAIN, config_entry.unique_id or config_entry.entry_id)} + ) assert entry is not None # Specify the mapping between field name and the expected fields in device entry. fields = { diff --git a/tests/components/apcupsd/test_sensor.py b/tests/components/apcupsd/test_sensor.py index 0fe7f12ad27..f36421c4183 100644 --- a/tests/components/apcupsd/test_sensor.py +++ b/tests/components/apcupsd/test_sensor.py @@ -244,11 +244,14 @@ async def test_sensor_unknown(hass: HomeAssistant) -> None: """Test if our integration can properly certain sensors as unknown when it becomes so.""" await async_init_integration(hass, status=MOCK_MINIMAL_STATUS) - assert hass.states.get("sensor.mode").state == MOCK_MINIMAL_STATUS["UPSMODE"] + ups_mode_id = "sensor.apc_ups_mode" + last_self_test_id = "sensor.apc_ups_last_self_test" + + assert hass.states.get(ups_mode_id).state == MOCK_MINIMAL_STATUS["UPSMODE"] # Last self test sensor should be added even if our status does not report it initially (it is # a sensor that appears only after a periodical or manual self test is performed). - assert hass.states.get("sensor.last_self_test") is not None - assert hass.states.get("sensor.last_self_test").state == STATE_UNKNOWN + assert hass.states.get(last_self_test_id) is not None + assert hass.states.get(last_self_test_id).state == STATE_UNKNOWN # Simulate an event (a self test) such that "LASTSTEST" field is being reported, the state of # the sensor should be properly updated with the corresponding value. @@ -259,7 +262,7 @@ async def test_sensor_unknown(hass: HomeAssistant) -> None: future = utcnow() + timedelta(minutes=2) async_fire_time_changed(hass, future) await hass.async_block_till_done() - assert hass.states.get("sensor.last_self_test").state == "1970-01-01 00:00:00 0000" + assert hass.states.get(last_self_test_id).state == "1970-01-01 00:00:00 0000" # Simulate another event (e.g., daemon restart) such that "LASTSTEST" is no longer reported. with patch("aioapcaccess.request_status") as mock_request_status: @@ -268,4 +271,4 @@ async def test_sensor_unknown(hass: HomeAssistant) -> None: async_fire_time_changed(hass, future) await hass.async_block_till_done() # The state should become unknown again. - assert hass.states.get("sensor.last_self_test").state == STATE_UNKNOWN + assert hass.states.get(last_self_test_id).state == STATE_UNKNOWN From a5013cddd56d4061082b2288a5bb07ec93071d0c Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Thu, 10 Apr 2025 16:46:30 +0200 Subject: [PATCH 429/505] Correct enum member check in home_connect (#142666) * Correct enum member check in home_connect * Update homeassistant/components/home_connect/coordinator.py Co-authored-by: Martin Hjelmare * Add mypy override --------- Co-authored-by: Martin Hjelmare --- homeassistant/components/home_connect/coordinator.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/home_connect/coordinator.py b/homeassistant/components/home_connect/coordinator.py index fb86bb2edc6..54dc24a6279 100644 --- a/homeassistant/components/home_connect/coordinator.py +++ b/homeassistant/components/home_connect/coordinator.py @@ -208,7 +208,7 @@ class HomeConnectCoordinator( events = self.data[event_message_ha_id].events for event in event_message.data.items: event_key = event.key - if event_key in SettingKey: + if event_key in SettingKey.__members__.values(): # type: ignore[comparison-overlap] setting_key = SettingKey(event_key) if setting_key in settings: settings[setting_key].value = event.value From a26cdef427695c41c9f43733248df4c323223270 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ab=C3=ADlio=20Costa?= Date: Thu, 10 Apr 2025 15:47:28 +0100 Subject: [PATCH 430/505] Refactor Whirlpool sensor tests (#142437) --- homeassistant/components/whirlpool/sensor.py | 47 +- .../components/whirlpool/strings.json | 2 + tests/components/whirlpool/__init__.py | 19 +- tests/components/whirlpool/conftest.py | 110 ++-- tests/components/whirlpool/const.py | 2 - .../whirlpool/snapshots/test_diagnostics.ambr | 12 +- .../whirlpool/snapshots/test_sensor.ambr | 374 ++++++++++++ tests/components/whirlpool/test_sensor.py | 566 ++++++++---------- 8 files changed, 747 insertions(+), 385 deletions(-) create mode 100644 tests/components/whirlpool/snapshots/test_sensor.ambr diff --git a/homeassistant/components/whirlpool/sensor.py b/homeassistant/components/whirlpool/sensor.py index 44d17228135..c41fda4197f 100644 --- a/homeassistant/components/whirlpool/sensor.py +++ b/homeassistant/components/whirlpool/sensor.py @@ -55,15 +55,12 @@ WASHER_DRYER_MACHINE_STATE = { MachineState.SystemInit: "system_initialize", } -WASHER_DRYER_CYCLE_FUNC = [ - (WasherDryer.get_cycle_status_filling, "cycle_filling"), - (WasherDryer.get_cycle_status_rinsing, "cycle_rinsing"), - (WasherDryer.get_cycle_status_sensing, "cycle_sensing"), - (WasherDryer.get_cycle_status_soaking, "cycle_soaking"), - (WasherDryer.get_cycle_status_spinning, "cycle_spinning"), - (WasherDryer.get_cycle_status_washing, "cycle_washing"), -] - +STATE_CYCLE_FILLING = "cycle_filling" +STATE_CYCLE_RINSING = "cycle_rinsing" +STATE_CYCLE_SENSING = "cycle_sensing" +STATE_CYCLE_SOAKING = "cycle_soaking" +STATE_CYCLE_SPINNING = "cycle_spinning" +STATE_CYCLE_WASHING = "cycle_washing" STATE_DOOR_OPEN = "door_open" @@ -76,9 +73,18 @@ def washer_dryer_state(washer_dryer: WasherDryer) -> str | None: machine_state = washer_dryer.get_machine_state() if machine_state == MachineState.RunningMainCycle: - for func, cycle_name in WASHER_DRYER_CYCLE_FUNC: - if func(washer_dryer): - return cycle_name + if washer_dryer.get_cycle_status_filling(): + return STATE_CYCLE_FILLING + if washer_dryer.get_cycle_status_rinsing(): + return STATE_CYCLE_RINSING + if washer_dryer.get_cycle_status_sensing(): + return STATE_CYCLE_SENSING + if washer_dryer.get_cycle_status_soaking(): + return STATE_CYCLE_SOAKING + if washer_dryer.get_cycle_status_spinning(): + return STATE_CYCLE_SPINNING + if washer_dryer.get_cycle_status_washing(): + return STATE_CYCLE_WASHING return WASHER_DRYER_MACHINE_STATE.get(machine_state) @@ -90,11 +96,16 @@ class WhirlpoolSensorEntityDescription(SensorEntityDescription): value_fn: Callable[[Appliance], str | None] -WASHER_DRYER_STATE_OPTIONS = ( - list(WASHER_DRYER_MACHINE_STATE.values()) - + [value for _, value in WASHER_DRYER_CYCLE_FUNC] - + [STATE_DOOR_OPEN] -) +WASHER_DRYER_STATE_OPTIONS = [ + *WASHER_DRYER_MACHINE_STATE.values(), + STATE_CYCLE_FILLING, + STATE_CYCLE_RINSING, + STATE_CYCLE_SENSING, + STATE_CYCLE_SOAKING, + STATE_CYCLE_SPINNING, + STATE_CYCLE_WASHING, + STATE_DOOR_OPEN, +] WASHER_SENSORS: tuple[WhirlpoolSensorEntityDescription, ...] = ( WhirlpoolSensorEntityDescription( @@ -221,9 +232,7 @@ class WasherDryerTimeSensor(WhirlpoolEntity, RestoreSensor): if machine_state is MachineState.RunningMainCycle: self._running = True - new_timestamp = now + timedelta(seconds=self._wd.get_time_remaining()) - if self._value is None or ( isinstance(self._value, datetime) and abs(new_timestamp - self._value) > timedelta(seconds=60) diff --git a/homeassistant/components/whirlpool/strings.json b/homeassistant/components/whirlpool/strings.json index 56fee795237..1cb5344b238 100644 --- a/homeassistant/components/whirlpool/strings.json +++ b/homeassistant/components/whirlpool/strings.json @@ -44,6 +44,7 @@ "entity": { "sensor": { "washer_state": { + "name": "State", "state": { "standby": "[%key:common::state::standby%]", "setting": "Setting", @@ -74,6 +75,7 @@ } }, "dryer_state": { + "name": "[%key:component::whirlpool::entity::sensor::washer_state::name%]", "state": { "standby": "[%key:common::state::standby%]", "setting": "[%key:component::whirlpool::entity::sensor::washer_state::state::setting%]", diff --git a/tests/components/whirlpool/__init__.py b/tests/components/whirlpool/__init__.py index 97d9b4d61d5..ef589092a4b 100644 --- a/tests/components/whirlpool/__init__.py +++ b/tests/components/whirlpool/__init__.py @@ -1,8 +1,11 @@ """Tests for the Whirlpool Sixth Sense integration.""" +from syrupy import SnapshotAssertion + from homeassistant.components.whirlpool.const import CONF_BRAND, DOMAIN -from homeassistant.const import CONF_PASSWORD, CONF_REGION, CONF_USERNAME +from homeassistant.const import CONF_PASSWORD, CONF_REGION, CONF_USERNAME, Platform from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_registry import EntityRegistry from tests.common import MockConfigEntry @@ -32,3 +35,17 @@ async def init_integration_with_entry( await hass.config_entries.async_setup(entry.entry_id) await hass.async_block_till_done() return entry + + +def snapshot_whirlpool_entities( + hass: HomeAssistant, + entity_registry: EntityRegistry, + snapshot: SnapshotAssertion, + platform: Platform, +) -> None: + """Snapshot Whirlpool entities.""" + entities = hass.states.async_all(platform) + for entity_state in entities: + entity_entry = entity_registry.async_get(entity_state.entity_id) + assert entity_entry == snapshot(name=f"{entity_entry.entity_id}-entry") + assert entity_state == snapshot(name=f"{entity_entry.entity_id}-state") diff --git a/tests/components/whirlpool/conftest.py b/tests/components/whirlpool/conftest.py index 5d063f02924..3d5680cb785 100644 --- a/tests/components/whirlpool/conftest.py +++ b/tests/components/whirlpool/conftest.py @@ -4,11 +4,10 @@ from unittest import mock from unittest.mock import AsyncMock, MagicMock import pytest -import whirlpool -import whirlpool.aircon +from whirlpool import aircon, washerdryer from whirlpool.backendselector import Brand, Region -from .const import MOCK_SAID1, MOCK_SAID2, MOCK_SAID3, MOCK_SAID4 +from .const import MOCK_SAID1, MOCK_SAID2 @pytest.fixture( @@ -49,7 +48,7 @@ def fixture_mock_auth_api(): @pytest.fixture(name="mock_appliances_manager_api", autouse=True) def fixture_mock_appliances_manager_api( - mock_aircon1_api, mock_aircon2_api, mock_sensor1_api, mock_sensor2_api + mock_aircon1_api, mock_aircon2_api, mock_washer_api, mock_dryer_api ): """Set up AppliancesManager fixture.""" with ( @@ -69,8 +68,8 @@ def fixture_mock_appliances_manager_api( mock_aircon2_api, ] mock_appliances_manager.return_value.washer_dryers = [ - mock_sensor1_api, - mock_sensor2_api, + mock_washer_api, + mock_dryer_api, ] yield mock_appliances_manager @@ -100,8 +99,8 @@ def get_aircon_mock(said): mock_aircon.appliance_info.model_number = "12345" mock_aircon.get_online.return_value = True mock_aircon.get_power_on.return_value = True - mock_aircon.get_mode.return_value = whirlpool.aircon.Mode.Cool - mock_aircon.get_fanspeed.return_value = whirlpool.aircon.FanSpeed.Auto + mock_aircon.get_mode.return_value = aircon.Mode.Cool + mock_aircon.get_fanspeed.return_value = aircon.FanSpeed.Auto mock_aircon.get_current_temp.return_value = 15 mock_aircon.get_temp.return_value = 20 mock_aircon.get_current_humidity.return_value = 80 @@ -141,53 +140,64 @@ def fixture_mock_aircon_api_instances(mock_aircon1_api, mock_aircon2_api): yield mock_aircon_api -def get_sensor_mock(said: str, data_model: str): - """Get a mock of a sensor.""" - mock_sensor = mock.Mock(said=said) - mock_sensor.name = f"WasherDryer {said}" - mock_sensor.register_attr_callback = MagicMock() - mock_sensor.appliance_info.data_model = data_model - mock_sensor.appliance_info.category = "washer_dryer" - mock_sensor.appliance_info.model_number = "12345" - mock_sensor.get_online.return_value = True - mock_sensor.get_machine_state.return_value = ( - whirlpool.washerdryer.MachineState.Standby +@pytest.fixture +def mock_washer_api(): + """Get a mock of a washer.""" + mock_washer = mock.Mock(said="said_washer") + mock_washer.name = "Washer" + mock_washer.fetch_data = AsyncMock() + mock_washer.register_attr_callback = MagicMock() + mock_washer.appliance_info.data_model = "washer" + mock_washer.appliance_info.category = "washer_dryer" + mock_washer.appliance_info.model_number = "12345" + mock_washer.get_online.return_value = True + mock_washer.get_machine_state.return_value = ( + washerdryer.MachineState.RunningMainCycle ) - mock_sensor.get_door_open.return_value = False - mock_sensor.get_dispense_1_level.return_value = 3 - mock_sensor.get_time_remaining.return_value = 3540 - mock_sensor.get_cycle_status_filling.return_value = False - mock_sensor.get_cycle_status_rinsing.return_value = False - mock_sensor.get_cycle_status_sensing.return_value = False - mock_sensor.get_cycle_status_soaking.return_value = False - mock_sensor.get_cycle_status_spinning.return_value = False - mock_sensor.get_cycle_status_washing.return_value = False + mock_washer.get_door_open.return_value = False + mock_washer.get_dispense_1_level.return_value = 3 + mock_washer.get_time_remaining.return_value = 3540 + mock_washer.get_cycle_status_filling.return_value = False + mock_washer.get_cycle_status_rinsing.return_value = False + mock_washer.get_cycle_status_sensing.return_value = False + mock_washer.get_cycle_status_soaking.return_value = False + mock_washer.get_cycle_status_spinning.return_value = False + mock_washer.get_cycle_status_washing.return_value = False - return mock_sensor + return mock_washer -@pytest.fixture(name="mock_sensor1_api", autouse=False) -def fixture_mock_sensor1_api(): - """Set up sensor API fixture.""" - return get_sensor_mock(MOCK_SAID3, "washer") +@pytest.fixture +def mock_dryer_api(): + """Get a mock of a dryer.""" + mock_dryer = mock.Mock(said="said_dryer") + mock_dryer.name = "Dryer" + mock_dryer.fetch_data = AsyncMock() + mock_dryer.register_attr_callback = MagicMock() + mock_dryer.appliance_info.data_model = "dryer" + mock_dryer.appliance_info.category = "washer_dryer" + mock_dryer.appliance_info.model_number = "12345" + mock_dryer.get_online.return_value = True + mock_dryer.get_machine_state.return_value = ( + washerdryer.MachineState.RunningMainCycle + ) + mock_dryer.get_door_open.return_value = False + mock_dryer.get_time_remaining.return_value = 3540 + mock_dryer.get_cycle_status_filling.return_value = False + mock_dryer.get_cycle_status_rinsing.return_value = False + mock_dryer.get_cycle_status_sensing.return_value = False + mock_dryer.get_cycle_status_soaking.return_value = False + mock_dryer.get_cycle_status_spinning.return_value = False + mock_dryer.get_cycle_status_washing.return_value = False + + return mock_dryer -@pytest.fixture(name="mock_sensor2_api", autouse=False) -def fixture_mock_sensor2_api(): - """Set up sensor API fixture.""" - return get_sensor_mock(MOCK_SAID4, "dryer") - - -@pytest.fixture(name="mock_sensor_api_instances", autouse=False) -def fixture_mock_sensor_api_instances(mock_sensor1_api, mock_sensor2_api): - """Set up sensor API fixture.""" +@pytest.fixture(autouse=True) +def mock_washer_dryer_api_instances(mock_washer_api, mock_dryer_api): + """Set up WasherDryer API fixture.""" with mock.patch( "homeassistant.components.whirlpool.sensor.WasherDryer" - ) as mock_sensor_api: - mock_sensor_api.side_effect = [ - mock_sensor1_api, - mock_sensor2_api, - mock_sensor1_api, - mock_sensor2_api, - ] - yield mock_sensor_api + ) as mock_washer_dryer_api: + mock_washer_dryer_api.side_effect = [mock_washer_api, mock_dryer_api] + yield mock_washer_dryer_api diff --git a/tests/components/whirlpool/const.py b/tests/components/whirlpool/const.py index 04ea5c0645c..f7348ba4641 100644 --- a/tests/components/whirlpool/const.py +++ b/tests/components/whirlpool/const.py @@ -2,5 +2,3 @@ MOCK_SAID1 = "said1" MOCK_SAID2 = "said2" -MOCK_SAID3 = "said3" -MOCK_SAID4 = "said4" diff --git a/tests/components/whirlpool/snapshots/test_diagnostics.ambr b/tests/components/whirlpool/snapshots/test_diagnostics.ambr index 7294e914f51..f1eef6f7dfc 100644 --- a/tests/components/whirlpool/snapshots/test_diagnostics.ambr +++ b/tests/components/whirlpool/snapshots/test_diagnostics.ambr @@ -17,16 +17,16 @@ 'ovens': dict({ }), 'washer_dryers': dict({ - 'WasherDryer said3': dict({ - 'category': 'washer_dryer', - 'data_model': 'washer', - 'model_number': '12345', - }), - 'WasherDryer said4': dict({ + 'Dryer': dict({ 'category': 'washer_dryer', 'data_model': 'dryer', 'model_number': '12345', }), + 'Washer': dict({ + 'category': 'washer_dryer', + 'data_model': 'washer', + 'model_number': '12345', + }), }), }), 'config_entry': dict({ diff --git a/tests/components/whirlpool/snapshots/test_sensor.ambr b/tests/components/whirlpool/snapshots/test_sensor.ambr new file mode 100644 index 00000000000..a422fc02158 --- /dev/null +++ b/tests/components/whirlpool/snapshots/test_sensor.ambr @@ -0,0 +1,374 @@ +# serializer version: 1 +# name: test_all_entities[sensor.dryer_end_time-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.dryer_end_time', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': 'mdi:progress-clock', + 'original_name': 'End time', + 'platform': 'whirlpool', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'end_time', + 'unique_id': 'said_dryer-timeremaining', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[sensor.dryer_end_time-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'timestamp', + 'friendly_name': 'Dryer End time', + 'icon': 'mdi:progress-clock', + }), + 'context': , + 'entity_id': 'sensor.dryer_end_time', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '2025-05-04T12:59:00+00:00', + }) +# --- +# name: test_all_entities[sensor.dryer_state-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'standby', + 'setting', + 'delay_countdown', + 'delay_paused', + 'smart_delay', + 'smart_grid_pause', + 'pause', + 'running_maincycle', + 'running_postcycle', + 'exception', + 'complete', + 'power_failure', + 'service_diagnostic_mode', + 'factory_diagnostic_mode', + 'life_test', + 'customer_focus_mode', + 'demo_mode', + 'hard_stop_or_error', + 'system_initialize', + 'cycle_filling', + 'cycle_rinsing', + 'cycle_sensing', + 'cycle_soaking', + 'cycle_spinning', + 'cycle_washing', + 'door_open', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.dryer_state', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'State', + 'platform': 'whirlpool', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'dryer_state', + 'unique_id': 'said_dryer-state', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[sensor.dryer_state-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'enum', + 'friendly_name': 'Dryer State', + 'options': list([ + 'standby', + 'setting', + 'delay_countdown', + 'delay_paused', + 'smart_delay', + 'smart_grid_pause', + 'pause', + 'running_maincycle', + 'running_postcycle', + 'exception', + 'complete', + 'power_failure', + 'service_diagnostic_mode', + 'factory_diagnostic_mode', + 'life_test', + 'customer_focus_mode', + 'demo_mode', + 'hard_stop_or_error', + 'system_initialize', + 'cycle_filling', + 'cycle_rinsing', + 'cycle_sensing', + 'cycle_soaking', + 'cycle_spinning', + 'cycle_washing', + 'door_open', + ]), + }), + 'context': , + 'entity_id': 'sensor.dryer_state', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'running_maincycle', + }) +# --- +# name: test_all_entities[sensor.washer_detergent_level-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'unknown', + 'empty', + '25', + '50', + '100', + 'active', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.washer_detergent_level', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Detergent level', + 'platform': 'whirlpool', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'whirlpool_tank', + 'unique_id': 'said_washer-DispenseLevel', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[sensor.washer_detergent_level-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'enum', + 'friendly_name': 'Washer Detergent level', + 'options': list([ + 'unknown', + 'empty', + '25', + '50', + '100', + 'active', + ]), + }), + 'context': , + 'entity_id': 'sensor.washer_detergent_level', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '50', + }) +# --- +# name: test_all_entities[sensor.washer_end_time-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.washer_end_time', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': 'mdi:progress-clock', + 'original_name': 'End time', + 'platform': 'whirlpool', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'end_time', + 'unique_id': 'said_washer-timeremaining', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[sensor.washer_end_time-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'timestamp', + 'friendly_name': 'Washer End time', + 'icon': 'mdi:progress-clock', + }), + 'context': , + 'entity_id': 'sensor.washer_end_time', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '2025-05-04T12:59:00+00:00', + }) +# --- +# name: test_all_entities[sensor.washer_state-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'standby', + 'setting', + 'delay_countdown', + 'delay_paused', + 'smart_delay', + 'smart_grid_pause', + 'pause', + 'running_maincycle', + 'running_postcycle', + 'exception', + 'complete', + 'power_failure', + 'service_diagnostic_mode', + 'factory_diagnostic_mode', + 'life_test', + 'customer_focus_mode', + 'demo_mode', + 'hard_stop_or_error', + 'system_initialize', + 'cycle_filling', + 'cycle_rinsing', + 'cycle_sensing', + 'cycle_soaking', + 'cycle_spinning', + 'cycle_washing', + 'door_open', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.washer_state', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'State', + 'platform': 'whirlpool', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'washer_state', + 'unique_id': 'said_washer-state', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[sensor.washer_state-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'enum', + 'friendly_name': 'Washer State', + 'options': list([ + 'standby', + 'setting', + 'delay_countdown', + 'delay_paused', + 'smart_delay', + 'smart_grid_pause', + 'pause', + 'running_maincycle', + 'running_postcycle', + 'exception', + 'complete', + 'power_failure', + 'service_diagnostic_mode', + 'factory_diagnostic_mode', + 'life_test', + 'customer_focus_mode', + 'demo_mode', + 'hard_stop_or_error', + 'system_initialize', + 'cycle_filling', + 'cycle_rinsing', + 'cycle_sensing', + 'cycle_soaking', + 'cycle_spinning', + 'cycle_washing', + 'door_open', + ]), + }), + 'context': , + 'entity_id': 'sensor.washer_state', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'running_maincycle', + }) +# --- diff --git a/tests/components/whirlpool/test_sensor.py b/tests/components/whirlpool/test_sensor.py index 43a5421391b..0c097d07296 100644 --- a/tests/components/whirlpool/test_sensor.py +++ b/tests/components/whirlpool/test_sensor.py @@ -1,346 +1,298 @@ """Test the Whirlpool Sensor domain.""" -from datetime import UTC, datetime +from datetime import UTC, datetime, timedelta from unittest.mock import MagicMock import pytest +from syrupy import SnapshotAssertion from whirlpool.washerdryer import MachineState from homeassistant.components.whirlpool.sensor import SCAN_INTERVAL -from homeassistant.core import CoreState, HomeAssistant, State +from homeassistant.const import STATE_UNKNOWN, Platform +from homeassistant.core import HomeAssistant, State from homeassistant.helpers import entity_registry as er from homeassistant.util.dt import as_timestamp, utc_from_timestamp, utcnow -from . import init_integration -from .const import MOCK_SAID3, MOCK_SAID4 +from . import init_integration, snapshot_whirlpool_entities from tests.common import async_fire_time_changed, mock_restore_cache_with_extra_data +WASHER_ENTITY_ID_BASE = "sensor.washer" +DRYER_ENTITY_ID_BASE = "sensor.dryer" -async def update_sensor_state( - hass: HomeAssistant, entity_id: str, mock_sensor_api_instance: MagicMock + +async def trigger_attr_callback( + hass: HomeAssistant, mock_api_instance: MagicMock ) -> State: """Simulate an update trigger from the API.""" - for call in mock_sensor_api_instance.register_attr_callback.call_args_list: + for call in mock_api_instance.register_attr_callback.call_args_list: update_ha_state_cb = call[0][0] update_ha_state_cb() - await hass.async_block_till_done() - - return hass.states.get(entity_id) - - -async def test_dryer_sensor_values( - hass: HomeAssistant, mock_sensor2_api: MagicMock, entity_registry: er.EntityRegistry -) -> None: - """Test the sensor value callbacks.""" - hass.set_state(CoreState.not_running) - thetimestamp: datetime = datetime(2022, 11, 29, 00, 00, 00, 00, UTC) - mock_restore_cache_with_extra_data( - hass, - ( - ( - State(f"sensor.washerdryer_{MOCK_SAID3}_end_time", "1"), - {"native_value": thetimestamp, "native_unit_of_measurement": None}, - ), - ( - State(f"sensor.washerdryer_{MOCK_SAID4}_end_time", "1"), - {"native_value": thetimestamp, "native_unit_of_measurement": None}, - ), - ), - ) - - await init_integration(hass) - - entity_id = f"sensor.washerdryer_{MOCK_SAID4}_none" - mock_instance = mock_sensor2_api - entry = entity_registry.async_get(entity_id) - assert entry - state = hass.states.get(entity_id) - assert state is not None - assert state.state == "standby" - - state = await update_sensor_state(hass, entity_id, mock_instance) - assert state is not None - state_id = f"sensor.washerdryer_{MOCK_SAID3}_end_time" - state = hass.states.get(state_id) - assert state.state == thetimestamp.isoformat() - - mock_instance.get_machine_state.return_value = MachineState.RunningMainCycle - mock_instance.get_cycle_status_filling.return_value = False - mock_instance.attr_value_to_bool.side_effect = [ - False, - False, - False, - False, - False, - False, - ] - - state = await update_sensor_state(hass, entity_id, mock_instance) - assert state is not None - assert state.state == "running_maincycle" - - mock_instance.get_machine_state.return_value = MachineState.Complete - - state = await update_sensor_state(hass, entity_id, mock_instance) - assert state is not None - assert state.state == "complete" - - -async def test_washer_sensor_values( - hass: HomeAssistant, mock_sensor1_api: MagicMock, entity_registry: er.EntityRegistry -) -> None: - """Test the sensor value callbacks.""" - hass.set_state(CoreState.not_running) - thetimestamp: datetime = datetime(2022, 11, 29, 00, 00, 00, 00, UTC) - mock_restore_cache_with_extra_data( - hass, - ( - ( - State(f"sensor.washerdryer_{MOCK_SAID3}_end_time", "1"), - {"native_value": thetimestamp, "native_unit_of_measurement": None}, - ), - ( - State(f"sensor.washerdryer_{MOCK_SAID4}_end_time", "1"), - {"native_value": thetimestamp, "native_unit_of_measurement": None}, - ), - ), - ) - - await init_integration(hass) - - async_fire_time_changed( - hass, - utcnow() + SCAN_INTERVAL, - ) await hass.async_block_till_done() - entity_id = f"sensor.washerdryer_{MOCK_SAID3}_none" - mock_instance = mock_sensor1_api - entry = entity_registry.async_get(entity_id) - assert entry - state = hass.states.get(entity_id) - assert state is not None - assert state.state == "standby" - state = await update_sensor_state(hass, entity_id, mock_instance) - assert state is not None - state_id = f"sensor.washerdryer_{MOCK_SAID3}_end_time" - state = hass.states.get(state_id) - assert state.state == thetimestamp.isoformat() - - state_id = f"sensor.washerdryer_{MOCK_SAID3}_detergent_level" - entry = entity_registry.async_get(state_id) - assert entry - assert entry.disabled - assert entry.disabled_by is er.RegistryEntryDisabler.INTEGRATION - - update_entry = entity_registry.async_update_entity( - entry.entity_id, disabled_by=None - ) - await hass.async_block_till_done() - - assert update_entry != entry - assert update_entry.disabled is False - state = hass.states.get(state_id) - assert state is None - - await hass.config_entries.async_reload(entry.config_entry_id) - state = hass.states.get(state_id) - assert state is not None - assert state.state == "50" - - # Test the washer cycle states - mock_instance.get_machine_state.return_value = MachineState.RunningMainCycle - mock_instance.get_cycle_status_filling.return_value = True - mock_instance.attr_value_to_bool.side_effect = [ - True, - False, - False, - False, - False, - False, - ] - - state = await update_sensor_state(hass, entity_id, mock_instance) - assert state is not None - assert state.state == "cycle_filling" - - mock_instance.get_cycle_status_filling.return_value = False - mock_instance.get_cycle_status_rinsing.return_value = True - mock_instance.attr_value_to_bool.side_effect = [ - False, - True, - False, - False, - False, - False, - ] - - state = await update_sensor_state(hass, entity_id, mock_instance) - assert state is not None - assert state.state == "cycle_rinsing" - - mock_instance.get_cycle_status_rinsing.return_value = False - mock_instance.get_cycle_status_sensing.return_value = True - mock_instance.attr_value_to_bool.side_effect = [ - False, - False, - True, - False, - False, - False, - ] - - state = await update_sensor_state(hass, entity_id, mock_instance) - assert state is not None - assert state.state == "cycle_sensing" - - mock_instance.get_cycle_status_sensing.return_value = False - mock_instance.get_cycle_status_soaking.return_value = True - mock_instance.attr_value_to_bool.side_effect = [ - False, - False, - False, - True, - False, - False, - ] - - state = await update_sensor_state(hass, entity_id, mock_instance) - assert state is not None - assert state.state == "cycle_soaking" - - mock_instance.get_cycle_status_soaking.return_value = False - mock_instance.get_cycle_status_spinning.return_value = True - mock_instance.attr_value_to_bool.side_effect = [ - False, - False, - False, - False, - True, - False, - ] - - state = await update_sensor_state(hass, entity_id, mock_instance) - assert state is not None - assert state.state == "cycle_spinning" - - mock_instance.get_cycle_status_spinning.return_value = False - mock_instance.get_cycle_status_washing.return_value = True - mock_instance.attr_value_to_bool.side_effect = [ - False, - False, - False, - False, - False, - True, - ] - - state = await update_sensor_state(hass, entity_id, mock_instance) - assert state is not None - assert state.state == "cycle_washing" - - mock_instance.get_machine_state.return_value = MachineState.Complete - mock_instance.attr_value_to_bool.side_effect = None - mock_instance.get_door_open.return_value = True - state = await update_sensor_state(hass, entity_id, mock_instance) - assert state is not None - assert state.state == "door_open" - - -async def test_restore_state(hass: HomeAssistant) -> None: - """Test sensor restore state.""" - # Home assistant is not running yet - hass.set_state(CoreState.not_running) - thetimestamp: datetime = datetime(2022, 11, 29, 00, 00, 00, 00, UTC) - mock_restore_cache_with_extra_data( - hass, - ( - ( - State(f"sensor.washerdryer_{MOCK_SAID3}_end_time", "1"), - {"native_value": thetimestamp, "native_unit_of_measurement": None}, - ), - ( - State(f"sensor.washerdryer_{MOCK_SAID4}_end_time", "1"), - {"native_value": thetimestamp, "native_unit_of_measurement": None}, - ), - ), - ) - - # create and add entry - await init_integration(hass) - # restore from cache - state = hass.states.get(f"sensor.washerdryer_{MOCK_SAID3}_end_time") - assert state.state == thetimestamp.isoformat() - state = hass.states.get(f"sensor.washerdryer_{MOCK_SAID4}_end_time") - assert state.state == thetimestamp.isoformat() - - -async def test_no_restore_state( - hass: HomeAssistant, mock_sensor1_api: MagicMock +# Freeze time for WasherDryerTimeSensor +@pytest.mark.freeze_time("2025-05-04 12:00:00") +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +async def test_all_entities( + hass: HomeAssistant, + snapshot: SnapshotAssertion, + entity_registry: er.EntityRegistry, ) -> None: - """Test sensor restore state with no restore.""" - # create and add entry - entity_id = f"sensor.washerdryer_{MOCK_SAID3}_end_time" + """Test all entities.""" await init_integration(hass) - # restore from cache - state = hass.states.get(entity_id) - assert state.state == "unknown" - - mock_sensor1_api.get_machine_state.return_value = MachineState.RunningMainCycle - state = await update_sensor_state(hass, entity_id, mock_sensor1_api) - assert state.state != "unknown" + snapshot_whirlpool_entities(hass, entity_registry, snapshot, Platform.SENSOR) +@pytest.mark.parametrize( + ("entity_id", "mock_fixture"), + [ + ("sensor.washer_end_time", "mock_washer_api"), + ("sensor.dryer_end_time", "mock_dryer_api"), + ], +) @pytest.mark.freeze_time("2022-11-30 00:00:00") -async def test_callback(hass: HomeAssistant, mock_sensor1_api: MagicMock) -> None: - """Test callback timestamp callback function.""" - hass.set_state(CoreState.not_running) - thetimestamp: datetime = datetime(2022, 11, 29, 00, 00, 00, 00, UTC) +async def test_washer_dryer_time_sensor( + hass: HomeAssistant, + entity_id: str, + mock_fixture: str, + request: pytest.FixtureRequest, +) -> None: + """Test Washer/Dryer end time sensors.""" + now = utcnow() + restored_datetime: datetime = datetime(2022, 11, 29, 00, 00, 00, 00, UTC) mock_restore_cache_with_extra_data( hass, - ( + [ ( - State(f"sensor.washerdryer_{MOCK_SAID3}_end_time", "1"), - {"native_value": thetimestamp, "native_unit_of_measurement": None}, - ), - ( - State(f"sensor.washerdryer_{MOCK_SAID4}_end_time", "1"), - {"native_value": thetimestamp, "native_unit_of_measurement": None}, - ), - ), + State(entity_id, "1"), + {"native_value": restored_datetime, "native_unit_of_measurement": None}, + ) + ], ) - # create and add entry + mock_instance = request.getfixturevalue(mock_fixture) + mock_instance.get_machine_state.return_value = MachineState.Pause await init_integration(hass) - # restore from cache - state = hass.states.get(f"sensor.washerdryer_{MOCK_SAID3}_end_time") - assert state.state == thetimestamp.isoformat() - callback = mock_sensor1_api.register_attr_callback.call_args_list[1][0][0] - callback() - state = hass.states.get(f"sensor.washerdryer_{MOCK_SAID3}_end_time") - assert state.state == thetimestamp.isoformat() - mock_sensor1_api.get_machine_state.return_value = MachineState.RunningMainCycle - mock_sensor1_api.get_time_remaining.return_value = 60 - callback() + # Test restored state. + state = hass.states.get(entity_id) + assert state.state == restored_datetime.isoformat() - # Test new timestamp when machine starts a cycle. - state = hass.states.get(f"sensor.washerdryer_{MOCK_SAID3}_end_time") - time = state.state - assert state.state != thetimestamp.isoformat() + # Test no time change because the machine is not running. + await trigger_attr_callback(hass, mock_instance) - # Test no timestamp change for < 60 seconds time change. - mock_sensor1_api.get_time_remaining.return_value = 65 - callback() - state = hass.states.get(f"sensor.washerdryer_{MOCK_SAID3}_end_time") - assert state.state == time + state = hass.states.get(entity_id) + assert state.state == restored_datetime.isoformat() + + # Test new time when machine starts a cycle. + mock_instance.get_machine_state.return_value = MachineState.RunningMainCycle + mock_instance.get_time_remaining.return_value = 60 + await trigger_attr_callback(hass, mock_instance) + + state = hass.states.get(entity_id) + expected_time = (now + timedelta(seconds=60)).isoformat() + assert state.state == expected_time + + # Test no state change for < 60 seconds elapsed time. + mock_instance.get_time_remaining.return_value = 65 + await trigger_attr_callback(hass, mock_instance) + + state = hass.states.get(entity_id) + assert state.state == expected_time # Test timestamp change for > 60 seconds. - mock_sensor1_api.get_time_remaining.return_value = 125 - callback() - state = hass.states.get(f"sensor.washerdryer_{MOCK_SAID3}_end_time") - newtime = utc_from_timestamp(as_timestamp(time) + 65) - assert state.state == newtime.isoformat() + mock_instance.get_time_remaining.return_value = 125 + await trigger_attr_callback(hass, mock_instance) + + state = hass.states.get(entity_id) + assert ( + state.state == utc_from_timestamp(as_timestamp(expected_time) + 65).isoformat() + ) + + # Test that periodic updates call the API to fetch data + mock_instance.fetch_data.reset_mock() + async_fire_time_changed(hass, utcnow() + SCAN_INTERVAL) + await hass.async_block_till_done() + mock_instance.fetch_data.assert_called_once() + + +@pytest.mark.parametrize( + ("entity_id", "mock_fixture"), + [ + ("sensor.washer_end_time", "mock_washer_api"), + ("sensor.dryer_end_time", "mock_dryer_api"), + ], +) +@pytest.mark.freeze_time("2022-11-30 00:00:00") +async def test_washer_dryer_time_sensor_no_restore( + hass: HomeAssistant, + entity_id: str, + mock_fixture: str, + request: pytest.FixtureRequest, +) -> None: + """Test Washer/Dryer end time sensors without state restore.""" + now = utcnow() + + mock_instance = request.getfixturevalue(mock_fixture) + mock_instance.get_machine_state.return_value = MachineState.Pause + await init_integration(hass) + + state = hass.states.get(entity_id) + assert state.state == STATE_UNKNOWN + + # Test no change because the machine is paused. + await trigger_attr_callback(hass, mock_instance) + + state = hass.states.get(entity_id) + assert state.state == STATE_UNKNOWN + + # Test new time when machine starts a cycle. + mock_instance.get_machine_state.return_value = MachineState.RunningMainCycle + mock_instance.get_time_remaining.return_value = 60 + await trigger_attr_callback(hass, mock_instance) + + state = hass.states.get(entity_id) + expected_time = (now + timedelta(seconds=60)).isoformat() + assert state.state == expected_time + + +@pytest.mark.parametrize( + ("entity_id", "mock_fixture"), + [ + ("sensor.washer_state", "mock_washer_api"), + ("sensor.dryer_state", "mock_dryer_api"), + ], +) +@pytest.mark.parametrize( + ("machine_state", "expected_state"), + [ + (MachineState.Standby, "standby"), + (MachineState.Setting, "setting"), + (MachineState.DelayCountdownMode, "delay_countdown"), + (MachineState.DelayPause, "delay_paused"), + (MachineState.SmartDelay, "smart_delay"), + (MachineState.SmartGridPause, "smart_grid_pause"), + (MachineState.Pause, "pause"), + (MachineState.RunningMainCycle, "running_maincycle"), + (MachineState.RunningPostCycle, "running_postcycle"), + (MachineState.Exceptions, "exception"), + (MachineState.Complete, "complete"), + (MachineState.PowerFailure, "power_failure"), + (MachineState.ServiceDiagnostic, "service_diagnostic_mode"), + (MachineState.FactoryDiagnostic, "factory_diagnostic_mode"), + (MachineState.LifeTest, "life_test"), + (MachineState.CustomerFocusMode, "customer_focus_mode"), + (MachineState.DemoMode, "demo_mode"), + (MachineState.HardStopOrError, "hard_stop_or_error"), + (MachineState.SystemInit, "system_initialize"), + ], +) +async def test_washer_dryer_machine_states( + hass: HomeAssistant, + entity_id: str, + mock_fixture: str, + machine_state: MachineState, + expected_state: str, + request: pytest.FixtureRequest, +) -> None: + """Test Washer/Dryer machine states.""" + mock_instance = request.getfixturevalue(mock_fixture) + await init_integration(hass) + + mock_instance.get_machine_state.return_value = machine_state + await trigger_attr_callback(hass, mock_instance) + state = hass.states.get(entity_id) + assert state is not None + assert state.state == expected_state + + +@pytest.mark.parametrize( + ("entity_id", "mock_fixture"), + [ + ("sensor.washer_state", "mock_washer_api"), + ("sensor.dryer_state", "mock_dryer_api"), + ], +) +@pytest.mark.parametrize( + ( + "filling", + "rinsing", + "sensing", + "soaking", + "spinning", + "washing", + "expected_state", + ), + [ + (True, False, False, False, False, False, "cycle_filling"), + (False, True, False, False, False, False, "cycle_rinsing"), + (False, False, True, False, False, False, "cycle_sensing"), + (False, False, False, True, False, False, "cycle_soaking"), + (False, False, False, False, True, False, "cycle_spinning"), + (False, False, False, False, False, True, "cycle_washing"), + ], +) +async def test_washer_dryer_running_states( + hass: HomeAssistant, + entity_id: str, + mock_fixture: str, + filling: bool, + rinsing: bool, + sensing: bool, + soaking: bool, + spinning: bool, + washing: bool, + expected_state: str, + request: pytest.FixtureRequest, +) -> None: + """Test Washer/Dryer machine states for RunningMainCycle.""" + mock_instance = request.getfixturevalue(mock_fixture) + await init_integration(hass) + + mock_instance.get_machine_state.return_value = MachineState.RunningMainCycle + mock_instance.get_cycle_status_filling.return_value = filling + mock_instance.get_cycle_status_rinsing.return_value = rinsing + mock_instance.get_cycle_status_sensing.return_value = sensing + mock_instance.get_cycle_status_soaking.return_value = soaking + mock_instance.get_cycle_status_spinning.return_value = spinning + mock_instance.get_cycle_status_washing.return_value = washing + + await trigger_attr_callback(hass, mock_instance) + state = hass.states.get(entity_id) + assert state is not None + assert state.state == expected_state + + +@pytest.mark.parametrize( + ("entity_id", "mock_fixture"), + [ + ("sensor.washer_state", "mock_washer_api"), + ("sensor.dryer_state", "mock_dryer_api"), + ], +) +async def test_washer_dryer_door_open_state( + hass: HomeAssistant, + entity_id: str, + mock_fixture: str, + request: pytest.FixtureRequest, +) -> None: + """Test Washer/Dryer machine state when door is open.""" + mock_instance = request.getfixturevalue(mock_fixture) + await init_integration(hass) + + state = hass.states.get(entity_id) + assert state.state == "running_maincycle" + + mock_instance.get_door_open.return_value = True + + await trigger_attr_callback(hass, mock_instance) + state = hass.states.get(entity_id) + assert state.state == "door_open" + + mock_instance.get_door_open.return_value = False + + await trigger_attr_callback(hass, mock_instance) + state = hass.states.get(entity_id) + assert state.state == "running_maincycle" From eee6e8a2c3fb503b457908926a65a4659d17e321 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Thu, 10 Apr 2025 16:58:46 +0200 Subject: [PATCH 431/505] Add WS command config_entries/flow/subscribe (#142459) --- .../components/config/config_entries.py | 65 ++++- homeassistant/components/onboarding/views.py | 32 ++- homeassistant/config_entries.py | 31 +++ .../components/config/test_config_entries.py | 251 ++++++++++++++++++ tests/components/onboarding/test_views.py | 83 +++++- 5 files changed, 458 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/config/config_entries.py b/homeassistant/components/config/config_entries.py index 74c9b5a9d0c..6e2d4a5da49 100644 --- a/homeassistant/components/config/config_entries.py +++ b/homeassistant/components/config/config_entries.py @@ -58,7 +58,8 @@ def async_setup(hass: HomeAssistant) -> bool: websocket_api.async_register_command(hass, config_entry_get_single) websocket_api.async_register_command(hass, config_entry_update) websocket_api.async_register_command(hass, config_entries_subscribe) - websocket_api.async_register_command(hass, config_entries_progress) + websocket_api.async_register_command(hass, config_entries_flow_progress) + websocket_api.async_register_command(hass, config_entries_flow_subscribe) websocket_api.async_register_command(hass, ignore_config_flow) websocket_api.async_register_command(hass, config_subentry_delete) @@ -357,7 +358,7 @@ class SubentryManagerFlowResourceView( @websocket_api.require_admin @websocket_api.websocket_command({"type": "config_entries/flow/progress"}) -def config_entries_progress( +def config_entries_flow_progress( hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg: dict[str, Any], @@ -378,6 +379,66 @@ def config_entries_progress( ) +@websocket_api.require_admin +@websocket_api.websocket_command({"type": "config_entries/flow/subscribe"}) +def config_entries_flow_subscribe( + hass: HomeAssistant, + connection: websocket_api.ActiveConnection, + msg: dict[str, Any], +) -> None: + """Subscribe to non user created flows being initiated or removed. + + When initiating the subscription, the current flows are sent to the client. + + Example of a non-user initiated flow is a discovered Hue hub that + requires user interaction to finish setup. + """ + + @callback + def async_on_flow_init_remove(change_type: str, flow_id: str) -> None: + """Forward config entry state events to websocket.""" + if change_type == "removed": + connection.send_message( + websocket_api.event_message( + msg["id"], + [{"type": change_type, "flow_id": flow_id}], + ) + ) + return + # change_type == "added" + connection.send_message( + websocket_api.event_message( + msg["id"], + [ + { + "type": change_type, + "flow_id": flow_id, + "flow": hass.config_entries.flow.async_get(flow_id), + } + ], + ) + ) + + connection.subscriptions[msg["id"]] = hass.config_entries.flow.async_subscribe_flow( + async_on_flow_init_remove + ) + connection.send_message( + websocket_api.event_message( + msg["id"], + [ + {"type": None, "flow_id": flw["flow_id"], "flow": flw} + for flw in hass.config_entries.flow.async_progress() + if flw["context"]["source"] + not in ( + config_entries.SOURCE_RECONFIGURE, + config_entries.SOURCE_USER, + ) + ], + ) + ) + connection.send_result(msg["id"]) + + def send_entry_not_found( connection: websocket_api.ActiveConnection, msg_id: int ) -> None: diff --git a/homeassistant/components/onboarding/views.py b/homeassistant/components/onboarding/views.py index 978e16963d9..47d9b1cb98b 100644 --- a/homeassistant/components/onboarding/views.py +++ b/homeassistant/components/onboarding/views.py @@ -31,7 +31,12 @@ from homeassistant.helpers import area_registry as ar from homeassistant.helpers.backup import async_get_manager as async_get_backup_manager from homeassistant.helpers.system_info import async_get_system_info from homeassistant.helpers.translation import async_get_translations -from homeassistant.setup import SetupPhases, async_pause_setup, async_setup_component +from homeassistant.setup import ( + SetupPhases, + async_pause_setup, + async_setup_component, + async_wait_component, +) if TYPE_CHECKING: from . import OnboardingData, OnboardingStorage, OnboardingStoreData @@ -60,6 +65,7 @@ async def async_setup( hass.http.register_view(BackupInfoView(data)) hass.http.register_view(RestoreBackupView(data)) hass.http.register_view(UploadBackupView(data)) + hass.http.register_view(WaitIntegrationOnboardingView(data)) await setup_cloud_views(hass, data) @@ -298,6 +304,30 @@ class IntegrationOnboardingView(_BaseOnboardingStepView): return self.json({"auth_code": auth_code}) +class WaitIntegrationOnboardingView(_NoAuthBaseOnboardingView): + """Get backup info view.""" + + url = "/api/onboarding/integration/wait" + name = "api:onboarding:integration:wait" + + @RequestDataValidator( + vol.Schema( + { + vol.Required("domain"): str, + } + ) + ) + async def post(self, request: web.Request, data: dict[str, Any]) -> web.Response: + """Handle wait for integration command.""" + hass = request.app[KEY_HASS] + domain = data["domain"] + return self.json( + { + "integration_loaded": await async_wait_component(hass, domain), + } + ) + + class AnalyticsOnboardingView(_BaseOnboardingStepView): """View to finish analytics onboarding step.""" diff --git a/homeassistant/config_entries.py b/homeassistant/config_entries.py index 705cc01061b..30bd075ed95 100644 --- a/homeassistant/config_entries.py +++ b/homeassistant/config_entries.py @@ -1375,6 +1375,7 @@ class ConfigEntriesFlowManager( function=self._async_fire_discovery_event, background=True, ) + self._flow_subscriptions: list[Callable[[str, str], None]] = [] async def async_wait_import_flow_initialized(self, handler: str) -> None: """Wait till all import flows in progress are initialized.""" @@ -1461,6 +1462,13 @@ class ConfigEntriesFlowManager( # Fire discovery event await self._discovery_event_debouncer.async_call() + if result["type"] != data_entry_flow.FlowResultType.ABORT and source in ( + DISCOVERY_SOURCES | {SOURCE_REAUTH} + ): + # Notify listeners that a flow is created + for subscription in self._flow_subscriptions: + subscription("added", flow.flow_id) + return result async def _async_init( @@ -1739,6 +1747,29 @@ class ConfigEntriesFlowManager( return True return False + @callback + def async_subscribe_flow( + self, listener: Callable[[str, str], None] + ) -> CALLBACK_TYPE: + """Subscribe to non user initiated flow init or remove.""" + self._flow_subscriptions.append(listener) + return lambda: self._flow_subscriptions.remove(listener) + + @callback + def _async_remove_flow_progress(self, flow_id: str) -> None: + """Remove a flow from in progress.""" + flow = self._progress.get(flow_id) + super()._async_remove_flow_progress(flow_id) + # Fire remove event for initialized non user initiated flows + if ( + not flow + or flow.cur_step is None + or flow.source not in (DISCOVERY_SOURCES | {SOURCE_REAUTH}) + ): + return + for listeners in self._flow_subscriptions: + listeners("removed", flow_id) + class ConfigEntryItems(UserDict[str, ConfigEntry]): """Container for config items, maps config_entry_id -> entry. diff --git a/tests/components/config/test_config_entries.py b/tests/components/config/test_config_entries.py index c6e65c312bb..6784866ea4b 100644 --- a/tests/components/config/test_config_entries.py +++ b/tests/components/config/test_config_entries.py @@ -8,6 +8,7 @@ from unittest.mock import ANY, AsyncMock, patch from aiohttp.test_utils import TestClient from freezegun.api import FrozenDateTimeFactory import pytest +from pytest_unordered import unordered import voluptuous as vol from homeassistant import config_entries as core_ce, data_entry_flow, loader @@ -882,6 +883,256 @@ async def test_get_progress_flow_unauth( assert resp2.status == HTTPStatus.UNAUTHORIZED +async def test_get_progress_subscribe( + hass: HomeAssistant, hass_ws_client: WebSocketGenerator +) -> None: + """Test querying for the flows that are in progress.""" + assert await async_setup_component(hass, "config", {}) + mock_platform(hass, "test.config_flow", None) + ws_client = await hass_ws_client(hass) + + mock_integration( + hass, MockModule("test", async_setup_entry=AsyncMock(return_value=True)) + ) + + entry = MockConfigEntry(domain="test", title="Test", entry_id="1234") + entry.add_to_hass(hass) + + class TestFlow(core_ce.ConfigFlow): + VERSION = 5 + + async def async_step_bluetooth( + self, discovery_info: HassioServiceInfo + ) -> ConfigFlowResult: + """Handle a bluetooth discovery.""" + return self.async_abort(reason="already_configured") + + async def async_step_hassio( + self, discovery_info: HassioServiceInfo + ) -> ConfigFlowResult: + """Handle a Hass.io discovery.""" + return await self.async_step_account() + + async def async_step_account(self, user_input: dict[str, Any] | None = None): + """Show a form to the user.""" + return self.async_show_form(step_id="account") + + async def async_step_user(self, user_input: dict[str, Any] | None = None): + """Handle a config flow initialized by the user.""" + return await self.async_step_account() + + async def async_step_reauth(self, user_input: dict[str, Any] | None = None): + """Handle a reauthentication flow.""" + nonlocal entry + assert self._get_reauth_entry() is entry + return await self.async_step_account() + + async def async_step_reconfigure( + self, user_input: dict[str, Any] | None = None + ): + """Handle a reconfiguration flow initialized by the user.""" + nonlocal entry + assert self._get_reconfigure_entry() is entry + return await self.async_step_account() + + await ws_client.send_json({"id": 1, "type": "config_entries/flow/subscribe"}) + response = await ws_client.receive_json() + assert response == {"id": 1, "event": [], "type": "event"} + response = await ws_client.receive_json() + assert response == {"id": 1, "result": None, "success": True, "type": "result"} + + flow_context = { + "bluetooth": {"source": core_ce.SOURCE_BLUETOOTH}, + "hassio": {"source": core_ce.SOURCE_HASSIO}, + "user": {"source": core_ce.SOURCE_USER}, + "reauth": {"source": core_ce.SOURCE_REAUTH, "entry_id": "1234"}, + "reconfigure": {"source": core_ce.SOURCE_RECONFIGURE, "entry_id": "1234"}, + } + forms = {} + + with mock_config_flow("test", TestFlow): + for key, context in flow_context.items(): + forms[key] = await hass.config_entries.flow.async_init( + "test", context=context + ) + + assert forms["bluetooth"]["type"] == data_entry_flow.FlowResultType.ABORT + for key in ("hassio", "user", "reauth", "reconfigure"): + assert forms[key]["type"] == data_entry_flow.FlowResultType.FORM + assert forms[key]["step_id"] == "account" + + for key in ("hassio", "user", "reauth", "reconfigure"): + hass.config_entries.flow.async_abort(forms[key]["flow_id"]) + + # Uninitialized flows and flows with SOURCE_USER and SOURCE_RECONFIGURE + # should be filtered out + for key in ("hassio", "reauth"): + response = await ws_client.receive_json() + assert response == { + "event": [ + { + "flow": { + "flow_id": forms[key]["flow_id"], + "handler": "test", + "step_id": "account", + "context": flow_context[key], + }, + "flow_id": forms[key]["flow_id"], + "type": "added", + } + ], + "id": 1, + "type": "event", + } + for key in ("hassio", "reauth"): + response = await ws_client.receive_json() + assert response == { + "event": [ + { + "flow_id": forms[key]["flow_id"], + "type": "removed", + } + ], + "id": 1, + "type": "event", + } + + +async def test_get_progress_subscribe_in_progress( + hass: HomeAssistant, hass_ws_client: WebSocketGenerator +) -> None: + """Test querying for the flows that are in progress.""" + assert await async_setup_component(hass, "config", {}) + mock_platform(hass, "test.config_flow", None) + ws_client = await hass_ws_client(hass) + + mock_integration( + hass, MockModule("test", async_setup_entry=AsyncMock(return_value=True)) + ) + + entry = MockConfigEntry(domain="test", title="Test", entry_id="1234") + entry.add_to_hass(hass) + + class TestFlow(core_ce.ConfigFlow): + VERSION = 5 + + async def async_step_bluetooth( + self, discovery_info: HassioServiceInfo + ) -> ConfigFlowResult: + """Handle a bluetooth discovery.""" + return self.async_abort(reason="already_configured") + + async def async_step_hassio( + self, discovery_info: HassioServiceInfo + ) -> ConfigFlowResult: + """Handle a Hass.io discovery.""" + return await self.async_step_account() + + async def async_step_account(self, user_input: dict[str, Any] | None = None): + """Show a form to the user.""" + return self.async_show_form(step_id="account") + + async def async_step_user(self, user_input: dict[str, Any] | None = None): + """Handle a config flow initialized by the user.""" + return await self.async_step_account() + + async def async_step_reauth(self, user_input: dict[str, Any] | None = None): + """Handle a reauthentication flow.""" + nonlocal entry + assert self._get_reauth_entry() is entry + return await self.async_step_account() + + async def async_step_reconfigure( + self, user_input: dict[str, Any] | None = None + ): + """Handle a reconfiguration flow initialized by the user.""" + nonlocal entry + assert self._get_reconfigure_entry() is entry + return await self.async_step_account() + + flow_context = { + "bluetooth": {"source": core_ce.SOURCE_BLUETOOTH}, + "hassio": {"source": core_ce.SOURCE_HASSIO}, + "user": {"source": core_ce.SOURCE_USER}, + "reauth": {"source": core_ce.SOURCE_REAUTH, "entry_id": "1234"}, + "reconfigure": {"source": core_ce.SOURCE_RECONFIGURE, "entry_id": "1234"}, + } + forms = {} + + with mock_config_flow("test", TestFlow): + for key, context in flow_context.items(): + forms[key] = await hass.config_entries.flow.async_init( + "test", context=context + ) + + assert forms["bluetooth"]["type"] == data_entry_flow.FlowResultType.ABORT + for key in ("hassio", "user", "reauth", "reconfigure"): + assert forms[key]["type"] == data_entry_flow.FlowResultType.FORM + assert forms[key]["step_id"] == "account" + + await ws_client.send_json({"id": 1, "type": "config_entries/flow/subscribe"}) + + # Uninitialized flows and flows with SOURCE_USER and SOURCE_RECONFIGURE + # should be filtered out + responses = [] + responses.append(await ws_client.receive_json()) + assert responses == [ + { + "event": unordered( + [ + { + "flow": { + "flow_id": forms[key]["flow_id"], + "handler": "test", + "step_id": "account", + "context": flow_context[key], + }, + "flow_id": forms[key]["flow_id"], + "type": None, + } + for key in ("hassio", "reauth") + ] + ), + "id": 1, + "type": "event", + } + ] + + response = await ws_client.receive_json() + assert response == {"id": ANY, "result": None, "success": True, "type": "result"} + + for key in ("hassio", "user", "reauth", "reconfigure"): + hass.config_entries.flow.async_abort(forms[key]["flow_id"]) + + for key in ("hassio", "reauth"): + response = await ws_client.receive_json() + assert response == { + "event": [ + { + "flow_id": forms[key]["flow_id"], + "type": "removed", + } + ], + "id": 1, + "type": "event", + } + + +async def test_get_progress_subscribe_unauth( + hass: HomeAssistant, hass_ws_client: WebSocketGenerator, hass_admin_user: MockUser +) -> None: + """Test we can't subscribe to flows.""" + assert await async_setup_component(hass, "config", {}) + hass_admin_user.groups = [] + ws_client = await hass_ws_client(hass) + + await ws_client.send_json({"id": 5, "type": "config_entries/flow/subscribe"}) + response = await ws_client.receive_json() + + assert not response["success"] + assert response["error"]["code"] == "unauthorized" + + async def test_options_flow(hass: HomeAssistant, client: TestClient) -> None: """Test we can change options.""" diff --git a/tests/components/onboarding/test_views.py b/tests/components/onboarding/test_views.py index 9c5e93e49fe..6a6be1da470 100644 --- a/tests/components/onboarding/test_views.py +++ b/tests/components/onboarding/test_views.py @@ -21,14 +21,16 @@ from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import area_registry as ar from homeassistant.helpers.backup import async_initialize_backup -from homeassistant.setup import async_setup_component +from homeassistant.setup import async_set_domains_to_be_loaded, async_setup_component from . import mock_storage from tests.common import ( CLIENT_ID, CLIENT_REDIRECT_URI, + MockModule, MockUser, + mock_integration, register_auth_provider, ) from tests.test_util.aiohttp import AiohttpClientMocker @@ -1205,3 +1207,82 @@ async def test_onboarding_cloud_status( assert req.status == HTTPStatus.OK data = await req.json() assert data == {"logged_in": False} + + +@pytest.mark.parametrize( + ("domain", "expected_result"), + [ + ("onboarding", {"integration_loaded": True}), + ("non_existing_domain", {"integration_loaded": False}), + ], +) +async def test_wait_integration( + hass: HomeAssistant, + hass_storage: dict[str, Any], + hass_client: ClientSessionGenerator, + domain: str, + expected_result: dict[str, Any], +) -> None: + """Test we can get wait for an integration to load.""" + mock_storage(hass_storage, {"done": []}) + + assert await async_setup_component(hass, "onboarding", {}) + await hass.async_block_till_done() + + client = await hass_client() + req = await client.post("/api/onboarding/integration/wait", json={"domain": domain}) + + assert req.status == HTTPStatus.OK + data = await req.json() + assert data == expected_result + + +async def test_wait_integration_startup( + hass: HomeAssistant, + hass_storage: dict[str, Any], + hass_client: ClientSessionGenerator, +) -> None: + """Test we can get wait for an integration to load during startup.""" + mock_storage(hass_storage, {"done": []}) + + assert await async_setup_component(hass, "onboarding", {}) + await hass.async_block_till_done() + client = await hass_client() + + setup_stall = asyncio.Event() + setup_started = asyncio.Event() + + async def mock_setup(hass: HomeAssistant, _) -> bool: + setup_started.set() + await setup_stall.wait() + return True + + mock_integration(hass, MockModule("test", async_setup=mock_setup)) + + # The integration is not loaded, and is also not scheduled to load + req = await client.post("/api/onboarding/integration/wait", json={"domain": "test"}) + assert req.status == HTTPStatus.OK + data = await req.json() + assert data == {"integration_loaded": False} + + # Mark the component as scheduled to be loaded + async_set_domains_to_be_loaded(hass, {"test"}) + + # Start loading the component, including its config entries + hass.async_create_task(async_setup_component(hass, "test", {})) + await setup_started.wait() + + # The component is not yet loaded + assert "test" not in hass.config.components + + # Allow setup to proceed + setup_stall.set() + + # The component is scheduled to load, this will block until the config entry is loaded + req = await client.post("/api/onboarding/integration/wait", json={"domain": "test"}) + assert req.status == HTTPStatus.OK + data = await req.json() + assert data == {"integration_loaded": True} + + # The component has been loaded + assert "test" in hass.config.components From efbb94a1b15f966a7147274783cee452c4ce7e19 Mon Sep 17 00:00:00 2001 From: Artur Pragacz <49985303+arturpragacz@users.noreply.github.com> Date: Thu, 10 Apr 2025 17:41:06 +0200 Subject: [PATCH 432/505] Use common helper function in resolve integration dependencies (#140989) Extract to helper function in resolve integration dependencies --- homeassistant/loader.py | 74 +++++++++++++++++++++-------------------- tests/test_loader.py | 12 +++---- 2 files changed, 44 insertions(+), 42 deletions(-) diff --git a/homeassistant/loader.py b/homeassistant/loader.py index e904fa4bdaf..2498cf39ffe 100644 --- a/homeassistant/loader.py +++ b/homeassistant/loader.py @@ -1447,31 +1447,13 @@ async def resolve_integrations_dependencies( Detects circular dependencies and missing integrations. """ - resolved = _ResolveDependenciesCache() - - async def _resolve_deps_catch_exceptions(itg: Integration) -> set[str] | None: - try: - return await _do_resolve_dependencies(itg, cache=resolved) - except Exception as exc: # noqa: BLE001 - _LOGGER.error("Unable to resolve dependencies for %s: %s", itg.domain, exc) - return None - - resolve_dependencies_tasks = { - itg.domain: create_eager_task( - _resolve_deps_catch_exceptions(itg), - name=f"resolve dependencies {itg.domain}", - loop=hass.loop, - ) - for itg in integrations - } - - result = await asyncio.gather(*resolve_dependencies_tasks.values()) - - return { - domain: deps - for domain, deps in zip(resolve_dependencies_tasks, result, strict=True) - if deps is not None - } + return await _resolve_integrations_dependencies( + hass, + "resolve dependencies", + integrations, + cache=_ResolveDependenciesCache(), + ignore_exceptions=False, + ) async def resolve_integrations_after_dependencies( @@ -1485,26 +1467,46 @@ async def resolve_integrations_after_dependencies( Detects circular dependencies and missing integrations. """ - resolved: dict[Integration, set[str] | Exception] = {} + return await _resolve_integrations_dependencies( + hass, + "resolve (after) dependencies", + integrations, + cache={}, + possible_after_dependencies=possible_after_dependencies, + ignore_exceptions=ignore_exceptions, + ) + + +async def _resolve_integrations_dependencies( + hass: HomeAssistant, + name: str, + integrations: Iterable[Integration], + *, + cache: _ResolveDependenciesCacheProtocol, + possible_after_dependencies: set[str] | None | UndefinedType = UNDEFINED, + ignore_exceptions: bool, +) -> dict[str, set[str]]: + """Resolve all dependencies, possibly including after_dependencies, for integrations. + + Detects circular dependencies and missing integrations. + """ async def _resolve_deps_catch_exceptions(itg: Integration) -> set[str] | None: try: - return await _do_resolve_dependencies( + return await _resolve_integration_dependencies( itg, - cache=resolved, + cache=cache, possible_after_dependencies=possible_after_dependencies, ignore_exceptions=ignore_exceptions, ) except Exception as exc: # noqa: BLE001 - _LOGGER.error( - "Unable to resolve (after) dependencies for %s: %s", itg.domain, exc - ) + _LOGGER.error("Unable to %s for %s: %s", name, itg.domain, exc) return None resolve_dependencies_tasks = { itg.domain: create_eager_task( _resolve_deps_catch_exceptions(itg), - name=f"resolve after dependencies {itg.domain}", + name=f"{name} {itg.domain}", loop=hass.loop, ) for itg in integrations @@ -1519,7 +1521,7 @@ async def resolve_integrations_after_dependencies( } -async def _do_resolve_dependencies( +async def _resolve_integration_dependencies( itg: Integration, *, cache: _ResolveDependenciesCacheProtocol, @@ -1542,7 +1544,7 @@ async def _do_resolve_dependencies( resolved = cache resolving: set[str] = set() - async def do_resolve_dependencies_impl(itg: Integration) -> set[str]: + async def resolve_dependencies_impl(itg: Integration) -> set[str]: domain = itg.domain # If it's already resolved, no point doing it again. @@ -1584,7 +1586,7 @@ async def _do_resolve_dependencies( all_dependencies.add(dep_domain) try: - dep_dependencies = await do_resolve_dependencies_impl(dep_integration) + dep_dependencies = await resolve_dependencies_impl(dep_integration) except CircularDependency as exc: exc.extend_cycle(domain) resolved[itg] = exc @@ -1600,7 +1602,7 @@ async def _do_resolve_dependencies( resolved[itg] = all_dependencies return all_dependencies - return await do_resolve_dependencies_impl(itg) + return await resolve_dependencies_impl(itg) class LoaderError(Exception): diff --git a/tests/test_loader.py b/tests/test_loader.py index 0b83ddee3ea..793e0de6fef 100644 --- a/tests/test_loader.py +++ b/tests/test_loader.py @@ -29,25 +29,25 @@ async def test_circular_component_dependencies(hass: HomeAssistant) -> None: mod_4 = mock_integration(hass, MockModule("mod4", dependencies=["mod2", "mod3"])) all_domains = {"mod1", "mod2", "mod3", "mod4"} - deps = await loader._do_resolve_dependencies(mod_4, cache={}) + deps = await loader._resolve_integration_dependencies(mod_4, cache={}) assert deps == {"mod1", "mod2", "mod3"} # Create a circular dependency mock_integration(hass, MockModule("mod1", dependencies=["mod4"])) with pytest.raises(loader.CircularDependency): - await loader._do_resolve_dependencies(mod_4, cache={}) + await loader._resolve_integration_dependencies(mod_4, cache={}) # Create a different circular dependency mock_integration(hass, MockModule("mod1", dependencies=["mod3"])) with pytest.raises(loader.CircularDependency): - await loader._do_resolve_dependencies(mod_4, cache={}) + await loader._resolve_integration_dependencies(mod_4, cache={}) # Create a circular after_dependency mock_integration( hass, MockModule("mod1", partial_manifest={"after_dependencies": ["mod4"]}) ) with pytest.raises(loader.CircularDependency): - await loader._do_resolve_dependencies( + await loader._resolve_integration_dependencies( mod_4, cache={}, possible_after_dependencies=all_domains, @@ -58,7 +58,7 @@ async def test_circular_component_dependencies(hass: HomeAssistant) -> None: hass, MockModule("mod1", partial_manifest={"after_dependencies": ["mod3"]}) ) with pytest.raises(loader.CircularDependency): - await loader._do_resolve_dependencies( + await loader._resolve_integration_dependencies( mod_4, cache={}, possible_after_dependencies=all_domains, @@ -72,7 +72,7 @@ async def test_circular_component_dependencies(hass: HomeAssistant) -> None: hass, MockModule("mod4", partial_manifest={"after_dependencies": ["mod2"]}) ) with pytest.raises(loader.CircularDependency): - await loader._do_resolve_dependencies( + await loader._resolve_integration_dependencies( mod_4, cache={}, possible_after_dependencies=all_domains, From c7ca88e666643fde92d61ffabdcedafadbb351ff Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Thu, 10 Apr 2025 17:47:02 +0200 Subject: [PATCH 433/505] Use common state for "Normal" in `onedrive` (#142673) --- homeassistant/components/onedrive/strings.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/onedrive/strings.json b/homeassistant/components/onedrive/strings.json index 90fa4efc3ec..b8fa7f8189d 100644 --- a/homeassistant/components/onedrive/strings.json +++ b/homeassistant/components/onedrive/strings.json @@ -124,7 +124,7 @@ "drive_state": { "name": "Drive state", "state": { - "normal": "Normal", + "normal": "[%key:common::state::normal%]", "nearing": "Nearing limit", "critical": "Critical", "exceeded": "Exceeded" From 1d9343df7f965fae4e7a6d011376d819739d0dd5 Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Thu, 10 Apr 2025 18:00:37 +0200 Subject: [PATCH 434/505] Fixes to user-facing strings of `rfxtrx` integration (#142677) - consistently use "RFXtrx" for the friendly name of the integration - apply sentence-casing to all strings - use the common state for "Normal" --- homeassistant/components/rfxtrx/strings.json | 52 ++++++++++---------- 1 file changed, 26 insertions(+), 26 deletions(-) diff --git a/homeassistant/components/rfxtrx/strings.json b/homeassistant/components/rfxtrx/strings.json index d0a61540a53..d3b65dc238a 100644 --- a/homeassistant/components/rfxtrx/strings.json +++ b/homeassistant/components/rfxtrx/strings.json @@ -48,7 +48,7 @@ "event_code": "Enter event code to add", "device": "Select device to configure" }, - "title": "Rfxtrx Options" + "title": "RFXtrx options" }, "set_device_options": { "data": { @@ -131,40 +131,40 @@ "level_9": "Level 9", "program": "Program", "stop": "Stop", - "0_5_seconds_up": "0.5 Seconds Up", - "0_5_seconds_down": "0.5 Seconds Down", - "2_seconds_up": "2 Seconds Up", - "2_seconds_down": "2 Seconds Down", + "0_5_seconds_up": "0.5 seconds up", + "0_5_seconds_down": "0.5 seconds down", + "2_seconds_up": "2 seconds up", + "2_seconds_down": "2 seconds down", "enable_sun_automation": "Enable sun automation", "disable_sun_automation": "Disable sun automation", - "normal": "Normal", - "normal_delayed": "Normal Delayed", + "normal": "[%key:common::state::normal%]", + "normal_delayed": "Normal delayed", "alarm": "Alarm", - "alarm_delayed": "Alarm Delayed", + "alarm_delayed": "Alarm delayed", "motion": "Motion", - "no_motion": "No Motion", + "no_motion": "No motion", "panic": "Panic", - "end_panic": "End Panic", + "end_panic": "End panic", "ir": "IR", - "arm_away": "Arm Away", - "arm_away_delayed": "Arm Away Delayed", - "arm_home": "Arm Home", - "arm_home_delayed": "Arm Home Delayed", + "arm_away": "Arm away", + "arm_away_delayed": "Arm away delayed", + "arm_home": "Arm home", + "arm_home_delayed": "Arm home delayed", "disarm": "Disarm", - "light_1_off": "Light 1 Off", - "light_1_on": "Light 1 On", - "light_2_off": "Light 2 Off", - "light_2_on": "Light 2 On", - "dark_detected": "Dark Detected", - "light_detected": "Light Detected", + "light_1_off": "Light 1 off", + "light_1_on": "Light 1 on", + "light_2_off": "Light 2 off", + "light_2_on": "Light 2 on", + "dark_detected": "Dark detected", + "light_detected": "Light detected", "battery_low": "Battery low", "pairing_kd101": "Pairing KD101", - "normal_tamper": "Normal Tamper", - "normal_delayed_tamper": "Normal Delayed Tamper", - "alarm_tamper": "Alarm Tamper", - "alarm_delayed_tamper": "Alarm Delayed Tamper", - "motion_tamper": "Motion Tamper", - "no_motion_tamper": "No Motion Tamper" + "normal_tamper": "Normal tamper", + "normal_delayed_tamper": "Normal delayed tamper", + "alarm_tamper": "Alarm tamper", + "alarm_delayed_tamper": "Alarm delayed tamper", + "motion_tamper": "Motion tamper", + "no_motion_tamper": "No motion tamper" } } } From d4dbd76a0aad609ac96809a5cb8a125d99166ba0 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Thu, 10 Apr 2025 19:15:54 +0200 Subject: [PATCH 435/505] Revert "Add onboarding view /api/onboarding/integration/wait" (#142680) This reverts commit 956cac8f1aa57950e4469669d4440951faf8b77c. --- homeassistant/components/onboarding/views.py | 32 +------- tests/components/onboarding/test_views.py | 83 +------------------- 2 files changed, 2 insertions(+), 113 deletions(-) diff --git a/homeassistant/components/onboarding/views.py b/homeassistant/components/onboarding/views.py index 47d9b1cb98b..978e16963d9 100644 --- a/homeassistant/components/onboarding/views.py +++ b/homeassistant/components/onboarding/views.py @@ -31,12 +31,7 @@ from homeassistant.helpers import area_registry as ar from homeassistant.helpers.backup import async_get_manager as async_get_backup_manager from homeassistant.helpers.system_info import async_get_system_info from homeassistant.helpers.translation import async_get_translations -from homeassistant.setup import ( - SetupPhases, - async_pause_setup, - async_setup_component, - async_wait_component, -) +from homeassistant.setup import SetupPhases, async_pause_setup, async_setup_component if TYPE_CHECKING: from . import OnboardingData, OnboardingStorage, OnboardingStoreData @@ -65,7 +60,6 @@ async def async_setup( hass.http.register_view(BackupInfoView(data)) hass.http.register_view(RestoreBackupView(data)) hass.http.register_view(UploadBackupView(data)) - hass.http.register_view(WaitIntegrationOnboardingView(data)) await setup_cloud_views(hass, data) @@ -304,30 +298,6 @@ class IntegrationOnboardingView(_BaseOnboardingStepView): return self.json({"auth_code": auth_code}) -class WaitIntegrationOnboardingView(_NoAuthBaseOnboardingView): - """Get backup info view.""" - - url = "/api/onboarding/integration/wait" - name = "api:onboarding:integration:wait" - - @RequestDataValidator( - vol.Schema( - { - vol.Required("domain"): str, - } - ) - ) - async def post(self, request: web.Request, data: dict[str, Any]) -> web.Response: - """Handle wait for integration command.""" - hass = request.app[KEY_HASS] - domain = data["domain"] - return self.json( - { - "integration_loaded": await async_wait_component(hass, domain), - } - ) - - class AnalyticsOnboardingView(_BaseOnboardingStepView): """View to finish analytics onboarding step.""" diff --git a/tests/components/onboarding/test_views.py b/tests/components/onboarding/test_views.py index 6a6be1da470..9c5e93e49fe 100644 --- a/tests/components/onboarding/test_views.py +++ b/tests/components/onboarding/test_views.py @@ -21,16 +21,14 @@ from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import area_registry as ar from homeassistant.helpers.backup import async_initialize_backup -from homeassistant.setup import async_set_domains_to_be_loaded, async_setup_component +from homeassistant.setup import async_setup_component from . import mock_storage from tests.common import ( CLIENT_ID, CLIENT_REDIRECT_URI, - MockModule, MockUser, - mock_integration, register_auth_provider, ) from tests.test_util.aiohttp import AiohttpClientMocker @@ -1207,82 +1205,3 @@ async def test_onboarding_cloud_status( assert req.status == HTTPStatus.OK data = await req.json() assert data == {"logged_in": False} - - -@pytest.mark.parametrize( - ("domain", "expected_result"), - [ - ("onboarding", {"integration_loaded": True}), - ("non_existing_domain", {"integration_loaded": False}), - ], -) -async def test_wait_integration( - hass: HomeAssistant, - hass_storage: dict[str, Any], - hass_client: ClientSessionGenerator, - domain: str, - expected_result: dict[str, Any], -) -> None: - """Test we can get wait for an integration to load.""" - mock_storage(hass_storage, {"done": []}) - - assert await async_setup_component(hass, "onboarding", {}) - await hass.async_block_till_done() - - client = await hass_client() - req = await client.post("/api/onboarding/integration/wait", json={"domain": domain}) - - assert req.status == HTTPStatus.OK - data = await req.json() - assert data == expected_result - - -async def test_wait_integration_startup( - hass: HomeAssistant, - hass_storage: dict[str, Any], - hass_client: ClientSessionGenerator, -) -> None: - """Test we can get wait for an integration to load during startup.""" - mock_storage(hass_storage, {"done": []}) - - assert await async_setup_component(hass, "onboarding", {}) - await hass.async_block_till_done() - client = await hass_client() - - setup_stall = asyncio.Event() - setup_started = asyncio.Event() - - async def mock_setup(hass: HomeAssistant, _) -> bool: - setup_started.set() - await setup_stall.wait() - return True - - mock_integration(hass, MockModule("test", async_setup=mock_setup)) - - # The integration is not loaded, and is also not scheduled to load - req = await client.post("/api/onboarding/integration/wait", json={"domain": "test"}) - assert req.status == HTTPStatus.OK - data = await req.json() - assert data == {"integration_loaded": False} - - # Mark the component as scheduled to be loaded - async_set_domains_to_be_loaded(hass, {"test"}) - - # Start loading the component, including its config entries - hass.async_create_task(async_setup_component(hass, "test", {})) - await setup_started.wait() - - # The component is not yet loaded - assert "test" not in hass.config.components - - # Allow setup to proceed - setup_stall.set() - - # The component is scheduled to load, this will block until the config entry is loaded - req = await client.post("/api/onboarding/integration/wait", json={"domain": "test"}) - assert req.status == HTTPStatus.OK - data = await req.json() - assert data == {"integration_loaded": True} - - # The component has been loaded - assert "test" in hass.config.components From 7cbcb21e805d431dd53ee85310074f5c6c2d0d84 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Thu, 10 Apr 2025 19:50:50 +0200 Subject: [PATCH 436/505] Revert "Don't create repairs asking user to remove duplicate flipr config entries" (#142647) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Revert "Don't create repairs asking user to remove duplicate flipr config ent…" This reverts commit 536e6868923ae7956f06b90baeb8f5bb1f15dfb1. --- homeassistant/config_entries.py | 13 +----------- tests/test_config_entries.py | 35 --------------------------------- 2 files changed, 1 insertion(+), 47 deletions(-) diff --git a/homeassistant/config_entries.py b/homeassistant/config_entries.py index 30bd075ed95..f5f73842042 100644 --- a/homeassistant/config_entries.py +++ b/homeassistant/config_entries.py @@ -2388,12 +2388,7 @@ class ConfigEntries: if unique_id is not UNDEFINED and entry.unique_id != unique_id: # Deprecated in 2024.11, should fail in 2025.11 if ( - # flipr creates duplicates during migration, and asks users to - # remove the duplicate. We don't need warn about it here too. - # We should remove the special case for "flipr" in HA Core 2025.4, - # when the flipr migration period ends - entry.domain != "flipr" - and unique_id is not None + unique_id is not None and self.async_entry_for_domain_unique_id(entry.domain, unique_id) is not None ): @@ -2760,12 +2755,6 @@ class ConfigEntries: issues.add(issue.issue_id) for domain, unique_ids in self._entries._domain_unique_id_index.items(): # noqa: SLF001 - # flipr creates duplicates during migration, and asks users to - # remove the duplicate. We don't need warn about it here too. - # We should remove the special case for "flipr" in HA Core 2025.4, - # when the flipr migration period ends - if domain == "flipr": - continue for unique_id, entries in unique_ids.items(): # We might mutate the list of entries, so we need a copy to not mess up # the index diff --git a/tests/test_config_entries.py b/tests/test_config_entries.py index 5c2e2aea215..2527a6a151d 100644 --- a/tests/test_config_entries.py +++ b/tests/test_config_entries.py @@ -8424,41 +8424,6 @@ async def test_async_update_entry_unique_id_collision( assert issue_registry.async_get_issue(HOMEASSISTANT_DOMAIN, issue_id) -@pytest.mark.parametrize("domain", ["flipr"]) -async def test_async_update_entry_unique_id_collision_allowed_domain( - hass: HomeAssistant, - manager: config_entries.ConfigEntries, - caplog: pytest.LogCaptureFixture, - issue_registry: ir.IssueRegistry, - domain: str, -) -> None: - """Test we warn when async_update_entry creates a unique_id collision. - - This tests we don't warn and don't create issues for domains which have - their own migration path. - """ - assert len(issue_registry.issues) == 0 - - entry1 = MockConfigEntry(domain=domain, unique_id=None) - entry2 = MockConfigEntry(domain=domain, unique_id="not none") - entry3 = MockConfigEntry(domain=domain, unique_id="very unique") - entry4 = MockConfigEntry(domain=domain, unique_id="also very unique") - entry1.add_to_manager(manager) - entry2.add_to_manager(manager) - entry3.add_to_manager(manager) - entry4.add_to_manager(manager) - - manager.async_update_entry(entry2, unique_id=None) - assert len(issue_registry.issues) == 0 - assert len(caplog.record_tuples) == 0 - - manager.async_update_entry(entry4, unique_id="very unique") - assert len(issue_registry.issues) == 0 - assert len(caplog.record_tuples) == 0 - - assert ("already in use") not in caplog.text - - async def test_unique_id_collision_issues( hass: HomeAssistant, manager: config_entries.ConfigEntries, From 505dfcbcd98cb7c236f2ecec66518edb481eab0d Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Thu, 10 Apr 2025 19:51:36 +0200 Subject: [PATCH 437/505] Use shorthand attributes for MQTT device tracker entity (#142671) --- .../components/mqtt/device_tracker.py | 79 ++++++------------- homeassistant/components/mqtt/entity.py | 15 +++- tests/components/mqtt/test_device_tracker.py | 2 - 3 files changed, 39 insertions(+), 57 deletions(-) diff --git a/homeassistant/components/mqtt/device_tracker.py b/homeassistant/components/mqtt/device_tracker.py index 4017245cf51..87adde14d03 100644 --- a/homeassistant/components/mqtt/device_tracker.py +++ b/homeassistant/components/mqtt/device_tracker.py @@ -4,7 +4,7 @@ from __future__ import annotations from collections.abc import Callable import logging -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, Any import voluptuous as vol @@ -28,8 +28,8 @@ from homeassistant.helpers.typing import ConfigType, VolSchemaType from . import subscription from .config import MQTT_BASE_SCHEMA -from .const import CONF_PAYLOAD_RESET, CONF_STATE_TOPIC -from .entity import CONF_JSON_ATTRS_TOPIC, MqttEntity, async_setup_entity_entry_helper +from .const import CONF_JSON_ATTRS_TOPIC, CONF_PAYLOAD_RESET, CONF_STATE_TOPIC +from .entity import MqttEntity, async_setup_entity_entry_helper from .models import MqttValueTemplate, ReceiveMessage from .schemas import MQTT_ENTITY_COMMON_SCHEMA from .util import valid_subscribe_topic @@ -111,6 +111,7 @@ class MqttDeviceTracker(MqttEntity, TrackerEntity): self._value_template = MqttValueTemplate( config.get(CONF_VALUE_TEMPLATE), entity=self ).async_render_with_possible_json_value + self._attr_source_type = self._config[CONF_SOURCE_TYPE] @callback def _tracker_message_received(self, msg: ReceiveMessage) -> None: @@ -124,72 +125,44 @@ class MqttDeviceTracker(MqttEntity, TrackerEntity): ) return if payload == self._config[CONF_PAYLOAD_HOME]: - self._location_name = STATE_HOME + self._attr_location_name = STATE_HOME elif payload == self._config[CONF_PAYLOAD_NOT_HOME]: - self._location_name = STATE_NOT_HOME + self._attr_location_name = STATE_NOT_HOME elif payload == self._config[CONF_PAYLOAD_RESET]: - self._location_name = None + self._attr_location_name = None else: if TYPE_CHECKING: assert isinstance(msg.payload, str) - self._location_name = msg.payload + self._attr_location_name = msg.payload @callback def _prepare_subscribe_topics(self) -> None: """(Re)Subscribe to topics.""" self.add_subscription( - CONF_STATE_TOPIC, self._tracker_message_received, {"_location_name"} + CONF_STATE_TOPIC, self._tracker_message_received, {"_attr_location_name"} ) - @property - def force_update(self) -> bool: - """Do not force updates if the state is the same.""" - return False - async def _subscribe_topics(self) -> None: """(Re)Subscribe to topics.""" subscription.async_subscribe_topics_internal(self.hass, self._sub_state) - @property - def latitude(self) -> float | None: - """Return latitude if provided in extra_state_attributes or None.""" + @callback + def _process_update_extra_state_attributes( + self, extra_state_attributes: dict[str, Any] + ) -> None: + """Extract the location from the extra state attributes.""" + self._attr_latitude = extra_state_attributes.get(ATTR_LATITUDE) + self._attr_longitude = extra_state_attributes.get(ATTR_LONGITUDE) if ( - self.extra_state_attributes is not None - and ATTR_LATITUDE in self.extra_state_attributes + ATTR_LATITUDE in extra_state_attributes + or ATTR_LONGITUDE in extra_state_attributes ): - latitude: float = self.extra_state_attributes[ATTR_LATITUDE] - return latitude - return None + # Reset manual set location + self._attr_location_name = None - @property - def location_accuracy(self) -> int: - """Return location accuracy if provided in extra_state_attributes or None.""" - if ( - self.extra_state_attributes is not None - and ATTR_GPS_ACCURACY in self.extra_state_attributes - ): - accuracy: int = self.extra_state_attributes[ATTR_GPS_ACCURACY] - return accuracy - return 0 - - @property - def longitude(self) -> float | None: - """Return longitude if provided in extra_state_attributes or None.""" - if ( - self.extra_state_attributes is not None - and ATTR_LONGITUDE in self.extra_state_attributes - ): - longitude: float = self.extra_state_attributes[ATTR_LONGITUDE] - return longitude - return None - - @property - def location_name(self) -> str | None: - """Return a location name for the current location of the device.""" - return self._location_name - - @property - def source_type(self) -> SourceType: - """Return the source type, eg gps or router, of the device.""" - source_type: SourceType = self._config[CONF_SOURCE_TYPE] - return source_type + self._attr_location_accuracy = extra_state_attributes.get(ATTR_GPS_ACCURACY, 0) + self._attr_extra_state_attributes = { + attribute: value + for attribute, value in extra_state_attributes.items() + if attribute not in {ATTR_GPS_ACCURACY, ATTR_LATITUDE, ATTR_LONGITUDE} + } diff --git a/homeassistant/components/mqtt/entity.py b/homeassistant/components/mqtt/entity.py index 2fe801b6a01..1202f04ed42 100644 --- a/homeassistant/components/mqtt/entity.py +++ b/homeassistant/components/mqtt/entity.py @@ -402,6 +402,7 @@ class MqttAttributesMixin(Entity): _message_callback: Callable[ [MessageCallbackType, set[str] | None, ReceiveMessage], None ] + _process_update_extra_state_attributes: Callable[[dict[str, Any]], None] def __init__(self, config: ConfigType) -> None: """Initialize the JSON attributes mixin.""" @@ -438,7 +439,13 @@ class MqttAttributesMixin(Entity): "msg_callback": partial( self._message_callback, self._attributes_message_received, - {"_attr_extra_state_attributes"}, + { + "_attr_extra_state_attributes", + "_attr_gps_accuracy", + "_attr_latitude", + "_attr_location_name", + "_attr_longitude", + }, ), "entity_id": self.entity_id, "qos": self._attributes_config.get(CONF_QOS), @@ -477,7 +484,11 @@ class MqttAttributesMixin(Entity): if k not in MQTT_ATTRIBUTES_BLOCKED and k not in self._attributes_extra_blocked } - self._attr_extra_state_attributes = filtered_dict + if hasattr(self, "_process_update_extra_state_attributes"): + self._process_update_extra_state_attributes(filtered_dict) + else: + self._attr_extra_state_attributes = filtered_dict + else: _LOGGER.warning("JSON result was not a dictionary") diff --git a/tests/components/mqtt/test_device_tracker.py b/tests/components/mqtt/test_device_tracker.py index 02289c8e476..c2b2ea73a4d 100644 --- a/tests/components/mqtt/test_device_tracker.py +++ b/tests/components/mqtt/test_device_tracker.py @@ -454,12 +454,10 @@ async def test_setting_device_tracker_location_via_lat_lon_message( async_fire_mqtt_message(hass, "attributes-topic", '{"longitude": -117.22743}') state = hass.states.get("device_tracker.test") - assert state.attributes["longitude"] == -117.22743 assert state.state == STATE_UNKNOWN async_fire_mqtt_message(hass, "attributes-topic", '{"latitude":32.87336}') state = hass.states.get("device_tracker.test") - assert state.attributes["latitude"] == 32.87336 assert state.state == STATE_UNKNOWN From 88428fc7726062158e91ea5cd3c51e52bad5e48a Mon Sep 17 00:00:00 2001 From: Thimo Seitz Date: Thu, 10 Apr 2025 20:14:30 +0200 Subject: [PATCH 438/505] Update growatt server dependency to 1.6.0 (#142606) * Update GrowattServer Dependency * Update requirements_test_all.txt --- homeassistant/components/growatt_server/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/growatt_server/manifest.json b/homeassistant/components/growatt_server/manifest.json index 98ceb35ee17..7b3e67228b1 100644 --- a/homeassistant/components/growatt_server/manifest.json +++ b/homeassistant/components/growatt_server/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/growatt_server", "iot_class": "cloud_polling", "loggers": ["growattServer"], - "requirements": ["growattServer==1.5.0"] + "requirements": ["growattServer==1.6.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index 876a2dc9ef8..2d71d8ebe1b 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1084,7 +1084,7 @@ greenwavereality==0.5.1 gridnet==5.0.1 # homeassistant.components.growatt_server -growattServer==1.5.0 +growattServer==1.6.0 # homeassistant.components.google_sheets gspread==5.5.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index b7754aef3bc..c8b0bfc5ea3 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -929,7 +929,7 @@ greeneye_monitor==3.0.3 gridnet==5.0.1 # homeassistant.components.growatt_server -growattServer==1.5.0 +growattServer==1.6.0 # homeassistant.components.google_sheets gspread==5.5.0 From cf63175232d8df7701feb33a49b3947a5b5f9636 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Thu, 10 Apr 2025 20:55:34 +0200 Subject: [PATCH 439/505] Abort reauth flows on config entry reload (#140931) * Abort reauth flows on config entry reload * Don't cancel reauth when reload is triggered by a reauth flow * Revert "Don't cancel reauth when reload is triggered by a reauth flow" This reverts commit f37c75621e99d4c160c2c4adc9b36e52e4cc81ec. * Don't fail in FlowManager._async_handle_step when the flow was aborted * Update tplink config flow * Add tests * Don't allow create_entry from an aborted flow * Add comment * Adjust after merge with dev --- .../components/tplink/config_flow.py | 6 ++-- homeassistant/config_entries.py | 28 +++++++++------ homeassistant/data_entry_flow.py | 9 +++-- tests/test_config_entries.py | 36 ++++++++++++++++++- tests/test_data_entry_flow.py | 4 +-- 5 files changed, 62 insertions(+), 21 deletions(-) diff --git a/homeassistant/components/tplink/config_flow.py b/homeassistant/components/tplink/config_flow.py index 291a7e78c62..0914c4191cf 100644 --- a/homeassistant/components/tplink/config_flow.py +++ b/homeassistant/components/tplink/config_flow.py @@ -567,7 +567,7 @@ class TPLinkConfigFlow(ConfigFlow, domain=DOMAIN): ) async def _async_reload_requires_auth_entries(self) -> None: - """Reload any in progress config flow that now have credentials.""" + """Reload all config entries after auth update.""" _config_entries = self.hass.config_entries if self.source == SOURCE_REAUTH: @@ -579,11 +579,9 @@ class TPLinkConfigFlow(ConfigFlow, domain=DOMAIN): context = flow["context"] if context.get("source") != SOURCE_REAUTH: continue - entry_id: str = context["entry_id"] + entry_id = context["entry_id"] if entry := _config_entries.async_get_entry(entry_id): await _config_entries.async_reload(entry.entry_id) - if entry.state is ConfigEntryState.LOADED: - _config_entries.flow.async_abort(flow["flow_id"]) @callback def _async_create_or_update_entry_from_device( diff --git a/homeassistant/config_entries.py b/homeassistant/config_entries.py index f5f73842042..3064fdd54bb 100644 --- a/homeassistant/config_entries.py +++ b/homeassistant/config_entries.py @@ -1521,10 +1521,9 @@ class ConfigEntriesFlowManager( # Clean up issue if this is a reauth flow if flow.context["source"] == SOURCE_REAUTH: - if (entry_id := flow.context.get("entry_id")) is not None and ( - entry := self.config_entries.async_get_entry(entry_id) - ) is not None: - issue_id = f"config_entry_reauth_{entry.domain}_{entry.entry_id}" + if (entry_id := flow.context.get("entry_id")) is not None: + # The config entry's domain is flow.handler + issue_id = f"config_entry_reauth_{flow.handler}_{entry_id}" ir.async_delete_issue(self.hass, HOMEASSISTANT_DOMAIN, issue_id) async def async_finish_flow( @@ -2128,13 +2127,7 @@ class ConfigEntries: # If the configuration entry is removed during reauth, it should # abort any reauth flow that is active for the removed entry and # linked issues. - for progress_flow in self.hass.config_entries.flow.async_progress_by_handler( - entry.domain, match_context={"entry_id": entry_id, "source": SOURCE_REAUTH} - ): - if "flow_id" in progress_flow: - self.hass.config_entries.flow.async_abort(progress_flow["flow_id"]) - issue_id = f"config_entry_reauth_{entry.domain}_{entry.entry_id}" - ir.async_delete_issue(self.hass, HOMEASSISTANT_DOMAIN, issue_id) + _abort_reauth_flows(self.hass, entry.domain, entry_id) self._async_dispatch(ConfigEntryChange.REMOVED, entry) @@ -2266,6 +2259,9 @@ class ConfigEntries: # attempts. entry.async_cancel_retry_setup() + # Abort any in-progress reauth flow and linked issues + _abort_reauth_flows(self.hass, entry.domain, entry_id) + if entry.domain not in self.hass.config.components: # If the component is not loaded, just load it as # the config entry will be loaded as well. We need @@ -3786,3 +3782,13 @@ async def _async_get_flow_handler( return handler raise data_entry_flow.UnknownHandler + + +@callback +def _abort_reauth_flows(hass: HomeAssistant, domain: str, entry_id: str) -> None: + """Abort reauth flows for an entry.""" + for progress_flow in hass.config_entries.flow.async_progress_by_handler( + domain, match_context={"entry_id": entry_id, "source": SOURCE_REAUTH} + ): + if "flow_id" in progress_flow: + hass.config_entries.flow.async_abort(progress_flow["flow_id"]) diff --git a/homeassistant/data_entry_flow.py b/homeassistant/data_entry_flow.py index 6a288380cd0..e2e31ffce29 100644 --- a/homeassistant/data_entry_flow.py +++ b/homeassistant/data_entry_flow.py @@ -494,8 +494,11 @@ class FlowManager(abc.ABC, Generic[_FlowContextT, _FlowResultT, _HandlerT]): ) if flow.flow_id not in self._progress: - # The flow was removed during the step - raise UnknownFlow + # The flow was removed during the step, raise UnknownFlow + # unless the result is an abort + if result["type"] != FlowResultType.ABORT: + raise UnknownFlow + return result # Setup the flow handler's preview if needed if result.get("preview") is not None: @@ -547,7 +550,7 @@ class FlowManager(abc.ABC, Generic[_FlowContextT, _FlowResultT, _HandlerT]): flow.cur_step = result return result - # Abort and Success results both finish the flow + # Abort and Success results both finish the flow. self._async_remove_flow_progress(flow.flow_id) return result diff --git a/tests/test_config_entries.py b/tests/test_config_entries.py index 2527a6a151d..13ecd855624 100644 --- a/tests/test_config_entries.py +++ b/tests/test_config_entries.py @@ -695,7 +695,7 @@ async def test_remove_entry_cancels_reauth( manager: config_entries.ConfigEntries, issue_registry: ir.IssueRegistry, ) -> None: - """Tests that removing a config entry, also aborts existing reauth flows.""" + """Tests that removing a config entry also aborts existing reauth flows.""" entry = MockConfigEntry(title="test_title", domain="test") mock_setup_entry = AsyncMock(side_effect=ConfigEntryAuthFailed()) @@ -722,6 +722,40 @@ async def test_remove_entry_cancels_reauth( assert not issue_registry.async_get_issue(HOMEASSISTANT_DOMAIN, issue_id) +async def test_reload_entry_cancels_reauth( + hass: HomeAssistant, + manager: config_entries.ConfigEntries, + issue_registry: ir.IssueRegistry, +) -> None: + """Tests that reloading a config entry also aborts existing reauth flows.""" + entry = MockConfigEntry(title="test_title", domain="test") + + mock_setup_entry = AsyncMock(side_effect=ConfigEntryAuthFailed()) + mock_integration(hass, MockModule("test", async_setup_entry=mock_setup_entry)) + mock_platform(hass, "test.config_flow", None) + + entry.add_to_hass(hass) + await manager.async_setup(entry.entry_id) + await hass.async_block_till_done() + + flows = hass.config_entries.flow.async_progress_by_handler("test") + assert len(flows) == 1 + assert flows[0]["context"]["entry_id"] == entry.entry_id + assert flows[0]["context"]["source"] == config_entries.SOURCE_REAUTH + assert entry.state is config_entries.ConfigEntryState.SETUP_ERROR + + issue_id = f"config_entry_reauth_test_{entry.entry_id}" + assert issue_registry.async_get_issue(HOMEASSISTANT_DOMAIN, issue_id) + + mock_setup_entry.return_value = True + mock_setup_entry.side_effect = None + await manager.async_reload(entry.entry_id) + + flows = hass.config_entries.flow.async_progress_by_handler("test") + assert len(flows) == 0 + assert not issue_registry.async_get_issue(HOMEASSISTANT_DOMAIN, issue_id) + + async def test_remove_entry_handles_callback_error( hass: HomeAssistant, manager: config_entries.ConfigEntries ) -> None: diff --git a/tests/test_data_entry_flow.py b/tests/test_data_entry_flow.py index bcc40251bad..804b1fea405 100644 --- a/tests/test_data_entry_flow.py +++ b/tests/test_data_entry_flow.py @@ -219,8 +219,8 @@ async def test_abort_aborted_flow(manager: MockFlowManager) -> None: manager.async_abort(self.flow_id) return self.async_abort(reason="blah") - with pytest.raises(data_entry_flow.UnknownFlow): - await manager.async_init("test") + form = await manager.async_init("test") + assert form["reason"] == "blah" assert len(manager.async_progress()) == 0 assert len(manager.mock_created_entries) == 0 From 5a09847596b5094e5d5fb58d9f6c76f83348bed1 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Thu, 10 Apr 2025 20:56:02 +0200 Subject: [PATCH 440/505] Add backup support to the hassio OS update entity (#142580) * Add backup support to the hassio OS update entity * Remove meaningless assert --- homeassistant/components/hassio/update.py | 16 +- .../components/hassio/update_helper.py | 27 ++- tests/components/hassio/test_update.py | 167 +++++++++++++++++- 3 files changed, 190 insertions(+), 20 deletions(-) diff --git a/homeassistant/components/hassio/update.py b/homeassistant/components/hassio/update.py index 263cf2dfe13..2c325979210 100644 --- a/homeassistant/components/hassio/update.py +++ b/homeassistant/components/hassio/update.py @@ -5,7 +5,6 @@ from __future__ import annotations from typing import Any from aiohasupervisor import SupervisorError -from aiohasupervisor.models import OSUpdate from awesomeversion import AwesomeVersion, AwesomeVersionStrategy from homeassistant.components.update import ( @@ -36,7 +35,7 @@ from .entity import ( HassioOSEntity, HassioSupervisorEntity, ) -from .update_helper import update_addon, update_core +from .update_helper import update_addon, update_core, update_os ENTITY_DESCRIPTION = UpdateEntityDescription( translation_key="update", @@ -170,7 +169,9 @@ class SupervisorOSUpdateEntity(HassioOSEntity, UpdateEntity): """Update entity to handle updates for the Home Assistant Operating System.""" _attr_supported_features = ( - UpdateEntityFeature.INSTALL | UpdateEntityFeature.SPECIFIC_VERSION + UpdateEntityFeature.INSTALL + | UpdateEntityFeature.SPECIFIC_VERSION + | UpdateEntityFeature.BACKUP ) _attr_title = "Home Assistant Operating System" @@ -203,14 +204,7 @@ class SupervisorOSUpdateEntity(HassioOSEntity, UpdateEntity): self, version: str | None, backup: bool, **kwargs: Any ) -> None: """Install an update.""" - try: - await self.coordinator.supervisor_client.os.update( - OSUpdate(version=version) - ) - except SupervisorError as err: - raise HomeAssistantError( - f"Error updating Home Assistant Operating System: {err}" - ) from err + await update_os(self.hass, version, backup) class SupervisorSupervisorUpdateEntity(HassioSupervisorEntity, UpdateEntity): diff --git a/homeassistant/components/hassio/update_helper.py b/homeassistant/components/hassio/update_helper.py index d801f6b5771..65a3ba38485 100644 --- a/homeassistant/components/hassio/update_helper.py +++ b/homeassistant/components/hassio/update_helper.py @@ -3,7 +3,11 @@ from __future__ import annotations from aiohasupervisor import SupervisorError -from aiohasupervisor.models import HomeAssistantUpdateOptions, StoreAddonUpdate +from aiohasupervisor.models import ( + HomeAssistantUpdateOptions, + OSUpdate, + StoreAddonUpdate, +) from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError @@ -57,3 +61,24 @@ async def update_core(hass: HomeAssistant, version: str | None, backup: bool) -> ) except SupervisorError as err: raise HomeAssistantError(f"Error updating Home Assistant Core: {err}") from err + + +async def update_os(hass: HomeAssistant, version: str | None, backup: bool) -> None: + """Update OS. + + Optionally make a core backup before updating. + """ + client = get_supervisor_client(hass) + + if backup: + # pylint: disable-next=import-outside-toplevel + from .backup import backup_core_before_update + + await backup_core_before_update(hass) + + try: + await client.os.update(OSUpdate(version=version)) + except SupervisorError as err: + raise HomeAssistantError( + f"Error updating Home Assistant Operating System: {err}" + ) from err diff --git a/tests/components/hassio/test_update.py b/tests/components/hassio/test_update.py index d41954b2ab7..b5f6dc96bef 100644 --- a/tests/components/hassio/test_update.py +++ b/tests/components/hassio/test_update.py @@ -6,7 +6,11 @@ from typing import Any from unittest.mock import AsyncMock, MagicMock, patch from aiohasupervisor import SupervisorBadRequestError, SupervisorError -from aiohasupervisor.models import HomeAssistantUpdateOptions, StoreAddonUpdate +from aiohasupervisor.models import ( + HomeAssistantUpdateOptions, + OSUpdate, + StoreAddonUpdate, +) import pytest from homeassistant.components.backup import BackupManagerError, ManagerBackup @@ -475,13 +479,123 @@ async def test_update_os(hass: HomeAssistant, supervisor_client: AsyncMock) -> N await hass.async_block_till_done() supervisor_client.os.update.return_value = None - await hass.services.async_call( - "update", - "install", - {"entity_id": "update.home_assistant_operating_system_update"}, - blocking=True, - ) - supervisor_client.os.update.assert_called_once() + with patch( + "homeassistant.components.backup.manager.BackupManager.async_create_backup", + ) as mock_create_backup: + await hass.services.async_call( + "update", + "install", + {"entity_id": "update.home_assistant_operating_system_update"}, + blocking=True, + ) + mock_create_backup.assert_not_called() + supervisor_client.os.update.assert_called_once_with(OSUpdate(version=None)) + + +@pytest.mark.parametrize( + ("commands", "default_mount", "expected_kwargs"), + [ + ( + [], + None, + { + "agent_ids": ["hassio.local"], + "include_addons": None, + "include_all_addons": False, + "include_database": True, + "include_folders": None, + "include_homeassistant": True, + "name": f"Home Assistant Core {HAVERSION}", + "password": None, + }, + ), + ( + [], + "my_nas", + { + "agent_ids": ["hassio.my_nas"], + "include_addons": None, + "include_all_addons": False, + "include_database": True, + "include_folders": None, + "include_homeassistant": True, + "name": f"Home Assistant Core {HAVERSION}", + "password": None, + }, + ), + ( + [ + { + "type": "backup/config/update", + "create_backup": { + "agent_ids": ["test-agent"], + "include_addons": ["my-addon"], + "include_all_addons": True, + "include_database": False, + "include_folders": ["share"], + "name": "cool_backup", + "password": "hunter2", + }, + }, + ], + None, + { + "agent_ids": ["test-agent"], + "include_addons": ["my-addon"], + "include_all_addons": True, + "include_database": False, + "include_folders": ["share"], + "include_homeassistant": True, + "name": "cool_backup", + "password": "hunter2", + "with_automatic_settings": True, + }, + ), + ], +) +async def test_update_os_with_backup( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + supervisor_client: AsyncMock, + commands: list[dict[str, Any]], + default_mount: str | None, + expected_kwargs: dict[str, Any], +) -> None: + """Test updating OS update entity.""" + config_entry = MockConfigEntry(domain=DOMAIN, data={}, unique_id=DOMAIN) + config_entry.add_to_hass(hass) + + with patch.dict(os.environ, MOCK_ENVIRON): + result = await async_setup_component( + hass, + "hassio", + {"http": {"server_port": 9999, "server_host": "127.0.0.1"}, "hassio": {}}, + ) + assert result + await setup_backup_integration(hass) + + client = await hass_ws_client(hass) + for command in commands: + await client.send_json_auto_id(command) + result = await client.receive_json() + assert result["success"] + + supervisor_client.os.update.return_value = None + supervisor_client.mounts.info.return_value.default_backup_mount = default_mount + with patch( + "homeassistant.components.backup.manager.BackupManager.async_create_backup", + ) as mock_create_backup: + await hass.services.async_call( + "update", + "install", + { + "entity_id": "update.home_assistant_operating_system_update", + "backup": True, + }, + blocking=True, + ) + mock_create_backup.assert_called_once_with(**expected_kwargs) + supervisor_client.os.update.assert_called_once_with(OSUpdate(version=None)) async def test_update_core(hass: HomeAssistant, supervisor_client: AsyncMock) -> None: @@ -746,6 +860,43 @@ async def test_update_os_with_error( ) +async def test_update_os_with_backup_and_error( + hass: HomeAssistant, + supervisor_client: AsyncMock, +) -> None: + """Test updating OS update entity with error.""" + config_entry = MockConfigEntry(domain=DOMAIN, data={}, unique_id=DOMAIN) + config_entry.add_to_hass(hass) + + with patch.dict(os.environ, MOCK_ENVIRON): + result = await async_setup_component( + hass, + "hassio", + {"http": {"server_port": 9999, "server_host": "127.0.0.1"}, "hassio": {}}, + ) + assert result + await setup_backup_integration(hass) + + supervisor_client.os.update.return_value = None + supervisor_client.mounts.info.return_value.default_backup_mount = None + with ( + patch( + "homeassistant.components.backup.manager.BackupManager.async_create_backup", + side_effect=BackupManagerError, + ), + pytest.raises(HomeAssistantError, match=r"^Error creating backup:"), + ): + await hass.services.async_call( + "update", + "install", + { + "entity_id": "update.home_assistant_operating_system_update", + "backup": True, + }, + blocking=True, + ) + + async def test_update_supervisor_with_error( hass: HomeAssistant, supervisor_client: AsyncMock ) -> None: From 8f73c53d26413c0eb26b72a8020d97b4f4f995d2 Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Thu, 10 Apr 2025 21:12:39 +0200 Subject: [PATCH 441/505] =?UTF-8?q?Replace=20"Setup=20your=20=E2=80=A6"=20?= =?UTF-8?q?with=20correct=20"Set=20up=20your=20=E2=80=A6"=20in=20`iometer`?= =?UTF-8?q?=20(#142685)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- homeassistant/components/iometer/strings.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/iometer/strings.json b/homeassistant/components/iometer/strings.json index b3878dd1b53..6e149354eee 100644 --- a/homeassistant/components/iometer/strings.json +++ b/homeassistant/components/iometer/strings.json @@ -2,7 +2,7 @@ "config": { "step": { "user": { - "description": "Setup your IOmeter device for local data", + "description": "Set up your IOmeter device for local data", "data": { "host": "[%key:common::config_flow::data::host%]" }, From 6fafafbed09183454733ecc4e528375ad25b8348 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Thu, 10 Apr 2025 21:16:12 +0200 Subject: [PATCH 442/505] Improve Syncthru config flow tests (#142618) --- tests/components/syncthru/conftest.py | 10 ++ tests/components/syncthru/test_config_flow.py | 98 ++++++++++++++----- 2 files changed, 86 insertions(+), 22 deletions(-) diff --git a/tests/components/syncthru/conftest.py b/tests/components/syncthru/conftest.py index 6563e0f7b41..be5896c4956 100644 --- a/tests/components/syncthru/conftest.py +++ b/tests/components/syncthru/conftest.py @@ -12,6 +12,16 @@ from homeassistant.const import CONF_NAME, CONF_URL from tests.common import MockConfigEntry, load_json_object_fixture +@pytest.fixture +def mock_setup_entry() -> Generator[AsyncMock]: + """Override async_setup_entry.""" + with patch( + "homeassistant.components.syncthru.async_setup_entry", + return_value=True, + ) as mock_setup_entry: + yield mock_setup_entry + + @pytest.fixture def mock_syncthru() -> Generator[AsyncMock]: """Mock the SyncThru class.""" diff --git a/tests/components/syncthru/test_config_flow.py b/tests/components/syncthru/test_config_flow.py index c551c94506e..e535ba50470 100644 --- a/tests/components/syncthru/test_config_flow.py +++ b/tests/components/syncthru/test_config_flow.py @@ -1,11 +1,12 @@ """Tests for syncthru config flow.""" -from unittest.mock import AsyncMock, patch +from unittest.mock import AsyncMock from pysyncthru import SyncThruAPINotSupported from homeassistant import config_entries from homeassistant.components.syncthru.const import DOMAIN +from homeassistant.config_entries import SOURCE_SSDP, SOURCE_USER from homeassistant.const import CONF_NAME, CONF_URL from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType @@ -26,15 +27,26 @@ FIXTURE_USER_INPUT = { } -async def test_show_setup_form(hass: HomeAssistant, mock_syncthru: AsyncMock) -> None: - """Test that the setup form is served.""" +async def test_full_flow( + hass: HomeAssistant, mock_syncthru: AsyncMock, mock_setup_entry: AsyncMock +) -> None: + """Test the full flow.""" result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_USER}, data=None + DOMAIN, context={"source": SOURCE_USER} ) assert result["type"] is FlowResultType.FORM assert result["step_id"] == "user" + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input=FIXTURE_USER_INPUT, + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["data"] == FIXTURE_USER_INPUT + assert result["result"].unique_id is None + async def test_already_configured_by_url( hass: HomeAssistant, mock_syncthru: AsyncMock @@ -82,39 +94,40 @@ async def test_unknown_state(hass: HomeAssistant, mock_syncthru: AsyncMock) -> N mock_syncthru.is_unknown_state.return_value = True result = await hass.config_entries.flow.async_init( DOMAIN, - context={"source": config_entries.SOURCE_USER}, - data=FIXTURE_USER_INPUT, + context={"source": SOURCE_USER}, + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + assert not result["errors"] + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input=FIXTURE_USER_INPUT, ) assert result["type"] is FlowResultType.FORM assert result["step_id"] == "user" assert result["errors"] == {CONF_URL: "unknown_state"} + mock_syncthru.is_unknown_state.return_value = False -async def test_success(hass: HomeAssistant, mock_syncthru: AsyncMock) -> None: - """Test successful flow provides entry creation data.""" - - with patch( - "homeassistant.components.syncthru.async_setup_entry", return_value=True - ) as mock_setup_entry: - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": config_entries.SOURCE_USER}, - data=FIXTURE_USER_INPUT, - ) - + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input=FIXTURE_USER_INPUT, + ) assert result["type"] is FlowResultType.CREATE_ENTRY - assert result["data"][CONF_URL] == FIXTURE_USER_INPUT[CONF_URL] - assert len(mock_setup_entry.mock_calls) == 1 -async def test_ssdp(hass: HomeAssistant, mock_syncthru: AsyncMock) -> None: +async def test_ssdp( + hass: HomeAssistant, mock_syncthru: AsyncMock, mock_setup_entry: AsyncMock +) -> None: """Test SSDP discovery initiates config properly.""" url = "http://192.168.1.2/" result = await hass.config_entries.flow.async_init( DOMAIN, - context={"source": config_entries.SOURCE_SSDP}, + context={"source": SOURCE_SSDP}, data=SsdpServiceInfo( ssdp_usn="mock_usn", ssdp_st="mock_st", @@ -135,3 +148,44 @@ async def test_ssdp(hass: HomeAssistant, mock_syncthru: AsyncMock) -> None: for k in result["data_schema"].schema: if k == CONF_URL: assert k.default() == url + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={CONF_URL: url, CONF_NAME: "Printer"}, + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["data"] == {CONF_URL: url, CONF_NAME: "Printer"} + assert result["result"].unique_id == "uuid:XXXXXXXX-XXXX-XXXX-XXXX-XXXXXXXXXXXX" + + +async def test_ssdp_already_configured( + hass: HomeAssistant, mock_syncthru: AsyncMock, mock_config_entry: MockConfigEntry +) -> None: + """Test SSDP discovery initiates config properly.""" + + mock_config_entry.add_to_hass(hass) + hass.config_entries.async_update_entry( + mock_config_entry, unique_id="uuid:XXXXXXXX-XXXX-XXXX-XXXX-XXXXXXXXXXXX" + ) + + url = "http://192.168.1.2/" + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_SSDP}, + data=SsdpServiceInfo( + ssdp_usn="mock_usn", + ssdp_st="mock_st", + ssdp_location="http://192.168.1.2:5200/Printer.xml", + upnp={ + ATTR_UPNP_DEVICE_TYPE: "urn:schemas-upnp-org:device:Printer:1", + ATTR_UPNP_MANUFACTURER: "Samsung Electronics", + ATTR_UPNP_PRESENTATION_URL: url, + ATTR_UPNP_SERIAL: "00000000", + ATTR_UPNP_UDN: "uuid:XXXXXXXX-XXXX-XXXX-XXXX-XXXXXXXXXXXX", + }, + ), + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" From bb3c2175bcfc8340bb3256dff9bf06806254eddc Mon Sep 17 00:00:00 2001 From: Simone Chemelli Date: Thu, 10 Apr 2025 21:16:53 +0200 Subject: [PATCH 443/505] Comelit config flow timeout error (#142667) --- homeassistant/components/comelit/config_flow.py | 15 ++++++++++++--- homeassistant/components/comelit/strings.json | 6 ++++++ 2 files changed, 18 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/comelit/config_flow.py b/homeassistant/components/comelit/config_flow.py index f29cc62136b..5854bc1e324 100644 --- a/homeassistant/components/comelit/config_flow.py +++ b/homeassistant/components/comelit/config_flow.py @@ -2,6 +2,7 @@ from __future__ import annotations +from asyncio.exceptions import TimeoutError from collections.abc import Mapping from typing import Any @@ -53,10 +54,18 @@ async def validate_input(hass: HomeAssistant, data: dict[str, Any]) -> dict[str, try: await api.login() - except aiocomelit_exceptions.CannotConnect as err: - raise CannotConnect from err + except (aiocomelit_exceptions.CannotConnect, TimeoutError) as err: + raise CannotConnect( + translation_domain=DOMAIN, + translation_key="cannot_connect", + translation_placeholders={"error": repr(err)}, + ) from err except aiocomelit_exceptions.CannotAuthenticate as err: - raise InvalidAuth from err + raise InvalidAuth( + translation_domain=DOMAIN, + translation_key="cannot_authenticate", + translation_placeholders={"error": repr(err)}, + ) from err finally: await api.logout() await api.close() diff --git a/homeassistant/components/comelit/strings.json b/homeassistant/components/comelit/strings.json index d4d0b8f670f..55bae00e3d8 100644 --- a/homeassistant/components/comelit/strings.json +++ b/homeassistant/components/comelit/strings.json @@ -69,6 +69,12 @@ }, "invalid_clima_data": { "message": "Invalid 'clima' data" + }, + "cannot_connect": { + "message": "Error connecting: {error}" + }, + "cannot_authenticate": { + "message": "Error authenticating: {error}" } } } From bf0d2e9bd2833516009261fa2f32c0015d14a340 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Thu, 10 Apr 2025 21:24:38 +0200 Subject: [PATCH 444/505] Extract Syncthru coordinator in separate file (#142620) --- homeassistant/components/syncthru/__init__.py | 60 ++++--------------- .../components/syncthru/binary_sensor.py | 25 +++----- .../components/syncthru/coordinator.py | 44 ++++++++++++++ homeassistant/components/syncthru/sensor.py | 31 ++++------ tests/components/syncthru/conftest.py | 2 +- 5 files changed, 76 insertions(+), 86 deletions(-) create mode 100644 homeassistant/components/syncthru/coordinator.py diff --git a/homeassistant/components/syncthru/__init__.py b/homeassistant/components/syncthru/__init__.py index 2817f4c21ce..b6e7c8a70c9 100644 --- a/homeassistant/components/syncthru/__init__.py +++ b/homeassistant/components/syncthru/__init__.py @@ -2,21 +2,15 @@ from __future__ import annotations -import asyncio -from datetime import timedelta -import logging - -from pysyncthru import ConnectionMode, SyncThru, SyncThruAPINotSupported +from pysyncthru import SyncThru, SyncThruAPINotSupported from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_URL, Platform +from homeassistant.const import Platform from homeassistant.core import HomeAssistant -from homeassistant.helpers import aiohttp_client, device_registry as dr -from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed +from homeassistant.helpers import device_registry as dr from .const import DOMAIN - -_LOGGER = logging.getLogger(__name__) +from .coordinator import SyncthruCoordinator PLATFORMS = [Platform.BINARY_SENSOR, Platform.SENSOR] @@ -24,41 +18,9 @@ PLATFORMS = [Platform.BINARY_SENSOR, Platform.SENSOR] async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up config entry.""" - session = aiohttp_client.async_get_clientsession(hass) - hass.data.setdefault(DOMAIN, {}) - printer = SyncThru( - entry.data[CONF_URL], session, connection_mode=ConnectionMode.API - ) - - async def async_update_data() -> SyncThru: - """Fetch data from the printer.""" - try: - async with asyncio.timeout(10): - await printer.update() - except SyncThruAPINotSupported as api_error: - # if an exception is thrown, printer does not support syncthru - _LOGGER.debug( - "Configured printer at %s does not provide SyncThru JSON API", - printer.url, - exc_info=api_error, - ) - raise - - # if the printer is offline, we raise an UpdateFailed - if printer.is_unknown_state(): - raise UpdateFailed(f"Configured printer at {printer.url} does not respond.") - return printer - - coordinator = DataUpdateCoordinator[SyncThru]( - hass, - _LOGGER, - config_entry=entry, - name=DOMAIN, - update_method=async_update_data, - update_interval=timedelta(seconds=30), - ) + coordinator = SyncthruCoordinator(hass, entry) await coordinator.async_config_entry_first_refresh() - hass.data[DOMAIN][entry.entry_id] = coordinator + hass.data.setdefault(DOMAIN, {})[entry.entry_id] = coordinator if isinstance(coordinator.last_exception, SyncThruAPINotSupported): # this means that the printer does not support the syncthru JSON API # and the config should simply be discarded @@ -67,12 +29,12 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: device_registry = dr.async_get(hass) device_registry.async_get_or_create( config_entry_id=entry.entry_id, - configuration_url=printer.url, - connections=device_connections(printer), + configuration_url=coordinator.syncthru.url, + connections=device_connections(coordinator.syncthru), manufacturer="Samsung", - identifiers=device_identifiers(printer), - model=printer.model(), - name=printer.hostname(), + identifiers=device_identifiers(coordinator.syncthru), + model=coordinator.syncthru.model(), + name=coordinator.syncthru.hostname(), ) await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) diff --git a/homeassistant/components/syncthru/binary_sensor.py b/homeassistant/components/syncthru/binary_sensor.py index e6d26d22433..6f6bd73af77 100644 --- a/homeassistant/components/syncthru/binary_sensor.py +++ b/homeassistant/components/syncthru/binary_sensor.py @@ -2,7 +2,7 @@ from __future__ import annotations -from pysyncthru import SyncThru, SyncthruState +from pysyncthru import SyncthruState from homeassistant.components.binary_sensor import ( BinarySensorDeviceClass, @@ -13,12 +13,9 @@ from homeassistant.const import CONF_NAME from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from homeassistant.helpers.update_coordinator import ( - CoordinatorEntity, - DataUpdateCoordinator, -) +from homeassistant.helpers.update_coordinator import CoordinatorEntity -from . import device_identifiers +from . import SyncthruCoordinator, device_identifiers from .const import DOMAIN SYNCTHRU_STATE_PROBLEM = { @@ -39,9 +36,7 @@ async def async_setup_entry( ) -> None: """Set up from config entry.""" - coordinator: DataUpdateCoordinator[SyncThru] = hass.data[DOMAIN][ - config_entry.entry_id - ] + coordinator: SyncthruCoordinator = hass.data[DOMAIN][config_entry.entry_id] name: str = config_entry.data[CONF_NAME] entities = [ @@ -52,12 +47,10 @@ async def async_setup_entry( async_add_entities(entities) -class SyncThruBinarySensor( - CoordinatorEntity[DataUpdateCoordinator[SyncThru]], BinarySensorEntity -): +class SyncThruBinarySensor(CoordinatorEntity[SyncthruCoordinator], BinarySensorEntity): """Implementation of an abstract Samsung Printer binary sensor platform.""" - def __init__(self, coordinator: DataUpdateCoordinator[SyncThru], name: str) -> None: + def __init__(self, coordinator: SyncthruCoordinator, name: str) -> None: """Initialize the sensor.""" super().__init__(coordinator) self.syncthru = coordinator.data @@ -85,7 +78,7 @@ class SyncThruOnlineSensor(SyncThruBinarySensor): _attr_device_class = BinarySensorDeviceClass.CONNECTIVITY - def __init__(self, coordinator: DataUpdateCoordinator[SyncThru], name: str) -> None: + def __init__(self, coordinator: SyncthruCoordinator, name: str) -> None: """Initialize the sensor.""" super().__init__(coordinator, name) self._id_suffix = "_online" @@ -101,9 +94,9 @@ class SyncThruProblemSensor(SyncThruBinarySensor): _attr_device_class = BinarySensorDeviceClass.PROBLEM - def __init__(self, syncthru, name): + def __init__(self, coordinator: SyncthruCoordinator, name: str) -> None: """Initialize the sensor.""" - super().__init__(syncthru, name) + super().__init__(coordinator, name) self._id_suffix = "_problem" @property diff --git a/homeassistant/components/syncthru/coordinator.py b/homeassistant/components/syncthru/coordinator.py new file mode 100644 index 00000000000..8bb10e8c861 --- /dev/null +++ b/homeassistant/components/syncthru/coordinator.py @@ -0,0 +1,44 @@ +"""Coordinator for Syncthru integration.""" + +import asyncio +from datetime import timedelta +import logging + +from pysyncthru import ConnectionMode, SyncThru + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_URL +from homeassistant.core import HomeAssistant +from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed + +from .const import DOMAIN + +_LOGGER = logging.getLogger(__name__) + + +class SyncthruCoordinator(DataUpdateCoordinator[SyncThru]): + """Class to manage fetching Syncthru data.""" + + def __init__(self, hass: HomeAssistant, entry: ConfigEntry) -> None: + """Initialize the Syncthru coordinator.""" + super().__init__( + hass, + _LOGGER, + name=DOMAIN, + update_interval=timedelta(seconds=30), + ) + self.syncthru = SyncThru( + entry.data[CONF_URL], + async_get_clientsession(hass), + connection_mode=ConnectionMode.API, + ) + + async def _async_update_data(self) -> SyncThru: + async with asyncio.timeout(10): + await self.syncthru.update() + if self.syncthru.is_unknown_state(): + raise UpdateFailed( + f"Configured printer at {self.syncthru.url} does not respond." + ) + return self.syncthru diff --git a/homeassistant/components/syncthru/sensor.py b/homeassistant/components/syncthru/sensor.py index c2063bf6c0a..4abe0e41136 100644 --- a/homeassistant/components/syncthru/sensor.py +++ b/homeassistant/components/syncthru/sensor.py @@ -10,12 +10,9 @@ from homeassistant.const import CONF_NAME, PERCENTAGE from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from homeassistant.helpers.update_coordinator import ( - CoordinatorEntity, - DataUpdateCoordinator, -) +from homeassistant.helpers.update_coordinator import CoordinatorEntity -from . import device_identifiers +from . import SyncthruCoordinator, device_identifiers from .const import DOMAIN COLORS = ["black", "cyan", "magenta", "yellow"] @@ -47,9 +44,7 @@ async def async_setup_entry( ) -> None: """Set up from config entry.""" - coordinator: DataUpdateCoordinator[SyncThru] = hass.data[DOMAIN][ - config_entry.entry_id - ] + coordinator: SyncthruCoordinator = hass.data[DOMAIN][config_entry.entry_id] printer: SyncThru = coordinator.data supp_toner = printer.toner_status(filter_supported=True) @@ -75,12 +70,12 @@ async def async_setup_entry( async_add_entities(entities) -class SyncThruSensor(CoordinatorEntity[DataUpdateCoordinator[SyncThru]], SensorEntity): +class SyncThruSensor(CoordinatorEntity[SyncthruCoordinator], SensorEntity): """Implementation of an abstract Samsung Printer sensor platform.""" _attr_icon = "mdi:printer" - def __init__(self, coordinator: DataUpdateCoordinator[SyncThru], name: str) -> None: + def __init__(self, coordinator: SyncthruCoordinator, name: str) -> None: """Initialize the sensor.""" super().__init__(coordinator) self.syncthru = coordinator.data @@ -112,7 +107,7 @@ class SyncThruMainSensor(SyncThruSensor): _attr_entity_registry_enabled_default = False - def __init__(self, coordinator: DataUpdateCoordinator[SyncThru], name: str) -> None: + def __init__(self, coordinator: SyncthruCoordinator, name: str) -> None: """Initialize the sensor.""" super().__init__(coordinator, name) self._id_suffix = "_main" @@ -135,9 +130,7 @@ class SyncThruTonerSensor(SyncThruSensor): _attr_native_unit_of_measurement = PERCENTAGE - def __init__( - self, coordinator: DataUpdateCoordinator[SyncThru], name: str, color: str - ) -> None: + def __init__(self, coordinator: SyncthruCoordinator, name: str, color: str) -> None: """Initialize the sensor.""" super().__init__(coordinator, name) self._attr_name = f"{name} Toner {color}" @@ -160,9 +153,7 @@ class SyncThruDrumSensor(SyncThruSensor): _attr_native_unit_of_measurement = PERCENTAGE - def __init__( - self, coordinator: DataUpdateCoordinator[SyncThru], name: str, color: str - ) -> None: + def __init__(self, coordinator: SyncthruCoordinator, name: str, color: str) -> None: """Initialize the sensor.""" super().__init__(coordinator, name) self._attr_name = f"{name} Drum {color}" @@ -184,7 +175,7 @@ class SyncThruInputTraySensor(SyncThruSensor): """Implementation of a Samsung Printer input tray sensor platform.""" def __init__( - self, coordinator: DataUpdateCoordinator[SyncThru], name: str, number: str + self, coordinator: SyncthruCoordinator, name: str, number: str ) -> None: """Initialize the sensor.""" super().__init__(coordinator, name) @@ -212,7 +203,7 @@ class SyncThruOutputTraySensor(SyncThruSensor): """Implementation of a Samsung Printer output tray sensor platform.""" def __init__( - self, coordinator: DataUpdateCoordinator[SyncThru], name: str, number: int + self, coordinator: SyncthruCoordinator, name: str, number: int ) -> None: """Initialize the sensor.""" super().__init__(coordinator, name) @@ -239,7 +230,7 @@ class SyncThruOutputTraySensor(SyncThruSensor): class SyncThruActiveAlertSensor(SyncThruSensor): """Implementation of a Samsung Printer active alerts sensor platform.""" - def __init__(self, coordinator: DataUpdateCoordinator[SyncThru], name: str) -> None: + def __init__(self, coordinator: SyncthruCoordinator, name: str) -> None: """Initialize the sensor.""" super().__init__(coordinator, name) self._attr_name = f"{name} Active Alerts" diff --git a/tests/components/syncthru/conftest.py b/tests/components/syncthru/conftest.py index be5896c4956..1142726d04e 100644 --- a/tests/components/syncthru/conftest.py +++ b/tests/components/syncthru/conftest.py @@ -27,7 +27,7 @@ def mock_syncthru() -> Generator[AsyncMock]: """Mock the SyncThru class.""" with ( patch( - "homeassistant.components.syncthru.SyncThru", + "homeassistant.components.syncthru.coordinator.SyncThru", autospec=True, ) as mock_syncthru, patch( From ea38639395be2ec4905bb8bfbc51495d0c9d8aa3 Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Thu, 10 Apr 2025 22:32:17 +0200 Subject: [PATCH 445/505] Validate MQTT device tracker location data before assigning (#141980) * Validate MQTT device tracker location data before assigning * Log warning for invalid gps_accuracy --- .../components/mqtt/device_tracker.py | 53 +++++++++++++-- tests/components/mqtt/test_device_tracker.py | 68 +++++++++++++++++++ 2 files changed, 116 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/mqtt/device_tracker.py b/homeassistant/components/mqtt/device_tracker.py index 87adde14d03..9a10170641e 100644 --- a/homeassistant/components/mqtt/device_tracker.py +++ b/homeassistant/components/mqtt/device_tracker.py @@ -28,7 +28,12 @@ from homeassistant.helpers.typing import ConfigType, VolSchemaType from . import subscription from .config import MQTT_BASE_SCHEMA -from .const import CONF_JSON_ATTRS_TOPIC, CONF_PAYLOAD_RESET, CONF_STATE_TOPIC +from .const import ( + CONF_JSON_ATTRS_TEMPLATE, + CONF_JSON_ATTRS_TOPIC, + CONF_PAYLOAD_RESET, + CONF_STATE_TOPIC, +) from .entity import MqttEntity, async_setup_entity_entry_helper from .models import MqttValueTemplate, ReceiveMessage from .schemas import MQTT_ENTITY_COMMON_SCHEMA @@ -151,16 +156,54 @@ class MqttDeviceTracker(MqttEntity, TrackerEntity): self, extra_state_attributes: dict[str, Any] ) -> None: """Extract the location from the extra state attributes.""" - self._attr_latitude = extra_state_attributes.get(ATTR_LATITUDE) - self._attr_longitude = extra_state_attributes.get(ATTR_LONGITUDE) if ( ATTR_LATITUDE in extra_state_attributes or ATTR_LONGITUDE in extra_state_attributes ): - # Reset manual set location + latitude: float | None + longitude: float | None + gps_accuracy: int + # Reset manually set location to allow automatic zone detection self._attr_location_name = None + if isinstance( + latitude := extra_state_attributes.get(ATTR_LATITUDE), (int, float) + ) and isinstance( + longitude := extra_state_attributes.get(ATTR_LONGITUDE), (int, float) + ): + self._attr_latitude = latitude + self._attr_longitude = longitude + else: + # Invalid or incomplete coordinates, reset location + self._attr_latitude = None + self._attr_longitude = None + _LOGGER.warning( + "Extra state attributes received at % and template %s " + "contain invalid or incomplete location info. Got %s", + self._config.get(CONF_JSON_ATTRS_TEMPLATE), + self._config.get(CONF_JSON_ATTRS_TOPIC), + extra_state_attributes, + ) + + if ATTR_GPS_ACCURACY in extra_state_attributes: + if isinstance( + gps_accuracy := extra_state_attributes[ATTR_GPS_ACCURACY], + (int, float), + ): + self._attr_location_accuracy = gps_accuracy + else: + _LOGGER.warning( + "Extra state attributes received at % and template %s " + "contain invalid GPS accuracy setting, " + "gps_accuracy was set to 0 as the default. Got %s", + self._config.get(CONF_JSON_ATTRS_TEMPLATE), + self._config.get(CONF_JSON_ATTRS_TOPIC), + extra_state_attributes, + ) + self._attr_location_accuracy = 0 + + else: + self._attr_location_accuracy = 0 - self._attr_location_accuracy = extra_state_attributes.get(ATTR_GPS_ACCURACY, 0) self._attr_extra_state_attributes = { attribute: value for attribute, value in extra_state_attributes.items() diff --git a/tests/components/mqtt/test_device_tracker.py b/tests/components/mqtt/test_device_tracker.py index c2b2ea73a4d..cd87ce9717a 100644 --- a/tests/components/mqtt/test_device_tracker.py +++ b/tests/components/mqtt/test_device_tracker.py @@ -450,14 +450,82 @@ async def test_setting_device_tracker_location_via_lat_lon_message( assert state.attributes["latitude"] == 50.1 assert state.attributes["longitude"] == -2.1 assert state.attributes["gps_accuracy"] == 0 + assert state.attributes["source_type"] == "gps" assert state.state == STATE_NOT_HOME + # incomplete coordinates results in unknown state async_fire_mqtt_message(hass, "attributes-topic", '{"longitude": -117.22743}') state = hass.states.get("device_tracker.test") + assert "latitude" not in state.attributes + assert "longitude" not in state.attributes + assert state.attributes["source_type"] == "gps" assert state.state == STATE_UNKNOWN async_fire_mqtt_message(hass, "attributes-topic", '{"latitude":32.87336}') state = hass.states.get("device_tracker.test") + assert "latitude" not in state.attributes + assert "longitude" not in state.attributes + assert state.attributes["source_type"] == "gps" + assert state.state == STATE_UNKNOWN + + # invalid coordinates results in unknown state + async_fire_mqtt_message( + hass, "attributes-topic", '{"longitude": -117.22743, "latitude":null}' + ) + state = hass.states.get("device_tracker.test") + assert "latitude" not in state.attributes + assert "longitude" not in state.attributes + assert state.attributes["source_type"] == "gps" + assert state.state == STATE_UNKNOWN + + # Test number validation + async_fire_mqtt_message( + hass, + "attributes-topic", + '{"latitude": "32.87336","longitude": "-117.22743", "gps_accuracy": "1.5", "source_type": "router"}', + ) + state = hass.states.get("device_tracker.test") + assert "latitude" not in state.attributes + assert "longitude" not in state.attributes + assert "gps_accuracy" not in state.attributes + # assert source_type is overridden by discovery + assert state.attributes["source_type"] == "router" + assert state.state == STATE_UNKNOWN + + # Test with invalid GPS accuracy should default to 0, + # but location updates as expected + async_fire_mqtt_message( + hass, + "attributes-topic", + '{"latitude": 32.871234,"longitude": -117.21234, "gps_accuracy": "invalid", "source_type": "router"}', + ) + state = hass.states.get("device_tracker.test") + assert state.state == STATE_NOT_HOME + assert state.attributes["latitude"] == 32.871234 + assert state.attributes["longitude"] == -117.21234 + assert state.attributes["gps_accuracy"] == 0 + assert state.attributes["source_type"] == "router" + + # Test with invalid latitude + async_fire_mqtt_message( + hass, + "attributes-topic", + '{"latitude": null,"longitude": "-117.22743", "gps_accuracy": 1, "source_type": "router"}', + ) + state = hass.states.get("device_tracker.test") + assert "latitude" not in state.attributes + assert "longitude" not in state.attributes + assert state.state == STATE_UNKNOWN + + # Test with invalid longitude + async_fire_mqtt_message( + hass, + "attributes-topic", + '{"latitude": 32.87336,"longitude": "unknown", "gps_accuracy": 1, "source_type": "router"}', + ) + state = hass.states.get("device_tracker.test") + assert "latitude" not in state.attributes + assert "longitude" not in state.attributes assert state.state == STATE_UNKNOWN From 4ad5eb5a827762ecb0d573a7b7dd8a9403a02f69 Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Thu, 10 Apr 2025 23:11:35 +0200 Subject: [PATCH 446/505] Fix EC certificate key not allowed in MQTT client setup (#142698) --- homeassistant/components/mqtt/config_flow.py | 1 + 1 file changed, 1 insertion(+) diff --git a/homeassistant/components/mqtt/config_flow.py b/homeassistant/components/mqtt/config_flow.py index 83592c4c23d..ecb7d9cfeb1 100644 --- a/homeassistant/components/mqtt/config_flow.py +++ b/homeassistant/components/mqtt/config_flow.py @@ -1673,6 +1673,7 @@ def async_is_pem_data(data: bytes) -> bool: return ( b"-----BEGIN CERTIFICATE-----" in data or b"-----BEGIN PRIVATE KEY-----" in data + or b"-----BEGIN EC PRIVATE KEY-----" in data or b"-----BEGIN RSA PRIVATE KEY-----" in data or b"-----BEGIN ENCRYPTED PRIVATE KEY-----" in data ) From 2eb1041f4b424f233a76b1bd6f448b5c8dcdd251 Mon Sep 17 00:00:00 2001 From: Renier Moorcroft <66512715+RenierM26@users.noreply.github.com> Date: Fri, 11 Apr 2025 00:07:03 +0200 Subject: [PATCH 447/505] Use sub stream as default option for EZVIZ (#136023) --- homeassistant/components/ezviz/const.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/ezviz/const.py b/homeassistant/components/ezviz/const.py index e6de538335c..1d165c7bbe8 100644 --- a/homeassistant/components/ezviz/const.py +++ b/homeassistant/components/ezviz/const.py @@ -32,4 +32,4 @@ EU_URL = "apiieu.ezvizlife.com" RUSSIA_URL = "apirus.ezvizru.com" DEFAULT_CAMERA_USERNAME = "admin" DEFAULT_TIMEOUT = 25 -DEFAULT_FFMPEG_ARGUMENTS = "" +DEFAULT_FFMPEG_ARGUMENTS = "/Streaming/Channels/102" From c6994731b14aed4fcbc2d0116288214938255975 Mon Sep 17 00:00:00 2001 From: Hugo van Rijswijk Date: Fri, 11 Apr 2025 00:24:59 +0200 Subject: [PATCH 448/505] Add Buienradar apparent temperature and forecast rain chance & wind gust (#135287) Co-authored-by: Joost Lekkerkerker --- homeassistant/components/buienradar/util.py | 18 ++++++++++++++++++ homeassistant/components/buienradar/weather.py | 5 +++++ 2 files changed, 23 insertions(+) diff --git a/homeassistant/components/buienradar/util.py b/homeassistant/components/buienradar/util.py index a7267320de3..4d54c95fd6c 100644 --- a/homeassistant/components/buienradar/util.py +++ b/homeassistant/components/buienradar/util.py @@ -12,6 +12,7 @@ from buienradar.constants import ( CONDITION, CONTENT, DATA, + FEELTEMPERATURE, FORECAST, HUMIDITY, MESSAGE, @@ -22,6 +23,7 @@ from buienradar.constants import ( TEMPERATURE, VISIBILITY, WINDAZIMUTH, + WINDGUST, WINDSPEED, ) from buienradar.urls import JSON_FEED_URL, json_precipitation_forecast_url @@ -200,6 +202,14 @@ class BrData: except (ValueError, TypeError): return None + @property + def feeltemperature(self): + """Return the feeltemperature, or None.""" + try: + return float(self.data.get(FEELTEMPERATURE)) + except (ValueError, TypeError): + return None + @property def pressure(self): """Return the pressure, or None.""" @@ -224,6 +234,14 @@ class BrData: except (ValueError, TypeError): return None + @property + def wind_gust(self): + """Return the windgust, or None.""" + try: + return float(self.data.get(WINDGUST)) + except (ValueError, TypeError): + return None + @property def wind_speed(self): """Return the windspeed, or None.""" diff --git a/homeassistant/components/buienradar/weather.py b/homeassistant/components/buienradar/weather.py index 4b71024c241..568926ef0cd 100644 --- a/homeassistant/components/buienradar/weather.py +++ b/homeassistant/components/buienradar/weather.py @@ -9,6 +9,7 @@ from buienradar.constants import ( MAX_TEMP, MIN_TEMP, RAIN, + RAIN_CHANCE, WINDAZIMUTH, WINDSPEED, ) @@ -33,6 +34,7 @@ from homeassistant.components.weather import ( ATTR_FORECAST_NATIVE_TEMP, ATTR_FORECAST_NATIVE_TEMP_LOW, ATTR_FORECAST_NATIVE_WIND_SPEED, + ATTR_FORECAST_PRECIPITATION_PROBABILITY, ATTR_FORECAST_TIME, ATTR_FORECAST_WIND_BEARING, Forecast, @@ -153,7 +155,9 @@ class BrWeather(WeatherEntity): ) self._attr_native_pressure = data.pressure self._attr_native_temperature = data.temperature + self._attr_native_apparent_temperature = data.feeltemperature self._attr_native_visibility = data.visibility + self._attr_native_wind_gust_speed = data.wind_gust self._attr_native_wind_speed = data.wind_speed self._attr_wind_bearing = data.wind_bearing @@ -188,6 +192,7 @@ class BrWeather(WeatherEntity): ATTR_FORECAST_NATIVE_TEMP_LOW: data_in.get(MIN_TEMP), ATTR_FORECAST_NATIVE_TEMP: data_in.get(MAX_TEMP), ATTR_FORECAST_NATIVE_PRECIPITATION: data_in.get(RAIN), + ATTR_FORECAST_PRECIPITATION_PROBABILITY: data_in.get(RAIN_CHANCE), ATTR_FORECAST_WIND_BEARING: data_in.get(WINDAZIMUTH), ATTR_FORECAST_NATIVE_WIND_SPEED: data_in.get(WINDSPEED), } From 32da8c52f7dd60003a7e71bb114274a557e0dfb3 Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Fri, 11 Apr 2025 00:58:48 +0200 Subject: [PATCH 449/505] Add test to assert different private key types are accepted and stored correctly in MQTT config flow (#142703) --- tests/components/mqtt/test_config_flow.py | 67 +++++++++++++++++++---- 1 file changed, 55 insertions(+), 12 deletions(-) diff --git a/tests/components/mqtt/test_config_flow.py b/tests/components/mqtt/test_config_flow.py index c94d692b374..cfc9e0bede0 100644 --- a/tests/components/mqtt/test_config_flow.py +++ b/tests/components/mqtt/test_config_flow.py @@ -77,6 +77,16 @@ MOCK_CLIENT_KEY = ( b"## mock client key file ##" b"\n-----END PRIVATE KEY-----" ) +MOCK_EC_CLIENT_KEY = ( + b"-----BEGIN EC PRIVATE KEY-----\n" + b"## mock client key file ##" + b"\n-----END EC PRIVATE KEY-----" +) +MOCK_RSA_CLIENT_KEY = ( + b"-----BEGIN RSA PRIVATE KEY-----\n" + b"## mock client key file ##" + b"\n-----END RSA PRIVATE KEY-----" +) MOCK_ENCRYPTED_CLIENT_KEY = ( b"-----BEGIN ENCRYPTED PRIVATE KEY-----\n" b"## mock client key file ##\n" @@ -139,7 +149,13 @@ def mock_client_key_check_fail() -> Generator[MagicMock]: @pytest.fixture -def mock_ssl_context() -> Generator[dict[str, MagicMock]]: +def mock_context_client_key() -> bytes: + """Mock the client key in the moched ssl context.""" + return MOCK_CLIENT_KEY + + +@pytest.fixture +def mock_ssl_context(mock_context_client_key: bytes) -> Generator[dict[str, MagicMock]]: """Mock the SSL context used to load the cert chain and to load verify locations.""" with ( patch("homeassistant.components.mqtt.config_flow.SSLContext") as mock_context, @@ -156,9 +172,9 @@ def mock_ssl_context() -> Generator[dict[str, MagicMock]]: "homeassistant.components.mqtt.config_flow.load_der_x509_certificate" ) as mock_der_cert_check, ): - mock_pem_key_check().private_bytes.return_value = MOCK_CLIENT_KEY + mock_pem_key_check().private_bytes.return_value = mock_context_client_key mock_pem_cert_check().public_bytes.return_value = MOCK_GENERIC_CERT - mock_der_key_check().private_bytes.return_value = MOCK_CLIENT_KEY + mock_der_key_check().private_bytes.return_value = mock_context_client_key mock_der_cert_check().public_bytes.return_value = MOCK_GENERIC_CERT yield { "context": mock_context, @@ -1952,9 +1968,15 @@ async def test_options_bad_will_message_fails( } +@pytest.mark.parametrize( + "mock_context_client_key", + [MOCK_CLIENT_KEY, MOCK_EC_CLIENT_KEY, MOCK_RSA_CLIENT_KEY], +) @pytest.mark.usefixtures("mock_ssl_context", "mock_process_uploaded_file") async def test_try_connection_with_advanced_parameters( - hass: HomeAssistant, mock_try_connection_success: MqttMockPahoClient + hass: HomeAssistant, + mock_try_connection_success: MqttMockPahoClient, + mock_context_client_key: bytes, ) -> None: """Test config flow with advanced parameters from config.""" config_entry = MockConfigEntry( @@ -1974,7 +1996,7 @@ async def test_try_connection_with_advanced_parameters( mqtt.CONF_CERTIFICATE: "auto", mqtt.CONF_TLS_INSECURE: True, mqtt.CONF_CLIENT_CERT: MOCK_CLIENT_CERT.decode(encoding="utf-8)"), - mqtt.CONF_CLIENT_KEY: MOCK_CLIENT_KEY.decode(encoding="utf-8"), + mqtt.CONF_CLIENT_KEY: mock_context_client_key.decode(encoding="utf-8"), mqtt.CONF_WS_PATH: "/path/", mqtt.CONF_WS_HEADERS: {"h1": "v1", "h2": "v2"}, mqtt.CONF_KEEPALIVE: 30, @@ -2047,13 +2069,34 @@ async def test_try_connection_with_advanced_parameters( # check if tls_insecure_set is called assert mock_try_connection_success.tls_insecure_set.mock_calls[0][1] == (True,) - # check if the ca certificate settings were not set during connection test - assert mock_try_connection_success.tls_set.mock_calls[0].kwargs[ - "certfile" - ] == mqtt.util.get_file_path(mqtt.CONF_CLIENT_CERT) - assert mock_try_connection_success.tls_set.mock_calls[0].kwargs[ - "keyfile" - ] == mqtt.util.get_file_path(mqtt.CONF_CLIENT_KEY) + def read_file(path: Path) -> bytes: + with open(path, mode="rb") as file: + return file.read() + + # check if the client certificate settings saved + client_cert_path = await hass.async_add_executor_job( + mqtt.util.get_file_path, mqtt.CONF_CLIENT_CERT + ) + assert ( + mock_try_connection_success.tls_set.mock_calls[0].kwargs["certfile"] + == client_cert_path + ) + assert ( + await hass.async_add_executor_job(read_file, client_cert_path) + == MOCK_CLIENT_CERT + ) + + client_key_path = await hass.async_add_executor_job( + mqtt.util.get_file_path, mqtt.CONF_CLIENT_KEY + ) + assert ( + mock_try_connection_success.tls_set.mock_calls[0].kwargs["keyfile"] + == client_key_path + ) + assert ( + await hass.async_add_executor_job(read_file, client_key_path) + == mock_context_client_key + ) # check if websockets options are set assert mock_try_connection_success.ws_set_options.mock_calls[0][1] == ( From f519b20495304231e0662895f5273b145d4573f0 Mon Sep 17 00:00:00 2001 From: Christopher Fenner <9592452+CFenner@users.noreply.github.com> Date: Fri, 11 Apr 2025 08:11:53 +0200 Subject: [PATCH 450/505] Add device error sensor to ViCare integration (#142605) * add error sensor * remove translation --- homeassistant/components/vicare/binary_sensor.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/homeassistant/components/vicare/binary_sensor.py b/homeassistant/components/vicare/binary_sensor.py index 902dfd18d30..a032b1fbbcb 100644 --- a/homeassistant/components/vicare/binary_sensor.py +++ b/homeassistant/components/vicare/binary_sensor.py @@ -112,6 +112,11 @@ GLOBAL_SENSORS: tuple[ViCareBinarySensorEntityDescription, ...] = ( device_class=BinarySensorDeviceClass.RUNNING, value_getter=lambda api: api.getOneTimeCharge(), ), + ViCareBinarySensorEntityDescription( + key="device_error", + device_class=BinarySensorDeviceClass.PROBLEM, + value_getter=lambda api: len(api.getDeviceErrors()) > 0, + ), ) From 56c4121eb2045db72e2e626818082df3fcf94648 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Fri, 11 Apr 2025 08:12:59 +0200 Subject: [PATCH 451/505] Refactor Syncthru sensor platform (#142704) --- homeassistant/components/syncthru/sensor.py | 291 ++++++++------------ 1 file changed, 115 insertions(+), 176 deletions(-) diff --git a/homeassistant/components/syncthru/sensor.py b/homeassistant/components/syncthru/sensor.py index 4abe0e41136..3f4c802e62d 100644 --- a/homeassistant/components/syncthru/sensor.py +++ b/homeassistant/components/syncthru/sensor.py @@ -2,9 +2,13 @@ from __future__ import annotations +from collections.abc import Callable +from dataclasses import dataclass +from typing import Any, cast + from pysyncthru import SyncThru, SyncthruState -from homeassistant.components.sensor import SensorEntity +from homeassistant.components.sensor import SensorEntity, SensorEntityDescription from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_NAME, PERCENTAGE from homeassistant.core import HomeAssistant @@ -15,17 +19,6 @@ from homeassistant.helpers.update_coordinator import CoordinatorEntity from . import SyncthruCoordinator, device_identifiers from .const import DOMAIN -COLORS = ["black", "cyan", "magenta", "yellow"] -DRUM_COLORS = COLORS -TONER_COLORS = COLORS -TRAYS = range(1, 6) -OUTPUT_TRAYS = range(6) -DEFAULT_MONITORED_CONDITIONS = [] -DEFAULT_MONITORED_CONDITIONS.extend([f"toner_{key}" for key in TONER_COLORS]) -DEFAULT_MONITORED_CONDITIONS.extend([f"drum_{key}" for key in DRUM_COLORS]) -DEFAULT_MONITORED_CONDITIONS.extend([f"tray_{key}" for key in TRAYS]) -DEFAULT_MONITORED_CONDITIONS.extend([f"output_tray_{key}" for key in OUTPUT_TRAYS]) - SYNCTHRU_STATE_HUMAN = { SyncthruState.INVALID: "invalid", SyncthruState.OFFLINE: "unreachable", @@ -37,6 +30,85 @@ SYNCTHRU_STATE_HUMAN = { } +@dataclass(frozen=True, kw_only=True) +class SyncThruSensorDescription(SensorEntityDescription): + """Describes a SyncThru sensor entity.""" + + value_fn: Callable[[SyncThru], str | None] + extra_state_attributes_fn: Callable[[SyncThru], dict[str, str | int]] | None = None + + +def get_toner_entity_description(color: str) -> SyncThruSensorDescription: + """Get toner entity description for a specific color.""" + return SyncThruSensorDescription( + key=f"toner_{color}", + name=f"Toner {color}", + native_unit_of_measurement=PERCENTAGE, + value_fn=lambda printer: printer.toner_status().get(color, {}).get("remaining"), + extra_state_attributes_fn=lambda printer: printer.toner_status().get(color, {}), + ) + + +def get_drum_entity_description(color: str) -> SyncThruSensorDescription: + """Get drum entity description for a specific color.""" + return SyncThruSensorDescription( + key=f"drum_{color}", + name=f"Drum {color}", + native_unit_of_measurement=PERCENTAGE, + value_fn=lambda printer: printer.drum_status().get(color, {}).get("remaining"), + extra_state_attributes_fn=lambda printer: printer.drum_status().get(color, {}), + ) + + +def get_input_tray_entity_description(tray: str) -> SyncThruSensorDescription: + """Get input tray entity description for a specific tray.""" + return SyncThruSensorDescription( + key=f"tray_{tray}", + name=f"Tray {tray}", + value_fn=( + lambda printer: printer.input_tray_status().get(tray, {}).get("newError") + or "Ready" + ), + extra_state_attributes_fn=( + lambda printer: printer.input_tray_status().get(tray, {}) + ), + ) + + +def get_output_tray_entity_description(tray: int) -> SyncThruSensorDescription: + """Get output tray entity description for a specific tray.""" + return SyncThruSensorDescription( + key=f"output_tray_{tray}", + name=f"Output Tray {tray}", + value_fn=( + lambda printer: printer.output_tray_status().get(tray, {}).get("status") + or "Ready" + ), + extra_state_attributes_fn=( + lambda printer: cast( + dict[str, str | int], printer.output_tray_status().get(tray, {}) + ) + ), + ) + + +SENSOR_TYPES: tuple[SyncThruSensorDescription, ...] = ( + SyncThruSensorDescription( + key="active_alerts", + name="Active Alerts", + value_fn=lambda printer: printer.raw().get("GXI_ACTIVE_ALERT_TOTAL"), + ), + SyncThruSensorDescription( + key="main", + name="", + value_fn=lambda printer: SYNCTHRU_STATE_HUMAN[printer.device_status()], + extra_state_attributes_fn=lambda printer: { + "display_text": printer.device_status_details(), + }, + ), +) + + async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, @@ -45,7 +117,7 @@ async def async_setup_entry( """Set up from config entry.""" coordinator: SyncthruCoordinator = hass.data[DOMAIN][config_entry.entry_id] - printer: SyncThru = coordinator.data + printer = coordinator.data supp_toner = printer.toner_status(filter_supported=True) supp_drum = printer.drum_status(filter_supported=True) @@ -53,40 +125,39 @@ async def async_setup_entry( supp_output_tray = printer.output_tray_status() name: str = config_entry.data[CONF_NAME] - entities: list[SyncThruSensor] = [ - SyncThruMainSensor(coordinator, name), - SyncThruActiveAlertSensor(coordinator, name), + entities: list[SyncThruSensorDescription] = [ + get_toner_entity_description(color) for color in supp_toner ] - entities.extend(SyncThruTonerSensor(coordinator, name, key) for key in supp_toner) - entities.extend(SyncThruDrumSensor(coordinator, name, key) for key in supp_drum) - entities.extend( - SyncThruInputTraySensor(coordinator, name, key) for key in supp_tray - ) - entities.extend( - SyncThruOutputTraySensor(coordinator, name, int_key) - for int_key in supp_output_tray - ) + entities.extend(get_drum_entity_description(color) for color in supp_drum) + entities.extend(get_input_tray_entity_description(key) for key in supp_tray) + entities.extend(get_output_tray_entity_description(key) for key in supp_output_tray) - async_add_entities(entities) + async_add_entities( + SyncThruSensor(coordinator, name, description) + for description in SENSOR_TYPES + tuple(entities) + ) class SyncThruSensor(CoordinatorEntity[SyncthruCoordinator], SensorEntity): """Implementation of an abstract Samsung Printer sensor platform.""" _attr_icon = "mdi:printer" + entity_description: SyncThruSensorDescription - def __init__(self, coordinator: SyncthruCoordinator, name: str) -> None: + def __init__( + self, + coordinator: SyncthruCoordinator, + name: str, + entity_description: SyncThruSensorDescription, + ) -> None: """Initialize the sensor.""" super().__init__(coordinator) + self.entity_description = entity_description self.syncthru = coordinator.data - self._attr_name = name - self._id_suffix = "" - - @property - def unique_id(self): - """Return unique ID for the sensor.""" - serial = self.syncthru.serial_number() - return f"{serial}{self._id_suffix}" if serial else None + self._attr_name = f"{name} {entity_description.name}".strip() + serial_number = coordinator.data.serial_number() + assert serial_number is not None + self._attr_unique_id = f"{serial_number}_{entity_description.key}" @property def device_info(self) -> DeviceInfo | None: @@ -97,146 +168,14 @@ class SyncThruSensor(CoordinatorEntity[SyncthruCoordinator], SensorEntity): identifiers=identifiers, ) - -class SyncThruMainSensor(SyncThruSensor): - """Implementation of the main sensor, conducting the actual polling. - - It also shows the detailed state and presents - the displayed current status message. - """ - - _attr_entity_registry_enabled_default = False - - def __init__(self, coordinator: SyncthruCoordinator, name: str) -> None: - """Initialize the sensor.""" - super().__init__(coordinator, name) - self._id_suffix = "_main" + @property + def native_value(self) -> str | int | None: + """Return the state of the sensor.""" + return self.entity_description.value_fn(self.syncthru) @property - def native_value(self): - """Set state to human readable version of syncthru status.""" - return SYNCTHRU_STATE_HUMAN[self.syncthru.device_status()] - - @property - def extra_state_attributes(self): - """Show current printer display text.""" - return { - "display_text": self.syncthru.device_status_details(), - } - - -class SyncThruTonerSensor(SyncThruSensor): - """Implementation of a Samsung Printer toner sensor platform.""" - - _attr_native_unit_of_measurement = PERCENTAGE - - def __init__(self, coordinator: SyncthruCoordinator, name: str, color: str) -> None: - """Initialize the sensor.""" - super().__init__(coordinator, name) - self._attr_name = f"{name} Toner {color}" - self._color = color - self._id_suffix = f"_toner_{color}" - - @property - def extra_state_attributes(self): - """Show all data returned for this toner.""" - return self.syncthru.toner_status().get(self._color, {}) - - @property - def native_value(self): - """Show amount of remaining toner.""" - return self.syncthru.toner_status().get(self._color, {}).get("remaining") - - -class SyncThruDrumSensor(SyncThruSensor): - """Implementation of a Samsung Printer drum sensor platform.""" - - _attr_native_unit_of_measurement = PERCENTAGE - - def __init__(self, coordinator: SyncthruCoordinator, name: str, color: str) -> None: - """Initialize the sensor.""" - super().__init__(coordinator, name) - self._attr_name = f"{name} Drum {color}" - self._color = color - self._id_suffix = f"_drum_{color}" - - @property - def extra_state_attributes(self): - """Show all data returned for this drum.""" - return self.syncthru.drum_status().get(self._color, {}) - - @property - def native_value(self): - """Show amount of remaining drum.""" - return self.syncthru.drum_status().get(self._color, {}).get("remaining") - - -class SyncThruInputTraySensor(SyncThruSensor): - """Implementation of a Samsung Printer input tray sensor platform.""" - - def __init__( - self, coordinator: SyncthruCoordinator, name: str, number: str - ) -> None: - """Initialize the sensor.""" - super().__init__(coordinator, name) - self._attr_name = f"{name} Tray {number}" - self._number = number - self._id_suffix = f"_tray_{number}" - - @property - def extra_state_attributes(self): - """Show all data returned for this input tray.""" - return self.syncthru.input_tray_status().get(self._number, {}) - - @property - def native_value(self): - """Display ready unless there is some error, then display error.""" - tray_state = ( - self.syncthru.input_tray_status().get(self._number, {}).get("newError") - ) - if tray_state == "": - tray_state = "Ready" - return tray_state - - -class SyncThruOutputTraySensor(SyncThruSensor): - """Implementation of a Samsung Printer output tray sensor platform.""" - - def __init__( - self, coordinator: SyncthruCoordinator, name: str, number: int - ) -> None: - """Initialize the sensor.""" - super().__init__(coordinator, name) - self._attr_name = f"{name} Output Tray {number}" - self._number = number - self._id_suffix = f"_output_tray_{number}" - - @property - def extra_state_attributes(self): - """Show all data returned for this output tray.""" - return self.syncthru.output_tray_status().get(self._number, {}) - - @property - def native_value(self): - """Display ready unless there is some error, then display error.""" - tray_state = ( - self.syncthru.output_tray_status().get(self._number, {}).get("status") - ) - if tray_state == "": - tray_state = "Ready" - return tray_state - - -class SyncThruActiveAlertSensor(SyncThruSensor): - """Implementation of a Samsung Printer active alerts sensor platform.""" - - def __init__(self, coordinator: SyncthruCoordinator, name: str) -> None: - """Initialize the sensor.""" - super().__init__(coordinator, name) - self._attr_name = f"{name} Active Alerts" - self._id_suffix = "_active_alerts" - - @property - def native_value(self): - """Show number of active alerts.""" - return self.syncthru.raw().get("GXI_ACTIVE_ALERT_TOTAL") + def extra_state_attributes(self) -> dict[str, Any] | None: + """Return the state attributes.""" + if self.entity_description.extra_state_attributes_fn: + return self.entity_description.extra_state_attributes_fn(self.syncthru) + return None From a06cd770a45f5cc118de0b7c580cccc9005cf5d4 Mon Sep 17 00:00:00 2001 From: starkillerOG Date: Fri, 11 Apr 2025 10:22:30 +0200 Subject: [PATCH 452/505] Bump reolink-aio 0.13.1 (#142719) --- homeassistant/components/reolink/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/reolink/manifest.json b/homeassistant/components/reolink/manifest.json index 82b9586cccc..9105dfda66f 100644 --- a/homeassistant/components/reolink/manifest.json +++ b/homeassistant/components/reolink/manifest.json @@ -19,5 +19,5 @@ "iot_class": "local_push", "loggers": ["reolink_aio"], "quality_scale": "platinum", - "requirements": ["reolink-aio==0.13.0"] + "requirements": ["reolink-aio==0.13.1"] } diff --git a/requirements_all.txt b/requirements_all.txt index 2d71d8ebe1b..8533afe0600 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2630,7 +2630,7 @@ renault-api==0.2.9 renson-endura-delta==1.7.2 # homeassistant.components.reolink -reolink-aio==0.13.0 +reolink-aio==0.13.1 # homeassistant.components.idteck_prox rfk101py==0.0.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index c8b0bfc5ea3..ddb84f768b7 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2134,7 +2134,7 @@ renault-api==0.2.9 renson-endura-delta==1.7.2 # homeassistant.components.reolink -reolink-aio==0.13.0 +reolink-aio==0.13.1 # homeassistant.components.rflink rflink==0.0.66 From dff7b3040500c8099815a4f81c968e735bee5f63 Mon Sep 17 00:00:00 2001 From: Christopher Fenner <9592452+CFenner@users.noreply.github.com> Date: Fri, 11 Apr 2025 10:32:54 +0200 Subject: [PATCH 453/505] Bump PyViCare to 2.44.0 (#142701) bump vicare to v2.44.0 --- homeassistant/components/vicare/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/vicare/manifest.json b/homeassistant/components/vicare/manifest.json index e39adaf6c4c..fed777e6435 100644 --- a/homeassistant/components/vicare/manifest.json +++ b/homeassistant/components/vicare/manifest.json @@ -11,5 +11,5 @@ "documentation": "https://www.home-assistant.io/integrations/vicare", "iot_class": "cloud_polling", "loggers": ["PyViCare"], - "requirements": ["PyViCare==2.43.1"] + "requirements": ["PyViCare==2.44.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index 8533afe0600..8602c699406 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -100,7 +100,7 @@ PyTransportNSW==0.1.1 PyTurboJPEG==1.7.5 # homeassistant.components.vicare -PyViCare==2.43.1 +PyViCare==2.44.0 # homeassistant.components.xiaomi_aqara PyXiaomiGateway==0.14.3 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index ddb84f768b7..da3e9daa3cc 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -94,7 +94,7 @@ PyTransportNSW==0.1.1 PyTurboJPEG==1.7.5 # homeassistant.components.vicare -PyViCare==2.43.1 +PyViCare==2.44.0 # homeassistant.components.xiaomi_aqara PyXiaomiGateway==0.14.3 From a4904a3f2d6ba4bf4bafd9534f32d05db989ccaa Mon Sep 17 00:00:00 2001 From: Stefan Agner Date: Fri, 11 Apr 2025 11:14:07 +0200 Subject: [PATCH 454/505] Bump aiohasupervisor from version 0.3.0 to version 0.3.1b1 (#142721) --- homeassistant/components/hassio/manifest.json | 2 +- homeassistant/package_constraints.txt | 2 +- pyproject.toml | 2 +- requirements.txt | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 6 files changed, 6 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/hassio/manifest.json b/homeassistant/components/hassio/manifest.json index ad98beb5baa..f267f8ce722 100644 --- a/homeassistant/components/hassio/manifest.json +++ b/homeassistant/components/hassio/manifest.json @@ -6,6 +6,6 @@ "documentation": "https://www.home-assistant.io/integrations/hassio", "iot_class": "local_polling", "quality_scale": "internal", - "requirements": ["aiohasupervisor==0.3.0"], + "requirements": ["aiohasupervisor==0.3.1b1"], "single_config_entry": true } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 3bcad4b8f30..cd51beebd41 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -3,7 +3,7 @@ aiodhcpwatcher==1.1.1 aiodiscover==2.6.1 aiodns==3.2.0 -aiohasupervisor==0.3.0 +aiohasupervisor==0.3.1b1 aiohttp-asyncmdnsresolver==0.1.1 aiohttp-fast-zlib==0.2.3 aiohttp==3.11.16 diff --git a/pyproject.toml b/pyproject.toml index 7c35d1d2f71..091bb617142 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -27,7 +27,7 @@ dependencies = [ # Integrations may depend on hassio integration without listing it to # change behavior based on presence of supervisor. Deprecated with #127228 # Lib can be removed with 2025.11 - "aiohasupervisor==0.3.0", + "aiohasupervisor==0.3.1b1", "aiohttp==3.11.16", "aiohttp_cors==0.7.0", "aiohttp-fast-zlib==0.2.3", diff --git a/requirements.txt b/requirements.txt index b07a8710e5d..a4b91259ef3 100644 --- a/requirements.txt +++ b/requirements.txt @@ -4,7 +4,7 @@ # Home Assistant Core aiodns==3.2.0 -aiohasupervisor==0.3.0 +aiohasupervisor==0.3.1b1 aiohttp==3.11.16 aiohttp_cors==0.7.0 aiohttp-fast-zlib==0.2.3 diff --git a/requirements_all.txt b/requirements_all.txt index 8602c699406..df4c7b72cef 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -261,7 +261,7 @@ aioguardian==2022.07.0 aioharmony==0.5.2 # homeassistant.components.hassio -aiohasupervisor==0.3.0 +aiohasupervisor==0.3.1b1 # homeassistant.components.home_connect aiohomeconnect==0.17.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index da3e9daa3cc..f5d97d0a86c 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -246,7 +246,7 @@ aioguardian==2022.07.0 aioharmony==0.5.2 # homeassistant.components.hassio -aiohasupervisor==0.3.0 +aiohasupervisor==0.3.1b1 # homeassistant.components.home_connect aiohomeconnect==0.17.0 From e1d223f7262a1477e5a0691acf85c828c63dd4b4 Mon Sep 17 00:00:00 2001 From: Simone Chemelli Date: Fri, 11 Apr 2025 11:32:19 +0200 Subject: [PATCH 455/505] Add exceptions translation to SamsungTV (#142406) * Add exceptions translation to SmasungTV * Update strings.json Co-authored-by: Franck Nijhof --------- Co-authored-by: Franck Nijhof --- homeassistant/components/samsungtv/device_trigger.py | 7 ++++++- homeassistant/components/samsungtv/entity.py | 4 +++- homeassistant/components/samsungtv/strings.json | 8 ++++++++ tests/components/samsungtv/test_remote.py | 7 ++++++- 4 files changed, 23 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/samsungtv/device_trigger.py b/homeassistant/components/samsungtv/device_trigger.py index 2b3d9dbe666..749276b61c4 100644 --- a/homeassistant/components/samsungtv/device_trigger.py +++ b/homeassistant/components/samsungtv/device_trigger.py @@ -15,6 +15,7 @@ from homeassistant.helpers.trigger import TriggerActionType, TriggerInfo from homeassistant.helpers.typing import ConfigType from . import trigger +from .const import DOMAIN from .helpers import ( async_get_client_by_device_entry, async_get_device_entry_by_device_id, @@ -75,4 +76,8 @@ async def async_attach_trigger( hass, trigger_config, action, trigger_info ) - raise HomeAssistantError(f"Unhandled trigger type {trigger_type}") + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="unhandled_trigger_type", + translation_placeholders={"trigger_type": trigger_type}, + ) diff --git a/homeassistant/components/samsungtv/entity.py b/homeassistant/components/samsungtv/entity.py index 61aa8abce53..f3ecee373e3 100644 --- a/homeassistant/components/samsungtv/entity.py +++ b/homeassistant/components/samsungtv/entity.py @@ -106,5 +106,7 @@ class SamsungTVEntity(CoordinatorEntity[SamsungTVDataUpdateCoordinator], Entity) self.entity_id, ) raise HomeAssistantError( - f"Entity {self.entity_id} does not support this service." + translation_domain=DOMAIN, + translation_key="service_unsupported", + translation_placeholders={"entity": self.entity_id}, ) diff --git a/homeassistant/components/samsungtv/strings.json b/homeassistant/components/samsungtv/strings.json index d08e2a843ba..6e72c2b8d13 100644 --- a/homeassistant/components/samsungtv/strings.json +++ b/homeassistant/components/samsungtv/strings.json @@ -60,5 +60,13 @@ "trigger_type": { "samsungtv.turn_on": "Device is requested to turn on" } + }, + "exceptions": { + "unhandled_trigger_type": { + "message": "Unhandled trigger type {trigger_type}." + }, + "service_unsupported": { + "message": "Entity {entity} does not support this action." + } } } diff --git a/tests/components/samsungtv/test_remote.py b/tests/components/samsungtv/test_remote.py index 854c92207bf..da7871ca9c5 100644 --- a/tests/components/samsungtv/test_remote.py +++ b/tests/components/samsungtv/test_remote.py @@ -122,9 +122,14 @@ async def test_turn_on_wol(hass: HomeAssistant) -> None: async def test_turn_on_without_turnon(hass: HomeAssistant, remote: Mock) -> None: """Test turn on.""" await setup_samsungtv_entry(hass, MOCK_CONFIG) - with pytest.raises(HomeAssistantError, match="does not support this service"): + with pytest.raises(HomeAssistantError) as exc_info: await hass.services.async_call( REMOTE_DOMAIN, SERVICE_TURN_ON, {ATTR_ENTITY_ID: ENTITY_ID}, True ) # nothing called as not supported feature assert remote.control.call_count == 0 + assert exc_info.value.translation_domain == DOMAIN + assert exc_info.value.translation_key == "service_unsupported" + assert exc_info.value.translation_placeholders == { + "entity": ENTITY_ID, + } From 16d9ccd423e6de12a96cb09d6ce6422fd9573107 Mon Sep 17 00:00:00 2001 From: starkillerOG Date: Fri, 11 Apr 2025 11:42:18 +0200 Subject: [PATCH 456/505] Reolink migrate unique ID debugging (#142723) * Filter out unexpected unique_ids * correct * Add test * fix styling --- homeassistant/components/reolink/__init__.py | 8 ++++++++ tests/components/reolink/test_init.py | 15 +++++++++++++-- 2 files changed, 21 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/reolink/__init__.py b/homeassistant/components/reolink/__init__.py index 99ca91c5bdf..c326f1120c9 100644 --- a/homeassistant/components/reolink/__init__.py +++ b/homeassistant/components/reolink/__init__.py @@ -420,6 +420,14 @@ def migrate_entity_ids( if entity.device_id in ch_device_ids: ch = ch_device_ids[entity.device_id] id_parts = entity.unique_id.split("_", 2) + if len(id_parts) < 3: + _LOGGER.warning( + "Reolink channel %s entity has unexpected unique_id format %s, with device id %s", + ch, + entity.unique_id, + entity.device_id, + ) + continue if host.api.supported(ch, "UID") and id_parts[1] != host.api.camera_uid(ch): new_id = f"{host.unique_id}_{host.api.camera_uid(ch)}_{id_parts[2]}" existing_entity = entity_reg.async_get_entity_id( diff --git a/tests/components/reolink/test_init.py b/tests/components/reolink/test_init.py index 4c4908dca6f..5915bd06608 100644 --- a/tests/components/reolink/test_init.py +++ b/tests/components/reolink/test_init.py @@ -424,6 +424,15 @@ async def test_removing_chime( True, True, ), + ( + f"{TEST_UID}_unexpected", + f"{TEST_UID}_unexpected", + f"{TEST_UID}_{TEST_UID_CAM}", + f"{TEST_UID}_{TEST_UID_CAM}", + Platform.SWITCH, + True, + True, + ), ], ) async def test_migrate_entity_ids( @@ -469,7 +478,8 @@ async def test_migrate_entity_ids( ) assert entity_registry.async_get_entity_id(domain, DOMAIN, original_id) - assert entity_registry.async_get_entity_id(domain, DOMAIN, new_id) is None + if original_id != new_id: + assert entity_registry.async_get_entity_id(domain, DOMAIN, new_id) is None assert device_registry.async_get_device(identifiers={(DOMAIN, original_dev_id)}) if new_dev_id != original_dev_id: @@ -482,7 +492,8 @@ async def test_migrate_entity_ids( assert await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() - assert entity_registry.async_get_entity_id(domain, DOMAIN, original_id) is None + if original_id != new_id: + assert entity_registry.async_get_entity_id(domain, DOMAIN, original_id) is None assert entity_registry.async_get_entity_id(domain, DOMAIN, new_id) if new_dev_id != original_dev_id: From af8ecdd48d65bbfbac001e7f28c4a7b0929694a6 Mon Sep 17 00:00:00 2001 From: Martin Hjelmare Date: Fri, 11 Apr 2025 12:15:11 +0200 Subject: [PATCH 457/505] Improve Z-Wave reconfigure flow (#142475) --- .../components/zwave_js/config_flow.py | 20 +++- .../components/zwave_js/strings.json | 1 + tests/components/zwave_js/test_config_flow.py | 107 ++++++++++++++++++ 3 files changed, 123 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/zwave_js/config_flow.py b/homeassistant/components/zwave_js/config_flow.py index 20ebe94c00e..1337331bfb6 100644 --- a/homeassistant/components/zwave_js/config_flow.py +++ b/homeassistant/components/zwave_js/config_flow.py @@ -23,7 +23,6 @@ from homeassistant.config_entries import ( SOURCE_USB, ConfigEntry, ConfigEntryBaseFlow, - ConfigEntryState, ConfigFlow, ConfigFlowResult, OptionsFlow, @@ -787,7 +786,21 @@ class OptionsFlowHandler(BaseZwaveJSFlow, OptionsFlow): {CONF_USE_ADDON: self.config_entry.data.get(CONF_USE_ADDON, True)} ), ) + if not user_input[CONF_USE_ADDON]: + if self.config_entry.data.get(CONF_USE_ADDON): + # Unload the config entry before stopping the add-on. + await self.hass.config_entries.async_unload(self.config_entry.entry_id) + addon_manager = get_addon_manager(self.hass) + _LOGGER.debug("Stopping Z-Wave JS add-on") + try: + await addon_manager.async_stop_addon() + except AddonError as err: + _LOGGER.error(err) + self.hass.config_entries.async_schedule_reload( + self.config_entry.entry_id + ) + raise AbortFlow("addon_stop_failed") from err return await self.async_step_manual() addon_info = await self._async_get_addon_info() @@ -840,10 +853,7 @@ class OptionsFlowHandler(BaseZwaveJSFlow, OptionsFlow): if addon_info.state == AddonState.RUNNING and not self.restart_addon: return await self.async_step_finish_addon_setup() - if ( - self.config_entry.data.get(CONF_USE_ADDON) - and self.config_entry.state == ConfigEntryState.LOADED - ): + if self.config_entry.data.get(CONF_USE_ADDON): # Disconnect integration before restarting add-on. await self.hass.config_entries.async_unload(self.config_entry.entry_id) diff --git a/homeassistant/components/zwave_js/strings.json b/homeassistant/components/zwave_js/strings.json index 8f23fee4447..644d829b032 100644 --- a/homeassistant/components/zwave_js/strings.json +++ b/homeassistant/components/zwave_js/strings.json @@ -214,6 +214,7 @@ "addon_install_failed": "[%key:component::zwave_js::config::abort::addon_install_failed%]", "addon_set_config_failed": "[%key:component::zwave_js::config::abort::addon_set_config_failed%]", "addon_start_failed": "[%key:component::zwave_js::config::abort::addon_start_failed%]", + "addon_stop_failed": "Failed to stop the Z-Wave add-on.", "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", "different_device": "The connected USB device is not the same as previously configured for this config entry. Please instead create a new config entry for the new device." diff --git a/tests/components/zwave_js/test_config_flow.py b/tests/components/zwave_js/test_config_flow.py index f62ae9c740b..fe1a665c4b0 100644 --- a/tests/components/zwave_js/test_config_flow.py +++ b/tests/components/zwave_js/test_config_flow.py @@ -70,6 +70,15 @@ def setup_entry_fixture() -> Generator[AsyncMock]: yield mock_setup_entry +@pytest.fixture(name="unload_entry") +def unload_entry_fixture() -> Generator[AsyncMock]: + """Mock entry unload.""" + with patch( + "homeassistant.components.zwave_js.async_unload_entry", return_value=True + ) as mock_unload_entry: + yield mock_unload_entry + + @pytest.fixture(name="supervisor") def mock_supervisor_fixture() -> Generator[None]: """Mock Supervisor.""" @@ -2038,6 +2047,104 @@ async def test_options_not_addon( assert client.disconnect.call_count == 1 +@pytest.mark.usefixtures("supervisor") +async def test_options_not_addon_with_addon( + hass: HomeAssistant, + setup_entry: AsyncMock, + unload_entry: AsyncMock, + integration: MockConfigEntry, + stop_addon: AsyncMock, +) -> None: + """Test options flow opting out of add-on on Supervisor with add-on.""" + entry = integration + hass.config_entries.async_update_entry( + entry, + data={**entry.data, "url": "ws://host1:3001", "use_addon": True}, + unique_id="1234", + ) + + assert entry.state is config_entries.ConfigEntryState.LOADED + assert unload_entry.call_count == 0 + setup_entry.reset_mock() + + result = await hass.config_entries.options.async_init(entry.entry_id) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "on_supervisor" + + result = await hass.config_entries.options.async_configure( + result["flow_id"], {"use_addon": False} + ) + + assert entry.state is config_entries.ConfigEntryState.NOT_LOADED + assert setup_entry.call_count == 0 + assert unload_entry.call_count == 1 + assert stop_addon.call_count == 1 + assert stop_addon.call_args == call("core_zwave_js") + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "manual" + + result = await hass.config_entries.options.async_configure( + result["flow_id"], + { + "url": "ws://localhost:3000", + }, + ) + await hass.async_block_till_done() + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert entry.data["url"] == "ws://localhost:3000" + assert entry.data["use_addon"] is False + assert entry.data["integration_created_addon"] is False + assert entry.state is config_entries.ConfigEntryState.LOADED + assert setup_entry.call_count == 1 + assert unload_entry.call_count == 1 + + +@pytest.mark.usefixtures("supervisor") +async def test_options_not_addon_with_addon_stop_fail( + hass: HomeAssistant, + setup_entry: AsyncMock, + unload_entry: AsyncMock, + integration: MockConfigEntry, + stop_addon: AsyncMock, +) -> None: + """Test options flow opting out of add-on and add-on stop error.""" + stop_addon.side_effect = SupervisorError("Boom!") + entry = integration + hass.config_entries.async_update_entry( + entry, + data={**entry.data, "url": "ws://host1:3001", "use_addon": True}, + unique_id="1234", + ) + + assert entry.state is config_entries.ConfigEntryState.LOADED + assert unload_entry.call_count == 0 + setup_entry.reset_mock() + + result = await hass.config_entries.options.async_init(entry.entry_id) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "on_supervisor" + + result = await hass.config_entries.options.async_configure( + result["flow_id"], {"use_addon": False} + ) + await hass.async_block_till_done() + + assert stop_addon.call_count == 1 + assert stop_addon.call_args == call("core_zwave_js") + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "addon_stop_failed" + assert entry.data["url"] == "ws://host1:3001" + assert entry.data["use_addon"] is True + assert entry.state is config_entries.ConfigEntryState.LOADED + assert setup_entry.call_count == 1 + assert unload_entry.call_count == 1 + + @pytest.mark.parametrize( ( "discovery_info", From 3b437c9b84bccb71b6a2a6372b692a82906aab92 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Fri, 11 Apr 2025 13:43:18 +0200 Subject: [PATCH 458/505] Add onboarding view /api/onboarding/integration/wait (#142688) --- homeassistant/components/onboarding/views.py | 32 +++++++- tests/components/onboarding/test_views.py | 83 +++++++++++++++++++- 2 files changed, 113 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/onboarding/views.py b/homeassistant/components/onboarding/views.py index 978e16963d9..47d9b1cb98b 100644 --- a/homeassistant/components/onboarding/views.py +++ b/homeassistant/components/onboarding/views.py @@ -31,7 +31,12 @@ from homeassistant.helpers import area_registry as ar from homeassistant.helpers.backup import async_get_manager as async_get_backup_manager from homeassistant.helpers.system_info import async_get_system_info from homeassistant.helpers.translation import async_get_translations -from homeassistant.setup import SetupPhases, async_pause_setup, async_setup_component +from homeassistant.setup import ( + SetupPhases, + async_pause_setup, + async_setup_component, + async_wait_component, +) if TYPE_CHECKING: from . import OnboardingData, OnboardingStorage, OnboardingStoreData @@ -60,6 +65,7 @@ async def async_setup( hass.http.register_view(BackupInfoView(data)) hass.http.register_view(RestoreBackupView(data)) hass.http.register_view(UploadBackupView(data)) + hass.http.register_view(WaitIntegrationOnboardingView(data)) await setup_cloud_views(hass, data) @@ -298,6 +304,30 @@ class IntegrationOnboardingView(_BaseOnboardingStepView): return self.json({"auth_code": auth_code}) +class WaitIntegrationOnboardingView(_NoAuthBaseOnboardingView): + """Get backup info view.""" + + url = "/api/onboarding/integration/wait" + name = "api:onboarding:integration:wait" + + @RequestDataValidator( + vol.Schema( + { + vol.Required("domain"): str, + } + ) + ) + async def post(self, request: web.Request, data: dict[str, Any]) -> web.Response: + """Handle wait for integration command.""" + hass = request.app[KEY_HASS] + domain = data["domain"] + return self.json( + { + "integration_loaded": await async_wait_component(hass, domain), + } + ) + + class AnalyticsOnboardingView(_BaseOnboardingStepView): """View to finish analytics onboarding step.""" diff --git a/tests/components/onboarding/test_views.py b/tests/components/onboarding/test_views.py index 9c5e93e49fe..6a6be1da470 100644 --- a/tests/components/onboarding/test_views.py +++ b/tests/components/onboarding/test_views.py @@ -21,14 +21,16 @@ from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import area_registry as ar from homeassistant.helpers.backup import async_initialize_backup -from homeassistant.setup import async_setup_component +from homeassistant.setup import async_set_domains_to_be_loaded, async_setup_component from . import mock_storage from tests.common import ( CLIENT_ID, CLIENT_REDIRECT_URI, + MockModule, MockUser, + mock_integration, register_auth_provider, ) from tests.test_util.aiohttp import AiohttpClientMocker @@ -1205,3 +1207,82 @@ async def test_onboarding_cloud_status( assert req.status == HTTPStatus.OK data = await req.json() assert data == {"logged_in": False} + + +@pytest.mark.parametrize( + ("domain", "expected_result"), + [ + ("onboarding", {"integration_loaded": True}), + ("non_existing_domain", {"integration_loaded": False}), + ], +) +async def test_wait_integration( + hass: HomeAssistant, + hass_storage: dict[str, Any], + hass_client: ClientSessionGenerator, + domain: str, + expected_result: dict[str, Any], +) -> None: + """Test we can get wait for an integration to load.""" + mock_storage(hass_storage, {"done": []}) + + assert await async_setup_component(hass, "onboarding", {}) + await hass.async_block_till_done() + + client = await hass_client() + req = await client.post("/api/onboarding/integration/wait", json={"domain": domain}) + + assert req.status == HTTPStatus.OK + data = await req.json() + assert data == expected_result + + +async def test_wait_integration_startup( + hass: HomeAssistant, + hass_storage: dict[str, Any], + hass_client: ClientSessionGenerator, +) -> None: + """Test we can get wait for an integration to load during startup.""" + mock_storage(hass_storage, {"done": []}) + + assert await async_setup_component(hass, "onboarding", {}) + await hass.async_block_till_done() + client = await hass_client() + + setup_stall = asyncio.Event() + setup_started = asyncio.Event() + + async def mock_setup(hass: HomeAssistant, _) -> bool: + setup_started.set() + await setup_stall.wait() + return True + + mock_integration(hass, MockModule("test", async_setup=mock_setup)) + + # The integration is not loaded, and is also not scheduled to load + req = await client.post("/api/onboarding/integration/wait", json={"domain": "test"}) + assert req.status == HTTPStatus.OK + data = await req.json() + assert data == {"integration_loaded": False} + + # Mark the component as scheduled to be loaded + async_set_domains_to_be_loaded(hass, {"test"}) + + # Start loading the component, including its config entries + hass.async_create_task(async_setup_component(hass, "test", {})) + await setup_started.wait() + + # The component is not yet loaded + assert "test" not in hass.config.components + + # Allow setup to proceed + setup_stall.set() + + # The component is scheduled to load, this will block until the config entry is loaded + req = await client.post("/api/onboarding/integration/wait", json={"domain": "test"}) + assert req.status == HTTPStatus.OK + data = await req.json() + assert data == {"integration_loaded": True} + + # The component has been loaded + assert "test" in hass.config.components From 2af6ee75844e8e92c2b8b53f33cabe08b46919ba Mon Sep 17 00:00:00 2001 From: Simone Chemelli Date: Fri, 11 Apr 2025 15:19:21 +0200 Subject: [PATCH 459/505] Add missing typed to SamsungTV (#142738) --- homeassistant/components/samsungtv/__init__.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/samsungtv/__init__.py b/homeassistant/components/samsungtv/__init__.py index e416cd35765..eef9a06ab8a 100644 --- a/homeassistant/components/samsungtv/__init__.py +++ b/homeassistant/components/samsungtv/__init__.py @@ -258,7 +258,9 @@ async def async_unload_entry(hass: HomeAssistant, entry: SamsungTVConfigEntry) - return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) -async def async_migrate_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool: +async def async_migrate_entry( + hass: HomeAssistant, config_entry: SamsungTVConfigEntry +) -> bool: """Migrate old entry.""" version = config_entry.version minor_version = config_entry.minor_version From cd45c5d8860b57b783c1328175b14366b056bf6e Mon Sep 17 00:00:00 2001 From: Martin Hjelmare Date: Fri, 11 Apr 2025 15:37:47 +0200 Subject: [PATCH 460/505] Avoid Z-Wave config entry unload in test teardown (#142732) --- tests/components/zwave_js/test_config_flow.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/tests/components/zwave_js/test_config_flow.py b/tests/components/zwave_js/test_config_flow.py index fe1a665c4b0..990c73c3aca 100644 --- a/tests/components/zwave_js/test_config_flow.py +++ b/tests/components/zwave_js/test_config_flow.py @@ -2101,6 +2101,10 @@ async def test_options_not_addon_with_addon( assert setup_entry.call_count == 1 assert unload_entry.call_count == 1 + # avoid unload entry in teardown + await hass.config_entries.async_unload(entry.entry_id) + assert entry.state is config_entries.ConfigEntryState.NOT_LOADED + @pytest.mark.usefixtures("supervisor") async def test_options_not_addon_with_addon_stop_fail( @@ -2144,6 +2148,10 @@ async def test_options_not_addon_with_addon_stop_fail( assert setup_entry.call_count == 1 assert unload_entry.call_count == 1 + # avoid unload entry in teardown + await hass.config_entries.async_unload(entry.entry_id) + assert entry.state is config_entries.ConfigEntryState.NOT_LOADED + @pytest.mark.parametrize( ( From f42f698dbc8705e84743489bedbb193fb1248f67 Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Fri, 11 Apr 2025 15:38:07 +0200 Subject: [PATCH 461/505] Fix missing sentence-casing in a few `plex` strings (#142720) --- homeassistant/components/plex/strings.json | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/plex/strings.json b/homeassistant/components/plex/strings.json index 4f5ca3f2bc4..6243e2caa93 100644 --- a/homeassistant/components/plex/strings.json +++ b/homeassistant/components/plex/strings.json @@ -11,7 +11,7 @@ } }, "manual_setup": { - "title": "Manual Plex Configuration", + "title": "Manual Plex configuration", "data": { "host": "[%key:common::config_flow::data::host%]", "port": "[%key:common::config_flow::data::port%]", @@ -29,8 +29,8 @@ } }, "error": { - "faulty_credentials": "Authorization failed, verify Token", - "host_or_token": "Must provide at least one of Host or Token", + "faulty_credentials": "Authorization failed, verify token", + "host_or_token": "Must provide at least one of host or token", "no_servers": "No servers linked to Plex account", "not_found": "Plex server not found", "ssl_error": "SSL certificate issue" @@ -47,12 +47,12 @@ "options": { "step": { "plex_mp_settings": { - "description": "Options for Plex Media Players", + "description": "Options for Plex media players", "data": { "use_episode_art": "Use episode art", "ignore_new_shared_users": "Ignore new managed/shared users", "monitored_users": "Monitored users", - "ignore_plex_web_clients": "Ignore Plex Web clients" + "ignore_plex_web_clients": "Ignore Plex web clients" } } } From a4234bf80ebb44504821897711a97908faf986fc Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Fri, 11 Apr 2025 15:39:44 +0200 Subject: [PATCH 462/505] Add more state references to `shelly` (#142716) - replace "Normal" with common state - replace `self_test` state attributes with references --- homeassistant/components/shelly/strings.json | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/shelly/strings.json b/homeassistant/components/shelly/strings.json index 43c709f4641..2f07742898c 100644 --- a/homeassistant/components/shelly/strings.json +++ b/homeassistant/components/shelly/strings.json @@ -175,16 +175,16 @@ "operation": { "state": { "warmup": "Warm-up", - "normal": "Normal", + "normal": "[%key:common::state::normal%]", "fault": "Fault" }, "state_attributes": { "self_test": { "state": { - "not_completed": "Not completed", - "completed": "Completed", - "running": "Running", - "pending": "Pending" + "not_completed": "[%key:component::shelly::entity::sensor::self_test::state::not_completed%]", + "completed": "[%key:component::shelly::entity::sensor::self_test::state::completed%]", + "running": "[%key:component::shelly::entity::sensor::self_test::state::running%]", + "pending": "[%key:component::shelly::entity::sensor::self_test::state::pending%]" } } } From 4aca9cd66b6b5076748b233f9a3d289a8e0201a3 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Fri, 11 Apr 2025 16:02:27 +0200 Subject: [PATCH 463/505] Move cloud onboarding API to an onboarding platform (#141978) * Move cloud onboarding API to an onboarding platform * Address review comments * Add tests * Move cloud onboarding tests to the cloud integration * Address review comments * Don't wait for platforms * Add test * Remove useless check for CLOUD_DATA --- homeassistant/components/cloud/onboarding.py | 110 ++++++++++ .../components/onboarding/__init__.py | 1 + homeassistant/components/onboarding/views.py | 175 ++++----------- script/hassfest/dependencies.py | 5 +- tests/components/cloud/test_onboarding.py | 165 +++++++++++++++ tests/components/onboarding/test_views.py | 199 +++++------------- 6 files changed, 382 insertions(+), 273 deletions(-) create mode 100644 homeassistant/components/cloud/onboarding.py create mode 100644 tests/components/cloud/test_onboarding.py diff --git a/homeassistant/components/cloud/onboarding.py b/homeassistant/components/cloud/onboarding.py new file mode 100644 index 00000000000..ab0a0fbe310 --- /dev/null +++ b/homeassistant/components/cloud/onboarding.py @@ -0,0 +1,110 @@ +"""Cloud onboarding views.""" + +from __future__ import annotations + +from collections.abc import Callable, Coroutine +from functools import wraps +from typing import TYPE_CHECKING, Any, Concatenate + +from aiohttp import web +from aiohttp.web_exceptions import HTTPUnauthorized + +from homeassistant.components.http import KEY_HASS +from homeassistant.components.onboarding import ( + BaseOnboardingView, + NoAuthBaseOnboardingView, +) +from homeassistant.core import HomeAssistant + +from . import http_api as cloud_http +from .const import DATA_CLOUD + +if TYPE_CHECKING: + from homeassistant.components.onboarding import OnboardingStoreData + + +async def async_setup_views(hass: HomeAssistant, data: OnboardingStoreData) -> None: + """Set up the cloud views.""" + + hass.http.register_view(CloudForgotPasswordView(data)) + hass.http.register_view(CloudLoginView(data)) + hass.http.register_view(CloudLogoutView(data)) + hass.http.register_view(CloudStatusView(data)) + + +def ensure_not_done[_ViewT: BaseOnboardingView, **_P]( + func: Callable[ + Concatenate[_ViewT, web.Request, _P], + Coroutine[Any, Any, web.Response], + ], +) -> Callable[Concatenate[_ViewT, web.Request, _P], Coroutine[Any, Any, web.Response]]: + """Home Assistant API decorator to check onboarding and cloud.""" + + @wraps(func) + async def _ensure_not_done( + self: _ViewT, + request: web.Request, + *args: _P.args, + **kwargs: _P.kwargs, + ) -> web.Response: + """Check onboarding status, cloud and call function.""" + if self._data["done"]: + # If at least one onboarding step is done, we don't allow accessing + # the cloud onboarding views. + raise HTTPUnauthorized + + return await func(self, request, *args, **kwargs) + + return _ensure_not_done + + +class CloudForgotPasswordView( + NoAuthBaseOnboardingView, cloud_http.CloudForgotPasswordView +): + """View to start Forgot Password flow.""" + + url = "/api/onboarding/cloud/forgot_password" + name = "api:onboarding:cloud:forgot_password" + + @ensure_not_done + async def post(self, request: web.Request) -> web.Response: + """Handle forgot password request.""" + return await super()._post(request) + + +class CloudLoginView(NoAuthBaseOnboardingView, cloud_http.CloudLoginView): + """Login to Home Assistant Cloud.""" + + url = "/api/onboarding/cloud/login" + name = "api:onboarding:cloud:login" + + @ensure_not_done + async def post(self, request: web.Request) -> web.Response: + """Handle login request.""" + return await super()._post(request) + + +class CloudLogoutView(NoAuthBaseOnboardingView, cloud_http.CloudLogoutView): + """Log out of the Home Assistant cloud.""" + + url = "/api/onboarding/cloud/logout" + name = "api:onboarding:cloud:logout" + + @ensure_not_done + async def post(self, request: web.Request) -> web.Response: + """Handle logout request.""" + return await super()._post(request) + + +class CloudStatusView(NoAuthBaseOnboardingView): + """Get cloud status view.""" + + url = "/api/onboarding/cloud/status" + name = "api:onboarding:cloud:status" + + @ensure_not_done + async def get(self, request: web.Request) -> web.Response: + """Return cloud status.""" + hass = request.app[KEY_HASS] + cloud = hass.data[DATA_CLOUD] + return self.json({"logged_in": cloud.is_logged_in}) diff --git a/homeassistant/components/onboarding/__init__.py b/homeassistant/components/onboarding/__init__.py index c11bd79c377..097cddd6603 100644 --- a/homeassistant/components/onboarding/__init__.py +++ b/homeassistant/components/onboarding/__init__.py @@ -21,6 +21,7 @@ from .const import ( STEP_USER, STEPS, ) +from .views import BaseOnboardingView, NoAuthBaseOnboardingView # noqa: F401 STORAGE_KEY = DOMAIN STORAGE_VERSION = 4 diff --git a/homeassistant/components/onboarding/views.py b/homeassistant/components/onboarding/views.py index 47d9b1cb98b..e9d163a1bbb 100644 --- a/homeassistant/components/onboarding/views.py +++ b/homeassistant/components/onboarding/views.py @@ -6,7 +6,8 @@ import asyncio from collections.abc import Callable, Coroutine from functools import wraps from http import HTTPStatus -from typing import TYPE_CHECKING, Any, Concatenate, cast +import logging +from typing import TYPE_CHECKING, Any, Concatenate, Protocol, cast from aiohttp import web from aiohttp.web_exceptions import HTTPUnauthorized @@ -27,16 +28,11 @@ from homeassistant.components.http.data_validator import RequestDataValidator from homeassistant.components.http.view import HomeAssistantView from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers import area_registry as ar +from homeassistant.helpers import area_registry as ar, integration_platform from homeassistant.helpers.backup import async_get_manager as async_get_backup_manager from homeassistant.helpers.system_info import async_get_system_info from homeassistant.helpers.translation import async_get_translations -from homeassistant.setup import ( - SetupPhases, - async_pause_setup, - async_setup_component, - async_wait_component, -) +from homeassistant.setup import async_setup_component, async_wait_component if TYPE_CHECKING: from . import OnboardingData, OnboardingStorage, OnboardingStoreData @@ -51,11 +47,14 @@ from .const import ( STEPS, ) +_LOGGER = logging.getLogger(__name__) + async def async_setup( hass: HomeAssistant, data: OnboardingStoreData, store: OnboardingStorage ) -> None: """Set up the onboarding view.""" + await async_process_onboarding_platforms(hass) hass.http.register_view(OnboardingStatusView(data, store)) hass.http.register_view(InstallationTypeOnboardingView(data)) hass.http.register_view(UserOnboardingView(data, store)) @@ -66,10 +65,38 @@ async def async_setup( hass.http.register_view(RestoreBackupView(data)) hass.http.register_view(UploadBackupView(data)) hass.http.register_view(WaitIntegrationOnboardingView(data)) - await setup_cloud_views(hass, data) -class _BaseOnboardingView(HomeAssistantView): +class OnboardingPlatformProtocol(Protocol): + """Define the format of onboarding platforms.""" + + async def async_setup_views( + self, hass: HomeAssistant, data: OnboardingStoreData + ) -> None: + """Set up onboarding views.""" + + +async def async_process_onboarding_platforms(hass: HomeAssistant) -> None: + """Start processing onboarding platforms.""" + await integration_platform.async_process_integration_platforms( + hass, DOMAIN, _register_onboarding_platform, wait_for_platforms=False + ) + + +async def _register_onboarding_platform( + hass: HomeAssistant, integration_domain: str, platform: OnboardingPlatformProtocol +) -> None: + """Register a onboarding platform.""" + if not hasattr(platform, "async_setup_views"): + _LOGGER.debug( + "'%s.onboarding' is not a valid onboarding platform", + integration_domain, + ) + return + await platform.async_setup_views(hass, hass.data[DOMAIN].steps) + + +class BaseOnboardingView(HomeAssistantView): """Base class for onboarding views.""" def __init__(self, data: OnboardingStoreData) -> None: @@ -77,13 +104,13 @@ class _BaseOnboardingView(HomeAssistantView): self._data = data -class _NoAuthBaseOnboardingView(_BaseOnboardingView): +class NoAuthBaseOnboardingView(BaseOnboardingView): """Base class for unauthenticated onboarding views.""" requires_auth = False -class OnboardingStatusView(_NoAuthBaseOnboardingView): +class OnboardingStatusView(NoAuthBaseOnboardingView): """Return the onboarding status.""" url = "/api/onboarding" @@ -101,7 +128,7 @@ class OnboardingStatusView(_NoAuthBaseOnboardingView): ) -class InstallationTypeOnboardingView(_NoAuthBaseOnboardingView): +class InstallationTypeOnboardingView(NoAuthBaseOnboardingView): """Return the installation type during onboarding.""" url = "/api/onboarding/installation_type" @@ -117,7 +144,7 @@ class InstallationTypeOnboardingView(_NoAuthBaseOnboardingView): return self.json({"installation_type": info["installation_type"]}) -class _BaseOnboardingStepView(_BaseOnboardingView): +class _BaseOnboardingStepView(BaseOnboardingView): """Base class for an onboarding step.""" step: str @@ -304,7 +331,7 @@ class IntegrationOnboardingView(_BaseOnboardingStepView): return self.json({"auth_code": auth_code}) -class WaitIntegrationOnboardingView(_NoAuthBaseOnboardingView): +class WaitIntegrationOnboardingView(NoAuthBaseOnboardingView): """Get backup info view.""" url = "/api/onboarding/integration/wait" @@ -350,7 +377,7 @@ class AnalyticsOnboardingView(_BaseOnboardingStepView): return self.json({}) -def with_backup_manager[_ViewT: _BaseOnboardingView, **_P]( +def with_backup_manager[_ViewT: BaseOnboardingView, **_P]( func: Callable[ Concatenate[_ViewT, BackupManager, web.Request, _P], Coroutine[Any, Any, web.Response], @@ -382,7 +409,7 @@ def with_backup_manager[_ViewT: _BaseOnboardingView, **_P]( return with_backup -class BackupInfoView(_NoAuthBaseOnboardingView): +class BackupInfoView(NoAuthBaseOnboardingView): """Get backup info view.""" url = "/api/onboarding/backup/info" @@ -401,7 +428,7 @@ class BackupInfoView(_NoAuthBaseOnboardingView): ) -class RestoreBackupView(_NoAuthBaseOnboardingView): +class RestoreBackupView(NoAuthBaseOnboardingView): """Restore backup view.""" url = "/api/onboarding/backup/restore" @@ -446,7 +473,7 @@ class RestoreBackupView(_NoAuthBaseOnboardingView): return web.Response(status=HTTPStatus.OK) -class UploadBackupView(_NoAuthBaseOnboardingView, backup_http.UploadBackupView): +class UploadBackupView(NoAuthBaseOnboardingView, backup_http.UploadBackupView): """Upload backup view.""" url = "/api/onboarding/backup/upload" @@ -458,116 +485,6 @@ class UploadBackupView(_NoAuthBaseOnboardingView, backup_http.UploadBackupView): return await self._post(request) -async def setup_cloud_views(hass: HomeAssistant, data: OnboardingStoreData) -> None: - """Set up the cloud views.""" - - with async_pause_setup(hass, SetupPhases.WAIT_IMPORT_PACKAGES): - # Import the cloud integration in an executor to avoid blocking the - # event loop. - def import_cloud() -> None: - """Import the cloud integration.""" - # pylint: disable-next=import-outside-toplevel - from homeassistant.components.cloud import http_api # noqa: F401 - - await hass.async_add_import_executor_job(import_cloud) - - # The cloud integration is imported locally to avoid cloud being imported by - # bootstrap.py and to avoid circular imports. - - # pylint: disable-next=import-outside-toplevel - from homeassistant.components.cloud import http_api as cloud_http - - # pylint: disable-next=import-outside-toplevel,hass-component-root-import - from homeassistant.components.cloud.const import DATA_CLOUD - - def with_cloud[_ViewT: _BaseOnboardingView, **_P]( - func: Callable[ - Concatenate[_ViewT, web.Request, _P], - Coroutine[Any, Any, web.Response], - ], - ) -> Callable[ - Concatenate[_ViewT, web.Request, _P], Coroutine[Any, Any, web.Response] - ]: - """Home Assistant API decorator to check onboarding and cloud.""" - - @wraps(func) - async def _with_cloud( - self: _ViewT, - request: web.Request, - *args: _P.args, - **kwargs: _P.kwargs, - ) -> web.Response: - """Check onboarding status, cloud and call function.""" - if self._data["done"]: - # If at least one onboarding step is done, we don't allow accessing - # the cloud onboarding views. - raise HTTPUnauthorized - - hass = request.app[KEY_HASS] - if DATA_CLOUD not in hass.data: - return self.json( - {"code": "cloud_disabled"}, - status_code=HTTPStatus.INTERNAL_SERVER_ERROR, - ) - - return await func(self, request, *args, **kwargs) - - return _with_cloud - - class CloudForgotPasswordView( - _NoAuthBaseOnboardingView, cloud_http.CloudForgotPasswordView - ): - """View to start Forgot Password flow.""" - - url = "/api/onboarding/cloud/forgot_password" - name = "api:onboarding:cloud:forgot_password" - - @with_cloud - async def post(self, request: web.Request) -> web.Response: - """Handle forgot password request.""" - return await super()._post(request) - - class CloudLoginView(_NoAuthBaseOnboardingView, cloud_http.CloudLoginView): - """Login to Home Assistant Cloud.""" - - url = "/api/onboarding/cloud/login" - name = "api:onboarding:cloud:login" - - @with_cloud - async def post(self, request: web.Request) -> web.Response: - """Handle login request.""" - return await super()._post(request) - - class CloudLogoutView(_NoAuthBaseOnboardingView, cloud_http.CloudLogoutView): - """Log out of the Home Assistant cloud.""" - - url = "/api/onboarding/cloud/logout" - name = "api:onboarding:cloud:logout" - - @with_cloud - async def post(self, request: web.Request) -> web.Response: - """Handle logout request.""" - return await super()._post(request) - - class CloudStatusView(_NoAuthBaseOnboardingView): - """Get cloud status view.""" - - url = "/api/onboarding/cloud/status" - name = "api:onboarding:cloud:status" - - @with_cloud - async def get(self, request: web.Request) -> web.Response: - """Return cloud status.""" - hass = request.app[KEY_HASS] - cloud = hass.data[DATA_CLOUD] - return self.json({"logged_in": cloud.is_logged_in}) - - hass.http.register_view(CloudForgotPasswordView(data)) - hass.http.register_view(CloudLoginView(data)) - hass.http.register_view(CloudLogoutView(data)) - hass.http.register_view(CloudStatusView(data)) - - @callback def _async_get_hass_provider(hass: HomeAssistant) -> HassAuthProvider: """Get the Home Assistant auth provider.""" diff --git a/script/hassfest/dependencies.py b/script/hassfest/dependencies.py index 52ea79d32fe..8f541760269 100644 --- a/script/hassfest/dependencies.py +++ b/script/hassfest/dependencies.py @@ -173,11 +173,10 @@ IGNORE_VIOLATIONS = { "logbook", # Temporary needed for migration until 2024.10 ("conversation", "assist_pipeline"), - # The onboarding integration provides limited backup and cloud APIs for use + # The onboarding integration provides limited backup for use # during onboarding. The onboarding integration waits for the backup manager - # and cloud to be ready before calling any backup or cloud functionality. + # and to be ready before calling any backup functionality. ("onboarding", "backup"), - ("onboarding", "cloud"), } diff --git a/tests/components/cloud/test_onboarding.py b/tests/components/cloud/test_onboarding.py new file mode 100644 index 00000000000..142cd90a59c --- /dev/null +++ b/tests/components/cloud/test_onboarding.py @@ -0,0 +1,165 @@ +"""Test the onboarding views.""" + +from http import HTTPStatus +from typing import Any +from unittest.mock import MagicMock + +import pytest + +from homeassistant.components import onboarding +from homeassistant.components.cloud import DOMAIN +from homeassistant.core import HomeAssistant +from homeassistant.setup import async_setup_component + +from tests.common import register_auth_provider +from tests.typing import ClientSessionGenerator + + +def mock_onboarding_storage(hass_storage, data): + """Mock the onboarding storage.""" + hass_storage[onboarding.STORAGE_KEY] = { + "version": onboarding.STORAGE_VERSION, + "data": data, + } + + +@pytest.fixture(autouse=True) +async def auth_active(hass: HomeAssistant) -> None: + """Ensure auth is always active.""" + await register_auth_provider(hass, {"type": "homeassistant"}) + + +@pytest.fixture(name="setup_cloud", autouse=True) +async def setup_cloud_fixture(hass: HomeAssistant, cloud: MagicMock) -> None: + """Fixture that sets up cloud.""" + assert await async_setup_component(hass, "homeassistant", {}) + assert await async_setup_component(hass, DOMAIN, {}) + await hass.async_block_till_done() + + +@pytest.mark.parametrize( + ("method", "view", "kwargs"), + [ + ( + "post", + "cloud/forgot_password", + {"json": {"email": "hello@bla.com"}}, + ), + ( + "post", + "cloud/login", + {"json": {"email": "my_username", "password": "my_password"}}, + ), + ("post", "cloud/logout", {}), + ("get", "cloud/status", {}), + ], +) +async def test_onboarding_view_after_done( + hass: HomeAssistant, + hass_storage: dict[str, Any], + hass_client: ClientSessionGenerator, + cloud: MagicMock, + method: str, + view: str, + kwargs: dict[str, Any], +) -> None: + """Test raising after onboarding.""" + mock_onboarding_storage(hass_storage, {"done": [onboarding.const.STEP_USER]}) + + assert await async_setup_component(hass, "onboarding", {}) + await hass.async_block_till_done() + + client = await hass_client() + + resp = await client.request(method, f"/api/onboarding/{view}", **kwargs) + + assert resp.status == 401 + + +async def test_onboarding_cloud_forgot_password( + hass: HomeAssistant, + hass_storage: dict[str, Any], + hass_client: ClientSessionGenerator, + cloud: MagicMock, +) -> None: + """Test cloud forgot password.""" + mock_onboarding_storage(hass_storage, {"done": []}) + + assert await async_setup_component(hass, "onboarding", {}) + await hass.async_block_till_done() + + client = await hass_client() + + mock_cognito = cloud.auth + + req = await client.post( + "/api/onboarding/cloud/forgot_password", json={"email": "hello@bla.com"} + ) + + assert req.status == HTTPStatus.OK + assert mock_cognito.async_forgot_password.call_count == 1 + + +async def test_onboarding_cloud_login( + hass: HomeAssistant, + hass_storage: dict[str, Any], + hass_client: ClientSessionGenerator, + cloud: MagicMock, +) -> None: + """Test logging out from cloud.""" + mock_onboarding_storage(hass_storage, {"done": []}) + + assert await async_setup_component(hass, "onboarding", {}) + await hass.async_block_till_done() + + client = await hass_client() + req = await client.post( + "/api/onboarding/cloud/login", + json={"email": "my_username", "password": "my_password"}, + ) + + assert req.status == HTTPStatus.OK + data = await req.json() + assert data == {"cloud_pipeline": None, "success": True} + assert cloud.login.call_count == 1 + + +async def test_onboarding_cloud_logout( + hass: HomeAssistant, + hass_storage: dict[str, Any], + hass_client: ClientSessionGenerator, + cloud: MagicMock, +) -> None: + """Test logging out from cloud.""" + mock_onboarding_storage(hass_storage, {"done": []}) + + assert await async_setup_component(hass, "onboarding", {}) + await hass.async_block_till_done() + + client = await hass_client() + req = await client.post("/api/onboarding/cloud/logout") + + assert req.status == HTTPStatus.OK + data = await req.json() + assert data == {"message": "ok"} + assert cloud.logout.call_count == 1 + + +async def test_onboarding_cloud_status( + hass: HomeAssistant, + hass_storage: dict[str, Any], + hass_client: ClientSessionGenerator, + cloud: MagicMock, +) -> None: + """Test logging out from cloud.""" + mock_onboarding_storage(hass_storage, {"done": []}) + + assert await async_setup_component(hass, "onboarding", {}) + await hass.async_block_till_done() + + client = await hass_client() + req = await client.get("/api/onboarding/cloud/status") + + assert req.status == HTTPStatus.OK + data = await req.json() + assert data == {"logged_in": False} diff --git a/tests/components/onboarding/test_views.py b/tests/components/onboarding/test_views.py index 6a6be1da470..8040eb978d5 100644 --- a/tests/components/onboarding/test_views.py +++ b/tests/components/onboarding/test_views.py @@ -6,16 +6,12 @@ from http import HTTPStatus from io import StringIO import os from typing import Any -from unittest.mock import ANY, DEFAULT, AsyncMock, MagicMock, Mock, patch +from unittest.mock import ANY, AsyncMock, Mock, patch -from hass_nabucasa.auth import CognitoAuth -from hass_nabucasa.const import STATE_CONNECTED -from hass_nabucasa.iot import CloudIoT import pytest from syrupy import SnapshotAssertion from homeassistant.components import backup, onboarding -from homeassistant.components.cloud import DOMAIN as CLOUD_DOMAIN, CloudClient from homeassistant.components.onboarding import const, views from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError @@ -31,6 +27,7 @@ from tests.common import ( MockModule, MockUser, mock_integration, + mock_platform, register_auth_provider, ) from tests.test_util.aiohttp import AiohttpClientMocker @@ -1073,142 +1070,6 @@ async def test_onboarding_backup_upload( mock_receive.assert_called_once_with(agent_ids=["backup.local"], contents=ANY) -@pytest.fixture(name="cloud") -async def cloud_fixture() -> AsyncGenerator[MagicMock]: - """Mock the cloud object. - - See the real hass_nabucasa.Cloud class for how to configure the mock. - """ - with patch( - "homeassistant.components.cloud.Cloud", autospec=True - ) as mock_cloud_class: - mock_cloud = mock_cloud_class.return_value - - mock_cloud.auth = MagicMock(spec=CognitoAuth) - mock_cloud.iot = MagicMock( - spec=CloudIoT, last_disconnect_reason=None, state=STATE_CONNECTED - ) - - def set_up_mock_cloud( - cloud_client: CloudClient, mode: str, **kwargs: Any - ) -> DEFAULT: - """Set up mock cloud with a mock constructor.""" - - # Attributes set in the constructor with parameters. - mock_cloud.client = cloud_client - - return DEFAULT - - mock_cloud_class.side_effect = set_up_mock_cloud - - # Attributes that we mock with default values. - mock_cloud.id_token = None - mock_cloud.is_logged_in = False - - yield mock_cloud - - -@pytest.fixture(name="setup_cloud") -async def setup_cloud_fixture(hass: HomeAssistant, cloud: MagicMock) -> None: - """Fixture that sets up cloud.""" - assert await async_setup_component(hass, "homeassistant", {}) - assert await async_setup_component(hass, CLOUD_DOMAIN, {}) - await hass.async_block_till_done() - - -@pytest.mark.usefixtures("setup_cloud") -async def test_onboarding_cloud_forgot_password( - hass: HomeAssistant, - hass_storage: dict[str, Any], - hass_client: ClientSessionGenerator, - cloud: MagicMock, -) -> None: - """Test cloud forgot password.""" - mock_storage(hass_storage, {"done": []}) - - assert await async_setup_component(hass, "onboarding", {}) - await hass.async_block_till_done() - - client = await hass_client() - - mock_cognito = cloud.auth - - req = await client.post( - "/api/onboarding/cloud/forgot_password", json={"email": "hello@bla.com"} - ) - - assert req.status == HTTPStatus.OK - assert mock_cognito.async_forgot_password.call_count == 1 - - -@pytest.mark.usefixtures("setup_cloud") -async def test_onboarding_cloud_login( - hass: HomeAssistant, - hass_storage: dict[str, Any], - hass_client: ClientSessionGenerator, - cloud: MagicMock, -) -> None: - """Test logging out from cloud.""" - mock_storage(hass_storage, {"done": []}) - - assert await async_setup_component(hass, "onboarding", {}) - await hass.async_block_till_done() - - client = await hass_client() - req = await client.post( - "/api/onboarding/cloud/login", - json={"email": "my_username", "password": "my_password"}, - ) - - assert req.status == HTTPStatus.OK - data = await req.json() - assert data == {"cloud_pipeline": None, "success": True} - assert cloud.login.call_count == 1 - - -@pytest.mark.usefixtures("setup_cloud") -async def test_onboarding_cloud_logout( - hass: HomeAssistant, - hass_storage: dict[str, Any], - hass_client: ClientSessionGenerator, - cloud: MagicMock, -) -> None: - """Test logging out from cloud.""" - mock_storage(hass_storage, {"done": []}) - - assert await async_setup_component(hass, "onboarding", {}) - await hass.async_block_till_done() - - client = await hass_client() - req = await client.post("/api/onboarding/cloud/logout") - - assert req.status == HTTPStatus.OK - data = await req.json() - assert data == {"message": "ok"} - assert cloud.logout.call_count == 1 - - -@pytest.mark.usefixtures("setup_cloud") -async def test_onboarding_cloud_status( - hass: HomeAssistant, - hass_storage: dict[str, Any], - hass_client: ClientSessionGenerator, - cloud: MagicMock, -) -> None: - """Test logging out from cloud.""" - mock_storage(hass_storage, {"done": []}) - - assert await async_setup_component(hass, "onboarding", {}) - await hass.async_block_till_done() - - client = await hass_client() - req = await client.get("/api/onboarding/cloud/status") - - assert req.status == HTTPStatus.OK - data = await req.json() - assert data == {"logged_in": False} - - @pytest.mark.parametrize( ("domain", "expected_result"), [ @@ -1286,3 +1147,59 @@ async def test_wait_integration_startup( # The component has been loaded assert "test" in hass.config.components + + +async def test_not_setup_platform_if_onboarded( + hass: HomeAssistant, hass_storage: dict[str, Any] +) -> None: + """Test if onboarding is done, we don't setup platforms.""" + mock_storage(hass_storage, {"done": onboarding.STEPS}) + + platform_mock = Mock(async_setup_views=AsyncMock(), spec=["async_setup_views"]) + mock_platform(hass, "test.onboarding", platform_mock) + assert await async_setup_component(hass, "test", {}) + await hass.async_block_till_done() + + assert await async_setup_component(hass, "onboarding", {}) + await hass.async_block_till_done() + + assert len(platform_mock.async_setup_views.mock_calls) == 0 + + +async def test_setup_platform_if_not_onboarded( + hass: HomeAssistant, hass_storage: dict[str, Any] +) -> None: + """Test if onboarding is not done, we setup platforms.""" + platform_mock = Mock(async_setup_views=AsyncMock(), spec=["async_setup_views"]) + mock_platform(hass, "test.onboarding", platform_mock) + assert await async_setup_component(hass, "test", {}) + await hass.async_block_till_done() + + assert await async_setup_component(hass, "onboarding", {}) + await hass.async_block_till_done() + + platform_mock.async_setup_views.assert_awaited_once_with(hass, {"done": []}) + + +@pytest.mark.parametrize( + "platform_mock", + [ + Mock(some_method=AsyncMock(), spec=["some_method"]), + Mock(spec=[]), + ], +) +async def test_bad_platform( + hass: HomeAssistant, + platform_mock: Mock, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test loading onboarding platform which doesn't have the expected methods.""" + mock_platform(hass, "test.onboarding", platform_mock) + assert await async_setup_component(hass, "test", {}) + await hass.async_block_till_done() + + assert await async_setup_component(hass, "onboarding", {}) + await hass.async_block_till_done() + + assert platform_mock.mock_calls == [] + assert "'test.onboarding' is not a valid onboarding platform" in caplog.text From 0105332476614f7e9fd90cd3c26e29cd7b5c8bb1 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Fri, 11 Apr 2025 16:09:15 +0200 Subject: [PATCH 464/505] Add WS command integration/wait (#142040) * Add WS command integration/wait * Add test * Update homeassistant/components/websocket_api/commands.py Co-authored-by: Martin Hjelmare * Use helper setup.async_wait_component * Add onboarding view * Revert "Add onboarding view" This reverts commit df3a1a05807ae18cac6455cf04ca0cd6bea31857. --------- Co-authored-by: Martin Hjelmare --- .../components/websocket_api/commands.py | 25 +++++- .../components/websocket_api/test_commands.py | 84 ++++++++++++++++++- 2 files changed, 107 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/websocket_api/commands.py b/homeassistant/components/websocket_api/commands.py index 4a360b4a43c..ddcdd4f1cf8 100644 --- a/homeassistant/components/websocket_api/commands.py +++ b/homeassistant/components/websocket_api/commands.py @@ -59,7 +59,11 @@ from homeassistant.loader import ( async_get_integration_descriptions, async_get_integrations, ) -from homeassistant.setup import async_get_loaded_integrations, async_get_setup_timings +from homeassistant.setup import ( + async_get_loaded_integrations, + async_get_setup_timings, + async_wait_component, +) from homeassistant.util.json import format_unserializable_data from . import const, decorators, messages @@ -98,6 +102,7 @@ def async_register_commands( async_reg(hass, handle_subscribe_entities) async_reg(hass, handle_supported_features) async_reg(hass, handle_integration_descriptions) + async_reg(hass, handle_integration_wait) def pong_message(iden: int) -> dict[str, Any]: @@ -923,3 +928,21 @@ async def handle_integration_descriptions( ) -> None: """Get metadata for all brands and integrations.""" connection.send_result(msg["id"], await async_get_integration_descriptions(hass)) + + +@decorators.websocket_command( + { + vol.Required("type"): "integration/wait", + vol.Required("domain"): str, + } +) +@decorators.async_response +async def handle_integration_wait( + hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any] +) -> None: + """Handle wait for integration command.""" + + domain: str = msg["domain"] + connection.send_result( + msg["id"], {"integration_loaded": await async_wait_component(hass, domain)} + ) diff --git a/tests/components/websocket_api/test_commands.py b/tests/components/websocket_api/test_commands.py index f03673048c0..80e6b8be056 100644 --- a/tests/components/websocket_api/test_commands.py +++ b/tests/components/websocket_api/test_commands.py @@ -26,15 +26,17 @@ from homeassistant.helpers import device_registry as dr from homeassistant.helpers.dispatcher import async_dispatcher_send from homeassistant.helpers.event import async_track_state_change_event from homeassistant.loader import async_get_integration -from homeassistant.setup import async_setup_component +from homeassistant.setup import async_set_domains_to_be_loaded, async_setup_component from homeassistant.util.json import json_loads from tests.common import ( MockConfigEntry, MockEntity, MockEntityPlatform, + MockModule, MockUser, async_mock_service, + mock_integration, mock_platform, ) from tests.typing import ( @@ -2824,3 +2826,83 @@ async def test_subscribe_entities_chained_state_change( await websocket_client.close() await hass.async_block_till_done() + + +@pytest.mark.parametrize( + ("domain", "result"), + [ + ("config", {"integration_loaded": True}), + ("non_existing_domain", {"integration_loaded": False}), + ], +) +async def test_wait_integration( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + domain: str, + result: dict[str, Any], +) -> None: + """Test we can get wait for an integration to load.""" + assert await async_setup_component(hass, "config", {}) + ws_client = await hass_ws_client(hass) + + await ws_client.send_json_auto_id({"type": "integration/wait", "domain": domain}) + response = await ws_client.receive_json() + assert response == { + "id": ANY, + "result": result, + "success": True, + "type": "result", + } + + +async def test_wait_integration_startup( + hass: HomeAssistant, hass_ws_client: WebSocketGenerator +) -> None: + """Test we can get wait for an integration to load during startup.""" + ws_client = await hass_ws_client(hass) + + setup_stall = asyncio.Event() + setup_started = asyncio.Event() + + async def mock_setup(hass: HomeAssistant, _) -> bool: + setup_started.set() + await setup_stall.wait() + return True + + mock_integration(hass, MockModule("test", async_setup=mock_setup)) + + # The integration is not loaded, and is also not scheduled to load + await ws_client.send_json_auto_id({"type": "integration/wait", "domain": "test"}) + response = await ws_client.receive_json() + assert response == { + "id": ANY, + "result": {"integration_loaded": False}, + "success": True, + "type": "result", + } + + # Mark the component as scheduled to be loaded + async_set_domains_to_be_loaded(hass, {"test"}) + + # Start loading the component, including its config entries + hass.async_create_task(async_setup_component(hass, "test", {})) + await setup_started.wait() + + # The component is not yet loaded + assert "test" not in hass.config.components + + # Allow setup to proceed + setup_stall.set() + + # The component is scheduled to load, this will block until the config entry is loaded + await ws_client.send_json_auto_id({"type": "integration/wait", "domain": "test"}) + response = await ws_client.receive_json() + assert response == { + "id": ANY, + "result": {"integration_loaded": True}, + "success": True, + "type": "result", + } + + # The component has been loaded + assert "test" in hass.config.components From 0e4f44b775d97e370dc74e3fe0aeb84c06cc1613 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Fri, 11 Apr 2025 16:51:32 +0200 Subject: [PATCH 465/505] Bump pySmartThings to 3.0.4 (#142739) --- homeassistant/components/smartthings/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/smartthings/manifest.json b/homeassistant/components/smartthings/manifest.json index dda7ef53cf5..4cd27e49664 100644 --- a/homeassistant/components/smartthings/manifest.json +++ b/homeassistant/components/smartthings/manifest.json @@ -30,5 +30,5 @@ "iot_class": "cloud_push", "loggers": ["pysmartthings"], "quality_scale": "bronze", - "requirements": ["pysmartthings==3.0.2"] + "requirements": ["pysmartthings==3.0.4"] } diff --git a/requirements_all.txt b/requirements_all.txt index df4c7b72cef..4a521e11914 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2322,7 +2322,7 @@ pysma==0.7.5 pysmappee==0.2.29 # homeassistant.components.smartthings -pysmartthings==3.0.2 +pysmartthings==3.0.4 # homeassistant.components.smarty pysmarty2==0.10.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index f5d97d0a86c..d25277a0dde 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1895,7 +1895,7 @@ pysma==0.7.5 pysmappee==0.2.29 # homeassistant.components.smartthings -pysmartthings==3.0.2 +pysmartthings==3.0.4 # homeassistant.components.smarty pysmarty2==0.10.2 From 5816a24577139c6224e1992a5ca96ab450731f81 Mon Sep 17 00:00:00 2001 From: Simone Chemelli Date: Fri, 11 Apr 2025 17:21:12 +0200 Subject: [PATCH 466/505] Cleanup snapshot call in tests (#142750) --- tests/components/comelit/test_climate.py | 2 +- tests/components/comelit/test_cover.py | 2 +- tests/components/comelit/test_light.py | 2 +- tests/components/comelit/test_sensor.py | 2 +- tests/components/comelit/test_switch.py | 2 +- tests/components/voip/test_voip.py | 4 ++-- tests/components/wyoming/test_conversation.py | 4 ++-- 7 files changed, 9 insertions(+), 9 deletions(-) diff --git a/tests/components/comelit/test_climate.py b/tests/components/comelit/test_climate.py index f9f28b4d675..059d7d27d77 100644 --- a/tests/components/comelit/test_climate.py +++ b/tests/components/comelit/test_climate.py @@ -42,7 +42,7 @@ async def test_all_entities( await snapshot_platform( hass, entity_registry, - snapshot(), + snapshot, mock_serial_bridge_config_entry.entry_id, ) diff --git a/tests/components/comelit/test_cover.py b/tests/components/comelit/test_cover.py index 1d6c1435a5a..7fb74911cc6 100644 --- a/tests/components/comelit/test_cover.py +++ b/tests/components/comelit/test_cover.py @@ -42,7 +42,7 @@ async def test_all_entities( await snapshot_platform( hass, entity_registry, - snapshot(), + snapshot, mock_serial_bridge_config_entry.entry_id, ) diff --git a/tests/components/comelit/test_light.py b/tests/components/comelit/test_light.py index 6c6de58c8ed..7c3cd15c135 100644 --- a/tests/components/comelit/test_light.py +++ b/tests/components/comelit/test_light.py @@ -36,7 +36,7 @@ async def test_all_entities( await snapshot_platform( hass, entity_registry, - snapshot(), + snapshot, mock_serial_bridge_config_entry.entry_id, ) diff --git a/tests/components/comelit/test_sensor.py b/tests/components/comelit/test_sensor.py index 8473158f662..2b857f9c94a 100644 --- a/tests/components/comelit/test_sensor.py +++ b/tests/components/comelit/test_sensor.py @@ -33,7 +33,7 @@ async def test_all_entities( await snapshot_platform( hass, entity_registry, - snapshot(), + snapshot, mock_vedo_config_entry.entry_id, ) diff --git a/tests/components/comelit/test_switch.py b/tests/components/comelit/test_switch.py index fb9a4aab79a..01efabf6b6f 100644 --- a/tests/components/comelit/test_switch.py +++ b/tests/components/comelit/test_switch.py @@ -36,7 +36,7 @@ async def test_all_entities( await snapshot_platform( hass, entity_registry, - snapshot(), + snapshot, mock_serial_bridge_config_entry.entry_id, ) diff --git a/tests/components/voip/test_voip.py b/tests/components/voip/test_voip.py index 459ab020336..7ac76227a1b 100644 --- a/tests/components/voip/test_voip.py +++ b/tests/components/voip/test_voip.py @@ -126,7 +126,7 @@ async def test_calls_not_allowed( await done.wait() assert sum(played_audio_bytes) > 0 - assert played_audio_bytes == snapshot() + assert played_audio_bytes == snapshot async def test_pipeline_not_found( @@ -846,7 +846,7 @@ async def test_pipeline_error( await done.wait() assert sum(played_audio_bytes) > 0 - assert played_audio_bytes == snapshot() + assert played_audio_bytes == snapshot @pytest.mark.usefixtures("socket_enabled") diff --git a/tests/components/wyoming/test_conversation.py b/tests/components/wyoming/test_conversation.py index 02b04503962..7278a254d4a 100644 --- a/tests/components/wyoming/test_conversation.py +++ b/tests/components/wyoming/test_conversation.py @@ -192,7 +192,7 @@ async def test_connection_lost( assert result.response.response_type == intent.IntentResponseType.ERROR assert result.response.error_code == intent.IntentResponseErrorCode.UNKNOWN assert result.response.speech, "No speech" - assert result.response.speech.get("plain", {}).get("speech") == snapshot() + assert result.response.speech.get("plain", {}).get("speech") == snapshot async def test_oserror( @@ -221,4 +221,4 @@ async def test_oserror( assert result.response.response_type == intent.IntentResponseType.ERROR assert result.response.error_code == intent.IntentResponseErrorCode.UNKNOWN assert result.response.speech, "No speech" - assert result.response.speech.get("plain", {}).get("speech") == snapshot() + assert result.response.speech.get("plain", {}).get("speech") == snapshot From 7b78f6db1711f41f825eb3101c6f7175fb495e52 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Fri, 11 Apr 2025 17:24:39 +0200 Subject: [PATCH 467/505] Fix SmartThings gas meter (#142741) --- .../components/smartthings/sensor.py | 6 +- tests/components/smartthings/conftest.py | 1 + .../fixtures/device_status/gas_meter.json | 61 ++++++ .../fixtures/devices/gas_meter.json | 56 +++++ .../smartthings/snapshots/test_init.ambr | 33 +++ .../smartthings/snapshots/test_sensor.ambr | 202 ++++++++++++++++++ 6 files changed, 356 insertions(+), 3 deletions(-) create mode 100644 tests/components/smartthings/fixtures/device_status/gas_meter.json create mode 100644 tests/components/smartthings/fixtures/devices/gas_meter.json diff --git a/homeassistant/components/smartthings/sensor.py b/homeassistant/components/smartthings/sensor.py index 346516be480..e081f35d0e0 100644 --- a/homeassistant/components/smartthings/sensor.py +++ b/homeassistant/components/smartthings/sensor.py @@ -413,7 +413,6 @@ CAPABILITY_TO_SENSORS: dict[ ) ] }, - # Haven't seen at devices yet Capability.GAS_METER: { Attribute.GAS_METER: [ SmartThingsSensorEntityDescription( @@ -421,7 +420,7 @@ CAPABILITY_TO_SENSORS: dict[ translation_key="gas_meter", native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, device_class=SensorDeviceClass.ENERGY, - state_class=SensorStateClass.MEASUREMENT, + state_class=SensorStateClass.TOTAL, ) ], Attribute.GAS_METER_CALORIFIC: [ @@ -443,7 +442,7 @@ CAPABILITY_TO_SENSORS: dict[ key=Attribute.GAS_METER_VOLUME, native_unit_of_measurement=UnitOfVolume.CUBIC_METERS, device_class=SensorDeviceClass.GAS, - state_class=SensorStateClass.MEASUREMENT, + state_class=SensorStateClass.TOTAL, ) ], }, @@ -1003,6 +1002,7 @@ CAPABILITY_TO_SENSORS: dict[ UNITS = { "C": UnitOfTemperature.CELSIUS, "F": UnitOfTemperature.FAHRENHEIT, + "ccf": UnitOfVolume.CENTUM_CUBIC_FEET, "lux": LIGHT_LUX, "mG": None, "μg/m^3": CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, diff --git a/tests/components/smartthings/conftest.py b/tests/components/smartthings/conftest.py index 277c327744f..26af812fe1f 100644 --- a/tests/components/smartthings/conftest.py +++ b/tests/components/smartthings/conftest.py @@ -146,6 +146,7 @@ def mock_smartthings() -> Generator[AsyncMock]: "ikea_kadrilj", "aux_ac", "hw_q80r_soundbar", + "gas_meter", ] ) def device_fixture( diff --git a/tests/components/smartthings/fixtures/device_status/gas_meter.json b/tests/components/smartthings/fixtures/device_status/gas_meter.json new file mode 100644 index 00000000000..dc7f9b2e0c3 --- /dev/null +++ b/tests/components/smartthings/fixtures/device_status/gas_meter.json @@ -0,0 +1,61 @@ +{ + "components": { + "main": { + "healthCheck": { + "checkInterval": { + "value": 60, + "unit": "s", + "data": { + "deviceScheme": "UNTRACKED", + "protocol": "cloud" + }, + "timestamp": "2025-02-27T14:06:11.704Z" + }, + "healthStatus": { + "value": null + }, + "DeviceWatch-Enroll": { + "value": null + }, + "DeviceWatch-DeviceStatus": { + "value": "online", + "data": {}, + "timestamp": "2025-04-11T13:00:00.444Z" + } + }, + "refresh": {}, + "gasMeter": { + "gasMeterPrecision": { + "value": { + "volume": 5, + "calorific": 1, + "conversion": 1 + }, + "timestamp": "2025-04-11T13:00:00.444Z" + }, + "gasMeterCalorific": { + "value": 40, + "timestamp": "2025-04-11T13:00:00.444Z" + }, + "gasMeterTime": { + "value": "2025-04-11T13:30:00.028Z", + "timestamp": "2025-04-11T13:30:00.532Z" + }, + "gasMeterVolume": { + "value": 14, + "unit": "ccf", + "timestamp": "2025-04-11T13:00:00.444Z" + }, + "gasMeterConversion": { + "value": 3.6, + "timestamp": "2025-04-11T13:00:00.444Z" + }, + "gasMeter": { + "value": 450.5, + "unit": "kWh", + "timestamp": "2025-04-11T13:00:00.444Z" + } + } + } + } +} diff --git a/tests/components/smartthings/fixtures/devices/gas_meter.json b/tests/components/smartthings/fixtures/devices/gas_meter.json new file mode 100644 index 00000000000..9bf8af654c7 --- /dev/null +++ b/tests/components/smartthings/fixtures/devices/gas_meter.json @@ -0,0 +1,56 @@ +{ + "items": [ + { + "deviceId": "3b57dca3-9a90-4f27-ba80-f947b1e60d58", + "name": "copper_gas_meter_v04", + "label": "Gas Meter", + "manufacturerName": "0A6v", + "presentationId": "ST_176e9efa-01d2-4d1b-8130-d37a4ef1b413", + "deviceManufacturerCode": "CopperLabs", + "locationId": "4e88bf74-3bed-4e6d-9fa7-6acb776a4df9", + "ownerId": "6fc21de5-123e-2f8c-2cc6-311635aeaaef", + "roomId": "fafae9db-a2b5-480f-8ff5-df8f913356df", + "components": [ + { + "id": "main", + "label": "main", + "capabilities": [ + { + "id": "healthCheck", + "version": 1 + }, + { + "id": "refresh", + "version": 1 + }, + { + "id": "gasMeter", + "version": 1 + } + ], + "categories": [ + { + "name": "GasMeter", + "categoryType": "manufacturer" + } + ] + } + ], + "createTime": "2025-02-27T14:06:11.522Z", + "profile": { + "id": "5cca2553-23d6-43c4-81ad-a1c6c43efa00" + }, + "viper": { + "manufacturerName": "CopperLabs", + "modelName": "Virtual Gas Meter", + "endpointAppId": "viper_1d5767a0-af08-11ed-a999-9f1f172a27ff" + }, + "type": "VIPER", + "restrictionTier": 0, + "allowed": null, + "executionContext": "CLOUD", + "relationships": [] + } + ], + "_links": {} +} diff --git a/tests/components/smartthings/snapshots/test_init.ambr b/tests/components/smartthings/snapshots/test_init.ambr index 8ec97af7d84..db8c3a6ccc5 100644 --- a/tests/components/smartthings/snapshots/test_init.ambr +++ b/tests/components/smartthings/snapshots/test_init.ambr @@ -1058,6 +1058,39 @@ 'via_device_id': None, }) # --- +# name: test_devices[gas_meter] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'config_entries_subentries': , + 'configuration_url': 'https://account.smartthings.com', + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'smartthings', + '3b57dca3-9a90-4f27-ba80-f947b1e60d58', + ), + }), + 'is_new': False, + 'labels': set({ + }), + 'manufacturer': 'CopperLabs', + 'model': 'Virtual Gas Meter', + 'model_id': None, + 'name': 'Gas Meter', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'suggested_area': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- # name: test_devices[ge_in_wall_smart_dimmer] DeviceRegistryEntrySnapshot({ 'area_id': 'theater', diff --git a/tests/components/smartthings/snapshots/test_sensor.ambr b/tests/components/smartthings/snapshots/test_sensor.ambr index 8ace345be18..e9441f2e408 100644 --- a/tests/components/smartthings/snapshots/test_sensor.ambr +++ b/tests/components/smartthings/snapshots/test_sensor.ambr @@ -8007,6 +8007,208 @@ 'state': 'unknown', }) # --- +# name: test_all_entities[gas_meter][sensor.gas_meter_gas-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.gas_meter_gas', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Gas', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '3b57dca3-9a90-4f27-ba80-f947b1e60d58_main_gasMeter_gasMeterVolume_gasMeterVolume', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[gas_meter][sensor.gas_meter_gas-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'gas', + 'friendly_name': 'Gas Meter Gas', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.gas_meter_gas', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '40', + }) +# --- +# name: test_all_entities[gas_meter][sensor.gas_meter_gas_meter-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.gas_meter_gas_meter', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Gas meter', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'gas_meter', + 'unique_id': '3b57dca3-9a90-4f27-ba80-f947b1e60d58_main_gasMeter_gasMeter_gasMeter', + 'unit_of_measurement': 'kWh', + }) +# --- +# name: test_all_entities[gas_meter][sensor.gas_meter_gas_meter-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Gas Meter Gas meter', + 'state_class': , + 'unit_of_measurement': 'kWh', + }), + 'context': , + 'entity_id': 'sensor.gas_meter_gas_meter', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '450.5', + }) +# --- +# name: test_all_entities[gas_meter][sensor.gas_meter_gas_meter_calorific-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.gas_meter_gas_meter_calorific', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Gas meter calorific', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'gas_meter_calorific', + 'unique_id': '3b57dca3-9a90-4f27-ba80-f947b1e60d58_main_gasMeter_gasMeterCalorific_gasMeterCalorific', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[gas_meter][sensor.gas_meter_gas_meter_calorific-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Gas Meter Gas meter calorific', + }), + 'context': , + 'entity_id': 'sensor.gas_meter_gas_meter_calorific', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '40', + }) +# --- +# name: test_all_entities[gas_meter][sensor.gas_meter_gas_meter_time-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.gas_meter_gas_meter_time', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Gas meter time', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'gas_meter_time', + 'unique_id': '3b57dca3-9a90-4f27-ba80-f947b1e60d58_main_gasMeter_gasMeterTime_gasMeterTime', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[gas_meter][sensor.gas_meter_gas_meter_time-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'timestamp', + 'friendly_name': 'Gas Meter Gas meter time', + }), + 'context': , + 'entity_id': 'sensor.gas_meter_gas_meter_time', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '2025-04-11T13:30:00+00:00', + }) +# --- # name: test_all_entities[generic_ef00_v1][sensor.thermostat_kuche_link_quality-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ From 20a3a061a15236b0d146614d5b49092d7a5cfc34 Mon Sep 17 00:00:00 2001 From: Bram Kragten Date: Fri, 11 Apr 2025 17:25:26 +0200 Subject: [PATCH 468/505] Update frontend to 20250411.0 (#142736) --- homeassistant/components/frontend/manifest.json | 2 +- homeassistant/package_constraints.txt | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/frontend/manifest.json b/homeassistant/components/frontend/manifest.json index 140d90c5dbe..64b49588ba1 100644 --- a/homeassistant/components/frontend/manifest.json +++ b/homeassistant/components/frontend/manifest.json @@ -20,5 +20,5 @@ "documentation": "https://www.home-assistant.io/integrations/frontend", "integration_type": "system", "quality_scale": "internal", - "requirements": ["home-assistant-frontend==20250404.0"] + "requirements": ["home-assistant-frontend==20250411.0"] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index cd51beebd41..96efb888ab7 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -38,7 +38,7 @@ habluetooth==3.37.0 hass-nabucasa==0.94.0 hassil==2.2.3 home-assistant-bluetooth==1.13.1 -home-assistant-frontend==20250404.0 +home-assistant-frontend==20250411.0 home-assistant-intents==2025.3.28 httpx==0.28.1 ifaddr==0.2.0 diff --git a/requirements_all.txt b/requirements_all.txt index 4a521e11914..02cee7082bc 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1157,7 +1157,7 @@ hole==0.8.0 holidays==0.69 # homeassistant.components.frontend -home-assistant-frontend==20250404.0 +home-assistant-frontend==20250411.0 # homeassistant.components.conversation home-assistant-intents==2025.3.28 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index d25277a0dde..fdbd0391601 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -987,7 +987,7 @@ hole==0.8.0 holidays==0.69 # homeassistant.components.frontend -home-assistant-frontend==20250404.0 +home-assistant-frontend==20250411.0 # homeassistant.components.conversation home-assistant-intents==2025.3.28 From 5a1a41beb11edb06173749e65ba8594ebeb70ad5 Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Fri, 11 Apr 2025 17:26:58 +0200 Subject: [PATCH 469/505] Fix reload of AVM FRITZ!Tools when new connected device is detected (#142430) --- homeassistant/components/fritz/button.py | 12 +----------- homeassistant/components/fritz/coordinator.py | 12 +++++++++++- homeassistant/components/fritz/entity.py | 3 +++ homeassistant/components/fritz/switch.py | 10 ---------- 4 files changed, 15 insertions(+), 22 deletions(-) diff --git a/homeassistant/components/fritz/button.py b/homeassistant/components/fritz/button.py index 4a5f7e5a443..926e233d159 100644 --- a/homeassistant/components/fritz/button.py +++ b/homeassistant/components/fritz/button.py @@ -18,7 +18,7 @@ from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC, Device from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from .const import BUTTON_TYPE_WOL, CONNECTION_TYPE_LAN, DOMAIN, MeshRoles +from .const import BUTTON_TYPE_WOL, CONNECTION_TYPE_LAN, MeshRoles from .coordinator import ( FRITZ_DATA_KEY, AvmWrapper, @@ -178,16 +178,6 @@ class FritzBoxWOLButton(FritzDeviceBase, ButtonEntity): self._name = f"{self.hostname} Wake on LAN" self._attr_unique_id = f"{self._mac}_wake_on_lan" self._is_available = True - self._attr_device_info = DeviceInfo( - connections={(CONNECTION_NETWORK_MAC, self._mac)}, - default_manufacturer="AVM", - default_model="FRITZ!Box Tracked device", - default_name=device.hostname, - via_device=( - DOMAIN, - avm_wrapper.unique_id, - ), - ) async def async_press(self) -> None: """Press the button.""" diff --git a/homeassistant/components/fritz/coordinator.py b/homeassistant/components/fritz/coordinator.py index d60232ec8ad..c0121ed9aa1 100644 --- a/homeassistant/components/fritz/coordinator.py +++ b/homeassistant/components/fritz/coordinator.py @@ -526,7 +526,7 @@ class FritzBoxTools(DataUpdateCoordinator[UpdateCoordinatorDataType]): def manage_device_info( self, dev_info: Device, dev_mac: str, consider_home: bool ) -> bool: - """Update device lists.""" + """Update device lists and return if device is new.""" _LOGGER.debug("Client dev_info: %s", dev_info) if dev_mac in self._devices: @@ -536,6 +536,16 @@ class FritzBoxTools(DataUpdateCoordinator[UpdateCoordinatorDataType]): device = FritzDevice(dev_mac, dev_info.name) device.update(dev_info, consider_home) self._devices[dev_mac] = device + + # manually register device entry for new connected device + dr.async_get(self.hass).async_get_or_create( + config_entry_id=self.config_entry.entry_id, + connections={(CONNECTION_NETWORK_MAC, dev_mac)}, + default_manufacturer="AVM", + default_model="FRITZ!Box Tracked device", + default_name=device.hostname, + via_device=(DOMAIN, self.unique_id), + ) return True async def async_send_signal_device_update(self, new_device: bool) -> None: diff --git a/homeassistant/components/fritz/entity.py b/homeassistant/components/fritz/entity.py index 33eb60d72cf..e8b5c49fd43 100644 --- a/homeassistant/components/fritz/entity.py +++ b/homeassistant/components/fritz/entity.py @@ -26,6 +26,9 @@ class FritzDeviceBase(CoordinatorEntity[AvmWrapper]): self._avm_wrapper = avm_wrapper self._mac: str = device.mac_address self._name: str = device.hostname or DEFAULT_DEVICE_NAME + self._attr_device_info = DeviceInfo( + connections={(dr.CONNECTION_NETWORK_MAC, device.mac_address)} + ) @property def name(self) -> str: diff --git a/homeassistant/components/fritz/switch.py b/homeassistant/components/fritz/switch.py index c00849c5240..a033e45fcec 100644 --- a/homeassistant/components/fritz/switch.py +++ b/homeassistant/components/fritz/switch.py @@ -514,16 +514,6 @@ class FritzBoxProfileSwitch(FritzDeviceBase, SwitchEntity): self._name = f"{device.hostname} Internet Access" self._attr_unique_id = f"{self._mac}_internet_access" self._attr_entity_category = EntityCategory.CONFIG - self._attr_device_info = DeviceInfo( - connections={(CONNECTION_NETWORK_MAC, self._mac)}, - default_manufacturer="AVM", - default_model="FRITZ!Box Tracked device", - default_name=device.hostname, - via_device=( - DOMAIN, - avm_wrapper.unique_id, - ), - ) @property def is_on(self) -> bool | None: From ca07975eadf15111b97bf65b87c6ee31b5e49064 Mon Sep 17 00:00:00 2001 From: Allen Porter Date: Fri, 11 Apr 2025 08:30:12 -0700 Subject: [PATCH 470/505] Fix Anthropic bug parsing a streaming response with no json (#142745) --- .../components/anthropic/conversation.py | 2 +- .../components/anthropic/test_conversation.py | 20 +++++++++++++++++-- 2 files changed, 19 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/anthropic/conversation.py b/homeassistant/components/anthropic/conversation.py index 5e5ad464eaa..288ec63509e 100644 --- a/homeassistant/components/anthropic/conversation.py +++ b/homeassistant/components/anthropic/conversation.py @@ -266,7 +266,7 @@ async def _transform_stream( raise ValueError("Unexpected stop event without a current block") if current_block["type"] == "tool_use": tool_block = cast(ToolUseBlockParam, current_block) - tool_args = json.loads(current_tool_args) + tool_args = json.loads(current_tool_args) if current_tool_args else {} tool_block["input"] = tool_args yield { "tool_calls": [ diff --git a/tests/components/anthropic/test_conversation.py b/tests/components/anthropic/test_conversation.py index 67a4434a664..caaef43e931 100644 --- a/tests/components/anthropic/test_conversation.py +++ b/tests/components/anthropic/test_conversation.py @@ -303,11 +303,27 @@ async def test_conversation_agent( @patch("homeassistant.components.anthropic.conversation.llm.AssistAPI._async_get_tools") +@pytest.mark.parametrize( + ("tool_call_json_parts", "expected_call_tool_args"), + [ + ( + ['{"param1": "test_value"}'], + {"param1": "test_value"}, + ), + ( + ['{"para', 'm1": "test_valu', 'e"}'], + {"param1": "test_value"}, + ), + ([""], {}), + ], +) async def test_function_call( mock_get_tools, hass: HomeAssistant, mock_config_entry_with_assist: MockConfigEntry, mock_init_component, + tool_call_json_parts: list[str], + expected_call_tool_args: dict[str, Any], ) -> None: """Test function call from the assistant.""" agent_id = "conversation.claude" @@ -343,7 +359,7 @@ async def test_function_call( 1, "toolu_0123456789AbCdEfGhIjKlM", "test_tool", - ['{"para', 'm1": "test_valu', 'e"}'], + tool_call_json_parts, ), ] ) @@ -387,7 +403,7 @@ async def test_function_call( llm.ToolInput( id="toolu_0123456789AbCdEfGhIjKlM", tool_name="test_tool", - tool_args={"param1": "test_value"}, + tool_args=expected_call_tool_args, ), llm.LLMContext( platform="anthropic", From a4fac730d4a667e0af9a3c5e2c5d3a407ebd2675 Mon Sep 17 00:00:00 2001 From: Jeff Rescignano Date: Fri, 11 Apr 2025 12:07:27 -0400 Subject: [PATCH 471/505] Upgrade sharkiq depedency to 1.1.0 (#142746) --- homeassistant/components/sharkiq/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/sharkiq/manifest.json b/homeassistant/components/sharkiq/manifest.json index 0e07dd96902..9f9009693e5 100644 --- a/homeassistant/components/sharkiq/manifest.json +++ b/homeassistant/components/sharkiq/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/sharkiq", "iot_class": "cloud_polling", "loggers": ["sharkiq"], - "requirements": ["sharkiq==1.0.2"] + "requirements": ["sharkiq==1.1.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index 02cee7082bc..fb8acbf1527 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2733,7 +2733,7 @@ sentry-sdk==1.45.1 sfrbox-api==0.0.11 # homeassistant.components.sharkiq -sharkiq==1.0.2 +sharkiq==1.1.0 # homeassistant.components.aquostv sharp_aquos_rc==0.3.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index fdbd0391601..cc6b5bc0e8e 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2213,7 +2213,7 @@ sentry-sdk==1.45.1 sfrbox-api==0.0.11 # homeassistant.components.sharkiq -sharkiq==1.0.2 +sharkiq==1.1.0 # homeassistant.components.simplefin simplefin4py==0.0.18 From a3341c4330241404213cbb2747104bd00df64403 Mon Sep 17 00:00:00 2001 From: Simone Chemelli Date: Fri, 11 Apr 2025 18:23:03 +0200 Subject: [PATCH 472/505] Add full test coverage for Comelit humidifier platform (#141852) * Add full test coverage for Comelit humidifier platform * clean * update snapshot * apply review comment --- tests/components/comelit/const.py | 2 +- .../comelit/snapshots/test_diagnostics.ambr | 4 +- .../comelit/snapshots/test_humidifier.ambr | 133 ++++++++ tests/components/comelit/test_humidifier.py | 292 ++++++++++++++++++ 4 files changed, 428 insertions(+), 3 deletions(-) create mode 100644 tests/components/comelit/snapshots/test_humidifier.ambr create mode 100644 tests/components/comelit/test_humidifier.py diff --git a/tests/components/comelit/const.py b/tests/components/comelit/const.py index d06e6cfd8cb..0cbdaf56bbe 100644 --- a/tests/components/comelit/const.py +++ b/tests/components/comelit/const.py @@ -38,7 +38,7 @@ BRIDGE_DEVICE_QUERY = { type="climate", val=[ [221, 0, "U", "M", 50, 0, 0, "U"], - [650, 0, "O", "M", 500, 0, 0, "N"], + [650, 0, "U", "M", 500, 0, 0, "U"], [0, 0], ], protected=0, diff --git a/tests/components/comelit/snapshots/test_diagnostics.ambr b/tests/components/comelit/snapshots/test_diagnostics.ambr index 51ea646df9f..c9ebf635353 100644 --- a/tests/components/comelit/snapshots/test_diagnostics.ambr +++ b/tests/components/comelit/snapshots/test_diagnostics.ambr @@ -27,12 +27,12 @@ list([ 650, 0, - 'O', + 'U', 'M', 500, 0, 0, - 'N', + 'U', ]), list([ 0, diff --git a/tests/components/comelit/snapshots/test_humidifier.ambr b/tests/components/comelit/snapshots/test_humidifier.ambr new file mode 100644 index 00000000000..ffe53d09c5d --- /dev/null +++ b/tests/components/comelit/snapshots/test_humidifier.ambr @@ -0,0 +1,133 @@ +# serializer version: 1 +# name: test_all_entities[humidifier.climate0_dehumidifier-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'available_modes': list([ + 'normal', + 'auto', + ]), + 'max_humidity': 90, + 'min_humidity': 10, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'humidifier', + 'entity_category': None, + 'entity_id': 'humidifier.climate0_dehumidifier', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Dehumidifier', + 'platform': 'comelit', + 'previous_unique_id': None, + 'supported_features': , + 'translation_key': 'dehumidifier', + 'unique_id': 'serial_bridge_config_entry_id-0-dehumidifier', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[humidifier.climate0_dehumidifier-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'action': , + 'available_modes': list([ + 'normal', + 'auto', + ]), + 'current_humidity': 65.0, + 'device_class': 'dehumidifier', + 'friendly_name': 'Climate0 Dehumidifier', + 'humidity': 50.0, + 'max_humidity': 90, + 'min_humidity': 10, + 'mode': 'normal', + 'supported_features': , + }), + 'context': , + 'entity_id': 'humidifier.climate0_dehumidifier', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_all_entities[humidifier.climate0_humidifier-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'available_modes': list([ + 'normal', + 'auto', + ]), + 'max_humidity': 90, + 'min_humidity': 10, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'humidifier', + 'entity_category': None, + 'entity_id': 'humidifier.climate0_humidifier', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Humidifier', + 'platform': 'comelit', + 'previous_unique_id': None, + 'supported_features': , + 'translation_key': 'humidifier', + 'unique_id': 'serial_bridge_config_entry_id-0-humidifier', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[humidifier.climate0_humidifier-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'action': , + 'available_modes': list([ + 'normal', + 'auto', + ]), + 'current_humidity': 65.0, + 'device_class': 'humidifier', + 'friendly_name': 'Climate0 Humidifier', + 'humidity': 50.0, + 'max_humidity': 90, + 'min_humidity': 10, + 'mode': 'normal', + 'supported_features': , + }), + 'context': , + 'entity_id': 'humidifier.climate0_humidifier', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- diff --git a/tests/components/comelit/test_humidifier.py b/tests/components/comelit/test_humidifier.py new file mode 100644 index 00000000000..448453aadef --- /dev/null +++ b/tests/components/comelit/test_humidifier.py @@ -0,0 +1,292 @@ +"""Tests for Comelit SimpleHome humidifier platform.""" + +from typing import Any +from unittest.mock import AsyncMock, patch + +from aiocomelit.api import ComelitSerialBridgeObject +from aiocomelit.const import CLIMATE, WATT +from freezegun.api import FrozenDateTimeFactory +import pytest +from syrupy import SnapshotAssertion + +from homeassistant.components.comelit.const import DOMAIN, SCAN_INTERVAL +from homeassistant.components.humidifier import ( + ATTR_HUMIDITY, + ATTR_MODE, + DOMAIN as HUMIDIFIER_DOMAIN, + MODE_AUTO, + MODE_NORMAL, + SERVICE_SET_HUMIDITY, + SERVICE_SET_MODE, + SERVICE_TURN_OFF, + SERVICE_TURN_ON, +) +from homeassistant.const import ATTR_ENTITY_ID, STATE_OFF, STATE_ON, Platform +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers import entity_registry as er + +from . import setup_integration + +from tests.common import MockConfigEntry, async_fire_time_changed, snapshot_platform + +ENTITY_ID = "humidifier.climate0_humidifier" + + +async def test_all_entities( + hass: HomeAssistant, + snapshot: SnapshotAssertion, + mock_serial_bridge: AsyncMock, + mock_serial_bridge_config_entry: MockConfigEntry, + entity_registry: er.EntityRegistry, +) -> None: + """Test all entities.""" + with patch( + "homeassistant.components.comelit.BRIDGE_PLATFORMS", [Platform.HUMIDIFIER] + ): + await setup_integration(hass, mock_serial_bridge_config_entry) + + await snapshot_platform( + hass, + entity_registry, + snapshot, + mock_serial_bridge_config_entry.entry_id, + ) + + +@pytest.mark.parametrize( + ("val", "mode", "humidity"), + [ + ( + [ + [100, 0, "U", "M", 210, 0, 0, "U"], + [650, 0, "U", "M", 500, 0, 0, "U"], + [0, 0], + ], + STATE_ON, + 50.0, + ), + ( + [ + [100, 1, "U", "A", 210, 1, 0, "O"], + [650, 1, "U", "A", 500, 1, 0, "O"], + [0, 0], + ], + STATE_ON, + 50.0, + ), + ( + [ + [100, 0, "O", "A", 210, 0, 0, "O"], + [650, 0, "O", "A", 500, 0, 0, "O"], + [0, 0], + ], + STATE_OFF, + 50.0, + ), + ], +) +async def test_humidifier_data_update( + hass: HomeAssistant, + freezer: FrozenDateTimeFactory, + mock_serial_bridge: AsyncMock, + mock_serial_bridge_config_entry: MockConfigEntry, + val: list[Any, Any], + mode: str, + humidity: float, +) -> None: + """Test humidifier data update.""" + await setup_integration(hass, mock_serial_bridge_config_entry) + + assert (state := hass.states.get(ENTITY_ID)) + assert state.state == STATE_ON + assert state.attributes[ATTR_HUMIDITY] == 50.0 + + mock_serial_bridge.get_all_devices.return_value[CLIMATE] = { + 0: ComelitSerialBridgeObject( + index=0, + name="Climate0", + status=0, + human_status="off", + type="climate", + val=val, + protected=0, + zone="Living room", + power=0.0, + power_unit=WATT, + ), + } + + freezer.tick(SCAN_INTERVAL) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + assert (state := hass.states.get(ENTITY_ID)) + assert state.state == mode + assert state.attributes[ATTR_HUMIDITY] == humidity + + +async def test_humidifier_data_update_bad_data( + hass: HomeAssistant, + freezer: FrozenDateTimeFactory, + mock_serial_bridge: AsyncMock, + mock_serial_bridge_config_entry: MockConfigEntry, +) -> None: + """Test humidifier data update.""" + await setup_integration(hass, mock_serial_bridge_config_entry) + + assert (state := hass.states.get(ENTITY_ID)) + assert state.state == STATE_ON + assert state.attributes[ATTR_HUMIDITY] == 50.0 + + mock_serial_bridge.get_all_devices.return_value[CLIMATE] = { + 0: ComelitSerialBridgeObject( + index=0, + name="Climate0", + status=0, + human_status="off", + type="climate", + val="bad_data", + protected=0, + zone="Living room", + power=0.0, + power_unit=WATT, + ), + } + + freezer.tick(SCAN_INTERVAL) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + assert (state := hass.states.get(ENTITY_ID)) + assert state.state == STATE_ON + assert state.attributes[ATTR_HUMIDITY] == 50.0 + + +async def test_humidifier_set_humidity( + hass: HomeAssistant, + mock_serial_bridge: AsyncMock, + mock_serial_bridge_config_entry: MockConfigEntry, +) -> None: + """Test humidifier set humidity service.""" + + await setup_integration(hass, mock_serial_bridge_config_entry) + + assert (state := hass.states.get(ENTITY_ID)) + assert state.state == STATE_ON + assert state.attributes[ATTR_HUMIDITY] == 50.0 + + # Test set humidity + await hass.services.async_call( + HUMIDIFIER_DOMAIN, + SERVICE_SET_HUMIDITY, + {ATTR_ENTITY_ID: ENTITY_ID, ATTR_HUMIDITY: 23}, + blocking=True, + ) + mock_serial_bridge.set_humidity_status.assert_called() + + assert (state := hass.states.get(ENTITY_ID)) + assert state.state == STATE_ON + assert state.attributes[ATTR_HUMIDITY] == 23.0 + + +async def test_humidifier_set_humidity_while_off( + hass: HomeAssistant, + mock_serial_bridge: AsyncMock, + mock_serial_bridge_config_entry: MockConfigEntry, +) -> None: + """Test humidifier set humidity service while off.""" + + await setup_integration(hass, mock_serial_bridge_config_entry) + + assert (state := hass.states.get(ENTITY_ID)) + assert state.state == STATE_ON + assert state.attributes[ATTR_HUMIDITY] == 50.0 + + # Switch humidifier off + await hass.services.async_call( + HUMIDIFIER_DOMAIN, + SERVICE_TURN_OFF, + {ATTR_ENTITY_ID: ENTITY_ID}, + blocking=True, + ) + mock_serial_bridge.set_humidity_status.assert_called() + + assert (state := hass.states.get(ENTITY_ID)) + assert state.state == STATE_OFF + + # Try setting humidity + with pytest.raises(HomeAssistantError) as exc_info: + await hass.services.async_call( + HUMIDIFIER_DOMAIN, + SERVICE_SET_HUMIDITY, + {ATTR_ENTITY_ID: ENTITY_ID, ATTR_HUMIDITY: 23}, + blocking=True, + ) + assert exc_info.value.translation_domain == DOMAIN + assert exc_info.value.translation_key == "humidity_while_off" + + +async def test_humidifier_set_mode( + hass: HomeAssistant, + mock_serial_bridge: AsyncMock, + mock_serial_bridge_config_entry: MockConfigEntry, +) -> None: + """Test humidifier set mode service.""" + + await setup_integration(hass, mock_serial_bridge_config_entry) + + assert (state := hass.states.get(ENTITY_ID)) + assert state.state == STATE_ON + assert state.attributes[ATTR_HUMIDITY] == 50.0 + assert state.attributes[ATTR_MODE] == MODE_NORMAL + + await hass.services.async_call( + HUMIDIFIER_DOMAIN, + SERVICE_SET_MODE, + {ATTR_ENTITY_ID: ENTITY_ID, ATTR_MODE: MODE_AUTO}, + blocking=True, + ) + mock_serial_bridge.set_humidity_status.assert_called() + + assert (state := hass.states.get(ENTITY_ID)) + assert state.state == STATE_ON + assert state.attributes[ATTR_MODE] == MODE_AUTO + + +async def test_humidifier_set_status( + hass: HomeAssistant, + mock_serial_bridge: AsyncMock, + mock_serial_bridge_config_entry: MockConfigEntry, +) -> None: + """Test humidifier set status service.""" + + await setup_integration(hass, mock_serial_bridge_config_entry) + + assert (state := hass.states.get(ENTITY_ID)) + assert state.state == STATE_ON + assert state.attributes[ATTR_HUMIDITY] == 50.0 + + # Test turn off + await hass.services.async_call( + HUMIDIFIER_DOMAIN, + SERVICE_TURN_OFF, + {ATTR_ENTITY_ID: ENTITY_ID}, + blocking=True, + ) + mock_serial_bridge.set_humidity_status.assert_called() + + assert (state := hass.states.get(ENTITY_ID)) + assert state.state == STATE_OFF + + # Test turn on + await hass.services.async_call( + HUMIDIFIER_DOMAIN, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: ENTITY_ID}, + blocking=True, + ) + mock_serial_bridge.set_humidity_status.assert_called() + + assert (state := hass.states.get(ENTITY_ID)) + assert state.state == STATE_ON From b01eac3ba5f9528275fbc2d39c21b60f7620c217 Mon Sep 17 00:00:00 2001 From: Manu <4445816+tr4nt0r@users.noreply.github.com> Date: Fri, 11 Apr 2025 19:39:40 +0200 Subject: [PATCH 473/505] Fix error in recurrence calculation of Habitica integration (#142759) Fix error in rrule calculation of Habitica integration --- homeassistant/components/habitica/util.py | 2 +- tests/components/habitica/fixtures/tasks.json | 43 +++ tests/components/habitica/fixtures/user.json | 3 +- .../habitica/snapshots/test_calendar.ambr | 28 ++ .../habitica/snapshots/test_services.ambr | 300 ++++++++++++++++++ .../habitica/snapshots/test_todo.ambr | 9 +- 6 files changed, 382 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/habitica/util.py b/homeassistant/components/habitica/util.py index 757c675b045..1ca908eb3ff 100644 --- a/homeassistant/components/habitica/util.py +++ b/homeassistant/components/habitica/util.py @@ -74,7 +74,7 @@ def build_rrule(task: TaskData) -> rrule: bysetpos = None if rrule_frequency == MONTHLY and task.weeksOfMonth: - bysetpos = task.weeksOfMonth + bysetpos = [i + 1 for i in task.weeksOfMonth] weekdays = weekdays if weekdays else [MO] return rrule( diff --git a/tests/components/habitica/fixtures/tasks.json b/tests/components/habitica/fixtures/tasks.json index 085508b4432..ecbe0a1f86d 100644 --- a/tests/components/habitica/fixtures/tasks.json +++ b/tests/components/habitica/fixtures/tasks.json @@ -624,6 +624,49 @@ "isDue": false, "id": "6e53f1f5-a315-4edd-984d-8d762e4a08ef" }, + { + "repeat": { + "m": false, + "t": false, + "w": false, + "th": false, + "f": false, + "s": false, + "su": true + }, + "challenge": {}, + "group": { + "completedBy": {}, + "assignedUsers": [] + }, + "_id": "369afeed-61e3-4bf7-9747-66e05807134c", + "frequency": "monthly", + "everyX": 1, + "streak": 1, + "nextDue": ["2024-12-14T23:00:00.000Z", "2025-01-18T23:00:00.000Z"], + "yesterDaily": true, + "history": [], + "completed": false, + "collapseChecklist": false, + "type": "daily", + "text": "Monatliche Finanzübersicht erstellen", + "notes": "Setze dich einmal im Monat hin, um deine Einnahmen und Ausgaben zu überprüfen und dein Budget zu planen.", + "tags": [], + "value": -0.9215181434950852, + "priority": 1, + "attribute": "str", + "byHabitica": false, + "startDate": "2024-04-04T22:00:00.000Z", + "daysOfMonth": [], + "weeksOfMonth": [0], + "checklist": [], + "reminders": [], + "createdAt": "2024-04-04T22:00:00.000Z", + "updatedAt": "2024-04-04T22:00:00.000Z", + "userId": "5f359083-ef78-4af0-985a-0b2c6d05797c", + "isDue": false, + "id": "369afeed-61e3-4bf7-9747-66e05807134c" + }, { "repeat": { "m": false, diff --git a/tests/components/habitica/fixtures/user.json b/tests/components/habitica/fixtures/user.json index 58eca2837b6..d2f0091b6dd 100644 --- a/tests/components/habitica/fixtures/user.json +++ b/tests/components/habitica/fixtures/user.json @@ -66,7 +66,8 @@ "564b9ac9-c53d-4638-9e7f-1cd96fe19baa", "f2c85972-1a19-4426-bc6d-ce3337b9d99f", "2c6d136c-a1c3-4bef-b7c4-fa980784b1e1", - "6e53f1f5-a315-4edd-984d-8d762e4a08ef" + "6e53f1f5-a315-4edd-984d-8d762e4a08ef", + "369afeed-61e3-4bf7-9747-66e05807134c" ], "habits": ["1d147de6-5c02-4740-8e2f-71d3015a37f4"] }, diff --git a/tests/components/habitica/snapshots/test_calendar.ambr b/tests/components/habitica/snapshots/test_calendar.ambr index 2948f31f1cf..c7f12684efe 100644 --- a/tests/components/habitica/snapshots/test_calendar.ambr +++ b/tests/components/habitica/snapshots/test_calendar.ambr @@ -87,6 +87,20 @@ 'summary': 'Fitnessstudio besuchen', 'uid': '2c6d136c-a1c3-4bef-b7c4-fa980784b1e1', }), + dict({ + 'description': 'Klicke um den Namen Deines aktuellen Projekts anzugeben & setze einen Terminplan!', + 'end': dict({ + 'date': '2024-09-23', + }), + 'location': None, + 'recurrence_id': None, + 'rrule': 'FREQ=MONTHLY;BYSETPOS=4;BYDAY=SU', + 'start': dict({ + 'date': '2024-09-22', + }), + 'summary': 'Arbeite an einem kreativen Projekt', + 'uid': '6e53f1f5-a315-4edd-984d-8d762e4a08ef', + }), dict({ 'description': 'Klicke um Änderungen zu machen!', 'end': dict({ @@ -563,6 +577,20 @@ 'summary': 'Fitnessstudio besuchen', 'uid': '2c6d136c-a1c3-4bef-b7c4-fa980784b1e1', }), + dict({ + 'description': 'Setze dich einmal im Monat hin, um deine Einnahmen und Ausgaben zu überprüfen und dein Budget zu planen.', + 'end': dict({ + 'date': '2024-10-07', + }), + 'location': None, + 'recurrence_id': None, + 'rrule': 'FREQ=MONTHLY;BYSETPOS=1;BYDAY=SU', + 'start': dict({ + 'date': '2024-10-06', + }), + 'summary': 'Monatliche Finanzübersicht erstellen', + 'uid': '369afeed-61e3-4bf7-9747-66e05807134c', + }), dict({ 'description': 'Klicke um Änderungen zu machen!', 'end': dict({ diff --git a/tests/components/habitica/snapshots/test_services.ambr b/tests/components/habitica/snapshots/test_services.ambr index 430cd379c0d..9fbb6a43e94 100644 --- a/tests/components/habitica/snapshots/test_services.ambr +++ b/tests/components/habitica/snapshots/test_services.ambr @@ -1193,6 +1193,81 @@ ]), 'yesterDaily': True, }), + dict({ + 'alias': None, + 'attribute': 'str', + 'byHabitica': False, + 'challenge': dict({ + 'broken': None, + 'id': None, + 'shortName': None, + 'taskId': None, + 'winner': None, + }), + 'checklist': list([ + ]), + 'collapseChecklist': False, + 'completed': False, + 'counterDown': None, + 'counterUp': None, + 'createdAt': '2024-04-04T22:00:00+00:00', + 'date': None, + 'daysOfMonth': list([ + ]), + 'down': None, + 'everyX': 1, + 'frequency': 'monthly', + 'group': dict({ + 'assignedDate': None, + 'assignedUsers': list([ + ]), + 'assignedUsersDetail': dict({ + }), + 'assigningUsername': None, + 'completedBy': dict({ + 'date': None, + 'userId': None, + }), + 'id': None, + 'managerNotes': None, + 'taskId': None, + }), + 'history': list([ + ]), + 'id': '369afeed-61e3-4bf7-9747-66e05807134c', + 'isDue': False, + 'nextDue': list([ + '2024-12-14T23:00:00+00:00', + '2025-01-18T23:00:00+00:00', + ]), + 'notes': 'Setze dich einmal im Monat hin, um deine Einnahmen und Ausgaben zu überprüfen und dein Budget zu planen.', + 'priority': 1, + 'reminders': list([ + ]), + 'repeat': dict({ + 'f': False, + 'm': False, + 's': False, + 'su': True, + 't': False, + 'th': False, + 'w': False, + }), + 'startDate': '2024-04-04T22:00:00+00:00', + 'streak': 1, + 'tags': list([ + ]), + 'text': 'Monatliche Finanzübersicht erstellen', + 'type': 'daily', + 'up': None, + 'updatedAt': '2024-04-04T22:00:00+00:00', + 'userId': '5f359083-ef78-4af0-985a-0b2c6d05797c', + 'value': -0.9215181434950852, + 'weeksOfMonth': list([ + 0, + ]), + 'yesterDaily': True, + }), dict({ 'alias': None, 'attribute': 'str', @@ -3465,6 +3540,81 @@ ]), 'yesterDaily': True, }), + dict({ + 'alias': None, + 'attribute': 'str', + 'byHabitica': False, + 'challenge': dict({ + 'broken': None, + 'id': None, + 'shortName': None, + 'taskId': None, + 'winner': None, + }), + 'checklist': list([ + ]), + 'collapseChecklist': False, + 'completed': False, + 'counterDown': None, + 'counterUp': None, + 'createdAt': '2024-04-04T22:00:00+00:00', + 'date': None, + 'daysOfMonth': list([ + ]), + 'down': None, + 'everyX': 1, + 'frequency': 'monthly', + 'group': dict({ + 'assignedDate': None, + 'assignedUsers': list([ + ]), + 'assignedUsersDetail': dict({ + }), + 'assigningUsername': None, + 'completedBy': dict({ + 'date': None, + 'userId': None, + }), + 'id': None, + 'managerNotes': None, + 'taskId': None, + }), + 'history': list([ + ]), + 'id': '369afeed-61e3-4bf7-9747-66e05807134c', + 'isDue': False, + 'nextDue': list([ + '2024-12-14T23:00:00+00:00', + '2025-01-18T23:00:00+00:00', + ]), + 'notes': 'Setze dich einmal im Monat hin, um deine Einnahmen und Ausgaben zu überprüfen und dein Budget zu planen.', + 'priority': 1, + 'reminders': list([ + ]), + 'repeat': dict({ + 'f': False, + 'm': False, + 's': False, + 'su': True, + 't': False, + 'th': False, + 'w': False, + }), + 'startDate': '2024-04-04T22:00:00+00:00', + 'streak': 1, + 'tags': list([ + ]), + 'text': 'Monatliche Finanzübersicht erstellen', + 'type': 'daily', + 'up': None, + 'updatedAt': '2024-04-04T22:00:00+00:00', + 'userId': '5f359083-ef78-4af0-985a-0b2c6d05797c', + 'value': -0.9215181434950852, + 'weeksOfMonth': list([ + 0, + ]), + 'yesterDaily': True, + }), dict({ 'alias': None, 'attribute': 'str', @@ -4608,6 +4758,81 @@ ]), 'yesterDaily': True, }), + dict({ + 'alias': None, + 'attribute': 'str', + 'byHabitica': False, + 'challenge': dict({ + 'broken': None, + 'id': None, + 'shortName': None, + 'taskId': None, + 'winner': None, + }), + 'checklist': list([ + ]), + 'collapseChecklist': False, + 'completed': False, + 'counterDown': None, + 'counterUp': None, + 'createdAt': '2024-04-04T22:00:00+00:00', + 'date': None, + 'daysOfMonth': list([ + ]), + 'down': None, + 'everyX': 1, + 'frequency': 'monthly', + 'group': dict({ + 'assignedDate': None, + 'assignedUsers': list([ + ]), + 'assignedUsersDetail': dict({ + }), + 'assigningUsername': None, + 'completedBy': dict({ + 'date': None, + 'userId': None, + }), + 'id': None, + 'managerNotes': None, + 'taskId': None, + }), + 'history': list([ + ]), + 'id': '369afeed-61e3-4bf7-9747-66e05807134c', + 'isDue': False, + 'nextDue': list([ + '2024-12-14T23:00:00+00:00', + '2025-01-18T23:00:00+00:00', + ]), + 'notes': 'Setze dich einmal im Monat hin, um deine Einnahmen und Ausgaben zu überprüfen und dein Budget zu planen.', + 'priority': 1, + 'reminders': list([ + ]), + 'repeat': dict({ + 'f': False, + 'm': False, + 's': False, + 'su': True, + 't': False, + 'th': False, + 'w': False, + }), + 'startDate': '2024-04-04T22:00:00+00:00', + 'streak': 1, + 'tags': list([ + ]), + 'text': 'Monatliche Finanzübersicht erstellen', + 'type': 'daily', + 'up': None, + 'updatedAt': '2024-04-04T22:00:00+00:00', + 'userId': '5f359083-ef78-4af0-985a-0b2c6d05797c', + 'value': -0.9215181434950852, + 'weeksOfMonth': list([ + 0, + ]), + 'yesterDaily': True, + }), dict({ 'alias': None, 'attribute': 'str', @@ -5199,6 +5424,81 @@ ]), 'yesterDaily': True, }), + dict({ + 'alias': None, + 'attribute': 'str', + 'byHabitica': False, + 'challenge': dict({ + 'broken': None, + 'id': None, + 'shortName': None, + 'taskId': None, + 'winner': None, + }), + 'checklist': list([ + ]), + 'collapseChecklist': False, + 'completed': False, + 'counterDown': None, + 'counterUp': None, + 'createdAt': '2024-04-04T22:00:00+00:00', + 'date': None, + 'daysOfMonth': list([ + ]), + 'down': None, + 'everyX': 1, + 'frequency': 'monthly', + 'group': dict({ + 'assignedDate': None, + 'assignedUsers': list([ + ]), + 'assignedUsersDetail': dict({ + }), + 'assigningUsername': None, + 'completedBy': dict({ + 'date': None, + 'userId': None, + }), + 'id': None, + 'managerNotes': None, + 'taskId': None, + }), + 'history': list([ + ]), + 'id': '369afeed-61e3-4bf7-9747-66e05807134c', + 'isDue': False, + 'nextDue': list([ + '2024-12-14T23:00:00+00:00', + '2025-01-18T23:00:00+00:00', + ]), + 'notes': 'Setze dich einmal im Monat hin, um deine Einnahmen und Ausgaben zu überprüfen und dein Budget zu planen.', + 'priority': 1, + 'reminders': list([ + ]), + 'repeat': dict({ + 'f': False, + 'm': False, + 's': False, + 'su': True, + 't': False, + 'th': False, + 'w': False, + }), + 'startDate': '2024-04-04T22:00:00+00:00', + 'streak': 1, + 'tags': list([ + ]), + 'text': 'Monatliche Finanzübersicht erstellen', + 'type': 'daily', + 'up': None, + 'updatedAt': '2024-04-04T22:00:00+00:00', + 'userId': '5f359083-ef78-4af0-985a-0b2c6d05797c', + 'value': -0.9215181434950852, + 'weeksOfMonth': list([ + 0, + ]), + 'yesterDaily': True, + }), dict({ 'alias': None, 'attribute': 'str', diff --git a/tests/components/habitica/snapshots/test_todo.ambr b/tests/components/habitica/snapshots/test_todo.ambr index 88204d53ded..fef9404a0f0 100644 --- a/tests/components/habitica/snapshots/test_todo.ambr +++ b/tests/components/habitica/snapshots/test_todo.ambr @@ -49,6 +49,13 @@ 'summary': 'Arbeite an einem kreativen Projekt', 'uid': '6e53f1f5-a315-4edd-984d-8d762e4a08ef', }), + dict({ + 'description': 'Setze dich einmal im Monat hin, um deine Einnahmen und Ausgaben zu überprüfen und dein Budget zu planen.', + 'due': '2024-12-14', + 'status': 'needs_action', + 'summary': 'Monatliche Finanzübersicht erstellen', + 'uid': '369afeed-61e3-4bf7-9747-66e05807134c', + }), dict({ 'description': 'Wähle eine Programmiersprache aus, die du noch nicht kennst, und lerne die Grundlagen.', 'status': 'needs_action', @@ -151,7 +158,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '4', + 'state': '5', }) # --- # name: test_todos[todo.test_user_to_do_s-entry] From ffcc2254ceb6a5bfda2249c51528d87f0d8c0c19 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Fri, 11 Apr 2025 19:40:37 +0200 Subject: [PATCH 474/505] Refactor Syncthru binary sensor (#142696) --- .../components/syncthru/binary_sensor.py | 92 +++++++++---------- 1 file changed, 46 insertions(+), 46 deletions(-) diff --git a/homeassistant/components/syncthru/binary_sensor.py b/homeassistant/components/syncthru/binary_sensor.py index 6f6bd73af77..d863c5546d8 100644 --- a/homeassistant/components/syncthru/binary_sensor.py +++ b/homeassistant/components/syncthru/binary_sensor.py @@ -2,11 +2,15 @@ from __future__ import annotations -from pysyncthru import SyncthruState +from collections.abc import Callable +from dataclasses import dataclass + +from pysyncthru import SyncThru, SyncthruState from homeassistant.components.binary_sensor import ( BinarySensorDeviceClass, BinarySensorEntity, + BinarySensorEntityDescription, ) from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_NAME @@ -29,6 +33,27 @@ SYNCTHRU_STATE_PROBLEM = { } +@dataclass(frozen=True, kw_only=True) +class SyncThruBinarySensorDescription(BinarySensorEntityDescription): + """Describes Syncthru binary sensor entities.""" + + value_fn: Callable[[SyncThru], bool | None] + + +BINARY_SENSORS: tuple[SyncThruBinarySensorDescription, ...] = ( + SyncThruBinarySensorDescription( + key="online", + device_class=BinarySensorDeviceClass.CONNECTIVITY, + value_fn=lambda printer: printer.is_online(), + ), + SyncThruBinarySensorDescription( + key="problem", + device_class=BinarySensorDeviceClass.PROBLEM, + value_fn=lambda printer: SYNCTHRU_STATE_PROBLEM[printer.device_status()], + ), +) + + async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, @@ -39,67 +64,42 @@ async def async_setup_entry( coordinator: SyncthruCoordinator = hass.data[DOMAIN][config_entry.entry_id] name: str = config_entry.data[CONF_NAME] - entities = [ - SyncThruOnlineSensor(coordinator, name), - SyncThruProblemSensor(coordinator, name), - ] - async_add_entities(entities) + async_add_entities( + SyncThruBinarySensor(coordinator, name, description) + for description in BINARY_SENSORS + ) class SyncThruBinarySensor(CoordinatorEntity[SyncthruCoordinator], BinarySensorEntity): """Implementation of an abstract Samsung Printer binary sensor platform.""" - def __init__(self, coordinator: SyncthruCoordinator, name: str) -> None: + entity_description: SyncThruBinarySensorDescription + + def __init__( + self, + coordinator: SyncthruCoordinator, + name: str, + entity_description: SyncThruBinarySensorDescription, + ) -> None: """Initialize the sensor.""" super().__init__(coordinator) - self.syncthru = coordinator.data + self.entity_description = entity_description + serial_number = coordinator.data.serial_number() + assert serial_number is not None + self._attr_unique_id = f"{serial_number}_{entity_description.key}" self._attr_name = name - self._id_suffix = "" - - @property - def unique_id(self): - """Return unique ID for the sensor.""" - serial = self.syncthru.serial_number() - return f"{serial}{self._id_suffix}" if serial else None @property def device_info(self) -> DeviceInfo | None: """Return device information.""" - if (identifiers := device_identifiers(self.syncthru)) is None: + if (identifiers := device_identifiers(self.coordinator.data)) is None: return None return DeviceInfo( identifiers=identifiers, ) - -class SyncThruOnlineSensor(SyncThruBinarySensor): - """Implementation of a sensor that checks whether is turned on/online.""" - - _attr_device_class = BinarySensorDeviceClass.CONNECTIVITY - - def __init__(self, coordinator: SyncthruCoordinator, name: str) -> None: - """Initialize the sensor.""" - super().__init__(coordinator, name) - self._id_suffix = "_online" - @property - def is_on(self): - """Set the state to whether the printer is online.""" - return self.syncthru.is_online() - - -class SyncThruProblemSensor(SyncThruBinarySensor): - """Implementation of a sensor that checks whether the printer works correctly.""" - - _attr_device_class = BinarySensorDeviceClass.PROBLEM - - def __init__(self, coordinator: SyncthruCoordinator, name: str) -> None: - """Initialize the sensor.""" - super().__init__(coordinator, name) - self._id_suffix = "_problem" - - @property - def is_on(self): - """Set the state to whether there is a problem with the printer.""" - return SYNCTHRU_STATE_PROBLEM[self.syncthru.device_status()] + def is_on(self) -> bool | None: + """Return true if the binary sensor is on.""" + return self.entity_description.value_fn(self.coordinator.data) From 0fcac987dffcf92ac65a95123c79ebdbc332c3cf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ab=C3=ADlio=20Costa?= Date: Fri, 11 Apr 2025 19:21:53 +0100 Subject: [PATCH 475/505] Update strings for Whirlpool config flows (#142758) --- homeassistant/components/whirlpool/strings.json | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/whirlpool/strings.json b/homeassistant/components/whirlpool/strings.json index 1cb5344b238..8f38330980e 100644 --- a/homeassistant/components/whirlpool/strings.json +++ b/homeassistant/components/whirlpool/strings.json @@ -13,19 +13,23 @@ "brand": "Brand" }, "data_description": { - "brand": "Please choose the brand of the mobile app you use, or the brand of the appliances in your account" + "username": "The username or email address you use to log in to the Whirlpool/Maytag app", + "password": "The password you use to log in to the Whirlpool/Maytag app", + "region": "The region where your appliances where purchased", + "brand": "The brand of the mobile app you use, or the brand of the appliances in your account" } }, "reauth_confirm": { "title": "[%key:common::config_flow::title::reauth%]", - "description": "For 'brand', please choose the brand of the mobile app you use, or the brand of the appliances in your account", "data": { "password": "[%key:common::config_flow::data::password%]", - "region": "Region", - "brand": "Brand" + "region": "[%key:component::whirlpool::config::step::user::data::region%]", + "brand": "[%key:component::whirlpool::config::step::user::data::brand%]" }, "data_description": { - "brand": "Please choose the brand of the mobile app you use, or the brand of the appliances in your account" + "password": "[%key:component::whirlpool::config::step::user::data_description::password%]", + "brand": "[%key:component::whirlpool::config::step::user::data_description::brand%]", + "region": "[%key:component::whirlpool::config::step::user::data_description::region%]" } } }, From 9d10d8f55e1b93a85812249ec9a8c082a8c67382 Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Fri, 11 Apr 2025 20:27:45 +0200 Subject: [PATCH 476/505] Fix slack DeprecationWarnings (#142754) --- homeassistant/components/slack/__init__.py | 2 +- homeassistant/components/slack/config_flow.py | 2 +- homeassistant/components/slack/notify.py | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/slack/__init__.py b/homeassistant/components/slack/__init__.py index aa67739016d..899b46ee7e8 100644 --- a/homeassistant/components/slack/__init__.py +++ b/homeassistant/components/slack/__init__.py @@ -5,7 +5,7 @@ from __future__ import annotations import logging from aiohttp.client_exceptions import ClientError -from slack.errors import SlackApiError +from slack_sdk.errors import SlackApiError from slack_sdk.web.async_client import AsyncWebClient from homeassistant.config_entries import ConfigEntry diff --git a/homeassistant/components/slack/config_flow.py b/homeassistant/components/slack/config_flow.py index fcdc2e8b362..551e9832b2b 100644 --- a/homeassistant/components/slack/config_flow.py +++ b/homeassistant/components/slack/config_flow.py @@ -4,7 +4,7 @@ from __future__ import annotations import logging -from slack.errors import SlackApiError +from slack_sdk.errors import SlackApiError from slack_sdk.web.async_client import AsyncSlackResponse, AsyncWebClient import voluptuous as vol diff --git a/homeassistant/components/slack/notify.py b/homeassistant/components/slack/notify.py index 16dd212301a..4c7f52e581f 100644 --- a/homeassistant/components/slack/notify.py +++ b/homeassistant/components/slack/notify.py @@ -10,7 +10,7 @@ from urllib.parse import urlparse from aiohttp import BasicAuth from aiohttp.client_exceptions import ClientError -from slack.errors import SlackApiError +from slack_sdk.errors import SlackApiError from slack_sdk.web.async_client import AsyncWebClient import voluptuous as vol From 2c316c582033e5e805a91e2a0b4bf8e3da1b6327 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 11 Apr 2025 09:39:30 -1000 Subject: [PATCH 477/505] Ensure person loads after recorder (#142585) Co-authored-by: Erik Montnemery --- homeassistant/bootstrap.py | 1 + homeassistant/components/onboarding/manifest.json | 2 +- tests/test_bootstrap.py | 11 ++++++++--- 3 files changed, 10 insertions(+), 4 deletions(-) diff --git a/homeassistant/bootstrap.py b/homeassistant/bootstrap.py index 962c7871028..f88912478a7 100644 --- a/homeassistant/bootstrap.py +++ b/homeassistant/bootstrap.py @@ -53,6 +53,7 @@ from .components import ( logbook as logbook_pre_import, # noqa: F401 lovelace as lovelace_pre_import, # noqa: F401 onboarding as onboarding_pre_import, # noqa: F401 + person as person_pre_import, # noqa: F401 recorder as recorder_import, # noqa: F401 - not named pre_import since it has requirements repairs as repairs_pre_import, # noqa: F401 search as search_pre_import, # noqa: F401 diff --git a/homeassistant/components/onboarding/manifest.json b/homeassistant/components/onboarding/manifest.json index a4cf814eb2a..e57857896e0 100644 --- a/homeassistant/components/onboarding/manifest.json +++ b/homeassistant/components/onboarding/manifest.json @@ -2,7 +2,7 @@ "domain": "onboarding", "name": "Home Assistant Onboarding", "codeowners": ["@home-assistant/core"], - "dependencies": ["auth", "http", "person"], + "dependencies": ["auth", "http"], "documentation": "https://www.home-assistant.io/integrations/onboarding", "integration_type": "system", "quality_scale": "internal" diff --git a/tests/test_bootstrap.py b/tests/test_bootstrap.py index 7a4f9fda257..ebfc6b81e00 100644 --- a/tests/test_bootstrap.py +++ b/tests/test_bootstrap.py @@ -924,7 +924,7 @@ async def test_setup_hass_invalid_core_config( "external_url": "https://abcdef.ui.nabu.casa", }, "map": {}, - "person": {"invalid": True}, + "frontend": {"invalid": True}, } ], ) @@ -1560,6 +1560,11 @@ async def test_no_base_platforms_loaded_before_recorder(hass: HomeAssistant) -> # we remove the platform YAML schema support for sensors "websocket_api": {"sensor.py"}, } + # person is a special case because it is a base platform + # in the sense that it creates entities in its namespace + # but its not used by other integrations to create entities + # so we want to make sure it is not loaded before the recorder + base_platforms = BASE_PLATFORMS | {"person"} integrations_before_recorder: set[str] = set() for _, integrations, _ in bootstrap.STAGE_0_INTEGRATIONS: @@ -1592,7 +1597,7 @@ async def test_no_base_platforms_loaded_before_recorder(hass: HomeAssistant) -> problems: dict[str, set[str]] = {} for domain in integrations: domain_with_base_platforms_deps = ( - integrations_all_dependencies[domain] & BASE_PLATFORMS + integrations_all_dependencies[domain] & base_platforms ) if domain_with_base_platforms_deps: problems[domain] = domain_with_base_platforms_deps @@ -1600,7 +1605,7 @@ async def test_no_base_platforms_loaded_before_recorder(hass: HomeAssistant) -> f"Integrations that are setup before recorder have base platforms in their dependencies: {problems}" ) - base_platform_py_files = {f"{base_platform}.py" for base_platform in BASE_PLATFORMS} + base_platform_py_files = {f"{base_platform}.py" for base_platform in base_platforms} for domain, integration in all_integrations.items(): integration_base_platforms_files = ( From 4f0ece1bb43f7f51e90f18cd0afc92d48c5ed36f Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Fri, 11 Apr 2025 22:37:15 +0200 Subject: [PATCH 478/505] Update uiprotect to 7.5.3 (#142766) --- homeassistant/components/unifiprotect/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/unifiprotect/manifest.json b/homeassistant/components/unifiprotect/manifest.json index a4bb6d20841..7cbb6128eef 100644 --- a/homeassistant/components/unifiprotect/manifest.json +++ b/homeassistant/components/unifiprotect/manifest.json @@ -40,7 +40,7 @@ "integration_type": "hub", "iot_class": "local_push", "loggers": ["uiprotect", "unifi_discovery"], - "requirements": ["uiprotect==7.5.1", "unifi-discovery==1.2.0"], + "requirements": ["uiprotect==7.5.3", "unifi-discovery==1.2.0"], "ssdp": [ { "manufacturer": "Ubiquiti Networks", diff --git a/requirements_all.txt b/requirements_all.txt index fb8acbf1527..187b8908cc6 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2968,7 +2968,7 @@ typedmonarchmoney==0.4.4 uasiren==0.0.1 # homeassistant.components.unifiprotect -uiprotect==7.5.1 +uiprotect==7.5.3 # homeassistant.components.landisgyr_heat_meter ultraheat-api==0.5.7 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index cc6b5bc0e8e..e5c3cdd9c39 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2394,7 +2394,7 @@ typedmonarchmoney==0.4.4 uasiren==0.0.1 # homeassistant.components.unifiprotect -uiprotect==7.5.1 +uiprotect==7.5.3 # homeassistant.components.landisgyr_heat_meter ultraheat-api==0.5.7 From b20f46e8b901cead43f34bacc216d735b4ad160e Mon Sep 17 00:00:00 2001 From: Mathijs van de Nes Date: Fri, 11 Apr 2025 22:55:05 +0200 Subject: [PATCH 479/505] Add non-shared ssl client_context (#142653) --- homeassistant/util/ssl.py | 20 ++++++++++++++++++-- tests/util/test_ssl.py | 26 ++++++++++++++++++++++++++ 2 files changed, 44 insertions(+), 2 deletions(-) diff --git a/homeassistant/util/ssl.py b/homeassistant/util/ssl.py index a22fd0c8fb4..4e26a126f39 100644 --- a/homeassistant/util/ssl.py +++ b/homeassistant/util/ssl.py @@ -82,10 +82,10 @@ def _client_context_no_verify(ssl_cipher_list: SSLCipherList) -> ssl.SSLContext: return sslcontext -@cache -def _client_context( +def _create_client_context( ssl_cipher_list: SSLCipherList = SSLCipherList.PYTHON_DEFAULT, ) -> ssl.SSLContext: + """Return an independent SSL context for making requests.""" # Reuse environment variable definition from requests, since it's already a # requirement. If the environment variable has no value, fall back to using # certs from certifi package. @@ -100,6 +100,14 @@ def _client_context( return sslcontext +@cache +def _client_context( + ssl_cipher_list: SSLCipherList = SSLCipherList.PYTHON_DEFAULT, +) -> ssl.SSLContext: + # Cached version of _create_client_context + return _create_client_context(ssl_cipher_list) + + # Create this only once and reuse it _DEFAULT_SSL_CONTEXT = _client_context(SSLCipherList.PYTHON_DEFAULT) _DEFAULT_NO_VERIFY_SSL_CONTEXT = _client_context_no_verify(SSLCipherList.PYTHON_DEFAULT) @@ -139,6 +147,14 @@ def client_context( return _SSL_CONTEXTS.get(ssl_cipher_list, _DEFAULT_SSL_CONTEXT) +def create_client_context( + ssl_cipher_list: SSLCipherList = SSLCipherList.PYTHON_DEFAULT, +) -> ssl.SSLContext: + """Return an independent SSL context for making requests.""" + # This explicitly uses the non-cached version to create a client context + return _create_client_context(ssl_cipher_list) + + def create_no_verify_ssl_context( ssl_cipher_list: SSLCipherList = SSLCipherList.PYTHON_DEFAULT, ) -> ssl.SSLContext: diff --git a/tests/util/test_ssl.py b/tests/util/test_ssl.py index c0cd2fdba10..0c30ad9b9b3 100644 --- a/tests/util/test_ssl.py +++ b/tests/util/test_ssl.py @@ -7,6 +7,7 @@ import pytest from homeassistant.util.ssl import ( SSLCipherList, client_context, + create_client_context, create_no_verify_ssl_context, ) @@ -56,3 +57,28 @@ def test_ssl_context_caching() -> None: assert create_no_verify_ssl_context() is create_no_verify_ssl_context( SSLCipherList.PYTHON_DEFAULT ) + + +def test_cteate_client_context(mock_sslcontext) -> None: + """Test create client context.""" + with patch("homeassistant.util.ssl.ssl.SSLContext", return_value=mock_sslcontext): + client_context() + mock_sslcontext.set_ciphers.assert_not_called() + + client_context(SSLCipherList.MODERN) + mock_sslcontext.set_ciphers.assert_not_called() + + client_context(SSLCipherList.INTERMEDIATE) + mock_sslcontext.set_ciphers.assert_not_called() + + client_context(SSLCipherList.INSECURE) + mock_sslcontext.set_ciphers.assert_not_called() + + +def test_create_client_context_independent() -> None: + """Test create_client_context independence.""" + shared_context = client_context() + independent_context_1 = create_client_context() + independent_context_2 = create_client_context() + assert shared_context is not independent_context_1 + assert independent_context_1 is not independent_context_2 From 3efb009e82aa8be15ab8669f7be400ff12917c18 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Sat, 12 Apr 2025 00:12:23 +0200 Subject: [PATCH 480/505] Introduce base entity in Syncthru (#142694) --- homeassistant/components/syncthru/__init__.py | 29 +------------------ .../components/syncthru/binary_sensor.py | 16 ++-------- homeassistant/components/syncthru/entity.py | 28 ++++++++++++++++++ homeassistant/components/syncthru/sensor.py | 16 ++-------- 4 files changed, 35 insertions(+), 54 deletions(-) create mode 100644 homeassistant/components/syncthru/entity.py diff --git a/homeassistant/components/syncthru/__init__.py b/homeassistant/components/syncthru/__init__.py index b6e7c8a70c9..016d0de7257 100644 --- a/homeassistant/components/syncthru/__init__.py +++ b/homeassistant/components/syncthru/__init__.py @@ -2,12 +2,11 @@ from __future__ import annotations -from pysyncthru import SyncThru, SyncThruAPINotSupported +from pysyncthru import SyncThruAPINotSupported from homeassistant.config_entries import ConfigEntry from homeassistant.const import Platform from homeassistant.core import HomeAssistant -from homeassistant.helpers import device_registry as dr from .const import DOMAIN from .coordinator import SyncthruCoordinator @@ -26,17 +25,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: # and the config should simply be discarded return False - device_registry = dr.async_get(hass) - device_registry.async_get_or_create( - config_entry_id=entry.entry_id, - configuration_url=coordinator.syncthru.url, - connections=device_connections(coordinator.syncthru), - manufacturer="Samsung", - identifiers=device_identifiers(coordinator.syncthru), - model=coordinator.syncthru.model(), - name=coordinator.syncthru.hostname(), - ) - await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) return True @@ -46,18 +34,3 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) hass.data[DOMAIN].pop(entry.entry_id, None) return unload_ok - - -def device_identifiers(printer: SyncThru) -> set[tuple[str, str]] | None: - """Get device identifiers for device registry.""" - serial = printer.serial_number() - if serial is None: - return None - return {(DOMAIN, serial)} - - -def device_connections(printer: SyncThru) -> set[tuple[str, str]]: - """Get device connections for device registry.""" - if mac := printer.raw().get("identity", {}).get("mac_addr"): - return {(dr.CONNECTION_NETWORK_MAC, mac)} - return set() diff --git a/homeassistant/components/syncthru/binary_sensor.py b/homeassistant/components/syncthru/binary_sensor.py index d863c5546d8..72157b9a22d 100644 --- a/homeassistant/components/syncthru/binary_sensor.py +++ b/homeassistant/components/syncthru/binary_sensor.py @@ -15,12 +15,11 @@ from homeassistant.components.binary_sensor import ( from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_NAME from homeassistant.core import HomeAssistant -from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from homeassistant.helpers.update_coordinator import CoordinatorEntity -from . import SyncthruCoordinator, device_identifiers +from . import SyncthruCoordinator from .const import DOMAIN +from .entity import SyncthruEntity SYNCTHRU_STATE_PROBLEM = { SyncthruState.INVALID: True, @@ -71,7 +70,7 @@ async def async_setup_entry( ) -class SyncThruBinarySensor(CoordinatorEntity[SyncthruCoordinator], BinarySensorEntity): +class SyncThruBinarySensor(SyncthruEntity, BinarySensorEntity): """Implementation of an abstract Samsung Printer binary sensor platform.""" entity_description: SyncThruBinarySensorDescription @@ -90,15 +89,6 @@ class SyncThruBinarySensor(CoordinatorEntity[SyncthruCoordinator], BinarySensorE self._attr_unique_id = f"{serial_number}_{entity_description.key}" self._attr_name = name - @property - def device_info(self) -> DeviceInfo | None: - """Return device information.""" - if (identifiers := device_identifiers(self.coordinator.data)) is None: - return None - return DeviceInfo( - identifiers=identifiers, - ) - @property def is_on(self) -> bool | None: """Return true if the binary sensor is on.""" diff --git a/homeassistant/components/syncthru/entity.py b/homeassistant/components/syncthru/entity.py new file mode 100644 index 00000000000..fa3fbb0f2f4 --- /dev/null +++ b/homeassistant/components/syncthru/entity.py @@ -0,0 +1,28 @@ +"""Base class for Syncthru entities.""" + +from homeassistant.helpers import device_registry as dr +from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from . import DOMAIN, SyncthruCoordinator + + +class SyncthruEntity(CoordinatorEntity[SyncthruCoordinator]): + """Base class for Syncthru entities.""" + + def __init__(self, coordinator: SyncthruCoordinator) -> None: + """Initialize the Syncthru entity.""" + super().__init__(coordinator) + serial_number = coordinator.syncthru.serial_number() + assert serial_number is not None + connections = set() + if mac := coordinator.syncthru.raw().get("identity", {}).get("mac_addr"): + connections.add((dr.CONNECTION_NETWORK_MAC, mac)) + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, serial_number)}, + connections=connections, + configuration_url=coordinator.syncthru.url, + manufacturer="Samsung", + model=coordinator.syncthru.model(), + name=coordinator.syncthru.hostname(), + ) diff --git a/homeassistant/components/syncthru/sensor.py b/homeassistant/components/syncthru/sensor.py index 3f4c802e62d..022d48b463f 100644 --- a/homeassistant/components/syncthru/sensor.py +++ b/homeassistant/components/syncthru/sensor.py @@ -12,12 +12,11 @@ from homeassistant.components.sensor import SensorEntity, SensorEntityDescriptio from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_NAME, PERCENTAGE from homeassistant.core import HomeAssistant -from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from homeassistant.helpers.update_coordinator import CoordinatorEntity -from . import SyncthruCoordinator, device_identifiers +from . import SyncthruCoordinator from .const import DOMAIN +from .entity import SyncthruEntity SYNCTHRU_STATE_HUMAN = { SyncthruState.INVALID: "invalid", @@ -138,7 +137,7 @@ async def async_setup_entry( ) -class SyncThruSensor(CoordinatorEntity[SyncthruCoordinator], SensorEntity): +class SyncThruSensor(SyncthruEntity, SensorEntity): """Implementation of an abstract Samsung Printer sensor platform.""" _attr_icon = "mdi:printer" @@ -159,15 +158,6 @@ class SyncThruSensor(CoordinatorEntity[SyncthruCoordinator], SensorEntity): assert serial_number is not None self._attr_unique_id = f"{serial_number}_{entity_description.key}" - @property - def device_info(self) -> DeviceInfo | None: - """Return device information.""" - if (identifiers := device_identifiers(self.syncthru)) is None: - return None - return DeviceInfo( - identifiers=identifiers, - ) - @property def native_value(self) -> str | int | None: """Return the state of the sensor.""" From c18d96e2f5bbb4c0177d87319eae78bb5a251e3f Mon Sep 17 00:00:00 2001 From: Robert Svensson Date: Sat, 12 Apr 2025 00:15:15 +0200 Subject: [PATCH 481/505] UniFi redact WLAN password (#142767) * Recact password key word in WLAN diagnostic data * Fix testdata --- homeassistant/components/unifi/diagnostics.py | 2 +- .../unifi/snapshots/test_diagnostics.ambr | 78 ++++++++++++++++++- tests/components/unifi/test_diagnostics.py | 70 +++++++++++++++++ 3 files changed, 148 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/unifi/diagnostics.py b/homeassistant/components/unifi/diagnostics.py index 21174342594..49a9b678b0f 100644 --- a/homeassistant/components/unifi/diagnostics.py +++ b/homeassistant/components/unifi/diagnostics.py @@ -27,7 +27,7 @@ REDACT_DEVICES = { "x_ssh_hostkey_fingerprint", "x_vwirekey", } -REDACT_WLANS = {"bc_filter_list", "x_passphrase"} +REDACT_WLANS = {"bc_filter_list", "password", "x_passphrase"} @callback diff --git a/tests/components/unifi/snapshots/test_diagnostics.ambr b/tests/components/unifi/snapshots/test_diagnostics.ambr index aa7337be0ba..04aec0541b9 100644 --- a/tests/components/unifi/snapshots/test_diagnostics.ambr +++ b/tests/components/unifi/snapshots/test_diagnostics.ambr @@ -1,5 +1,5 @@ # serializer version: 1 -# name: test_entry_diagnostics[dpi_group_payload0-dpi_app_payload0-device_payload0-client_payload0-config_entry_options0] +# name: test_entry_diagnostics[wlan_payload0-dpi_group_payload0-dpi_app_payload0-device_payload0-client_payload0-config_entry_options0] dict({ 'clients': dict({ '00:00:00:00:00:00': dict({ @@ -128,6 +128,82 @@ }), 'role_is_admin': True, 'wlans': dict({ + '67f2eaec026b2c2893c41b2a': dict({ + '_id': '67f2eaec026b2c2893c41b2a', + 'ap_group_ids': list([ + '67f2e03f7c572754fa1a249e', + ]), + 'ap_group_mode': 'all', + 'bc_filter_list': '**REDACTED**', + 'bss_transition': True, + 'dtim_6e': 3, + 'dtim_mode': 'default', + 'dtim_na': 3, + 'dtim_ng': 1, + 'enabled': True, + 'enhanced_iot': False, + 'fast_roaming_enabled': False, + 'group_rekey': 3600, + 'hide_ssid': False, + 'hotspot2conf_enabled': False, + 'iapp_enabled': True, + 'is_guest': False, + 'l2_isolation': False, + 'mac_filter_enabled': False, + 'mac_filter_list': list([ + ]), + 'mac_filter_policy': 'allow', + 'mcastenhance_enabled': False, + 'minrate_na_advertising_rates': False, + 'minrate_na_data_rate_kbps': 6000, + 'minrate_na_enabled': False, + 'minrate_ng_advertising_rates': False, + 'minrate_ng_data_rate_kbps': 1000, + 'minrate_ng_enabled': True, + 'minrate_setting_preference': 'auto', + 'mlo_enabled': False, + 'name': 'devices', + 'networkconf_id': '67f2e03f7c572754fa1a2498', + 'no2ghz_oui': True, + 'passphrase_autogenerated': True, + 'pmf_mode': 'disabled', + 'private_preshared_keys': list([ + dict({ + 'networkconf_id': '67f2e03f7c572754fa1a2498', + 'password': '**REDACTED**', + }), + ]), + 'private_preshared_keys_enabled': True, + 'proxy_arp': False, + 'radius_das_enabled': False, + 'radius_mac_auth_enabled': False, + 'radius_macacl_format': 'none_lower', + 'sae_anti_clogging': 5, + 'sae_groups': list([ + ]), + 'sae_psk': list([ + ]), + 'sae_sync': 5, + 'schedule': list([ + ]), + 'schedule_with_duration': list([ + ]), + 'security': 'wpapsk', + 'setting_preference': 'manual', + 'site_id': '67f2e00e7c572754fa1a247e', + 'uapsd_enabled': False, + 'usergroup_id': '67f2e03f7c572754fa1a2499', + 'wlan_band': '2g', + 'wlan_bands': list([ + '2g', + ]), + 'wpa3_fast_roaming': False, + 'wpa3_support': False, + 'wpa3_transition': False, + 'wpa_enc': 'ccmp', + 'wpa_mode': 'wpa2', + 'x_passphrase': '**REDACTED**', + }), }), }) # --- diff --git a/tests/components/unifi/test_diagnostics.py b/tests/components/unifi/test_diagnostics.py index 80359a9c75c..e9fd86f0f8b 100644 --- a/tests/components/unifi/test_diagnostics.py +++ b/tests/components/unifi/test_diagnostics.py @@ -103,6 +103,75 @@ DPI_GROUP_DATA = [ "dpiapp_ids": ["5f976f62e3c58f018ec7e17d"], } ] +WLAN_DATA = [ + { + "setting_preference": "manual", + "wpa3_support": False, + "dtim_6e": 3, + "minrate_na_advertising_rates": False, + "wpa_mode": "wpa2", + "minrate_setting_preference": "auto", + "minrate_ng_advertising_rates": False, + "hotspot2conf_enabled": False, + "radius_das_enabled": False, + "mlo_enabled": False, + "group_rekey": 3600, + "radius_macacl_format": "none_lower", + "pmf_mode": "disabled", + "wpa3_transition": False, + "passphrase_autogenerated": True, + "private_preshared_keys": [ + { + "password": "should be redacted", + "networkconf_id": "67f2e03f7c572754fa1a2498", + } + ], + "mcastenhance_enabled": False, + "usergroup_id": "67f2e03f7c572754fa1a2499", + "proxy_arp": False, + "sae_sync": 5, + "iapp_enabled": True, + "uapsd_enabled": False, + "enhanced_iot": False, + "name": "devices", + "site_id": "67f2e00e7c572754fa1a247e", + "hide_ssid": False, + "wlan_band": "2g", + "_id": "67f2eaec026b2c2893c41b2a", + "private_preshared_keys_enabled": True, + "no2ghz_oui": True, + "networkconf_id": "67f2e03f7c572754fa1a2498", + "is_guest": False, + "dtim_na": 3, + "minrate_na_enabled": False, + "sae_groups": [], + "enabled": True, + "sae_psk": [], + "wlan_bands": ["2g"], + "mac_filter_policy": "allow", + "security": "wpapsk", + "ap_group_ids": ["67f2e03f7c572754fa1a249e"], + "l2_isolation": False, + "minrate_ng_enabled": True, + "bss_transition": True, + "minrate_ng_data_rate_kbps": 1000, + "radius_mac_auth_enabled": False, + "schedule_with_duration": [], + "wpa3_fast_roaming": False, + "ap_group_mode": "all", + "fast_roaming_enabled": False, + "wpa_enc": "ccmp", + "mac_filter_list": [], + "dtim_mode": "default", + "schedule": [], + "bc_filter_list": "should be redacted", + "minrate_na_data_rate_kbps": 6000, + "mac_filter_enabled": False, + "sae_anti_clogging": 5, + "dtim_ng": 1, + "x_passphrase": "should be redacted", + } +] @pytest.mark.parametrize( @@ -119,6 +188,7 @@ DPI_GROUP_DATA = [ @pytest.mark.parametrize("device_payload", [DEVICE_DATA]) @pytest.mark.parametrize("dpi_app_payload", [DPI_APP_DATA]) @pytest.mark.parametrize("dpi_group_payload", [DPI_GROUP_DATA]) +@pytest.mark.parametrize("wlan_payload", [WLAN_DATA]) async def test_entry_diagnostics( hass: HomeAssistant, hass_client: ClientSessionGenerator, From 49721a541a0261d08e9085948688c6a7f97b33f2 Mon Sep 17 00:00:00 2001 From: Ernst Klamer Date: Sat, 12 Apr 2025 00:16:14 +0200 Subject: [PATCH 482/505] bump xiaomi-ble to 0.36.0 (#142761) --- homeassistant/components/xiaomi_ble/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/xiaomi_ble/manifest.json b/homeassistant/components/xiaomi_ble/manifest.json index d7156246d38..ed534387114 100644 --- a/homeassistant/components/xiaomi_ble/manifest.json +++ b/homeassistant/components/xiaomi_ble/manifest.json @@ -24,5 +24,5 @@ "dependencies": ["bluetooth_adapters"], "documentation": "https://www.home-assistant.io/integrations/xiaomi_ble", "iot_class": "local_push", - "requirements": ["xiaomi-ble==0.35.0"] + "requirements": ["xiaomi-ble==0.36.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index 187b8908cc6..c3b9d83d625 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -3094,7 +3094,7 @@ wyoming==1.5.4 xbox-webapi==2.1.0 # homeassistant.components.xiaomi_ble -xiaomi-ble==0.35.0 +xiaomi-ble==0.36.0 # homeassistant.components.knx xknx==3.6.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index e5c3cdd9c39..4bf005bb439 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2499,7 +2499,7 @@ wyoming==1.5.4 xbox-webapi==2.1.0 # homeassistant.components.xiaomi_ble -xiaomi-ble==0.35.0 +xiaomi-ble==0.36.0 # homeassistant.components.knx xknx==3.6.0 From ee37b32ca15d08651fff1669b68f22532a166b99 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 11 Apr 2025 13:57:47 -1000 Subject: [PATCH 483/505] Log lutron_caseta exception on pairing failure (#140776) --- homeassistant/components/lutron_caseta/config_flow.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/lutron_caseta/config_flow.py b/homeassistant/components/lutron_caseta/config_flow.py index 45e7a04bdc9..115da5cb101 100644 --- a/homeassistant/components/lutron_caseta/config_flow.py +++ b/homeassistant/components/lutron_caseta/config_flow.py @@ -123,7 +123,8 @@ class LutronCasetaFlowHandler(ConfigFlow, domain=DOMAIN): assets = None try: assets = await async_pair(self.data[CONF_HOST]) - except (TimeoutError, OSError): + except (TimeoutError, OSError) as exc: + _LOGGER.debug("Pairing failed", exc_info=exc) errors["base"] = "cannot_connect" if not errors: From ad3c4d24b8cab7b764bd28b3f38864916acbd10d Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Sat, 12 Apr 2025 02:08:09 +0200 Subject: [PATCH 484/505] Update h2 to 4.2.0 (#142777) --- homeassistant/components/iaqualink/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/iaqualink/manifest.json b/homeassistant/components/iaqualink/manifest.json index 7e05bd72f0b..a0742865438 100644 --- a/homeassistant/components/iaqualink/manifest.json +++ b/homeassistant/components/iaqualink/manifest.json @@ -6,6 +6,6 @@ "documentation": "https://www.home-assistant.io/integrations/iaqualink", "iot_class": "cloud_polling", "loggers": ["iaqualink"], - "requirements": ["iaqualink==0.5.3", "h2==4.1.0"], + "requirements": ["iaqualink==0.5.3", "h2==4.2.0"], "single_config_entry": true } diff --git a/requirements_all.txt b/requirements_all.txt index c3b9d83d625..d6972d363fd 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1096,7 +1096,7 @@ gstreamer-player==1.1.2 guppy3==3.1.5 # homeassistant.components.iaqualink -h2==4.1.0 +h2==4.2.0 # homeassistant.components.ffmpeg ha-ffmpeg==3.2.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 4bf005bb439..9013ba24c57 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -938,7 +938,7 @@ gspread==5.5.0 guppy3==3.1.5 # homeassistant.components.iaqualink -h2==4.1.0 +h2==4.2.0 # homeassistant.components.ffmpeg ha-ffmpeg==3.2.2 From 234c4c1958e78929ca760087f27618e96833ef87 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Sat, 12 Apr 2025 09:41:54 +0200 Subject: [PATCH 485/505] Move backup backup onboarding API to an onboarding platform (#142713) * Move backup backup onboarding API to an onboarding platform * Move additional test from onboarding to backup * Remove backup tests from onboarding --- homeassistant/components/backup/onboarding.py | 143 ++++++ homeassistant/components/onboarding/views.py | 123 +----- script/hassfest/dependencies.py | 4 - .../snapshots/test_onboarding.ambr} | 0 tests/components/backup/test_onboarding.py | 414 ++++++++++++++++++ tests/components/onboarding/test_views.py | 362 +-------------- 6 files changed, 560 insertions(+), 486 deletions(-) create mode 100644 homeassistant/components/backup/onboarding.py rename tests/components/{onboarding/snapshots/test_views.ambr => backup/snapshots/test_onboarding.ambr} (100%) create mode 100644 tests/components/backup/test_onboarding.py diff --git a/homeassistant/components/backup/onboarding.py b/homeassistant/components/backup/onboarding.py new file mode 100644 index 00000000000..1bbd3937567 --- /dev/null +++ b/homeassistant/components/backup/onboarding.py @@ -0,0 +1,143 @@ +"""Backup onboarding views.""" + +from __future__ import annotations + +from collections.abc import Callable, Coroutine +from functools import wraps +from http import HTTPStatus +from typing import TYPE_CHECKING, Any, Concatenate + +from aiohttp import web +from aiohttp.web_exceptions import HTTPUnauthorized +import voluptuous as vol + +from homeassistant.components.http import KEY_HASS +from homeassistant.components.http.data_validator import RequestDataValidator +from homeassistant.components.onboarding import ( + BaseOnboardingView, + NoAuthBaseOnboardingView, +) +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers.backup import async_get_manager as async_get_backup_manager + +from . import BackupManager, Folder, IncorrectPasswordError, http as backup_http + +if TYPE_CHECKING: + from homeassistant.components.onboarding import OnboardingStoreData + + +async def async_setup_views(hass: HomeAssistant, data: OnboardingStoreData) -> None: + """Set up the backup views.""" + + hass.http.register_view(BackupInfoView(data)) + hass.http.register_view(RestoreBackupView(data)) + hass.http.register_view(UploadBackupView(data)) + + +def with_backup_manager[_ViewT: BaseOnboardingView, **_P]( + func: Callable[ + Concatenate[_ViewT, BackupManager, web.Request, _P], + Coroutine[Any, Any, web.Response], + ], +) -> Callable[Concatenate[_ViewT, web.Request, _P], Coroutine[Any, Any, web.Response]]: + """Home Assistant API decorator to check onboarding and inject manager.""" + + @wraps(func) + async def with_backup( + self: _ViewT, + request: web.Request, + *args: _P.args, + **kwargs: _P.kwargs, + ) -> web.Response: + """Check admin and call function.""" + if self._data["done"]: + raise HTTPUnauthorized + + try: + manager = await async_get_backup_manager(request.app[KEY_HASS]) + except HomeAssistantError: + return self.json( + {"code": "backup_disabled"}, + status_code=HTTPStatus.INTERNAL_SERVER_ERROR, + ) + + return await func(self, manager, request, *args, **kwargs) + + return with_backup + + +class BackupInfoView(NoAuthBaseOnboardingView): + """Get backup info view.""" + + url = "/api/onboarding/backup/info" + name = "api:onboarding:backup:info" + + @with_backup_manager + async def get(self, manager: BackupManager, request: web.Request) -> web.Response: + """Return backup info.""" + backups, _ = await manager.async_get_backups() + return self.json( + { + "backups": list(backups.values()), + "state": manager.state, + "last_action_event": manager.last_action_event, + } + ) + + +class RestoreBackupView(NoAuthBaseOnboardingView): + """Restore backup view.""" + + url = "/api/onboarding/backup/restore" + name = "api:onboarding:backup:restore" + + @RequestDataValidator( + vol.Schema( + { + vol.Required("backup_id"): str, + vol.Required("agent_id"): str, + vol.Optional("password"): str, + vol.Optional("restore_addons"): [str], + vol.Optional("restore_database", default=True): bool, + vol.Optional("restore_folders"): [vol.Coerce(Folder)], + } + ) + ) + @with_backup_manager + async def post( + self, manager: BackupManager, request: web.Request, data: dict[str, Any] + ) -> web.Response: + """Restore a backup.""" + try: + await manager.async_restore_backup( + data["backup_id"], + agent_id=data["agent_id"], + password=data.get("password"), + restore_addons=data.get("restore_addons"), + restore_database=data["restore_database"], + restore_folders=data.get("restore_folders"), + restore_homeassistant=True, + ) + except IncorrectPasswordError: + return self.json( + {"code": "incorrect_password"}, status_code=HTTPStatus.BAD_REQUEST + ) + except HomeAssistantError as err: + return self.json( + {"code": "restore_failed", "message": str(err)}, + status_code=HTTPStatus.BAD_REQUEST, + ) + return web.Response(status=HTTPStatus.OK) + + +class UploadBackupView(NoAuthBaseOnboardingView, backup_http.UploadBackupView): + """Upload backup view.""" + + url = "/api/onboarding/backup/upload" + name = "api:onboarding:backup:upload" + + @with_backup_manager + async def post(self, manager: BackupManager, request: web.Request) -> web.Response: + """Upload a backup file.""" + return await self._post(request) diff --git a/homeassistant/components/onboarding/views.py b/homeassistant/components/onboarding/views.py index e9d163a1bbb..bbe198f0d2f 100644 --- a/homeassistant/components/onboarding/views.py +++ b/homeassistant/components/onboarding/views.py @@ -3,11 +3,9 @@ from __future__ import annotations import asyncio -from collections.abc import Callable, Coroutine -from functools import wraps from http import HTTPStatus import logging -from typing import TYPE_CHECKING, Any, Concatenate, Protocol, cast +from typing import TYPE_CHECKING, Any, Protocol, cast from aiohttp import web from aiohttp.web_exceptions import HTTPUnauthorized @@ -17,19 +15,11 @@ from homeassistant.auth.const import GROUP_ID_ADMIN from homeassistant.auth.providers.homeassistant import HassAuthProvider from homeassistant.components import person from homeassistant.components.auth import indieauth -from homeassistant.components.backup import ( - BackupManager, - Folder, - IncorrectPasswordError, - http as backup_http, -) from homeassistant.components.http import KEY_HASS, KEY_HASS_REFRESH_TOKEN_ID from homeassistant.components.http.data_validator import RequestDataValidator from homeassistant.components.http.view import HomeAssistantView from homeassistant.core import HomeAssistant, callback -from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import area_registry as ar, integration_platform -from homeassistant.helpers.backup import async_get_manager as async_get_backup_manager from homeassistant.helpers.system_info import async_get_system_info from homeassistant.helpers.translation import async_get_translations from homeassistant.setup import async_setup_component, async_wait_component @@ -61,9 +51,6 @@ async def async_setup( hass.http.register_view(CoreConfigOnboardingView(data, store)) hass.http.register_view(IntegrationOnboardingView(data, store)) hass.http.register_view(AnalyticsOnboardingView(data, store)) - hass.http.register_view(BackupInfoView(data)) - hass.http.register_view(RestoreBackupView(data)) - hass.http.register_view(UploadBackupView(data)) hass.http.register_view(WaitIntegrationOnboardingView(data)) @@ -377,114 +364,6 @@ class AnalyticsOnboardingView(_BaseOnboardingStepView): return self.json({}) -def with_backup_manager[_ViewT: BaseOnboardingView, **_P]( - func: Callable[ - Concatenate[_ViewT, BackupManager, web.Request, _P], - Coroutine[Any, Any, web.Response], - ], -) -> Callable[Concatenate[_ViewT, web.Request, _P], Coroutine[Any, Any, web.Response]]: - """Home Assistant API decorator to check onboarding and inject manager.""" - - @wraps(func) - async def with_backup( - self: _ViewT, - request: web.Request, - *args: _P.args, - **kwargs: _P.kwargs, - ) -> web.Response: - """Check admin and call function.""" - if self._data["done"]: - raise HTTPUnauthorized - - try: - manager = await async_get_backup_manager(request.app[KEY_HASS]) - except HomeAssistantError: - return self.json( - {"code": "backup_disabled"}, - status_code=HTTPStatus.INTERNAL_SERVER_ERROR, - ) - - return await func(self, manager, request, *args, **kwargs) - - return with_backup - - -class BackupInfoView(NoAuthBaseOnboardingView): - """Get backup info view.""" - - url = "/api/onboarding/backup/info" - name = "api:onboarding:backup:info" - - @with_backup_manager - async def get(self, manager: BackupManager, request: web.Request) -> web.Response: - """Return backup info.""" - backups, _ = await manager.async_get_backups() - return self.json( - { - "backups": list(backups.values()), - "state": manager.state, - "last_action_event": manager.last_action_event, - } - ) - - -class RestoreBackupView(NoAuthBaseOnboardingView): - """Restore backup view.""" - - url = "/api/onboarding/backup/restore" - name = "api:onboarding:backup:restore" - - @RequestDataValidator( - vol.Schema( - { - vol.Required("backup_id"): str, - vol.Required("agent_id"): str, - vol.Optional("password"): str, - vol.Optional("restore_addons"): [str], - vol.Optional("restore_database", default=True): bool, - vol.Optional("restore_folders"): [vol.Coerce(Folder)], - } - ) - ) - @with_backup_manager - async def post( - self, manager: BackupManager, request: web.Request, data: dict[str, Any] - ) -> web.Response: - """Restore a backup.""" - try: - await manager.async_restore_backup( - data["backup_id"], - agent_id=data["agent_id"], - password=data.get("password"), - restore_addons=data.get("restore_addons"), - restore_database=data["restore_database"], - restore_folders=data.get("restore_folders"), - restore_homeassistant=True, - ) - except IncorrectPasswordError: - return self.json( - {"code": "incorrect_password"}, status_code=HTTPStatus.BAD_REQUEST - ) - except HomeAssistantError as err: - return self.json( - {"code": "restore_failed", "message": str(err)}, - status_code=HTTPStatus.BAD_REQUEST, - ) - return web.Response(status=HTTPStatus.OK) - - -class UploadBackupView(NoAuthBaseOnboardingView, backup_http.UploadBackupView): - """Upload backup view.""" - - url = "/api/onboarding/backup/upload" - name = "api:onboarding:backup:upload" - - @with_backup_manager - async def post(self, manager: BackupManager, request: web.Request) -> web.Response: - """Upload a backup file.""" - return await self._post(request) - - @callback def _async_get_hass_provider(hass: HomeAssistant) -> HassAuthProvider: """Get the Home Assistant auth provider.""" diff --git a/script/hassfest/dependencies.py b/script/hassfest/dependencies.py index 8f541760269..370be8d66f1 100644 --- a/script/hassfest/dependencies.py +++ b/script/hassfest/dependencies.py @@ -173,10 +173,6 @@ IGNORE_VIOLATIONS = { "logbook", # Temporary needed for migration until 2024.10 ("conversation", "assist_pipeline"), - # The onboarding integration provides limited backup for use - # during onboarding. The onboarding integration waits for the backup manager - # and to be ready before calling any backup functionality. - ("onboarding", "backup"), } diff --git a/tests/components/onboarding/snapshots/test_views.ambr b/tests/components/backup/snapshots/test_onboarding.ambr similarity index 100% rename from tests/components/onboarding/snapshots/test_views.ambr rename to tests/components/backup/snapshots/test_onboarding.ambr diff --git a/tests/components/backup/test_onboarding.py b/tests/components/backup/test_onboarding.py new file mode 100644 index 00000000000..7dfd57ec60a --- /dev/null +++ b/tests/components/backup/test_onboarding.py @@ -0,0 +1,414 @@ +"""Test the onboarding views.""" + +from io import StringIO +from typing import Any +from unittest.mock import ANY, patch + +import pytest +from syrupy import SnapshotAssertion + +from homeassistant.components import backup, onboarding +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers.backup import async_initialize_backup +from homeassistant.setup import async_setup_component + +from tests.common import register_auth_provider +from tests.typing import ClientSessionGenerator + + +def mock_onboarding_storage(hass_storage, data): + """Mock the onboarding storage.""" + hass_storage[onboarding.STORAGE_KEY] = { + "version": onboarding.STORAGE_VERSION, + "data": data, + } + + +@pytest.fixture(autouse=True) +def auth_active(hass: HomeAssistant) -> None: + """Ensure auth is always active.""" + hass.loop.run_until_complete( + register_auth_provider(hass, {"type": "homeassistant"}) + ) + + +@pytest.mark.parametrize( + ("method", "view", "kwargs"), + [ + ("get", "backup/info", {}), + ( + "post", + "backup/restore", + {"json": {"backup_id": "abc123", "agent_id": "test"}}, + ), + ("post", "backup/upload", {}), + ], +) +async def test_onboarding_view_after_done( + hass: HomeAssistant, + hass_storage: dict[str, Any], + hass_client: ClientSessionGenerator, + method: str, + view: str, + kwargs: dict[str, Any], +) -> None: + """Test raising after onboarding.""" + mock_onboarding_storage(hass_storage, {"done": [onboarding.const.STEP_USER]}) + + assert await async_setup_component(hass, "onboarding", {}) + async_initialize_backup(hass) + assert await async_setup_component(hass, "backup", {}) + await hass.async_block_till_done() + + client = await hass_client() + + resp = await client.request(method, f"/api/onboarding/{view}", **kwargs) + + assert resp.status == 401 + + +@pytest.mark.parametrize( + ("method", "view", "kwargs"), + [ + ("get", "backup/info", {}), + ( + "post", + "backup/restore", + {"json": {"backup_id": "abc123", "agent_id": "test"}}, + ), + ("post", "backup/upload", {}), + ], +) +async def test_onboarding_backup_view_without_backup( + hass: HomeAssistant, + hass_storage: dict[str, Any], + hass_client: ClientSessionGenerator, + method: str, + view: str, + kwargs: dict[str, Any], +) -> None: + """Test interacting with backup wievs when backup integration is missing.""" + mock_onboarding_storage(hass_storage, {"done": []}) + + assert await async_setup_component(hass, "onboarding", {}) + await hass.async_block_till_done() + + client = await hass_client() + + resp = await client.request(method, f"/api/onboarding/{view}", **kwargs) + + assert resp.status == 404 + + +async def test_onboarding_backup_info( + hass: HomeAssistant, + hass_storage: dict[str, Any], + hass_client: ClientSessionGenerator, + snapshot: SnapshotAssertion, +) -> None: + """Test backup info.""" + mock_onboarding_storage(hass_storage, {"done": []}) + + assert await async_setup_component(hass, "onboarding", {}) + async_initialize_backup(hass) + assert await async_setup_component(hass, "backup", {}) + await hass.async_block_till_done() + + client = await hass_client() + + backups = { + "abc123": backup.ManagerBackup( + addons=[backup.AddonInfo(name="Test", slug="test", version="1.0.0")], + agents={ + "backup.local": backup.manager.AgentBackupStatus(protected=True, size=0) + }, + backup_id="abc123", + date="1970-01-01T00:00:00.000Z", + database_included=True, + extra_metadata={"instance_id": "abc123", "with_automatic_settings": True}, + folders=[backup.Folder.MEDIA, backup.Folder.SHARE], + homeassistant_included=True, + homeassistant_version="2024.12.0", + name="Test", + failed_agent_ids=[], + with_automatic_settings=True, + ), + "def456": backup.ManagerBackup( + addons=[], + agents={ + "test.remote": backup.manager.AgentBackupStatus(protected=True, size=0) + }, + backup_id="def456", + date="1980-01-01T00:00:00.000Z", + database_included=False, + extra_metadata={ + "instance_id": "unknown_uuid", + "with_automatic_settings": True, + }, + folders=[backup.Folder.MEDIA, backup.Folder.SHARE], + homeassistant_included=True, + homeassistant_version="2024.12.0", + name="Test 2", + failed_agent_ids=[], + with_automatic_settings=None, + ), + } + + with patch( + "homeassistant.components.backup.manager.BackupManager.async_get_backups", + return_value=(backups, {}), + ): + resp = await client.get("/api/onboarding/backup/info") + + assert resp.status == 200 + assert await resp.json() == snapshot + + +@pytest.mark.parametrize( + ("params", "expected_kwargs"), + [ + ( + {"backup_id": "abc123", "agent_id": "backup.local"}, + { + "agent_id": "backup.local", + "password": None, + "restore_addons": None, + "restore_database": True, + "restore_folders": None, + "restore_homeassistant": True, + }, + ), + ( + { + "backup_id": "abc123", + "agent_id": "backup.local", + "password": "hunter2", + "restore_addons": ["addon_1"], + "restore_database": True, + "restore_folders": ["media"], + }, + { + "agent_id": "backup.local", + "password": "hunter2", + "restore_addons": ["addon_1"], + "restore_database": True, + "restore_folders": [backup.Folder.MEDIA], + "restore_homeassistant": True, + }, + ), + ( + { + "backup_id": "abc123", + "agent_id": "backup.local", + "password": "hunter2", + "restore_addons": ["addon_1", "addon_2"], + "restore_database": False, + "restore_folders": ["media", "share"], + }, + { + "agent_id": "backup.local", + "password": "hunter2", + "restore_addons": ["addon_1", "addon_2"], + "restore_database": False, + "restore_folders": [backup.Folder.MEDIA, backup.Folder.SHARE], + "restore_homeassistant": True, + }, + ), + ], +) +async def test_onboarding_backup_restore( + hass: HomeAssistant, + hass_storage: dict[str, Any], + hass_client: ClientSessionGenerator, + params: dict[str, Any], + expected_kwargs: dict[str, Any], +) -> None: + """Test restore backup.""" + mock_onboarding_storage(hass_storage, {"done": []}) + + assert await async_setup_component(hass, "onboarding", {}) + async_initialize_backup(hass) + assert await async_setup_component(hass, "backup", {}) + await hass.async_block_till_done() + + client = await hass_client() + + with patch( + "homeassistant.components.backup.manager.BackupManager.async_restore_backup", + ) as mock_restore: + resp = await client.post("/api/onboarding/backup/restore", json=params) + assert resp.status == 200 + mock_restore.assert_called_once_with("abc123", **expected_kwargs) + + +@pytest.mark.parametrize( + ("params", "restore_error", "expected_status", "expected_json", "restore_calls"), + [ + # Missing agent_id + ( + {"backup_id": "abc123"}, + None, + 400, + { + "message": "Message format incorrect: required key not provided @ data['agent_id']" + }, + 0, + ), + # Missing backup_id + ( + {"agent_id": "backup.local"}, + None, + 400, + { + "message": "Message format incorrect: required key not provided @ data['backup_id']" + }, + 0, + ), + # Invalid restore_database + ( + { + "backup_id": "abc123", + "agent_id": "backup.local", + "restore_database": "yes_please", + }, + None, + 400, + { + "message": "Message format incorrect: expected bool for dictionary value @ data['restore_database']" + }, + 0, + ), + # Invalid folder + ( + { + "backup_id": "abc123", + "agent_id": "backup.local", + "restore_folders": ["invalid"], + }, + None, + 400, + { + "message": "Message format incorrect: expected Folder or one of 'share', 'addons/local', 'ssl', 'media' @ data['restore_folders'][0]" + }, + 0, + ), + # Wrong password + ( + {"backup_id": "abc123", "agent_id": "backup.local"}, + backup.IncorrectPasswordError, + 400, + {"code": "incorrect_password"}, + 1, + ), + # Home Assistant error + ( + {"backup_id": "abc123", "agent_id": "backup.local"}, + HomeAssistantError("Boom!"), + 400, + {"code": "restore_failed", "message": "Boom!"}, + 1, + ), + ], +) +async def test_onboarding_backup_restore_error( + hass: HomeAssistant, + hass_storage: dict[str, Any], + hass_client: ClientSessionGenerator, + params: dict[str, Any], + restore_error: Exception | None, + expected_status: int, + expected_json: str, + restore_calls: int, +) -> None: + """Test restore backup fails.""" + mock_onboarding_storage(hass_storage, {"done": []}) + + assert await async_setup_component(hass, "onboarding", {}) + async_initialize_backup(hass) + assert await async_setup_component(hass, "backup", {}) + await hass.async_block_till_done() + + client = await hass_client() + + with patch( + "homeassistant.components.backup.manager.BackupManager.async_restore_backup", + side_effect=restore_error, + ) as mock_restore: + resp = await client.post("/api/onboarding/backup/restore", json=params) + + assert resp.status == expected_status + assert await resp.json() == expected_json + assert len(mock_restore.mock_calls) == restore_calls + + +@pytest.mark.parametrize( + ("params", "restore_error", "expected_status", "expected_message", "restore_calls"), + [ + # Unexpected error + ( + {"backup_id": "abc123", "agent_id": "backup.local"}, + Exception("Boom!"), + 500, + "500 Internal Server Error", + 1, + ), + ], +) +async def test_onboarding_backup_restore_unexpected_error( + hass: HomeAssistant, + hass_storage: dict[str, Any], + hass_client: ClientSessionGenerator, + params: dict[str, Any], + restore_error: Exception | None, + expected_status: int, + expected_message: str, + restore_calls: int, +) -> None: + """Test restore backup fails.""" + mock_onboarding_storage(hass_storage, {"done": []}) + + assert await async_setup_component(hass, "onboarding", {}) + async_initialize_backup(hass) + assert await async_setup_component(hass, "backup", {}) + await hass.async_block_till_done() + + client = await hass_client() + + with patch( + "homeassistant.components.backup.manager.BackupManager.async_restore_backup", + side_effect=restore_error, + ) as mock_restore: + resp = await client.post("/api/onboarding/backup/restore", json=params) + + assert resp.status == expected_status + assert (await resp.content.read()).decode().startswith(expected_message) + assert len(mock_restore.mock_calls) == restore_calls + + +async def test_onboarding_backup_upload( + hass: HomeAssistant, + hass_storage: dict[str, Any], + hass_client: ClientSessionGenerator, +) -> None: + """Test upload backup.""" + mock_onboarding_storage(hass_storage, {"done": []}) + + assert await async_setup_component(hass, "onboarding", {}) + async_initialize_backup(hass) + assert await async_setup_component(hass, "backup", {}) + await hass.async_block_till_done() + + client = await hass_client() + + with patch( + "homeassistant.components.backup.manager.BackupManager.async_receive_backup", + return_value="abc123", + ) as mock_receive: + resp = await client.post( + "/api/onboarding/backup/upload?agent_id=backup.local", + data={"file": StringIO("test")}, + ) + assert resp.status == 201 + assert await resp.json() == {"backup_id": "abc123"} + mock_receive.assert_called_once_with(agent_ids=["backup.local"], contents=ANY) diff --git a/tests/components/onboarding/test_views.py b/tests/components/onboarding/test_views.py index 8040eb978d5..08acdc94afc 100644 --- a/tests/components/onboarding/test_views.py +++ b/tests/components/onboarding/test_views.py @@ -3,20 +3,16 @@ import asyncio from collections.abc import AsyncGenerator from http import HTTPStatus -from io import StringIO import os from typing import Any -from unittest.mock import ANY, AsyncMock, Mock, patch +from unittest.mock import AsyncMock, Mock, patch import pytest -from syrupy import SnapshotAssertion -from homeassistant.components import backup, onboarding +from homeassistant.components import onboarding from homeassistant.components.onboarding import const, views from homeassistant.core import HomeAssistant -from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import area_registry as ar -from homeassistant.helpers.backup import async_initialize_backup from homeassistant.setup import async_set_domains_to_be_loaded, async_setup_component from . import mock_storage @@ -632,13 +628,6 @@ async def test_onboarding_installation_type( ("method", "view", "kwargs"), [ ("get", "installation_type", {}), - ("get", "backup/info", {}), - ( - "post", - "backup/restore", - {"json": {"backup_id": "abc123", "agent_id": "test"}}, - ), - ("post", "backup/upload", {}), ], ) async def test_onboarding_view_after_done( @@ -723,353 +712,6 @@ async def test_complete_onboarding( listener_3.assert_called_once_with() -@pytest.mark.parametrize( - ("method", "view", "kwargs"), - [ - ("get", "backup/info", {}), - ( - "post", - "backup/restore", - {"json": {"backup_id": "abc123", "agent_id": "test"}}, - ), - ("post", "backup/upload", {}), - ], -) -async def test_onboarding_backup_view_without_backup( - hass: HomeAssistant, - hass_storage: dict[str, Any], - hass_client: ClientSessionGenerator, - method: str, - view: str, - kwargs: dict[str, Any], -) -> None: - """Test interacting with backup wievs when backup integration is missing.""" - mock_storage(hass_storage, {"done": []}) - - assert await async_setup_component(hass, "onboarding", {}) - await hass.async_block_till_done() - - client = await hass_client() - - resp = await client.request(method, f"/api/onboarding/{view}", **kwargs) - - assert resp.status == 500 - assert await resp.json() == {"code": "backup_disabled"} - - -async def test_onboarding_backup_info( - hass: HomeAssistant, - hass_storage: dict[str, Any], - hass_client: ClientSessionGenerator, - snapshot: SnapshotAssertion, -) -> None: - """Test backup info.""" - mock_storage(hass_storage, {"done": []}) - - assert await async_setup_component(hass, "onboarding", {}) - async_initialize_backup(hass) - assert await async_setup_component(hass, "backup", {}) - await hass.async_block_till_done() - - client = await hass_client() - - backups = { - "abc123": backup.ManagerBackup( - addons=[backup.AddonInfo(name="Test", slug="test", version="1.0.0")], - agents={ - "backup.local": backup.manager.AgentBackupStatus(protected=True, size=0) - }, - backup_id="abc123", - date="1970-01-01T00:00:00.000Z", - database_included=True, - extra_metadata={"instance_id": "abc123", "with_automatic_settings": True}, - folders=[backup.Folder.MEDIA, backup.Folder.SHARE], - homeassistant_included=True, - homeassistant_version="2024.12.0", - name="Test", - failed_agent_ids=[], - with_automatic_settings=True, - ), - "def456": backup.ManagerBackup( - addons=[], - agents={ - "test.remote": backup.manager.AgentBackupStatus(protected=True, size=0) - }, - backup_id="def456", - date="1980-01-01T00:00:00.000Z", - database_included=False, - extra_metadata={ - "instance_id": "unknown_uuid", - "with_automatic_settings": True, - }, - folders=[backup.Folder.MEDIA, backup.Folder.SHARE], - homeassistant_included=True, - homeassistant_version="2024.12.0", - name="Test 2", - failed_agent_ids=[], - with_automatic_settings=None, - ), - } - - with patch( - "homeassistant.components.backup.manager.BackupManager.async_get_backups", - return_value=(backups, {}), - ): - resp = await client.get("/api/onboarding/backup/info") - - assert resp.status == 200 - assert await resp.json() == snapshot - - -@pytest.mark.parametrize( - ("params", "expected_kwargs"), - [ - ( - {"backup_id": "abc123", "agent_id": "backup.local"}, - { - "agent_id": "backup.local", - "password": None, - "restore_addons": None, - "restore_database": True, - "restore_folders": None, - "restore_homeassistant": True, - }, - ), - ( - { - "backup_id": "abc123", - "agent_id": "backup.local", - "password": "hunter2", - "restore_addons": ["addon_1"], - "restore_database": True, - "restore_folders": ["media"], - }, - { - "agent_id": "backup.local", - "password": "hunter2", - "restore_addons": ["addon_1"], - "restore_database": True, - "restore_folders": [backup.Folder.MEDIA], - "restore_homeassistant": True, - }, - ), - ( - { - "backup_id": "abc123", - "agent_id": "backup.local", - "password": "hunter2", - "restore_addons": ["addon_1", "addon_2"], - "restore_database": False, - "restore_folders": ["media", "share"], - }, - { - "agent_id": "backup.local", - "password": "hunter2", - "restore_addons": ["addon_1", "addon_2"], - "restore_database": False, - "restore_folders": [backup.Folder.MEDIA, backup.Folder.SHARE], - "restore_homeassistant": True, - }, - ), - ], -) -async def test_onboarding_backup_restore( - hass: HomeAssistant, - hass_storage: dict[str, Any], - hass_client: ClientSessionGenerator, - params: dict[str, Any], - expected_kwargs: dict[str, Any], -) -> None: - """Test restore backup.""" - mock_storage(hass_storage, {"done": []}) - - assert await async_setup_component(hass, "onboarding", {}) - async_initialize_backup(hass) - assert await async_setup_component(hass, "backup", {}) - await hass.async_block_till_done() - - client = await hass_client() - - with patch( - "homeassistant.components.backup.manager.BackupManager.async_restore_backup", - ) as mock_restore: - resp = await client.post("/api/onboarding/backup/restore", json=params) - assert resp.status == 200 - mock_restore.assert_called_once_with("abc123", **expected_kwargs) - - -@pytest.mark.parametrize( - ("params", "restore_error", "expected_status", "expected_json", "restore_calls"), - [ - # Missing agent_id - ( - {"backup_id": "abc123"}, - None, - 400, - { - "message": "Message format incorrect: required key not provided @ data['agent_id']" - }, - 0, - ), - # Missing backup_id - ( - {"agent_id": "backup.local"}, - None, - 400, - { - "message": "Message format incorrect: required key not provided @ data['backup_id']" - }, - 0, - ), - # Invalid restore_database - ( - { - "backup_id": "abc123", - "agent_id": "backup.local", - "restore_database": "yes_please", - }, - None, - 400, - { - "message": "Message format incorrect: expected bool for dictionary value @ data['restore_database']" - }, - 0, - ), - # Invalid folder - ( - { - "backup_id": "abc123", - "agent_id": "backup.local", - "restore_folders": ["invalid"], - }, - None, - 400, - { - "message": "Message format incorrect: expected Folder or one of 'share', 'addons/local', 'ssl', 'media' @ data['restore_folders'][0]" - }, - 0, - ), - # Wrong password - ( - {"backup_id": "abc123", "agent_id": "backup.local"}, - backup.IncorrectPasswordError, - 400, - {"code": "incorrect_password"}, - 1, - ), - # Home Assistant error - ( - {"backup_id": "abc123", "agent_id": "backup.local"}, - HomeAssistantError("Boom!"), - 400, - {"code": "restore_failed", "message": "Boom!"}, - 1, - ), - ], -) -async def test_onboarding_backup_restore_error( - hass: HomeAssistant, - hass_storage: dict[str, Any], - hass_client: ClientSessionGenerator, - params: dict[str, Any], - restore_error: Exception | None, - expected_status: int, - expected_json: str, - restore_calls: int, -) -> None: - """Test restore backup fails.""" - mock_storage(hass_storage, {"done": []}) - - assert await async_setup_component(hass, "onboarding", {}) - async_initialize_backup(hass) - assert await async_setup_component(hass, "backup", {}) - await hass.async_block_till_done() - - client = await hass_client() - - with patch( - "homeassistant.components.backup.manager.BackupManager.async_restore_backup", - side_effect=restore_error, - ) as mock_restore: - resp = await client.post("/api/onboarding/backup/restore", json=params) - - assert resp.status == expected_status - assert await resp.json() == expected_json - assert len(mock_restore.mock_calls) == restore_calls - - -@pytest.mark.parametrize( - ("params", "restore_error", "expected_status", "expected_message", "restore_calls"), - [ - # Unexpected error - ( - {"backup_id": "abc123", "agent_id": "backup.local"}, - Exception("Boom!"), - 500, - "500 Internal Server Error", - 1, - ), - ], -) -async def test_onboarding_backup_restore_unexpected_error( - hass: HomeAssistant, - hass_storage: dict[str, Any], - hass_client: ClientSessionGenerator, - params: dict[str, Any], - restore_error: Exception | None, - expected_status: int, - expected_message: str, - restore_calls: int, -) -> None: - """Test restore backup fails.""" - mock_storage(hass_storage, {"done": []}) - - assert await async_setup_component(hass, "onboarding", {}) - async_initialize_backup(hass) - assert await async_setup_component(hass, "backup", {}) - await hass.async_block_till_done() - - client = await hass_client() - - with patch( - "homeassistant.components.backup.manager.BackupManager.async_restore_backup", - side_effect=restore_error, - ) as mock_restore: - resp = await client.post("/api/onboarding/backup/restore", json=params) - - assert resp.status == expected_status - assert (await resp.content.read()).decode().startswith(expected_message) - assert len(mock_restore.mock_calls) == restore_calls - - -async def test_onboarding_backup_upload( - hass: HomeAssistant, - hass_storage: dict[str, Any], - hass_client: ClientSessionGenerator, -) -> None: - """Test upload backup.""" - mock_storage(hass_storage, {"done": []}) - - assert await async_setup_component(hass, "onboarding", {}) - async_initialize_backup(hass) - assert await async_setup_component(hass, "backup", {}) - await hass.async_block_till_done() - - client = await hass_client() - - with patch( - "homeassistant.components.backup.manager.BackupManager.async_receive_backup", - return_value="abc123", - ) as mock_receive: - resp = await client.post( - "/api/onboarding/backup/upload?agent_id=backup.local", - data={"file": StringIO("test")}, - ) - assert resp.status == 201 - assert await resp.json() == {"backup_id": "abc123"} - mock_receive.assert_called_once_with(agent_ids=["backup.local"], contents=ANY) - - @pytest.mark.parametrize( ("domain", "expected_result"), [ From 4eda08157409de5e34069164335ab9b670db3685 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Sat, 12 Apr 2025 11:01:41 +0200 Subject: [PATCH 486/505] Remove unnecessary error handling from backup onboarding (#142786) --- homeassistant/components/backup/onboarding.py | 9 +-------- 1 file changed, 1 insertion(+), 8 deletions(-) diff --git a/homeassistant/components/backup/onboarding.py b/homeassistant/components/backup/onboarding.py index 1bbd3937567..ad7027c988c 100644 --- a/homeassistant/components/backup/onboarding.py +++ b/homeassistant/components/backup/onboarding.py @@ -54,14 +54,7 @@ def with_backup_manager[_ViewT: BaseOnboardingView, **_P]( if self._data["done"]: raise HTTPUnauthorized - try: - manager = await async_get_backup_manager(request.app[KEY_HASS]) - except HomeAssistantError: - return self.json( - {"code": "backup_disabled"}, - status_code=HTTPStatus.INTERNAL_SERVER_ERROR, - ) - + manager = await async_get_backup_manager(request.app[KEY_HASS]) return await func(self, manager, request, *args, **kwargs) return with_backup From 6b65b21ee0db5778aa729c7af14dc354bb045f9e Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 12 Apr 2025 00:20:07 -1000 Subject: [PATCH 487/505] Migrate inkbird to use entry.runtime_data (#142780) --- homeassistant/components/inkbird/__init__.py | 15 +++++++-------- homeassistant/components/inkbird/coordinator.py | 4 +++- homeassistant/components/inkbird/sensor.py | 11 +++-------- 3 files changed, 13 insertions(+), 17 deletions(-) diff --git a/homeassistant/components/inkbird/__init__.py b/homeassistant/components/inkbird/__init__.py index 738d412d849..bc81b852f02 100644 --- a/homeassistant/components/inkbird/__init__.py +++ b/homeassistant/components/inkbird/__init__.py @@ -8,27 +8,26 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import Platform from homeassistant.core import HomeAssistant -from .const import CONF_DEVICE_TYPE, DOMAIN +from .const import CONF_DEVICE_TYPE from .coordinator import INKBIRDActiveBluetoothProcessorCoordinator PLATFORMS: list[Platform] = [Platform.SENSOR] +INKBIRDConfigEntry = ConfigEntry[INKBIRDActiveBluetoothProcessorCoordinator] -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + +async def async_setup_entry(hass: HomeAssistant, entry: INKBIRDConfigEntry) -> bool: """Set up INKBIRD BLE device from a config entry.""" device_type: str | None = entry.data.get(CONF_DEVICE_TYPE) data = INKBIRDBluetoothDeviceData(device_type) coordinator = INKBIRDActiveBluetoothProcessorCoordinator(hass, entry, data) - hass.data.setdefault(DOMAIN, {})[entry.entry_id] = coordinator + entry.runtime_data = coordinator await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) # only start after all platforms have had a chance to subscribe entry.async_on_unload(coordinator.async_start()) return True -async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_unload_entry(hass: HomeAssistant, entry: INKBIRDConfigEntry) -> bool: """Unload a config entry.""" - if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS): - hass.data[DOMAIN].pop(entry.entry_id) - - return unload_ok + return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/homeassistant/components/inkbird/coordinator.py b/homeassistant/components/inkbird/coordinator.py index bcd519b32aa..b119682a7d6 100644 --- a/homeassistant/components/inkbird/coordinator.py +++ b/homeassistant/components/inkbird/coordinator.py @@ -27,7 +27,9 @@ _LOGGER = logging.getLogger(__name__) FALLBACK_POLL_INTERVAL = timedelta(seconds=180) -class INKBIRDActiveBluetoothProcessorCoordinator(ActiveBluetoothProcessorCoordinator): +class INKBIRDActiveBluetoothProcessorCoordinator( + ActiveBluetoothProcessorCoordinator[SensorUpdate] +): """Coordinator for INKBIRD Bluetooth devices.""" def __init__( diff --git a/homeassistant/components/inkbird/sensor.py b/homeassistant/components/inkbird/sensor.py index efda28b110d..447d7ac961b 100644 --- a/homeassistant/components/inkbird/sensor.py +++ b/homeassistant/components/inkbird/sensor.py @@ -4,12 +4,10 @@ from __future__ import annotations from inkbird_ble import DeviceClass, DeviceKey, SensorUpdate, Units -from homeassistant import config_entries from homeassistant.components.bluetooth.passive_update_processor import ( PassiveBluetoothDataProcessor, PassiveBluetoothDataUpdate, PassiveBluetoothEntityKey, - PassiveBluetoothProcessorCoordinator, PassiveBluetoothProcessorEntity, ) from homeassistant.components.sensor import ( @@ -27,7 +25,7 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.sensor import sensor_device_info_to_hass_device_info -from .const import DOMAIN +from . import INKBIRDConfigEntry SENSOR_DESCRIPTIONS = { (DeviceClass.TEMPERATURE, Units.TEMP_CELSIUS): SensorEntityDescription( @@ -97,20 +95,17 @@ def sensor_update_to_bluetooth_data_update( async def async_setup_entry( hass: HomeAssistant, - entry: config_entries.ConfigEntry, + entry: INKBIRDConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the INKBIRD BLE sensors.""" - coordinator: PassiveBluetoothProcessorCoordinator = hass.data[DOMAIN][ - entry.entry_id - ] processor = PassiveBluetoothDataProcessor(sensor_update_to_bluetooth_data_update) entry.async_on_unload( processor.async_add_entities_listener( INKBIRDBluetoothSensorEntity, async_add_entities ) ) - entry.async_on_unload(coordinator.async_register_processor(processor)) + entry.async_on_unload(entry.runtime_data.async_register_processor(processor)) class INKBIRDBluetoothSensorEntity( From eb19c7af32e34a4876aaf9bedcbcbe9cffe5299c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=2E=20Diego=20Rodr=C3=ADguez=20Royo?= Date: Sat, 12 Apr 2025 14:58:35 +0200 Subject: [PATCH 488/505] Disable Home Connect appliance refresh when frequent disconnects are detected (#142615) * Disable specific updates for an appliance when is done repeatedly * Fix deprecation issues fix tests * Fix message * Avoid fetching appliance info also * Apply suggestions Co-authored-by: Martin Hjelmare * Create specific RepairFlow for enabling appliance's updates --------- Co-authored-by: Martin Hjelmare --- .../components/home_connect/__init__.py | 21 +- .../components/home_connect/coordinator.py | 68 ++++++- .../components/home_connect/repairs.py | 60 ++++++ .../components/home_connect/strings.json | 11 ++ .../home_connect/test_coordinator.py | 183 +++++++++++++++++- 5 files changed, 336 insertions(+), 7 deletions(-) create mode 100644 homeassistant/components/home_connect/repairs.py diff --git a/homeassistant/components/home_connect/__init__.py b/homeassistant/components/home_connect/__init__.py index fe01a3e9564..38db34aa72a 100644 --- a/homeassistant/components/home_connect/__init__.py +++ b/homeassistant/components/home_connect/__init__.py @@ -11,9 +11,12 @@ import aiohttp from homeassistant.const import Platform from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady -from homeassistant.helpers import config_entry_oauth2_flow, config_validation as cv +from homeassistant.helpers import ( + config_entry_oauth2_flow, + config_validation as cv, + issue_registry as ir, +) from homeassistant.helpers.entity_registry import RegistryEntry, async_migrate_entries -from homeassistant.helpers.issue_registry import async_delete_issue from homeassistant.helpers.typing import ConfigType from .api import AsyncConfigEntryAuth @@ -86,8 +89,18 @@ async def async_unload_entry( hass: HomeAssistant, entry: HomeConnectConfigEntry ) -> bool: """Unload a config entry.""" - async_delete_issue(hass, DOMAIN, "deprecated_set_program_and_option_actions") - async_delete_issue(hass, DOMAIN, "deprecated_command_actions") + issue_registry = ir.async_get(hass) + issues_to_delete = [ + "deprecated_set_program_and_option_actions", + "deprecated_command_actions", + ] + [ + issue_id + for (issue_domain, issue_id) in issue_registry.issues + if issue_domain == DOMAIN + and issue_id.startswith("home_connect_too_many_connected_paired_events") + ] + for issue_id in issues_to_delete: + issue_registry.async_delete(DOMAIN, issue_id) return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/homeassistant/components/home_connect/coordinator.py b/homeassistant/components/home_connect/coordinator.py index 54dc24a6279..4b4ec37ac61 100644 --- a/homeassistant/components/home_connect/coordinator.py +++ b/homeassistant/components/home_connect/coordinator.py @@ -39,7 +39,7 @@ from propcache.api import cached_property from homeassistant.config_entries import ConfigEntry from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady -from homeassistant.helpers import device_registry as dr +from homeassistant.helpers import device_registry as dr, issue_registry as ir from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from .const import API_DEFAULT_RETRY_AFTER, APPLIANCES_WITH_PROGRAMS, DOMAIN @@ -47,6 +47,9 @@ from .utils import get_dict_from_home_connect_error _LOGGER = logging.getLogger(__name__) +MAX_EXECUTIONS_TIME_WINDOW = 15 * 60 # 15 minutes +MAX_EXECUTIONS = 5 + type HomeConnectConfigEntry = ConfigEntry[HomeConnectCoordinator] @@ -114,6 +117,7 @@ class HomeConnectCoordinator( ] = {} self.device_registry = dr.async_get(self.hass) self.data = {} + self._execution_tracker: dict[str, list[float]] = defaultdict(list) @cached_property def context_listeners(self) -> dict[tuple[str, EventKey], list[CALLBACK_TYPE]]: @@ -172,7 +176,7 @@ class HomeConnectCoordinator( f"home_connect-events_listener_task-{self.config_entry.entry_id}", ) - async def _event_listener(self) -> None: + async def _event_listener(self) -> None: # noqa: C901 """Match event with listener for event type.""" retry_time = 10 while True: @@ -238,6 +242,9 @@ class HomeConnectCoordinator( self._call_event_listener(event_message) case EventType.CONNECTED | EventType.PAIRED: + if self.refreshed_too_often_recently(event_message_ha_id): + continue + appliance_info = await self.client.get_specific_appliance( event_message_ha_id ) @@ -592,3 +599,60 @@ class HomeConnectCoordinator( [], ): listener() + + def refreshed_too_often_recently(self, appliance_ha_id: str) -> bool: + """Check if the appliance data hasn't been refreshed too often recently.""" + + now = self.hass.loop.time() + if len(self._execution_tracker[appliance_ha_id]) >= MAX_EXECUTIONS: + return True + + execution_tracker = self._execution_tracker[appliance_ha_id] = [ + timestamp + for timestamp in self._execution_tracker[appliance_ha_id] + if now - timestamp < MAX_EXECUTIONS_TIME_WINDOW + ] + + execution_tracker.append(now) + + if len(execution_tracker) >= MAX_EXECUTIONS: + ir.async_create_issue( + self.hass, + DOMAIN, + f"home_connect_too_many_connected_paired_events_{appliance_ha_id}", + is_fixable=True, + is_persistent=True, + severity=ir.IssueSeverity.ERROR, + translation_key="home_connect_too_many_connected_paired_events", + data={ + "entry_id": self.config_entry.entry_id, + "appliance_ha_id": appliance_ha_id, + }, + translation_placeholders={ + "appliance_name": self.data[appliance_ha_id].info.name, + "times": str(MAX_EXECUTIONS), + "time_window": str(MAX_EXECUTIONS_TIME_WINDOW // 60), + "home_connect_resource_url": "https://www.home-connect.com/global/help-support/error-codes#/Togglebox=15362315-13320636-1/", + "home_assistant_core_new_issue_url": ( + "https://github.com/home-assistant/core/issues/new?template=bug_report.yml" + f"&integration_name={DOMAIN}&integration_link=https://www.home-assistant.io/integrations/{DOMAIN}/" + ), + }, + ) + return True + + return False + + async def reset_execution_tracker(self, appliance_ha_id: str) -> None: + """Reset the execution tracker for a specific appliance.""" + self._execution_tracker.pop(appliance_ha_id, None) + appliance_info = await self.client.get_specific_appliance(appliance_ha_id) + + appliance_data = await self._get_appliance_data( + appliance_info, self.data.get(appliance_info.ha_id) + ) + self.data[appliance_ha_id].update(appliance_data) + for listener, context in self._special_listeners.values(): + if EventKey.BSH_COMMON_APPLIANCE_DEPAIRED not in context: + listener() + self._call_all_event_listeners_for_appliance(appliance_ha_id) diff --git a/homeassistant/components/home_connect/repairs.py b/homeassistant/components/home_connect/repairs.py new file mode 100644 index 00000000000..21c6775e549 --- /dev/null +++ b/homeassistant/components/home_connect/repairs.py @@ -0,0 +1,60 @@ +"""Repairs flows for Home Connect.""" + +from typing import cast + +import voluptuous as vol + +from homeassistant import data_entry_flow +from homeassistant.components.repairs import ConfirmRepairFlow, RepairsFlow +from homeassistant.core import HomeAssistant +from homeassistant.helpers import issue_registry as ir + +from .coordinator import HomeConnectConfigEntry + + +class EnableApplianceUpdatesFlow(RepairsFlow): + """Handler for enabling appliance's updates after being refreshed too many times.""" + + async def async_step_init( + self, user_input: dict[str, str] | None = None + ) -> data_entry_flow.FlowResult: + """Handle the first step of a fix flow.""" + return await self.async_step_confirm() + + async def async_step_confirm( + self, user_input: dict[str, str] | None = None + ) -> data_entry_flow.FlowResult: + """Handle the confirm step of a fix flow.""" + if user_input is not None: + assert self.data + entry = self.hass.config_entries.async_get_entry( + cast(str, self.data["entry_id"]) + ) + assert entry + entry = cast(HomeConnectConfigEntry, entry) + await entry.runtime_data.reset_execution_tracker( + cast(str, self.data["appliance_ha_id"]) + ) + return self.async_create_entry(data={}) + + issue_registry = ir.async_get(self.hass) + description_placeholders = None + if issue := issue_registry.async_get_issue(self.handler, self.issue_id): + description_placeholders = issue.translation_placeholders + + return self.async_show_form( + step_id="confirm", + data_schema=vol.Schema({}), + description_placeholders=description_placeholders, + ) + + +async def async_create_fix_flow( + hass: HomeAssistant, + issue_id: str, + data: dict[str, str | int | float | None] | None, +) -> RepairsFlow: + """Create flow.""" + if issue_id.startswith("home_connect_too_many_connected_paired_events"): + return EnableApplianceUpdatesFlow() + return ConfirmRepairFlow() diff --git a/homeassistant/components/home_connect/strings.json b/homeassistant/components/home_connect/strings.json index 5b52183fccf..070dcf34f9c 100644 --- a/homeassistant/components/home_connect/strings.json +++ b/homeassistant/components/home_connect/strings.json @@ -110,6 +110,17 @@ } }, "issues": { + "home_connect_too_many_connected_paired_events": { + "title": "{appliance_name} sent too many connected or paired events", + "fix_flow": { + "step": { + "confirm": { + "title": "[%key:component::home_connect::issues::home_connect_too_many_connected_paired_events::title%]", + "description": "The appliance \"{appliance_name}\" has been reported as connected or paired {times} times in less than {time_window} minutes, so refreshes on connected or paired events has been disabled to avoid exceeding the API rate limit.\n\nPlease refer to the [Home Connect Wi-Fi requirements and recommendations]({home_connect_resource_url}). If everything seems right with your network configuration, restart the appliance.\n\nClick \"submit\" to re-enable the updates.\nIf the issue persists, please create an issue in the [Home Assistant core repository]({home_assistant_core_new_issue_url})." + } + } + } + }, "deprecated_time_alarm_clock_in_automations_scripts": { "title": "Deprecated alarm clock entity detected in some automations or scripts", "fix_flow": { diff --git a/tests/components/home_connect/test_coordinator.py b/tests/components/home_connect/test_coordinator.py index d3b514bcc17..a74c4199318 100644 --- a/tests/components/home_connect/test_coordinator.py +++ b/tests/components/home_connect/test_coordinator.py @@ -2,6 +2,7 @@ from collections.abc import Awaitable, Callable from datetime import timedelta +from http import HTTPStatus from typing import Any, cast from unittest.mock import AsyncMock, MagicMock, patch @@ -14,7 +15,9 @@ from aiohomeconnect.model import ( EventKey, EventMessage, EventType, + GetSetting, HomeAppliance, + SettingKey, ) from aiohomeconnect.model.error import ( EventStreamInterruptedError, @@ -39,6 +42,8 @@ from homeassistant.config_entries import ConfigEntries, ConfigEntryState from homeassistant.const import ( ATTR_ENTITY_ID, EVENT_STATE_REPORTED, + STATE_OFF, + STATE_ON, STATE_UNAVAILABLE, Platform, ) @@ -48,11 +53,16 @@ from homeassistant.core import ( HomeAssistant, callback, ) -from homeassistant.helpers import device_registry as dr, entity_registry as er +from homeassistant.helpers import ( + device_registry as dr, + entity_registry as er, + issue_registry as ir, +) from homeassistant.setup import async_setup_component from homeassistant.util import dt as dt_util from tests.common import MockConfigEntry, async_fire_time_changed +from tests.typing import ClientSessionGenerator INITIAL_FETCH_CLIENT_METHODS = [ "get_settings", @@ -609,3 +619,174 @@ async def test_paired_disconnected_devices_not_fetching( client.get_specific_appliance.assert_awaited_once_with(appliance.ha_id) for method in INITIAL_FETCH_CLIENT_METHODS: assert getattr(client, method).call_count == 0 + + +async def test_coordinator_disabling_updates_for_appliance( + hass: HomeAssistant, + config_entry: MockConfigEntry, + integration_setup: Callable[[MagicMock], Awaitable[bool]], + setup_credentials: None, + client: MagicMock, + issue_registry: ir.IssueRegistry, + hass_client: ClientSessionGenerator, +) -> None: + """Test coordinator disables appliance updates on frequent connect/paired events. + + A repair issue should be created when the updates are disabled. + When the user confirms the issue the updates should be enabled again. + """ + appliance_ha_id = "SIEMENS-HCS02DWH1-6BE58C26DCC1" + issue_id = f"home_connect_too_many_connected_paired_events_{appliance_ha_id}" + + assert config_entry.state == ConfigEntryState.NOT_LOADED + assert await integration_setup(client) + assert config_entry.state == ConfigEntryState.LOADED + + assert hass.states.is_state("switch.dishwasher_power", STATE_ON) + + await client.add_events( + [ + EventMessage( + appliance_ha_id, + EventType.CONNECTED, + data=ArrayOfEvents([]), + ) + for _ in range(5) + ] + ) + await hass.async_block_till_done() + + issue = issue_registry.async_get_issue(DOMAIN, issue_id) + assert issue + + get_settings_original_side_effect = client.get_settings.side_effect + + async def get_settings_side_effect(ha_id: str) -> ArrayOfSettings: + if ha_id == appliance_ha_id: + return ArrayOfSettings( + [ + GetSetting( + SettingKey.BSH_COMMON_POWER_STATE, + SettingKey.BSH_COMMON_POWER_STATE.value, + BSH_POWER_OFF, + ) + ] + ) + return cast(ArrayOfSettings, get_settings_original_side_effect(ha_id)) + + client.get_settings = AsyncMock(side_effect=get_settings_side_effect) + + await client.add_events( + [ + EventMessage( + appliance_ha_id, + EventType.CONNECTED, + data=ArrayOfEvents([]), + ) + ] + ) + await hass.async_block_till_done() + + assert hass.states.is_state("switch.dishwasher_power", STATE_ON) + + _client = await hass_client() + resp = await _client.post( + "/api/repairs/issues/fix", + json={"handler": DOMAIN, "issue_id": issue.issue_id}, + ) + assert resp.status == HTTPStatus.OK + flow_id = (await resp.json())["flow_id"] + resp = await _client.post(f"/api/repairs/issues/fix/{flow_id}") + assert resp.status == HTTPStatus.OK + + assert not issue_registry.async_get_issue(DOMAIN, issue_id) + + await client.add_events( + [ + EventMessage( + appliance_ha_id, + EventType.CONNECTED, + data=ArrayOfEvents([]), + ) + ] + ) + await hass.async_block_till_done() + + assert hass.states.is_state("switch.dishwasher_power", STATE_OFF) + + +async def test_coordinator_disabling_updates_for_appliance_is_gone_after_entry_reload( + hass: HomeAssistant, + config_entry: MockConfigEntry, + integration_setup: Callable[[MagicMock], Awaitable[bool]], + setup_credentials: None, + client: MagicMock, + issue_registry: ir.IssueRegistry, + hass_client: ClientSessionGenerator, +) -> None: + """Test that updates are enabled again after unloading the entry. + + The repair issue should also be deleted. + """ + appliance_ha_id = "SIEMENS-HCS02DWH1-6BE58C26DCC1" + issue_id = f"home_connect_too_many_connected_paired_events_{appliance_ha_id}" + + assert config_entry.state == ConfigEntryState.NOT_LOADED + assert await integration_setup(client) + assert config_entry.state == ConfigEntryState.LOADED + + assert hass.states.is_state("switch.dishwasher_power", STATE_ON) + + await client.add_events( + [ + EventMessage( + appliance_ha_id, + EventType.CONNECTED, + data=ArrayOfEvents([]), + ) + for _ in range(5) + ] + ) + await hass.async_block_till_done() + + issue = issue_registry.async_get_issue(DOMAIN, issue_id) + assert issue + + await hass.config_entries.async_unload(config_entry.entry_id) + await hass.async_block_till_done() + + assert not issue_registry.async_get_issue(DOMAIN, issue_id) + + assert config_entry.state == ConfigEntryState.NOT_LOADED + assert await integration_setup(client) + assert config_entry.state == ConfigEntryState.LOADED + + get_settings_original_side_effect = client.get_settings.side_effect + + async def get_settings_side_effect(ha_id: str) -> ArrayOfSettings: + if ha_id == appliance_ha_id: + return ArrayOfSettings( + [ + GetSetting( + SettingKey.BSH_COMMON_POWER_STATE, + SettingKey.BSH_COMMON_POWER_STATE.value, + BSH_POWER_OFF, + ) + ] + ) + return cast(ArrayOfSettings, get_settings_original_side_effect(ha_id)) + + client.get_settings = AsyncMock(side_effect=get_settings_side_effect) + + await client.add_events( + [ + EventMessage( + appliance_ha_id, + EventType.CONNECTED, + data=ArrayOfEvents([]), + ) + ] + ) + await hass.async_block_till_done() + + assert hass.states.is_state("switch.dishwasher_power", STATE_OFF) From 5129c7521b269a44231ddfc67901e94f524960ec Mon Sep 17 00:00:00 2001 From: peteS-UK <64092177+peteS-UK@users.noreply.github.com> Date: Sat, 12 Apr 2025 16:09:17 +0100 Subject: [PATCH 489/505] Force Squeezebox item id to string (#142793) force item_id to string --- .../components/squeezebox/browse_media.py | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/squeezebox/browse_media.py b/homeassistant/components/squeezebox/browse_media.py index 633f004993f..eadd706fcd8 100644 --- a/homeassistant/components/squeezebox/browse_media.py +++ b/homeassistant/components/squeezebox/browse_media.py @@ -148,7 +148,7 @@ def _build_response_apps_radios_category( ) -> BrowseMedia: """Build item for App or radio category.""" return BrowseMedia( - media_content_id=item.get("id", ""), + media_content_id=item["id"], title=item["title"], media_content_type=cmd, media_class=browse_data.content_type_media_class[cmd]["item"], @@ -163,7 +163,7 @@ def _build_response_known_app( """Build item for app or radio.""" return BrowseMedia( - media_content_id=item.get("id", ""), + media_content_id=item["id"], title=item["title"], media_content_type=search_type, media_class=browse_data.content_type_media_class[search_type]["item"], @@ -185,7 +185,7 @@ def _build_response_favorites(item: dict[str, Any]) -> BrowseMedia: ) if item["hasitems"] and not item["isaudio"]: return BrowseMedia( - media_content_id=item.get("id", ""), + media_content_id=item["id"], title=item["title"], media_content_type="Favorites", media_class=CONTENT_TYPE_MEDIA_CLASS["Favorites"]["item"], @@ -193,7 +193,7 @@ def _build_response_favorites(item: dict[str, Any]) -> BrowseMedia: can_play=False, ) return BrowseMedia( - media_content_id=item.get("id", ""), + media_content_id=item["id"], title=item["title"], media_content_type="Favorites", media_class=CONTENT_TYPE_MEDIA_CLASS[MediaType.TRACK]["item"], @@ -217,7 +217,7 @@ def _get_item_thumbnail( item_thumbnail = player.generate_image_url_from_track_id(artwork_track_id) elif item_type is not None: item_thumbnail = entity.get_browse_image_url( - item_type, item.get("id", ""), artwork_track_id + item_type, item["id"], artwork_track_id ) elif search_type in ["Apps", "Radios"]: @@ -263,6 +263,8 @@ async def build_item_response( children = [] for item in result["items"]: + # Force the item id to a string in case it's numeric from some lms + item["id"] = str(item.get("id", "")) if search_type == "Favorites": child_media = _build_response_favorites(item) @@ -294,7 +296,7 @@ async def build_item_response( elif item_type: child_media = BrowseMedia( - media_content_id=str(item.get("id", "")), + media_content_id=item["id"], title=item["title"], media_content_type=item_type, media_class=CONTENT_TYPE_MEDIA_CLASS[item_type]["item"], From d218ac85f740f9635f05d9e9699d3d8f076e4bfd Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Sat, 12 Apr 2025 19:15:38 +0200 Subject: [PATCH 490/505] Update pytest warnings filter (#142797) --- pyproject.toml | 105 +++++++++++++++++-------------------------------- 1 file changed, 37 insertions(+), 68 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 091bb617142..da076d1953d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -501,17 +501,18 @@ filterwarnings = [ # Modify app state for testing "ignore:Changing state of started or joined application is deprecated:DeprecationWarning:tests.components.http.test_ban", - # -- Tests - # Ignore custom pytest marks - "ignore:Unknown pytest.mark.disable_autouse_fixture:pytest.PytestUnknownMarkWarning:tests.components.met", - "ignore:Unknown pytest.mark.dataset:pytest.PytestUnknownMarkWarning:tests.components.screenlogic", + # -- Tests + # Ignore custom pytest marks + "ignore:Unknown pytest.mark.disable_autouse_fixture:pytest.PytestUnknownMarkWarning:tests.components.met", + "ignore:Unknown pytest.mark.dataset:pytest.PytestUnknownMarkWarning:tests.components.screenlogic", + + # -- DeprecationWarning already fixed in our codebase + # https://github.com/kurtmckee/feedparser/pull/389 - 6.0.11 + "ignore:.*a temporary mapping .* from `updated_parsed` to `published_parsed` if `updated_parsed` doesn't exist:DeprecationWarning:feedparser.util", # -- design choice 3rd party - # https://github.com/gwww/elkm1/blob/2.2.10/elkm1_lib/util.py#L8-L19 + # https://github.com/gwww/elkm1/blob/2.2.11/elkm1_lib/util.py#L8-L19 "ignore:ssl.TLSVersion.TLSv1 is deprecated:DeprecationWarning:elkm1_lib.util", - # https://github.com/allenporter/ical/pull/215 - # https://github.com/allenporter/ical/blob/8.2.0/ical/util.py#L21-L23 - "ignore:datetime.*utcnow\\(\\) is deprecated and scheduled for removal:DeprecationWarning:ical.util", # https://github.com/bachya/regenmaschine/blob/2024.03.0/regenmaschine/client.py#L52 "ignore:ssl.TLSVersion.SSLv3 is deprecated:DeprecationWarning:regenmaschine.client", @@ -522,28 +523,16 @@ filterwarnings = [ "ignore:Deprecated call to `pkg_resources.declare_namespace\\(('azure'|'google.*'|'pywinusb'|'repoze'|'xbox'|'zope')\\)`:DeprecationWarning:pkg_resources", # -- tracked upstream / open PRs - # - pyOpenSSL v24.2.1 - # https://github.com/certbot/certbot/issues/9828 - v2.11.0 - # https://github.com/certbot/certbot/issues/9992 - "ignore:X509Extension support in pyOpenSSL is deprecated. You should use the APIs in cryptography:DeprecationWarning:acme.crypto_util", - "ignore:CSR support in pyOpenSSL is deprecated. You should use the APIs in cryptography:DeprecationWarning:acme.crypto_util", - "ignore:CSR support in pyOpenSSL is deprecated. You should use the APIs in cryptography:DeprecationWarning:josepy.util", - # - other - # https://github.com/foxel/python_ndms2_client/issues/6 - v0.1.3 - # https://github.com/foxel/python_ndms2_client/pull/8 - "ignore:'telnetlib' is deprecated and slated for removal in Python 3.13:DeprecationWarning:ndms2_client.connection", + # https://github.com/hacf-fr/meteofrance-api/pull/688 - v1.4.0 - 2025-03-26 + "ignore:datetime.*utcnow\\(\\) is deprecated and scheduled for removal:DeprecationWarning:meteofrance_api.model.forecast", # -- fixed, waiting for release / update - # https://github.com/bachya/aiopurpleair/pull/200 - >=2023.10.0 - "ignore:datetime.*utcfromtimestamp\\(\\) is deprecated and scheduled for removal:DeprecationWarning:aiopurpleair.helpers.validators", # https://bugs.launchpad.net/beautifulsoup/+bug/2076897 - >4.12.3 "ignore:The 'strip_cdata' option of HTMLParser\\(\\) has never done anything and will eventually be removed:DeprecationWarning:bs4.builder._lxml", # https://github.com/DataDog/datadogpy/pull/290 - >=0.23.0 "ignore:invalid escape sequence:SyntaxWarning:.*datadog.dogstatsd.base", # https://github.com/DataDog/datadogpy/pull/566/files - >=0.37.0 "ignore:pkg_resources is deprecated as an API:DeprecationWarning:datadog.util.compat", - # https://github.com/fwestenberg/devialet/pull/6 - >1.4.5 - "ignore:datetime.*utcnow\\(\\) is deprecated and scheduled for removal:DeprecationWarning:devialet.devialet_api", # https://github.com/httplib2/httplib2/pull/226 - >=0.21.0 "ignore:ssl.PROTOCOL_TLS is deprecated:DeprecationWarning:httplib2", # https://github.com/influxdata/influxdb-client-python/issues/603 >=1.45.0 @@ -551,27 +540,24 @@ filterwarnings = [ "ignore:datetime.*utcfromtimestamp\\(\\) is deprecated and scheduled for removal:DeprecationWarning:influxdb_client.client.write.point", # https://github.com/majuss/lupupy/pull/15 - >0.3.2 "ignore:\"is not\" with 'str' literal. Did you mean \"!=\"?:SyntaxWarning:.*lupupy.devices.alarm", - # https://github.com/nextcord/nextcord/pull/1095 - >2.6.1 + # https://github.com/nextcord/nextcord/pull/1095 - >=3.0.0 "ignore:pkg_resources is deprecated as an API:DeprecationWarning:nextcord.health_check", - # https://github.com/eclipse/paho.mqtt.python/issues/653 - >=2.0.0 - # https://github.com/eclipse/paho.mqtt.python/pull/665 - "ignore:ssl.PROTOCOL_TLS is deprecated:DeprecationWarning:paho.mqtt.client", # https://github.com/vacanza/python-holidays/discussions/1800 - >1.0.0 "ignore::DeprecationWarning:holidays", # https://github.com/rytilahti/python-miio/pull/1809 - >=0.6.0.dev0 "ignore:datetime.*utcnow\\(\\) is deprecated and scheduled for removal:DeprecationWarning:miio.protocol", "ignore:datetime.*utcnow\\(\\) is deprecated and scheduled for removal:DeprecationWarning:miio.miioprotocol", + # https://github.com/rytilahti/python-miio/pull/1993 - >0.6.0.dev0 + "ignore:functools.partial will be a method descriptor in future Python versions; wrap it in enum.member\\(\\) if you want to preserve the old behavior:FutureWarning:miio.miot_device", # https://github.com/okunishinishi/python-stringcase/commit/6a5c5bbd3fe5337862abc7fd0853a0f36e18b2e1 - >1.2.0 "ignore:invalid escape sequence:SyntaxWarning:.*stringcase", - # -- fixed for Python 3.13 - # https://github.com/rhasspy/wyoming/commit/e34af30d455b6f2bb9e5cfb25fad8d276914bc54 - >=1.4.2 - "ignore:'audioop' is deprecated and slated for removal in Python 3.13:DeprecationWarning:wyoming.audio", - # -- other # Locale changes might take some time to resolve upstream # https://github.com/Squachen/micloud/blob/v_0.6/micloud/micloud.py#L35 - v0.6 - 2022-12-08 "ignore:'locale.getdefaultlocale' is deprecated and slated for removal in Python 3.15:DeprecationWarning:micloud.micloud", + # https://pypi.org/project/agent-py/ - v0.0.24 - 2024-11-07 + "ignore:with timeout\\(\\) is deprecated:DeprecationWarning:agent.a", # https://github.com/MatsNl/pyatag/issues/11 - v0.3.7.1 - 2023-10-09 "ignore:datetime.*utcnow\\(\\) is deprecated and scheduled for removal:DeprecationWarning:pyatag.gateway", # https://github.com/lidatong/dataclasses-json/issues/328 @@ -580,15 +566,22 @@ filterwarnings = [ # https://pypi.org/project/emulated-roku/ - v0.3.0 - 2023-12-19 # https://github.com/martonperei/emulated_roku "ignore:loop argument is deprecated:DeprecationWarning:emulated_roku", - # https://github.com/w1ll1am23/pyeconet/blob/v0.1.23/src/pyeconet/api.py#L38 - v0.1.23 - 2024-10-08 + # https://pypi.org/project/foobot_async/ - v1.0.1 - 2024-08-16 + "ignore:with timeout\\(\\) is deprecated:DeprecationWarning:foobot_async", + # https://pypi.org/project/pyeconet/ - v0.1.28 - 2025-02-15 + # https://github.com/w1ll1am23/pyeconet/blob/v0.1.28/src/pyeconet/api.py#L38 "ignore:ssl.PROTOCOL_TLS is deprecated:DeprecationWarning:pyeconet.api", # https://github.com/thecynic/pylutron - v0.2.16 - 2024-10-22 "ignore:setDaemon\\(\\) is deprecated, set the daemon attribute instead:DeprecationWarning:pylutron", + # https://pypi.org/project/PyMetEireann/ - v2024.11.0 - 2024-11-23 + "ignore:datetime.*utcnow\\(\\) is deprecated and scheduled for removal:DeprecationWarning:meteireann", # https://github.com/pschmitt/pynuki/blob/1.6.3/pynuki/utils.py#L21 - v1.6.3 - 2024-02-24 "ignore:datetime.*utcnow\\(\\) is deprecated and scheduled for removal:DeprecationWarning:pynuki.utils", - # https://github.com/lextudio/pysnmp/blob/v7.1.10/pysnmp/smi/compiler.py#L23-L31 - v7.1.10 - 2024-11-04 + # https://github.com/lextudio/pysnmp/blob/v7.1.17/pysnmp/smi/compiler.py#L23-L31 - v7.1.17 - 2025-03-19 "ignore:smiV1Relaxed is deprecated. Please use smi_v1_relaxed instead:DeprecationWarning:pysnmp.smi.compiler", - "ignore:getReadersFromUrls is deprecated. Please use get_readers_from_urls instead:DeprecationWarning:pysmi.reader.url", # wrong stacklevel + "ignore:getReadersFromUrls is deprecated. Please use get_readers_from_urls instead:DeprecationWarning:pysnmp.smi.compiler", + # https://github.com/Python-roborock/python-roborock/issues/305 - 2.18.0 - 2025-04-06 + "ignore:Callback API version 1 is deprecated, update to latest version:DeprecationWarning:roborock.cloud_api", # https://github.com/briis/pyweatherflowudp/blob/v1.4.5/pyweatherflowudp/const.py#L20 - v1.4.5 - 2023-10-10 "ignore:This function will be removed in future versions of pint:DeprecationWarning:pyweatherflowudp.const", # Wrong stacklevel @@ -620,35 +613,11 @@ filterwarnings = [ "ignore:pkg_resources is deprecated as an API:DeprecationWarning:habitipy.api", # https://github.com/eavanvalkenburg/pysiaalarm/blob/v3.1.1/src/pysiaalarm/data/data.py#L7 - v3.1.1 - 2023-04-17 "ignore:pkg_resources is deprecated as an API:DeprecationWarning:pysiaalarm.data.data", - # https://pypi.org/project/pybotvac/ - v0.0.25 - 2024-04-11 + # https://pypi.org/project/pybotvac/ - v0.0.26 - 2025-02-26 "ignore:pkg_resources is deprecated as an API:DeprecationWarning:pybotvac.version", # https://github.com/home-assistant-ecosystem/python-mystrom/blob/2.2.0/pymystrom/__init__.py#L10 - v2.2.0 - 2023-05-21 "ignore:pkg_resources is deprecated as an API:DeprecationWarning:pymystrom", - # -- Python 3.13 - # HomeAssistant - "ignore:'audioop' is deprecated and slated for removal in Python 3.13:DeprecationWarning:homeassistant.components.assist_pipeline.websocket_api", - "ignore:'telnetlib' is deprecated and slated for removal in Python 3.13:DeprecationWarning:homeassistant.components.hddtemp.sensor", - # https://pypi.org/project/nextcord/ - v2.6.0 - 2023-09-23 - # https://github.com/nextcord/nextcord/issues/1174 - # https://github.com/nextcord/nextcord/blob/v2.6.1/nextcord/player.py#L5 - "ignore:'audioop' is deprecated and slated for removal in Python 3.13:DeprecationWarning:nextcord.player", - # https://pypi.org/project/SpeechRecognition/ - v3.11.0 - 2024-05-05 - # https://github.com/Uberi/speech_recognition/blob/3.11.0/speech_recognition/__init__.py#L7 - "ignore:'aifc' is deprecated and slated for removal in Python 3.13:DeprecationWarning:speech_recognition", - # https://pypi.org/project/voip-utils/ - v0.2.0 - 2024-09-06 - # https://github.com/home-assistant-libs/voip-utils/blob/0.2.0/voip_utils/rtp_audio.py#L3 - "ignore:'audioop' is deprecated and slated for removal in Python 3.13:DeprecationWarning:voip_utils.rtp_audio", - - # -- Python 3.13 - unmaintained projects, last release about 2+ years - # https://pypi.org/project/pydub/ - v0.25.1 - 2021-03-10 - "ignore:'audioop' is deprecated and slated for removal in Python 3.13:DeprecationWarning:pydub.utils", - # https://github.com/heathbar/plum-lightpad-python/issues/7 - v0.0.11 - 2018-10-16 - "ignore:'telnetlib' is deprecated and slated for removal in Python 3.13:DeprecationWarning:plumlightpad.lightpad", - # https://pypi.org/project/pyws66i/ - v1.1 - 2022-04-05 - # https://github.com/ssaenger/pyws66i/blob/v1.1/pyws66i/__init__.py#L2 - "ignore:'telnetlib' is deprecated and slated for removal in Python 3.13:DeprecationWarning:pyws66i", - # -- New in Python 3.13 # https://github.com/kurtmckee/feedparser/pull/389 - >6.0.11 # https://github.com/kurtmckee/feedparser/issues/481 @@ -660,18 +629,22 @@ filterwarnings = [ "ignore:telnetlib was removed in Python 3.13.*'standard-telnetlib':DeprecationWarning:plumlightpad.lightpad", "ignore:telnetlib was removed in Python 3.13.*'standard-telnetlib':DeprecationWarning:pyws66i", + # -- Websockets 14.1 + # https://websockets.readthedocs.io/en/stable/howto/upgrade.html + "ignore:websockets.legacy is deprecated:DeprecationWarning:websockets.legacy", + # https://github.com/bluecurrent/HomeAssistantAPI + "ignore:websockets.client.connect is deprecated:DeprecationWarning:bluecurrent_api.websocket", + "ignore:websockets.client.WebSocketClientProtocol is deprecated:DeprecationWarning:bluecurrent_api.websocket", + "ignore:websockets.exceptions.InvalidStatusCode is deprecated:DeprecationWarning:bluecurrent_api.websocket", + # https://github.com/graphql-python/gql + "ignore:websockets.client.WebSocketClientProtocol is deprecated:DeprecationWarning:gql.transport.websockets_base", + # -- unmaintained projects, last release about 2+ years - # https://pypi.org/project/agent-py/ - v0.0.23 - 2020-06-04 - "ignore:with timeout\\(\\) is deprecated:DeprecationWarning:agent.a", # https://pypi.org/project/aiomodernforms/ - v0.1.8 - 2021-06-27 "ignore:with timeout\\(\\) is deprecated:DeprecationWarning:aiomodernforms.modernforms", - # https://pypi.org/project/alarmdecoder/ - v1.13.11 - 2021-06-01 - "ignore:invalid escape sequence:SyntaxWarning:.*alarmdecoder", # https://pypi.org/project/directv/ - v0.4.0 - 2020-09-12 "ignore:with timeout\\(\\) is deprecated:DeprecationWarning:directv.directv", "ignore:datetime.*utcnow\\(\\) is deprecated and scheduled for removal:DeprecationWarning:directv.models", - # https://pypi.org/project/foobot_async/ - v1.0.1 - 2024-08-16 - "ignore:with timeout\\(\\) is deprecated:DeprecationWarning:foobot_async", # https://pypi.org/project/httpsig/ - v1.3.0 - 2018-11-28 "ignore:pkg_resources is deprecated as an API:DeprecationWarning:httpsig", # https://pypi.org/project/influxdb/ - v5.3.2 - 2024-04-18 (archived) @@ -688,8 +661,6 @@ filterwarnings = [ "ignore:datetime.*utcnow\\(\\) is deprecated and scheduled for removal:DeprecationWarning:oauth2client.client", # https://pypi.org/project/opuslib/ - v3.0.1 - 2018-01-16 "ignore:\"is not\" with 'int' literal. Did you mean \"!=\"?:SyntaxWarning:.*opuslib.api.decoder", - # https://pypi.org/project/passlib/ - v1.7.4 - 2020-10-08 - "ignore:'crypt' is deprecated and slated for removal in Python 3.13:DeprecationWarning:passlib.utils", # https://pypi.org/project/pilight/ - v0.1.1 - 2016-10-19 "ignore:pkg_resources is deprecated as an API:DeprecationWarning:pilight", # https://pypi.org/project/plumlightpad/ - v0.0.11 - 2018-10-16 @@ -701,8 +672,6 @@ filterwarnings = [ "ignore:invalid escape sequence:SyntaxWarning:.*pydub.utils", # https://pypi.org/project/pyiss/ - v1.0.1 - 2016-12-19 "ignore:\"is\" with 'int' literal. Did you mean \"==\"?:SyntaxWarning:.*pyiss", - # https://pypi.org/project/PyMetEireann/ - v2021.8.0 - 2021-08-16 - "ignore:datetime.*utcnow\\(\\) is deprecated and scheduled for removal:DeprecationWarning:meteireann", # https://pypi.org/project/PyPasser/ - v0.0.5 - 2021-10-21 "ignore:invalid escape sequence:SyntaxWarning:.*pypasser.utils", # https://pypi.org/project/pyqwikswitch/ - v0.94 - 2019-08-19 From 3489ea30ddd7ece04553d40e874e81b67fc8bffa Mon Sep 17 00:00:00 2001 From: Dionisis Toulatos <47108480+dionisis2014@users.noreply.github.com> Date: Sat, 12 Apr 2025 20:30:06 +0300 Subject: [PATCH 491/505] Fix MQTT device discovery when using node_id (#142784) * Fix device discovery when using node_id * tests --------- Co-authored-by: jbouwh Co-authored-by: Jan Bouwhuis --- homeassistant/components/mqtt/discovery.py | 2 +- tests/components/mqtt/test_discovery.py | 172 ++++++++++++++++++++- 2 files changed, 166 insertions(+), 8 deletions(-) diff --git a/homeassistant/components/mqtt/discovery.py b/homeassistant/components/mqtt/discovery.py index a527e712615..4ebdbbb6236 100644 --- a/homeassistant/components/mqtt/discovery.py +++ b/homeassistant/components/mqtt/discovery.py @@ -254,7 +254,7 @@ def _generate_device_config( comp_config = config[CONF_COMPONENTS] for platform, discover_id in mqtt_data.discovery_already_discovered: ids = discover_id.split(" ") - component_node_id = ids.pop(0) + component_node_id = f"{ids.pop(1)} {ids.pop(0)}" if len(ids) > 2 else ids.pop(0) component_object_id = " ".join(ids) if not ids: continue diff --git a/tests/components/mqtt/test_discovery.py b/tests/components/mqtt/test_discovery.py index ee33cbcbaa1..ee559ef4235 100644 --- a/tests/components/mqtt/test_discovery.py +++ b/tests/components/mqtt/test_discovery.py @@ -388,23 +388,181 @@ async def test_only_valid_components( assert not mock_dispatcher_send.called -async def test_correct_config_discovery( - hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator +@pytest.mark.parametrize( + ("discovery_topic", "discovery_hash"), + [ + ("homeassistant/binary_sensor/bla/config", ("binary_sensor", "bla")), + ("homeassistant/binary_sensor/node/bla/config", ("binary_sensor", "node bla")), + ], + ids=["without_node", "with_node"], +) +async def test_correct_config_discovery_component( + hass: HomeAssistant, + mqtt_mock_entry: MqttMockHAClientGenerator, + device_registry: dr.DeviceRegistry, + discovery_topic: str, + discovery_hash: tuple[str, str], ) -> None: """Test sending in correct JSON.""" await mqtt_mock_entry() + config_init = { + "name": "Beer", + "state_topic": "test-topic", + "unique_id": "bla001", + "device": {"identifiers": "0AFFD2", "name": "test_device1"}, + "o": {"name": "foobar"}, + } async_fire_mqtt_message( hass, - "homeassistant/binary_sensor/bla/config", - '{ "name": "Beer", "state_topic": "test-topic" }', + discovery_topic, + json.dumps(config_init), ) await hass.async_block_till_done() - state = hass.states.get("binary_sensor.beer") + state = hass.states.get("binary_sensor.test_device1_beer") assert state is not None - assert state.name == "Beer" - assert ("binary_sensor", "bla") in hass.data["mqtt"].discovery_already_discovered + assert state.name == "test_device1 Beer" + assert discovery_hash in hass.data["mqtt"].discovery_already_discovered + + device_entry = device_registry.async_get_device(identifiers={("mqtt", "0AFFD2")}) + assert device_entry is not None + assert device_entry.name == "test_device1" + + # Update the device and component + config_update = { + "name": "Milk", + "state_topic": "test-topic", + "unique_id": "bla001", + "device": {"identifiers": "0AFFD2", "name": "test_device2"}, + "o": {"name": "foobar"}, + } + async_fire_mqtt_message( + hass, + discovery_topic, + json.dumps(config_update), + ) + await hass.async_block_till_done() + + state = hass.states.get("binary_sensor.test_device1_beer") + + assert state is not None + assert state.name == "test_device2 Milk" + assert discovery_hash in hass.data["mqtt"].discovery_already_discovered + + device_entry = device_registry.async_get_device(identifiers={("mqtt", "0AFFD2")}) + assert device_entry is not None + assert device_entry.name == "test_device2" + + # Remove the device and component + async_fire_mqtt_message( + hass, + discovery_topic, + "", + ) + await hass.async_block_till_done() + + state = hass.states.get("binary_sensor.test_device1_beer") + + assert state is None + + device_entry = device_registry.async_get_device(identifiers={("mqtt", "0AFFD2")}) + assert device_entry is None + + +@pytest.mark.parametrize( + ("discovery_topic", "discovery_hash"), + [ + ("homeassistant/device/some_id/config", ("binary_sensor", "some_id bla")), + ( + "homeassistant/device/node_id/some_id/config", + ("binary_sensor", "some_id node_id bla"), + ), + ], + ids=["without_node", "with_node"], +) +async def test_correct_config_discovery_device( + hass: HomeAssistant, + mqtt_mock_entry: MqttMockHAClientGenerator, + device_registry: dr.DeviceRegistry, + discovery_topic: str, + discovery_hash: tuple[str, str], +) -> None: + """Test sending in correct JSON.""" + await mqtt_mock_entry() + config_init = { + "cmps": { + "bla": { + "platform": "binary_sensor", + "name": "Beer", + "state_topic": "test-topic", + "unique_id": "bla001", + }, + }, + "device": {"identifiers": "0AFFD2", "name": "test_device1"}, + "o": {"name": "foobar"}, + } + async_fire_mqtt_message( + hass, + discovery_topic, + json.dumps(config_init), + ) + await hass.async_block_till_done() + + state = hass.states.get("binary_sensor.test_device1_beer") + + assert state is not None + assert state.name == "test_device1 Beer" + assert discovery_hash in hass.data["mqtt"].discovery_already_discovered + + device_entry = device_registry.async_get_device(identifiers={("mqtt", "0AFFD2")}) + assert device_entry is not None + assert device_entry.name == "test_device1" + + # Update the device and component + config_update = { + "cmps": { + "bla": { + "platform": "binary_sensor", + "name": "Milk", + "state_topic": "test-topic", + "unique_id": "bla001", + }, + }, + "device": {"identifiers": "0AFFD2", "name": "test_device2"}, + "o": {"name": "foobar"}, + } + async_fire_mqtt_message( + hass, + discovery_topic, + json.dumps(config_update), + ) + await hass.async_block_till_done() + + state = hass.states.get("binary_sensor.test_device1_beer") + + assert state is not None + assert state.name == "test_device2 Milk" + assert discovery_hash in hass.data["mqtt"].discovery_already_discovered + + device_entry = device_registry.async_get_device(identifiers={("mqtt", "0AFFD2")}) + assert device_entry is not None + assert device_entry.name == "test_device2" + + # Remove the device and component + async_fire_mqtt_message( + hass, + discovery_topic, + "", + ) + await hass.async_block_till_done() + + state = hass.states.get("binary_sensor.test_device1_beer") + + assert state is None + + device_entry = device_registry.async_get_device(identifiers={("mqtt", "0AFFD2")}) + assert device_entry is None @pytest.mark.parametrize( From f13bdd0da43be09ebfec90304c019440254d268e Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 12 Apr 2025 07:47:02 -1000 Subject: [PATCH 492/505] Add support for passing though `description_placeholders` to `_abort_if_unique_id_configured` (#142779) --- homeassistant/config_entries.py | 3 ++- tests/test_config_entries.py | 11 +++++++++-- 2 files changed, 11 insertions(+), 3 deletions(-) diff --git a/homeassistant/config_entries.py b/homeassistant/config_entries.py index 3064fdd54bb..c58a33ad68d 100644 --- a/homeassistant/config_entries.py +++ b/homeassistant/config_entries.py @@ -2913,6 +2913,7 @@ class ConfigFlow(ConfigEntryBaseFlow): reload_on_update: bool = True, *, error: str = "already_configured", + description_placeholders: Mapping[str, str] | None = None, ) -> None: """Abort if the unique ID is already configured. @@ -2953,7 +2954,7 @@ class ConfigFlow(ConfigEntryBaseFlow): return if should_reload: self.hass.config_entries.async_schedule_reload(entry.entry_id) - raise data_entry_flow.AbortFlow(error) + raise data_entry_flow.AbortFlow(error, description_placeholders) async def async_set_unique_id( self, unique_id: str | None = None, *, raise_on_progress: bool = True diff --git a/tests/test_config_entries.py b/tests/test_config_entries.py index 13ecd855624..ba599c88518 100644 --- a/tests/test_config_entries.py +++ b/tests/test_config_entries.py @@ -3259,7 +3259,9 @@ async def test_unique_id_update_existing_entry_without_reload( """Test user step.""" await self.async_set_unique_id("mock-unique-id") self._abort_if_unique_id_configured( - updates={"host": "1.1.1.1"}, reload_on_update=False + updates={"host": "1.1.1.1"}, + reload_on_update=False, + description_placeholders={"title": "Other device"}, ) with ( @@ -3275,6 +3277,7 @@ async def test_unique_id_update_existing_entry_without_reload( assert result["type"] == FlowResultType.ABORT assert result["reason"] == "already_configured" + assert result["description_placeholders"]["title"] == "Other device" assert entry.data["host"] == "1.1.1.1" assert entry.data["additional"] == "data" assert len(async_reload.mock_calls) == 0 @@ -3309,7 +3312,9 @@ async def test_unique_id_update_existing_entry_with_reload( """Test user step.""" await self.async_set_unique_id("mock-unique-id") await self._abort_if_unique_id_configured( - updates=updates, reload_on_update=True + updates=updates, + reload_on_update=True, + description_placeholders={"title": "Other device"}, ) with ( @@ -3325,6 +3330,7 @@ async def test_unique_id_update_existing_entry_with_reload( assert result["type"] == FlowResultType.ABORT assert result["reason"] == "already_configured" + assert result["description_placeholders"]["title"] == "Other device" assert entry.data["host"] == "1.1.1.1" assert entry.data["additional"] == "data" assert len(async_reload.mock_calls) == 1 @@ -3345,6 +3351,7 @@ async def test_unique_id_update_existing_entry_with_reload( assert result["type"] == FlowResultType.ABORT assert result["reason"] == "already_configured" + assert result["description_placeholders"]["title"] == "Other device" assert entry.data["host"] == "2.2.2.2" assert entry.data["additional"] == "data" assert len(async_reload.mock_calls) == 0 From 06d61558627f7454f489bb158b498bed158daf6b Mon Sep 17 00:00:00 2001 From: Ernst Klamer Date: Sat, 12 Apr 2025 20:18:26 +0200 Subject: [PATCH 493/505] add support for quadruple button events for xiaomi-ble (#142760) * bump xiaomi-ble to 0.36.0 * fix ruff * fix ruff * revert dependency bump --- homeassistant/components/xiaomi_ble/const.py | 2 ++ .../components/xiaomi_ble/device_trigger.py | 15 +++++++++++++++ homeassistant/components/xiaomi_ble/strings.json | 2 ++ 3 files changed, 19 insertions(+) diff --git a/homeassistant/components/xiaomi_ble/const.py b/homeassistant/components/xiaomi_ble/const.py index 8ea99cf1f84..aab443c67fa 100644 --- a/homeassistant/components/xiaomi_ble/const.py +++ b/homeassistant/components/xiaomi_ble/const.py @@ -37,6 +37,7 @@ LOCK_FINGERPRINT = "lock_fingerprint" MOTION_DEVICE: Final = "motion_device" DOUBLE_BUTTON: Final = "double_button" TRIPPLE_BUTTON: Final = "tripple_button" +QUADRUPLE_BUTTON: Final = "quadruple_button" REMOTE: Final = "remote" REMOTE_FAN: Final = "remote_fan" REMOTE_VENFAN: Final = "remote_ventilator_fan" @@ -48,6 +49,7 @@ BUTTON_PRESS_LONG: Final = "button_press_long" BUTTON_PRESS_DOUBLE_LONG: Final = "button_press_double_long" DOUBLE_BUTTON_PRESS_DOUBLE_LONG: Final = "double_button_press_double_long" TRIPPLE_BUTTON_PRESS_DOUBLE_LONG: Final = "tripple_button_press_double_long" +QUADRUPLE_BUTTON_PRESS_DOUBLE_LONG: Final = "quadruple_button_press_double_long" class XiaomiBleEvent(TypedDict): diff --git a/homeassistant/components/xiaomi_ble/device_trigger.py b/homeassistant/components/xiaomi_ble/device_trigger.py index 119424788db..3c5488a1e74 100644 --- a/homeassistant/components/xiaomi_ble/device_trigger.py +++ b/homeassistant/components/xiaomi_ble/device_trigger.py @@ -47,6 +47,8 @@ from .const import ( LOCK_FINGERPRINT, MOTION, MOTION_DEVICE, + QUADRUPLE_BUTTON, + QUADRUPLE_BUTTON_PRESS_DOUBLE_LONG, REMOTE, REMOTE_BATHROOM, REMOTE_FAN, @@ -123,6 +125,12 @@ EVENT_TYPES = { DIMMER: ["dimmer"], DOUBLE_BUTTON: ["button_left", "button_right"], TRIPPLE_BUTTON: ["button_left", "button_middle", "button_right"], + QUADRUPLE_BUTTON: [ + "button_left", + "button_mid_left", + "button_mid_right", + "button_right", + ], ERROR: ["error"], FINGERPRINT: ["fingerprint"], LOCK: ["lock"], @@ -205,6 +213,11 @@ TRIGGER_MODEL_DATA = { event_types=EVENT_TYPES[TRIPPLE_BUTTON], triggers=TRIGGERS_BY_TYPE[BUTTON_PRESS_DOUBLE_LONG], ), + QUADRUPLE_BUTTON_PRESS_DOUBLE_LONG: TriggerModelData( + event_class=EVENT_CLASS_BUTTON, + event_types=EVENT_TYPES[QUADRUPLE_BUTTON], + triggers=TRIGGERS_BY_TYPE[BUTTON_PRESS_DOUBLE_LONG], + ), ERROR: TriggerModelData( event_class=EVENT_CLASS_ERROR, event_types=EVENT_TYPES[ERROR], @@ -261,6 +274,8 @@ MODEL_DATA = { "XMWXKG01YL": TRIGGER_MODEL_DATA[DOUBLE_BUTTON_PRESS_DOUBLE_LONG], "K9B-2BTN": TRIGGER_MODEL_DATA[DOUBLE_BUTTON_PRESS_DOUBLE_LONG], "K9B-3BTN": TRIGGER_MODEL_DATA[TRIPPLE_BUTTON_PRESS_DOUBLE_LONG], + "KS1": TRIGGER_MODEL_DATA[QUADRUPLE_BUTTON_PRESS_DOUBLE_LONG], + "KS1BP": TRIGGER_MODEL_DATA[QUADRUPLE_BUTTON_PRESS_DOUBLE_LONG], "YLYK01YL": TRIGGER_MODEL_DATA[REMOTE], "YLYK01YL-FANRC": TRIGGER_MODEL_DATA[REMOTE_FAN], "YLYK01YL-VENFAN": TRIGGER_MODEL_DATA[REMOTE_VENFAN], diff --git a/homeassistant/components/xiaomi_ble/strings.json b/homeassistant/components/xiaomi_ble/strings.json index cdee3fc3838..06b49b8e86f 100644 --- a/homeassistant/components/xiaomi_ble/strings.json +++ b/homeassistant/components/xiaomi_ble/strings.json @@ -86,6 +86,8 @@ "trigger_type": { "button": "Button \"{subtype}\"", "button_left": "Button Left \"{subtype}\"", + "button_mid_left": "Button Mid Left \"{subtype}\"", + "button_mid_right": "Button Mid Right \"{subtype}\"", "button_middle": "Button Middle \"{subtype}\"", "button_right": "Button Right \"{subtype}\"", "button_on": "Button On \"{subtype}\"", From 6feb9d4b4e1645c60f549d6129239e86538bdf5e Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Sat, 12 Apr 2025 20:19:49 +0200 Subject: [PATCH 494/505] Add entity translations to Syncthru (#142774) * Add entity translations to Syncthru * Add entity translations to Syncthru * Fix --- .../components/syncthru/binary_sensor.py | 1 - homeassistant/components/syncthru/entity.py | 2 + homeassistant/components/syncthru/sensor.py | 21 +- .../components/syncthru/strings.json | 44 ++ .../snapshots/test_binary_sensor.ambr | 28 +- .../syncthru/snapshots/test_sensor.ambr | 395 +++++++++--------- 6 files changed, 272 insertions(+), 219 deletions(-) diff --git a/homeassistant/components/syncthru/binary_sensor.py b/homeassistant/components/syncthru/binary_sensor.py index 72157b9a22d..f68f33043ee 100644 --- a/homeassistant/components/syncthru/binary_sensor.py +++ b/homeassistant/components/syncthru/binary_sensor.py @@ -87,7 +87,6 @@ class SyncThruBinarySensor(SyncthruEntity, BinarySensorEntity): serial_number = coordinator.data.serial_number() assert serial_number is not None self._attr_unique_id = f"{serial_number}_{entity_description.key}" - self._attr_name = name @property def is_on(self) -> bool | None: diff --git a/homeassistant/components/syncthru/entity.py b/homeassistant/components/syncthru/entity.py index fa3fbb0f2f4..0b5e6324953 100644 --- a/homeassistant/components/syncthru/entity.py +++ b/homeassistant/components/syncthru/entity.py @@ -10,6 +10,8 @@ from . import DOMAIN, SyncthruCoordinator class SyncthruEntity(CoordinatorEntity[SyncthruCoordinator]): """Base class for Syncthru entities.""" + _attr_has_entity_name = True + def __init__(self, coordinator: SyncthruCoordinator) -> None: """Initialize the Syncthru entity.""" super().__init__(coordinator) diff --git a/homeassistant/components/syncthru/sensor.py b/homeassistant/components/syncthru/sensor.py index 022d48b463f..77d1123e773 100644 --- a/homeassistant/components/syncthru/sensor.py +++ b/homeassistant/components/syncthru/sensor.py @@ -41,7 +41,7 @@ def get_toner_entity_description(color: str) -> SyncThruSensorDescription: """Get toner entity description for a specific color.""" return SyncThruSensorDescription( key=f"toner_{color}", - name=f"Toner {color}", + translation_key=f"toner_{color}", native_unit_of_measurement=PERCENTAGE, value_fn=lambda printer: printer.toner_status().get(color, {}).get("remaining"), extra_state_attributes_fn=lambda printer: printer.toner_status().get(color, {}), @@ -52,7 +52,7 @@ def get_drum_entity_description(color: str) -> SyncThruSensorDescription: """Get drum entity description for a specific color.""" return SyncThruSensorDescription( key=f"drum_{color}", - name=f"Drum {color}", + translation_key=f"drum_{color}", native_unit_of_measurement=PERCENTAGE, value_fn=lambda printer: printer.drum_status().get(color, {}).get("remaining"), extra_state_attributes_fn=lambda printer: printer.drum_status().get(color, {}), @@ -61,9 +61,16 @@ def get_drum_entity_description(color: str) -> SyncThruSensorDescription: def get_input_tray_entity_description(tray: str) -> SyncThruSensorDescription: """Get input tray entity description for a specific tray.""" + placeholders = {} + translation_key = f"tray_{tray}" + if "_" in tray: + _, identifier = tray.split("_") + placeholders["tray_number"] = identifier + translation_key = "tray" return SyncThruSensorDescription( key=f"tray_{tray}", - name=f"Tray {tray}", + translation_key=translation_key, + translation_placeholders=placeholders, value_fn=( lambda printer: printer.input_tray_status().get(tray, {}).get("newError") or "Ready" @@ -78,7 +85,8 @@ def get_output_tray_entity_description(tray: int) -> SyncThruSensorDescription: """Get output tray entity description for a specific tray.""" return SyncThruSensorDescription( key=f"output_tray_{tray}", - name=f"Output Tray {tray}", + translation_key="output_tray", + translation_placeholders={"tray_number": str(tray)}, value_fn=( lambda printer: printer.output_tray_status().get(tray, {}).get("status") or "Ready" @@ -94,12 +102,12 @@ def get_output_tray_entity_description(tray: int) -> SyncThruSensorDescription: SENSOR_TYPES: tuple[SyncThruSensorDescription, ...] = ( SyncThruSensorDescription( key="active_alerts", - name="Active Alerts", + translation_key="active_alerts", value_fn=lambda printer: printer.raw().get("GXI_ACTIVE_ALERT_TOTAL"), ), SyncThruSensorDescription( key="main", - name="", + name=None, value_fn=lambda printer: SYNCTHRU_STATE_HUMAN[printer.device_status()], extra_state_attributes_fn=lambda printer: { "display_text": printer.device_status_details(), @@ -153,7 +161,6 @@ class SyncThruSensor(SyncthruEntity, SensorEntity): super().__init__(coordinator) self.entity_description = entity_description self.syncthru = coordinator.data - self._attr_name = f"{name} {entity_description.name}".strip() serial_number = coordinator.data.serial_number() assert serial_number is not None self._attr_unique_id = f"{serial_number}_{entity_description.key}" diff --git a/homeassistant/components/syncthru/strings.json b/homeassistant/components/syncthru/strings.json index c4087bdee04..d78d51db86d 100644 --- a/homeassistant/components/syncthru/strings.json +++ b/homeassistant/components/syncthru/strings.json @@ -23,5 +23,49 @@ } } } + }, + "entity": { + "sensor": { + "toner_black": { + "name": "Black toner level" + }, + "toner_cyan": { + "name": "Cyan toner level" + }, + "toner_magenta": { + "name": "Magenta toner level" + }, + "toner_yellow": { + "name": "Yellow toner level" + }, + "drum_black": { + "name": "Black drum level" + }, + "drum_cyan": { + "name": "Cyan drum level" + }, + "drum_magenta": { + "name": "Magenta drum level" + }, + "drum_yellow": { + "name": "Yellow drum level" + }, + "tray_mp": { + "name": "Multi-purpose tray" + }, + "tray_manual": { + "name": "Manual feed tray" + }, + "tray": { + "name": "Input tray {tray_number}" + }, + "output_tray": { + "name": "Output tray {tray_number}" + }, + "active_alerts": { + "name": "Active alerts", + "unit_of_measurement": "alerts" + } + } } } diff --git a/tests/components/syncthru/snapshots/test_binary_sensor.ambr b/tests/components/syncthru/snapshots/test_binary_sensor.ambr index 82b62394a63..4f8809fd984 100644 --- a/tests/components/syncthru/snapshots/test_binary_sensor.ambr +++ b/tests/components/syncthru/snapshots/test_binary_sensor.ambr @@ -1,5 +1,5 @@ # serializer version: 1 -# name: test_all_entities[binary_sensor.my_printer-entry] +# name: test_all_entities[binary_sensor.sec84251907c415_connectivity-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -12,8 +12,8 @@ 'disabled_by': None, 'domain': 'binary_sensor', 'entity_category': None, - 'entity_id': 'binary_sensor.my_printer', - 'has_entity_name': False, + 'entity_id': 'binary_sensor.sec84251907c415_connectivity', + 'has_entity_name': True, 'hidden_by': None, 'icon': None, 'id': , @@ -24,7 +24,7 @@ }), 'original_device_class': , 'original_icon': None, - 'original_name': 'My Printer', + 'original_name': 'Connectivity', 'platform': 'syncthru', 'previous_unique_id': None, 'supported_features': 0, @@ -33,21 +33,21 @@ 'unit_of_measurement': None, }) # --- -# name: test_all_entities[binary_sensor.my_printer-state] +# name: test_all_entities[binary_sensor.sec84251907c415_connectivity-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'connectivity', - 'friendly_name': 'My Printer', + 'friendly_name': 'SEC84251907C415 Connectivity', }), 'context': , - 'entity_id': 'binary_sensor.my_printer', + 'entity_id': 'binary_sensor.sec84251907c415_connectivity', 'last_changed': , 'last_reported': , 'last_updated': , 'state': 'on', }) # --- -# name: test_all_entities[binary_sensor.my_printer_2-entry] +# name: test_all_entities[binary_sensor.sec84251907c415_problem-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -60,8 +60,8 @@ 'disabled_by': None, 'domain': 'binary_sensor', 'entity_category': None, - 'entity_id': 'binary_sensor.my_printer_2', - 'has_entity_name': False, + 'entity_id': 'binary_sensor.sec84251907c415_problem', + 'has_entity_name': True, 'hidden_by': None, 'icon': None, 'id': , @@ -72,7 +72,7 @@ }), 'original_device_class': , 'original_icon': None, - 'original_name': 'My Printer', + 'original_name': 'Problem', 'platform': 'syncthru', 'previous_unique_id': None, 'supported_features': 0, @@ -81,14 +81,14 @@ 'unit_of_measurement': None, }) # --- -# name: test_all_entities[binary_sensor.my_printer_2-state] +# name: test_all_entities[binary_sensor.sec84251907c415_problem-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'problem', - 'friendly_name': 'My Printer', + 'friendly_name': 'SEC84251907C415 Problem', }), 'context': , - 'entity_id': 'binary_sensor.my_printer_2', + 'entity_id': 'binary_sensor.sec84251907c415_problem', 'last_changed': , 'last_reported': , 'last_updated': , diff --git a/tests/components/syncthru/snapshots/test_sensor.ambr b/tests/components/syncthru/snapshots/test_sensor.ambr index 50d892b5343..87e96a5cc53 100644 --- a/tests/components/syncthru/snapshots/test_sensor.ambr +++ b/tests/components/syncthru/snapshots/test_sensor.ambr @@ -1,5 +1,5 @@ # serializer version: 1 -# name: test_all_entities[sensor.my_printer-entry] +# name: test_all_entities[sensor.sec84251907c415-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -12,8 +12,8 @@ 'disabled_by': None, 'domain': 'sensor', 'entity_category': None, - 'entity_id': 'sensor.my_printer', - 'has_entity_name': False, + 'entity_id': 'sensor.sec84251907c415', + 'has_entity_name': True, 'hidden_by': None, 'icon': None, 'id': , @@ -24,7 +24,7 @@ }), 'original_device_class': None, 'original_icon': 'mdi:printer', - 'original_name': 'My Printer', + 'original_name': None, 'platform': 'syncthru', 'previous_unique_id': None, 'supported_features': 0, @@ -33,22 +33,22 @@ 'unit_of_measurement': None, }) # --- -# name: test_all_entities[sensor.my_printer-state] +# name: test_all_entities[sensor.sec84251907c415-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'display_text': '', - 'friendly_name': 'My Printer', + 'friendly_name': 'SEC84251907C415', 'icon': 'mdi:printer', }), 'context': , - 'entity_id': 'sensor.my_printer', + 'entity_id': 'sensor.sec84251907c415', 'last_changed': , 'last_reported': , 'last_updated': , 'state': 'warning', }) # --- -# name: test_all_entities[sensor.my_printer_active_alerts-entry] +# name: test_all_entities[sensor.sec84251907c415_active_alerts-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -61,8 +61,8 @@ 'disabled_by': None, 'domain': 'sensor', 'entity_category': None, - 'entity_id': 'sensor.my_printer_active_alerts', - 'has_entity_name': False, + 'entity_id': 'sensor.sec84251907c415_active_alerts', + 'has_entity_name': True, 'hidden_by': None, 'icon': None, 'id': , @@ -73,30 +73,31 @@ }), 'original_device_class': None, 'original_icon': 'mdi:printer', - 'original_name': 'My Printer Active Alerts', + 'original_name': 'Active alerts', 'platform': 'syncthru', 'previous_unique_id': None, 'supported_features': 0, - 'translation_key': None, + 'translation_key': 'active_alerts', 'unique_id': '08HRB8GJ3F019DD_active_alerts', - 'unit_of_measurement': None, + 'unit_of_measurement': 'alerts', }) # --- -# name: test_all_entities[sensor.my_printer_active_alerts-state] +# name: test_all_entities[sensor.sec84251907c415_active_alerts-state] StateSnapshot({ 'attributes': ReadOnlyDict({ - 'friendly_name': 'My Printer Active Alerts', + 'friendly_name': 'SEC84251907C415 Active alerts', 'icon': 'mdi:printer', + 'unit_of_measurement': 'alerts', }), 'context': , - 'entity_id': 'sensor.my_printer_active_alerts', + 'entity_id': 'sensor.sec84251907c415_active_alerts', 'last_changed': , 'last_reported': , 'last_updated': , 'state': '2', }) # --- -# name: test_all_entities[sensor.my_printer_output_tray_1-entry] +# name: test_all_entities[sensor.sec84251907c415_black_toner_level-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -109,8 +110,8 @@ 'disabled_by': None, 'domain': 'sensor', 'entity_category': None, - 'entity_id': 'sensor.my_printer_output_tray_1', - 'has_entity_name': False, + 'entity_id': 'sensor.sec84251907c415_black_toner_level', + 'has_entity_name': True, 'hidden_by': None, 'icon': None, 'id': , @@ -121,71 +122,20 @@ }), 'original_device_class': None, 'original_icon': 'mdi:printer', - 'original_name': 'My Printer Output Tray 1', + 'original_name': 'Black toner level', 'platform': 'syncthru', 'previous_unique_id': None, 'supported_features': 0, - 'translation_key': None, - 'unique_id': '08HRB8GJ3F019DD_output_tray_1', - 'unit_of_measurement': None, - }) -# --- -# name: test_all_entities[sensor.my_printer_output_tray_1-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'capacity': 50, - 'friendly_name': 'My Printer Output Tray 1', - 'icon': 'mdi:printer', - 'name': 1, - 'status': '', - }), - 'context': , - 'entity_id': 'sensor.my_printer_output_tray_1', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'Ready', - }) -# --- -# name: test_all_entities[sensor.my_printer_toner_black-entry] - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.my_printer_toner_black', - 'has_entity_name': False, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': None, - 'original_icon': 'mdi:printer', - 'original_name': 'My Printer Toner black', - 'platform': 'syncthru', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': None, + 'translation_key': 'toner_black', 'unique_id': '08HRB8GJ3F019DD_toner_black', 'unit_of_measurement': '%', }) # --- -# name: test_all_entities[sensor.my_printer_toner_black-state] +# name: test_all_entities[sensor.sec84251907c415_black_toner_level-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'cnt': 1176, - 'friendly_name': 'My Printer Toner black', + 'friendly_name': 'SEC84251907C415 Black toner level', 'icon': 'mdi:printer', 'newError': 'C1-5110', 'opt': 1, @@ -193,14 +143,14 @@ 'unit_of_measurement': '%', }), 'context': , - 'entity_id': 'sensor.my_printer_toner_black', + 'entity_id': 'sensor.sec84251907c415_black_toner_level', 'last_changed': , 'last_reported': , 'last_updated': , 'state': '8', }) # --- -# name: test_all_entities[sensor.my_printer_toner_cyan-entry] +# name: test_all_entities[sensor.sec84251907c415_cyan_toner_level-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -213,8 +163,8 @@ 'disabled_by': None, 'domain': 'sensor', 'entity_category': None, - 'entity_id': 'sensor.my_printer_toner_cyan', - 'has_entity_name': False, + 'entity_id': 'sensor.sec84251907c415_cyan_toner_level', + 'has_entity_name': True, 'hidden_by': None, 'icon': None, 'id': , @@ -225,20 +175,20 @@ }), 'original_device_class': None, 'original_icon': 'mdi:printer', - 'original_name': 'My Printer Toner cyan', + 'original_name': 'Cyan toner level', 'platform': 'syncthru', 'previous_unique_id': None, 'supported_features': 0, - 'translation_key': None, + 'translation_key': 'toner_cyan', 'unique_id': '08HRB8GJ3F019DD_toner_cyan', 'unit_of_measurement': '%', }) # --- -# name: test_all_entities[sensor.my_printer_toner_cyan-state] +# name: test_all_entities[sensor.sec84251907c415_cyan_toner_level-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'cnt': 25, - 'friendly_name': 'My Printer Toner cyan', + 'friendly_name': 'SEC84251907C415 Cyan toner level', 'icon': 'mdi:printer', 'newError': '', 'opt': 1, @@ -246,14 +196,14 @@ 'unit_of_measurement': '%', }), 'context': , - 'entity_id': 'sensor.my_printer_toner_cyan', + 'entity_id': 'sensor.sec84251907c415_cyan_toner_level', 'last_changed': , 'last_reported': , 'last_updated': , 'state': '98', }) # --- -# name: test_all_entities[sensor.my_printer_toner_magenta-entry] +# name: test_all_entities[sensor.sec84251907c415_input_tray_1-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -266,8 +216,8 @@ 'disabled_by': None, 'domain': 'sensor', 'entity_category': None, - 'entity_id': 'sensor.my_printer_toner_magenta', - 'has_entity_name': False, + 'entity_id': 'sensor.sec84251907c415_input_tray_1', + 'has_entity_name': True, 'hidden_by': None, 'icon': None, 'id': , @@ -278,126 +228,20 @@ }), 'original_device_class': None, 'original_icon': 'mdi:printer', - 'original_name': 'My Printer Toner magenta', + 'original_name': 'Input tray 1', 'platform': 'syncthru', 'previous_unique_id': None, 'supported_features': 0, - 'translation_key': None, - 'unique_id': '08HRB8GJ3F019DD_toner_magenta', - 'unit_of_measurement': '%', - }) -# --- -# name: test_all_entities[sensor.my_printer_toner_magenta-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'cnt': 25, - 'friendly_name': 'My Printer Toner magenta', - 'icon': 'mdi:printer', - 'newError': '', - 'opt': 1, - 'remaining': 98, - 'unit_of_measurement': '%', - }), - 'context': , - 'entity_id': 'sensor.my_printer_toner_magenta', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '98', - }) -# --- -# name: test_all_entities[sensor.my_printer_toner_yellow-entry] - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.my_printer_toner_yellow', - 'has_entity_name': False, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': None, - 'original_icon': 'mdi:printer', - 'original_name': 'My Printer Toner yellow', - 'platform': 'syncthru', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': None, - 'unique_id': '08HRB8GJ3F019DD_toner_yellow', - 'unit_of_measurement': '%', - }) -# --- -# name: test_all_entities[sensor.my_printer_toner_yellow-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'cnt': 27, - 'friendly_name': 'My Printer Toner yellow', - 'icon': 'mdi:printer', - 'newError': '', - 'opt': 1, - 'remaining': 97, - 'unit_of_measurement': '%', - }), - 'context': , - 'entity_id': 'sensor.my_printer_toner_yellow', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '97', - }) -# --- -# name: test_all_entities[sensor.my_printer_tray_tray_1-entry] - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.my_printer_tray_tray_1', - 'has_entity_name': False, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': None, - 'original_icon': 'mdi:printer', - 'original_name': 'My Printer Tray tray_1', - 'platform': 'syncthru', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': None, + 'translation_key': 'tray', 'unique_id': '08HRB8GJ3F019DD_tray_tray_1', 'unit_of_measurement': None, }) # --- -# name: test_all_entities[sensor.my_printer_tray_tray_1-state] +# name: test_all_entities[sensor.sec84251907c415_input_tray_1-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'capa': 150, - 'friendly_name': 'My Printer Tray tray_1', + 'friendly_name': 'SEC84251907C415 Input tray 1', 'icon': 'mdi:printer', 'newError': '', 'opt': 1, @@ -408,10 +252,167 @@ 'paper_type2': 0, }), 'context': , - 'entity_id': 'sensor.my_printer_tray_tray_1', + 'entity_id': 'sensor.sec84251907c415_input_tray_1', 'last_changed': , 'last_reported': , 'last_updated': , 'state': 'Ready', }) # --- +# name: test_all_entities[sensor.sec84251907c415_magenta_toner_level-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.sec84251907c415_magenta_toner_level', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:printer', + 'original_name': 'Magenta toner level', + 'platform': 'syncthru', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'toner_magenta', + 'unique_id': '08HRB8GJ3F019DD_toner_magenta', + 'unit_of_measurement': '%', + }) +# --- +# name: test_all_entities[sensor.sec84251907c415_magenta_toner_level-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'cnt': 25, + 'friendly_name': 'SEC84251907C415 Magenta toner level', + 'icon': 'mdi:printer', + 'newError': '', + 'opt': 1, + 'remaining': 98, + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.sec84251907c415_magenta_toner_level', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '98', + }) +# --- +# name: test_all_entities[sensor.sec84251907c415_output_tray_1-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.sec84251907c415_output_tray_1', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:printer', + 'original_name': 'Output tray 1', + 'platform': 'syncthru', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'output_tray', + 'unique_id': '08HRB8GJ3F019DD_output_tray_1', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[sensor.sec84251907c415_output_tray_1-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'capacity': 50, + 'friendly_name': 'SEC84251907C415 Output tray 1', + 'icon': 'mdi:printer', + 'name': 1, + 'status': '', + }), + 'context': , + 'entity_id': 'sensor.sec84251907c415_output_tray_1', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'Ready', + }) +# --- +# name: test_all_entities[sensor.sec84251907c415_yellow_toner_level-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.sec84251907c415_yellow_toner_level', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:printer', + 'original_name': 'Yellow toner level', + 'platform': 'syncthru', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'toner_yellow', + 'unique_id': '08HRB8GJ3F019DD_toner_yellow', + 'unit_of_measurement': '%', + }) +# --- +# name: test_all_entities[sensor.sec84251907c415_yellow_toner_level-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'cnt': 27, + 'friendly_name': 'SEC84251907C415 Yellow toner level', + 'icon': 'mdi:printer', + 'newError': '', + 'opt': 1, + 'remaining': 97, + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.sec84251907c415_yellow_toner_level', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '97', + }) +# --- From ebe71a1a38f72b56db660c79fbd55f922db73763 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Sat, 12 Apr 2025 20:22:12 +0200 Subject: [PATCH 495/505] Add diagnostics support to Syncthru (#142776) --- .../components/syncthru/diagnostics.py | 18 ++ .../syncthru/snapshots/test_diagnostics.ambr | 193 ++++++++++++++++++ tests/components/syncthru/test_diagnostics.py | 29 +++ 3 files changed, 240 insertions(+) create mode 100644 homeassistant/components/syncthru/diagnostics.py create mode 100644 tests/components/syncthru/snapshots/test_diagnostics.ambr create mode 100644 tests/components/syncthru/test_diagnostics.py diff --git a/homeassistant/components/syncthru/diagnostics.py b/homeassistant/components/syncthru/diagnostics.py new file mode 100644 index 00000000000..5ff860ed41e --- /dev/null +++ b/homeassistant/components/syncthru/diagnostics.py @@ -0,0 +1,18 @@ +"""Diagnostics support for Syncthru.""" + +from __future__ import annotations + +from typing import Any + +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant + +from .const import DOMAIN + + +async def async_get_config_entry_diagnostics( + hass: HomeAssistant, entry: ConfigEntry +) -> dict[str, Any]: + """Return diagnostics for a config entry.""" + + return hass.data[DOMAIN][entry.entry_id].data.raw() diff --git a/tests/components/syncthru/snapshots/test_diagnostics.ambr b/tests/components/syncthru/snapshots/test_diagnostics.ambr new file mode 100644 index 00000000000..9b22561a2d6 --- /dev/null +++ b/tests/components/syncthru/snapshots/test_diagnostics.ambr @@ -0,0 +1,193 @@ +# serializer version: 1 +# name: test_diagnostics + dict({ + 'GXI_A3_SUPPORT': 0, + 'GXI_ACTIVE_ALERT_TOTAL': 2, + 'GXI_ADMIN_WUI_HAS_DEFAULT_PASS': 0, + 'GXI_IMAGING_BLACK_VALID': 1, + 'GXI_IMAGING_COLOR_VALID': 1, + 'GXI_IMAGING_CYAN_VALID': 1, + 'GXI_IMAGING_MAGENTA_VALID': 1, + 'GXI_IMAGING_YELLOW_VALID': 1, + 'GXI_INSTALL_OPTION_MULTIBIN': 0, + 'GXI_INTRAY_MANUALFEEDING_TRAY_SUPPORT': 0, + 'GXI_SUPPORT_COLOR': 1, + 'GXI_SUPPORT_MULTI_PASS': 1, + 'GXI_SUPPORT_PAPER_LEVEL': 0, + 'GXI_SUPPORT_PAPER_SETTING': 1, + 'GXI_SWS_ADMIN_USE_AAA': 0, + 'GXI_SYS_LUI_SUPPORT': 0, + 'GXI_TONER_BLACK_VALID': 1, + 'GXI_TONER_CYAN_VALID': 1, + 'GXI_TONER_MAGENTA_VALID': 1, + 'GXI_TONER_YELLOW_VALID': 1, + 'GXI_TRAY2_MANDATORY_SUPPORT': 0, + 'capability': dict({ + 'hdd': dict({ + 'capa': 40, + 'opt': 2, + }), + 'ram': dict({ + 'capa': 65536, + 'opt': 65536, + }), + 'scanner': dict({ + 'capa': 0, + 'opt': 0, + }), + }), + 'drum_black': dict({ + 'newError': '', + 'opt': 0, + 'remaining': 44, + }), + 'drum_color': dict({ + 'newError': '', + 'opt': 1, + 'remaining': 44, + }), + 'drum_cyan': dict({ + 'newError': '', + 'opt': 0, + 'remaining': 100, + }), + 'drum_magenta': dict({ + 'newError': '', + 'opt': 0, + 'remaining': 100, + }), + 'drum_yellow': dict({ + 'newError': '', + 'opt': 0, + 'remaining': 100, + }), + 'identity': dict({ + 'admin_email': '', + 'admin_name': '', + 'admin_phone': '', + 'customer_support': '', + 'device_name': 'Samsung C430W', + 'host_name': 'SEC84251907C415', + 'ip_addr': '192.168.0.251', + 'ipv6_link_addr': '', + 'location': 'Living room', + 'mac_addr': '84:25:19:07:C4:15', + 'model_name': 'C430W', + 'serial_num': '08HRB8GJ3F019DD', + }), + 'manual': dict({ + 'capa': 0, + 'newError': '', + 'opt': 0, + 'paper_size1': 0, + 'paper_size2': 0, + 'paper_type1': 2, + 'paper_type2': 0, + }), + 'mp': dict({ + 'capa': 0, + 'newError': '', + 'opt': 0, + 'paper_level': 0, + 'paper_size1': 0, + 'paper_size2': 0, + 'paper_type1': 2, + 'paper_type2': 0, + }), + 'multibin': list([ + 0, + ]), + 'options': dict({ + 'hdd': 0, + 'wlan': 1, + }), + 'outputTray': list([ + list([ + 1, + 50, + '', + ]), + ]), + 'status': dict({ + 'hrDeviceStatus': 3, + 'status1': '', + 'status2': '', + 'status3': '', + 'status4': '', + }), + 'toner_black': dict({ + 'cnt': 1176, + 'newError': 'C1-5110', + 'opt': 1, + 'remaining': 8, + }), + 'toner_cyan': dict({ + 'cnt': 25, + 'newError': '', + 'opt': 1, + 'remaining': 98, + }), + 'toner_magenta': dict({ + 'cnt': 25, + 'newError': '', + 'opt': 1, + 'remaining': 98, + }), + 'toner_yellow': dict({ + 'cnt': 27, + 'newError': '', + 'opt': 1, + 'remaining': 97, + }), + 'tray1': dict({ + 'capa': 150, + 'newError': '', + 'opt': 1, + 'paper_level': 0, + 'paper_size1': 4, + 'paper_size2': 0, + 'paper_type1': 2, + 'paper_type2': 0, + }), + 'tray2': dict({ + 'capa': 0, + 'newError': '', + 'opt': 0, + 'paper_level': 0, + 'paper_size1': 0, + 'paper_size2': 0, + 'paper_type1': 2, + 'paper_type2': 0, + }), + 'tray3': dict({ + 'capa': 0, + 'newError': '', + 'opt': 0, + 'paper_level': 0, + 'paper_size1': 0, + 'paper_size2': 0, + 'paper_type1': 2, + 'paper_type2': 0, + }), + 'tray4': dict({ + 'capa': 0, + 'newError': '', + 'opt': 0, + 'paper_level': 0, + 'paper_size1': 0, + 'paper_size2': 0, + 'paper_type1': 2, + 'paper_type2': 0, + }), + 'tray5': dict({ + 'capa': 0, + 'newError': '0', + 'opt': 0, + 'paper_level': 0, + 'paper_size1': 0, + 'paper_size2': 0, + 'paper_type1': 2, + 'paper_type2': 0, + }), + }) +# --- diff --git a/tests/components/syncthru/test_diagnostics.py b/tests/components/syncthru/test_diagnostics.py new file mode 100644 index 00000000000..f5988936328 --- /dev/null +++ b/tests/components/syncthru/test_diagnostics.py @@ -0,0 +1,29 @@ +"""Tests for the diagnostics data provided by the Syncthru integration.""" + +from unittest.mock import AsyncMock + +from syrupy import SnapshotAssertion + +from homeassistant.core import HomeAssistant + +from . import setup_integration + +from tests.common import MockConfigEntry +from tests.components.diagnostics import get_diagnostics_for_config_entry +from tests.typing import ClientSessionGenerator + + +async def test_diagnostics( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + mock_syncthru: AsyncMock, + mock_config_entry: MockConfigEntry, + snapshot: SnapshotAssertion, +) -> None: + """Test diagnostics.""" + await setup_integration(hass, mock_config_entry) + + assert ( + await get_diagnostics_for_config_entry(hass, hass_client, mock_config_entry) + == snapshot + ) From b95701779988befa7bd69e8769bdd5d0b8e26c3e Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Sat, 12 Apr 2025 20:50:37 +0200 Subject: [PATCH 496/505] Clean up Syncthru unique id (#142778) --- .../components/syncthru/binary_sensor.py | 19 +------------- homeassistant/components/syncthru/entity.py | 7 +++++- homeassistant/components/syncthru/sensor.py | 25 +++++-------------- 3 files changed, 13 insertions(+), 38 deletions(-) diff --git a/homeassistant/components/syncthru/binary_sensor.py b/homeassistant/components/syncthru/binary_sensor.py index f68f33043ee..45a3e263465 100644 --- a/homeassistant/components/syncthru/binary_sensor.py +++ b/homeassistant/components/syncthru/binary_sensor.py @@ -13,7 +13,6 @@ from homeassistant.components.binary_sensor import ( BinarySensorEntityDescription, ) from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_NAME from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback @@ -62,11 +61,8 @@ async def async_setup_entry( coordinator: SyncthruCoordinator = hass.data[DOMAIN][config_entry.entry_id] - name: str = config_entry.data[CONF_NAME] - async_add_entities( - SyncThruBinarySensor(coordinator, name, description) - for description in BINARY_SENSORS + SyncThruBinarySensor(coordinator, description) for description in BINARY_SENSORS ) @@ -75,19 +71,6 @@ class SyncThruBinarySensor(SyncthruEntity, BinarySensorEntity): entity_description: SyncThruBinarySensorDescription - def __init__( - self, - coordinator: SyncthruCoordinator, - name: str, - entity_description: SyncThruBinarySensorDescription, - ) -> None: - """Initialize the sensor.""" - super().__init__(coordinator) - self.entity_description = entity_description - serial_number = coordinator.data.serial_number() - assert serial_number is not None - self._attr_unique_id = f"{serial_number}_{entity_description.key}" - @property def is_on(self) -> bool | None: """Return true if the binary sensor is on.""" diff --git a/homeassistant/components/syncthru/entity.py b/homeassistant/components/syncthru/entity.py index 0b5e6324953..a2feafbc495 100644 --- a/homeassistant/components/syncthru/entity.py +++ b/homeassistant/components/syncthru/entity.py @@ -2,6 +2,7 @@ from homeassistant.helpers import device_registry as dr from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.entity import EntityDescription from homeassistant.helpers.update_coordinator import CoordinatorEntity from . import DOMAIN, SyncthruCoordinator @@ -12,11 +13,15 @@ class SyncthruEntity(CoordinatorEntity[SyncthruCoordinator]): _attr_has_entity_name = True - def __init__(self, coordinator: SyncthruCoordinator) -> None: + def __init__( + self, coordinator: SyncthruCoordinator, entity_description: EntityDescription + ) -> None: """Initialize the Syncthru entity.""" super().__init__(coordinator) + self.entity_description = entity_description serial_number = coordinator.syncthru.serial_number() assert serial_number is not None + self._attr_unique_id = f"{serial_number}_{entity_description.key}" connections = set() if mac := coordinator.syncthru.raw().get("identity", {}).get("mac_addr"): connections.add((dr.CONNECTION_NETWORK_MAC, mac)) diff --git a/homeassistant/components/syncthru/sensor.py b/homeassistant/components/syncthru/sensor.py index 77d1123e773..f3fb9d6689e 100644 --- a/homeassistant/components/syncthru/sensor.py +++ b/homeassistant/components/syncthru/sensor.py @@ -10,7 +10,7 @@ from pysyncthru import SyncThru, SyncthruState from homeassistant.components.sensor import SensorEntity, SensorEntityDescription from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_NAME, PERCENTAGE +from homeassistant.const import PERCENTAGE from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback @@ -131,7 +131,6 @@ async def async_setup_entry( supp_tray = printer.input_tray_status(filter_supported=True) supp_output_tray = printer.output_tray_status() - name: str = config_entry.data[CONF_NAME] entities: list[SyncThruSensorDescription] = [ get_toner_entity_description(color) for color in supp_toner ] @@ -140,7 +139,7 @@ async def async_setup_entry( entities.extend(get_output_tray_entity_description(key) for key in supp_output_tray) async_add_entities( - SyncThruSensor(coordinator, name, description) + SyncThruSensor(coordinator, description) for description in SENSOR_TYPES + tuple(entities) ) @@ -151,28 +150,16 @@ class SyncThruSensor(SyncthruEntity, SensorEntity): _attr_icon = "mdi:printer" entity_description: SyncThruSensorDescription - def __init__( - self, - coordinator: SyncthruCoordinator, - name: str, - entity_description: SyncThruSensorDescription, - ) -> None: - """Initialize the sensor.""" - super().__init__(coordinator) - self.entity_description = entity_description - self.syncthru = coordinator.data - serial_number = coordinator.data.serial_number() - assert serial_number is not None - self._attr_unique_id = f"{serial_number}_{entity_description.key}" - @property def native_value(self) -> str | int | None: """Return the state of the sensor.""" - return self.entity_description.value_fn(self.syncthru) + return self.entity_description.value_fn(self.coordinator.data) @property def extra_state_attributes(self) -> dict[str, Any] | None: """Return the state attributes.""" if self.entity_description.extra_state_attributes_fn: - return self.entity_description.extra_state_attributes_fn(self.syncthru) + return self.entity_description.extra_state_attributes_fn( + self.coordinator.data + ) return None From cba0cf060962d223f1e5b86ecd9a1222f863985e Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Sat, 12 Apr 2025 20:59:59 +0200 Subject: [PATCH 497/505] Migrate Syncthru to runtime data (#142775) --- homeassistant/components/syncthru/__init__.py | 14 +++++--------- homeassistant/components/syncthru/binary_sensor.py | 8 +++----- homeassistant/components/syncthru/coordinator.py | 4 +++- homeassistant/components/syncthru/diagnostics.py | 7 +++---- homeassistant/components/syncthru/entity.py | 3 ++- homeassistant/components/syncthru/sensor.py | 8 +++----- tests/components/syncthru/conftest.py | 2 +- 7 files changed, 20 insertions(+), 26 deletions(-) diff --git a/homeassistant/components/syncthru/__init__.py b/homeassistant/components/syncthru/__init__.py index 016d0de7257..f514f538821 100644 --- a/homeassistant/components/syncthru/__init__.py +++ b/homeassistant/components/syncthru/__init__.py @@ -4,22 +4,20 @@ from __future__ import annotations from pysyncthru import SyncThruAPINotSupported -from homeassistant.config_entries import ConfigEntry from homeassistant.const import Platform from homeassistant.core import HomeAssistant -from .const import DOMAIN -from .coordinator import SyncthruCoordinator +from .coordinator import SyncThruConfigEntry, SyncthruCoordinator PLATFORMS = [Platform.BINARY_SENSOR, Platform.SENSOR] -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_setup_entry(hass: HomeAssistant, entry: SyncThruConfigEntry) -> bool: """Set up config entry.""" coordinator = SyncthruCoordinator(hass, entry) await coordinator.async_config_entry_first_refresh() - hass.data.setdefault(DOMAIN, {})[entry.entry_id] = coordinator + entry.runtime_data = coordinator if isinstance(coordinator.last_exception, SyncThruAPINotSupported): # this means that the printer does not support the syncthru JSON API # and the config should simply be discarded @@ -29,8 +27,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: return True -async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_unload_entry(hass: HomeAssistant, entry: SyncThruConfigEntry) -> bool: """Unload the config entry.""" - unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) - hass.data[DOMAIN].pop(entry.entry_id, None) - return unload_ok + return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/homeassistant/components/syncthru/binary_sensor.py b/homeassistant/components/syncthru/binary_sensor.py index 45a3e263465..56edff38680 100644 --- a/homeassistant/components/syncthru/binary_sensor.py +++ b/homeassistant/components/syncthru/binary_sensor.py @@ -12,12 +12,10 @@ from homeassistant.components.binary_sensor import ( BinarySensorEntity, BinarySensorEntityDescription, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from . import SyncthruCoordinator -from .const import DOMAIN +from .coordinator import SyncThruConfigEntry from .entity import SyncthruEntity SYNCTHRU_STATE_PROBLEM = { @@ -54,12 +52,12 @@ BINARY_SENSORS: tuple[SyncThruBinarySensorDescription, ...] = ( async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: SyncThruConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up from config entry.""" - coordinator: SyncthruCoordinator = hass.data[DOMAIN][config_entry.entry_id] + coordinator = config_entry.runtime_data async_add_entities( SyncThruBinarySensor(coordinator, description) for description in BINARY_SENSORS diff --git a/homeassistant/components/syncthru/coordinator.py b/homeassistant/components/syncthru/coordinator.py index 8bb10e8c861..0b96b354436 100644 --- a/homeassistant/components/syncthru/coordinator.py +++ b/homeassistant/components/syncthru/coordinator.py @@ -16,11 +16,13 @@ from .const import DOMAIN _LOGGER = logging.getLogger(__name__) +type SyncThruConfigEntry = ConfigEntry[SyncthruCoordinator] + class SyncthruCoordinator(DataUpdateCoordinator[SyncThru]): """Class to manage fetching Syncthru data.""" - def __init__(self, hass: HomeAssistant, entry: ConfigEntry) -> None: + def __init__(self, hass: HomeAssistant, entry: SyncThruConfigEntry) -> None: """Initialize the Syncthru coordinator.""" super().__init__( hass, diff --git a/homeassistant/components/syncthru/diagnostics.py b/homeassistant/components/syncthru/diagnostics.py index 5ff860ed41e..169d354ef76 100644 --- a/homeassistant/components/syncthru/diagnostics.py +++ b/homeassistant/components/syncthru/diagnostics.py @@ -4,15 +4,14 @@ from __future__ import annotations from typing import Any -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant -from .const import DOMAIN +from .coordinator import SyncThruConfigEntry async def async_get_config_entry_diagnostics( - hass: HomeAssistant, entry: ConfigEntry + hass: HomeAssistant, entry: SyncThruConfigEntry ) -> dict[str, Any]: """Return diagnostics for a config entry.""" - return hass.data[DOMAIN][entry.entry_id].data.raw() + return entry.runtime_data.data.raw() diff --git a/homeassistant/components/syncthru/entity.py b/homeassistant/components/syncthru/entity.py index a2feafbc495..3f1aecbf0d4 100644 --- a/homeassistant/components/syncthru/entity.py +++ b/homeassistant/components/syncthru/entity.py @@ -5,7 +5,8 @@ from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity import EntityDescription from homeassistant.helpers.update_coordinator import CoordinatorEntity -from . import DOMAIN, SyncthruCoordinator +from .const import DOMAIN +from .coordinator import SyncthruCoordinator class SyncthruEntity(CoordinatorEntity[SyncthruCoordinator]): diff --git a/homeassistant/components/syncthru/sensor.py b/homeassistant/components/syncthru/sensor.py index f3fb9d6689e..7896b275f45 100644 --- a/homeassistant/components/syncthru/sensor.py +++ b/homeassistant/components/syncthru/sensor.py @@ -9,13 +9,11 @@ from typing import Any, cast from pysyncthru import SyncThru, SyncthruState from homeassistant.components.sensor import SensorEntity, SensorEntityDescription -from homeassistant.config_entries import ConfigEntry from homeassistant.const import PERCENTAGE from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from . import SyncthruCoordinator -from .const import DOMAIN +from .coordinator import SyncThruConfigEntry from .entity import SyncthruEntity SYNCTHRU_STATE_HUMAN = { @@ -118,12 +116,12 @@ SENSOR_TYPES: tuple[SyncThruSensorDescription, ...] = ( async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: SyncThruConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up from config entry.""" - coordinator: SyncthruCoordinator = hass.data[DOMAIN][config_entry.entry_id] + coordinator = config_entry.runtime_data printer = coordinator.data supp_toner = printer.toner_status(filter_supported=True) diff --git a/tests/components/syncthru/conftest.py b/tests/components/syncthru/conftest.py index 1142726d04e..61b91d815a2 100644 --- a/tests/components/syncthru/conftest.py +++ b/tests/components/syncthru/conftest.py @@ -6,7 +6,7 @@ from unittest.mock import AsyncMock, patch from pysyncthru import SyncthruState import pytest -from homeassistant.components.syncthru import DOMAIN +from homeassistant.components.syncthru.const import DOMAIN from homeassistant.const import CONF_NAME, CONF_URL from tests.common import MockConfigEntry, load_json_object_fixture From 67c0af4c57091b35bcbfdd3179d0f57b0d847828 Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Sat, 12 Apr 2025 21:04:05 +0200 Subject: [PATCH 498/505] Fix spelling of "off-peak", add common state for "Normal" in `plugwise` (#142682) --- homeassistant/components/plugwise/strings.json | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/homeassistant/components/plugwise/strings.json b/homeassistant/components/plugwise/strings.json index 96f5366bb2a..344cee66d68 100644 --- a/homeassistant/components/plugwise/strings.json +++ b/homeassistant/components/plugwise/strings.json @@ -19,7 +19,7 @@ "host": "[%key:common::config_flow::data::ip%]", "password": "Smile ID", "port": "[%key:common::config_flow::data::port%]", - "username": "Smile Username" + "username": "Smile username" }, "data_description": { "password": "The Smile ID printed on the label on the back of your Adam, Smile-T, or P1.", @@ -122,7 +122,7 @@ "name": "Gateway mode", "state": { "away": "Pause", - "full": "Normal", + "full": "[%key:common::state::normal%]", "vacation": "Vacation" } }, @@ -184,7 +184,7 @@ "name": "Electricity consumed peak interval" }, "electricity_consumed_off_peak_interval": { - "name": "Electricity consumed off peak interval" + "name": "Electricity consumed off-peak interval" }, "electricity_produced_interval": { "name": "Electricity produced interval" @@ -193,19 +193,19 @@ "name": "Electricity produced peak interval" }, "electricity_produced_off_peak_interval": { - "name": "Electricity produced off peak interval" + "name": "Electricity produced off-peak interval" }, "electricity_consumed_point": { "name": "Electricity consumed point" }, "electricity_consumed_off_peak_point": { - "name": "Electricity consumed off peak point" + "name": "Electricity consumed off-peak point" }, "electricity_consumed_peak_point": { "name": "Electricity consumed peak point" }, "electricity_consumed_off_peak_cumulative": { - "name": "Electricity consumed off peak cumulative" + "name": "Electricity consumed off-peak cumulative" }, "electricity_consumed_peak_cumulative": { "name": "Electricity consumed peak cumulative" @@ -214,13 +214,13 @@ "name": "Electricity produced point" }, "electricity_produced_off_peak_point": { - "name": "Electricity produced off peak point" + "name": "Electricity produced off-peak point" }, "electricity_produced_peak_point": { "name": "Electricity produced peak point" }, "electricity_produced_off_peak_cumulative": { - "name": "Electricity produced off peak cumulative" + "name": "Electricity produced off-peak cumulative" }, "electricity_produced_peak_cumulative": { "name": "Electricity produced peak cumulative" From d6b4f1c95d2e574de55ca930bf52e110256241b5 Mon Sep 17 00:00:00 2001 From: Mathijs van de Nes Date: Sun, 13 Apr 2025 00:02:07 +0200 Subject: [PATCH 499/505] Ensure no ALPN is negotiated for SMTP (#142296) --- homeassistant/components/smtp/notify.py | 11 +++++++---- tests/components/smtp/test_notify.py | 2 ++ 2 files changed, 9 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/smtp/notify.py b/homeassistant/components/smtp/notify.py index e86b22690a4..943be229ec3 100644 --- a/homeassistant/components/smtp/notify.py +++ b/homeassistant/components/smtp/notify.py @@ -38,7 +38,7 @@ from homeassistant.helpers import config_validation as cv from homeassistant.helpers.reload import setup_reload_service from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from homeassistant.util import dt as dt_util -from homeassistant.util.ssl import client_context +from homeassistant.util.ssl import create_client_context from .const import ( ATTR_HTML, @@ -86,6 +86,7 @@ def get_service( ) -> MailNotificationService | None: """Get the mail notification service.""" setup_reload_service(hass, DOMAIN, PLATFORMS) + ssl_context = create_client_context() if config[CONF_VERIFY_SSL] else None mail_service = MailNotificationService( config[CONF_SERVER], config[CONF_PORT], @@ -98,6 +99,7 @@ def get_service( config.get(CONF_SENDER_NAME), config[CONF_DEBUG], config[CONF_VERIFY_SSL], + ssl_context, ) if mail_service.connection_is_valid(): @@ -122,6 +124,7 @@ class MailNotificationService(BaseNotificationService): sender_name, debug, verify_ssl, + ssl_context, ): """Initialize the SMTP service.""" self._server = server @@ -136,23 +139,23 @@ class MailNotificationService(BaseNotificationService): self.debug = debug self._verify_ssl = verify_ssl self.tries = 2 + self._ssl_context = ssl_context def connect(self): """Connect/authenticate to SMTP Server.""" - ssl_context = client_context() if self._verify_ssl else None if self.encryption == "tls": mail = smtplib.SMTP_SSL( self._server, self._port, timeout=self._timeout, - context=ssl_context, + context=self._ssl_context, ) else: mail = smtplib.SMTP(self._server, self._port, timeout=self._timeout) mail.set_debuglevel(self.debug) mail.ehlo_or_helo_if_needed() if self.encryption == "starttls": - mail.starttls(context=ssl_context) + mail.starttls(context=self._ssl_context) mail.ehlo() if self.username and self.password: mail.login(self.username, self.password) diff --git a/tests/components/smtp/test_notify.py b/tests/components/smtp/test_notify.py index 901d7e547fe..0eb8fda09c5 100644 --- a/tests/components/smtp/test_notify.py +++ b/tests/components/smtp/test_notify.py @@ -14,6 +14,7 @@ from homeassistant.const import SERVICE_RELOAD from homeassistant.core import HomeAssistant from homeassistant.exceptions import ServiceValidationError from homeassistant.setup import async_setup_component +from homeassistant.util.ssl import create_client_context from tests.common import get_fixture_path @@ -84,6 +85,7 @@ def message(): "Home Assistant", 0, True, + create_client_context(), ) From 505e09242d2971f63cc5f717c5be056a36acd153 Mon Sep 17 00:00:00 2001 From: zry98 Date: Sun, 13 Apr 2025 00:03:01 +0200 Subject: [PATCH 500/505] Bump xiaomi-ble to 0.37.0 (#142812) --- homeassistant/components/xiaomi_ble/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/xiaomi_ble/manifest.json b/homeassistant/components/xiaomi_ble/manifest.json index ed534387114..a908d4747ad 100644 --- a/homeassistant/components/xiaomi_ble/manifest.json +++ b/homeassistant/components/xiaomi_ble/manifest.json @@ -24,5 +24,5 @@ "dependencies": ["bluetooth_adapters"], "documentation": "https://www.home-assistant.io/integrations/xiaomi_ble", "iot_class": "local_push", - "requirements": ["xiaomi-ble==0.36.0"] + "requirements": ["xiaomi-ble==0.37.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index d6972d363fd..be71ed9360f 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -3094,7 +3094,7 @@ wyoming==1.5.4 xbox-webapi==2.1.0 # homeassistant.components.xiaomi_ble -xiaomi-ble==0.36.0 +xiaomi-ble==0.37.0 # homeassistant.components.knx xknx==3.6.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 9013ba24c57..fe02c481bad 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2499,7 +2499,7 @@ wyoming==1.5.4 xbox-webapi==2.1.0 # homeassistant.components.xiaomi_ble -xiaomi-ble==0.36.0 +xiaomi-ble==0.37.0 # homeassistant.components.knx xknx==3.6.0 From 03ccb529e45ae5089dc529e576bc0a34ce52c947 Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Sun, 13 Apr 2025 00:03:28 +0200 Subject: [PATCH 501/505] Update pillow to 11.2.1 (#142811) --- homeassistant/components/doods/manifest.json | 2 +- homeassistant/components/generic/manifest.json | 2 +- homeassistant/components/image_upload/manifest.json | 2 +- homeassistant/components/matrix/manifest.json | 2 +- homeassistant/components/proxy/manifest.json | 2 +- homeassistant/components/qrcode/manifest.json | 2 +- homeassistant/components/seven_segments/manifest.json | 2 +- homeassistant/components/sighthound/manifest.json | 2 +- homeassistant/components/tensorflow/manifest.json | 2 +- homeassistant/package_constraints.txt | 2 +- pyproject.toml | 2 +- requirements.txt | 2 +- requirements_all.txt | 2 +- requirements_test.txt | 1 - requirements_test_all.txt | 2 +- 15 files changed, 14 insertions(+), 15 deletions(-) diff --git a/homeassistant/components/doods/manifest.json b/homeassistant/components/doods/manifest.json index 2c672dd4abb..cb31c7d6314 100644 --- a/homeassistant/components/doods/manifest.json +++ b/homeassistant/components/doods/manifest.json @@ -6,5 +6,5 @@ "iot_class": "local_polling", "loggers": ["pydoods"], "quality_scale": "legacy", - "requirements": ["pydoods==1.0.2", "Pillow==11.1.0"] + "requirements": ["pydoods==1.0.2", "Pillow==11.2.1"] } diff --git a/homeassistant/components/generic/manifest.json b/homeassistant/components/generic/manifest.json index 35c5ae93b72..b5e25c08851 100644 --- a/homeassistant/components/generic/manifest.json +++ b/homeassistant/components/generic/manifest.json @@ -7,5 +7,5 @@ "documentation": "https://www.home-assistant.io/integrations/generic", "integration_type": "device", "iot_class": "local_push", - "requirements": ["av==13.1.0", "Pillow==11.1.0"] + "requirements": ["av==13.1.0", "Pillow==11.2.1"] } diff --git a/homeassistant/components/image_upload/manifest.json b/homeassistant/components/image_upload/manifest.json index e43377a3230..bc01476d509 100644 --- a/homeassistant/components/image_upload/manifest.json +++ b/homeassistant/components/image_upload/manifest.json @@ -7,5 +7,5 @@ "documentation": "https://www.home-assistant.io/integrations/image_upload", "integration_type": "system", "quality_scale": "internal", - "requirements": ["Pillow==11.1.0"] + "requirements": ["Pillow==11.2.1"] } diff --git a/homeassistant/components/matrix/manifest.json b/homeassistant/components/matrix/manifest.json index b173a2c850b..6cab2c39c97 100644 --- a/homeassistant/components/matrix/manifest.json +++ b/homeassistant/components/matrix/manifest.json @@ -6,5 +6,5 @@ "iot_class": "cloud_push", "loggers": ["matrix_client"], "quality_scale": "legacy", - "requirements": ["matrix-nio==0.25.2", "Pillow==11.1.0"] + "requirements": ["matrix-nio==0.25.2", "Pillow==11.2.1"] } diff --git a/homeassistant/components/proxy/manifest.json b/homeassistant/components/proxy/manifest.json index 6925b9e2133..02074a18b61 100644 --- a/homeassistant/components/proxy/manifest.json +++ b/homeassistant/components/proxy/manifest.json @@ -4,5 +4,5 @@ "codeowners": [], "documentation": "https://www.home-assistant.io/integrations/proxy", "quality_scale": "legacy", - "requirements": ["Pillow==11.1.0"] + "requirements": ["Pillow==11.2.1"] } diff --git a/homeassistant/components/qrcode/manifest.json b/homeassistant/components/qrcode/manifest.json index cd3ee8eca42..e29e95abc62 100644 --- a/homeassistant/components/qrcode/manifest.json +++ b/homeassistant/components/qrcode/manifest.json @@ -6,5 +6,5 @@ "iot_class": "calculated", "loggers": ["pyzbar"], "quality_scale": "legacy", - "requirements": ["Pillow==11.1.0", "pyzbar==0.1.7"] + "requirements": ["Pillow==11.2.1", "pyzbar==0.1.7"] } diff --git a/homeassistant/components/seven_segments/manifest.json b/homeassistant/components/seven_segments/manifest.json index cdc3b16f95d..6107a6057d1 100644 --- a/homeassistant/components/seven_segments/manifest.json +++ b/homeassistant/components/seven_segments/manifest.json @@ -5,5 +5,5 @@ "documentation": "https://www.home-assistant.io/integrations/seven_segments", "iot_class": "local_polling", "quality_scale": "legacy", - "requirements": ["Pillow==11.1.0"] + "requirements": ["Pillow==11.2.1"] } diff --git a/homeassistant/components/sighthound/manifest.json b/homeassistant/components/sighthound/manifest.json index e1226fd344d..cee768b6ad0 100644 --- a/homeassistant/components/sighthound/manifest.json +++ b/homeassistant/components/sighthound/manifest.json @@ -6,5 +6,5 @@ "iot_class": "cloud_polling", "loggers": ["simplehound"], "quality_scale": "legacy", - "requirements": ["Pillow==11.1.0", "simplehound==0.3"] + "requirements": ["Pillow==11.2.1", "simplehound==0.3"] } diff --git a/homeassistant/components/tensorflow/manifest.json b/homeassistant/components/tensorflow/manifest.json index 81705e326f7..11e1b1d3485 100644 --- a/homeassistant/components/tensorflow/manifest.json +++ b/homeassistant/components/tensorflow/manifest.json @@ -11,6 +11,6 @@ "tf-models-official==2.5.0", "pycocotools==2.0.6", "numpy==2.2.2", - "Pillow==11.1.0" + "Pillow==11.2.1" ] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 96efb888ab7..24fb7709782 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -49,7 +49,7 @@ numpy==2.2.2 orjson==3.10.16 packaging>=23.1 paho-mqtt==2.1.0 -Pillow==11.1.0 +Pillow==11.2.1 propcache==0.3.1 psutil-home-assistant==0.0.1 PyJWT==2.10.1 diff --git a/pyproject.toml b/pyproject.toml index da076d1953d..8bb155a66ae 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -83,7 +83,7 @@ dependencies = [ "PyJWT==2.10.1", # PyJWT has loose dependency. We want the latest one. "cryptography==44.0.1", - "Pillow==11.1.0", + "Pillow==11.2.1", "propcache==0.3.1", "pyOpenSSL==25.0.0", "orjson==3.10.16", diff --git a/requirements.txt b/requirements.txt index a4b91259ef3..b771b7f38b8 100644 --- a/requirements.txt +++ b/requirements.txt @@ -35,7 +35,7 @@ mutagen==1.47.0 numpy==2.2.2 PyJWT==2.10.1 cryptography==44.0.1 -Pillow==11.1.0 +Pillow==11.2.1 propcache==0.3.1 pyOpenSSL==25.0.0 orjson==3.10.16 diff --git a/requirements_all.txt b/requirements_all.txt index be71ed9360f..66e7abac332 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -33,7 +33,7 @@ Mastodon.py==2.0.1 # homeassistant.components.seven_segments # homeassistant.components.sighthound # homeassistant.components.tensorflow -Pillow==11.1.0 +Pillow==11.2.1 # homeassistant.components.plex PlexAPI==4.15.16 diff --git a/requirements_test.txt b/requirements_test.txt index 962a113e1a0..40de8fd7945 100644 --- a/requirements_test.txt +++ b/requirements_test.txt @@ -42,7 +42,6 @@ types-caldav==1.3.0.20241107 types-chardet==0.1.5 types-decorator==5.1.8.20250121 types-pexpect==4.9.0.20241208 -types-pillow==10.2.0.20240822 types-protobuf==5.29.1.20241207 types-psutil==6.1.0.20241221 types-pyserial==3.5.0.20250130 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index fe02c481bad..bced838dd90 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -33,7 +33,7 @@ Mastodon.py==2.0.1 # homeassistant.components.seven_segments # homeassistant.components.sighthound # homeassistant.components.tensorflow -Pillow==11.1.0 +Pillow==11.2.1 # homeassistant.components.plex PlexAPI==4.15.16 From d23c9f715e1f7772ec8ae176f3db4b9afa7db95d Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Sun, 13 Apr 2025 00:04:50 +0200 Subject: [PATCH 502/505] Update beautifulsoup4 to 4.13.3 (#142751) --- homeassistant/components/scrape/manifest.json | 2 +- pyproject.toml | 7 ++----- requirements_all.txt | 2 +- requirements_test.txt | 1 - requirements_test_all.txt | 2 +- 5 files changed, 5 insertions(+), 9 deletions(-) diff --git a/homeassistant/components/scrape/manifest.json b/homeassistant/components/scrape/manifest.json index 56b9470b4f7..28e08372d68 100644 --- a/homeassistant/components/scrape/manifest.json +++ b/homeassistant/components/scrape/manifest.json @@ -6,5 +6,5 @@ "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/scrape", "iot_class": "cloud_polling", - "requirements": ["beautifulsoup4==4.12.3", "lxml==5.3.0"] + "requirements": ["beautifulsoup4==4.13.3", "lxml==5.3.0"] } diff --git a/pyproject.toml b/pyproject.toml index 8bb155a66ae..87d0cda9f71 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -527,8 +527,6 @@ filterwarnings = [ "ignore:datetime.*utcnow\\(\\) is deprecated and scheduled for removal:DeprecationWarning:meteofrance_api.model.forecast", # -- fixed, waiting for release / update - # https://bugs.launchpad.net/beautifulsoup/+bug/2076897 - >4.12.3 - "ignore:The 'strip_cdata' option of HTMLParser\\(\\) has never done anything and will eventually be removed:DeprecationWarning:bs4.builder._lxml", # https://github.com/DataDog/datadogpy/pull/290 - >=0.23.0 "ignore:invalid escape sequence:SyntaxWarning:.*datadog.dogstatsd.base", # https://github.com/DataDog/datadogpy/pull/566/files - >=0.37.0 @@ -584,9 +582,6 @@ filterwarnings = [ "ignore:Callback API version 1 is deprecated, update to latest version:DeprecationWarning:roborock.cloud_api", # https://github.com/briis/pyweatherflowudp/blob/v1.4.5/pyweatherflowudp/const.py#L20 - v1.4.5 - 2023-10-10 "ignore:This function will be removed in future versions of pint:DeprecationWarning:pyweatherflowudp.const", - # Wrong stacklevel - # https://bugs.launchpad.net/beautifulsoup/+bug/2034451 fixed in >4.12.3 - "ignore:It looks like you're parsing an XML document using an HTML parser:UserWarning:html.parser", # New in aiohttp - v3.9.0 "ignore:It is recommended to use web.AppKey instances for keys:UserWarning:(homeassistant|tests|aiohttp_cors)", # - SyntaxWarnings @@ -645,6 +640,8 @@ filterwarnings = [ # https://pypi.org/project/directv/ - v0.4.0 - 2020-09-12 "ignore:with timeout\\(\\) is deprecated:DeprecationWarning:directv.directv", "ignore:datetime.*utcnow\\(\\) is deprecated and scheduled for removal:DeprecationWarning:directv.models", + # https://pypi.org/project/enocean/ - v0.50.1 (installed) -> v0.60.1 - 2021-06-18 + "ignore:It looks like you're using an HTML parser to parse an XML document:UserWarning:enocean.protocol.eep", # https://pypi.org/project/httpsig/ - v1.3.0 - 2018-11-28 "ignore:pkg_resources is deprecated as an API:DeprecationWarning:httpsig", # https://pypi.org/project/influxdb/ - v5.3.2 - 2024-04-18 (archived) diff --git a/requirements_all.txt b/requirements_all.txt index 66e7abac332..18813e5e531 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -590,7 +590,7 @@ batinfo==0.4.2 # beacontools[scan]==2.1.0 # homeassistant.components.scrape -beautifulsoup4==4.12.3 +beautifulsoup4==4.13.3 # homeassistant.components.beewi_smartclim # beewi-smartclim==0.0.10 diff --git a/requirements_test.txt b/requirements_test.txt index 40de8fd7945..7b4ab7a02c0 100644 --- a/requirements_test.txt +++ b/requirements_test.txt @@ -37,7 +37,6 @@ tqdm==4.67.1 types-aiofiles==24.1.0.20241221 types-atomicwrites==1.4.5.1 types-croniter==5.0.1.20241205 -types-beautifulsoup4==4.12.0.20250204 types-caldav==1.3.0.20241107 types-chardet==0.1.5 types-decorator==5.1.8.20250121 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index bced838dd90..76205936755 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -527,7 +527,7 @@ babel==2.15.0 base36==0.1.1 # homeassistant.components.scrape -beautifulsoup4==4.12.3 +beautifulsoup4==4.13.3 # homeassistant.components.bmw_connected_drive bimmer-connected[china]==0.17.2 From 6737c51fcab133c767b649bb69e6b12a267c8f25 Mon Sep 17 00:00:00 2001 From: Chase Mamatey Date: Sat, 12 Apr 2025 19:00:49 -0400 Subject: [PATCH 503/505] Fix duke_energy data retrieval to adhere to service start date (#136054) --- .../components/duke_energy/coordinator.py | 14 +++++--------- 1 file changed, 5 insertions(+), 9 deletions(-) diff --git a/homeassistant/components/duke_energy/coordinator.py b/homeassistant/components/duke_energy/coordinator.py index a76168475c0..a70c94e6fee 100644 --- a/homeassistant/components/duke_energy/coordinator.py +++ b/homeassistant/components/duke_energy/coordinator.py @@ -179,22 +179,18 @@ class DukeEnergyCoordinator(DataUpdateCoordinator[None]): one = timedelta(days=1) if start_time is None: # Max 3 years of data - agreement_date = dt_util.parse_datetime(meter["agreementActiveDate"]) - if agreement_date is None: - start = dt_util.now(tz) - timedelta(days=3 * 365) - else: - start = max( - agreement_date.replace(tzinfo=tz), - dt_util.now(tz) - timedelta(days=3 * 365), - ) + start = dt_util.now(tz) - timedelta(days=3 * 365) else: start = datetime.fromtimestamp(start_time, tz=tz) - lookback + agreement_date = dt_util.parse_datetime(meter["agreementActiveDate"]) + if agreement_date is not None: + start = max(agreement_date.replace(tzinfo=tz), start) start = start.replace(hour=0, minute=0, second=0, microsecond=0) end = dt_util.now(tz).replace(hour=0, minute=0, second=0, microsecond=0) - one _LOGGER.debug("Data lookup range: %s - %s", start, end) - start_step = end - lookback + start_step = max(end - lookback, start) end_step = end usage: dict[datetime, dict[str, float | int]] = {} while True: From 5eb25b2d4a698ddd30d791f1a77888914794cf5f Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Sun, 13 Apr 2025 12:40:53 +0200 Subject: [PATCH 504/505] Use common states for "Low"/"Medium"/"High" in `sensibo` (#142118) --- homeassistant/components/sensibo/strings.json | 22 +++++++++---------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/homeassistant/components/sensibo/strings.json b/homeassistant/components/sensibo/strings.json index 0fbcda461c8..e7a440b4910 100644 --- a/homeassistant/components/sensibo/strings.json +++ b/homeassistant/components/sensibo/strings.json @@ -115,7 +115,7 @@ "sensitivity": { "name": "Pure sensitivity", "state": { - "n": "Normal", + "n": "[%key:common::state::normal%]", "s": "Sensitive" } }, @@ -140,10 +140,10 @@ "name": "[%key:component::climate::entity_component::_::state_attributes::fan_mode::name%]", "state": { "auto": "[%key:component::climate::entity_component::_::state_attributes::fan_mode::state::auto%]", - "high": "[%key:component::climate::entity_component::_::state_attributes::fan_mode::state::high%]", - "low": "[%key:component::climate::entity_component::_::state_attributes::fan_mode::state::low%]", + "high": "[%key:common::state::high%]", + "low": "[%key:common::state::low%]", "medium_low": "Medium low", - "medium": "[%key:component::climate::entity_component::_::state_attributes::fan_mode::state::medium%]", + "medium": "[%key:common::state::medium%]", "medium_high": "Medium high", "strong": "Strong", "quiet": "Quiet" @@ -226,10 +226,10 @@ "name": "[%key:component::climate::entity_component::_::state_attributes::fan_mode::name%]", "state": { "auto": "[%key:component::climate::entity_component::_::state_attributes::fan_mode::state::auto%]", - "high": "[%key:component::climate::entity_component::_::state_attributes::fan_mode::state::high%]", - "low": "[%key:component::climate::entity_component::_::state_attributes::fan_mode::state::low%]", + "high": "[%key:common::state::high%]", + "low": "[%key:common::state::low%]", "medium_low": "[%key:component::sensibo::entity::sensor::climate_react_low::state_attributes::fanlevel::state::medium_low%]", - "medium": "[%key:component::climate::entity_component::_::state_attributes::fan_mode::state::medium%]", + "medium": "[%key:common::state::medium%]", "medium_high": "[%key:component::sensibo::entity::sensor::climate_react_low::state_attributes::fanlevel::state::medium_high%]", "strong": "[%key:component::sensibo::entity::sensor::climate_react_low::state_attributes::fanlevel::state::strong%]", "quiet": "[%key:component::sensibo::entity::sensor::climate_react_low::state_attributes::fanlevel::state::quiet%]" @@ -364,11 +364,11 @@ "state": { "quiet": "[%key:component::sensibo::entity::sensor::climate_react_low::state_attributes::fanlevel::state::quiet%]", "strong": "[%key:component::sensibo::entity::sensor::climate_react_low::state_attributes::fanlevel::state::strong%]", - "low": "[%key:component::climate::entity_component::_::state_attributes::fan_mode::state::low%]", + "low": "[%key:common::state::low%]", "medium_low": "[%key:component::sensibo::entity::sensor::climate_react_low::state_attributes::fanlevel::state::medium_low%]", - "medium": "[%key:component::climate::entity_component::_::state_attributes::fan_mode::state::medium%]", + "medium": "[%key:common::state::medium%]", "medium_high": "[%key:component::sensibo::entity::sensor::climate_react_low::state_attributes::fanlevel::state::medium_high%]", - "high": "[%key:component::climate::entity_component::_::state_attributes::fan_mode::state::high%]", + "high": "[%key:common::state::high%]", "auto": "[%key:component::climate::entity_component::_::state_attributes::fan_mode::state::auto%]" } }, @@ -524,7 +524,7 @@ "selector": { "sensitivity": { "options": { - "normal": "[%key:component::sensibo::entity::sensor::sensitivity::state::n%]", + "normal": "[%key:common::state::normal%]", "sensitive": "[%key:component::sensibo::entity::sensor::sensitivity::state::s%]" } }, From 31c2d22912e953ba35b8e8814ff731c044a5b59b Mon Sep 17 00:00:00 2001 From: Brett Adams Date: Sun, 13 Apr 2025 21:55:16 +1000 Subject: [PATCH 505/505] Check Energy Live API works before creating the coordinator in Tessie (#142510) * Check live API works before creating the coordinator * Fix diag * Fix mypy on entity * is not None --- homeassistant/components/tessie/__init__.py | 24 ++++++++++++++++--- .../components/tessie/binary_sensor.py | 2 ++ .../components/tessie/coordinator.py | 12 +++++++++- .../components/tessie/diagnostics.py | 4 +++- homeassistant/components/tessie/entity.py | 2 +- homeassistant/components/tessie/models.py | 2 +- homeassistant/components/tessie/sensor.py | 9 +++++-- 7 files changed, 46 insertions(+), 9 deletions(-) diff --git a/homeassistant/components/tessie/__init__.py b/homeassistant/components/tessie/__init__.py index e247931e3ba..7fd2729ef03 100644 --- a/homeassistant/components/tessie/__init__.py +++ b/homeassistant/components/tessie/__init__.py @@ -6,7 +6,12 @@ import logging from aiohttp import ClientError, ClientResponseError from tesla_fleet_api.const import Scope -from tesla_fleet_api.exceptions import TeslaFleetError +from tesla_fleet_api.exceptions import ( + Forbidden, + InvalidToken, + SubscriptionRequired, + TeslaFleetError, +) from tesla_fleet_api.tessie import Tessie from tessie_api import get_state_of_all_vehicles @@ -124,12 +129,24 @@ async def async_setup_entry(hass: HomeAssistant, entry: TessieConfigEntry) -> bo continue api = tessie.energySites.create(site_id) + + try: + live_status = (await api.live_status())["response"] + except (InvalidToken, Forbidden, SubscriptionRequired) as e: + raise ConfigEntryAuthFailed from e + except TeslaFleetError as e: + raise ConfigEntryNotReady(e.message) from e + energysites.append( TessieEnergyData( api=api, id=site_id, - live_coordinator=TessieEnergySiteLiveCoordinator( - hass, entry, api + live_coordinator=( + TessieEnergySiteLiveCoordinator( + hass, entry, api, live_status + ) + if isinstance(live_status, dict) + else None ), info_coordinator=TessieEnergySiteInfoCoordinator( hass, entry, api @@ -147,6 +164,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: TessieConfigEntry) -> bo *( energysite.live_coordinator.async_config_entry_first_refresh() for energysite in energysites + if energysite.live_coordinator is not None ), *( energysite.info_coordinator.async_config_entry_first_refresh() diff --git a/homeassistant/components/tessie/binary_sensor.py b/homeassistant/components/tessie/binary_sensor.py index 515339c3da8..cdf3b0035fc 100644 --- a/homeassistant/components/tessie/binary_sensor.py +++ b/homeassistant/components/tessie/binary_sensor.py @@ -191,6 +191,7 @@ async def async_setup_entry( TessieEnergyLiveBinarySensorEntity(energy, description) for energy in entry.runtime_data.energysites for description in ENERGY_LIVE_DESCRIPTIONS + if energy.live_coordinator is not None ), ( TessieEnergyInfoBinarySensorEntity(vehicle, description) @@ -233,6 +234,7 @@ class TessieEnergyLiveBinarySensorEntity(TessieEnergyEntity, BinarySensorEntity) ) -> None: """Initialize the binary sensor.""" self.entity_description = description + assert data.live_coordinator is not None super().__init__(data, data.live_coordinator, description.key) def _async_update_attrs(self) -> None: diff --git a/homeassistant/components/tessie/coordinator.py b/homeassistant/components/tessie/coordinator.py index 2382595b058..8b6fb639a64 100644 --- a/homeassistant/components/tessie/coordinator.py +++ b/homeassistant/components/tessie/coordinator.py @@ -102,7 +102,11 @@ class TessieEnergySiteLiveCoordinator(DataUpdateCoordinator[dict[str, Any]]): config_entry: TessieConfigEntry def __init__( - self, hass: HomeAssistant, config_entry: TessieConfigEntry, api: EnergySite + self, + hass: HomeAssistant, + config_entry: TessieConfigEntry, + api: EnergySite, + data: dict[str, Any], ) -> None: """Initialize Tessie Energy Site Live coordinator.""" super().__init__( @@ -114,6 +118,12 @@ class TessieEnergySiteLiveCoordinator(DataUpdateCoordinator[dict[str, Any]]): ) self.api = api + # Convert Wall Connectors from array to dict + data["wall_connectors"] = { + wc["din"]: wc for wc in (data.get("wall_connectors") or []) + } + self.data = data + async def _async_update_data(self) -> dict[str, Any]: """Update energy site data using Tessie API.""" diff --git a/homeassistant/components/tessie/diagnostics.py b/homeassistant/components/tessie/diagnostics.py index bd2db772b57..21fc208612d 100644 --- a/homeassistant/components/tessie/diagnostics.py +++ b/homeassistant/components/tessie/diagnostics.py @@ -41,7 +41,9 @@ async def async_get_config_entry_diagnostics( ] energysites = [ { - "live": async_redact_data(x.live_coordinator.data, ENERGY_LIVE_REDACT), + "live": async_redact_data(x.live_coordinator.data, ENERGY_LIVE_REDACT) + if x.live_coordinator + else None, "info": async_redact_data(x.info_coordinator.data, ENERGY_INFO_REDACT), } for x in entry.runtime_data.energysites diff --git a/homeassistant/components/tessie/entity.py b/homeassistant/components/tessie/entity.py index a2b6d3c9761..fb49d02f42e 100644 --- a/homeassistant/components/tessie/entity.py +++ b/homeassistant/components/tessie/entity.py @@ -155,7 +155,7 @@ class TessieWallConnectorEntity(TessieBaseEntity): via_device=(DOMAIN, str(data.id)), serial_number=din.split("-")[-1], ) - + assert data.live_coordinator super().__init__(data.live_coordinator, key) @property diff --git a/homeassistant/components/tessie/models.py b/homeassistant/components/tessie/models.py index 03652782cfe..5330d2d0bf0 100644 --- a/homeassistant/components/tessie/models.py +++ b/homeassistant/components/tessie/models.py @@ -28,7 +28,7 @@ class TessieEnergyData: """Data for a Energy Site in the Tessie integration.""" api: EnergySite - live_coordinator: TessieEnergySiteLiveCoordinator + live_coordinator: TessieEnergySiteLiveCoordinator | None info_coordinator: TessieEnergySiteInfoCoordinator id: int device: DeviceInfo diff --git a/homeassistant/components/tessie/sensor.py b/homeassistant/components/tessie/sensor.py index e5b476057fa..52accb15575 100644 --- a/homeassistant/components/tessie/sensor.py +++ b/homeassistant/components/tessie/sensor.py @@ -396,12 +396,16 @@ async def async_setup_entry( TessieEnergyLiveSensorEntity(energysite, description) for energysite in entry.runtime_data.energysites for description in ENERGY_LIVE_DESCRIPTIONS - if description.key in energysite.live_coordinator.data - or description.key == "percentage_charged" + if energysite.live_coordinator is not None + and ( + description.key in energysite.live_coordinator.data + or description.key == "percentage_charged" + ) ), ( # Add wall connectors TessieWallConnectorSensorEntity(energysite, din, description) for energysite in entry.runtime_data.energysites + if energysite.live_coordinator is not None for din in energysite.live_coordinator.data.get("wall_connectors", {}) for description in WALL_CONNECTOR_DESCRIPTIONS ), @@ -446,6 +450,7 @@ class TessieEnergyLiveSensorEntity(TessieEnergyEntity, SensorEntity): ) -> None: """Initialize the sensor.""" self.entity_description = description + assert data.live_coordinator is not None super().__init__(data, data.live_coordinator, description.key) def _async_update_attrs(self) -> None: