diff --git a/homeassistant/components/airzone/manifest.json b/homeassistant/components/airzone/manifest.json index 20b8a452324fa..6987b3213c1a2 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.7.2"] + "requirements": ["aioairzone==0.7.4"] } diff --git a/homeassistant/components/apprise/notify.py b/homeassistant/components/apprise/notify.py index e4b350c4da854..acd1db00d930f 100644 --- a/homeassistant/components/apprise/notify.py +++ b/homeassistant/components/apprise/notify.py @@ -52,9 +52,11 @@ def get_service( return None # Ordered list of URLs - if config.get(CONF_URL) and not a_obj.add(config[CONF_URL]): - _LOGGER.error("Invalid Apprise URL(s) supplied") - return None + if urls := config.get(CONF_URL): + for entry in urls: + if not a_obj.add(entry): + _LOGGER.error("One or more specified Apprise URL(s) are invalid") + return None return AppriseNotificationService(a_obj) diff --git a/homeassistant/components/climate/reproduce_state.py b/homeassistant/components/climate/reproduce_state.py index 2897a956fc690..e5fb5d6004b0e 100644 --- a/homeassistant/components/climate/reproduce_state.py +++ b/homeassistant/components/climate/reproduce_state.py @@ -68,13 +68,22 @@ async def call_service( [ATTR_TEMPERATURE, ATTR_TARGET_TEMP_HIGH, ATTR_TARGET_TEMP_LOW], ) - if ATTR_PRESET_MODE in state.attributes: + if ( + ATTR_PRESET_MODE in state.attributes + and state.attributes[ATTR_PRESET_MODE] is not None + ): await call_service(SERVICE_SET_PRESET_MODE, [ATTR_PRESET_MODE]) - if ATTR_SWING_MODE in state.attributes: + if ( + ATTR_SWING_MODE in state.attributes + and state.attributes[ATTR_SWING_MODE] is not None + ): await call_service(SERVICE_SET_SWING_MODE, [ATTR_SWING_MODE]) - if ATTR_FAN_MODE in state.attributes: + if ( + ATTR_FAN_MODE in state.attributes + and state.attributes[ATTR_FAN_MODE] is not None + ): await call_service(SERVICE_SET_FAN_MODE, [ATTR_FAN_MODE]) if ATTR_HUMIDITY in state.attributes: diff --git a/homeassistant/components/deluge/manifest.json b/homeassistant/components/deluge/manifest.json index 4b59f72219c0d..26ee44a6652d1 100644 --- a/homeassistant/components/deluge/manifest.json +++ b/homeassistant/components/deluge/manifest.json @@ -7,5 +7,5 @@ "integration_type": "service", "iot_class": "local_polling", "loggers": ["deluge_client"], - "requirements": ["deluge-client==1.7.1"] + "requirements": ["deluge-client==1.10.2"] } diff --git a/homeassistant/components/ecovacs/manifest.json b/homeassistant/components/ecovacs/manifest.json index 61bd425b13949..326c2916bedf1 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.9", "deebot-client==5.2.1"] + "requirements": ["py-sucks==0.9.9", "deebot-client==5.2.2"] } diff --git a/homeassistant/components/ecovacs/vacuum.py b/homeassistant/components/ecovacs/vacuum.py index debd751bb7971..a9990bc6fff7b 100644 --- a/homeassistant/components/ecovacs/vacuum.py +++ b/homeassistant/components/ecovacs/vacuum.py @@ -95,7 +95,7 @@ def on_error(self, error: str) -> None: This will not change the entity's state. If the error caused the state to change, that will come through as a separate on_status event """ - if error == "no_error": + if error in ["no_error", sucks.ERROR_CODES["100"]]: self.error = None else: self.error = error diff --git a/homeassistant/components/enigma2/media_player.py b/homeassistant/components/enigma2/media_player.py index 032669499d1cc..c3be9c6f7a31e 100644 --- a/homeassistant/components/enigma2/media_player.py +++ b/homeassistant/components/enigma2/media_player.py @@ -1,9 +1,12 @@ """Support for Enigma2 media players.""" from __future__ import annotations -from aiohttp.client_exceptions import ClientConnectorError +import contextlib +from logging import getLogger + +from aiohttp.client_exceptions import ClientConnectorError, ServerDisconnectedError from openwebif.api import OpenWebIfDevice -from openwebif.enums import RemoteControlCodes, SetVolumeOption +from openwebif.enums import PowerState, RemoteControlCodes, SetVolumeOption import voluptuous as vol from yarl import URL @@ -50,6 +53,8 @@ ATTR_MEDIA_END_TIME = "media_end_time" ATTR_MEDIA_START_TIME = "media_start_time" +_LOGGER = getLogger(__name__) + PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( { vol.Required(CONF_HOST): cv.string, @@ -143,7 +148,12 @@ def __init__(self, name: str, device: OpenWebIfDevice, about: dict) -> None: async def async_turn_off(self) -> None: """Turn off media player.""" - await self._device.turn_off() + if self._device.turn_off_to_deep: + with contextlib.suppress(ServerDisconnectedError): + await self._device.set_powerstate(PowerState.DEEP_STANDBY) + self._attr_available = False + else: + await self._device.set_powerstate(PowerState.STANDBY) async def async_turn_on(self) -> None: """Turn the media player on.""" @@ -191,8 +201,19 @@ async def async_select_source(self, source: str) -> None: async def async_update(self) -> None: """Update state of the media_player.""" - await self._device.update() - self._attr_available = not self._device.is_offline + try: + await self._device.update() + except ClientConnectorError as err: + if self._attr_available: + _LOGGER.warning( + "%s is unavailable. Error: %s", self._device.base.host, err + ) + self._attr_available = False + return + + if not self._attr_available: + _LOGGER.debug("%s is available", self._device.base.host) + self._attr_available = True if not self._device.status.in_standby: self._attr_extra_state_attributes = { diff --git a/homeassistant/components/govee_light_local/manifest.json b/homeassistant/components/govee_light_local/manifest.json index f34fd0b899f1a..cb7955f540749 100644 --- a/homeassistant/components/govee_light_local/manifest.json +++ b/homeassistant/components/govee_light_local/manifest.json @@ -6,5 +6,5 @@ "dependencies": ["network"], "documentation": "https://www.home-assistant.io/integrations/govee_light_local", "iot_class": "local_push", - "requirements": ["govee-local-api==1.4.1"] + "requirements": ["govee-local-api==1.4.4"] } diff --git a/homeassistant/components/holiday/manifest.json b/homeassistant/components/holiday/manifest.json index 0608f8c404e18..234df9980357d 100644 --- a/homeassistant/components/holiday/manifest.json +++ b/homeassistant/components/holiday/manifest.json @@ -5,5 +5,5 @@ "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/holiday", "iot_class": "local_polling", - "requirements": ["holidays==0.42", "babel==2.13.1"] + "requirements": ["holidays==0.43", "babel==2.13.1"] } diff --git a/homeassistant/components/html5/manifest.json b/homeassistant/components/html5/manifest.json index a7eddc9f67e61..f480086d153ba 100644 --- a/homeassistant/components/html5/manifest.json +++ b/homeassistant/components/html5/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/html5", "iot_class": "cloud_push", "loggers": ["http_ece", "py_vapid", "pywebpush"], - "requirements": ["pywebpush==1.9.2"] + "requirements": ["pywebpush==1.14.1"] } diff --git a/homeassistant/components/lutron/__init__.py b/homeassistant/components/lutron/__init__.py index ad1cbfe5ca6ed..ad69a14c0f5d1 100644 --- a/homeassistant/components/lutron/__init__.py +++ b/homeassistant/components/lutron/__init__.py @@ -16,8 +16,8 @@ ) from homeassistant.core import DOMAIN as HOMEASSISTANT_DOMAIN, HomeAssistant from homeassistant.data_entry_flow import FlowResultType +from homeassistant.helpers import device_registry as dr, entity_registry as er import homeassistant.helpers.config_validation as cv -import homeassistant.helpers.device_registry as dr from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue from homeassistant.helpers.typing import ConfigType from homeassistant.util import slugify @@ -186,6 +186,9 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> b lutron_client.connect() _LOGGER.info("Connected to main repeater at %s", host) + entity_registry = er.async_get(hass) + device_registry = dr.async_get(hass) + entry_data = LutronData( client=lutron_client, binary_sensors=[], @@ -201,17 +204,39 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> b for area in lutron_client.areas: _LOGGER.debug("Working on area %s", area.name) for output in area.outputs: + platform = None _LOGGER.debug("Working on output %s", output.type) if output.type == "SYSTEM_SHADE": entry_data.covers.append((area.name, output)) + platform = Platform.COVER elif output.type == "CEILING_FAN_TYPE": entry_data.fans.append((area.name, output)) + platform = Platform.FAN # Deprecated, should be removed in 2024.8 entry_data.lights.append((area.name, output)) elif output.is_dimmable: entry_data.lights.append((area.name, output)) + platform = Platform.LIGHT else: entry_data.switches.append((area.name, output)) + platform = Platform.SWITCH + + _async_check_entity_unique_id( + hass, + entity_registry, + platform, + output.uuid, + output.legacy_uuid, + entry_data.client.guid, + ) + _async_check_device_identifiers( + hass, + device_registry, + output.uuid, + output.legacy_uuid, + entry_data.client.guid, + ) + for keypad in area.keypads: for button in keypad.buttons: # If the button has a function assigned to it, add it as a scene @@ -228,11 +253,46 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> b ) entry_data.scenes.append((area.name, keypad, button, led)) + platform = Platform.SCENE + _async_check_entity_unique_id( + hass, + entity_registry, + platform, + button.uuid, + button.legacy_uuid, + entry_data.client.guid, + ) + if led is not None: + platform = Platform.SWITCH + _async_check_entity_unique_id( + hass, + entity_registry, + platform, + led.uuid, + led.legacy_uuid, + entry_data.client.guid, + ) + entry_data.buttons.append(LutronButton(hass, area.name, keypad, button)) if area.occupancy_group is not None: entry_data.binary_sensors.append((area.name, area.occupancy_group)) + platform = Platform.BINARY_SENSOR + _async_check_entity_unique_id( + hass, + entity_registry, + platform, + area.occupancy_group.uuid, + area.occupancy_group.legacy_uuid, + entry_data.client.guid, + ) + _async_check_device_identifiers( + hass, + device_registry, + area.occupancy_group.uuid, + area.occupancy_group.legacy_uuid, + entry_data.client.guid, + ) - device_registry = dr.async_get(hass) device_registry.async_get_or_create( config_entry_id=config_entry.entry_id, identifiers={(DOMAIN, lutron_client.guid)}, @@ -247,6 +307,52 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> b return True +def _async_check_entity_unique_id( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + platform: str, + uuid: str, + legacy_uuid: str, + controller_guid: str, +) -> None: + """If uuid becomes available update to use it.""" + + if not uuid: + return + + unique_id = f"{controller_guid}_{legacy_uuid}" + entity_id = entity_registry.async_get_entity_id( + domain=platform, platform=DOMAIN, unique_id=unique_id + ) + + if entity_id: + new_unique_id = f"{controller_guid}_{uuid}" + _LOGGER.debug("Updating entity id from %s to %s", unique_id, new_unique_id) + entity_registry.async_update_entity(entity_id, new_unique_id=new_unique_id) + + +def _async_check_device_identifiers( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + uuid: str, + legacy_uuid: str, + controller_guid: str, +) -> None: + """If uuid becomes available update to use it.""" + + if not uuid: + return + + unique_id = f"{controller_guid}_{legacy_uuid}" + device = device_registry.async_get_device(identifiers={(DOMAIN, unique_id)}) + if device: + new_unique_id = f"{controller_guid}_{uuid}" + _LOGGER.debug("Updating device id from %s to %s", unique_id, new_unique_id) + device_registry.async_update_device( + device.id, new_identifiers={(DOMAIN, new_unique_id)} + ) + + async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Clean up resources and entities associated with the integration.""" return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/homeassistant/components/lutron/entity.py b/homeassistant/components/lutron/entity.py index 461e5acb56dd8..3910ecfa0ba49 100644 --- a/homeassistant/components/lutron/entity.py +++ b/homeassistant/components/lutron/entity.py @@ -41,11 +41,11 @@ def _update_callback( self.schedule_update_ha_state() @property - def unique_id(self) -> str | None: + def unique_id(self) -> str: """Return a unique ID.""" - # Temporary fix for https://github.com/thecynic/pylutron/issues/70 + if self._lutron_device.uuid is None: - return None + return f"{self._controller.guid}_{self._lutron_device.legacy_uuid}" return f"{self._controller.guid}_{self._lutron_device.uuid}" def update(self) -> None: @@ -63,7 +63,7 @@ def __init__( """Initialize the device.""" super().__init__(area_name, lutron_device, controller) self._attr_device_info = DeviceInfo( - identifiers={(DOMAIN, lutron_device.uuid)}, + identifiers={(DOMAIN, self.unique_id)}, manufacturer="Lutron", name=lutron_device.name, suggested_area=area_name, diff --git a/homeassistant/components/mobile_app/__init__.py b/homeassistant/components/mobile_app/__init__.py index 831d2d5cdfb02..19aba56f26066 100644 --- a/homeassistant/components/mobile_app/__init__.py +++ b/homeassistant/components/mobile_app/__init__.py @@ -150,5 +150,5 @@ async def async_remove_entry(hass: HomeAssistant, entry: ConfigEntry) -> None: await store.async_save(savable_state(hass)) if CONF_CLOUDHOOK_URL in entry.data: - with suppress(cloud.CloudNotAvailable): + with suppress(cloud.CloudNotAvailable, ValueError): await cloud.async_delete_cloudhook(hass, entry.data[CONF_WEBHOOK_ID]) diff --git a/homeassistant/components/motion_blinds/manifest.json b/homeassistant/components/motion_blinds/manifest.json index 6f7b7dfae3814..fa333d9060f92 100644 --- a/homeassistant/components/motion_blinds/manifest.json +++ b/homeassistant/components/motion_blinds/manifest.json @@ -21,5 +21,5 @@ "documentation": "https://www.home-assistant.io/integrations/motion_blinds", "iot_class": "local_push", "loggers": ["motionblinds"], - "requirements": ["motionblinds==0.6.20"] + "requirements": ["motionblinds==0.6.21"] } diff --git a/homeassistant/components/overkiz/config_flow.py b/homeassistant/components/overkiz/config_flow.py index 4f3f50bf0e857..5ded6de86f31f 100644 --- a/homeassistant/components/overkiz/config_flow.py +++ b/homeassistant/components/overkiz/config_flow.py @@ -354,9 +354,9 @@ async def async_step_reauth(self, entry_data: Mapping[str, Any]) -> FlowResult: self._user = self._reauth_entry.data[CONF_USERNAME] self._server = self._reauth_entry.data[CONF_HUB] - self._api_type = self._reauth_entry.data[CONF_API_TYPE] + self._api_type = self._reauth_entry.data.get(CONF_API_TYPE, APIType.CLOUD) - if self._reauth_entry.data[CONF_API_TYPE] == APIType.LOCAL: + if self._api_type == APIType.LOCAL: self._host = self._reauth_entry.data[CONF_HOST] return await self.async_step_user(dict(entry_data)) diff --git a/homeassistant/components/reolink/__init__.py b/homeassistant/components/reolink/__init__.py index 028b2c89311b7..bb8c9427a9c4a 100644 --- a/homeassistant/components/reolink/__init__.py +++ b/homeassistant/components/reolink/__init__.py @@ -100,10 +100,7 @@ async def async_check_firmware_update() -> ( async with asyncio.timeout(host.api.timeout * (RETRY_ATTEMPTS + 2)): try: return await host.api.check_new_firmware() - except (ReolinkError, asyncio.exceptions.CancelledError) as err: - task = asyncio.current_task() - if task is not None: - task.uncancel() + except ReolinkError as err: if starting: _LOGGER.debug( "Error checking Reolink firmware update at startup " @@ -133,15 +130,16 @@ async def async_check_firmware_update() -> ( update_interval=FIRMWARE_UPDATE_INTERVAL, ) # Fetch initial data so we have data when entities subscribe - try: - # If camera WAN blocked, firmware check fails, do not prevent setup - await asyncio.gather( - device_coordinator.async_config_entry_first_refresh(), - firmware_coordinator.async_config_entry_first_refresh(), - ) - except ConfigEntryNotReady: + results = await asyncio.gather( + device_coordinator.async_config_entry_first_refresh(), + firmware_coordinator.async_config_entry_first_refresh(), + return_exceptions=True, + ) + # If camera WAN blocked, firmware check fails, do not prevent setup + # so don't check firmware_coordinator exceptions + if isinstance(results[0], BaseException): await host.stop() - raise + raise results[0] hass.data.setdefault(DOMAIN, {})[config_entry.entry_id] = ReolinkData( host=host, diff --git a/homeassistant/components/reolink/manifest.json b/homeassistant/components/reolink/manifest.json index 40e85b9680b1f..0f2ef19ba8733 100644 --- a/homeassistant/components/reolink/manifest.json +++ b/homeassistant/components/reolink/manifest.json @@ -18,5 +18,5 @@ "documentation": "https://www.home-assistant.io/integrations/reolink", "iot_class": "local_push", "loggers": ["reolink_aio"], - "requirements": ["reolink-aio==0.8.7"] + "requirements": ["reolink-aio==0.8.8"] } diff --git a/homeassistant/components/roku/manifest.json b/homeassistant/components/roku/manifest.json index 4e255fcf86cd3..50a4b57fcdd8c 100644 --- a/homeassistant/components/roku/manifest.json +++ b/homeassistant/components/roku/manifest.json @@ -11,7 +11,7 @@ "iot_class": "local_polling", "loggers": ["rokuecp"], "quality_scale": "silver", - "requirements": ["rokuecp==0.19.0"], + "requirements": ["rokuecp==0.19.1"], "ssdp": [ { "st": "roku:ecp", diff --git a/homeassistant/components/roomba/manifest.json b/homeassistant/components/roomba/manifest.json index fbe6c9254380a..3d524dcf1d895 100644 --- a/homeassistant/components/roomba/manifest.json +++ b/homeassistant/components/roomba/manifest.json @@ -24,7 +24,7 @@ "documentation": "https://www.home-assistant.io/integrations/roomba", "iot_class": "local_push", "loggers": ["paho_mqtt", "roombapy"], - "requirements": ["roombapy==1.6.10"], + "requirements": ["roombapy==1.6.12"], "zeroconf": [ { "type": "_amzn-alexa._tcp.local.", diff --git a/homeassistant/components/tankerkoenig/coordinator.py b/homeassistant/components/tankerkoenig/coordinator.py index f1f200a59642e..c28ebf4aab2ca 100644 --- a/homeassistant/components/tankerkoenig/coordinator.py +++ b/homeassistant/components/tankerkoenig/coordinator.py @@ -12,6 +12,7 @@ TankerkoenigConnectionError, TankerkoenigError, TankerkoenigInvalidKeyError, + TankerkoenigRateLimitError, ) from homeassistant.config_entries import ConfigEntry @@ -19,7 +20,7 @@ from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady from homeassistant.helpers.aiohttp_client import async_get_clientsession -from homeassistant.helpers.update_coordinator import DataUpdateCoordinator +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from .const import CONF_FUEL_TYPES, CONF_STATIONS @@ -78,13 +79,22 @@ async def _async_update_data(self) -> dict[str, PriceInfo]: station_ids = list(self.stations) prices = {} - # The API seems to only return at most 10 results, so split the list in chunks of 10 # and merge it together. for index in range(ceil(len(station_ids) / 10)): - data = await self._tankerkoenig.prices( - station_ids[index * 10 : (index + 1) * 10] - ) + try: + data = await self._tankerkoenig.prices( + station_ids[index * 10 : (index + 1) * 10] + ) + except TankerkoenigInvalidKeyError as err: + raise ConfigEntryAuthFailed(err) from err + except (TankerkoenigError, TankerkoenigConnectionError) as err: + if isinstance(err, TankerkoenigRateLimitError): + _LOGGER.warning( + "API rate limit reached, consider to increase polling interval" + ) + raise UpdateFailed(err) from err + prices.update(data) return prices diff --git a/homeassistant/components/tankerkoenig/manifest.json b/homeassistant/components/tankerkoenig/manifest.json index adea5b9649028..4570d0e564912 100644 --- a/homeassistant/components/tankerkoenig/manifest.json +++ b/homeassistant/components/tankerkoenig/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/tankerkoenig", "iot_class": "cloud_polling", "loggers": ["aiotankerkoenig"], - "requirements": ["aiotankerkoenig==0.3.0"] + "requirements": ["aiotankerkoenig==0.4.1"] } diff --git a/homeassistant/components/teslemetry/entity.py b/homeassistant/components/teslemetry/entity.py index d8dcf9934cc58..024d0603e7e83 100644 --- a/homeassistant/components/teslemetry/entity.py +++ b/homeassistant/components/teslemetry/entity.py @@ -3,6 +3,9 @@ import asyncio from typing import Any +from tesla_fleet_api.exceptions import TeslaFleetError + +from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.update_coordinator import CoordinatorEntity @@ -45,11 +48,22 @@ def __init__( async def wake_up_if_asleep(self) -> None: """Wake up the vehicle if its asleep.""" async with self._wakelock: + times = 0 while self.coordinator.data["state"] != TeslemetryState.ONLINE: - state = (await self.api.wake_up())["response"]["state"] + try: + if times == 0: + cmd = await self.api.wake_up() + else: + cmd = await self.api.vehicle() + state = cmd["response"]["state"] + except TeslaFleetError as e: + raise HomeAssistantError(str(e)) from e self.coordinator.data["state"] = state if state != TeslemetryState.ONLINE: - await asyncio.sleep(5) + times += 1 + if times >= 4: # Give up after 30 seconds total + raise HomeAssistantError("Could not wake up vehicle") + await asyncio.sleep(times * 5) def get(self, key: str | None = None, default: Any | None = None) -> Any: """Return a specific value from coordinator data.""" diff --git a/homeassistant/components/tessie/climate.py b/homeassistant/components/tessie/climate.py index 8eb69d619ffd2..c856e8211cc46 100644 --- a/homeassistant/components/tessie/climate.py +++ b/homeassistant/components/tessie/climate.py @@ -11,6 +11,7 @@ ) from homeassistant.components.climate import ( + ATTR_HVAC_MODE, ClimateEntity, ClimateEntityFeature, HVACMode, @@ -112,9 +113,12 @@ async def async_turn_off(self) -> None: async def async_set_temperature(self, **kwargs: Any) -> None: """Set the climate temperature.""" - temp = kwargs[ATTR_TEMPERATURE] - await self.run(set_temperature, temperature=temp) - self.set(("climate_state_driver_temp_setting", temp)) + if mode := kwargs.get(ATTR_HVAC_MODE): + await self.async_set_hvac_mode(mode) + + if temp := kwargs.get(ATTR_TEMPERATURE): + await self.run(set_temperature, temperature=temp) + self.set(("climate_state_driver_temp_setting", temp)) async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None: """Set the climate mode and state.""" diff --git a/homeassistant/components/tile/device_tracker.py b/homeassistant/components/tile/device_tracker.py index 81d3fb00c6e67..df166d17db556 100644 --- a/homeassistant/components/tile/device_tracker.py +++ b/homeassistant/components/tile/device_tracker.py @@ -20,6 +20,7 @@ CoordinatorEntity, DataUpdateCoordinator, ) +from homeassistant.util.dt import as_utc from . import TileData from .const import DOMAIN @@ -145,16 +146,23 @@ def _handle_coordinator_update(self) -> None: @callback def _update_from_latest_data(self) -> None: """Update the entity from the latest data.""" - self._attr_extra_state_attributes.update( - { - ATTR_ALTITUDE: self._tile.altitude, - ATTR_IS_LOST: self._tile.lost, - ATTR_LAST_LOST_TIMESTAMP: self._tile.lost_timestamp, - ATTR_LAST_TIMESTAMP: self._tile.last_timestamp, - ATTR_RING_STATE: self._tile.ring_state, - ATTR_VOIP_STATE: self._tile.voip_state, - } - ) + self._attr_extra_state_attributes = { + ATTR_ALTITUDE: self._tile.altitude, + ATTR_IS_LOST: self._tile.lost, + ATTR_RING_STATE: self._tile.ring_state, + ATTR_VOIP_STATE: self._tile.voip_state, + } + for timestamp_attr in ( + (ATTR_LAST_LOST_TIMESTAMP, self._tile.lost_timestamp), + (ATTR_LAST_TIMESTAMP, self._tile.last_timestamp), + ): + if not timestamp_attr[1]: + # If the API doesn't return a value for a particular timestamp + # attribute, skip it: + continue + self._attr_extra_state_attributes[timestamp_attr[0]] = as_utc( + timestamp_attr[1] + ) async def async_added_to_hass(self) -> None: """Handle entity which will be added.""" diff --git a/homeassistant/components/unifiprotect/light.py b/homeassistant/components/unifiprotect/light.py index 485e715ea39dd..891754265005d 100644 --- a/homeassistant/components/unifiprotect/light.py +++ b/homeassistant/components/unifiprotect/light.py @@ -78,7 +78,7 @@ def _async_get_state_attrs(self) -> tuple[Any, ...]: is a change. """ - return (self._attr_available, self._attr_brightness) + return (self._attr_available, self._attr_is_on, self._attr_brightness) @callback def _async_update_device_from_protect(self, device: ProtectModelWithId) -> None: diff --git a/homeassistant/components/workday/manifest.json b/homeassistant/components/workday/manifest.json index 05026ae6e99bc..62819f74c2a6c 100644 --- a/homeassistant/components/workday/manifest.json +++ b/homeassistant/components/workday/manifest.json @@ -7,5 +7,5 @@ "iot_class": "local_polling", "loggers": ["holidays"], "quality_scale": "internal", - "requirements": ["holidays==0.42"] + "requirements": ["holidays==0.43"] } diff --git a/homeassistant/const.py b/homeassistant/const.py index 84730bbf6d285..da3323e756271 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -16,7 +16,7 @@ APPLICATION_NAME: Final = "HomeAssistant" MAJOR_VERSION: Final = 2024 MINOR_VERSION: Final = 2 -PATCH_VERSION: Final = "2" +PATCH_VERSION: Final = "3" __short_version__: Final = f"{MAJOR_VERSION}.{MINOR_VERSION}" __version__: Final = f"{__short_version__}.{PATCH_VERSION}" REQUIRED_PYTHON_VER: Final[tuple[int, int, int]] = (3, 11, 0) diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 380eb593a556e..b9c5df6845688 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -141,10 +141,6 @@ pubnub!=6.4.0 # https://github.com/dahlia/iso4217/issues/16 iso4217!=1.10.20220401 -# Matplotlib 3.6.2 has issues building wheels on armhf/armv7 -# We need at least >=2.1.0 (tensorflow integration -> pycocotools) -matplotlib==3.6.1 - # pyOpenSSL 24.0.0 or later required to avoid import errors when # cryptography 42.0.0 is installed with botocore pyOpenSSL>=24.0.0 diff --git a/pyproject.toml b/pyproject.toml index 0f9f9187e9a4c..fb6831f013243 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "homeassistant" -version = "2024.2.2" +version = "2024.2.3" license = {text = "Apache-2.0"} description = "Open-source home automation platform running on Python 3." readme = "README.rst" diff --git a/requirements_all.txt b/requirements_all.txt index 70c74a10dd44e..6666763de4669 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -191,7 +191,7 @@ aioairq==0.3.2 aioairzone-cloud==0.3.8 # homeassistant.components.airzone -aioairzone==0.7.2 +aioairzone==0.7.4 # homeassistant.components.ambient_station aioambient==2024.01.0 @@ -377,7 +377,7 @@ aioswitcher==3.4.1 aiosyncthing==0.5.1 # homeassistant.components.tankerkoenig -aiotankerkoenig==0.3.0 +aiotankerkoenig==0.4.1 # homeassistant.components.tractive aiotractive==0.5.6 @@ -687,7 +687,7 @@ debugpy==1.8.0 # decora==0.6 # homeassistant.components.ecovacs -deebot-client==5.2.1 +deebot-client==5.2.2 # homeassistant.components.ihc # homeassistant.components.namecheapdns @@ -695,7 +695,7 @@ deebot-client==5.2.1 defusedxml==0.7.1 # homeassistant.components.deluge -deluge-client==1.7.1 +deluge-client==1.10.2 # homeassistant.components.lametric demetriek==0.4.0 @@ -967,7 +967,7 @@ gotailwind==0.2.2 govee-ble==0.31.0 # homeassistant.components.govee_light_local -govee-local-api==1.4.1 +govee-local-api==1.4.4 # homeassistant.components.remote_rpi_gpio gpiozero==1.6.2 @@ -1059,7 +1059,7 @@ hole==0.8.0 # homeassistant.components.holiday # homeassistant.components.workday -holidays==0.42 +holidays==0.43 # homeassistant.components.frontend home-assistant-frontend==20240207.1 @@ -1313,7 +1313,7 @@ moehlenhoff-alpha2==1.3.0 mopeka-iot-ble==0.5.0 # homeassistant.components.motion_blinds -motionblinds==0.6.20 +motionblinds==0.6.21 # homeassistant.components.motioneye motioneye-client==0.3.14 @@ -2360,7 +2360,7 @@ pywaze==0.5.1 pyweatherflowudp==1.4.5 # homeassistant.components.html5 -pywebpush==1.9.2 +pywebpush==1.14.1 # homeassistant.components.wemo pywemo==1.4.0 @@ -2423,7 +2423,7 @@ renault-api==0.2.1 renson-endura-delta==1.7.1 # homeassistant.components.reolink -reolink-aio==0.8.7 +reolink-aio==0.8.8 # homeassistant.components.idteck_prox rfk101py==0.0.1 @@ -2444,13 +2444,13 @@ rjpl==0.3.6 rocketchat-API==0.6.1 # homeassistant.components.roku -rokuecp==0.19.0 +rokuecp==0.19.1 # homeassistant.components.romy romy==0.0.7 # homeassistant.components.roomba -roombapy==1.6.10 +roombapy==1.6.12 # homeassistant.components.roon roonapi==0.1.6 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 89ec09e301cc6..3c3bfc9257557 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -170,7 +170,7 @@ aioairq==0.3.2 aioairzone-cloud==0.3.8 # homeassistant.components.airzone -aioairzone==0.7.2 +aioairzone==0.7.4 # homeassistant.components.ambient_station aioambient==2024.01.0 @@ -350,7 +350,7 @@ aioswitcher==3.4.1 aiosyncthing==0.5.1 # homeassistant.components.tankerkoenig -aiotankerkoenig==0.3.0 +aiotankerkoenig==0.4.1 # homeassistant.components.tractive aiotractive==0.5.6 @@ -562,7 +562,7 @@ dbus-fast==2.21.1 debugpy==1.8.0 # homeassistant.components.ecovacs -deebot-client==5.2.1 +deebot-client==5.2.2 # homeassistant.components.ihc # homeassistant.components.namecheapdns @@ -570,7 +570,7 @@ deebot-client==5.2.1 defusedxml==0.7.1 # homeassistant.components.deluge -deluge-client==1.7.1 +deluge-client==1.10.2 # homeassistant.components.lametric demetriek==0.4.0 @@ -784,7 +784,7 @@ gotailwind==0.2.2 govee-ble==0.31.0 # homeassistant.components.govee_light_local -govee-local-api==1.4.1 +govee-local-api==1.4.4 # homeassistant.components.gpsd gps3==0.33.3 @@ -855,7 +855,7 @@ hole==0.8.0 # homeassistant.components.holiday # homeassistant.components.workday -holidays==0.42 +holidays==0.43 # homeassistant.components.frontend home-assistant-frontend==20240207.1 @@ -1049,7 +1049,7 @@ moehlenhoff-alpha2==1.3.0 mopeka-iot-ble==0.5.0 # homeassistant.components.motion_blinds -motionblinds==0.6.20 +motionblinds==0.6.21 # homeassistant.components.motioneye motioneye-client==0.3.14 @@ -1809,7 +1809,7 @@ pywaze==0.5.1 pyweatherflowudp==1.4.5 # homeassistant.components.html5 -pywebpush==1.9.2 +pywebpush==1.14.1 # homeassistant.components.wemo pywemo==1.4.0 @@ -1857,7 +1857,7 @@ renault-api==0.2.1 renson-endura-delta==1.7.1 # homeassistant.components.reolink -reolink-aio==0.8.7 +reolink-aio==0.8.8 # homeassistant.components.rflink rflink==0.0.65 @@ -1866,13 +1866,13 @@ rflink==0.0.65 ring-doorbell[listen]==0.8.7 # homeassistant.components.roku -rokuecp==0.19.0 +rokuecp==0.19.1 # homeassistant.components.romy romy==0.0.7 # homeassistant.components.roomba -roombapy==1.6.10 +roombapy==1.6.12 # homeassistant.components.roon roonapi==0.1.6 diff --git a/script/gen_requirements_all.py b/script/gen_requirements_all.py index 3e61a266ae13a..42d1e1efa0882 100755 --- a/script/gen_requirements_all.py +++ b/script/gen_requirements_all.py @@ -134,10 +134,6 @@ # https://github.com/dahlia/iso4217/issues/16 iso4217!=1.10.20220401 -# Matplotlib 3.6.2 has issues building wheels on armhf/armv7 -# We need at least >=2.1.0 (tensorflow integration -> pycocotools) -matplotlib==3.6.1 - # pyOpenSSL 24.0.0 or later required to avoid import errors when # cryptography 42.0.0 is installed with botocore pyOpenSSL>=24.0.0 diff --git a/tests/components/apprise/test_notify.py b/tests/components/apprise/test_notify.py index ab827a9521020..ead8f735236ec 100644 --- a/tests/components/apprise/test_notify.py +++ b/tests/components/apprise/test_notify.py @@ -118,7 +118,48 @@ async def test_apprise_notification(hass: HomeAssistant) -> None: await hass.async_block_till_done() # Validate calls were made under the hood correctly - obj.add.assert_called_once_with([config[BASE_COMPONENT]["url"]]) + obj.add.assert_called_once_with(config[BASE_COMPONENT]["url"]) + obj.notify.assert_called_once_with( + **{"body": data["message"], "title": data["title"], "tag": None} + ) + + +async def test_apprise_multiple_notification(hass: HomeAssistant) -> None: + """Test apprise notification.""" + + config = { + BASE_COMPONENT: { + "name": "test", + "platform": "apprise", + "url": [ + "mailto://user:pass@example.com, mailto://user:pass@gmail.com", + "json://user:pass@gmail.com", + ], + } + } + + # Our Message + data = {"title": "Test Title", "message": "Test Message"} + + with patch( + "homeassistant.components.apprise.notify.apprise.Apprise" + ) as mock_apprise: + obj = MagicMock() + obj.add.return_value = True + obj.notify.return_value = True + mock_apprise.return_value = obj + assert await async_setup_component(hass, BASE_COMPONENT, config) + await hass.async_block_till_done() + + # Test the existence of our service + assert hass.services.has_service(BASE_COMPONENT, "test") + + # Test the call to our underlining notify() call + await hass.services.async_call(BASE_COMPONENT, "test", data) + await hass.async_block_till_done() + + # Validate 2 calls were made under the hood + assert obj.add.call_count == 2 obj.notify.assert_called_once_with( **{"body": data["message"], "title": data["title"], "tag": None} ) diff --git a/tests/components/climate/test_reproduce_state.py b/tests/components/climate/test_reproduce_state.py index 34cf443302927..b8719fd8fd0c5 100644 --- a/tests/components/climate/test_reproduce_state.py +++ b/tests/components/climate/test_reproduce_state.py @@ -119,6 +119,25 @@ async def test_attribute(hass: HomeAssistant, service, attribute) -> None: assert calls_1[0].data == {"entity_id": ENTITY_1, attribute: value} +@pytest.mark.parametrize( + ("service", "attribute"), + [ + (SERVICE_SET_PRESET_MODE, ATTR_PRESET_MODE), + (SERVICE_SET_SWING_MODE, ATTR_SWING_MODE), + (SERVICE_SET_FAN_MODE, ATTR_FAN_MODE), + ], +) +async def test_attribute_with_none(hass: HomeAssistant, service, attribute) -> None: + """Test that service call is not made for attributes with None value.""" + calls_1 = async_mock_service(hass, DOMAIN, service) + + await async_reproduce_states(hass, [State(ENTITY_1, None, {attribute: None})]) + + await hass.async_block_till_done() + + assert len(calls_1) == 0 + + async def test_attribute_partial_temperature(hass: HomeAssistant) -> None: """Test that service call ignores null attributes.""" calls_1 = async_mock_service(hass, DOMAIN, SERVICE_SET_TEMPERATURE) diff --git a/tests/components/mobile_app/test_init.py b/tests/components/mobile_app/test_init.py index 6a365e84fb0f1..5d25e9568cf13 100644 --- a/tests/components/mobile_app/test_init.py +++ b/tests/components/mobile_app/test_init.py @@ -5,6 +5,7 @@ import pytest +from homeassistant.components.cloud import CloudNotAvailable from homeassistant.components.mobile_app.const import ( ATTR_DEVICE_NAME, CONF_CLOUDHOOK_URL, @@ -118,6 +119,32 @@ async def additional_steps( await _test_create_cloud_hook(hass, hass_admin_user, {}, True, additional_steps) +@pytest.mark.parametrize("exception", (CloudNotAvailable, ValueError)) +async def test_remove_cloudhook( + hass: HomeAssistant, + hass_admin_user: MockUser, + caplog: pytest.LogCaptureFixture, + exception: Exception, +) -> None: + """Test removing a cloud hook when config entry is removed.""" + + async def additional_steps( + config_entry: ConfigEntry, mock_create_cloudhook: Mock, cloud_hook: str + ) -> None: + webhook_id = config_entry.data[CONF_WEBHOOK_ID] + assert config_entry.data[CONF_CLOUDHOOK_URL] == cloud_hook + with patch( + "homeassistant.components.cloud.async_delete_cloudhook", + side_effect=exception, + ) as delete_cloudhook: + await hass.config_entries.async_remove(config_entry.entry_id) + await hass.async_block_till_done() + delete_cloudhook.assert_called_once_with(hass, webhook_id) + assert str(exception) not in caplog.text + + await _test_create_cloud_hook(hass, hass_admin_user, {}, True, additional_steps) + + async def test_create_cloud_hook_aleady_exists( hass: HomeAssistant, hass_admin_user: MockUser, diff --git a/tests/components/roomba/test_config_flow.py b/tests/components/roomba/test_config_flow.py index f62ca1a73b93b..4f4701ad2e3be 100644 --- a/tests/components/roomba/test_config_flow.py +++ b/tests/components/roomba/test_config_flow.py @@ -98,12 +98,12 @@ def _mocked_discovery(*_): roomba = RoombaInfo( hostname="irobot-BLID", - robot_name="robot_name", + robotname="robot_name", ip=MOCK_IP, mac="mac", - firmware="firmware", + sw="firmware", sku="sku", - capabilities="capabilities", + cap={"cap": 1}, ) roomba_discovery.get_all = MagicMock(return_value=[roomba]) diff --git a/tests/components/tankerkoenig/test_coordinator.py b/tests/components/tankerkoenig/test_coordinator.py new file mode 100644 index 0000000000000..650fa5a18acef --- /dev/null +++ b/tests/components/tankerkoenig/test_coordinator.py @@ -0,0 +1,51 @@ +"""Tests for the Tankerkoening integration.""" +from __future__ import annotations + +from datetime import timedelta +from unittest.mock import AsyncMock + +from aiotankerkoenig.exceptions import TankerkoenigRateLimitError +import pytest + +from homeassistant.components.tankerkoenig.const import DEFAULT_SCAN_INTERVAL +from homeassistant.config_entries import ConfigEntryState +from homeassistant.const import STATE_UNAVAILABLE +from homeassistant.core import HomeAssistant +import homeassistant.util.dt as dt_util + +from tests.common import MockConfigEntry, async_fire_time_changed + + +@pytest.mark.usefixtures("setup_integration") +async def test_rate_limit( + hass: HomeAssistant, + config_entry: MockConfigEntry, + tankerkoenig: AsyncMock, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test detection of API rate limit.""" + assert config_entry.state == ConfigEntryState.LOADED + state = hass.states.get("binary_sensor.station_somewhere_street_1_status") + assert state + assert state.state == "on" + + tankerkoenig.prices.side_effect = TankerkoenigRateLimitError + async_fire_time_changed( + hass, dt_util.utcnow() + timedelta(minutes=DEFAULT_SCAN_INTERVAL) + ) + await hass.async_block_till_done() + assert ( + "API rate limit reached, consider to increase polling interval" in caplog.text + ) + state = hass.states.get("binary_sensor.station_somewhere_street_1_status") + assert state + assert state.state == STATE_UNAVAILABLE + + tankerkoenig.prices.side_effect = None + async_fire_time_changed( + hass, dt_util.utcnow() + timedelta(minutes=DEFAULT_SCAN_INTERVAL * 2) + ) + await hass.async_block_till_done() + state = hass.states.get("binary_sensor.station_somewhere_street_1_status") + assert state + assert state.state == "on" diff --git a/tests/components/teslemetry/conftest.py b/tests/components/teslemetry/conftest.py index 0fc279eaa215a..8c1fe070dde91 100644 --- a/tests/components/teslemetry/conftest.py +++ b/tests/components/teslemetry/conftest.py @@ -37,6 +37,16 @@ def mock_wake_up(): yield mock_wake_up +@pytest.fixture(autouse=True) +def mock_vehicle(): + """Mock Tesla Fleet API Vehicle Specific vehicle method.""" + with patch( + "homeassistant.components.teslemetry.VehicleSpecific.vehicle", + return_value=WAKE_UP_ONLINE, + ) as mock_vehicle: + yield mock_vehicle + + @pytest.fixture(autouse=True) def mock_request(): """Mock Tesla Fleet API Vehicle Specific class.""" diff --git a/tests/components/teslemetry/test_climate.py b/tests/components/teslemetry/test_climate.py index ede38a695e20d..2e791f68b9359 100644 --- a/tests/components/teslemetry/test_climate.py +++ b/tests/components/teslemetry/test_climate.py @@ -26,6 +26,7 @@ from homeassistant.helpers import entity_registry as er from . import assert_entities, setup_platform +from .const import WAKE_UP_ASLEEP, WAKE_UP_ONLINE from tests.common import async_fire_time_changed @@ -108,7 +109,11 @@ async def test_errors( async def test_asleep_or_offline( - hass: HomeAssistant, mock_vehicle_data, freezer: FrozenDateTimeFactory + hass: HomeAssistant, + mock_vehicle_data, + mock_wake_up, + mock_vehicle, + freezer: FrozenDateTimeFactory, ) -> None: """Tests asleep is handled.""" @@ -123,9 +128,47 @@ async def test_asleep_or_offline( async_fire_time_changed(hass) await hass.async_block_till_done() mock_vehicle_data.assert_called_once() + mock_wake_up.reset_mock() + + # Run a command but fail trying to wake up the vehicle + mock_wake_up.side_effect = InvalidCommand + with pytest.raises(HomeAssistantError) as error: + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: [entity_id]}, + blocking=True, + ) + assert error + mock_wake_up.assert_called_once() + + mock_wake_up.side_effect = None + mock_wake_up.reset_mock() + + # Run a command but timeout trying to wake up the vehicle + mock_wake_up.return_value = WAKE_UP_ASLEEP + mock_vehicle.return_value = WAKE_UP_ASLEEP + with patch( + "homeassistant.components.teslemetry.entity.asyncio.sleep" + ), pytest.raises(HomeAssistantError) as error: + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: [entity_id]}, + blocking=True, + ) + assert error + mock_wake_up.assert_called_once() + mock_vehicle.assert_called() + + mock_wake_up.reset_mock() + mock_vehicle.reset_mock() + mock_wake_up.return_value = WAKE_UP_ONLINE + mock_vehicle.return_value = WAKE_UP_ONLINE - # Run a command that will wake up the vehicle, but not immediately + # Run a command and wake up the vehicle immediately await hass.services.async_call( CLIMATE_DOMAIN, SERVICE_TURN_ON, {ATTR_ENTITY_ID: [entity_id]}, blocking=True ) await hass.async_block_till_done() + mock_wake_up.assert_called_once() diff --git a/tests/components/tessie/test_climate.py b/tests/components/tessie/test_climate.py index cbb6b7ad09e6f..6d1c8c220d150 100644 --- a/tests/components/tessie/test_climate.py +++ b/tests/components/tessie/test_climate.py @@ -54,14 +54,22 @@ async def test_climate( with patch( "homeassistant.components.tessie.climate.set_temperature", return_value=TEST_RESPONSE, - ) as mock_set: + ) as mock_set, patch( + "homeassistant.components.tessie.climate.start_climate_preconditioning", + return_value=TEST_RESPONSE, + ) as mock_set2: await hass.services.async_call( CLIMATE_DOMAIN, SERVICE_SET_TEMPERATURE, - {ATTR_ENTITY_ID: [entity_id], ATTR_TEMPERATURE: 20}, + { + ATTR_ENTITY_ID: [entity_id], + ATTR_HVAC_MODE: HVACMode.HEAT_COOL, + ATTR_TEMPERATURE: 20, + }, blocking=True, ) mock_set.assert_called_once() + mock_set2.assert_called_once() state = hass.states.get(entity_id) assert state.attributes[ATTR_TEMPERATURE] == 20