diff --git a/homeassistant/components/airzone/manifest.json b/homeassistant/components/airzone/manifest.json index 3f8fbc5647b..821f564f176 100644 --- a/homeassistant/components/airzone/manifest.json +++ b/homeassistant/components/airzone/manifest.json @@ -3,7 +3,7 @@ "name": "Airzone", "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/airzone", - "requirements": ["aioairzone==0.2.3"], + "requirements": ["aioairzone==0.3.3"], "codeowners": ["@Noltari"], "iot_class": "local_polling", "loggers": ["aioairzone"] diff --git a/homeassistant/components/elkm1/__init__.py b/homeassistant/components/elkm1/__init__.py index 6791c2ec1bb..3fed62e961e 100644 --- a/homeassistant/components/elkm1/__init__.py +++ b/homeassistant/components/elkm1/__init__.py @@ -363,6 +363,9 @@ async def async_wait_for_elk_to_sync( # VN is the first command sent for panel, when we get # it back we now we are logged in either with or without a password elk.add_handler("VN", first_response) + # Some panels do not respond to the vn request so we + # check for lw as well + elk.add_handler("LW", first_response) elk.add_handler("sync_complete", sync_complete) for name, event, timeout in ( ("login", login_event, login_timeout), diff --git a/homeassistant/components/energy/validate.py b/homeassistant/components/energy/validate.py index d9cd9a73aa0..e48a576f44e 100644 --- a/homeassistant/components/energy/validate.py +++ b/homeassistant/components/energy/validate.py @@ -489,7 +489,7 @@ async def async_validate(hass: HomeAssistant) -> EnergyPreferencesValidation: # Fetch the needed statistics metadata statistics_metadata.update( - await hass.async_add_executor_job( + await recorder.get_instance(hass).async_add_executor_job( functools.partial( recorder.statistics.get_metadata, hass, diff --git a/homeassistant/components/energy/websocket_api.py b/homeassistant/components/energy/websocket_api.py index 3d47d6e894b..ad77308b410 100644 --- a/homeassistant/components/energy/websocket_api.py +++ b/homeassistant/components/energy/websocket_api.py @@ -260,7 +260,7 @@ async def ws_get_fossil_energy_consumption( statistic_ids.append(msg["co2_statistic_id"]) # Fetch energy + CO2 statistics - statistics = await hass.async_add_executor_job( + statistics = await recorder.get_instance(hass).async_add_executor_job( recorder.statistics.statistics_during_period, hass, start_time, diff --git a/homeassistant/components/fritzbox/config_flow.py b/homeassistant/components/fritzbox/config_flow.py index 0841757d147..bf290cb28f7 100644 --- a/homeassistant/components/fritzbox/config_flow.py +++ b/homeassistant/components/fritzbox/config_flow.py @@ -1,6 +1,7 @@ """Config flow for AVM FRITZ!SmartHome.""" from __future__ import annotations +import ipaddress from typing import Any from urllib.parse import urlparse @@ -120,6 +121,12 @@ class FritzboxConfigFlow(ConfigFlow, domain=DOMAIN): assert isinstance(host, str) self.context[CONF_HOST] = host + if ( + ipaddress.ip_address(host).version == 6 + and ipaddress.ip_address(host).is_link_local + ): + return self.async_abort(reason="ignore_ip6_link_local") + if uuid := discovery_info.upnp.get(ssdp.ATTR_UPNP_UDN): if uuid.startswith("uuid:"): uuid = uuid[5:] diff --git a/homeassistant/components/fritzbox/strings.json b/homeassistant/components/fritzbox/strings.json index 336671fd7a8..738c454e237 100644 --- a/homeassistant/components/fritzbox/strings.json +++ b/homeassistant/components/fritzbox/strings.json @@ -28,6 +28,7 @@ "abort": { "already_in_progress": "[%key:common::config_flow::abort::already_in_progress%]", "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", + "ignore_ip6_link_local": "IPv6 link local address is not supported.", "no_devices_found": "[%key:common::config_flow::abort::no_devices_found%]", "not_supported": "Connected to AVM FRITZ!Box but it's unable to control Smart Home devices.", "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]" diff --git a/homeassistant/components/fritzbox/translations/en.json b/homeassistant/components/fritzbox/translations/en.json index 5eb34096da0..3d85504b1b4 100644 --- a/homeassistant/components/fritzbox/translations/en.json +++ b/homeassistant/components/fritzbox/translations/en.json @@ -3,6 +3,7 @@ "abort": { "already_configured": "Device is already configured", "already_in_progress": "Configuration flow is already in progress", + "ignore_ip6_link_local": "IPv6 link local address is not supported.", "no_devices_found": "No devices found on the network", "not_supported": "Connected to AVM FRITZ!Box but it's unable to control Smart Home devices.", "reauth_successful": "Re-authentication was successful" diff --git a/homeassistant/components/generic/config_flow.py b/homeassistant/components/generic/config_flow.py index d3b2a260477..df8946ccbad 100644 --- a/homeassistant/components/generic/config_flow.py +++ b/homeassistant/components/generic/config_flow.py @@ -109,6 +109,20 @@ def build_schema( return vol.Schema(spec) +def build_schema_content_type(user_input: dict[str, Any] | MappingProxyType[str, Any]): + """Create schema for conditional 2nd page specifying stream content_type.""" + return vol.Schema( + { + vol.Required( + CONF_CONTENT_TYPE, + description={ + "suggested_value": user_input.get(CONF_CONTENT_TYPE, "image/jpeg") + }, + ): str, + } + ) + + def get_image_type(image): """Get the format of downloaded bytes that could be an image.""" fmt = None @@ -129,14 +143,14 @@ async def async_test_still(hass, info) -> tuple[dict[str, str], str | None]: """Verify that the still image is valid before we create an entity.""" fmt = None if not (url := info.get(CONF_STILL_IMAGE_URL)): - return {}, None + return {}, info.get(CONF_CONTENT_TYPE, "image/jpeg") if not isinstance(url, template_helper.Template) and url: url = cv.template(url) url.hass = hass try: url = url.async_render(parse_result=False) except TemplateError as err: - _LOGGER.error("Error parsing template %s: %s", url, err) + _LOGGER.warning("Problem rendering template %s: %s", url, err) return {CONF_STILL_IMAGE_URL: "template_error"}, None verify_ssl = info.get(CONF_VERIFY_SSL) auth = generate_auth(info) @@ -228,6 +242,11 @@ class GenericIPCamConfigFlow(ConfigFlow, domain=DOMAIN): VERSION = 1 + def __init__(self): + """Initialize Generic ConfigFlow.""" + self.cached_user_input: dict[str, Any] = {} + self.cached_title = "" + @staticmethod def async_get_options_flow( config_entry: ConfigEntry, @@ -238,8 +257,8 @@ class GenericIPCamConfigFlow(ConfigFlow, domain=DOMAIN): def check_for_existing(self, options): """Check whether an existing entry is using the same URLs.""" return any( - entry.options[CONF_STILL_IMAGE_URL] == options[CONF_STILL_IMAGE_URL] - and entry.options[CONF_STREAM_SOURCE] == options[CONF_STREAM_SOURCE] + entry.options.get(CONF_STILL_IMAGE_URL) == options.get(CONF_STILL_IMAGE_URL) + and entry.options.get(CONF_STREAM_SOURCE) == options.get(CONF_STREAM_SOURCE) for entry in self._async_current_entries() ) @@ -264,10 +283,17 @@ class GenericIPCamConfigFlow(ConfigFlow, domain=DOMAIN): if not errors: user_input[CONF_CONTENT_TYPE] = still_format user_input[CONF_LIMIT_REFETCH_TO_URL_CHANGE] = False - await self.async_set_unique_id(self.flow_id) - return self.async_create_entry( - title=name, data={}, options=user_input - ) + if user_input.get(CONF_STILL_IMAGE_URL): + await self.async_set_unique_id(self.flow_id) + return self.async_create_entry( + title=name, data={}, options=user_input + ) + # If user didn't specify a still image URL, + # we can't (yet) autodetect it from the stream. + # Show a conditional 2nd page to ask them the content type. + self.cached_user_input = user_input + self.cached_title = name + return await self.async_step_content_type() else: user_input = DEFAULT_DATA.copy() @@ -277,12 +303,36 @@ class GenericIPCamConfigFlow(ConfigFlow, domain=DOMAIN): errors=errors, ) + async def async_step_content_type( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Handle the user's choice for stream content_type.""" + if user_input is not None: + user_input = self.cached_user_input | user_input + await self.async_set_unique_id(self.flow_id) + return self.async_create_entry( + title=self.cached_title, data={}, options=user_input + ) + return self.async_show_form( + step_id="content_type", + data_schema=build_schema_content_type({}), + errors={}, + ) + async def async_step_import(self, import_config) -> FlowResult: """Handle config import from yaml.""" # abort if we've already got this one. if self.check_for_existing(import_config): return self.async_abort(reason="already_exists") errors, still_format = await async_test_still(self.hass, import_config) + if errors.get(CONF_STILL_IMAGE_URL) == "template_error": + _LOGGER.warning( + "Could not render template, but it could be that " + "referenced entities are still initialising. " + "Continuing assuming that imported YAML template is valid" + ) + errors.pop(CONF_STILL_IMAGE_URL) + still_format = import_config.get(CONF_CONTENT_TYPE, "image/jpeg") errors = errors | await async_test_stream(self.hass, import_config) still_url = import_config.get(CONF_STILL_IMAGE_URL) stream_url = import_config.get(CONF_STREAM_SOURCE) @@ -308,6 +358,8 @@ class GenericOptionsFlowHandler(OptionsFlow): def __init__(self, config_entry: ConfigEntry) -> None: """Initialize Generic IP Camera options flow.""" self.config_entry = config_entry + self.cached_user_input: dict[str, Any] = {} + self.cached_title = "" async def async_step_init( self, user_input: dict[str, Any] | None = None @@ -316,29 +368,52 @@ class GenericOptionsFlowHandler(OptionsFlow): errors: dict[str, str] = {} if user_input is not None: - errors, still_format = await async_test_still(self.hass, user_input) + errors, still_format = await async_test_still( + self.hass, self.config_entry.options | user_input + ) errors = errors | await async_test_stream(self.hass, user_input) still_url = user_input.get(CONF_STILL_IMAGE_URL) stream_url = user_input.get(CONF_STREAM_SOURCE) if not errors: - return self.async_create_entry( - title=slug_url(still_url) or slug_url(stream_url) or DEFAULT_NAME, - data={ - CONF_AUTHENTICATION: user_input.get(CONF_AUTHENTICATION), - CONF_STREAM_SOURCE: user_input.get(CONF_STREAM_SOURCE), - CONF_PASSWORD: user_input.get(CONF_PASSWORD), - CONF_STILL_IMAGE_URL: user_input.get(CONF_STILL_IMAGE_URL), - CONF_CONTENT_TYPE: still_format, - CONF_USERNAME: user_input.get(CONF_USERNAME), - CONF_LIMIT_REFETCH_TO_URL_CHANGE: user_input[ - CONF_LIMIT_REFETCH_TO_URL_CHANGE - ], - CONF_FRAMERATE: user_input[CONF_FRAMERATE], - CONF_VERIFY_SSL: user_input[CONF_VERIFY_SSL], - }, - ) + title = slug_url(still_url) or slug_url(stream_url) or DEFAULT_NAME + data = { + CONF_AUTHENTICATION: user_input.get(CONF_AUTHENTICATION), + CONF_STREAM_SOURCE: user_input.get(CONF_STREAM_SOURCE), + CONF_PASSWORD: user_input.get(CONF_PASSWORD), + CONF_STILL_IMAGE_URL: user_input.get(CONF_STILL_IMAGE_URL), + CONF_CONTENT_TYPE: still_format + or self.config_entry.options.get(CONF_CONTENT_TYPE), + CONF_USERNAME: user_input.get(CONF_USERNAME), + CONF_LIMIT_REFETCH_TO_URL_CHANGE: user_input[ + CONF_LIMIT_REFETCH_TO_URL_CHANGE + ], + CONF_FRAMERATE: user_input[CONF_FRAMERATE], + CONF_VERIFY_SSL: user_input[CONF_VERIFY_SSL], + } + if still_url: + return self.async_create_entry( + title=title, + data=data, + ) + self.cached_title = title + self.cached_user_input = data + return await self.async_step_content_type() + return self.async_show_form( step_id="init", data_schema=build_schema(user_input or self.config_entry.options, True), errors=errors, ) + + async def async_step_content_type( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Handle the user's choice for stream content_type.""" + if user_input is not None: + user_input = self.cached_user_input | user_input + return self.async_create_entry(title=self.cached_title, data=user_input) + return self.async_show_form( + step_id="content_type", + data_schema=build_schema_content_type(self.cached_user_input), + errors={}, + ) diff --git a/homeassistant/components/generic/strings.json b/homeassistant/components/generic/strings.json index eb1bfcc3c55..01b1fe48a82 100644 --- a/homeassistant/components/generic/strings.json +++ b/homeassistant/components/generic/strings.json @@ -30,11 +30,16 @@ "limit_refetch_to_url_change": "Limit refetch to url change", "password": "[%key:common::config_flow::data::password%]", "username": "[%key:common::config_flow::data::username%]", - "content_type": "Content Type", "framerate": "Frame Rate (Hz)", "verify_ssl": "[%key:common::config_flow::data::verify_ssl%]" } }, + "content_type": { + "description": "Specify the content type for the stream.", + "data": { + "content_type": "Content Type" + } + }, "confirm": { "description": "[%key:common::config_flow::description::confirm_setup%]" } @@ -51,10 +56,15 @@ "limit_refetch_to_url_change": "[%key:component::generic::config::step::user::data::limit_refetch_to_url_change%]", "password": "[%key:common::config_flow::data::password%]", "username": "[%key:common::config_flow::data::username%]", - "content_type": "[%key:component::generic::config::step::user::data::content_type%]", "framerate": "[%key:component::generic::config::step::user::data::framerate%]", "verify_ssl": "[%key:common::config_flow::data::verify_ssl%]" } + }, + "content_type": { + "description": "[%key:component::generic::config::step::content_type::description%]", + "data": { + "content_type": "[%key:component::generic::config::step::content_type::data::content_type%]" + } } }, "error": { diff --git a/homeassistant/components/generic/translations/en.json b/homeassistant/components/generic/translations/en.json index 5346c2b5106..478ea76b476 100644 --- a/homeassistant/components/generic/translations/en.json +++ b/homeassistant/components/generic/translations/en.json @@ -23,10 +23,15 @@ "confirm": { "description": "Do you want to start set up?" }, + "content_type": { + "data": { + "content_type": "Content Type" + }, + "description": "Specify the content type for the stream." + }, "user": { "data": { "authentication": "Authentication", - "content_type": "Content Type", "framerate": "Frame Rate (Hz)", "limit_refetch_to_url_change": "Limit refetch to url change", "password": "Password", @@ -57,10 +62,15 @@ "unknown": "Unexpected error" }, "step": { + "content_type": { + "data": { + "content_type": "Content Type" + }, + "description": "Specify the content type for the stream." + }, "init": { "data": { "authentication": "Authentication", - "content_type": "Content Type", "framerate": "Frame Rate (Hz)", "limit_refetch_to_url_change": "Limit refetch to url change", "password": "Password", diff --git a/homeassistant/components/google/api.py b/homeassistant/components/google/api.py index ea3d23dcb01..80c66d7af0c 100644 --- a/homeassistant/components/google/api.py +++ b/homeassistant/components/google/api.py @@ -5,6 +5,7 @@ from __future__ import annotations from collections.abc import Awaitable, Callable import datetime import logging +import time from typing import Any from googleapiclient import discovery as google_discovery @@ -58,7 +59,7 @@ class DeviceAuth(config_entry_oauth2_flow.LocalOAuth2Implementation): "refresh_token": creds.refresh_token, "scope": " ".join(creds.scopes), "token_type": "Bearer", - "expires_in": creds.token_expiry.timestamp(), + "expires_in": creds.token_expiry.timestamp() - time.time(), } @@ -157,16 +158,16 @@ def _async_google_creds(hass: HomeAssistant, token: dict[str, Any]) -> Credentia client_id=conf[CONF_CLIENT_ID], client_secret=conf[CONF_CLIENT_SECRET], refresh_token=token["refresh_token"], - token_expiry=token["expires_at"], + token_expiry=datetime.datetime.fromtimestamp(token["expires_at"]), token_uri=oauth2client.GOOGLE_TOKEN_URI, scopes=[conf[CONF_CALENDAR_ACCESS].scope], user_agent=None, ) -def _api_time_format(time: datetime.datetime | None) -> str | None: +def _api_time_format(date_time: datetime.datetime | None) -> str | None: """Convert a datetime to the api string format.""" - return time.isoformat("T") if time else None + return date_time.isoformat("T") if date_time else None class GoogleCalendarService: diff --git a/homeassistant/components/google/calendar.py b/homeassistant/components/google/calendar.py index d04d913ae67..1174868bb78 100644 --- a/homeassistant/components/google/calendar.py +++ b/homeassistant/components/google/calendar.py @@ -183,7 +183,9 @@ class GoogleCalendarEventDevice(CalendarEventDevice): valid_items = filter(self._event_filter, items) self._event = copy.deepcopy(next(valid_items, None)) if self._event: - (summary, offset) = extract_offset(self._event["summary"], self._offset) + (summary, offset) = extract_offset( + self._event.get("summary", ""), self._offset + ) self._event["summary"] = summary self._offset_reached = is_offset_reached( get_date(self._event["start"]), offset diff --git a/homeassistant/components/hassio/__init__.py b/homeassistant/components/hassio/__init__.py index 7b8608a7fad..5f2cc386a87 100644 --- a/homeassistant/components/hassio/__init__.py +++ b/homeassistant/components/hassio/__init__.py @@ -824,7 +824,7 @@ class HassioDataUpdateCoordinator(DataUpdateCoordinator): self.data = {} self.entry_id = config_entry.entry_id self.dev_reg = dev_reg - self.is_hass_os = "hassos" in get_info(self.hass) + self.is_hass_os = (get_info(self.hass) or {}).get("hassos") is not None async def _async_update_data(self) -> dict[str, Any]: """Update data via library.""" @@ -891,6 +891,12 @@ class HassioDataUpdateCoordinator(DataUpdateCoordinator): if stale_addons := supervisor_addon_devices - set(new_data[DATA_KEY_ADDONS]): async_remove_addons_from_dev_reg(self.dev_reg, stale_addons) + if not self.is_hass_os and ( + dev := self.dev_reg.async_get_device({(DOMAIN, "OS")}) + ): + # Remove the OS device if it exists and the installation is not hassos + self.dev_reg.async_remove_device(dev.id) + # If there are new add-ons, we should reload the config entry so we can # create new devices and entities. We can return an empty dict because # coordinator will be recreated. diff --git a/homeassistant/components/homekit/config_flow.py b/homeassistant/components/homekit/config_flow.py index 79193cd3dac..b7a00bc3ade 100644 --- a/homeassistant/components/homekit/config_flow.py +++ b/homeassistant/components/homekit/config_flow.py @@ -652,7 +652,7 @@ def _exclude_by_entity_registry( (entry := ent_reg.async_get(entity_id)) and ( entry.hidden_by is not None - or (not include_entity_category or entry.entity_category is not None) + or (not include_entity_category and entry.entity_category is not None) ) ) diff --git a/homeassistant/components/onvif/device.py b/homeassistant/components/onvif/device.py index 1d08ec04f46..f45362c6e6c 100644 --- a/homeassistant/components/onvif/device.py +++ b/homeassistant/components/onvif/device.py @@ -204,9 +204,10 @@ class ONVIFDevice: if self._dt_diff_seconds > 5: LOGGER.warning( - "The date/time on the device (UTC) is '%s', " + "The date/time on %s (UTC) is '%s', " "which is different from the system '%s', " "this could lead to authentication issues", + self.name, cam_date_utc, system_date, ) diff --git a/homeassistant/components/powerwall/__init__.py b/homeassistant/components/powerwall/__init__.py index d2850330b7a..6537ea249c1 100644 --- a/homeassistant/components/powerwall/__init__.py +++ b/homeassistant/components/powerwall/__init__.py @@ -223,13 +223,18 @@ def call_base_info(power_wall: Powerwall, host: str) -> PowerwallBaseInfo: def _fetch_powerwall_data(power_wall: Powerwall) -> PowerwallData: """Process and update powerwall data.""" + try: + backup_reserve = power_wall.get_backup_reserve_percentage() + except MissingAttributeError: + backup_reserve = None + return PowerwallData( charge=power_wall.get_charge(), site_master=power_wall.get_sitemaster(), meters=power_wall.get_meters(), grid_services_active=power_wall.is_grid_services_active(), grid_status=power_wall.get_grid_status(), - backup_reserve=power_wall.get_backup_reserve_percentage(), + backup_reserve=backup_reserve, ) diff --git a/homeassistant/components/powerwall/models.py b/homeassistant/components/powerwall/models.py index cb9b84be16a..6f8ccb98459 100644 --- a/homeassistant/components/powerwall/models.py +++ b/homeassistant/components/powerwall/models.py @@ -38,7 +38,7 @@ class PowerwallData: meters: MetersAggregates grid_services_active: bool grid_status: GridStatus - backup_reserve: float + backup_reserve: float | None class PowerwallRuntimeData(TypedDict): diff --git a/homeassistant/components/powerwall/sensor.py b/homeassistant/components/powerwall/sensor.py index 9e66c61a2bb..573dcab6bcc 100644 --- a/homeassistant/components/powerwall/sensor.py +++ b/homeassistant/components/powerwall/sensor.py @@ -117,9 +117,11 @@ async def async_setup_entry( data: PowerwallData = coordinator.data entities: list[PowerWallEntity] = [ PowerWallChargeSensor(powerwall_data), - PowerWallBackupReserveSensor(powerwall_data), ] + if data.backup_reserve is not None: + entities.append(PowerWallBackupReserveSensor(powerwall_data)) + for meter in data.meters.meters: entities.append(PowerWallExportSensor(powerwall_data, meter)) entities.append(PowerWallImportSensor(powerwall_data, meter)) @@ -190,8 +192,10 @@ class PowerWallBackupReserveSensor(PowerWallEntity, SensorEntity): return f"{self.base_unique_id}_backup_reserve" @property - def native_value(self) -> int: + def native_value(self) -> int | None: """Get the current value in percentage.""" + if self.data.backup_reserve is None: + return None return round(self.data.backup_reserve) diff --git a/homeassistant/components/remote_rpi_gpio/manifest.json b/homeassistant/components/remote_rpi_gpio/manifest.json index 7e42611dedf..f1613b2ba6b 100644 --- a/homeassistant/components/remote_rpi_gpio/manifest.json +++ b/homeassistant/components/remote_rpi_gpio/manifest.json @@ -2,8 +2,8 @@ "domain": "remote_rpi_gpio", "name": "remote_rpi_gpio", "documentation": "https://www.home-assistant.io/integrations/remote_rpi_gpio", - "requirements": ["gpiozero==1.5.1"], + "requirements": ["gpiozero==1.6.2", "pigpio==1.78"], "codeowners": [], "iot_class": "local_push", - "loggers": ["gpiozero"] + "loggers": ["gpiozero", "pigpio"] } diff --git a/homeassistant/components/sun/__init__.py b/homeassistant/components/sun/__init__.py index c619607d5b2..94758d4e99f 100644 --- a/homeassistant/components/sun/__init__.py +++ b/homeassistant/components/sun/__init__.py @@ -130,9 +130,12 @@ class Sun(Entity): self._config_listener = self.hass.bus.async_listen( EVENT_CORE_CONFIG_UPDATE, self.update_location ) - self._loaded_listener = self.hass.bus.async_listen( - EVENT_COMPONENT_LOADED, self.loading_complete - ) + if DOMAIN in hass.config.components: + self.update_location() + else: + self._loaded_listener = self.hass.bus.async_listen( + EVENT_COMPONENT_LOADED, self.loading_complete + ) @callback def loading_complete(self, event_: Event) -> None: @@ -158,6 +161,7 @@ class Sun(Entity): """Remove the loaded listener.""" if self._loaded_listener: self._loaded_listener() + self._loaded_listener = None @callback def remove_listeners(self): diff --git a/homeassistant/components/tado/__init__.py b/homeassistant/components/tado/__init__.py index 9a5e1eb9c1e..0029dbf8c89 100644 --- a/homeassistant/components/tado/__init__.py +++ b/homeassistant/components/tado/__init__.py @@ -18,7 +18,10 @@ from homeassistant.util import Throttle from .const import ( CONF_FALLBACK, + CONST_OVERLAY_MANUAL, + CONST_OVERLAY_TADO_DEFAULT, CONST_OVERLAY_TADO_MODE, + CONST_OVERLAY_TADO_OPTIONS, DATA, DOMAIN, INSIDE_TEMPERATURE_MEASUREMENT, @@ -51,7 +54,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: username = entry.data[CONF_USERNAME] password = entry.data[CONF_PASSWORD] - fallback = entry.options.get(CONF_FALLBACK, CONST_OVERLAY_TADO_MODE) + fallback = entry.options.get(CONF_FALLBACK, CONST_OVERLAY_TADO_DEFAULT) tadoconnector = TadoConnector(hass, username, password, fallback) @@ -99,7 +102,16 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: def _async_import_options_from_data_if_missing(hass: HomeAssistant, entry: ConfigEntry): options = dict(entry.options) if CONF_FALLBACK not in options: - options[CONF_FALLBACK] = entry.data.get(CONF_FALLBACK, CONST_OVERLAY_TADO_MODE) + options[CONF_FALLBACK] = entry.data.get( + CONF_FALLBACK, CONST_OVERLAY_TADO_DEFAULT + ) + hass.config_entries.async_update_entry(entry, options=options) + + if options[CONF_FALLBACK] not in CONST_OVERLAY_TADO_OPTIONS: + if options[CONF_FALLBACK]: + options[CONF_FALLBACK] = CONST_OVERLAY_TADO_MODE + else: + options[CONF_FALLBACK] = CONST_OVERLAY_MANUAL hass.config_entries.async_update_entry(entry, options=options) diff --git a/homeassistant/components/tado/config_flow.py b/homeassistant/components/tado/config_flow.py index 95b415c5acc..a03b370d0a3 100644 --- a/homeassistant/components/tado/config_flow.py +++ b/homeassistant/components/tado/config_flow.py @@ -11,7 +11,13 @@ from homeassistant.const import CONF_PASSWORD, CONF_USERNAME from homeassistant.core import callback from homeassistant.data_entry_flow import FlowResult -from .const import CONF_FALLBACK, CONST_OVERLAY_TADO_OPTIONS, DOMAIN, UNIQUE_ID +from .const import ( + CONF_FALLBACK, + CONST_OVERLAY_TADO_DEFAULT, + CONST_OVERLAY_TADO_OPTIONS, + DOMAIN, + UNIQUE_ID, +) _LOGGER = logging.getLogger(__name__) @@ -126,7 +132,10 @@ class OptionsFlowHandler(config_entries.OptionsFlow): data_schema = vol.Schema( { vol.Optional( - CONF_FALLBACK, default=self.config_entry.options.get(CONF_FALLBACK) + CONF_FALLBACK, + default=self.config_entry.options.get( + CONF_FALLBACK, CONST_OVERLAY_TADO_DEFAULT + ), ): vol.In(CONST_OVERLAY_TADO_OPTIONS), } ) diff --git a/homeassistant/components/telegram_bot/broadcast.py b/homeassistant/components/telegram_bot/broadcast.py index dd0d4cd1f31..dff061da243 100644 --- a/homeassistant/components/telegram_bot/broadcast.py +++ b/homeassistant/components/telegram_bot/broadcast.py @@ -1,17 +1,6 @@ """Support for Telegram bot to send messages only.""" -import logging - -from . import initialize_bot - -_LOGGER = logging.getLogger(__name__) -async def async_setup_platform(hass, config): +async def async_setup_platform(hass, bot, config): """Set up the Telegram broadcast platform.""" - bot = initialize_bot(config) - - bot_config = await hass.async_add_executor_job(bot.getMe) - _LOGGER.debug( - "Telegram broadcast platform setup with bot %s", bot_config["username"] - ) return True diff --git a/homeassistant/components/tplink/light.py b/homeassistant/components/tplink/light.py index 9a67f8daf4b..32af7cf47be 100644 --- a/homeassistant/components/tplink/light.py +++ b/homeassistant/components/tplink/light.py @@ -103,7 +103,7 @@ RANDOM_EFFECT_DICT: Final = { vol.Optional("random_seed", default=100): vol.All( vol.Coerce(int), vol.Range(min=1, max=100) ), - vol.Required("backgrounds"): vol.All( + vol.Optional("backgrounds"): vol.All( cv.ensure_list, vol.Length(min=1, max=16), [vol.All(vol.Coerce(tuple), HSV_SEQUENCE)], @@ -366,7 +366,7 @@ class TPLinkSmartLightStrip(TPLinkSmartBulb): fadeoff: int, init_states: tuple[int, int, int], random_seed: int, - backgrounds: Sequence[tuple[int, int, int]], + backgrounds: Sequence[tuple[int, int, int]] | None = None, hue_range: tuple[int, int] | None = None, saturation_range: tuple[int, int] | None = None, brightness_range: tuple[int, int] | None = None, @@ -378,8 +378,9 @@ class TPLinkSmartLightStrip(TPLinkSmartBulb): "type": "random", "init_states": [init_states], "random_seed": random_seed, - "backgrounds": backgrounds, } + if backgrounds: + effect["backgrounds"] = backgrounds if fadeoff: effect["fadeoff"] = fadeoff if hue_range: diff --git a/homeassistant/components/tplink/services.yaml b/homeassistant/components/tplink/services.yaml index 26e002e0e30..6f2d9054b1e 100644 --- a/homeassistant/components/tplink/services.yaml +++ b/homeassistant/components/tplink/services.yaml @@ -93,7 +93,7 @@ random_effect: - [199, 89, 50] - [160, 50, 50] - [180, 100, 50] - required: true + required: false selector: object: segments: diff --git a/homeassistant/components/unifiprotect/config_flow.py b/homeassistant/components/unifiprotect/config_flow.py index 3039d5153e5..bd66b5f086a 100644 --- a/homeassistant/components/unifiprotect/config_flow.py +++ b/homeassistant/components/unifiprotect/config_flow.py @@ -116,9 +116,6 @@ class ProtectFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): self.hass.config_entries.async_update_entry( entry, data={**entry.data, CONF_HOST: new_host} ) - self.hass.async_create_task( - self.hass.config_entries.async_reload(entry.entry_id) - ) return self.async_abort(reason="already_configured") if entry_host in (direct_connect_domain, source_ip) or ( entry_has_direct_connect diff --git a/homeassistant/components/utility_meter/__init__.py b/homeassistant/components/utility_meter/__init__.py index ecc1110d7ff..5cb4d6f7abb 100644 --- a/homeassistant/components/utility_meter/__init__.py +++ b/homeassistant/components/utility_meter/__init__.py @@ -106,27 +106,24 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: async def async_reset_meters(service_call): """Reset all sensors of a meter.""" - entity_id = service_call.data["entity_id"] + meters = service_call.data["entity_id"] - domain = split_entity_id(entity_id)[0] - if domain == DOMAIN: - for entity in hass.data[DATA_LEGACY_COMPONENT].entities: - if entity_id == entity.entity_id: - _LOGGER.debug( - "forward reset meter from %s to %s", - entity_id, - entity.tracked_entity_id, - ) - entity_id = entity.tracked_entity_id - - _LOGGER.debug("reset meter %s", entity_id) - async_dispatcher_send(hass, SIGNAL_RESET_METER, entity_id) + for meter in meters: + _LOGGER.debug("resetting meter %s", meter) + domain, entity = split_entity_id(meter) + # backward compatibility up to 2022.07: + if domain == DOMAIN: + async_dispatcher_send( + hass, SIGNAL_RESET_METER, f"{SELECT_DOMAIN}.{entity}" + ) + else: + async_dispatcher_send(hass, SIGNAL_RESET_METER, meter) hass.services.async_register( DOMAIN, SERVICE_RESET, async_reset_meters, - vol.Schema({ATTR_ENTITY_ID: cv.entity_id}), + vol.Schema({ATTR_ENTITY_ID: vol.All(cv.ensure_list, [cv.entity_id])}), ) if DOMAIN not in config: diff --git a/homeassistant/components/utility_meter/services.yaml b/homeassistant/components/utility_meter/services.yaml index 32a6069d3bb..39f0c9056b5 100644 --- a/homeassistant/components/utility_meter/services.yaml +++ b/homeassistant/components/utility_meter/services.yaml @@ -6,7 +6,6 @@ reset: target: entity: domain: select - integration: utility_meter next_tariff: name: Next Tariff diff --git a/homeassistant/components/version/manifest.json b/homeassistant/components/version/manifest.json index 966286d0b3b..80b3f2fb3dc 100644 --- a/homeassistant/components/version/manifest.json +++ b/homeassistant/components/version/manifest.json @@ -2,7 +2,7 @@ "domain": "version", "name": "Version", "documentation": "https://www.home-assistant.io/integrations/version", - "requirements": ["pyhaversion==22.04.0"], + "requirements": ["pyhaversion==22.4.1"], "codeowners": ["@fabaff", "@ludeeus"], "quality_scale": "internal", "iot_class": "local_push", diff --git a/homeassistant/components/zha/api.py b/homeassistant/components/zha/api.py index c42384682da..faf8ccc5053 100644 --- a/homeassistant/components/zha/api.py +++ b/homeassistant/components/zha/api.py @@ -232,7 +232,7 @@ GROUP_MEMBER_SCHEMA = vol.All( vol.Schema( { vol.Required(ATTR_IEEE): IEEE_SCHEMA, - vol.Required(ATTR_ENDPOINT_ID): int, + vol.Required(ATTR_ENDPOINT_ID): vol.Coerce(int), } ), _cv_group_member, @@ -244,8 +244,8 @@ CLUSTER_BINDING_SCHEMA = vol.All( { vol.Required(ATTR_NAME): cv.string, vol.Required(ATTR_TYPE): cv.string, - vol.Required(ATTR_ID): int, - vol.Required(ATTR_ENDPOINT_ID): int, + vol.Required(ATTR_ID): vol.Coerce(int), + vol.Required(ATTR_ENDPOINT_ID): vol.Coerce(int), } ), _cv_cluster_binding, diff --git a/homeassistant/components/zha/core/channels/base.py b/homeassistant/components/zha/core/channels/base.py index 0dd6169373b..b10209f45f4 100644 --- a/homeassistant/components/zha/core/channels/base.py +++ b/homeassistant/components/zha/core/channels/base.py @@ -8,7 +8,12 @@ import logging from typing import Any import zigpy.exceptions -from zigpy.zcl.foundation import ConfigureReportingResponseRecord, Status +from zigpy.zcl.foundation import ( + CommandSchema, + ConfigureReportingResponseRecord, + Status, + ZCLAttributeDef, +) from homeassistant.const import ATTR_COMMAND from homeassistant.core import callback @@ -20,6 +25,7 @@ from ..const import ( ATTR_ATTRIBUTE_ID, ATTR_ATTRIBUTE_NAME, ATTR_CLUSTER_ID, + ATTR_PARAMS, ATTR_TYPE, ATTR_UNIQUE_ID, ATTR_VALUE, @@ -111,7 +117,11 @@ class ZigbeeChannel(LogMixin): if not hasattr(self, "_value_attribute") and self.REPORT_CONFIG: attr = self.REPORT_CONFIG[0].get("attr") if isinstance(attr, str): - self.value_attribute = self.cluster.attributes_by_name.get(attr) + attribute: ZCLAttributeDef = self.cluster.attributes_by_name.get(attr) + if attribute is not None: + self.value_attribute = attribute.id + else: + self.value_attribute = None else: self.value_attribute = attr self._status = ChannelStatus.CREATED @@ -354,14 +364,27 @@ class ZigbeeChannel(LogMixin): """Handle ZDO commands on this cluster.""" @callback - def zha_send_event(self, command: str, args: int | dict) -> None: + def zha_send_event(self, command: str, arg: list | dict | CommandSchema) -> None: """Relay events to hass.""" + + if isinstance(arg, CommandSchema): + args = [a for a in arg if a is not None] + params = arg.as_dict() + elif isinstance(arg, (list, dict)): + # Quirks can directly send lists and dicts to ZHA this way + args = arg + params = {} + else: + raise TypeError(f"Unexpected zha_send_event {command!r} argument: {arg!r}") + self._ch_pool.zha_send_event( { ATTR_UNIQUE_ID: self.unique_id, ATTR_CLUSTER_ID: self.cluster.cluster_id, ATTR_COMMAND: command, + # Maintain backwards compatibility with the old zigpy response format ATTR_ARGS: args, + ATTR_PARAMS: params, } ) diff --git a/homeassistant/components/zha/core/const.py b/homeassistant/components/zha/core/const.py index b3f06b9eba6..1e249ebd52b 100644 --- a/homeassistant/components/zha/core/const.py +++ b/homeassistant/components/zha/core/const.py @@ -43,6 +43,7 @@ ATTR_NEIGHBORS = "neighbors" ATTR_NODE_DESCRIPTOR = "node_descriptor" ATTR_NWK = "nwk" ATTR_OUT_CLUSTERS = "out_clusters" +ATTR_PARAMS = "params" ATTR_POWER_SOURCE = "power_source" ATTR_PROFILE_ID = "profile_id" ATTR_QUIRK_APPLIED = "quirk_applied" diff --git a/homeassistant/components/zha/core/device.py b/homeassistant/components/zha/core/device.py index e80a0725cc1..41d90b48869 100644 --- a/homeassistant/components/zha/core/device.py +++ b/homeassistant/components/zha/core/device.py @@ -661,7 +661,11 @@ class ZHADevice(LogMixin): async def async_add_to_group(self, group_id: int) -> None: """Add this device to the provided zigbee group.""" try: - await self._zigpy_device.add_to_group(group_id) + # A group name is required. However, the spec also explicitly states that + # the group name can be ignored by the receiving device if a device cannot + # store it, so we cannot rely on it existing after being written. This is + # only done to make the ZCL command valid. + await self._zigpy_device.add_to_group(group_id, name=f"0x{group_id:04X}") except (zigpy.exceptions.ZigbeeException, asyncio.TimeoutError) as ex: self.debug( "Failed to add device '%s' to group: 0x%04x ex: %s", @@ -687,7 +691,9 @@ class ZHADevice(LogMixin): ) -> None: """Add the device endpoint to the provided zigbee group.""" try: - await self._zigpy_device.endpoints[endpoint_id].add_to_group(group_id) + await self._zigpy_device.endpoints[endpoint_id].add_to_group( + group_id, name=f"0x{group_id:04X}" + ) except (zigpy.exceptions.ZigbeeException, asyncio.TimeoutError) as ex: self.debug( "Failed to add endpoint: %s for device: '%s' to group: 0x%04x ex: %s", diff --git a/homeassistant/components/zha/core/group.py b/homeassistant/components/zha/core/group.py index af17f28e622..1392041c4d4 100644 --- a/homeassistant/components/zha/core/group.py +++ b/homeassistant/components/zha/core/group.py @@ -2,7 +2,6 @@ from __future__ import annotations import asyncio -import collections import logging from typing import TYPE_CHECKING, Any, NamedTuple @@ -30,9 +29,12 @@ class GroupMember(NamedTuple): endpoint_id: int -GroupEntityReference = collections.namedtuple( - "GroupEntityReference", "name original_name entity_id" -) +class GroupEntityReference(NamedTuple): + """Reference to a group entity.""" + + name: str + original_name: str + entity_id: int class ZHAGroupMember(LogMixin): diff --git a/homeassistant/components/zha/manifest.json b/homeassistant/components/zha/manifest.json index fcf6a126963..14000fac875 100644 --- a/homeassistant/components/zha/manifest.json +++ b/homeassistant/components/zha/manifest.json @@ -7,7 +7,7 @@ "bellows==0.29.0", "pyserial==3.5", "pyserial-asyncio==0.6", - "zha-quirks==0.0.69", + "zha-quirks==0.0.71", "zigpy-deconz==0.15.0", "zigpy==0.44.1", "zigpy-xbee==0.14.0", diff --git a/homeassistant/components/zwave_js/api.py b/homeassistant/components/zwave_js/api.py index 0e947de982b..9cd79ecb27b 100644 --- a/homeassistant/components/zwave_js/api.py +++ b/homeassistant/components/zwave_js/api.py @@ -496,6 +496,7 @@ async def websocket_node_metadata( "wakeup": node.device_config.metadata.wakeup, "reset": node.device_config.metadata.reset, "device_database_url": node.device_database_url, + "comments": node.device_config.metadata.comments, } connection.send_result( msg[ID], diff --git a/homeassistant/const.py b/homeassistant/const.py index 53e52f11e67..b84dee7274c 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -7,7 +7,7 @@ from .backports.enum import StrEnum MAJOR_VERSION: Final = 2022 MINOR_VERSION: Final = 4 -PATCH_VERSION: Final = "0" +PATCH_VERSION: Final = "1" __short_version__: Final = f"{MAJOR_VERSION}.{MINOR_VERSION}" __version__: Final = f"{__short_version__}.{PATCH_VERSION}" REQUIRED_PYTHON_VER: Final[tuple[int, int, int]] = (3, 9, 0) diff --git a/requirements_all.txt b/requirements_all.txt index 9dea0b1d92e..425573c4c61 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -110,7 +110,7 @@ aio_geojson_nsw_rfs_incidents==0.4 aio_georss_gdacs==0.5 # homeassistant.components.airzone -aioairzone==0.2.3 +aioairzone==0.3.3 # homeassistant.components.ambient_station aioambient==2021.11.0 @@ -740,7 +740,7 @@ googlemaps==2.5.1 goslide-api==0.5.1 # homeassistant.components.remote_rpi_gpio -gpiozero==1.5.1 +gpiozero==1.6.2 # homeassistant.components.gpsd gps3==0.33.3 @@ -1198,6 +1198,9 @@ phone_modem==0.1.1 # homeassistant.components.onewire pi1wire==0.1.0 +# homeassistant.components.remote_rpi_gpio +pigpio==1.78 + # homeassistant.components.pilight pilight==0.1.1 @@ -1511,7 +1514,7 @@ pygtfs==0.1.6 pygti==0.9.2 # homeassistant.components.version -pyhaversion==22.04.0 +pyhaversion==22.4.1 # homeassistant.components.heos pyheos==0.7.2 @@ -2470,7 +2473,7 @@ zengge==0.2 zeroconf==0.38.4 # homeassistant.components.zha -zha-quirks==0.0.69 +zha-quirks==0.0.71 # homeassistant.components.zhong_hong zhong_hong_hvac==1.0.9 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index d9ff1a51483..e3baebd2e9d 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -94,7 +94,7 @@ aio_geojson_nsw_rfs_incidents==0.4 aio_georss_gdacs==0.5 # homeassistant.components.airzone -aioairzone==0.2.3 +aioairzone==0.3.3 # homeassistant.components.ambient_station aioambient==2021.11.0 @@ -999,7 +999,7 @@ pygatt[GATTTOOL]==4.0.5 pygti==0.9.2 # homeassistant.components.version -pyhaversion==22.04.0 +pyhaversion==22.4.1 # homeassistant.components.heos pyheos==0.7.2 @@ -1601,7 +1601,7 @@ youless-api==0.16 zeroconf==0.38.4 # homeassistant.components.zha -zha-quirks==0.0.69 +zha-quirks==0.0.71 # homeassistant.components.zha zigpy-deconz==0.15.0 diff --git a/setup.cfg b/setup.cfg index 779f517a1fd..2420a7607d0 100644 --- a/setup.cfg +++ b/setup.cfg @@ -1,6 +1,6 @@ [metadata] name = homeassistant -version = 2022.4.0 +version = 2022.4.1 author = The Home Assistant Authors author_email = hello@home-assistant.io license = Apache-2.0 diff --git a/tests/components/airzone/test_config_flow.py b/tests/components/airzone/test_config_flow.py index a6612d6de9c..08eb35ef52b 100644 --- a/tests/components/airzone/test_config_flow.py +++ b/tests/components/airzone/test_config_flow.py @@ -2,7 +2,7 @@ from unittest.mock import MagicMock, patch -from aiohttp.client_exceptions import ClientConnectorError +from aiohttp.client_exceptions import ClientConnectorError, ClientResponseError from homeassistant import data_entry_flow from homeassistant.components.airzone.const import DOMAIN @@ -23,6 +23,12 @@ async def test_form(hass): ) as mock_setup_entry, patch( "homeassistant.components.airzone.AirzoneLocalApi.get_hvac", return_value=HVAC_MOCK, + ), patch( + "homeassistant.components.airzone.AirzoneLocalApi.get_hvac_systems", + side_effect=ClientResponseError(MagicMock(), MagicMock()), + ), patch( + "homeassistant.components.airzone.AirzoneLocalApi.get_webserver", + side_effect=ClientResponseError(MagicMock(), MagicMock()), ): result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_USER} diff --git a/tests/components/fritzbox/const.py b/tests/components/fritzbox/const.py index 58ad5ae177c..1725d974c6f 100644 --- a/tests/components/fritzbox/const.py +++ b/tests/components/fritzbox/const.py @@ -6,7 +6,7 @@ MOCK_CONFIG = { DOMAIN: { CONF_DEVICES: [ { - CONF_HOST: "fake_host", + CONF_HOST: "10.0.0.1", CONF_PASSWORD: "fake_pass", CONF_USERNAME: "fake_user", } diff --git a/tests/components/fritzbox/test_config_flow.py b/tests/components/fritzbox/test_config_flow.py index a3b258d405e..442b2f4d568 100644 --- a/tests/components/fritzbox/test_config_flow.py +++ b/tests/components/fritzbox/test_config_flow.py @@ -2,6 +2,7 @@ import dataclasses from unittest import mock from unittest.mock import Mock, patch +from urllib.parse import urlparse from pyfritzhome import LoginError import pytest @@ -24,15 +25,35 @@ from .const import CONF_FAKE_NAME, MOCK_CONFIG from tests.common import MockConfigEntry MOCK_USER_DATA = MOCK_CONFIG[DOMAIN][CONF_DEVICES][0] -MOCK_SSDP_DATA = ssdp.SsdpServiceInfo( - ssdp_usn="mock_usn", - ssdp_st="mock_st", - ssdp_location="https://fake_host:12345/test", - upnp={ - ATTR_UPNP_FRIENDLY_NAME: CONF_FAKE_NAME, - ATTR_UPNP_UDN: "uuid:only-a-test", - }, -) +MOCK_SSDP_DATA = { + "ip4_valid": ssdp.SsdpServiceInfo( + ssdp_usn="mock_usn", + ssdp_st="mock_st", + ssdp_location="https://10.0.0.1:12345/test", + upnp={ + ATTR_UPNP_FRIENDLY_NAME: CONF_FAKE_NAME, + ATTR_UPNP_UDN: "uuid:only-a-test", + }, + ), + "ip6_valid": ssdp.SsdpServiceInfo( + ssdp_usn="mock_usn", + ssdp_st="mock_st", + ssdp_location="https://[1234::1]:12345/test", + upnp={ + ATTR_UPNP_FRIENDLY_NAME: CONF_FAKE_NAME, + ATTR_UPNP_UDN: "uuid:only-a-test", + }, + ), + "ip6_invalid": ssdp.SsdpServiceInfo( + ssdp_usn="mock_usn", + ssdp_st="mock_st", + ssdp_location="https://[fe80::1%1]:12345/test", + upnp={ + ATTR_UPNP_FRIENDLY_NAME: CONF_FAKE_NAME, + ATTR_UPNP_UDN: "uuid:only-a-test", + }, + ), +} @pytest.fixture(name="fritz") @@ -56,8 +77,8 @@ async def test_user(hass: HomeAssistant, fritz: Mock): result["flow_id"], user_input=MOCK_USER_DATA ) assert result["type"] == RESULT_TYPE_CREATE_ENTRY - assert result["title"] == "fake_host" - assert result["data"][CONF_HOST] == "fake_host" + assert result["title"] == "10.0.0.1" + assert result["data"][CONF_HOST] == "10.0.0.1" assert result["data"][CONF_PASSWORD] == "fake_pass" assert result["data"][CONF_USERNAME] == "fake_user" assert not result["result"].unique_id @@ -183,12 +204,29 @@ async def test_reauth_not_successful(hass: HomeAssistant, fritz: Mock): assert result["reason"] == "no_devices_found" -async def test_ssdp(hass: HomeAssistant, fritz: Mock): +@pytest.mark.parametrize( + "test_data,expected_result", + [ + (MOCK_SSDP_DATA["ip4_valid"], RESULT_TYPE_FORM), + (MOCK_SSDP_DATA["ip6_valid"], RESULT_TYPE_FORM), + (MOCK_SSDP_DATA["ip6_invalid"], RESULT_TYPE_ABORT), + ], +) +async def test_ssdp( + hass: HomeAssistant, + fritz: Mock, + test_data: ssdp.SsdpServiceInfo, + expected_result: str, +): """Test starting a flow from discovery.""" result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": SOURCE_SSDP}, data=MOCK_SSDP_DATA + DOMAIN, context={"source": SOURCE_SSDP}, data=test_data ) - assert result["type"] == RESULT_TYPE_FORM + assert result["type"] == expected_result + + if expected_result == RESULT_TYPE_ABORT: + return + assert result["step_id"] == "confirm" result = await hass.config_entries.flow.async_configure( @@ -197,7 +235,7 @@ async def test_ssdp(hass: HomeAssistant, fritz: Mock): ) assert result["type"] == RESULT_TYPE_CREATE_ENTRY assert result["title"] == CONF_FAKE_NAME - assert result["data"][CONF_HOST] == "fake_host" + assert result["data"][CONF_HOST] == urlparse(test_data.ssdp_location).hostname assert result["data"][CONF_PASSWORD] == "fake_pass" assert result["data"][CONF_USERNAME] == "fake_user" assert result["result"].unique_id == "only-a-test" @@ -205,7 +243,7 @@ async def test_ssdp(hass: HomeAssistant, fritz: Mock): async def test_ssdp_no_friendly_name(hass: HomeAssistant, fritz: Mock): """Test starting a flow from discovery without friendly name.""" - MOCK_NO_NAME = dataclasses.replace(MOCK_SSDP_DATA) + MOCK_NO_NAME = dataclasses.replace(MOCK_SSDP_DATA["ip4_valid"]) MOCK_NO_NAME.upnp = MOCK_NO_NAME.upnp.copy() del MOCK_NO_NAME.upnp[ATTR_UPNP_FRIENDLY_NAME] result = await hass.config_entries.flow.async_init( @@ -219,8 +257,8 @@ async def test_ssdp_no_friendly_name(hass: HomeAssistant, fritz: Mock): user_input={CONF_PASSWORD: "fake_pass", CONF_USERNAME: "fake_user"}, ) assert result["type"] == RESULT_TYPE_CREATE_ENTRY - assert result["title"] == "fake_host" - assert result["data"][CONF_HOST] == "fake_host" + assert result["title"] == "10.0.0.1" + assert result["data"][CONF_HOST] == "10.0.0.1" assert result["data"][CONF_PASSWORD] == "fake_pass" assert result["data"][CONF_USERNAME] == "fake_user" assert result["result"].unique_id == "only-a-test" @@ -231,7 +269,7 @@ async def test_ssdp_auth_failed(hass: HomeAssistant, fritz: Mock): fritz().login.side_effect = LoginError("Boom") result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": SOURCE_SSDP}, data=MOCK_SSDP_DATA + DOMAIN, context={"source": SOURCE_SSDP}, data=MOCK_SSDP_DATA["ip4_valid"] ) assert result["type"] == RESULT_TYPE_FORM assert result["step_id"] == "confirm" @@ -251,7 +289,7 @@ async def test_ssdp_not_successful(hass: HomeAssistant, fritz: Mock): fritz().login.side_effect = OSError("Boom") result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": SOURCE_SSDP}, data=MOCK_SSDP_DATA + DOMAIN, context={"source": SOURCE_SSDP}, data=MOCK_SSDP_DATA["ip4_valid"] ) assert result["type"] == RESULT_TYPE_FORM assert result["step_id"] == "confirm" @@ -269,7 +307,7 @@ async def test_ssdp_not_supported(hass: HomeAssistant, fritz: Mock): fritz().get_device_elements.side_effect = HTTPError("Boom") result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": SOURCE_SSDP}, data=MOCK_SSDP_DATA + DOMAIN, context={"source": SOURCE_SSDP}, data=MOCK_SSDP_DATA["ip4_valid"] ) assert result["type"] == RESULT_TYPE_FORM assert result["step_id"] == "confirm" @@ -285,13 +323,13 @@ async def test_ssdp_not_supported(hass: HomeAssistant, fritz: Mock): async def test_ssdp_already_in_progress_unique_id(hass: HomeAssistant, fritz: Mock): """Test starting a flow from discovery twice.""" result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": SOURCE_SSDP}, data=MOCK_SSDP_DATA + DOMAIN, context={"source": SOURCE_SSDP}, data=MOCK_SSDP_DATA["ip4_valid"] ) assert result["type"] == RESULT_TYPE_FORM assert result["step_id"] == "confirm" result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": SOURCE_SSDP}, data=MOCK_SSDP_DATA + DOMAIN, context={"source": SOURCE_SSDP}, data=MOCK_SSDP_DATA["ip4_valid"] ) assert result["type"] == RESULT_TYPE_ABORT assert result["reason"] == "already_in_progress" @@ -300,12 +338,12 @@ async def test_ssdp_already_in_progress_unique_id(hass: HomeAssistant, fritz: Mo async def test_ssdp_already_in_progress_host(hass: HomeAssistant, fritz: Mock): """Test starting a flow from discovery twice.""" result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": SOURCE_SSDP}, data=MOCK_SSDP_DATA + DOMAIN, context={"source": SOURCE_SSDP}, data=MOCK_SSDP_DATA["ip4_valid"] ) assert result["type"] == RESULT_TYPE_FORM assert result["step_id"] == "confirm" - MOCK_NO_UNIQUE_ID = dataclasses.replace(MOCK_SSDP_DATA) + MOCK_NO_UNIQUE_ID = dataclasses.replace(MOCK_SSDP_DATA["ip4_valid"]) MOCK_NO_UNIQUE_ID.upnp = MOCK_NO_UNIQUE_ID.upnp.copy() del MOCK_NO_UNIQUE_ID.upnp[ATTR_UPNP_UDN] result = await hass.config_entries.flow.async_init( @@ -324,7 +362,7 @@ async def test_ssdp_already_configured(hass: HomeAssistant, fritz: Mock): assert not result["result"].unique_id result2 = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": SOURCE_SSDP}, data=MOCK_SSDP_DATA + DOMAIN, context={"source": SOURCE_SSDP}, data=MOCK_SSDP_DATA["ip4_valid"] ) assert result2["type"] == RESULT_TYPE_ABORT assert result2["reason"] == "already_configured" diff --git a/tests/components/fritzbox/test_init.py b/tests/components/fritzbox/test_init.py index f69d7256e23..fdf787d4cf2 100644 --- a/tests/components/fritzbox/test_init.py +++ b/tests/components/fritzbox/test_init.py @@ -35,12 +35,12 @@ async def test_setup(hass: HomeAssistant, fritz: Mock): entries = hass.config_entries.async_entries() assert entries assert len(entries) == 1 - assert entries[0].data[CONF_HOST] == "fake_host" + assert entries[0].data[CONF_HOST] == "10.0.0.1" assert entries[0].data[CONF_PASSWORD] == "fake_pass" assert entries[0].data[CONF_USERNAME] == "fake_user" assert fritz.call_count == 1 assert fritz.call_args_list == [ - call(host="fake_host", password="fake_pass", user="fake_user") + call(host="10.0.0.1", password="fake_pass", user="fake_user") ] diff --git a/tests/components/generic/test_config_flow.py b/tests/components/generic/test_config_flow.py index 7e6c55dafff..548b09cf0bb 100644 --- a/tests/components/generic/test_config_flow.py +++ b/tests/components/generic/test_config_flow.py @@ -10,6 +10,7 @@ import pytest import respx from homeassistant import config_entries, data_entry_flow, setup +from homeassistant.components.camera import async_get_image from homeassistant.components.generic.const import ( CONF_CONTENT_TYPE, CONF_FRAMERATE, @@ -191,7 +192,7 @@ async def test_form_rtsp_mode(hass, fakeimg_png, mock_av_open, user_flow): assert len(mock_setup.mock_calls) == 1 -async def test_form_only_stream(hass, mock_av_open): +async def test_form_only_stream(hass, mock_av_open, fakeimgbytes_jpg): """Test we complete ok if the user wants stream only.""" await setup.async_setup_component(hass, "persistent_notification", {}) result = await hass.config_entries.flow.async_init( @@ -204,21 +205,34 @@ async def test_form_only_stream(hass, mock_av_open): result["flow_id"], data, ) - assert result2["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY - assert result2["title"] == "127_0_0_1_testurl_2" - assert result2["options"] == { + assert result2["type"] == data_entry_flow.RESULT_TYPE_FORM + result3 = await hass.config_entries.flow.async_configure( + result2["flow_id"], + {CONF_CONTENT_TYPE: "image/jpeg"}, + ) + + assert result3["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY + assert result3["title"] == "127_0_0_1_testurl_2" + assert result3["options"] == { CONF_AUTHENTICATION: HTTP_BASIC_AUTHENTICATION, CONF_STREAM_SOURCE: "http://127.0.0.1/testurl/2", CONF_RTSP_TRANSPORT: "tcp", CONF_USERNAME: "fred_flintstone", CONF_PASSWORD: "bambam", CONF_LIMIT_REFETCH_TO_URL_CHANGE: False, - CONF_CONTENT_TYPE: None, + CONF_CONTENT_TYPE: "image/jpeg", CONF_FRAMERATE: 5, CONF_VERIFY_SSL: False, } await hass.async_block_till_done() + + with patch( + "homeassistant.components.generic.camera.GenericCamera.async_camera_image", + return_value=fakeimgbytes_jpg, + ): + image_obj = await async_get_image(hass, "camera.127_0_0_1_testurl_2") + assert image_obj.content == fakeimgbytes_jpg assert len(mock_setup.mock_calls) == 1 @@ -478,6 +492,45 @@ async def test_options_template_error(hass, fakeimgbytes_png, mock_av_open): assert result4["errors"] == {"still_image_url": "template_error"} +@respx.mock +async def test_options_only_stream(hass, fakeimgbytes_png, mock_av_open): + """Test the options flow without a still_image_url.""" + respx.get("http://127.0.0.1/testurl/2").respond(stream=fakeimgbytes_png) + data = TESTDATA.copy() + data.pop(CONF_STILL_IMAGE_URL) + + mock_entry = MockConfigEntry( + title="Test Camera", + domain=DOMAIN, + data={}, + options=data, + ) + with mock_av_open: + mock_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_entry.entry_id) + await hass.async_block_till_done() + + result = await hass.config_entries.options.async_init(mock_entry.entry_id) + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["step_id"] == "init" + + # try updating the config options + result2 = await hass.config_entries.options.async_configure( + result["flow_id"], + user_input=data, + ) + # Should be shown a 2nd form + assert result2["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result2["step_id"] == "content_type" + + result3 = await hass.config_entries.options.async_configure( + result2["flow_id"], + user_input={CONF_CONTENT_TYPE: "image/png"}, + ) + assert result3["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY + assert result3["data"][CONF_CONTENT_TYPE] == "image/png" + + # These below can be deleted after deprecation period is finished. @respx.mock async def test_import(hass, fakeimg_png, mock_av_open): diff --git a/tests/components/google/test_calendar.py b/tests/components/google/test_calendar.py index 53e7308bc6f..a5aca8a27d4 100644 --- a/tests/components/google/test_calendar.py +++ b/tests/components/google/test_calendar.py @@ -272,6 +272,35 @@ async def test_all_day_offset_event(hass, mock_events_list_items, component_setu } +async def test_missing_summary(hass, mock_events_list_items, component_setup): + """Test that we can create an event trigger on device.""" + start_event = dt_util.now() + datetime.timedelta(minutes=14) + end_event = start_event + datetime.timedelta(minutes=60) + event = { + **TEST_EVENT, + "start": {"dateTime": start_event.isoformat()}, + "end": {"dateTime": end_event.isoformat()}, + } + del event["summary"] + mock_events_list_items([event]) + + assert await component_setup() + + state = hass.states.get(TEST_ENTITY) + assert state.name == TEST_ENTITY_NAME + assert state.state == STATE_OFF + assert dict(state.attributes) == { + "friendly_name": TEST_ENTITY_NAME, + "message": "", + "all_day": False, + "offset_reached": False, + "start_time": start_event.strftime(DATE_STR_FORMAT), + "end_time": end_event.strftime(DATE_STR_FORMAT), + "location": event["location"], + "description": event["description"], + } + + async def test_update_error( hass, calendar_resource, component_setup, test_api_calendar ): diff --git a/tests/components/google/test_config_flow.py b/tests/components/google/test_config_flow.py index a5467b95dcb..e96a4c3fd5f 100644 --- a/tests/components/google/test_config_flow.py +++ b/tests/components/google/test_config_flow.py @@ -97,6 +97,12 @@ async def test_full_flow( assert "data" in result data = result["data"] assert "token" in data + assert 0 < data["token"]["expires_in"] < 8 * 86400 + assert ( + datetime.datetime.now().timestamp() + <= data["token"]["expires_at"] + < (datetime.datetime.now() + datetime.timedelta(days=8)).timestamp() + ) data["token"].pop("expires_at") data["token"].pop("expires_in") assert data == { diff --git a/tests/components/hassio/test_binary_sensor.py b/tests/components/hassio/test_binary_sensor.py index a49b27ba4e7..0f4691e2795 100644 --- a/tests/components/hassio/test_binary_sensor.py +++ b/tests/components/hassio/test_binary_sensor.py @@ -24,7 +24,11 @@ def mock_all(aioclient_mock, request): "http://127.0.0.1/info", json={ "result": "ok", - "data": {"supervisor": "222", "homeassistant": "0.110.0", "hassos": None}, + "data": { + "supervisor": "222", + "homeassistant": "0.110.0", + "hassos": "1.2.3", + }, }, ) aioclient_mock.get( diff --git a/tests/components/hassio/test_init.py b/tests/components/hassio/test_init.py index 527c98b615e..2e7bea90f68 100644 --- a/tests/components/hassio/test_init.py +++ b/tests/components/hassio/test_init.py @@ -30,7 +30,11 @@ def mock_all(aioclient_mock, request): "http://127.0.0.1/info", json={ "result": "ok", - "data": {"supervisor": "222", "homeassistant": "0.110.0", "hassos": None}, + "data": { + "supervisor": "222", + "homeassistant": "0.110.0", + "hassos": "1.2.3", + }, }, ) aioclient_mock.get( @@ -396,14 +400,14 @@ async def test_service_calls(hassio_env, hass, aioclient_mock, caplog): ) await hass.async_block_till_done() - assert aioclient_mock.call_count == 8 + assert aioclient_mock.call_count == 9 assert aioclient_mock.mock_calls[-1][2] == "test" await hass.services.async_call("hassio", "host_shutdown", {}) await hass.services.async_call("hassio", "host_reboot", {}) await hass.async_block_till_done() - assert aioclient_mock.call_count == 10 + assert aioclient_mock.call_count == 11 await hass.services.async_call("hassio", "backup_full", {}) await hass.services.async_call( @@ -418,7 +422,7 @@ async def test_service_calls(hassio_env, hass, aioclient_mock, caplog): ) await hass.async_block_till_done() - assert aioclient_mock.call_count == 12 + assert aioclient_mock.call_count == 13 assert aioclient_mock.mock_calls[-1][2] == { "homeassistant": True, "addons": ["test"], @@ -442,7 +446,7 @@ async def test_service_calls(hassio_env, hass, aioclient_mock, caplog): ) await hass.async_block_till_done() - assert aioclient_mock.call_count == 14 + assert aioclient_mock.call_count == 15 assert aioclient_mock.mock_calls[-1][2] == { "addons": ["test"], "folders": ["ssl"], @@ -461,12 +465,12 @@ async def test_service_calls_core(hassio_env, hass, aioclient_mock): await hass.services.async_call("homeassistant", "stop") await hass.async_block_till_done() - assert aioclient_mock.call_count == 4 + assert aioclient_mock.call_count == 5 await hass.services.async_call("homeassistant", "check_config") await hass.async_block_till_done() - assert aioclient_mock.call_count == 4 + assert aioclient_mock.call_count == 5 with patch( "homeassistant.config.async_check_ha_config_file", return_value=None @@ -475,7 +479,7 @@ async def test_service_calls_core(hassio_env, hass, aioclient_mock): await hass.async_block_till_done() assert mock_check_config.called - assert aioclient_mock.call_count == 5 + assert aioclient_mock.call_count == 6 async def test_entry_load_and_unload(hass): @@ -628,10 +632,17 @@ async def test_device_registry_calls(hass): ), patch( "homeassistant.components.hassio.HassIO.get_os_info", return_value=os_mock_data, + ), patch( + "homeassistant.components.hassio.HassIO.get_info", + return_value={ + "supervisor": "222", + "homeassistant": "0.110.0", + "hassos": None, + }, ): async_fire_time_changed(hass, dt_util.now() + timedelta(hours=3)) await hass.async_block_till_done() - assert len(dev_reg.devices) == 5 + assert len(dev_reg.devices) == 4 async def test_coordinator_updates(hass, caplog): diff --git a/tests/components/hassio/test_sensor.py b/tests/components/hassio/test_sensor.py index 9dc620ba94f..382d804eaac 100644 --- a/tests/components/hassio/test_sensor.py +++ b/tests/components/hassio/test_sensor.py @@ -24,7 +24,11 @@ def mock_all(aioclient_mock, request): "http://127.0.0.1/info", json={ "result": "ok", - "data": {"supervisor": "222", "homeassistant": "0.110.0", "hassos": None}, + "data": { + "supervisor": "222", + "homeassistant": "0.110.0", + "hassos": "1.2.3", + }, }, ) aioclient_mock.get( diff --git a/tests/components/hassio/test_update.py b/tests/components/hassio/test_update.py index e682562297d..3407c26fc2f 100644 --- a/tests/components/hassio/test_update.py +++ b/tests/components/hassio/test_update.py @@ -25,7 +25,11 @@ def mock_all(aioclient_mock, request): "http://127.0.0.1/info", json={ "result": "ok", - "data": {"supervisor": "222", "homeassistant": "0.110.0", "hassos": None}, + "data": { + "supervisor": "222", + "homeassistant": "0.110.0", + "hassos": "1.2.3", + }, }, ) aioclient_mock.get( @@ -483,3 +487,25 @@ async def test_not_release_notes(hass, aioclient_mock, hass_ws_client): ) result = await client.receive_json() assert result["result"] is None + + +async def test_no_os_entity(hass): + """Test handling where there is no os entity.""" + with patch.dict(os.environ, MOCK_ENVIRON), patch( + "homeassistant.components.hassio.HassIO.get_info", + return_value={ + "supervisor": "222", + "homeassistant": "0.110.0", + "hassos": None, + }, + ): + result = await async_setup_component( + hass, + "hassio", + {"http": {"server_port": 9999, "server_host": "127.0.0.1"}, "hassio": {}}, + ) + assert result + await hass.async_block_till_done() + + # Verify that the entity does not exist + assert not hass.states.get("update.home_assistant_operating_system_update") diff --git a/tests/components/homekit/test_config_flow.py b/tests/components/homekit/test_config_flow.py index fc3ef3e2710..42ce6779528 100644 --- a/tests/components/homekit/test_config_flow.py +++ b/tests/components/homekit/test_config_flow.py @@ -1347,6 +1347,16 @@ async def test_options_flow_exclude_mode_skips_category_entities( entity_category=EntityCategory.CONFIG, ) hass.states.async_set(sonos_config_switch.entity_id, "off") + + sonos_notconfig_switch: RegistryEntry = entity_reg.async_get_or_create( + "switch", + "sonos", + "notconfig", + device_id="1234", + entity_category=None, + ) + hass.states.async_set(sonos_notconfig_switch.entity_id, "off") + await hass.async_block_till_done() result = await hass.config_entries.options.async_init( @@ -1391,14 +1401,24 @@ async def test_options_flow_exclude_mode_skips_category_entities( result4 = await hass.config_entries.options.async_configure( result2["flow_id"], - user_input={"entities": ["media_player.tv", "switch.other"]}, + user_input={ + "entities": [ + "media_player.tv", + "switch.other", + sonos_notconfig_switch.entity_id, + ] + }, ) assert result4["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY assert config_entry.options == { "mode": "bridge", "filter": { "exclude_domains": [], - "exclude_entities": ["media_player.tv", "switch.other"], + "exclude_entities": [ + "media_player.tv", + "switch.other", + sonos_notconfig_switch.entity_id, + ], "include_domains": ["media_player", "switch"], "include_entities": [], }, diff --git a/tests/components/powerwall/test_sensor.py b/tests/components/powerwall/test_sensor.py index c40d88fb252..f904f587ed3 100644 --- a/tests/components/powerwall/test_sensor.py +++ b/tests/components/powerwall/test_sensor.py @@ -1,5 +1,7 @@ """The sensor tests for the powerwall platform.""" -from unittest.mock import patch +from unittest.mock import Mock, patch + +from tesla_powerwall.error import MissingAttributeError from homeassistant.components.powerwall.const import DOMAIN from homeassistant.components.sensor import ATTR_STATE_CLASS @@ -112,3 +114,26 @@ async def test_sensors(hass, entity_registry_enabled_by_default): # HA changes the implementation and a new one appears for key, value in expected_attributes.items(): assert state.attributes[key] == value + + +async def test_sensor_backup_reserve_unavailable(hass): + """Confirm that backup reserve sensor is not added if data is unavailable from the device.""" + + mock_powerwall = await _mock_powerwall_with_fixtures(hass) + mock_powerwall.get_backup_reserve_percentage = Mock( + side_effect=MissingAttributeError(Mock(), "backup_reserve_percent", "operation") + ) + + config_entry = MockConfigEntry(domain=DOMAIN, data={CONF_IP_ADDRESS: "1.2.3.4"}) + config_entry.add_to_hass(hass) + with patch( + "homeassistant.components.powerwall.config_flow.Powerwall", + return_value=mock_powerwall, + ), patch( + "homeassistant.components.powerwall.Powerwall", return_value=mock_powerwall + ): + assert await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + state = hass.states.get("sensor.powerwall_backup_reserve") + assert state is None diff --git a/tests/components/telegram_bot/conftest.py b/tests/components/telegram_bot/conftest.py index 61818e4c377..d8d445fbb86 100644 --- a/tests/components/telegram_bot/conftest.py +++ b/tests/components/telegram_bot/conftest.py @@ -2,7 +2,6 @@ from unittest.mock import patch import pytest -from telegram.ext.dispatcher import Dispatcher from homeassistant.components.telegram_bot import ( CONF_ALLOWED_CHAT_IDS, @@ -176,12 +175,3 @@ async def polling_platform(hass, config_polling): config_polling, ) await hass.async_block_till_done() - - -@pytest.fixture(autouse=True) -def clear_dispatcher(): - """Clear the singleton that telegram.ext.dispatcher.Dispatcher sets on itself.""" - yield - Dispatcher._set_singleton(None) - # This is how python-telegram-bot resets the dispatcher in their test suite - Dispatcher._Dispatcher__singleton_semaphore.release() diff --git a/tests/components/telegram_bot/test_broadcast.py b/tests/components/telegram_bot/test_broadcast.py new file mode 100644 index 00000000000..22e4b8c6065 --- /dev/null +++ b/tests/components/telegram_bot/test_broadcast.py @@ -0,0 +1,20 @@ +"""Test Telegram broadcast.""" +from homeassistant.setup import async_setup_component + + +async def test_setup(hass): + """Test setting up Telegram broadcast.""" + assert await async_setup_component( + hass, + "telegram_bot", + { + "telegram_bot": { + "platform": "broadcast", + "api_key": "1234567890:ABC", + "allowed_chat_ids": [1], + } + }, + ) + await hass.async_block_till_done() + + assert hass.services.has_service("telegram_bot", "send_message") is True diff --git a/tests/components/telegram_bot/test_telegram_bot.py b/tests/components/telegram_bot/test_telegram_bot.py index 9b099a180f7..94d069a6bc5 100644 --- a/tests/components/telegram_bot/test_telegram_bot.py +++ b/tests/components/telegram_bot/test_telegram_bot.py @@ -1,4 +1,5 @@ """Tests for the telegram_bot component.""" +import pytest from telegram import Update from telegram.ext.dispatcher import Dispatcher @@ -8,6 +9,15 @@ from homeassistant.components.telegram_bot.webhooks import TELEGRAM_WEBHOOK_URL from tests.common import async_capture_events +@pytest.fixture(autouse=True) +def clear_dispatcher(): + """Clear the singleton that telegram.ext.dispatcher.Dispatcher sets on itself.""" + yield + Dispatcher._set_singleton(None) + # This is how python-telegram-bot resets the dispatcher in their test suite + Dispatcher._Dispatcher__singleton_semaphore.release() + + async def test_webhook_platform_init(hass, webhook_platform): """Test initialization of the webhooks platform.""" assert hass.services.has_service(DOMAIN, SERVICE_SEND_MESSAGE) is True diff --git a/tests/components/tplink/test_light.py b/tests/components/tplink/test_light.py index 9975aa1c660..c56bb6399fb 100644 --- a/tests/components/tplink/test_light.py +++ b/tests/components/tplink/test_light.py @@ -517,6 +517,33 @@ async def test_smart_strip_custom_random_effect(hass: HomeAssistant) -> None: ) strip.set_custom_effect.reset_mock() + await hass.services.async_call( + DOMAIN, + "random_effect", + { + ATTR_ENTITY_ID: entity_id, + "init_states": [340, 20, 50], + }, + blocking=True, + ) + strip.set_custom_effect.assert_called_once_with( + { + "custom": 1, + "id": "yMwcNpLxijmoKamskHCvvravpbnIqAIN", + "brightness": 100, + "name": "Custom", + "segments": [0], + "expansion_strategy": 1, + "enable": 1, + "duration": 0, + "transition": 0, + "type": "random", + "init_states": [[340, 20, 50]], + "random_seed": 100, + } + ) + strip.set_custom_effect.reset_mock() + strip.effect = { "custom": 1, "id": "yMwcNpLxijmoKamskHCvvravpbnIqAIN", diff --git a/tests/components/unifiprotect/test_config_flow.py b/tests/components/unifiprotect/test_config_flow.py index 4c7f12a69fa..78931aa460a 100644 --- a/tests/components/unifiprotect/test_config_flow.py +++ b/tests/components/unifiprotect/test_config_flow.py @@ -365,10 +365,7 @@ async def test_discovered_by_unifi_discovery_direct_connect_updated( ) mock_config.add_to_hass(hass) - with _patch_discovery(), patch( - "homeassistant.components.unifiprotect.async_setup_entry", - return_value=True, - ) as mock_setup_entry: + with _patch_discovery(): result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_INTEGRATION_DISCOVERY}, @@ -378,7 +375,6 @@ async def test_discovered_by_unifi_discovery_direct_connect_updated( assert result["type"] == RESULT_TYPE_ABORT assert result["reason"] == "already_configured" - assert len(mock_setup_entry.mock_calls) == 1 assert mock_config.data[CONF_HOST] == DIRECT_CONNECT_DOMAIN @@ -401,10 +397,7 @@ async def test_discovered_by_unifi_discovery_direct_connect_updated_but_not_usin ) mock_config.add_to_hass(hass) - with _patch_discovery(), patch( - "homeassistant.components.unifiprotect.async_setup_entry", - return_value=True, - ) as mock_setup_entry: + with _patch_discovery(): result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_INTEGRATION_DISCOVERY}, @@ -414,7 +407,6 @@ async def test_discovered_by_unifi_discovery_direct_connect_updated_but_not_usin assert result["type"] == RESULT_TYPE_ABORT assert result["reason"] == "already_configured" - assert len(mock_setup_entry.mock_calls) == 1 assert mock_config.data[CONF_HOST] == "127.0.0.1" diff --git a/tests/components/utility_meter/test_init.py b/tests/components/utility_meter/test_init.py index 853fd827f0a..d90c8e17997 100644 --- a/tests/components/utility_meter/test_init.py +++ b/tests/components/utility_meter/test_init.py @@ -65,7 +65,16 @@ async def test_restore_state(hass): assert state.state == "midpeak" -async def test_services(hass): +@pytest.mark.parametrize( + "meter", + ( + ["select.energy_bill"], + "select.energy_bill", + ["utility_meter.energy_bill"], + "utility_meter.energy_bill", + ), +) +async def test_services(hass, meter): """Test energy sensor reset service.""" config = { "utility_meter": { @@ -159,7 +168,7 @@ async def test_services(hass): assert state.state == "1" # Reset meters - data = {ATTR_ENTITY_ID: "select.energy_bill"} + data = {ATTR_ENTITY_ID: meter} await hass.services.async_call(DOMAIN, SERVICE_RESET, data) await hass.async_block_till_done() diff --git a/tests/components/zha/test_device_action.py b/tests/components/zha/test_device_action.py index 9bc52a784f6..e31f6b50fb5 100644 --- a/tests/components/zha/test_device_action.py +++ b/tests/components/zha/test_device_action.py @@ -140,3 +140,13 @@ async def test_action(hass, device_ias): assert calls[0].domain == DOMAIN assert calls[0].service == "warning_device_warn" assert calls[0].data["ieee"] == ieee_address + + +async def test_invalid_zha_event_type(hass, device_ias): + """Test that unexpected types are not passed to `zha_send_event`.""" + zigpy_device, zha_device = device_ias + channel = zha_device.channels.pools[0].client_channels["1:0x0006"] + + # `zha_send_event` accepts only zigpy responses, lists, and dicts + with pytest.raises(TypeError): + channel.zha_send_event(COMMAND_SINGLE, 123) diff --git a/tests/components/zwave_js/fixtures/wallmote_central_scene_state.json b/tests/components/zwave_js/fixtures/wallmote_central_scene_state.json index af5314002fa..e4d9f01341e 100644 --- a/tests/components/zwave_js/fixtures/wallmote_central_scene_state.json +++ b/tests/components/zwave_js/fixtures/wallmote_central_scene_state.json @@ -68,7 +68,11 @@ "inclusion": "To add the ZP3111 to the Z-Wave network (inclusion), place the Z-Wave primary controller into inclusion mode. Press the Program Switch of ZP3111 for sending the NIF. After sending NIF, Z-Wave will send the auto inclusion, otherwise, ZP3111 will go to sleep after 20 seconds.", "exclusion": "To remove the ZP3111 from the Z-Wave network (exclusion), place the Z-Wave primary controller into \u201cexclusion\u201d mode, and following its instruction to delete the ZP3111 to the controller. Press the Program Switch of ZP3111 once to be excluded.", "reset": "Remove cover to triggered tamper switch, LED flash once & send out Alarm Report. Press Program Switch 10 times within 10 seconds, ZP3111 will send the \u201cDevice Reset Locally Notification\u201d command and reset to the factory default. (Remark: This is to be used only in the case of primary controller being inoperable or otherwise unavailable.)", - "manual": "https://products.z-wavealliance.org/ProductManual/File?folder=&filename=MarketCertificationFiles/2479/ZP3111-5_R2_20170316.pdf" + "manual": "https://products.z-wavealliance.org/ProductManual/File?folder=&filename=MarketCertificationFiles/2479/ZP3111-5_R2_20170316.pdf", + "comments": { + "level": "info", + "text": "test" + } }, "isEmbedded": true }, diff --git a/tests/components/zwave_js/test_api.py b/tests/components/zwave_js/test_api.py index 1596b099ab1..7ec7d98217b 100644 --- a/tests/components/zwave_js/test_api.py +++ b/tests/components/zwave_js/test_api.py @@ -249,6 +249,7 @@ async def test_node_metadata(hass, wallmote_central_scene, integration, hass_ws_ result["device_database_url"] == "https://devices.zwave-js.io/?jumpTo=0x0086:0x0002:0x0082:0.0" ) + assert result["comments"] == [{"level": "info", "text": "test"}] # Test getting non-existent node fails await ws_client.send_json( diff --git a/tests/test_config_entries.py b/tests/test_config_entries.py index b62e9bffbce..2c3092427dc 100644 --- a/tests/test_config_entries.py +++ b/tests/test_config_entries.py @@ -8,8 +8,12 @@ import pytest from homeassistant import config_entries, data_entry_flow, loader from homeassistant.components.hassio import HassioServiceInfo -from homeassistant.const import EVENT_HOMEASSISTANT_STARTED, EVENT_HOMEASSISTANT_STOP -from homeassistant.core import CoreState, callback +from homeassistant.const import ( + EVENT_COMPONENT_LOADED, + EVENT_HOMEASSISTANT_STARTED, + EVENT_HOMEASSISTANT_STOP, +) +from homeassistant.core import CoreState, Event, callback from homeassistant.data_entry_flow import RESULT_TYPE_ABORT, BaseServiceInfo from homeassistant.exceptions import ( ConfigEntryAuthFailed, @@ -2299,6 +2303,72 @@ async def test_async_setup_init_entry(hass): assert entries[0].state is config_entries.ConfigEntryState.LOADED +async def test_async_setup_init_entry_completes_before_loaded_event_fires(hass): + """Test a config entry being initialized during integration setup before the loaded event fires.""" + + @callback + def _record_load(event: Event) -> None: + nonlocal load_events + load_events.append(event) + + listener = hass.bus.async_listen(EVENT_COMPONENT_LOADED, _record_load) + load_events: list[Event] = [] + + async def mock_async_setup(hass, config): + """Mock setup.""" + hass.async_create_task( + hass.config_entries.flow.async_init( + "comp", + context={"source": config_entries.SOURCE_IMPORT}, + data={}, + ) + ) + return True + + async_setup_entry = AsyncMock(return_value=True) + mock_integration( + hass, + MockModule( + "comp", async_setup=mock_async_setup, async_setup_entry=async_setup_entry + ), + ) + mock_entity_platform(hass, "config_flow.comp", None) + + class TestFlow(config_entries.ConfigFlow): + """Test flow.""" + + VERSION = 1 + + async def async_step_three(self, user_input=None): + """Test import step creating entry.""" + return self.async_create_entry(title="title", data={}) + + async def async_step_two(self, user_input=None): + """Test import step creating entry.""" + return await self.async_step_three() + + async def async_step_one(self, user_input=None): + """Test import step creating entry.""" + return await self.async_step_two() + + async def async_step_import(self, user_input=None): + """Test import step creating entry.""" + return await self.async_step_one() + + # This test must not use hass.async_block_till_done() + # as its explicitly testing what happens without it + with patch.dict(config_entries.HANDLERS, {"comp": TestFlow}): + assert await async_setup_component(hass, "comp", {}) + assert len(async_setup_entry.mock_calls) == 1 + assert load_events[0].event_type == EVENT_COMPONENT_LOADED + assert load_events[0].data == {"component": "comp"} + entries = hass.config_entries.async_entries("comp") + assert len(entries) == 1 + assert entries[0].state is config_entries.ConfigEntryState.LOADED + + listener() + + async def test_async_setup_update_entry(hass): """Test a config entry being updated during integration setup.""" entry = MockConfigEntry(domain="comp", data={"value": "initial"})