diff --git a/homeassistant/components/aftership/config_flow.py b/homeassistant/components/aftership/config_flow.py index 3da6ac9e3d5a9..9457809150187 100644 --- a/homeassistant/components/aftership/config_flow.py +++ b/homeassistant/components/aftership/config_flow.py @@ -10,7 +10,7 @@ from homeassistant.config_entries import ConfigFlow from homeassistant.const import CONF_API_KEY, CONF_NAME from homeassistant.core import DOMAIN as HOMEASSISTANT_DOMAIN -from homeassistant.data_entry_flow import AbortFlow, FlowResult +from homeassistant.data_entry_flow import FlowResult from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue @@ -51,25 +51,6 @@ async def async_step_user( async def async_step_import(self, config: dict[str, Any]) -> FlowResult: """Import configuration from yaml.""" - try: - self._async_abort_entries_match({CONF_API_KEY: config[CONF_API_KEY]}) - except AbortFlow as err: - async_create_issue( - self.hass, - DOMAIN, - "deprecated_yaml_import_issue_already_configured", - breaks_in_ha_version="2024.4.0", - is_fixable=False, - issue_domain=DOMAIN, - severity=IssueSeverity.WARNING, - translation_key="deprecated_yaml_import_issue_already_configured", - translation_placeholders={ - "domain": DOMAIN, - "integration_title": "AfterShip", - }, - ) - raise err - async_create_issue( self.hass, HOMEASSISTANT_DOMAIN, @@ -84,6 +65,8 @@ async def async_step_import(self, config: dict[str, Any]) -> FlowResult: "integration_title": "AfterShip", }, ) + + self._async_abort_entries_match({CONF_API_KEY: config[CONF_API_KEY]}) return self.async_create_entry( title=config.get(CONF_NAME, "AfterShip"), data={CONF_API_KEY: config[CONF_API_KEY]}, diff --git a/homeassistant/components/aftership/strings.json b/homeassistant/components/aftership/strings.json index b49c19976a635..ace8eb6d2d3a0 100644 --- a/homeassistant/components/aftership/strings.json +++ b/homeassistant/components/aftership/strings.json @@ -49,10 +49,6 @@ } }, "issues": { - "deprecated_yaml_import_issue_already_configured": { - "title": "The {integration_title} YAML configuration import failed", - "description": "Configuring {integration_title} using YAML is being removed but the YAML configuration was already imported.\n\nRemove the YAML configuration and restart Home Assistant." - }, "deprecated_yaml_import_issue_cannot_connect": { "title": "The {integration_title} YAML configuration import failed", "description": "Configuring {integration_title} using YAML is being removed but there was an connection error importing your YAML configuration.\n\nEnsure connection to {integration_title} works and restart Home Assistant to try again or remove the {integration_title} YAML configuration from your configuration.yaml file and continue to [set up the integration]({url}) manually." diff --git a/homeassistant/components/alexa/handlers.py b/homeassistant/components/alexa/handlers.py index f99b0231e4d4b..2796c10795b89 100644 --- a/homeassistant/components/alexa/handlers.py +++ b/homeassistant/components/alexa/handlers.py @@ -1304,13 +1304,14 @@ async def async_api_set_range( service = None data: dict[str, Any] = {ATTR_ENTITY_ID: entity.entity_id} range_value = directive.payload["rangeValue"] + supported = entity.attributes.get(ATTR_SUPPORTED_FEATURES, 0) # Cover Position if instance == f"{cover.DOMAIN}.{cover.ATTR_POSITION}": range_value = int(range_value) - if range_value == 0: + if supported & cover.CoverEntityFeature.CLOSE and range_value == 0: service = cover.SERVICE_CLOSE_COVER - elif range_value == 100: + elif supported & cover.CoverEntityFeature.OPEN and range_value == 100: service = cover.SERVICE_OPEN_COVER else: service = cover.SERVICE_SET_COVER_POSITION @@ -1319,9 +1320,9 @@ async def async_api_set_range( # Cover Tilt elif instance == f"{cover.DOMAIN}.tilt": range_value = int(range_value) - if range_value == 0: + if supported & cover.CoverEntityFeature.CLOSE_TILT and range_value == 0: service = cover.SERVICE_CLOSE_COVER_TILT - elif range_value == 100: + elif supported & cover.CoverEntityFeature.OPEN_TILT and range_value == 100: service = cover.SERVICE_OPEN_COVER_TILT else: service = cover.SERVICE_SET_COVER_TILT_POSITION @@ -1332,13 +1333,11 @@ async def async_api_set_range( range_value = int(range_value) if range_value == 0: service = fan.SERVICE_TURN_OFF + elif supported & fan.FanEntityFeature.SET_SPEED: + service = fan.SERVICE_SET_PERCENTAGE + data[fan.ATTR_PERCENTAGE] = range_value else: - supported = entity.attributes.get(ATTR_SUPPORTED_FEATURES, 0) - if supported and fan.FanEntityFeature.SET_SPEED: - service = fan.SERVICE_SET_PERCENTAGE - data[fan.ATTR_PERCENTAGE] = range_value - else: - service = fan.SERVICE_TURN_ON + service = fan.SERVICE_TURN_ON # Humidifier target humidity elif instance == f"{humidifier.DOMAIN}.{humidifier.ATTR_HUMIDITY}": diff --git a/homeassistant/components/assist_pipeline/pipeline.py b/homeassistant/components/assist_pipeline/pipeline.py index ed9029d1c2c1d..26d599da836ab 100644 --- a/homeassistant/components/assist_pipeline/pipeline.py +++ b/homeassistant/components/assist_pipeline/pipeline.py @@ -369,6 +369,7 @@ class PipelineStage(StrEnum): STT = "stt" INTENT = "intent" TTS = "tts" + END = "end" PIPELINE_STAGE_ORDER = [ @@ -1024,35 +1025,32 @@ async def text_to_speech(self, tts_input: str) -> None: ) ) - if tts_input := tts_input.strip(): - try: - # Synthesize audio and get URL - tts_media_id = tts_generate_media_source_id( - self.hass, - tts_input, - engine=self.tts_engine, - language=self.pipeline.tts_language, - options=self.tts_options, - ) - tts_media = await media_source.async_resolve_media( - self.hass, - tts_media_id, - None, - ) - except Exception as src_error: - _LOGGER.exception("Unexpected error during text-to-speech") - raise TextToSpeechError( - code="tts-failed", - message="Unexpected error during text-to-speech", - ) from src_error - - _LOGGER.debug("TTS result %s", tts_media) - tts_output = { - "media_id": tts_media_id, - **asdict(tts_media), - } - else: - tts_output = {} + try: + # Synthesize audio and get URL + tts_media_id = tts_generate_media_source_id( + self.hass, + tts_input, + engine=self.tts_engine, + language=self.pipeline.tts_language, + options=self.tts_options, + ) + tts_media = await media_source.async_resolve_media( + self.hass, + tts_media_id, + None, + ) + except Exception as src_error: + _LOGGER.exception("Unexpected error during text-to-speech") + raise TextToSpeechError( + code="tts-failed", + message="Unexpected error during text-to-speech", + ) from src_error + + _LOGGER.debug("TTS result %s", tts_media) + tts_output = { + "media_id": tts_media_id, + **asdict(tts_media), + } self.process_event( PipelineEvent(PipelineEventType.TTS_END, {"tts_output": tts_output}) @@ -1345,7 +1343,11 @@ async def buffer_then_audio_stream() -> ( self.conversation_id, self.device_id, ) - current_stage = PipelineStage.TTS + if tts_input.strip(): + current_stage = PipelineStage.TTS + else: + # Skip TTS + current_stage = PipelineStage.END if self.run.end_stage != PipelineStage.INTENT: # text-to-speech diff --git a/homeassistant/components/blink/services.py b/homeassistant/components/blink/services.py index 12ac0d3b859d0..dae2f0ad951cb 100644 --- a/homeassistant/components/blink/services.py +++ b/homeassistant/components/blink/services.py @@ -25,6 +25,11 @@ ) from .coordinator import BlinkUpdateCoordinator +SERVICE_UPDATE_SCHEMA = vol.Schema( + { + vol.Required(ATTR_DEVICE_ID): vol.All(cv.ensure_list, [cv.string]), + } +) SERVICE_SAVE_VIDEO_SCHEMA = vol.Schema( { vol.Required(ATTR_DEVICE_ID): vol.All(cv.ensure_list, [cv.string]), @@ -152,7 +157,7 @@ async def blink_refresh(call: ServiceCall): # Register all the above services service_mapping = [ - (blink_refresh, SERVICE_REFRESH, None), + (blink_refresh, SERVICE_REFRESH, SERVICE_UPDATE_SCHEMA), ( async_handle_save_video_service, SERVICE_SAVE_VIDEO, diff --git a/homeassistant/components/blink/services.yaml b/homeassistant/components/blink/services.yaml index 95f4d33f91f73..aaecde64353cb 100644 --- a/homeassistant/components/blink/services.yaml +++ b/homeassistant/components/blink/services.yaml @@ -1,14 +1,28 @@ # Describes the format for available Blink services blink_update: + fields: + device_id: + required: true + selector: + device: + integration: blink + trigger_camera: - target: - entity: - integration: blink - domain: camera + fields: + device_id: + required: true + selector: + device: + integration: blink save_video: fields: + device_id: + required: true + selector: + device: + integration: blink name: required: true example: "Living Room" @@ -22,6 +36,11 @@ save_video: save_recent_clips: fields: + device_id: + required: true + selector: + device: + integration: blink name: required: true example: "Living Room" @@ -35,6 +54,11 @@ save_recent_clips: send_pin: fields: + device_id: + required: true + selector: + device: + integration: blink pin: example: "abc123" selector: diff --git a/homeassistant/components/blink/strings.json b/homeassistant/components/blink/strings.json index f47f72acb9cf0..fc0450dc8ea2a 100644 --- a/homeassistant/components/blink/strings.json +++ b/homeassistant/components/blink/strings.json @@ -57,11 +57,23 @@ "services": { "blink_update": { "name": "Update", - "description": "Forces a refresh." + "description": "Forces a refresh.", + "fields": { + "device_id": { + "name": "Device ID", + "description": "The Blink device id." + } + } }, "trigger_camera": { "name": "Trigger camera", - "description": "Requests camera to take new image." + "description": "Requests camera to take new image.", + "fields": { + "device_id": { + "name": "Device ID", + "description": "The Blink device id." + } + } }, "save_video": { "name": "Save video", @@ -74,6 +86,10 @@ "filename": { "name": "File name", "description": "Filename to writable path (directory may need to be included in allowlist_external_dirs in config)." + }, + "device_id": { + "name": "Device ID", + "description": "The Blink device id." } } }, @@ -88,6 +104,10 @@ "file_path": { "name": "Output directory", "description": "Directory name of writable path (directory may need to be included in allowlist_external_dirs in config)." + }, + "device_id": { + "name": "Device ID", + "description": "The Blink device id." } } }, @@ -98,6 +118,10 @@ "pin": { "name": "Pin", "description": "PIN received from blink. Leave empty if you only received a verification email." + }, + "device_id": { + "name": "Device ID", + "description": "The Blink device id." } } } diff --git a/homeassistant/components/caldav/manifest.json b/homeassistant/components/caldav/manifest.json index a7365515758e1..619523ae7a152 100644 --- a/homeassistant/components/caldav/manifest.json +++ b/homeassistant/components/caldav/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/caldav", "iot_class": "cloud_polling", "loggers": ["caldav", "vobject"], - "requirements": ["caldav==1.3.6"] + "requirements": ["caldav==1.3.8"] } diff --git a/homeassistant/components/caldav/todo.py b/homeassistant/components/caldav/todo.py index 1bd24dc542af7..b7089c3da65d2 100644 --- a/homeassistant/components/caldav/todo.py +++ b/homeassistant/components/caldav/todo.py @@ -98,10 +98,7 @@ def _to_ics_fields(item: TodoItem) -> dict[str, Any]: if status := item.status: item_data["status"] = TODO_STATUS_MAP_INV.get(status, "NEEDS-ACTION") if due := item.due: - if isinstance(due, datetime): - item_data["due"] = dt_util.as_utc(due).strftime("%Y%m%dT%H%M%SZ") - else: - item_data["due"] = due.strftime("%Y%m%d") + item_data["due"] = due if description := item.description: item_data["description"] = description return item_data @@ -162,7 +159,10 @@ async def async_update_todo_item(self, item: TodoItem) -> None: except (requests.ConnectionError, DAVError) as err: raise HomeAssistantError(f"CalDAV lookup error: {err}") from err vtodo = todo.icalendar_component # type: ignore[attr-defined] - vtodo.update(**_to_ics_fields(item)) + updated_fields = _to_ics_fields(item) + if "due" in updated_fields: + todo.set_due(updated_fields.pop("due")) # type: ignore[attr-defined] + vtodo.update(**updated_fields) try: await self.hass.async_add_executor_job( partial( diff --git a/homeassistant/components/climate/intent.py b/homeassistant/components/climate/intent.py index 23cc3d5bcd206..4152fb5ee2d50 100644 --- a/homeassistant/components/climate/intent.py +++ b/homeassistant/components/climate/intent.py @@ -21,7 +21,7 @@ class GetTemperatureIntent(intent.IntentHandler): """Handle GetTemperature intents.""" intent_type = INTENT_GET_TEMPERATURE - slot_schema = {vol.Optional("area"): str} + slot_schema = {vol.Optional("area"): str, vol.Optional("name"): str} async def async_handle(self, intent_obj: intent.Intent) -> intent.IntentResponse: """Handle the intent.""" @@ -49,6 +49,20 @@ async def async_handle(self, intent_obj: intent.Intent) -> intent.IntentResponse if climate_state is None: raise intent.IntentHandleError(f"No climate entity in area {area_name}") + climate_entity = component.get_entity(climate_state.entity_id) + elif "name" in slots: + # Filter by name + entity_name = slots["name"]["value"] + + for maybe_climate in intent.async_match_states( + hass, name=entity_name, domains=[DOMAIN] + ): + climate_state = maybe_climate + break + + if climate_state is None: + raise intent.IntentHandleError(f"No climate entity named {entity_name}") + climate_entity = component.get_entity(climate_state.entity_id) else: # First entity diff --git a/homeassistant/components/enphase_envoy/coordinator.py b/homeassistant/components/enphase_envoy/coordinator.py index 75f2ef3928974..02a9d2f249191 100644 --- a/homeassistant/components/enphase_envoy/coordinator.py +++ b/homeassistant/components/enphase_envoy/coordinator.py @@ -144,7 +144,10 @@ async def _async_update_data(self) -> dict[str, Any]: if not self._setup_complete: await self._async_setup_and_authenticate() self._async_mark_setup_complete() - return (await envoy.update()).raw + # dump all received data in debug mode to assist troubleshooting + envoy_data = await envoy.update() + _LOGGER.debug("Envoy data: %s", envoy_data) + return envoy_data.raw except INVALID_AUTH_ERRORS as err: if self._setup_complete and tries == 0: # token likely expired or firmware changed, try to re-authenticate diff --git a/homeassistant/components/fastdotcom/__init__.py b/homeassistant/components/fastdotcom/__init__.py index 2fe5b3ccafc6a..56f9ba4fd5f5b 100644 --- a/homeassistant/components/fastdotcom/__init__.py +++ b/homeassistant/components/fastdotcom/__init__.py @@ -35,15 +35,16 @@ ) -async def async_setup_platform(hass: HomeAssistant, config: ConfigType) -> bool: +async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up the Fast.com component. (deprecated).""" - hass.async_create_task( - hass.config_entries.flow.async_init( - DOMAIN, - context={"source": SOURCE_IMPORT}, - data=config[DOMAIN], + if DOMAIN in config: + hass.async_create_task( + hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_IMPORT}, + data=config[DOMAIN], + ) ) - ) return True diff --git a/homeassistant/components/fitbit/application_credentials.py b/homeassistant/components/fitbit/application_credentials.py index caf0384eca269..caa47351f4567 100644 --- a/homeassistant/components/fitbit/application_credentials.py +++ b/homeassistant/components/fitbit/application_credentials.py @@ -60,7 +60,10 @@ async def _post(self, data: dict[str, Any]) -> dict[str, Any]: resp.raise_for_status() except aiohttp.ClientResponseError as err: if _LOGGER.isEnabledFor(logging.DEBUG): - error_body = await resp.text() if not session.closed else "" + try: + error_body = await resp.text() + except aiohttp.ClientError: + error_body = "" _LOGGER.debug( "Client response error status=%s, body=%s", err.status, error_body ) diff --git a/homeassistant/components/hive/manifest.json b/homeassistant/components/hive/manifest.json index 67da3617b4427..870223f8fe6e6 100644 --- a/homeassistant/components/hive/manifest.json +++ b/homeassistant/components/hive/manifest.json @@ -9,5 +9,5 @@ }, "iot_class": "cloud_polling", "loggers": ["apyhiveapi"], - "requirements": ["pyhiveapi==0.5.14"] + "requirements": ["pyhiveapi==0.5.16"] } diff --git a/homeassistant/components/improv_ble/config_flow.py b/homeassistant/components/improv_ble/config_flow.py index bfc86ac01629f..762f37ef5d4e6 100644 --- a/homeassistant/components/improv_ble/config_flow.py +++ b/homeassistant/components/improv_ble/config_flow.py @@ -405,7 +405,7 @@ async def _try_call(func: Coroutine[Any, Any, _T]) -> _T: raise AbortFlow("characteristic_missing") from err except improv_ble_errors.CommandFailed: raise - except Exception as err: # pylint: disable=broad-except + except Exception as err: _LOGGER.exception("Unexpected exception") raise AbortFlow("unknown") from err diff --git a/homeassistant/components/local_calendar/manifest.json b/homeassistant/components/local_calendar/manifest.json index d7b16ee3bef7f..f5a24e07b0cd1 100644 --- a/homeassistant/components/local_calendar/manifest.json +++ b/homeassistant/components/local_calendar/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/local_calendar", "iot_class": "local_polling", "loggers": ["ical"], - "requirements": ["ical==6.1.0"] + "requirements": ["ical==6.1.1"] } diff --git a/homeassistant/components/local_todo/manifest.json b/homeassistant/components/local_todo/manifest.json index 4c3a8e10a6219..335a89eab3c4c 100644 --- a/homeassistant/components/local_todo/manifest.json +++ b/homeassistant/components/local_todo/manifest.json @@ -5,5 +5,5 @@ "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/local_todo", "iot_class": "local_polling", - "requirements": ["ical==6.1.0"] + "requirements": ["ical==6.1.1"] } diff --git a/homeassistant/components/lyric/climate.py b/homeassistant/components/lyric/climate.py index f01e4c4fe55ec..e2504232c689a 100644 --- a/homeassistant/components/lyric/climate.py +++ b/homeassistant/components/lyric/climate.py @@ -2,6 +2,7 @@ from __future__ import annotations import asyncio +import enum import logging from time import localtime, strftime, time from typing import Any @@ -151,6 +152,13 @@ async def async_setup_entry( ) +class LyricThermostatType(enum.Enum): + """Lyric thermostats are classified as TCC or LCC devices.""" + + TCC = enum.auto() + LCC = enum.auto() + + class LyricClimate(LyricDeviceEntity, ClimateEntity): """Defines a Honeywell Lyric climate entity.""" @@ -201,8 +209,10 @@ def __init__( # Setup supported features if device.changeableValues.thermostatSetpointStatus: self._attr_supported_features = SUPPORT_FLAGS_LCC + self._attr_thermostat_type = LyricThermostatType.LCC else: self._attr_supported_features = SUPPORT_FLAGS_TCC + self._attr_thermostat_type = LyricThermostatType.TCC # Setup supported fan modes if device_fan_modes := device.settings.attributes.get("fan", {}).get( @@ -365,56 +375,69 @@ async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None: """Set hvac mode.""" _LOGGER.debug("HVAC mode: %s", hvac_mode) try: - if LYRIC_HVAC_MODES[hvac_mode] == LYRIC_HVAC_MODE_HEAT_COOL: - # If the system is off, turn it to Heat first then to Auto, - # otherwise it turns to. - # Auto briefly and then reverts to Off (perhaps related to - # heatCoolMode). This is the behavior that happens with the - # native app as well, so likely a bug in the api itself - if HVAC_MODES[self.device.changeableValues.mode] == HVACMode.OFF: - _LOGGER.debug( - "HVAC mode passed to lyric: %s", - HVAC_MODES[LYRIC_HVAC_MODE_COOL], - ) - await self._update_thermostat( - self.location, - self.device, - mode=HVAC_MODES[LYRIC_HVAC_MODE_HEAT], - autoChangeoverActive=False, - ) - # Sleep 3 seconds before proceeding - await asyncio.sleep(3) - _LOGGER.debug( - "HVAC mode passed to lyric: %s", - HVAC_MODES[LYRIC_HVAC_MODE_HEAT], - ) - await self._update_thermostat( - self.location, - self.device, - mode=HVAC_MODES[LYRIC_HVAC_MODE_HEAT], - autoChangeoverActive=True, - ) - else: - _LOGGER.debug( - "HVAC mode passed to lyric: %s", - HVAC_MODES[self.device.changeableValues.mode], - ) - await self._update_thermostat( - self.location, self.device, autoChangeoverActive=True - ) - else: + match self._attr_thermostat_type: + case LyricThermostatType.TCC: + await self._async_set_hvac_mode_tcc(hvac_mode) + case LyricThermostatType.LCC: + await self._async_set_hvac_mode_lcc(hvac_mode) + except LYRIC_EXCEPTIONS as exception: + _LOGGER.error(exception) + await self.coordinator.async_refresh() + + async def _async_set_hvac_mode_tcc(self, hvac_mode: HVACMode) -> None: + if LYRIC_HVAC_MODES[hvac_mode] == LYRIC_HVAC_MODE_HEAT_COOL: + # If the system is off, turn it to Heat first then to Auto, + # otherwise it turns to. + # Auto briefly and then reverts to Off (perhaps related to + # heatCoolMode). This is the behavior that happens with the + # native app as well, so likely a bug in the api itself + if HVAC_MODES[self.device.changeableValues.mode] == HVACMode.OFF: _LOGGER.debug( - "HVAC mode passed to lyric: %s", LYRIC_HVAC_MODES[hvac_mode] + "HVAC mode passed to lyric: %s", + HVAC_MODES[LYRIC_HVAC_MODE_COOL], ) await self._update_thermostat( self.location, self.device, - mode=LYRIC_HVAC_MODES[hvac_mode], + mode=HVAC_MODES[LYRIC_HVAC_MODE_HEAT], autoChangeoverActive=False, ) - except LYRIC_EXCEPTIONS as exception: - _LOGGER.error(exception) - await self.coordinator.async_refresh() + # Sleep 3 seconds before proceeding + await asyncio.sleep(3) + _LOGGER.debug( + "HVAC mode passed to lyric: %s", + HVAC_MODES[LYRIC_HVAC_MODE_HEAT], + ) + await self._update_thermostat( + self.location, + self.device, + mode=HVAC_MODES[LYRIC_HVAC_MODE_HEAT], + autoChangeoverActive=True, + ) + else: + _LOGGER.debug( + "HVAC mode passed to lyric: %s", + HVAC_MODES[self.device.changeableValues.mode], + ) + await self._update_thermostat( + self.location, self.device, autoChangeoverActive=True + ) + else: + _LOGGER.debug("HVAC mode passed to lyric: %s", LYRIC_HVAC_MODES[hvac_mode]) + await self._update_thermostat( + self.location, + self.device, + mode=LYRIC_HVAC_MODES[hvac_mode], + autoChangeoverActive=False, + ) + + async def _async_set_hvac_mode_lcc(self, hvac_mode: HVACMode) -> None: + _LOGGER.debug("HVAC mode passed to lyric: %s", LYRIC_HVAC_MODES[hvac_mode]) + await self._update_thermostat( + self.location, + self.device, + mode=LYRIC_HVAC_MODES[hvac_mode], + ) async def async_set_preset_mode(self, preset_mode: str) -> None: """Set preset (PermanentHold, HoldUntil, NoHold, VacationHold) mode.""" diff --git a/homeassistant/components/moehlenhoff_alpha2/binary_sensor.py b/homeassistant/components/moehlenhoff_alpha2/binary_sensor.py index 8acc88d83141e..5cdca72fa5522 100644 --- a/homeassistant/components/moehlenhoff_alpha2/binary_sensor.py +++ b/homeassistant/components/moehlenhoff_alpha2/binary_sensor.py @@ -27,6 +27,7 @@ async def async_setup_entry( Alpha2IODeviceBatterySensor(coordinator, io_device_id) for io_device_id, io_device in coordinator.data["io_devices"].items() if io_device["_HEATAREA_ID"] + and io_device["_HEATAREA_ID"] in coordinator.data["heat_areas"] ) diff --git a/homeassistant/components/moehlenhoff_alpha2/sensor.py b/homeassistant/components/moehlenhoff_alpha2/sensor.py index e41c6b041f61b..2c2e44f451d95 100644 --- a/homeassistant/components/moehlenhoff_alpha2/sensor.py +++ b/homeassistant/components/moehlenhoff_alpha2/sensor.py @@ -25,7 +25,7 @@ async def async_setup_entry( Alpha2HeatControlValveOpeningSensor(coordinator, heat_control_id) for heat_control_id, heat_control in coordinator.data["heat_controls"].items() if heat_control["INUSE"] - and heat_control["_HEATAREA_ID"] + and heat_control["_HEATAREA_ID"] in coordinator.data["heat_areas"] and heat_control.get("ACTOR_PERCENT") is not None ) diff --git a/homeassistant/components/mqtt/__init__.py b/homeassistant/components/mqtt/__init__.py index 16f584db011b4..593d5bbd2029c 100644 --- a/homeassistant/components/mqtt/__init__.py +++ b/homeassistant/components/mqtt/__init__.py @@ -247,7 +247,6 @@ async def async_check_config_schema( schema(config) except vol.Invalid as exc: integration = await async_get_integration(hass, DOMAIN) - # pylint: disable-next=protected-access message = conf_util.format_schema_error( hass, exc, domain, config, integration.documentation ) diff --git a/homeassistant/components/plugwise/const.py b/homeassistant/components/plugwise/const.py index 34bb5c926aef9..f5677c0b4a9d7 100644 --- a/homeassistant/components/plugwise/const.py +++ b/homeassistant/components/plugwise/const.py @@ -3,7 +3,7 @@ from datetime import timedelta import logging -from typing import Final +from typing import Final, Literal from homeassistant.const import Platform @@ -36,6 +36,23 @@ "stretch": "Stretch", } +NumberType = Literal[ + "maximum_boiler_temperature", + "max_dhw_temperature", + "temperature_offset", +] + +SelectType = Literal[ + "select_dhw_mode", + "select_regulation_mode", + "select_schedule", +] +SelectOptionsType = Literal[ + "dhw_modes", + "regulation_modes", + "available_schedules", +] + # Default directives DEFAULT_MAX_TEMP: Final = 30 DEFAULT_MIN_TEMP: Final = 4 diff --git a/homeassistant/components/plugwise/manifest.json b/homeassistant/components/plugwise/manifest.json index bb2b428bf197c..92923e98d2c23 100644 --- a/homeassistant/components/plugwise/manifest.json +++ b/homeassistant/components/plugwise/manifest.json @@ -7,6 +7,6 @@ "integration_type": "hub", "iot_class": "local_polling", "loggers": ["crcmod", "plugwise"], - "requirements": ["plugwise==0.34.5"], + "requirements": ["plugwise==0.35.3"], "zeroconf": ["_plugwise._tcp.local."] } diff --git a/homeassistant/components/plugwise/number.py b/homeassistant/components/plugwise/number.py index 2c87edddf0413..c21ecbd94c794 100644 --- a/homeassistant/components/plugwise/number.py +++ b/homeassistant/components/plugwise/number.py @@ -5,7 +5,6 @@ from dataclasses import dataclass from plugwise import Smile -from plugwise.constants import NumberType from homeassistant.components.number import ( NumberDeviceClass, @@ -18,7 +17,7 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from .const import DOMAIN +from .const import DOMAIN, NumberType from .coordinator import PlugwiseDataUpdateCoordinator from .entity import PlugwiseEntity diff --git a/homeassistant/components/plugwise/select.py b/homeassistant/components/plugwise/select.py index c12ca67155485..eef873703c188 100644 --- a/homeassistant/components/plugwise/select.py +++ b/homeassistant/components/plugwise/select.py @@ -5,7 +5,6 @@ from dataclasses import dataclass from plugwise import Smile -from plugwise.constants import SelectOptionsType, SelectType from homeassistant.components.select import SelectEntity, SelectEntityDescription from homeassistant.config_entries import ConfigEntry @@ -13,7 +12,7 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from .const import DOMAIN +from .const import DOMAIN, SelectOptionsType, SelectType from .coordinator import PlugwiseDataUpdateCoordinator from .entity import PlugwiseEntity diff --git a/homeassistant/components/plugwise/strings.json b/homeassistant/components/plugwise/strings.json index 5348a1dc4840c..addd1ceadb156 100644 --- a/homeassistant/components/plugwise/strings.json +++ b/homeassistant/components/plugwise/strings.json @@ -108,7 +108,10 @@ } }, "select_schedule": { - "name": "Thermostat schedule" + "name": "Thermostat schedule", + "state": { + "off": "Off" + } } }, "sensor": { diff --git a/homeassistant/components/schlage/manifest.json b/homeassistant/components/schlage/manifest.json index 1eb7cb2ab0fb8..e14a5bc706e11 100644 --- a/homeassistant/components/schlage/manifest.json +++ b/homeassistant/components/schlage/manifest.json @@ -5,5 +5,5 @@ "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/schlage", "iot_class": "cloud_polling", - "requirements": ["pyschlage==2023.11.0"] + "requirements": ["pyschlage==2023.12.0"] } diff --git a/homeassistant/components/smartthings/climate.py b/homeassistant/components/smartthings/climate.py index b97ca06a471da..f07c293939a6a 100644 --- a/homeassistant/components/smartthings/climate.py +++ b/homeassistant/components/smartthings/climate.py @@ -528,10 +528,10 @@ def swing_mode(self) -> str: def _determine_preset_modes(self) -> list[str] | None: """Return a list of available preset modes.""" - supported_modes = self._device.status.attributes[ + supported_modes: list | None = self._device.status.attributes[ "supportedAcOptionalMode" ].value - if WINDFREE in supported_modes: + if supported_modes and WINDFREE in supported_modes: return [WINDFREE] return None diff --git a/homeassistant/components/tasmota/manifest.json b/homeassistant/components/tasmota/manifest.json index 42fc849a2cf10..2ce81772774f7 100644 --- a/homeassistant/components/tasmota/manifest.json +++ b/homeassistant/components/tasmota/manifest.json @@ -8,5 +8,5 @@ "iot_class": "local_push", "loggers": ["hatasmota"], "mqtt": ["tasmota/discovery/#"], - "requirements": ["HATasmota==0.7.3"] + "requirements": ["HATasmota==0.8.0"] } diff --git a/homeassistant/components/tasmota/mixins.py b/homeassistant/components/tasmota/mixins.py index 21030b8c14b07..48dbe51fd6752 100644 --- a/homeassistant/components/tasmota/mixins.py +++ b/homeassistant/components/tasmota/mixins.py @@ -112,8 +112,11 @@ class TasmotaAvailability(TasmotaEntity): def __init__(self, **kwds: Any) -> None: """Initialize the availability mixin.""" - self._available = False super().__init__(**kwds) + if self._tasmota_entity.deep_sleep_enabled: + self._available = True + else: + self._available = False async def async_added_to_hass(self) -> None: """Subscribe to MQTT events.""" @@ -122,6 +125,8 @@ async def async_added_to_hass(self) -> None: async_subscribe_connection_status(self.hass, self.async_mqtt_connected) ) await super().async_added_to_hass() + if self._tasmota_entity.deep_sleep_enabled: + await self._tasmota_entity.poll_status() async def availability_updated(self, available: bool) -> None: """Handle updated availability.""" @@ -135,6 +140,8 @@ def async_mqtt_connected(self, _: bool) -> None: if not self.hass.is_stopping: if not mqtt_connected(self.hass): self._available = False + elif self._tasmota_entity.deep_sleep_enabled: + self._available = True self.async_write_ha_state() @property diff --git a/homeassistant/components/withings/__init__.py b/homeassistant/components/withings/__init__.py index 701f7f444cfd3..f42fb7a57b98d 100644 --- a/homeassistant/components/withings/__init__.py +++ b/homeassistant/components/withings/__init__.py @@ -192,52 +192,67 @@ async def _refresh_token() -> str: hass.data.setdefault(DOMAIN, {})[entry.entry_id] = withings_data + register_lock = asyncio.Lock() + webhooks_registered = False + async def unregister_webhook( _: Any, ) -> None: - LOGGER.debug("Unregister Withings webhook (%s)", entry.data[CONF_WEBHOOK_ID]) - webhook_unregister(hass, entry.data[CONF_WEBHOOK_ID]) - await async_unsubscribe_webhooks(client) - for coordinator in withings_data.coordinators: - coordinator.webhook_subscription_listener(False) + nonlocal webhooks_registered + async with register_lock: + LOGGER.debug( + "Unregister Withings webhook (%s)", entry.data[CONF_WEBHOOK_ID] + ) + webhook_unregister(hass, entry.data[CONF_WEBHOOK_ID]) + await async_unsubscribe_webhooks(client) + for coordinator in withings_data.coordinators: + coordinator.webhook_subscription_listener(False) + webhooks_registered = False async def register_webhook( _: Any, ) -> None: - if cloud.async_active_subscription(hass): - webhook_url = await _async_cloudhook_generate_url(hass, entry) - else: - webhook_url = webhook_generate_url(hass, entry.data[CONF_WEBHOOK_ID]) - url = URL(webhook_url) - if url.scheme != "https" or url.port != 443: - LOGGER.warning( - "Webhook not registered - " - "https and port 443 is required to register the webhook" + nonlocal webhooks_registered + async with register_lock: + if webhooks_registered: + return + if cloud.async_active_subscription(hass): + webhook_url = await _async_cloudhook_generate_url(hass, entry) + else: + webhook_url = webhook_generate_url(hass, entry.data[CONF_WEBHOOK_ID]) + url = URL(webhook_url) + if url.scheme != "https" or url.port != 443: + LOGGER.warning( + "Webhook not registered - " + "https and port 443 is required to register the webhook" + ) + return + + webhook_name = "Withings" + if entry.title != DEFAULT_TITLE: + webhook_name = f"{DEFAULT_TITLE} {entry.title}" + + webhook_register( + hass, + DOMAIN, + webhook_name, + entry.data[CONF_WEBHOOK_ID], + get_webhook_handler(withings_data), + allowed_methods=[METH_POST], ) - return - - webhook_name = "Withings" - if entry.title != DEFAULT_TITLE: - webhook_name = f"{DEFAULT_TITLE} {entry.title}" - - webhook_register( - hass, - DOMAIN, - webhook_name, - entry.data[CONF_WEBHOOK_ID], - get_webhook_handler(withings_data), - allowed_methods=[METH_POST], - ) - - await async_subscribe_webhooks(client, webhook_url) - for coordinator in withings_data.coordinators: - coordinator.webhook_subscription_listener(True) - LOGGER.debug("Register Withings webhook: %s", webhook_url) - entry.async_on_unload( - hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, unregister_webhook) - ) + LOGGER.debug("Registered Withings webhook at hass: %s", webhook_url) + + await async_subscribe_webhooks(client, webhook_url) + for coordinator in withings_data.coordinators: + coordinator.webhook_subscription_listener(True) + LOGGER.debug("Registered Withings webhook at Withings: %s", webhook_url) + entry.async_on_unload( + hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, unregister_webhook) + ) + webhooks_registered = True async def manage_cloudhook(state: cloud.CloudConnectionState) -> None: + LOGGER.debug("Cloudconnection state changed to %s", state) if state is cloud.CloudConnectionState.CLOUD_CONNECTED: await register_webhook(None) diff --git a/homeassistant/components/wyoming/devices.py b/homeassistant/components/wyoming/devices.py index 90dad8897078b..47a5cdc7eb83f 100644 --- a/homeassistant/components/wyoming/devices.py +++ b/homeassistant/components/wyoming/devices.py @@ -17,11 +17,11 @@ class SatelliteDevice: satellite_id: str device_id: str is_active: bool = False - is_enabled: bool = True + is_muted: bool = False pipeline_name: str | None = None _is_active_listener: Callable[[], None] | None = None - _is_enabled_listener: Callable[[], None] | None = None + _is_muted_listener: Callable[[], None] | None = None _pipeline_listener: Callable[[], None] | None = None @callback @@ -33,12 +33,12 @@ def set_is_active(self, active: bool) -> None: self._is_active_listener() @callback - def set_is_enabled(self, enabled: bool) -> None: - """Set enabled state.""" - if enabled != self.is_enabled: - self.is_enabled = enabled - if self._is_enabled_listener is not None: - self._is_enabled_listener() + def set_is_muted(self, muted: bool) -> None: + """Set muted state.""" + if muted != self.is_muted: + self.is_muted = muted + if self._is_muted_listener is not None: + self._is_muted_listener() @callback def set_pipeline_name(self, pipeline_name: str) -> None: @@ -54,9 +54,9 @@ def set_is_active_listener(self, is_active_listener: Callable[[], None]) -> None self._is_active_listener = is_active_listener @callback - def set_is_enabled_listener(self, is_enabled_listener: Callable[[], None]) -> None: - """Listen for updates to is_enabled.""" - self._is_enabled_listener = is_enabled_listener + def set_is_muted_listener(self, is_muted_listener: Callable[[], None]) -> None: + """Listen for updates to muted status.""" + self._is_muted_listener = is_muted_listener @callback def set_pipeline_listener(self, pipeline_listener: Callable[[], None]) -> None: @@ -70,11 +70,11 @@ def get_assist_in_progress_entity_id(self, hass: HomeAssistant) -> str | None: "binary_sensor", DOMAIN, f"{self.satellite_id}-assist_in_progress" ) - def get_satellite_enabled_entity_id(self, hass: HomeAssistant) -> str | None: - """Return entity id for satellite enabled switch.""" + def get_muted_entity_id(self, hass: HomeAssistant) -> str | None: + """Return entity id for satellite muted switch.""" ent_reg = er.async_get(hass) return ent_reg.async_get_entity_id( - "switch", DOMAIN, f"{self.satellite_id}-satellite_enabled" + "switch", DOMAIN, f"{self.satellite_id}-mute" ) def get_pipeline_entity_id(self, hass: HomeAssistant) -> str | None: diff --git a/homeassistant/components/wyoming/satellite.py b/homeassistant/components/wyoming/satellite.py index 0e8e5d62f4b7d..16240cb625b5d 100644 --- a/homeassistant/components/wyoming/satellite.py +++ b/homeassistant/components/wyoming/satellite.py @@ -49,7 +49,6 @@ def __init__( self.hass = hass self.service = service self.device = device - self.is_enabled = True self.is_running = True self._client: AsyncTcpClient | None = None @@ -57,9 +56,9 @@ def __init__( self._is_pipeline_running = False self._audio_queue: asyncio.Queue[bytes | None] = asyncio.Queue() self._pipeline_id: str | None = None - self._enabled_changed_event = asyncio.Event() + self._muted_changed_event = asyncio.Event() - self.device.set_is_enabled_listener(self._enabled_changed) + self.device.set_is_muted_listener(self._muted_changed) self.device.set_pipeline_listener(self._pipeline_changed) async def run(self) -> None: @@ -69,12 +68,12 @@ async def run(self) -> None: try: while self.is_running: try: - # Check if satellite has been disabled - if not self.device.is_enabled: - await self.on_disabled() + # Check if satellite has been muted + while self.device.is_muted: + await self.on_muted() if not self.is_running: - # Satellite was stopped while waiting to be enabled - break + # Satellite was stopped while waiting to be unmuted + return # Connect and run pipeline loop await self._run_once() @@ -86,14 +85,14 @@ async def run(self) -> None: # Ensure sensor is off self.device.set_is_active(False) - await self.on_stopped() + await self.on_stopped() def stop(self) -> None: """Signal satellite task to stop running.""" self.is_running = False - # Unblock waiting for enabled - self._enabled_changed_event.set() + # Unblock waiting for unmuted + self._muted_changed_event.set() async def on_restart(self) -> None: """Block until pipeline loop will be restarted.""" @@ -111,9 +110,9 @@ async def on_reconnect(self) -> None: ) await asyncio.sleep(_RECONNECT_SECONDS) - async def on_disabled(self) -> None: - """Block until device may be enabled again.""" - await self._enabled_changed_event.wait() + async def on_muted(self) -> None: + """Block until device may be unmated again.""" + await self._muted_changed_event.wait() async def on_stopped(self) -> None: """Run when run() has fully stopped.""" @@ -121,14 +120,14 @@ async def on_stopped(self) -> None: # ------------------------------------------------------------------------- - def _enabled_changed(self) -> None: - """Run when device enabled status changes.""" - - if not self.device.is_enabled: + def _muted_changed(self) -> None: + """Run when device muted status changes.""" + if self.device.is_muted: # Cancel any running pipeline self._audio_queue.put_nowait(None) - self._enabled_changed_event.set() + self._muted_changed_event.set() + self._muted_changed_event.clear() def _pipeline_changed(self) -> None: """Run when device pipeline changes.""" @@ -140,7 +139,7 @@ async def _run_once(self) -> None: """Run pipelines until an error occurs.""" self.device.set_is_active(False) - while self.is_running and self.is_enabled: + while self.is_running and (not self.device.is_muted): try: await self._connect() break @@ -150,7 +149,7 @@ async def _run_once(self) -> None: assert self._client is not None _LOGGER.debug("Connected to satellite") - if (not self.is_running) or (not self.is_enabled): + if (not self.is_running) or self.device.is_muted: # Run was cancelled or satellite was disabled during connection return @@ -159,7 +158,7 @@ async def _run_once(self) -> None: # Wait until we get RunPipeline event run_pipeline: RunPipeline | None = None - while self.is_running and self.is_enabled: + while self.is_running and (not self.device.is_muted): run_event = await self._client.read_event() if run_event is None: raise ConnectionResetError("Satellite disconnected") @@ -173,7 +172,7 @@ async def _run_once(self) -> None: assert run_pipeline is not None _LOGGER.debug("Received run information: %s", run_pipeline) - if (not self.is_running) or (not self.is_enabled): + if (not self.is_running) or self.device.is_muted: # Run was cancelled or satellite was disabled while waiting for # RunPipeline event. return @@ -188,7 +187,7 @@ async def _run_once(self) -> None: raise ValueError(f"Invalid end stage: {end_stage}") # Each loop is a pipeline run - while self.is_running and self.is_enabled: + while self.is_running and (not self.device.is_muted): # Use select to get pipeline each time in case it's changed pipeline_id = pipeline_select.get_chosen_pipeline( self.hass, @@ -243,9 +242,17 @@ async def _run_once(self) -> None: chunk = AudioChunk.from_event(client_event) chunk = self._chunk_converter.convert(chunk) self._audio_queue.put_nowait(chunk.audio) + elif AudioStop.is_type(client_event.type): + # Stop pipeline + _LOGGER.debug("Client requested pipeline to stop") + self._audio_queue.put_nowait(b"") + break else: _LOGGER.debug("Unexpected event from satellite: %s", client_event) + # Ensure task finishes + await _pipeline_task + _LOGGER.debug("Pipeline finished") def _event_callback(self, event: assist_pipeline.PipelineEvent) -> None: @@ -336,12 +343,23 @@ def _event_callback(self, event: assist_pipeline.PipelineEvent) -> None: async def _connect(self) -> None: """Connect to satellite over TCP.""" + await self._disconnect() + _LOGGER.debug( "Connecting to satellite at %s:%s", self.service.host, self.service.port ) self._client = AsyncTcpClient(self.service.host, self.service.port) await self._client.connect() + async def _disconnect(self) -> None: + """Disconnect if satellite is currently connected.""" + if self._client is None: + return + + _LOGGER.debug("Disconnecting from satellite") + await self._client.disconnect() + self._client = None + async def _stream_tts(self, media_id: str) -> None: """Stream TTS WAV audio to satellite in chunks.""" assert self._client is not None diff --git a/homeassistant/components/wyoming/strings.json b/homeassistant/components/wyoming/strings.json index 19b6a513d4ba4..c7ae63e7b9551 100644 --- a/homeassistant/components/wyoming/strings.json +++ b/homeassistant/components/wyoming/strings.json @@ -42,8 +42,8 @@ } }, "switch": { - "satellite_enabled": { - "name": "Satellite enabled" + "mute": { + "name": "Mute" } } } diff --git a/homeassistant/components/wyoming/switch.py b/homeassistant/components/wyoming/switch.py index 2bc4312258891..7366a52efab6d 100644 --- a/homeassistant/components/wyoming/switch.py +++ b/homeassistant/components/wyoming/switch.py @@ -29,17 +29,17 @@ async def async_setup_entry( # Setup is only forwarded for satellites assert item.satellite is not None - async_add_entities([WyomingSatelliteEnabledSwitch(item.satellite.device)]) + async_add_entities([WyomingSatelliteMuteSwitch(item.satellite.device)]) -class WyomingSatelliteEnabledSwitch( +class WyomingSatelliteMuteSwitch( WyomingSatelliteEntity, restore_state.RestoreEntity, SwitchEntity ): - """Entity to represent if satellite is enabled.""" + """Entity to represent if satellite is muted.""" entity_description = SwitchEntityDescription( - key="satellite_enabled", - translation_key="satellite_enabled", + key="mute", + translation_key="mute", entity_category=EntityCategory.CONFIG, ) @@ -49,17 +49,17 @@ async def async_added_to_hass(self) -> None: state = await self.async_get_last_state() - # Default to on - self._attr_is_on = (state is None) or (state.state == STATE_ON) + # Default to off + self._attr_is_on = (state is not None) and (state.state == STATE_ON) async def async_turn_on(self, **kwargs: Any) -> None: """Turn on.""" self._attr_is_on = True self.async_write_ha_state() - self._device.set_is_enabled(True) + self._device.set_is_muted(True) async def async_turn_off(self, **kwargs: Any) -> None: """Turn off.""" self._attr_is_on = False self.async_write_ha_state() - self._device.set_is_enabled(False) + self._device.set_is_muted(False) diff --git a/homeassistant/components/zeroconf/manifest.json b/homeassistant/components/zeroconf/manifest.json index 5eb77b0c41cc9..6738431b304d0 100644 --- a/homeassistant/components/zeroconf/manifest.json +++ b/homeassistant/components/zeroconf/manifest.json @@ -8,5 +8,5 @@ "iot_class": "local_push", "loggers": ["zeroconf"], "quality_scale": "internal", - "requirements": ["zeroconf==0.127.0"] + "requirements": ["zeroconf==0.128.4"] } diff --git a/homeassistant/components/zha/__init__.py b/homeassistant/components/zha/__init__.py index 2046070d6a52d..1eb3369c1bef9 100644 --- a/homeassistant/components/zha/__init__.py +++ b/homeassistant/components/zha/__init__.py @@ -37,8 +37,6 @@ DOMAIN, PLATFORMS, SIGNAL_ADD_ENTITIES, - STARTUP_FAILURE_DELAY_S, - STARTUP_RETRIES, RadioType, ) from .core.device import get_device_automation_triggers @@ -161,49 +159,40 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> b _LOGGER.debug("Trigger cache: %s", zha_data.device_trigger_cache) - # Retry setup a few times before giving up to deal with missing serial ports in VMs - for attempt in range(STARTUP_RETRIES): - try: - zha_gateway = await ZHAGateway.async_from_config( - hass=hass, - config=zha_data.yaml_config, - config_entry=config_entry, - ) - break - except NetworkSettingsInconsistent as exc: - await warn_on_inconsistent_network_settings( - hass, - config_entry=config_entry, - old_state=exc.old_state, - new_state=exc.new_state, - ) - raise ConfigEntryError( - "Network settings do not match most recent backup" - ) from exc - except TransientConnectionError as exc: - raise ConfigEntryNotReady from exc - except Exception as exc: # pylint: disable=broad-except - _LOGGER.debug( - "Couldn't start coordinator (attempt %s of %s)", - attempt + 1, - STARTUP_RETRIES, - exc_info=exc, - ) - - if attempt < STARTUP_RETRIES - 1: - await asyncio.sleep(STARTUP_FAILURE_DELAY_S) - continue - - if RadioType[config_entry.data[CONF_RADIO_TYPE]] == RadioType.ezsp: - try: - # Ignore all exceptions during probing, they shouldn't halt setup - await warn_on_wrong_silabs_firmware( - hass, config_entry.data[CONF_DEVICE][CONF_DEVICE_PATH] - ) - except AlreadyRunningEZSP as ezsp_exc: - raise ConfigEntryNotReady from ezsp_exc - - raise + try: + zha_gateway = await ZHAGateway.async_from_config( + hass=hass, + config=zha_data.yaml_config, + config_entry=config_entry, + ) + except NetworkSettingsInconsistent as exc: + await warn_on_inconsistent_network_settings( + hass, + config_entry=config_entry, + old_state=exc.old_state, + new_state=exc.new_state, + ) + raise ConfigEntryError( + "Network settings do not match most recent backup" + ) from exc + except TransientConnectionError as exc: + raise ConfigEntryNotReady from exc + except Exception as exc: + _LOGGER.debug("Failed to set up ZHA", exc_info=exc) + device_path = config_entry.data[CONF_DEVICE][CONF_DEVICE_PATH] + + if ( + not device_path.startswith("socket://") + and RadioType[config_entry.data[CONF_RADIO_TYPE]] == RadioType.ezsp + ): + try: + # Ignore all exceptions during probing, they shouldn't halt setup + if await warn_on_wrong_silabs_firmware(hass, device_path): + raise ConfigEntryError("Incorrect firmware installed") from exc + except AlreadyRunningEZSP as ezsp_exc: + raise ConfigEntryNotReady from ezsp_exc + + raise ConfigEntryNotReady from exc repairs.async_delete_blocking_issues(hass) diff --git a/homeassistant/components/zha/core/const.py b/homeassistant/components/zha/core/const.py index f89ed8d9a5291..7e591a596e525 100644 --- a/homeassistant/components/zha/core/const.py +++ b/homeassistant/components/zha/core/const.py @@ -139,7 +139,6 @@ CONF_ENABLE_QUIRKS = "enable_quirks" CONF_RADIO_TYPE = "radio_type" CONF_USB_PATH = "usb_path" -CONF_USE_THREAD = "use_thread" CONF_ZIGPY = "zigpy_config" CONF_CONSIDER_UNAVAILABLE_MAINS = "consider_unavailable_mains" @@ -409,9 +408,6 @@ class Strobe(t.enum8): Strobe = 0x01 -STARTUP_FAILURE_DELAY_S = 3 -STARTUP_RETRIES = 3 - EZSP_OVERWRITE_EUI64 = ( "i_understand_i_can_update_eui64_only_once_and_i_still_want_to_do_it" ) diff --git a/homeassistant/components/zha/core/gateway.py b/homeassistant/components/zha/core/gateway.py index 5c038a2d7f857..6c461ac45c3f8 100644 --- a/homeassistant/components/zha/core/gateway.py +++ b/homeassistant/components/zha/core/gateway.py @@ -46,7 +46,6 @@ ATTR_SIGNATURE, ATTR_TYPE, CONF_RADIO_TYPE, - CONF_USE_THREAD, CONF_ZIGPY, DEBUG_COMP_BELLOWS, DEBUG_COMP_ZHA, @@ -158,15 +157,6 @@ def get_application_controller_data(self) -> tuple[ControllerApplication, dict]: if CONF_NWK_VALIDATE_SETTINGS not in app_config: app_config[CONF_NWK_VALIDATE_SETTINGS] = True - # The bellows UART thread sometimes propagates a cancellation into the main Core - # event loop, when a connection to a TCP coordinator fails in a specific way - if ( - CONF_USE_THREAD not in app_config - and radio_type is RadioType.ezsp - and app_config[CONF_DEVICE][CONF_DEVICE_PATH].startswith("socket://") - ): - app_config[CONF_USE_THREAD] = False - # Local import to avoid circular dependencies # pylint: disable-next=import-outside-toplevel from homeassistant.components.homeassistant_hardware.silabs_multiprotocol_addon import ( diff --git a/homeassistant/components/zha/manifest.json b/homeassistant/components/zha/manifest.json index 4c8a58a12cf6d..fe58ff044cddd 100644 --- a/homeassistant/components/zha/manifest.json +++ b/homeassistant/components/zha/manifest.json @@ -21,13 +21,13 @@ "universal_silabs_flasher" ], "requirements": [ - "bellows==0.37.1", + "bellows==0.37.3", "pyserial==3.5", "pyserial-asyncio==0.6", "zha-quirks==0.0.107", - "zigpy-deconz==0.22.0", - "zigpy==0.60.0", - "zigpy-xbee==0.20.0", + "zigpy-deconz==0.22.2", + "zigpy==0.60.1", + "zigpy-xbee==0.20.1", "zigpy-zigate==0.12.0", "zigpy-znp==0.12.0", "universal-silabs-flasher==0.0.15", diff --git a/homeassistant/components/zha/radio_manager.py b/homeassistant/components/zha/radio_manager.py index d3ca03de8d89c..92a90e0e13a41 100644 --- a/homeassistant/components/zha/radio_manager.py +++ b/homeassistant/components/zha/radio_manager.py @@ -10,7 +10,6 @@ import os from typing import Any, Self -from bellows.config import CONF_USE_THREAD import voluptuous as vol from zigpy.application import ControllerApplication import zigpy.backups @@ -175,7 +174,6 @@ async def connect_zigpy_app(self) -> ControllerApplication: app_config[CONF_DATABASE] = database_path app_config[CONF_DEVICE] = self.device_settings app_config[CONF_NWK_BACKUP_ENABLED] = False - app_config[CONF_USE_THREAD] = False app_config = self.radio_type.controller.SCHEMA(app_config) app = await self.radio_type.controller.new( diff --git a/homeassistant/const.py b/homeassistant/const.py index df96500103509..fcebd21eafdc1 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -7,7 +7,7 @@ APPLICATION_NAME: Final = "HomeAssistant" MAJOR_VERSION: Final = 2023 MINOR_VERSION: Final = 12 -PATCH_VERSION: Final = "1" +PATCH_VERSION: Final = "2" __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 2ec4c6843876b..7d5f53f8f56a9 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -57,7 +57,7 @@ voluptuous-serialize==2.6.0 voluptuous==0.13.1 webrtc-noise-gain==1.2.3 yarl==1.9.2 -zeroconf==0.127.0 +zeroconf==0.128.4 # Constrain pycryptodome to avoid vulnerability # see https://github.com/home-assistant/core/pull/16238 diff --git a/pyproject.toml b/pyproject.toml index 112a03f5e5bd5..7b06c7a650697 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "homeassistant" -version = "2023.12.1" +version = "2023.12.2" 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 9094578dd65cd..27a14e0b13771 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -28,7 +28,7 @@ DoorBirdPy==2.1.0 HAP-python==4.9.1 # homeassistant.components.tasmota -HATasmota==0.7.3 +HATasmota==0.8.0 # homeassistant.components.mastodon Mastodon.py==1.5.1 @@ -523,7 +523,7 @@ beautifulsoup4==4.12.2 # beewi-smartclim==0.0.10 # homeassistant.components.zha -bellows==0.37.1 +bellows==0.37.3 # homeassistant.components.bmw_connected_drive bimmer-connected[china]==0.14.6 @@ -604,7 +604,7 @@ btsmarthub-devicelist==0.2.3 buienradar==1.0.5 # homeassistant.components.caldav -caldav==1.3.6 +caldav==1.3.8 # homeassistant.components.circuit circuit-webhook==1.0.1 @@ -1054,7 +1054,7 @@ ibmiotf==0.3.4 # homeassistant.components.local_calendar # homeassistant.components.local_todo -ical==6.1.0 +ical==6.1.1 # homeassistant.components.ping icmplib==3.0 @@ -1476,7 +1476,7 @@ plexauth==0.0.6 plexwebsocket==0.0.14 # homeassistant.components.plugwise -plugwise==0.34.5 +plugwise==0.35.3 # homeassistant.components.plum_lightpad plumlightpad==0.0.11 @@ -1775,7 +1775,7 @@ pyhaversion==22.8.0 pyheos==0.7.2 # homeassistant.components.hive -pyhiveapi==0.5.14 +pyhiveapi==0.5.16 # homeassistant.components.homematic pyhomematic==0.1.77 @@ -2026,7 +2026,7 @@ pysabnzbd==1.1.1 pysaj==0.0.16 # homeassistant.components.schlage -pyschlage==2023.11.0 +pyschlage==2023.12.0 # homeassistant.components.sensibo pysensibo==1.0.36 @@ -2810,7 +2810,7 @@ zamg==0.3.3 zengge==0.2 # homeassistant.components.zeroconf -zeroconf==0.127.0 +zeroconf==0.128.4 # homeassistant.components.zeversolar zeversolar==0.3.1 @@ -2825,10 +2825,10 @@ zhong-hong-hvac==1.0.9 ziggo-mediabox-xl==1.1.0 # homeassistant.components.zha -zigpy-deconz==0.22.0 +zigpy-deconz==0.22.2 # homeassistant.components.zha -zigpy-xbee==0.20.0 +zigpy-xbee==0.20.1 # homeassistant.components.zha zigpy-zigate==0.12.0 @@ -2837,7 +2837,7 @@ zigpy-zigate==0.12.0 zigpy-znp==0.12.0 # homeassistant.components.zha -zigpy==0.60.0 +zigpy==0.60.1 # homeassistant.components.zoneminder zm-py==0.5.2 diff --git a/requirements_test.txt b/requirements_test.txt index d880fecaca5fe..ee45a75766961 100644 --- a/requirements_test.txt +++ b/requirements_test.txt @@ -14,7 +14,7 @@ mock-open==1.4.0 mypy==1.7.1 pre-commit==3.5.0 pydantic==1.10.12 -pylint==3.0.2 +pylint==3.0.3 pylint-per-file-ignores==1.2.1 pipdeptree==2.11.0 pytest-asyncio==0.21.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index ae0f9d25a0b1e..cae0417ff0a55 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -25,7 +25,7 @@ DoorBirdPy==2.1.0 HAP-python==4.9.1 # homeassistant.components.tasmota -HATasmota==0.7.3 +HATasmota==0.8.0 # homeassistant.components.doods # homeassistant.components.generic @@ -445,7 +445,7 @@ base36==0.1.1 beautifulsoup4==4.12.2 # homeassistant.components.zha -bellows==0.37.1 +bellows==0.37.3 # homeassistant.components.bmw_connected_drive bimmer-connected[china]==0.14.6 @@ -503,7 +503,7 @@ bthome-ble==3.2.0 buienradar==1.0.5 # homeassistant.components.caldav -caldav==1.3.6 +caldav==1.3.8 # homeassistant.components.coinbase coinbase==2.1.0 @@ -832,7 +832,7 @@ ibeacon-ble==1.0.1 # homeassistant.components.local_calendar # homeassistant.components.local_todo -ical==6.1.0 +ical==6.1.1 # homeassistant.components.ping icmplib==3.0 @@ -1134,7 +1134,7 @@ plexauth==0.0.6 plexwebsocket==0.0.14 # homeassistant.components.plugwise -plugwise==0.34.5 +plugwise==0.35.3 # homeassistant.components.plum_lightpad plumlightpad==0.0.11 @@ -1340,7 +1340,7 @@ pyhaversion==22.8.0 pyheos==0.7.2 # homeassistant.components.hive -pyhiveapi==0.5.14 +pyhiveapi==0.5.16 # homeassistant.components.homematic pyhomematic==0.1.77 @@ -1531,7 +1531,7 @@ pyrympro==0.0.7 pysabnzbd==1.1.1 # homeassistant.components.schlage -pyschlage==2023.11.0 +pyschlage==2023.12.0 # homeassistant.components.sensibo pysensibo==1.0.36 @@ -2105,7 +2105,7 @@ yt-dlp==2023.11.16 zamg==0.3.3 # homeassistant.components.zeroconf -zeroconf==0.127.0 +zeroconf==0.128.4 # homeassistant.components.zeversolar zeversolar==0.3.1 @@ -2114,10 +2114,10 @@ zeversolar==0.3.1 zha-quirks==0.0.107 # homeassistant.components.zha -zigpy-deconz==0.22.0 +zigpy-deconz==0.22.2 # homeassistant.components.zha -zigpy-xbee==0.20.0 +zigpy-xbee==0.20.1 # homeassistant.components.zha zigpy-zigate==0.12.0 @@ -2126,7 +2126,7 @@ zigpy-zigate==0.12.0 zigpy-znp==0.12.0 # homeassistant.components.zha -zigpy==0.60.0 +zigpy==0.60.1 # homeassistant.components.zwave_js zwave-js-server-python==0.54.0 diff --git a/tests/components/aftership/test_config_flow.py b/tests/components/aftership/test_config_flow.py index 2ac5919a5555a..4668e7a61e4f2 100644 --- a/tests/components/aftership/test_config_flow.py +++ b/tests/components/aftership/test_config_flow.py @@ -77,7 +77,9 @@ async def test_flow_cannot_connect(hass: HomeAssistant, mock_setup_entry) -> Non } -async def test_import_flow(hass: HomeAssistant, mock_setup_entry) -> None: +async def test_import_flow( + hass: HomeAssistant, issue_registry: ir.IssueRegistry, mock_setup_entry +) -> None: """Test importing yaml config.""" with patch( @@ -95,11 +97,12 @@ async def test_import_flow(hass: HomeAssistant, mock_setup_entry) -> None: assert result["data"] == { CONF_API_KEY: "yaml-api-key", } - issue_registry = ir.async_get(hass) assert len(issue_registry.issues) == 1 -async def test_import_flow_already_exists(hass: HomeAssistant) -> None: +async def test_import_flow_already_exists( + hass: HomeAssistant, issue_registry: ir.IssueRegistry +) -> None: """Test importing yaml config where entry already exists.""" entry = MockConfigEntry(domain=DOMAIN, data={CONF_API_KEY: "yaml-api-key"}) entry.add_to_hass(hass) @@ -108,3 +111,4 @@ async def test_import_flow_already_exists(hass: HomeAssistant) -> None: ) assert result["type"] == FlowResultType.ABORT assert result["reason"] == "already_configured" + assert len(issue_registry.issues) == 1 diff --git a/tests/components/alexa/test_smart_home.py b/tests/components/alexa/test_smart_home.py index 7a1abe96110a1..0a5b1f79f72a0 100644 --- a/tests/components/alexa/test_smart_home.py +++ b/tests/components/alexa/test_smart_home.py @@ -6,7 +6,7 @@ from homeassistant.components.alexa import smart_home, state_report import homeassistant.components.camera as camera -from homeassistant.components.cover import CoverDeviceClass +from homeassistant.components.cover import CoverDeviceClass, CoverEntityFeature from homeassistant.components.media_player import MediaPlayerEntityFeature from homeassistant.components.vacuum import VacuumEntityFeature from homeassistant.config import async_process_ha_core_config @@ -1884,7 +1884,91 @@ async def test_group(hass: HomeAssistant) -> None: ) -async def test_cover_position_range(hass: HomeAssistant) -> None: +@pytest.mark.parametrize( + ("position", "position_attr_in_service_call", "supported_features", "service_call"), + [ + ( + 30, + 30, + CoverEntityFeature.SET_POSITION + | CoverEntityFeature.OPEN + | CoverEntityFeature.CLOSE, + "cover.set_cover_position", + ), + ( + 0, + None, + CoverEntityFeature.SET_POSITION + | CoverEntityFeature.OPEN + | CoverEntityFeature.CLOSE, + "cover.close_cover", + ), + ( + 99, + 99, + CoverEntityFeature.SET_POSITION + | CoverEntityFeature.OPEN + | CoverEntityFeature.CLOSE, + "cover.set_cover_position", + ), + ( + 100, + None, + CoverEntityFeature.SET_POSITION + | CoverEntityFeature.OPEN + | CoverEntityFeature.CLOSE, + "cover.open_cover", + ), + ( + 0, + 0, + CoverEntityFeature.SET_POSITION, + "cover.set_cover_position", + ), + ( + 60, + 60, + CoverEntityFeature.SET_POSITION, + "cover.set_cover_position", + ), + ( + 100, + 100, + CoverEntityFeature.SET_POSITION, + "cover.set_cover_position", + ), + ( + 0, + 0, + CoverEntityFeature.SET_POSITION | CoverEntityFeature.OPEN, + "cover.set_cover_position", + ), + ( + 100, + 100, + CoverEntityFeature.SET_POSITION | CoverEntityFeature.CLOSE, + "cover.set_cover_position", + ), + ], + ids=[ + "position_30_open_close", + "position_0_open_close", + "position_99_open_close", + "position_100_open_close", + "position_0_no_open_close", + "position_60_no_open_close", + "position_100_no_open_close", + "position_0_no_close", + "position_100_no_open", + ], +) +async def test_cover_position( + hass: HomeAssistant, + position: int, + position_attr_in_service_call: int | None, + supported_features: CoverEntityFeature, + service_call: str, +) -> None: """Test cover discovery and position using rangeController.""" device = ( "cover.test_range", @@ -1892,8 +1976,8 @@ async def test_cover_position_range(hass: HomeAssistant) -> None: { "friendly_name": "Test cover range", "device_class": "blind", - "supported_features": 7, - "position": 30, + "supported_features": supported_features, + "position": position, }, ) appliance = await discovery_test(device, hass) @@ -1969,58 +2053,112 @@ async def test_cover_position_range(hass: HomeAssistant) -> None: "range": {"minimumValue": 1, "maximumValue": 100}, } in position_state_mappings - call, _ = await assert_request_calls_service( - "Alexa.RangeController", - "SetRangeValue", - "cover#test_range", - "cover.set_cover_position", - hass, - payload={"rangeValue": 50}, - instance="cover.position", - ) - assert call.data["position"] == 50 - call, msg = await assert_request_calls_service( "Alexa.RangeController", "SetRangeValue", "cover#test_range", - "cover.close_cover", + service_call, hass, - payload={"rangeValue": 0}, + payload={"rangeValue": position}, instance="cover.position", ) + assert call.data.get("position") == position_attr_in_service_call properties = msg["context"]["properties"][0] assert properties["name"] == "rangeValue" assert properties["namespace"] == "Alexa.RangeController" - assert properties["value"] == 0 + assert properties["value"] == position - call, msg = await assert_request_calls_service( - "Alexa.RangeController", - "SetRangeValue", - "cover#test_range", - "cover.open_cover", - hass, - payload={"rangeValue": 100}, - instance="cover.position", + +async def test_cover_position_range( + hass: HomeAssistant, +) -> None: + """Test cover discovery and position range using rangeController. + + Also tests an invalid cover position being handled correctly. + """ + + device = ( + "cover.test_range", + "open", + { + "friendly_name": "Test cover range", + "device_class": "blind", + "supported_features": 7, + "position": 30, + }, ) - properties = msg["context"]["properties"][0] - assert properties["name"] == "rangeValue" - assert properties["namespace"] == "Alexa.RangeController" - assert properties["value"] == 100 + appliance = await discovery_test(device, hass) - call, msg = await assert_request_calls_service( + assert appliance["endpointId"] == "cover#test_range" + assert appliance["displayCategories"][0] == "INTERIOR_BLIND" + assert appliance["friendlyName"] == "Test cover range" + + capabilities = assert_endpoint_capabilities( + appliance, + "Alexa.PowerController", "Alexa.RangeController", - "AdjustRangeValue", - "cover#test_range", - "cover.open_cover", - hass, - payload={"rangeValueDelta": 99, "rangeValueDeltaDefault": False}, - instance="cover.position", + "Alexa.EndpointHealth", + "Alexa", ) - properties = msg["context"]["properties"][0] - assert properties["name"] == "rangeValue" - assert properties["namespace"] == "Alexa.RangeController" - assert properties["value"] == 100 + + range_capability = get_capability(capabilities, "Alexa.RangeController") + assert range_capability is not None + assert range_capability["instance"] == "cover.position" + + properties = range_capability["properties"] + assert properties["nonControllable"] is False + assert {"name": "rangeValue"} in properties["supported"] + + capability_resources = range_capability["capabilityResources"] + assert capability_resources is not None + assert { + "@type": "text", + "value": {"text": "Position", "locale": "en-US"}, + } in capability_resources["friendlyNames"] + + assert { + "@type": "asset", + "value": {"assetId": "Alexa.Setting.Opening"}, + } in capability_resources["friendlyNames"] + + configuration = range_capability["configuration"] + assert configuration is not None + assert configuration["unitOfMeasure"] == "Alexa.Unit.Percent" + + supported_range = configuration["supportedRange"] + assert supported_range["minimumValue"] == 0 + assert supported_range["maximumValue"] == 100 + assert supported_range["precision"] == 1 + + # Assert for Position Semantics + position_semantics = range_capability["semantics"] + assert position_semantics is not None + + position_action_mappings = position_semantics["actionMappings"] + assert position_action_mappings is not None + assert { + "@type": "ActionsToDirective", + "actions": ["Alexa.Actions.Lower", "Alexa.Actions.Close"], + "directive": {"name": "SetRangeValue", "payload": {"rangeValue": 0}}, + } in position_action_mappings + assert { + "@type": "ActionsToDirective", + "actions": ["Alexa.Actions.Raise", "Alexa.Actions.Open"], + "directive": {"name": "SetRangeValue", "payload": {"rangeValue": 100}}, + } in position_action_mappings + + position_state_mappings = position_semantics["stateMappings"] + assert position_state_mappings is not None + assert { + "@type": "StatesToValue", + "states": ["Alexa.States.Closed"], + "value": 0, + } in position_state_mappings + assert { + "@type": "StatesToRange", + "states": ["Alexa.States.Open"], + "range": {"minimumValue": 1, "maximumValue": 100}, + } in position_state_mappings call, msg = await assert_request_calls_service( "Alexa.RangeController", @@ -3435,7 +3573,100 @@ async def test_presence_sensor(hass: HomeAssistant) -> None: assert {"name": "humanPresenceDetectionState"} in properties["supported"] -async def test_cover_tilt_position_range(hass: HomeAssistant) -> None: +@pytest.mark.parametrize( + ( + "tilt_position", + "tilt_position_attr_in_service_call", + "supported_features", + "service_call", + ), + [ + ( + 30, + 30, + CoverEntityFeature.SET_TILT_POSITION + | CoverEntityFeature.OPEN_TILT + | CoverEntityFeature.CLOSE_TILT + | CoverEntityFeature.STOP_TILT, + "cover.set_cover_tilt_position", + ), + ( + 0, + None, + CoverEntityFeature.SET_TILT_POSITION + | CoverEntityFeature.OPEN_TILT + | CoverEntityFeature.CLOSE_TILT + | CoverEntityFeature.STOP_TILT, + "cover.close_cover_tilt", + ), + ( + 99, + 99, + CoverEntityFeature.SET_TILT_POSITION + | CoverEntityFeature.OPEN_TILT + | CoverEntityFeature.CLOSE_TILT + | CoverEntityFeature.STOP_TILT, + "cover.set_cover_tilt_position", + ), + ( + 100, + None, + CoverEntityFeature.SET_TILT_POSITION + | CoverEntityFeature.OPEN_TILT + | CoverEntityFeature.CLOSE_TILT + | CoverEntityFeature.STOP_TILT, + "cover.open_cover_tilt", + ), + ( + 0, + 0, + CoverEntityFeature.SET_TILT_POSITION, + "cover.set_cover_tilt_position", + ), + ( + 60, + 60, + CoverEntityFeature.SET_TILT_POSITION, + "cover.set_cover_tilt_position", + ), + ( + 100, + 100, + CoverEntityFeature.SET_TILT_POSITION, + "cover.set_cover_tilt_position", + ), + ( + 0, + 0, + CoverEntityFeature.SET_TILT_POSITION | CoverEntityFeature.OPEN_TILT, + "cover.set_cover_tilt_position", + ), + ( + 100, + 100, + CoverEntityFeature.SET_TILT_POSITION | CoverEntityFeature.CLOSE_TILT, + "cover.set_cover_tilt_position", + ), + ], + ids=[ + "tilt_position_30_open_close", + "tilt_position_0_open_close", + "tilt_position_99_open_close", + "tilt_position_100_open_close", + "tilt_position_0_no_open_close", + "tilt_position_60_no_open_close", + "tilt_position_100_no_open_close", + "tilt_position_0_no_close", + "tilt_position_100_no_open", + ], +) +async def test_cover_tilt_position( + hass: HomeAssistant, + tilt_position: int, + tilt_position_attr_in_service_call: int | None, + supported_features: CoverEntityFeature, + service_call: str, +) -> None: """Test cover discovery and tilt position using rangeController.""" device = ( "cover.test_tilt_range", @@ -3443,8 +3674,8 @@ async def test_cover_tilt_position_range(hass: HomeAssistant) -> None: { "friendly_name": "Test cover tilt range", "device_class": "blind", - "supported_features": 240, - "tilt_position": 30, + "supported_features": supported_features, + "tilt_position": tilt_position, }, ) appliance = await discovery_test(device, hass) @@ -3474,58 +3705,74 @@ async def test_cover_tilt_position_range(hass: HomeAssistant) -> None: state_mappings = semantics["stateMappings"] assert state_mappings is not None - call, _ = await assert_request_calls_service( - "Alexa.RangeController", - "SetRangeValue", - "cover#test_tilt_range", - "cover.set_cover_tilt_position", - hass, - payload={"rangeValue": 50}, - instance="cover.tilt", - ) - assert call.data["tilt_position"] == 50 - call, msg = await assert_request_calls_service( "Alexa.RangeController", "SetRangeValue", "cover#test_tilt_range", - "cover.close_cover_tilt", + service_call, hass, - payload={"rangeValue": 0}, + payload={"rangeValue": tilt_position}, instance="cover.tilt", ) + assert call.data.get("tilt_position") == tilt_position_attr_in_service_call properties = msg["context"]["properties"][0] assert properties["name"] == "rangeValue" assert properties["namespace"] == "Alexa.RangeController" - assert properties["value"] == 0 + assert properties["value"] == tilt_position - call, msg = await assert_request_calls_service( + +async def test_cover_tilt_position_range(hass: HomeAssistant) -> None: + """Test cover discovery and tilt position range using rangeController. + + Also tests and invalid tilt position being handled correctly. + """ + device = ( + "cover.test_tilt_range", + "open", + { + "friendly_name": "Test cover tilt range", + "device_class": "blind", + "supported_features": 240, + "tilt_position": 30, + }, + ) + appliance = await discovery_test(device, hass) + + assert appliance["endpointId"] == "cover#test_tilt_range" + assert appliance["displayCategories"][0] == "INTERIOR_BLIND" + assert appliance["friendlyName"] == "Test cover tilt range" + + capabilities = assert_endpoint_capabilities( + appliance, + "Alexa.PowerController", "Alexa.RangeController", - "SetRangeValue", - "cover#test_tilt_range", - "cover.open_cover_tilt", - hass, - payload={"rangeValue": 100}, - instance="cover.tilt", + "Alexa.EndpointHealth", + "Alexa", ) - properties = msg["context"]["properties"][0] - assert properties["name"] == "rangeValue" - assert properties["namespace"] == "Alexa.RangeController" - assert properties["value"] == 100 - call, msg = await assert_request_calls_service( + range_capability = get_capability(capabilities, "Alexa.RangeController") + assert range_capability is not None + assert range_capability["instance"] == "cover.tilt" + + semantics = range_capability["semantics"] + assert semantics is not None + + action_mappings = semantics["actionMappings"] + assert action_mappings is not None + + state_mappings = semantics["stateMappings"] + assert state_mappings is not None + + call, _ = await assert_request_calls_service( "Alexa.RangeController", - "AdjustRangeValue", + "SetRangeValue", "cover#test_tilt_range", - "cover.open_cover_tilt", + "cover.set_cover_tilt_position", hass, - payload={"rangeValueDelta": 99, "rangeValueDeltaDefault": False}, + payload={"rangeValue": 50}, instance="cover.tilt", ) - properties = msg["context"]["properties"][0] - assert properties["name"] == "rangeValue" - assert properties["namespace"] == "Alexa.RangeController" - assert properties["value"] == 100 + assert call.data["tilt_position"] == 50 call, msg = await assert_request_calls_service( "Alexa.RangeController", diff --git a/tests/components/assist_pipeline/snapshots/test_websocket.ambr b/tests/components/assist_pipeline/snapshots/test_websocket.ambr index 072b1ff730ace..c165675a6ff93 100644 --- a/tests/components/assist_pipeline/snapshots/test_websocket.ambr +++ b/tests/components/assist_pipeline/snapshots/test_websocket.ambr @@ -662,15 +662,33 @@ # --- # name: test_pipeline_empty_tts_output.1 dict({ - 'engine': 'test', - 'language': 'en-US', - 'tts_input': '', - 'voice': 'james_earl_jones', + 'conversation_id': None, + 'device_id': None, + 'engine': 'homeassistant', + 'intent_input': 'never mind', + 'language': 'en', }) # --- # name: test_pipeline_empty_tts_output.2 dict({ - 'tts_output': dict({ + 'intent_output': dict({ + 'conversation_id': None, + 'response': dict({ + 'card': dict({ + }), + 'data': dict({ + 'failed': list([ + ]), + 'success': list([ + ]), + 'targets': list([ + ]), + }), + 'language': 'en', + 'response_type': 'action_done', + 'speech': dict({ + }), + }), }), }) # --- diff --git a/tests/components/assist_pipeline/test_websocket.py b/tests/components/assist_pipeline/test_websocket.py index 0e2a3ad538c75..458320a9a90c1 100644 --- a/tests/components/assist_pipeline/test_websocket.py +++ b/tests/components/assist_pipeline/test_websocket.py @@ -2467,10 +2467,10 @@ async def test_pipeline_empty_tts_output( await client.send_json_auto_id( { "type": "assist_pipeline/run", - "start_stage": "tts", + "start_stage": "intent", "end_stage": "tts", "input": { - "text": "", + "text": "never mind", }, } ) @@ -2486,16 +2486,15 @@ async def test_pipeline_empty_tts_output( assert msg["event"]["data"] == snapshot events.append(msg["event"]) - # text-to-speech + # intent msg = await client.receive_json() - assert msg["event"]["type"] == "tts-start" + assert msg["event"]["type"] == "intent-start" assert msg["event"]["data"] == snapshot events.append(msg["event"]) msg = await client.receive_json() - assert msg["event"]["type"] == "tts-end" + assert msg["event"]["type"] == "intent-end" assert msg["event"]["data"] == snapshot - assert not msg["event"]["data"]["tts_output"] events.append(msg["event"]) # run end diff --git a/tests/components/caldav/test_todo.py b/tests/components/caldav/test_todo.py index 6e92f21146353..a90529297beb4 100644 --- a/tests/components/caldav/test_todo.py +++ b/tests/components/caldav/test_todo.py @@ -1,4 +1,5 @@ """The tests for the webdav todo component.""" +from datetime import UTC, date, datetime from typing import Any from unittest.mock import MagicMock, Mock @@ -200,12 +201,16 @@ async def test_supported_components( ), ( {"due_date": "2023-11-18"}, - {"status": "NEEDS-ACTION", "summary": "Cheese", "due": "20231118"}, + {"status": "NEEDS-ACTION", "summary": "Cheese", "due": date(2023, 11, 18)}, {**RESULT_ITEM, "due": "2023-11-18"}, ), ( {"due_datetime": "2023-11-18T08:30:00-06:00"}, - {"status": "NEEDS-ACTION", "summary": "Cheese", "due": "20231118T143000Z"}, + { + "status": "NEEDS-ACTION", + "summary": "Cheese", + "due": datetime(2023, 11, 18, 14, 30, 00, tzinfo=UTC), + }, {**RESULT_ITEM, "due": "2023-11-18T08:30:00-06:00"}, ), ( @@ -311,13 +316,13 @@ async def test_add_item_failure( ), ( {"due_date": "2023-11-18"}, - ["SUMMARY:Cheese", "DUE:20231118"], + ["SUMMARY:Cheese", "DUE;VALUE=DATE:20231118"], "1", {**RESULT_ITEM, "due": "2023-11-18"}, ), ( {"due_datetime": "2023-11-18T08:30:00-06:00"}, - ["SUMMARY:Cheese", "DUE:20231118T143000Z"], + ["SUMMARY:Cheese", "DUE;TZID=America/Regina:20231118T083000"], "1", {**RESULT_ITEM, "due": "2023-11-18T08:30:00-06:00"}, ), diff --git a/tests/components/climate/test_intent.py b/tests/components/climate/test_intent.py index eaf7029d303b5..6473eca1b883b 100644 --- a/tests/components/climate/test_intent.py +++ b/tests/components/climate/test_intent.py @@ -153,7 +153,7 @@ async def test_get_temperature( state = response.matched_states[0] assert state.attributes["current_temperature"] == 10.0 - # Select by area instead (climate_2) + # Select by area (climate_2) response = await intent.async_handle( hass, "test", @@ -166,6 +166,19 @@ async def test_get_temperature( state = response.matched_states[0] assert state.attributes["current_temperature"] == 22.0 + # Select by name (climate_2) + response = await intent.async_handle( + hass, + "test", + climate_intent.INTENT_GET_TEMPERATURE, + {"name": {"value": "Climate 2"}}, + ) + assert response.response_type == intent.IntentResponseType.QUERY_ANSWER + assert len(response.matched_states) == 1 + assert response.matched_states[0].entity_id == climate_2.entity_id + state = response.matched_states[0] + assert state.attributes["current_temperature"] == 22.0 + async def test_get_temperature_no_entities( hass: HomeAssistant, diff --git a/tests/components/fitbit/test_init.py b/tests/components/fitbit/test_init.py index b6bf75c1c69b2..3ed3695ff3d5a 100644 --- a/tests/components/fitbit/test_init.py +++ b/tests/components/fitbit/test_init.py @@ -107,18 +107,21 @@ async def test_token_refresh_success( @pytest.mark.parametrize("token_expiration_time", [12345]) +@pytest.mark.parametrize("closing", [True, False]) async def test_token_requires_reauth( hass: HomeAssistant, integration_setup: Callable[[], Awaitable[bool]], config_entry: MockConfigEntry, aioclient_mock: AiohttpClientMocker, setup_credentials: None, + closing: bool, ) -> None: """Test where token is expired and the refresh attempt requires reauth.""" aioclient_mock.post( OAUTH2_TOKEN, status=HTTPStatus.UNAUTHORIZED, + closing=closing, ) assert not await integration_setup() diff --git a/tests/components/google/test_calendar.py b/tests/components/google/test_calendar.py index a70cd8aee9fd2..8466f5ad4ebb8 100644 --- a/tests/components/google/test_calendar.py +++ b/tests/components/google/test_calendar.py @@ -1301,6 +1301,7 @@ async def test_event_differs_timezone( } +@pytest.mark.freeze_time("2023-11-30 12:15:00 +00:00") async def test_invalid_rrule_fix( hass: HomeAssistant, hass_client: ClientSessionGenerator, diff --git a/tests/components/plugwise/fixtures/adam_multiple_devices_per_zone/all_data.json b/tests/components/plugwise/fixtures/adam_multiple_devices_per_zone/all_data.json index 279fe6b8a43bf..f97182782e6be 100644 --- a/tests/components/plugwise/fixtures/adam_multiple_devices_per_zone/all_data.json +++ b/tests/components/plugwise/fixtures/adam_multiple_devices_per_zone/all_data.json @@ -112,7 +112,8 @@ "Bios Schema met Film Avond", "GF7 Woonkamer", "Badkamer Schema", - "CV Jessie" + "CV Jessie", + "off" ], "dev_class": "zone_thermostat", "firmware": "2016-10-27T02:00:00+02:00", @@ -251,7 +252,8 @@ "Bios Schema met Film Avond", "GF7 Woonkamer", "Badkamer Schema", - "CV Jessie" + "CV Jessie", + "off" ], "dev_class": "zone_thermostat", "firmware": "2016-08-02T02:00:00+02:00", @@ -334,7 +336,8 @@ "Bios Schema met Film Avond", "GF7 Woonkamer", "Badkamer Schema", - "CV Jessie" + "CV Jessie", + "off" ], "dev_class": "zone_thermostat", "firmware": "2016-10-27T02:00:00+02:00", @@ -344,7 +347,7 @@ "model": "Lisa", "name": "Zone Lisa Bios", "preset_modes": ["home", "asleep", "away", "vacation", "no_frost"], - "select_schedule": "None", + "select_schedule": "off", "sensors": { "battery": 67, "setpoint": 13.0, @@ -373,7 +376,8 @@ "Bios Schema met Film Avond", "GF7 Woonkamer", "Badkamer Schema", - "CV Jessie" + "CV Jessie", + "off" ], "dev_class": "thermostatic_radiator_valve", "firmware": "2019-03-27T01:00:00+01:00", @@ -383,7 +387,7 @@ "model": "Tom/Floor", "name": "CV Kraan Garage", "preset_modes": ["home", "asleep", "away", "vacation", "no_frost"], - "select_schedule": "None", + "select_schedule": "off", "sensors": { "battery": 68, "setpoint": 5.5, @@ -414,7 +418,8 @@ "Bios Schema met Film Avond", "GF7 Woonkamer", "Badkamer Schema", - "CV Jessie" + "CV Jessie", + "off" ], "dev_class": "zone_thermostat", "firmware": "2016-10-27T02:00:00+02:00", diff --git a/tests/components/plugwise/fixtures/anna_heatpump_heating/all_data.json b/tests/components/plugwise/fixtures/anna_heatpump_heating/all_data.json index 9ef93d63bdd3c..d655f95c79b39 100644 --- a/tests/components/plugwise/fixtures/anna_heatpump_heating/all_data.json +++ b/tests/components/plugwise/fixtures/anna_heatpump_heating/all_data.json @@ -59,7 +59,7 @@ }, "3cb70739631c4d17a86b8b12e8a5161b": { "active_preset": "home", - "available_schedules": ["standaard"], + "available_schedules": ["standaard", "off"], "dev_class": "thermostat", "firmware": "2018-02-08T11:15:53+01:00", "hardware": "6539-1301-5002", diff --git a/tests/components/plugwise/fixtures/m_adam_cooling/all_data.json b/tests/components/plugwise/fixtures/m_adam_cooling/all_data.json index 2e1063d14d377..7b570a6cf61f1 100644 --- a/tests/components/plugwise/fixtures/m_adam_cooling/all_data.json +++ b/tests/components/plugwise/fixtures/m_adam_cooling/all_data.json @@ -52,7 +52,7 @@ "ad4838d7d35c4d6ea796ee12ae5aedf8": { "active_preset": "asleep", "available": true, - "available_schedules": ["Weekschema", "Badkamer", "Test"], + "available_schedules": ["Weekschema", "Badkamer", "Test", "off"], "control_state": "cooling", "dev_class": "thermostat", "location": "f2bf9048bef64cc5b6d5110154e33c81", @@ -102,7 +102,7 @@ "e2f4322d57924fa090fbbc48b3a140dc": { "active_preset": "home", "available": true, - "available_schedules": ["Weekschema", "Badkamer", "Test"], + "available_schedules": ["Weekschema", "Badkamer", "Test", "off"], "control_state": "off", "dev_class": "zone_thermostat", "firmware": "2016-10-10T02:00:00+02:00", diff --git a/tests/components/plugwise/fixtures/m_adam_heating/all_data.json b/tests/components/plugwise/fixtures/m_adam_heating/all_data.json index 81d60bed9d41a..5725904769803 100644 --- a/tests/components/plugwise/fixtures/m_adam_heating/all_data.json +++ b/tests/components/plugwise/fixtures/m_adam_heating/all_data.json @@ -80,7 +80,7 @@ "ad4838d7d35c4d6ea796ee12ae5aedf8": { "active_preset": "asleep", "available": true, - "available_schedules": ["Weekschema", "Badkamer", "Test"], + "available_schedules": ["Weekschema", "Badkamer", "Test", "off"], "control_state": "preheating", "dev_class": "thermostat", "location": "f2bf9048bef64cc5b6d5110154e33c81", @@ -124,7 +124,7 @@ "e2f4322d57924fa090fbbc48b3a140dc": { "active_preset": "home", "available": true, - "available_schedules": ["Weekschema", "Badkamer", "Test"], + "available_schedules": ["Weekschema", "Badkamer", "Test", "off"], "control_state": "off", "dev_class": "zone_thermostat", "firmware": "2016-10-10T02:00:00+02:00", diff --git a/tests/components/plugwise/fixtures/m_anna_heatpump_cooling/all_data.json b/tests/components/plugwise/fixtures/m_anna_heatpump_cooling/all_data.json index 844eae4c2f708..92c95f6c5a9dd 100644 --- a/tests/components/plugwise/fixtures/m_anna_heatpump_cooling/all_data.json +++ b/tests/components/plugwise/fixtures/m_anna_heatpump_cooling/all_data.json @@ -59,7 +59,7 @@ }, "3cb70739631c4d17a86b8b12e8a5161b": { "active_preset": "home", - "available_schedules": ["standaard"], + "available_schedules": ["standaard", "off"], "dev_class": "thermostat", "firmware": "2018-02-08T11:15:53+01:00", "hardware": "6539-1301-5002", diff --git a/tests/components/plugwise/fixtures/m_anna_heatpump_idle/all_data.json b/tests/components/plugwise/fixtures/m_anna_heatpump_idle/all_data.json index f6be6f3518856..be400b9bc9847 100644 --- a/tests/components/plugwise/fixtures/m_anna_heatpump_idle/all_data.json +++ b/tests/components/plugwise/fixtures/m_anna_heatpump_idle/all_data.json @@ -59,7 +59,7 @@ }, "3cb70739631c4d17a86b8b12e8a5161b": { "active_preset": "home", - "available_schedules": ["standaard"], + "available_schedules": ["standaard", "off"], "dev_class": "thermostat", "firmware": "2018-02-08T11:15:53+01:00", "hardware": "6539-1301-5002", diff --git a/tests/components/plugwise/snapshots/test_diagnostics.ambr b/tests/components/plugwise/snapshots/test_diagnostics.ambr index 29f23a137fb07..c2bbea9418a10 100644 --- a/tests/components/plugwise/snapshots/test_diagnostics.ambr +++ b/tests/components/plugwise/snapshots/test_diagnostics.ambr @@ -115,6 +115,7 @@ 'GF7 Woonkamer', 'Badkamer Schema', 'CV Jessie', + 'off', ]), 'dev_class': 'zone_thermostat', 'firmware': '2016-10-27T02:00:00+02:00', @@ -260,6 +261,7 @@ 'GF7 Woonkamer', 'Badkamer Schema', 'CV Jessie', + 'off', ]), 'dev_class': 'zone_thermostat', 'firmware': '2016-08-02T02:00:00+02:00', @@ -349,6 +351,7 @@ 'GF7 Woonkamer', 'Badkamer Schema', 'CV Jessie', + 'off', ]), 'dev_class': 'zone_thermostat', 'firmware': '2016-10-27T02:00:00+02:00', @@ -364,7 +367,7 @@ 'vacation', 'no_frost', ]), - 'select_schedule': 'None', + 'select_schedule': 'off', 'sensors': dict({ 'battery': 67, 'setpoint': 13.0, @@ -394,6 +397,7 @@ 'GF7 Woonkamer', 'Badkamer Schema', 'CV Jessie', + 'off', ]), 'dev_class': 'thermostatic_radiator_valve', 'firmware': '2019-03-27T01:00:00+01:00', @@ -409,7 +413,7 @@ 'vacation', 'no_frost', ]), - 'select_schedule': 'None', + 'select_schedule': 'off', 'sensors': dict({ 'battery': 68, 'setpoint': 5.5, @@ -441,6 +445,7 @@ 'GF7 Woonkamer', 'Badkamer Schema', 'CV Jessie', + 'off', ]), 'dev_class': 'zone_thermostat', 'firmware': '2016-10-27T02:00:00+02:00', diff --git a/tests/components/tasmota/test_binary_sensor.py b/tests/components/tasmota/test_binary_sensor.py index 2bfb4a9d5e243..d5f1e4d710170 100644 --- a/tests/components/tasmota/test_binary_sensor.py +++ b/tests/components/tasmota/test_binary_sensor.py @@ -31,6 +31,8 @@ help_test_availability_discovery_update, help_test_availability_poll_state, help_test_availability_when_connection_lost, + help_test_deep_sleep_availability, + help_test_deep_sleep_availability_when_connection_lost, help_test_discovery_device_remove, help_test_discovery_removal, help_test_discovery_update_unchanged, @@ -313,6 +315,21 @@ async def test_availability_when_connection_lost( ) +async def test_deep_sleep_availability_when_connection_lost( + hass: HomeAssistant, + mqtt_client_mock: MqttMockPahoClient, + mqtt_mock: MqttMockHAClient, + setup_tasmota, +) -> None: + """Test availability after MQTT disconnection.""" + config = copy.deepcopy(DEFAULT_CONFIG) + config["swc"][0] = 1 + config["swn"][0] = "Test" + await help_test_deep_sleep_availability_when_connection_lost( + hass, mqtt_client_mock, mqtt_mock, Platform.BINARY_SENSOR, config + ) + + async def test_availability( hass: HomeAssistant, mqtt_mock: MqttMockHAClient, setup_tasmota ) -> None: @@ -323,6 +340,18 @@ async def test_availability( await help_test_availability(hass, mqtt_mock, Platform.BINARY_SENSOR, config) +async def test_deep_sleep_availability( + hass: HomeAssistant, mqtt_mock: MqttMockHAClient, setup_tasmota +) -> None: + """Test availability when deep sleep is enabled.""" + config = copy.deepcopy(DEFAULT_CONFIG) + config["swc"][0] = 1 + config["swn"][0] = "Test" + await help_test_deep_sleep_availability( + hass, mqtt_mock, Platform.BINARY_SENSOR, config + ) + + async def test_availability_discovery_update( hass: HomeAssistant, mqtt_mock: MqttMockHAClient, setup_tasmota ) -> None: diff --git a/tests/components/tasmota/test_common.py b/tests/components/tasmota/test_common.py index a184f650faea8..1f414cb4e5abb 100644 --- a/tests/components/tasmota/test_common.py +++ b/tests/components/tasmota/test_common.py @@ -4,6 +4,7 @@ from unittest.mock import ANY from hatasmota.const import ( + CONF_DEEP_SLEEP, CONF_MAC, CONF_OFFLINE, CONF_ONLINE, @@ -188,6 +189,76 @@ async def help_test_availability_when_connection_lost( assert state.state != STATE_UNAVAILABLE +async def help_test_deep_sleep_availability_when_connection_lost( + hass, + mqtt_client_mock, + mqtt_mock, + domain, + config, + sensor_config=None, + object_id="tasmota_test", +): + """Test availability after MQTT disconnection when deep sleep is enabled. + + This is a test helper for the TasmotaAvailability mixin. + """ + config[CONF_DEEP_SLEEP] = 1 + async_fire_mqtt_message( + hass, + f"{DEFAULT_PREFIX}/{config[CONF_MAC]}/config", + json.dumps(config), + ) + await hass.async_block_till_done() + if sensor_config: + async_fire_mqtt_message( + hass, + f"{DEFAULT_PREFIX}/{config[CONF_MAC]}/sensors", + json.dumps(sensor_config), + ) + await hass.async_block_till_done() + + # Device online + state = hass.states.get(f"{domain}.{object_id}") + assert state.state != STATE_UNAVAILABLE + + # Disconnected from MQTT server -> state changed to unavailable + mqtt_mock.connected = False + await hass.async_add_executor_job(mqtt_client_mock.on_disconnect, None, None, 0) + await hass.async_block_till_done() + await hass.async_block_till_done() + await hass.async_block_till_done() + state = hass.states.get(f"{domain}.{object_id}") + assert state.state == STATE_UNAVAILABLE + + # Reconnected to MQTT server -> state no longer unavailable + mqtt_mock.connected = True + await hass.async_add_executor_job(mqtt_client_mock.on_connect, None, None, None, 0) + await hass.async_block_till_done() + await hass.async_block_till_done() + await hass.async_block_till_done() + state = hass.states.get(f"{domain}.{object_id}") + assert state.state != STATE_UNAVAILABLE + + # Receive LWT again + async_fire_mqtt_message( + hass, + get_topic_tele_will(config), + config_get_state_online(config), + ) + await hass.async_block_till_done() + state = hass.states.get(f"{domain}.{object_id}") + assert state.state != STATE_UNAVAILABLE + + async_fire_mqtt_message( + hass, + get_topic_tele_will(config), + config_get_state_offline(config), + ) + await hass.async_block_till_done() + state = hass.states.get(f"{domain}.{object_id}") + assert state.state != STATE_UNAVAILABLE + + async def help_test_availability( hass, mqtt_mock, @@ -236,6 +307,55 @@ async def help_test_availability( assert state.state == STATE_UNAVAILABLE +async def help_test_deep_sleep_availability( + hass, + mqtt_mock, + domain, + config, + sensor_config=None, + object_id="tasmota_test", +): + """Test availability when deep sleep is enabled. + + This is a test helper for the TasmotaAvailability mixin. + """ + config[CONF_DEEP_SLEEP] = 1 + async_fire_mqtt_message( + hass, + f"{DEFAULT_PREFIX}/{config[CONF_MAC]}/config", + json.dumps(config), + ) + await hass.async_block_till_done() + if sensor_config: + async_fire_mqtt_message( + hass, + f"{DEFAULT_PREFIX}/{config[CONF_MAC]}/sensors", + json.dumps(sensor_config), + ) + await hass.async_block_till_done() + + state = hass.states.get(f"{domain}.{object_id}") + assert state.state != STATE_UNAVAILABLE + + async_fire_mqtt_message( + hass, + get_topic_tele_will(config), + config_get_state_online(config), + ) + await hass.async_block_till_done() + state = hass.states.get(f"{domain}.{object_id}") + assert state.state != STATE_UNAVAILABLE + + async_fire_mqtt_message( + hass, + get_topic_tele_will(config), + config_get_state_offline(config), + ) + await hass.async_block_till_done() + state = hass.states.get(f"{domain}.{object_id}") + assert state.state != STATE_UNAVAILABLE + + async def help_test_availability_discovery_update( hass, mqtt_mock, diff --git a/tests/components/tasmota/test_cover.py b/tests/components/tasmota/test_cover.py index e2bdc8b2ca728..cae65521e21ea 100644 --- a/tests/components/tasmota/test_cover.py +++ b/tests/components/tasmota/test_cover.py @@ -22,6 +22,8 @@ help_test_availability_discovery_update, help_test_availability_poll_state, help_test_availability_when_connection_lost, + help_test_deep_sleep_availability, + help_test_deep_sleep_availability_when_connection_lost, help_test_discovery_device_remove, help_test_discovery_removal, help_test_discovery_update_unchanged, @@ -663,6 +665,27 @@ async def test_availability_when_connection_lost( ) +async def test_deep_sleep_availability_when_connection_lost( + hass: HomeAssistant, + mqtt_client_mock: MqttMockPahoClient, + mqtt_mock: MqttMockHAClient, + setup_tasmota, +) -> None: + """Test availability after MQTT disconnection.""" + config = copy.deepcopy(DEFAULT_CONFIG) + config["dn"] = "Test" + config["rl"][0] = 3 + config["rl"][1] = 3 + await help_test_deep_sleep_availability_when_connection_lost( + hass, + mqtt_client_mock, + mqtt_mock, + Platform.COVER, + config, + object_id="test_cover_1", + ) + + async def test_availability( hass: HomeAssistant, mqtt_mock: MqttMockHAClient, setup_tasmota ) -> None: @@ -676,6 +699,19 @@ async def test_availability( ) +async def test_deep_sleep_availability( + hass: HomeAssistant, mqtt_mock: MqttMockHAClient, setup_tasmota +) -> None: + """Test availability.""" + config = copy.deepcopy(DEFAULT_CONFIG) + config["dn"] = "Test" + config["rl"][0] = 3 + config["rl"][1] = 3 + await help_test_deep_sleep_availability( + hass, mqtt_mock, Platform.COVER, config, object_id="test_cover_1" + ) + + async def test_availability_discovery_update( hass: HomeAssistant, mqtt_mock: MqttMockHAClient, setup_tasmota ) -> None: diff --git a/tests/components/tasmota/test_fan.py b/tests/components/tasmota/test_fan.py index 2a50e2d43b57c..05e3151be2e6f 100644 --- a/tests/components/tasmota/test_fan.py +++ b/tests/components/tasmota/test_fan.py @@ -22,6 +22,8 @@ help_test_availability_discovery_update, help_test_availability_poll_state, help_test_availability_when_connection_lost, + help_test_deep_sleep_availability, + help_test_deep_sleep_availability_when_connection_lost, help_test_discovery_device_remove, help_test_discovery_removal, help_test_discovery_update_unchanged, @@ -232,6 +234,20 @@ async def test_availability_when_connection_lost( ) +async def test_deep_sleep_availability_when_connection_lost( + hass: HomeAssistant, + mqtt_client_mock: MqttMockPahoClient, + mqtt_mock: MqttMockHAClient, + setup_tasmota, +) -> None: + """Test availability after MQTT disconnection.""" + config = copy.deepcopy(DEFAULT_CONFIG) + config["if"] = 1 + await help_test_deep_sleep_availability_when_connection_lost( + hass, mqtt_client_mock, mqtt_mock, Platform.FAN, config, object_id="tasmota" + ) + + async def test_availability( hass: HomeAssistant, mqtt_mock: MqttMockHAClient, setup_tasmota ) -> None: @@ -243,6 +259,17 @@ async def test_availability( ) +async def test_deep_sleep_availability( + hass: HomeAssistant, mqtt_mock: MqttMockHAClient, setup_tasmota +) -> None: + """Test availability.""" + config = copy.deepcopy(DEFAULT_CONFIG) + config["if"] = 1 + await help_test_deep_sleep_availability( + hass, mqtt_mock, Platform.FAN, config, object_id="tasmota" + ) + + async def test_availability_discovery_update( hass: HomeAssistant, mqtt_mock: MqttMockHAClient, setup_tasmota ) -> None: diff --git a/tests/components/tasmota/test_light.py b/tests/components/tasmota/test_light.py index 27b7bd1a82a64..50f11fb775721 100644 --- a/tests/components/tasmota/test_light.py +++ b/tests/components/tasmota/test_light.py @@ -22,6 +22,8 @@ help_test_availability_discovery_update, help_test_availability_poll_state, help_test_availability_when_connection_lost, + help_test_deep_sleep_availability, + help_test_deep_sleep_availability_when_connection_lost, help_test_discovery_device_remove, help_test_discovery_removal, help_test_discovery_update_unchanged, @@ -1669,6 +1671,21 @@ async def test_availability_when_connection_lost( ) +async def test_deep_sleep_availability_when_connection_lost( + hass: HomeAssistant, + mqtt_client_mock: MqttMockPahoClient, + mqtt_mock: MqttMockHAClient, + setup_tasmota, +) -> None: + """Test availability after MQTT disconnection.""" + config = copy.deepcopy(DEFAULT_CONFIG) + config["rl"][0] = 2 + config["lt_st"] = 1 # 1 channel light (Dimmer) + await help_test_deep_sleep_availability_when_connection_lost( + hass, mqtt_client_mock, mqtt_mock, Platform.LIGHT, config + ) + + async def test_availability( hass: HomeAssistant, mqtt_mock: MqttMockHAClient, setup_tasmota ) -> None: @@ -1679,6 +1696,16 @@ async def test_availability( await help_test_availability(hass, mqtt_mock, Platform.LIGHT, config) +async def test_deep_sleep_availability( + hass: HomeAssistant, mqtt_mock: MqttMockHAClient, setup_tasmota +) -> None: + """Test availability.""" + config = copy.deepcopy(DEFAULT_CONFIG) + config["rl"][0] = 2 + config["lt_st"] = 1 # 1 channel light (Dimmer) + await help_test_deep_sleep_availability(hass, mqtt_mock, Platform.LIGHT, config) + + async def test_availability_discovery_update( hass: HomeAssistant, mqtt_mock: MqttMockHAClient, setup_tasmota ) -> None: diff --git a/tests/components/tasmota/test_sensor.py b/tests/components/tasmota/test_sensor.py index 2f50a84ffdd1a..dc4820779a667 100644 --- a/tests/components/tasmota/test_sensor.py +++ b/tests/components/tasmota/test_sensor.py @@ -28,6 +28,8 @@ help_test_availability_discovery_update, help_test_availability_poll_state, help_test_availability_when_connection_lost, + help_test_deep_sleep_availability, + help_test_deep_sleep_availability_when_connection_lost, help_test_discovery_device_remove, help_test_discovery_removal, help_test_discovery_update_unchanged, @@ -1222,6 +1224,26 @@ async def test_availability_when_connection_lost( ) +async def test_deep_sleep_availability_when_connection_lost( + hass: HomeAssistant, + mqtt_client_mock: MqttMockPahoClient, + mqtt_mock: MqttMockHAClient, + setup_tasmota, +) -> None: + """Test availability after MQTT disconnection.""" + config = copy.deepcopy(DEFAULT_CONFIG) + sensor_config = copy.deepcopy(DEFAULT_SENSOR_CONFIG) + await help_test_deep_sleep_availability_when_connection_lost( + hass, + mqtt_client_mock, + mqtt_mock, + Platform.SENSOR, + config, + sensor_config, + "tasmota_dht11_temperature", + ) + + async def test_availability( hass: HomeAssistant, mqtt_mock: MqttMockHAClient, setup_tasmota ) -> None: @@ -1238,6 +1260,22 @@ async def test_availability( ) +async def test_deep_sleep_availability( + hass: HomeAssistant, mqtt_mock: MqttMockHAClient, setup_tasmota +) -> None: + """Test availability.""" + config = copy.deepcopy(DEFAULT_CONFIG) + sensor_config = copy.deepcopy(DEFAULT_SENSOR_CONFIG) + await help_test_deep_sleep_availability( + hass, + mqtt_mock, + Platform.SENSOR, + config, + sensor_config, + "tasmota_dht11_temperature", + ) + + async def test_availability_discovery_update( hass: HomeAssistant, mqtt_mock: MqttMockHAClient, setup_tasmota ) -> None: diff --git a/tests/components/tasmota/test_switch.py b/tests/components/tasmota/test_switch.py index 54d94b46fe89d..1a16f372fc98b 100644 --- a/tests/components/tasmota/test_switch.py +++ b/tests/components/tasmota/test_switch.py @@ -20,6 +20,8 @@ help_test_availability_discovery_update, help_test_availability_poll_state, help_test_availability_when_connection_lost, + help_test_deep_sleep_availability, + help_test_deep_sleep_availability_when_connection_lost, help_test_discovery_device_remove, help_test_discovery_removal, help_test_discovery_update_unchanged, @@ -158,6 +160,20 @@ async def test_availability_when_connection_lost( ) +async def test_deep_sleep_availability_when_connection_lost( + hass: HomeAssistant, + mqtt_client_mock: MqttMockPahoClient, + mqtt_mock: MqttMockHAClient, + setup_tasmota, +) -> None: + """Test availability after MQTT disconnection.""" + config = copy.deepcopy(DEFAULT_CONFIG) + config["rl"][0] = 1 + await help_test_deep_sleep_availability_when_connection_lost( + hass, mqtt_client_mock, mqtt_mock, Platform.SWITCH, config + ) + + async def test_availability( hass: HomeAssistant, mqtt_mock: MqttMockHAClient, setup_tasmota ) -> None: @@ -167,6 +183,15 @@ async def test_availability( await help_test_availability(hass, mqtt_mock, Platform.SWITCH, config) +async def test_deep_sleep_availability( + hass: HomeAssistant, mqtt_mock: MqttMockHAClient, setup_tasmota +) -> None: + """Test availability.""" + config = copy.deepcopy(DEFAULT_CONFIG) + config["rl"][0] = 1 + await help_test_deep_sleep_availability(hass, mqtt_mock, Platform.SWITCH, config) + + async def test_availability_discovery_update( hass: HomeAssistant, mqtt_mock: MqttMockHAClient, setup_tasmota ) -> None: diff --git a/tests/components/wyoming/test_devices.py b/tests/components/wyoming/test_devices.py index 549f76f20f1c2..0273a7da27526 100644 --- a/tests/components/wyoming/test_devices.py +++ b/tests/components/wyoming/test_devices.py @@ -5,7 +5,7 @@ from homeassistant.components.wyoming import DOMAIN from homeassistant.components.wyoming.devices import SatelliteDevice from homeassistant.config_entries import ConfigEntry -from homeassistant.const import STATE_OFF, STATE_ON +from homeassistant.const import STATE_OFF from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr @@ -34,11 +34,11 @@ async def test_device_registry_info( assert assist_in_progress_state is not None assert assist_in_progress_state.state == STATE_OFF - satellite_enabled_id = satellite_device.get_satellite_enabled_entity_id(hass) - assert satellite_enabled_id - satellite_enabled_state = hass.states.get(satellite_enabled_id) - assert satellite_enabled_state is not None - assert satellite_enabled_state.state == STATE_ON + muted_id = satellite_device.get_muted_entity_id(hass) + assert muted_id + muted_state = hass.states.get(muted_id) + assert muted_state is not None + assert muted_state.state == STATE_OFF pipeline_entity_id = satellite_device.get_pipeline_entity_id(hass) assert pipeline_entity_id @@ -59,9 +59,9 @@ async def test_remove_device_registry_entry( assert assist_in_progress_id assert hass.states.get(assist_in_progress_id) is not None - satellite_enabled_id = satellite_device.get_satellite_enabled_entity_id(hass) - assert satellite_enabled_id - assert hass.states.get(satellite_enabled_id) is not None + muted_id = satellite_device.get_muted_entity_id(hass) + assert muted_id + assert hass.states.get(muted_id) is not None pipeline_entity_id = satellite_device.get_pipeline_entity_id(hass) assert pipeline_entity_id @@ -74,5 +74,5 @@ async def test_remove_device_registry_entry( # Everything should be gone assert hass.states.get(assist_in_progress_id) is None - assert hass.states.get(satellite_enabled_id) is None + assert hass.states.get(muted_id) is None assert hass.states.get(pipeline_entity_id) is None diff --git a/tests/components/wyoming/test_satellite.py b/tests/components/wyoming/test_satellite.py index 50252007aa5bc..07a6aa8925e19 100644 --- a/tests/components/wyoming/test_satellite.py +++ b/tests/components/wyoming/test_satellite.py @@ -196,7 +196,7 @@ async def test_satellite_pipeline(hass: HomeAssistant) -> None: await mock_client.detect_event.wait() assert not device.is_active - assert device.is_enabled + assert not device.is_muted # Wake word is detected event_callback( @@ -312,35 +312,36 @@ async def test_satellite_pipeline(hass: HomeAssistant) -> None: await hass.async_block_till_done() -async def test_satellite_disabled(hass: HomeAssistant) -> None: - """Test callback for a satellite that has been disabled.""" - on_disabled_event = asyncio.Event() +async def test_satellite_muted(hass: HomeAssistant) -> None: + """Test callback for a satellite that has been muted.""" + on_muted_event = asyncio.Event() original_make_satellite = wyoming._make_satellite - def make_disabled_satellite( + def make_muted_satellite( hass: HomeAssistant, config_entry: ConfigEntry, service: WyomingService ): satellite = original_make_satellite(hass, config_entry, service) - satellite.device.is_enabled = False + satellite.device.set_is_muted(True) return satellite - async def on_disabled(self): - on_disabled_event.set() + async def on_muted(self): + self.device.set_is_muted(False) + on_muted_event.set() with patch( "homeassistant.components.wyoming.data.load_wyoming_info", return_value=SATELLITE_INFO, ), patch( - "homeassistant.components.wyoming._make_satellite", make_disabled_satellite + "homeassistant.components.wyoming._make_satellite", make_muted_satellite ), patch( - "homeassistant.components.wyoming.satellite.WyomingSatellite.on_disabled", - on_disabled, + "homeassistant.components.wyoming.satellite.WyomingSatellite.on_muted", + on_muted, ): await setup_config_entry(hass) async with asyncio.timeout(1): - await on_disabled_event.wait() + await on_muted_event.wait() async def test_satellite_restart(hass: HomeAssistant) -> None: @@ -368,11 +369,19 @@ async def on_restart(self): async def test_satellite_reconnect(hass: HomeAssistant) -> None: """Test satellite reconnect call after connection refused.""" - on_reconnect_event = asyncio.Event() + num_reconnects = 0 + reconnect_event = asyncio.Event() + stopped_event = asyncio.Event() async def on_reconnect(self): - self.stop() - on_reconnect_event.set() + nonlocal num_reconnects + num_reconnects += 1 + if num_reconnects >= 2: + reconnect_event.set() + self.stop() + + async def on_stopped(self): + stopped_event.set() with patch( "homeassistant.components.wyoming.data.load_wyoming_info", @@ -383,10 +392,14 @@ async def on_reconnect(self): ), patch( "homeassistant.components.wyoming.satellite.WyomingSatellite.on_reconnect", on_reconnect, + ), patch( + "homeassistant.components.wyoming.satellite.WyomingSatellite.on_stopped", + on_stopped, ): await setup_config_entry(hass) async with asyncio.timeout(1): - await on_reconnect_event.wait() + await reconnect_event.wait() + await stopped_event.wait() async def test_satellite_disconnect_before_pipeline(hass: HomeAssistant) -> None: diff --git a/tests/components/wyoming/test_switch.py b/tests/components/wyoming/test_switch.py index 0b05724d76198..fc5a8689cebb3 100644 --- a/tests/components/wyoming/test_switch.py +++ b/tests/components/wyoming/test_switch.py @@ -5,28 +5,28 @@ from homeassistant.core import HomeAssistant -async def test_satellite_enabled( +async def test_muted( hass: HomeAssistant, satellite_config_entry: ConfigEntry, satellite_device: SatelliteDevice, ) -> None: - """Test satellite enabled.""" - satellite_enabled_id = satellite_device.get_satellite_enabled_entity_id(hass) - assert satellite_enabled_id + """Test satellite muted.""" + muted_id = satellite_device.get_muted_entity_id(hass) + assert muted_id - state = hass.states.get(satellite_enabled_id) + state = hass.states.get(muted_id) assert state is not None - assert state.state == STATE_ON - assert satellite_device.is_enabled + assert state.state == STATE_OFF + assert not satellite_device.is_muted await hass.services.async_call( "switch", - "turn_off", - {"entity_id": satellite_enabled_id}, + "turn_on", + {"entity_id": muted_id}, blocking=True, ) - state = hass.states.get(satellite_enabled_id) + state = hass.states.get(muted_id) assert state is not None - assert state.state == STATE_OFF - assert not satellite_device.is_enabled + assert state.state == STATE_ON + assert satellite_device.is_muted diff --git a/tests/components/zha/conftest.py b/tests/components/zha/conftest.py index 1b3a536007ad5..55405d0a51c81 100644 --- a/tests/components/zha/conftest.py +++ b/tests/components/zha/conftest.py @@ -46,7 +46,7 @@ def disable_request_retry_delay(): with patch( "homeassistant.components.zha.core.cluster_handlers.RETRYABLE_REQUEST_DECORATOR", zigpy.util.retryable_request(tries=3, delay=0), - ), patch("homeassistant.components.zha.STARTUP_FAILURE_DELAY_S", 0.01): + ): yield diff --git a/tests/components/zha/test_gateway.py b/tests/components/zha/test_gateway.py index 4f5209207046e..1d9042daa4aa6 100644 --- a/tests/components/zha/test_gateway.py +++ b/tests/components/zha/test_gateway.py @@ -1,9 +1,8 @@ """Test ZHA Gateway.""" import asyncio -from unittest.mock import MagicMock, patch +from unittest.mock import patch import pytest -from zigpy.application import ControllerApplication import zigpy.profiles.zha as zha import zigpy.zcl.clusters.general as general import zigpy.zcl.clusters.lighting as lighting @@ -223,48 +222,6 @@ async def test_gateway_create_group_with_id( assert zha_group.group_id == 0x1234 -@patch( - "homeassistant.components.zha.core.gateway.ZHAGateway.async_load_devices", - MagicMock(), -) -@patch( - "homeassistant.components.zha.core.gateway.ZHAGateway.async_load_groups", - MagicMock(), -) -@pytest.mark.parametrize( - ("device_path", "thread_state", "config_override"), - [ - ("/dev/ttyUSB0", True, {}), - ("socket://192.168.1.123:9999", False, {}), - ("socket://192.168.1.123:9999", True, {"use_thread": True}), - ], -) -async def test_gateway_initialize_bellows_thread( - device_path: str, - thread_state: bool, - config_override: dict, - hass: HomeAssistant, - zigpy_app_controller: ControllerApplication, - config_entry: MockConfigEntry, -) -> None: - """Test ZHA disabling the UART thread when connecting to a TCP coordinator.""" - config_entry.data = dict(config_entry.data) - config_entry.data["device"]["path"] = device_path - config_entry.add_to_hass(hass) - - zha_gateway = ZHAGateway(hass, {"zigpy_config": config_override}, config_entry) - - with patch( - "bellows.zigbee.application.ControllerApplication.new", - return_value=zigpy_app_controller, - ) as mock_new: - await zha_gateway.async_initialize() - - mock_new.mock_calls[-1].kwargs["config"]["use_thread"] is thread_state - - await zha_gateway.shutdown() - - @pytest.mark.parametrize( ("device_path", "config_override", "expected_channel"), [ diff --git a/tests/components/zha/test_repairs.py b/tests/components/zha/test_repairs.py index d168e2e57b12a..0efff5ecb526f 100644 --- a/tests/components/zha/test_repairs.py +++ b/tests/components/zha/test_repairs.py @@ -95,7 +95,6 @@ def test_detect_radio_hardware_failure(hass: HomeAssistant) -> None: assert _detect_radio_hardware(hass, SKYCONNECT_DEVICE) == HardwareType.OTHER -@patch("homeassistant.components.zha.STARTUP_RETRIES", new=1) @pytest.mark.parametrize( ("detected_hardware", "expected_learn_more_url"), [ @@ -176,7 +175,7 @@ async def test_multipan_firmware_no_repair_on_probe_failure( await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() - assert config_entry.state == ConfigEntryState.SETUP_ERROR + assert config_entry.state == ConfigEntryState.SETUP_RETRY await hass.config_entries.async_unload(config_entry.entry_id) @@ -189,7 +188,6 @@ async def test_multipan_firmware_no_repair_on_probe_failure( assert issue is None -@patch("homeassistant.components.zha.STARTUP_RETRIES", new=1) async def test_multipan_firmware_retry_on_probe_ezsp( hass: HomeAssistant, config_entry: MockConfigEntry,