diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index beeb2f01dbcf15..112703d7280a57 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -33,6 +33,6 @@ jobs: languages: python - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@db488ddef3bf6cb639b32c2e9a7c0a7ea8271d28 # v4.37.8 + uses: github/codeql-action/analyze@cdf488f595d80d6e07e03d4674febd5ab45fa938 # v4.37.9 with: category: "/language:python" diff --git a/homeassistant/components/google_sheets/services.py b/homeassistant/components/google_sheets/services.py index 61362701588779..91f70f0c680c85 100644 --- a/homeassistant/components/google_sheets/services.py +++ b/homeassistant/components/google_sheets/services.py @@ -72,7 +72,7 @@ def _append_to_sheet(call: ServiceCall, entry: GoogleSheetsConfigEntry) -> None: client = Client(Credentials(entry.data[CONF_TOKEN][CONF_ACCESS_TOKEN])) # type: ignore[no-untyped-call] sheet = client.open_by_key(entry.unique_id) worksheet = _get_worksheet(sheet, call.data.get(WORKSHEET)) - columns: list[str] = next(iter(worksheet.get_values("A1:ZZ1")), []) + columns: list[str] = next(iter(worksheet.get_values("1:1")), []) add_created_column = call.data[ADD_CREATED_COLUMN] now = str(dt_util.now()) rows = [] diff --git a/homeassistant/components/imou/button.py b/homeassistant/components/imou/button.py index 0ea9a40b6b56f0..bb6e481b908b6e 100644 --- a/homeassistant/components/imou/button.py +++ b/homeassistant/components/imou/button.py @@ -3,7 +3,6 @@ from typing import override from pyimouapi.const import PARAM_RESTART_DEVICE -from pyimouapi.exceptions import ImouException from pyimouapi.ha_device import ImouHaDevice from homeassistant.components.button import ( @@ -12,12 +11,12 @@ ButtonEntityDescription, ) from homeassistant.core import HomeAssistant -from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from .const import DOMAIN, PTZ_MOVE_DURATION_MS, imou_device_identifier +from .const import PTZ_MOVE_DURATION_MS, imou_device_identifier from .coordinator import ImouConfigEntry, ImouDataUpdateCoordinator from .entity import ImouEntity +from .helpers import async_wrap_imou_command PARALLEL_UPDATES = 1 # Button types not yet exported by pyimouapi (keep module-local). @@ -100,18 +99,12 @@ class ImouButton(ImouEntity, ButtonEntity): entity_description: ButtonEntityDescription @override + @async_wrap_imou_command("press_button_failed") async def async_press(self) -> None: """Handle button press.""" duration = PTZ_MOVE_DURATION_MS if self._entity_type in PTZ_BUTTON_TYPES else 0 - try: - await self.coordinator.device_manager.async_press_button( - self.device, - self._entity_type, - duration, - ) - except ImouException as e: - raise HomeAssistantError( - translation_domain=DOMAIN, - translation_key="press_button_failed", - translation_placeholders={"error": e.message}, - ) from e + await self.coordinator.device_manager.async_press_button( + self.device, + self._entity_type, + duration, + ) diff --git a/homeassistant/components/imou/camera.py b/homeassistant/components/imou/camera.py index cbdd8debaa3ebc..f2d21ffbf7cb54 100644 --- a/homeassistant/components/imou/camera.py +++ b/homeassistant/components/imou/camera.py @@ -4,7 +4,6 @@ from typing import override from pyimouapi.const import PARAM_HD, PARAM_MOTION_DETECT, PARAM_STATE -from pyimouapi.exceptions import ImouException from pyimouapi.ha_device import ImouHaDevice from homeassistant.components.camera import ( @@ -13,12 +12,12 @@ CameraEntityFeature, ) from homeassistant.core import HomeAssistant -from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from .const import DOMAIN, PARAM_HEADER_DETECT, imou_device_identifier +from .const import PARAM_HEADER_DETECT, imou_device_identifier from .coordinator import ImouConfigEntry, ImouDataUpdateCoordinator from .entity import ImouEntity +from .helpers import async_wrap_imou_command PARALLEL_UPDATES = 0 @@ -89,37 +88,25 @@ def __init__( super().__init__(coordinator, description, device) @override + @async_wrap_imou_command("get_stream_failed") async def stream_source(self) -> str | None: """Return the live stream URL from the Imou cloud.""" - try: - return await self.coordinator.device_manager.async_get_device_stream( - self.device, - self.entity_description.resolution, - PYIMOUAPI_LIVE_PROTOCOL, - ) - except ImouException as err: - raise HomeAssistantError( - translation_domain=DOMAIN, - translation_key="get_stream_failed", - translation_placeholders={"error": err.message}, - ) from err + return await self.coordinator.device_manager.async_get_device_stream( + self.device, + self.entity_description.resolution, + PYIMOUAPI_LIVE_PROTOCOL, + ) @override + @async_wrap_imou_command("get_image_failed") async def async_camera_image( self, width: int | None = None, height: int | None = None ) -> bytes | None: """Return bytes of camera image.""" - try: - return await self.coordinator.device_manager.async_get_device_image( - self.device, - PYIMOUAPI_SNAPSHOT_WAIT_SECONDS, - ) - except ImouException as err: - raise HomeAssistantError( - translation_domain=DOMAIN, - translation_key="get_image_failed", - translation_placeholders={"error": err.message}, - ) from err + return await self.coordinator.device_manager.async_get_device_image( + self.device, + PYIMOUAPI_SNAPSHOT_WAIT_SECONDS, + ) @property @override diff --git a/homeassistant/components/imou/helpers.py b/homeassistant/components/imou/helpers.py new file mode 100644 index 00000000000000..7d22b8969bf532 --- /dev/null +++ b/homeassistant/components/imou/helpers.py @@ -0,0 +1,44 @@ +"""Helpers for Imou.""" + +from collections.abc import Awaitable, Callable, Coroutine +from functools import wraps +from typing import Any, Concatenate + +from pyimouapi.exceptions import ImouException, InvalidAppIdOrSecretException + +from homeassistant.exceptions import HomeAssistantError + +from .const import DOMAIN +from .entity import ImouEntity + + +def async_wrap_imou_command[_T: ImouEntity, **_P, _R]( + error_key: str, +) -> Callable[ + [Callable[Concatenate[_T, _P], Awaitable[_R]]], + Callable[Concatenate[_T, _P], Coroutine[Any, Any, _R]], +]: + """Wrap an Imou command and start reauthentication when credentials are rejected.""" + + def decorator( + func: Callable[Concatenate[_T, _P], Awaitable[_R]], + ) -> Callable[Concatenate[_T, _P], Coroutine[Any, Any, _R]]: + @wraps(func) + async def wrapper(self: _T, *args: _P.args, **kwargs: _P.kwargs) -> _R: + try: + return await func(self, *args, **kwargs) + except InvalidAppIdOrSecretException as err: + self.coordinator.config_entry.async_start_reauth(self.hass) + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="invalid_auth", + ) from err + except ImouException as err: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key=error_key, + ) from err + + return wrapper + + return decorator diff --git a/homeassistant/components/imou/select.py b/homeassistant/components/imou/select.py index e4f74073fc0cba..a4be6634e6c18a 100644 --- a/homeassistant/components/imou/select.py +++ b/homeassistant/components/imou/select.py @@ -8,18 +8,17 @@ PARAM_NIGHT_VISION_MODE, PARAM_OPTIONS, ) -from pyimouapi.exceptions import ImouException from pyimouapi.ha_device import ImouHaDevice from homeassistant.components.select import SelectEntity, SelectEntityDescription from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant -from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from .const import DOMAIN, imou_device_identifier +from .const import imou_device_identifier from .coordinator import ImouConfigEntry, ImouDataUpdateCoordinator from .entity import ImouEntity +from .helpers import async_wrap_imou_command PARALLEL_UPDATES = 0 @@ -87,18 +86,12 @@ def current_option(self) -> str | None: return self.device.selects[self._entity_type][PARAM_CURRENT_OPTION] @override + @async_wrap_imou_command("select_option_failed") async def async_select_option(self, option: str) -> None: """Change the selected option.""" - try: - await self.coordinator.device_manager.async_select_option( - self.device, - self._entity_type, - option, - ) - except ImouException as e: - raise HomeAssistantError( - translation_domain=DOMAIN, - translation_key="select_option_failed", - translation_placeholders={"error": e.message}, - ) from e + await self.coordinator.device_manager.async_select_option( + self.device, + self._entity_type, + option, + ) await self.coordinator.async_request_refresh() diff --git a/homeassistant/components/imou/strings.json b/homeassistant/components/imou/strings.json index c9834ce9706909..e5872d8ed7d44d 100644 --- a/homeassistant/components/imou/strings.json +++ b/homeassistant/components/imou/strings.json @@ -138,22 +138,22 @@ }, "exceptions": { "get_image_failed": { - "message": "Could not get a snapshot from Imou: {error}" + "message": "Could not get a snapshot from Imou" }, "get_stream_failed": { - "message": "Could not get the live stream URL from Imou: {error}" + "message": "Could not get the live stream URL from Imou" }, "invalid_auth": { "message": "Imou rejected the App ID and App secret" }, "press_button_failed": { - "message": "Imou rejected the button press: {error}" + "message": "Imou rejected the button press" }, "select_option_failed": { - "message": "Imou rejected the new option: {error}" + "message": "Imou rejected the new option" }, "switch_operation_failed": { - "message": "Imou rejected the switch change: {error}" + "message": "Imou rejected the switch change" } }, "selector": { diff --git a/homeassistant/components/imou/switch.py b/homeassistant/components/imou/switch.py index d60db6a676c719..91cb78fc35f516 100644 --- a/homeassistant/components/imou/switch.py +++ b/homeassistant/components/imou/switch.py @@ -3,7 +3,6 @@ from typing import Any, override from pyimouapi.const import PARAM_MOTION_DETECT, PARAM_STATE -from pyimouapi.exceptions import ImouException from pyimouapi.ha_device import ImouHaDevice from homeassistant.components.switch import ( @@ -12,11 +11,9 @@ SwitchEntityDescription, ) from homeassistant.core import HomeAssistant -from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import ( - DOMAIN, PARAM_AB_ALARM_SOUND, PARAM_AUDIO_ENCODE_CONTROL, PARAM_CLOSE_CAMERA, @@ -28,6 +25,7 @@ ) from .coordinator import ImouConfigEntry, ImouDataUpdateCoordinator from .entity import ImouEntity +from .helpers import async_wrap_imou_command PARALLEL_UPDATES = 0 @@ -122,18 +120,12 @@ async def async_turn_off(self, **kwargs: Any) -> None: """Turn the switch off.""" await self._async_switch_operation(False) + @async_wrap_imou_command("switch_operation_failed") async def _async_switch_operation(self, enable: bool) -> None: """Call the vendor library to change switch state.""" - try: - await self.coordinator.device_manager.async_switch_operation( - self.device, - self._entity_type, - enable, - ) - except ImouException as e: - raise HomeAssistantError( - translation_domain=DOMAIN, - translation_key="switch_operation_failed", - translation_placeholders={"error": e.message}, - ) from e + await self.coordinator.device_manager.async_switch_operation( + self.device, + self._entity_type, + enable, + ) await self.coordinator.async_request_refresh() diff --git a/homeassistant/components/lyngdorf/manifest.json b/homeassistant/components/lyngdorf/manifest.json index 6bd78d3cdd3008..c609a318e23410 100644 --- a/homeassistant/components/lyngdorf/manifest.json +++ b/homeassistant/components/lyngdorf/manifest.json @@ -14,6 +14,10 @@ { "deviceType": "urn:schemas-upnp-org:device:MediaRenderer:2", "manufacturer": "Lyngdorf" + }, + { + "deviceType": "urn:schemas-upnp-org:device:MediaRenderer:2", + "manufacturer": "Steinway Lyngdorf" } ] } diff --git a/homeassistant/components/midea/quality_scale.yaml b/homeassistant/components/midea/quality_scale.yaml index 0ccd33eafc9c93..f0efa6b447fab6 100644 --- a/homeassistant/components/midea/quality_scale.yaml +++ b/homeassistant/components/midea/quality_scale.yaml @@ -39,7 +39,7 @@ rules: log-when-unavailable: todo parallel-updates: done reauthentication-flow: todo - test-coverage: todo + test-coverage: done # Gold devices: todo diff --git a/homeassistant/components/miele/binary_sensor.py b/homeassistant/components/miele/binary_sensor.py index 44a4c9f92d6058..e6eb28f3e547ec 100644 --- a/homeassistant/components/miele/binary_sensor.py +++ b/homeassistant/components/miele/binary_sensor.py @@ -44,6 +44,7 @@ class MieleBinarySensorDefinition: BINARY_SENSOR_TYPES: Final[tuple[MieleBinarySensorDefinition, ...]] = ( MieleBinarySensorDefinition( types=( + MieleAppliance.COFFEE_SYSTEM, MieleAppliance.DISH_WARMER, MieleAppliance.DISHWASHER, MieleAppliance.FREEZER, diff --git a/homeassistant/components/nintendo_parental_controls/strings.json b/homeassistant/components/nintendo_parental_controls/strings.json index d33c6a0a2720e6..13c9a1f1adb9ce 100644 --- a/homeassistant/components/nintendo_parental_controls/strings.json +++ b/homeassistant/components/nintendo_parental_controls/strings.json @@ -112,27 +112,27 @@ "fields": { "bonus_time": { "description": "The amount of bonus time to add in minutes. Maximum is 30 minutes, minimum is 5.", - "name": "Bonus Time" + "name": "Bonus time" }, "device_id": { "description": "The ID of the device to add bonus time to.", "name": "Device" } }, - "name": "Add Bonus Time" + "name": "Add bonus time" }, "device_usage_report": { - "description": "Get today's application usage details for a device.", + "description": "Retrieves today's application usage details for a device.", "fields": { "device_id": { "description": "The ID of the device to get usage details for.", "name": "Device" } }, - "name": "Device usage report" + "name": "Get device usage report" }, "player_usage_report": { - "description": "Get today's application usage details for a specific player.", + "description": "Retrieves today's application usage details for a specific player.", "fields": { "device_id": { "description": "The ID of the device to get player usage details for.", @@ -143,10 +143,10 @@ "name": "Player" } }, - "name": "Player usage report" + "name": "Get player usage report" }, "update_pin_code": { - "description": "Update the PIN code for the selected Nintendo Switch.", + "description": "Updates the PIN code for the selected Nintendo Switch.", "fields": { "device_id": { "description": "The ID of the device to update the PIN code for.", @@ -157,7 +157,7 @@ "name": "PIN" } }, - "name": "Update PIN Code" + "name": "Update PIN code" } } } diff --git a/homeassistant/components/remote_calendar/calendar.py b/homeassistant/components/remote_calendar/calendar.py index 7273bf345e8d22..30cdd57e303b43 100644 --- a/homeassistant/components/remote_calendar/calendar.py +++ b/homeassistant/components/remote_calendar/calendar.py @@ -4,7 +4,7 @@ import logging from typing import override -from ical.event import Event +from ical.event import Event, EventStatus from ical.timeline import Timeline, materialize_timeline from homeassistant.components.calendar import CalendarEntity, CalendarEvent @@ -67,7 +67,11 @@ def event(self) -> CalendarEvent | None: if self._timeline is None: return None now = dt_util.now() - events = self._timeline.active_after(now) + events = ( + event + for event in self._timeline.active_after(now) + if not _is_cancelled(event) + ) if event := next(events, None): return _get_calendar_event(event) return None @@ -84,7 +88,11 @@ def events_in_range() -> list[CalendarEvent]: start_date, end_date, ) - return [_get_calendar_event(event) for event in events] + return [ + _get_calendar_event(event) + for event in events + if not _is_cancelled(event) + ] return await self.hass.async_add_executor_job(events_in_range) @@ -132,6 +140,16 @@ async def _async_handle_coordinator_update(self) -> None: self.async_write_ha_state() +def _is_cancelled(event: Event) -> bool: + """Return whether an event has been called off. + + rfc5545 keeps a cancelled event in the calendar rather than deleting it, so + a remote calendar can serve one. A calendar entity does not return such + events. + """ + return event.status == EventStatus.CANCELLED + + def _get_calendar_event(event: Event) -> CalendarEvent: """Return a CalendarEvent from an API event.""" diff --git a/homeassistant/components/roomba/strings.json b/homeassistant/components/roomba/strings.json index a6eebcc8f246e4..3428b341645178 100644 --- a/homeassistant/components/roomba/strings.json +++ b/homeassistant/components/roomba/strings.json @@ -89,6 +89,23 @@ } } }, + "exceptions": { + "invalid_fan_speed": { + "message": "Invalid fan speed {fan_speed}. Expected one of: {fan_speeds}." + }, + "invalid_fan_speed_format": { + "message": "Invalid fan speed {fan_speed}. Expected the format behavior-spray_amount, for example Standard-1." + }, + "invalid_mop_behavior": { + "message": "Invalid mop behavior {behavior}. Expected one of: {behaviors}." + }, + "invalid_spray_amount": { + "message": "Invalid spray amount {spray_amount}. Expected one of: {spray_amounts}." + }, + "spray_amount_not_a_number": { + "message": "Invalid spray amount {spray_amount}. Expected a whole number." + } + }, "options": { "step": { "init": { diff --git a/homeassistant/components/roomba/vacuum.py b/homeassistant/components/roomba/vacuum.py index ee4f9858798a22..a608c837d5ca14 100644 --- a/homeassistant/components/roomba/vacuum.py +++ b/homeassistant/components/roomba/vacuum.py @@ -11,11 +11,13 @@ VacuumEntityFeature, ) from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ServiceValidationError from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.util import dt as dt_util from homeassistant.util.unit_system import METRIC_SYSTEM from . import roomba_reported_state +from .const import DOMAIN from .entity import IRobotEntity from .models import RoombaConfigEntry @@ -317,8 +319,14 @@ async def async_set_fan_speed(self, fan_speed: str, **kwargs: Any) -> None: high_perf = True carpet_boost = False else: - _LOGGER.error("No such fan speed available: %s", fan_speed) - return + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="invalid_fan_speed", + translation_placeholders={ + "fan_speed": fan_speed, + "fan_speeds": ", ".join(FAN_SPEEDS), + }, + ) # The set_preference method does only accept string values def _set_fan_speed_preferences() -> None: @@ -371,30 +379,36 @@ async def async_set_fan_speed(self, fan_speed: str, **kwargs: Any) -> None: spray = int(split[1]) if behavior.capitalize() in BRAAVA_MOP_BEHAVIORS: behavior = behavior.capitalize() - # pylint: disable-next=home-assistant-action-swallowed-exception - except IndexError: - _LOGGER.error( - "Fan speed error: expected {behavior}-{spray_amount}, got '%s'", - fan_speed, - ) - return - except ValueError: - _LOGGER.error("Spray amount error: expected integer, got '%s'", split[1]) - return + except IndexError as err: + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="invalid_fan_speed_format", + translation_placeholders={"fan_speed": fan_speed}, + ) from err + except ValueError as err: + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="spray_amount_not_a_number", + translation_placeholders={"spray_amount": split[1]}, + ) from err if behavior not in BRAAVA_MOP_BEHAVIORS: - _LOGGER.error( - "Mop behavior error: expected one of %s, got '%s'", - str(BRAAVA_MOP_BEHAVIORS), - behavior, + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="invalid_mop_behavior", + translation_placeholders={ + "behavior": behavior, + "behaviors": ", ".join(BRAAVA_MOP_BEHAVIORS), + }, ) - return if spray not in BRAAVA_SPRAY_AMOUNT: - _LOGGER.error( - "Spray amount error: expected one of %s, got '%d'", - str(BRAAVA_SPRAY_AMOUNT), - spray, + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="invalid_spray_amount", + translation_placeholders={ + "spray_amount": str(spray), + "spray_amounts": ", ".join(str(s) for s in BRAAVA_SPRAY_AMOUNT), + }, ) - return overlap = 0 if behavior == MOP_STANDARD: diff --git a/homeassistant/components/smartthings/climate.py b/homeassistant/components/smartthings/climate.py index bc6ed4951310c7..f91cf63d5285b3 100644 --- a/homeassistant/components/smartthings/climate.py +++ b/homeassistant/components/smartthings/climate.py @@ -115,6 +115,7 @@ "smart": "smart", "motionIndirect": "motion_indirect", "motionDirect": "motion_direct", + "dryComfort": "dry_comfort", } HA_MODE_TO_PRESET_MODE = {v: k for k, v in PRESET_MODE_TO_HA.items()} diff --git a/homeassistant/components/smartthings/strings.json b/homeassistant/components/smartthings/strings.json index 9b6007fd18366f..7db88dcd85aa79 100644 --- a/homeassistant/components/smartthings/strings.json +++ b/homeassistant/components/smartthings/strings.json @@ -130,6 +130,7 @@ }, "preset_mode": { "state": { + "dry_comfort": "Dry comfort", "long_wind": "Long wind", "motion_direct": "Motion direct", "motion_indirect": "Motion indirect", diff --git a/homeassistant/components/smtp/__init__.py b/homeassistant/components/smtp/__init__.py index 397bf4953ebac4..9e66d053630767 100644 --- a/homeassistant/components/smtp/__init__.py +++ b/homeassistant/components/smtp/__init__.py @@ -26,7 +26,14 @@ from homeassistant.helpers.typing import ConfigType from homeassistant.util.ssl import client_context, client_context_no_verify -from .const import CONF_ENCRYPTION, CONF_ENTRY, CONF_OLD_RECIPIENT, CONF_SERVER, DOMAIN +from .const import ( + CONF_ENCRYPTION, + CONF_ENTRY, + CONF_OLD_RECIPIENT, + CONF_SERVER, + DEFAULT_TIMEOUT, + DOMAIN, +) from .services import async_setup_services _LOGGER = logging.getLogger(__name__) @@ -71,7 +78,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: SmtpConfigEntry) -> bool port=entry.data[CONF_PORT], username=entry.data.get(CONF_USERNAME), password=entry.data.get(CONF_PASSWORD), - timeout=entry.options.get(CONF_TIMEOUT), + timeout=entry.options.get(CONF_TIMEOUT, DEFAULT_TIMEOUT), use_tls=entry.data[CONF_ENCRYPTION] == "tls", start_tls=entry.data[CONF_ENCRYPTION] == "starttls", tls_context=( diff --git a/homeassistant/components/smtp/config_flow.py b/homeassistant/components/smtp/config_flow.py index 61a91ab02ff222..e902be6c4dc83f 100644 --- a/homeassistant/components/smtp/config_flow.py +++ b/homeassistant/components/smtp/config_flow.py @@ -65,7 +65,7 @@ OPTIONS_SCHEMA = vol.Schema( { - vol.Optional(CONF_TIMEOUT, default=DEFAULT_TIMEOUT): vol.All( + vol.Optional(CONF_TIMEOUT): vol.All( NumberSelector( NumberSelectorConfig( min=1, @@ -309,7 +309,7 @@ async def validate_input( port=user_input[CONF_PORT], username=user_input.get(CONF_USERNAME), password=user_input.get(CONF_PASSWORD), - timeout=options.get(CONF_TIMEOUT), + timeout=options.get(CONF_TIMEOUT, DEFAULT_TIMEOUT), use_tls=user_input[CONF_ENCRYPTION] == "tls", start_tls=user_input[CONF_ENCRYPTION] == "starttls", tls_context=( diff --git a/homeassistant/components/smtp/const.py b/homeassistant/components/smtp/const.py index 935d077ea2925e..e4888fa6b13ab9 100644 --- a/homeassistant/components/smtp/const.py +++ b/homeassistant/components/smtp/const.py @@ -20,7 +20,7 @@ DEFAULT_HOST: Final = "localhost" DEFAULT_PORT: Final = 587 -DEFAULT_TIMEOUT: Final = 5 +DEFAULT_TIMEOUT: Final = 60 DEFAULT_DEBUG: Final = False DEFAULT_ENCRYPTION: Final = "starttls" diff --git a/homeassistant/components/smtp/strings.json b/homeassistant/components/smtp/strings.json index d5dbfbcd3ec7d0..d11c74e8375187 100644 --- a/homeassistant/components/smtp/strings.json +++ b/homeassistant/components/smtp/strings.json @@ -154,7 +154,7 @@ "timeout": "Connection timeout" }, "data_description": { - "timeout": "Maximum time to wait for a response from the SMTP server before the connection attempt is aborted." + "timeout": "Maximum time to wait for a response from the SMTP server before the connection attempt is aborted. Defaults to 60 seconds." } } } diff --git a/homeassistant/components/stiebel_eltron/__init__.py b/homeassistant/components/stiebel_eltron/__init__.py index 41933a733e4255..a49d03eb840db3 100644 --- a/homeassistant/components/stiebel_eltron/__init__.py +++ b/homeassistant/components/stiebel_eltron/__init__.py @@ -1,19 +1,20 @@ """The component for STIEBEL ELTRON heat pumps with ISGWeb Modbus module.""" -import logging - -from modbus_connection import ModbusError -from modbus_connection.pymodbus import connect_tcp +from modbus_connection import ModbusTcpParams from pystiebeleltron import StiebelEltronModbusError, get_controller_model +from homeassistant.components.modbus import async_get_unit from homeassistant.const import CONF_HOST, CONF_PORT, Platform from homeassistant.core import HomeAssistant -from homeassistant.exceptions import ConfigEntryNotReady +from homeassistant.exceptions import ( + ConfigEntryError, + ConfigEntryNotReady, + HomeAssistantError, +) from .const import DEFAULT_PORT, UNIT_ID from .coordinator import StiebelEltronConfigEntry, StiebelEltronDataCoordinator -_LOGGER = logging.getLogger(__name__) _PLATFORMS: list[Platform] = [Platform.CLIMATE] @@ -26,27 +27,24 @@ async def async_setup_entry( port = entry.data.get(CONF_PORT, DEFAULT_PORT) try: - connection = await connect_tcp(host, port=port) - except ModbusError as exception: - raise ConfigEntryNotReady("Could not connect to device") from exception - entry.async_on_unload(connection.close) + unit = async_get_unit( + hass, entry, ModbusTcpParams(host=host, port=port), UNIT_ID + ) + # Another integration already holds this host and port with link settings + # that cannot be honoured on one connection. + except HomeAssistantError as exception: + raise ConfigEntryError(str(exception)) from exception try: - model = await get_controller_model(connection.for_unit(UNIT_ID)) + model = await get_controller_model(unit) except StiebelEltronModbusError as exception: raise ConfigEntryNotReady("Could not read controller model") from exception - coordinator = StiebelEltronDataCoordinator(hass, entry, model, connection, host) + coordinator = StiebelEltronDataCoordinator(hass, entry, model, unit, host) entry.runtime_data = coordinator await coordinator.async_config_entry_first_refresh() - entry.async_on_unload( - connection.on_connection_lost( - lambda: hass.config_entries.async_schedule_reload(entry.entry_id) - ) - ) - await hass.config_entries.async_forward_entry_setups(entry, _PLATFORMS) return True diff --git a/homeassistant/components/stiebel_eltron/config_flow.py b/homeassistant/components/stiebel_eltron/config_flow.py index 0bbe59b0a5ee71..4e07dc185605ce 100644 --- a/homeassistant/components/stiebel_eltron/config_flow.py +++ b/homeassistant/components/stiebel_eltron/config_flow.py @@ -3,13 +3,15 @@ import logging from typing import Any, override -from modbus_connection import ModbusError -from modbus_connection.pymodbus import connect_tcp +from modbus_connection import ModbusTcpParams from pystiebeleltron import StiebelEltronModbusError, get_controller_model import voluptuous as vol +from homeassistant.components.modbus import async_get_temporary_unit from homeassistant.config_entries import ConfigFlow, ConfigFlowResult from homeassistant.const import CONF_HOST, CONF_PORT +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.device_registry import format_mac from homeassistant.helpers.selector import ( NumberSelector, @@ -36,15 +38,18 @@ ) -async def check_controller_model(host: str, port: int) -> str | None: +async def check_controller_model( + hass: HomeAssistant, host: str, port: int +) -> str | None: """Check if the controller model is valid.""" try: - connection = await connect_tcp(host, port=port) - try: - await get_controller_model(connection.for_unit(UNIT_ID)) - finally: - await connection.close() - except StiebelEltronModbusError, ModbusError: + async with async_get_temporary_unit( + hass, ModbusTcpParams(host=host, port=port), UNIT_ID + ) as unit: + await get_controller_model(unit) + # HomeAssistantError: another integration already holds this host and port + # with link settings that cannot be honoured on one connection. + except StiebelEltronModbusError, HomeAssistantError: _LOGGER.debug("Cannot connect to Stiebel Eltron device", exc_info=True) return "cannot_connect" except Exception: @@ -69,7 +74,7 @@ async def async_step_dhcp( self._abort_if_unique_id_configured(updates={CONF_HOST: discovery_info.ip}) self._async_abort_entries_match({CONF_HOST: discovery_info.ip}) - error = await check_controller_model(discovery_info.ip, DEFAULT_PORT) + error = await check_controller_model(self.hass, discovery_info.ip, DEFAULT_PORT) if error is not None: return self.async_abort(reason=error) @@ -104,7 +109,7 @@ async def async_step_user( {CONF_HOST: user_input[CONF_HOST], CONF_PORT: user_input[CONF_PORT]} ) error = await check_controller_model( - user_input[CONF_HOST], user_input[CONF_PORT] + self.hass, user_input[CONF_HOST], user_input[CONF_PORT] ) if error is not None: errors["base"] = error @@ -129,7 +134,7 @@ async def async_step_reconfigure( {CONF_HOST: user_input[CONF_HOST], CONF_PORT: user_input[CONF_PORT]} ) error = await check_controller_model( - user_input[CONF_HOST], user_input[CONF_PORT] + self.hass, user_input[CONF_HOST], user_input[CONF_PORT] ) if error is not None: errors["base"] = error diff --git a/homeassistant/components/stiebel_eltron/coordinator.py b/homeassistant/components/stiebel_eltron/coordinator.py index 8e1f6dde65b449..5331689049bfc4 100644 --- a/homeassistant/components/stiebel_eltron/coordinator.py +++ b/homeassistant/components/stiebel_eltron/coordinator.py @@ -4,7 +4,7 @@ import logging from typing import override -from modbus_connection import ModbusConnection, ModbusError +from modbus_connection import ModbusError, ModbusUnit from pystiebeleltron import ControllerModel from pystiebeleltron.lwz import LwzStiebelEltronAPI @@ -13,7 +13,7 @@ from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed -from .const import ATTR_MANUFACTURER, DEFAULT_SCAN_INTERVAL, DOMAIN, UNIT_ID +from .const import ATTR_MANUFACTURER, DEFAULT_SCAN_INTERVAL, DOMAIN _LOGGER: logging.Logger = logging.getLogger(__package__) @@ -28,7 +28,7 @@ def __init__( hass: HomeAssistant, entry: StiebelEltronConfigEntry, model: ControllerModel, - connection: ModbusConnection, + unit: ModbusUnit, host: str, ) -> None: """Initialize the StiebelEltronDataCoordinator.""" @@ -42,7 +42,7 @@ def __init__( # the register values), so there is nothing to diff against. always_update=True, ) - self.api_client = LwzStiebelEltronAPI(connection.for_unit(UNIT_ID)) + self.api_client = LwzStiebelEltronAPI(unit) self.device_info = DeviceInfo( identifiers={(DOMAIN, entry.entry_id)}, configuration_url=f"http://{host}", diff --git a/homeassistant/components/stiebel_eltron/manifest.json b/homeassistant/components/stiebel_eltron/manifest.json index 4aaee654aa5641..29c9fd19563b14 100644 --- a/homeassistant/components/stiebel_eltron/manifest.json +++ b/homeassistant/components/stiebel_eltron/manifest.json @@ -3,6 +3,7 @@ "name": "STIEBEL ELTRON", "codeowners": ["@fucm", "@ThyMYthOS"], "config_flow": true, + "dependencies": ["modbus"], "dhcp": [ { "hostname": "servicewelt*" @@ -11,7 +12,7 @@ "documentation": "https://www.home-assistant.io/integrations/stiebel_eltron", "integration_type": "device", "iot_class": "local_polling", - "loggers": ["pymodbus", "pystiebeleltron"], + "loggers": ["pystiebeleltron"], "quality_scale": "silver", "requirements": ["pystiebeleltron==0.7.0"] } diff --git a/homeassistant/components/stt/__init__.py b/homeassistant/components/stt/__init__.py index c4ad6297868c6f..8a98763b31c404 100644 --- a/homeassistant/components/stt/__init__.py +++ b/homeassistant/components/stt/__init__.py @@ -72,6 +72,11 @@ CONFIG_SCHEMA = cv.empty_config_schema(DOMAIN) +# Audio is read from the request body in chunks of at most 4096 bytes because +# line-based iteration raises LineTooLong on binary audio without newline bytes. +# At 16 kHz/16-bit/mono, 4096 bytes represents up to 128 ms of audio. +AUDIO_CHUNK_SIZE = 4096 + @callback def async_default_engine(hass: HomeAssistant) -> str | None: @@ -291,7 +296,7 @@ async def post(self, request: web.Request, provider: str) -> web.Response: # Process audio stream result = await stt_provider.async_process_audio_stream( - metadata, request.content + metadata, request.content.iter_chunked(AUDIO_CHUNK_SIZE) ) else: # Check format @@ -300,7 +305,7 @@ async def post(self, request: web.Request, provider: str) -> web.Response: # Process audio stream result = await provider_entity.internal_async_process_audio_stream( - metadata, request.content + metadata, request.content.iter_chunked(AUDIO_CHUNK_SIZE) ) # Return result diff --git a/homeassistant/components/subaru/__init__.py b/homeassistant/components/subaru/__init__.py index 8ecf33e8f48309..c5e027dc67d6da 100644 --- a/homeassistant/components/subaru/__init__.py +++ b/homeassistant/components/subaru/__init__.py @@ -75,7 +75,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: SubaruConfigEntry) -> bo hass, entry, controller=controller, vehicle_info=vehicle_info ) - await coordinator.async_refresh() + await coordinator.async_config_entry_first_refresh() entry.runtime_data = SubaruRuntimeData( controller=controller, diff --git a/homeassistant/components/zha/manifest.json b/homeassistant/components/zha/manifest.json index 777d31002a4098..507085d7ffe042 100644 --- a/homeassistant/components/zha/manifest.json +++ b/homeassistant/components/zha/manifest.json @@ -23,7 +23,7 @@ "universal_silabs_flasher", "serialx" ], - "requirements": ["zha==2.2.0", "zha-quirks==2.2.1"], + "requirements": ["zha==2.2.1", "zha-quirks==2.2.1"], "usb": [ { "description": "*2652*", diff --git a/homeassistant/generated/ssdp.py b/homeassistant/generated/ssdp.py index cdf5317a2725d7..f88cb841628b5e 100644 --- a/homeassistant/generated/ssdp.py +++ b/homeassistant/generated/ssdp.py @@ -221,6 +221,10 @@ "deviceType": "urn:schemas-upnp-org:device:MediaRenderer:2", "manufacturer": "Lyngdorf", }, + { + "deviceType": "urn:schemas-upnp-org:device:MediaRenderer:2", + "manufacturer": "Steinway Lyngdorf", + }, ], "nanoleaf": [ { diff --git a/homeassistant/helpers/selector.py b/homeassistant/helpers/selector.py index a92876d3b9e003..946e14c936d6d0 100644 --- a/homeassistant/helpers/selector.py +++ b/homeassistant/helpers/selector.py @@ -1370,7 +1370,8 @@ class MediaSelector(Selector[MediaSelectorConfig]): vol.Required("media_content_id"): str, # Although marked as optional in frontend, this field is required vol.Required("media_content_type"): str, - vol.Remove("metadata"): dict, + # Data used by frontend for decoration. + vol.Optional("metadata"): dict, } ) @@ -1378,7 +1379,7 @@ def __init__(self, config: MediaSelectorConfig | None = None) -> None: """Instantiate a selector.""" super().__init__(config) - def __call__(self, data: Any) -> dict[str, str] | list[dict[str, str]]: + def __call__(self, data: Any) -> dict[str, Any] | list[dict[str, Any]]: """Validate the passed selection.""" item_schema_dict = { key: value @@ -1393,7 +1394,7 @@ def __call__(self, data: Any) -> dict[str, str] | list[dict[str, str]]: item_schema = vol.Schema(item_schema_dict) if not self.config["multiple"]: - media: dict[str, str] = item_schema(data) + media: dict[str, Any] = item_schema(data) return media # Backwards compatibility for places that now accept multiple items diff --git a/requirements_all.txt b/requirements_all.txt index 4b38a9ba6b7a4d..fcef77b4d5b091 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -3540,7 +3540,7 @@ zeversolar==0.3.2 zha-quirks==2.2.1 # homeassistant.components.zha -zha==2.2.0 +zha==2.2.1 # homeassistant.components.zhong_hong zhong-hong-hvac==1.0.19 diff --git a/tests/components/collection_image/test_config_flow.py b/tests/components/collection_image/test_config_flow.py index 9292854bf90d70..fdc365fc781250 100644 --- a/tests/components/collection_image/test_config_flow.py +++ b/tests/components/collection_image/test_config_flow.py @@ -32,6 +32,7 @@ def _data_from_uri(uri: str) -> dict: "media": { "media_content_id": uri, "media_content_type": "", + "metadata": {"a": "b"}, } } @@ -95,6 +96,18 @@ async def test_config_flow_error( assert result.get("type") is FlowResultType.FORM assert result.get("title") is None assert result.get("data") is None + + media_key = next( + key + for key in result["data_schema"].schema + if getattr(key, "schema", key) == "media" + ) + assert media_key.description["suggested_value"]["media_content_id"] == uri + assert ( + media_key.description["suggested_value"]["metadata"] + == data["media"]["metadata"] + ) + assert result.get("errors") == {"media": error} assert result.get("description_placeholders") == placeholders assert len(mock_setup_entry.mock_calls) == 0 diff --git a/tests/components/google_sheets/test_init.py b/tests/components/google_sheets/test_init.py index c6c5cf17e00715..5887e7f718ad58 100644 --- a/tests/components/google_sheets/test_init.py +++ b/tests/components/google_sheets/test_init.py @@ -347,6 +347,7 @@ async def test_append_sheet_created_column_uses_configured_time_zone( rows_data = mock_worksheet.append_rows.call_args[0][0] assert rows_data[0] == ["bar", "2024-01-15 23:30:45.123456+11:00"] + mock_worksheet.get_values.assert_called_once_with("1:1") async def test_get_sheet( diff --git a/tests/components/imou/test_button.py b/tests/components/imou/test_button.py index 142016d3641830..f1586d91765426 100644 --- a/tests/components/imou/test_button.py +++ b/tests/components/imou/test_button.py @@ -4,7 +4,7 @@ from freezegun.api import FrozenDateTimeFactory from pyimouapi.const import PARAM_STATE, PARAM_STATUS -from pyimouapi.exceptions import ImouException +from pyimouapi.exceptions import ImouException, InvalidAppIdOrSecretException from pyimouapi.ha_device import DeviceStatus, ImouHaDevice import pytest from syrupy.assertion import SnapshotAssertion @@ -13,6 +13,7 @@ from homeassistant.components.imou.button import PARAM_MUTE, PARAM_PTZ_UP from homeassistant.components.imou.const import PTZ_MOVE_DURATION_MS from homeassistant.components.imou.coordinator import SCAN_INTERVAL +from homeassistant.config_entries import SOURCE_REAUTH, ConfigEntryState from homeassistant.const import ATTR_ENTITY_ID, STATE_UNAVAILABLE, Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError @@ -130,8 +131,30 @@ async def test_press_button_service_propagates_api_error( entity_id = hass.states.async_all("button")[0].entity_id + with pytest.raises(HomeAssistantError, match="Imou rejected the button press"): + await hass.services.async_call( + BUTTON_DOMAIN, + SERVICE_PRESS, + {ATTR_ENTITY_ID: entity_id}, + blocking=True, + ) + + +@pytest.mark.usefixtures("init_integration") +async def test_press_button_invalid_auth_starts_reauth( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_imou_ha_device_manager: MagicMock, +) -> None: + """Rejected credentials while pressing a button start reauthentication.""" + mock_imou_ha_device_manager.async_press_button.side_effect = ( + InvalidAppIdOrSecretException("fail") + ) + + entity_id = hass.states.async_all("button")[0].entity_id + with pytest.raises( - HomeAssistantError, match="Imou rejected the button press: cloud failure" + HomeAssistantError, match="Imou rejected the App ID and App secret" ): await hass.services.async_call( BUTTON_DOMAIN, @@ -139,6 +162,10 @@ async def test_press_button_service_propagates_api_error( {ATTR_ENTITY_ID: entity_id}, blocking=True, ) + await hass.async_block_till_done() + + assert mock_config_entry.state is ConfigEntryState.LOADED + assert any(mock_config_entry.async_get_active_flows(hass, {SOURCE_REAUTH})) @pytest.mark.parametrize( diff --git a/tests/components/imou/test_camera.py b/tests/components/imou/test_camera.py index f5bb225bf3bda2..94a53637e8fd7d 100644 --- a/tests/components/imou/test_camera.py +++ b/tests/components/imou/test_camera.py @@ -4,7 +4,7 @@ from freezegun.api import FrozenDateTimeFactory from pyimouapi.const import PARAM_HD, PARAM_MOTION_DETECT, PARAM_STATE -from pyimouapi.exceptions import ImouException +from pyimouapi.exceptions import ImouException, InvalidAppIdOrSecretException import pytest from syrupy.assertion import SnapshotAssertion @@ -16,6 +16,7 @@ ) from homeassistant.components.imou.const import PARAM_HEADER_DETECT from homeassistant.components.imou.coordinator import SCAN_INTERVAL +from homeassistant.config_entries import SOURCE_REAUTH, ConfigEntryState from homeassistant.const import STATE_UNAVAILABLE, Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError @@ -299,11 +300,48 @@ async def test_camera_stream_source_propagates_api_error( entity_id = _camera_entity_id(entity_registry, mock_config_entry) with pytest.raises( HomeAssistantError, - match="Could not get the live stream URL from Imou: stream failure", + match="Could not get the live stream URL from Imou", ): await async_get_stream_source(hass, entity_id) +@pytest.mark.parametrize( + "imou_mock_devices", + [ + [ + create_online_device( + "d1", + "Device 1", + channel_id="1", + button_keys=(), + ) + ] + ], + indirect=True, +) +@pytest.mark.usefixtures("entity_registry_enabled_by_default", "init_integration") +async def test_camera_stream_source_invalid_auth_starts_reauth( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + mock_imou_ha_device_manager: MagicMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Rejected credentials while fetching a stream start reauthentication.""" + mock_imou_ha_device_manager.async_get_device_stream.side_effect = ( + InvalidAppIdOrSecretException("fail") + ) + + entity_id = _camera_entity_id(entity_registry, mock_config_entry) + with pytest.raises( + HomeAssistantError, match="Imou rejected the App ID and App secret" + ): + await async_get_stream_source(hass, entity_id) + await hass.async_block_till_done() + + assert mock_config_entry.state is ConfigEntryState.LOADED + assert any(mock_config_entry.async_get_active_flows(hass, {SOURCE_REAUTH})) + + @pytest.mark.parametrize( "imou_mock_devices", [ @@ -333,11 +371,48 @@ async def test_camera_image_propagates_api_error( entity_id = _camera_entity_id(entity_registry, mock_config_entry) with pytest.raises( HomeAssistantError, - match="Could not get a snapshot from Imou: image failure", + match="Could not get a snapshot from Imou", ): await async_get_image(hass, entity_id) +@pytest.mark.parametrize( + "imou_mock_devices", + [ + [ + create_online_device( + "d1", + "Device 1", + channel_id="1", + button_keys=(), + ) + ] + ], + indirect=True, +) +@pytest.mark.usefixtures("entity_registry_enabled_by_default", "init_integration") +async def test_camera_image_invalid_auth_starts_reauth( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + mock_imou_ha_device_manager: MagicMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Rejected credentials while fetching a snapshot start reauthentication.""" + mock_imou_ha_device_manager.async_get_device_image.side_effect = ( + InvalidAppIdOrSecretException("fail") + ) + + entity_id = _camera_entity_id(entity_registry, mock_config_entry) + with pytest.raises( + HomeAssistantError, match="Imou rejected the App ID and App secret" + ): + await async_get_image(hass, entity_id) + await hass.async_block_till_done() + + assert mock_config_entry.state is ConfigEntryState.LOADED + assert any(mock_config_entry.async_get_active_flows(hass, {SOURCE_REAUTH})) + + @pytest.mark.parametrize( "imou_mock_devices", [ diff --git a/tests/components/imou/test_select.py b/tests/components/imou/test_select.py index c56e61f2ef4aa4..25cc64c275671a 100644 --- a/tests/components/imou/test_select.py +++ b/tests/components/imou/test_select.py @@ -11,13 +11,14 @@ PARAM_STATE, PARAM_STATUS, ) -from pyimouapi.exceptions import ImouException +from pyimouapi.exceptions import ImouException, InvalidAppIdOrSecretException from pyimouapi.ha_device import DeviceStatus, ImouHaDevice import pytest from syrupy.assertion import SnapshotAssertion from homeassistant.components.imou.coordinator import SCAN_INTERVAL from homeassistant.components.select import DOMAIN as SELECT_DOMAIN +from homeassistant.config_entries import SOURCE_REAUTH, ConfigEntryState from homeassistant.const import ( ATTR_ENTITY_ID, ATTR_OPTION, @@ -145,8 +146,39 @@ async def test_select_option_propagates_api_error( if entry.unique_id == "d1$device_volume" ) + with pytest.raises(HomeAssistantError, match="Imou rejected the new option"): + await hass.services.async_call( + SELECT_DOMAIN, + SERVICE_SELECT_OPTION, + {ATTR_ENTITY_ID: volume_entry.entity_id, ATTR_OPTION: "high"}, + blocking=True, + ) + + +@pytest.mark.parametrize("platforms", [[Platform.SELECT]], indirect=True) +@pytest.mark.parametrize("imou_mock_devices", [select_mock_devices], indirect=True) +@pytest.mark.usefixtures("init_integration") +async def test_select_option_invalid_auth_starts_reauth( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + mock_config_entry: MockConfigEntry, + mock_imou_ha_device_manager: MagicMock, +) -> None: + """Rejected credentials while changing a select start reauthentication.""" + mock_imou_ha_device_manager.async_select_option.side_effect = ( + InvalidAppIdOrSecretException("fail") + ) + + volume_entry = next( + entry + for entry in er.async_entries_for_config_entry( + entity_registry, mock_config_entry.entry_id + ) + if entry.unique_id == "d1$device_volume" + ) + with pytest.raises( - HomeAssistantError, match="Imou rejected the new option: cloud failure" + HomeAssistantError, match="Imou rejected the App ID and App secret" ): await hass.services.async_call( SELECT_DOMAIN, @@ -154,6 +186,10 @@ async def test_select_option_propagates_api_error( {ATTR_ENTITY_ID: volume_entry.entity_id, ATTR_OPTION: "high"}, blocking=True, ) + await hass.async_block_till_done() + + assert mock_config_entry.state is ConfigEntryState.LOADED + assert any(mock_config_entry.async_get_active_flows(hass, {SOURCE_REAUTH})) @pytest.mark.parametrize("platforms", [[Platform.SELECT]], indirect=True) diff --git a/tests/components/imou/test_switch.py b/tests/components/imou/test_switch.py index 0d10e6c80664d4..04f2a7f4a9d274 100644 --- a/tests/components/imou/test_switch.py +++ b/tests/components/imou/test_switch.py @@ -4,7 +4,7 @@ from freezegun.api import FrozenDateTimeFactory from pyimouapi.const import PARAM_MOTION_DETECT, PARAM_STATE, PARAM_STATUS -from pyimouapi.exceptions import ImouException +from pyimouapi.exceptions import ImouException, InvalidAppIdOrSecretException from pyimouapi.ha_device import DeviceStatus, ImouHaDevice import pytest from syrupy.assertion import SnapshotAssertion @@ -12,6 +12,7 @@ from homeassistant.components.imou.const import PARAM_HEADER_DETECT from homeassistant.components.imou.coordinator import SCAN_INTERVAL from homeassistant.components.switch import DOMAIN as SWITCH_DOMAIN +from homeassistant.config_entries import SOURCE_REAUTH, ConfigEntryState from homeassistant.const import ( ATTR_ENTITY_ID, SERVICE_TURN_OFF, @@ -173,8 +174,31 @@ async def test_turn_on_service_propagates_api_error( entity_id = hass.states.async_all("switch")[0].entity_id + with pytest.raises(HomeAssistantError, match="Imou rejected the switch change"): + await hass.services.async_call( + SWITCH_DOMAIN, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: entity_id}, + blocking=True, + ) + + +@pytest.mark.parametrize("imou_mock_devices", [SWITCH_MOCK_DEVICES], indirect=True) +@pytest.mark.usefixtures("init_integration") +async def test_turn_on_invalid_auth_starts_reauth( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_imou_ha_device_manager: MagicMock, +) -> None: + """Rejected credentials while toggling a switch start reauthentication.""" + mock_imou_ha_device_manager.async_switch_operation.side_effect = ( + InvalidAppIdOrSecretException("fail") + ) + + entity_id = hass.states.async_all("switch")[0].entity_id + with pytest.raises( - HomeAssistantError, match="Imou rejected the switch change: cloud failure" + HomeAssistantError, match="Imou rejected the App ID and App secret" ): await hass.services.async_call( SWITCH_DOMAIN, @@ -182,6 +206,10 @@ async def test_turn_on_service_propagates_api_error( {ATTR_ENTITY_ID: entity_id}, blocking=True, ) + await hass.async_block_till_done() + + assert mock_config_entry.state is ConfigEntryState.LOADED + assert any(mock_config_entry.async_get_active_flows(hass, {SOURCE_REAUTH})) @pytest.mark.parametrize( diff --git a/tests/components/miele/snapshots/test_binary_sensor.ambr b/tests/components/miele/snapshots/test_binary_sensor.ambr index 09b9decdce4e8e..a9ee24f7ce65ca 100644 --- a/tests/components/miele/snapshots/test_binary_sensor.ambr +++ b/tests/components/miele/snapshots/test_binary_sensor.ambr @@ -2927,3 +2927,306 @@ 'state': 'off', }) # --- +# name: test_coffee_system_binary_sensor_states[platforms0-coffee_system.json][binary_sensor.coffee_system_door-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.coffee_system_door', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Door', + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Door', + 'platform': 'miele', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'DummyAppliance_CoffeeSystem-state_signal_door', + 'unit_of_measurement': None, + }) +# --- +# name: test_coffee_system_binary_sensor_states[platforms0-coffee_system.json][binary_sensor.coffee_system_door-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'door', + : 'Coffee system Door', + }), + 'context': , + 'entity_id': 'binary_sensor.coffee_system_door', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_coffee_system_binary_sensor_states[platforms0-coffee_system.json][binary_sensor.coffee_system_mobile_start-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': , + 'entity_id': 'binary_sensor.coffee_system_mobile_start', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Mobile start', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Mobile start', + 'platform': 'miele', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'mobile_start', + 'unique_id': 'DummyAppliance_CoffeeSystem-state_mobile_start', + 'unit_of_measurement': None, + }) +# --- +# name: test_coffee_system_binary_sensor_states[platforms0-coffee_system.json][binary_sensor.coffee_system_mobile_start-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'Coffee system Mobile start', + }), + 'context': , + 'entity_id': 'binary_sensor.coffee_system_mobile_start', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_coffee_system_binary_sensor_states[platforms0-coffee_system.json][binary_sensor.coffee_system_notification_active-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': , + 'entity_id': 'binary_sensor.coffee_system_notification_active', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Notification active', + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Notification active', + 'platform': 'miele', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'notification_active', + 'unique_id': 'DummyAppliance_CoffeeSystem-state_signal_info', + 'unit_of_measurement': None, + }) +# --- +# name: test_coffee_system_binary_sensor_states[platforms0-coffee_system.json][binary_sensor.coffee_system_notification_active-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'problem', + : 'Coffee system Notification active', + }), + 'context': , + 'entity_id': 'binary_sensor.coffee_system_notification_active', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_coffee_system_binary_sensor_states[platforms0-coffee_system.json][binary_sensor.coffee_system_problem-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': , + 'entity_id': 'binary_sensor.coffee_system_problem', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Problem', + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Problem', + 'platform': 'miele', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'DummyAppliance_CoffeeSystem-state_signal_failure', + 'unit_of_measurement': None, + }) +# --- +# name: test_coffee_system_binary_sensor_states[platforms0-coffee_system.json][binary_sensor.coffee_system_problem-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'problem', + : 'Coffee system Problem', + }), + 'context': , + 'entity_id': 'binary_sensor.coffee_system_problem', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_coffee_system_binary_sensor_states[platforms0-coffee_system.json][binary_sensor.coffee_system_remote_control-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': , + 'entity_id': 'binary_sensor.coffee_system_remote_control', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Remote control', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Remote control', + 'platform': 'miele', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'remote_control', + 'unique_id': 'DummyAppliance_CoffeeSystem-state_full_remote_control', + 'unit_of_measurement': None, + }) +# --- +# name: test_coffee_system_binary_sensor_states[platforms0-coffee_system.json][binary_sensor.coffee_system_remote_control-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'Coffee system Remote control', + }), + 'context': , + 'entity_id': 'binary_sensor.coffee_system_remote_control', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_coffee_system_binary_sensor_states[platforms0-coffee_system.json][binary_sensor.coffee_system_smart_grid-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': , + 'entity_id': 'binary_sensor.coffee_system_smart_grid', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Smart grid', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Smart grid', + 'platform': 'miele', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'smart_grid', + 'unique_id': 'DummyAppliance_CoffeeSystem-state_smart_grid', + 'unit_of_measurement': None, + }) +# --- +# name: test_coffee_system_binary_sensor_states[platforms0-coffee_system.json][binary_sensor.coffee_system_smart_grid-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'Coffee system Smart grid', + }), + 'context': , + 'entity_id': 'binary_sensor.coffee_system_smart_grid', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- diff --git a/tests/components/miele/test_binary_sensor.py b/tests/components/miele/test_binary_sensor.py index 02cdd7eafe1348..e9de038bfd5b2c 100644 --- a/tests/components/miele/test_binary_sensor.py +++ b/tests/components/miele/test_binary_sensor.py @@ -39,3 +39,18 @@ async def test_binary_sensor_states_api_push( """Test binary sensor state when the API pushes data via SSE.""" await snapshot_platform(hass, entity_registry, snapshot, setup_platform.entry_id) + + +@pytest.mark.parametrize("load_device_file", ["coffee_system.json"]) +@pytest.mark.parametrize("platforms", [(BINARY_SENSOR_DOMAIN,)]) +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +async def test_coffee_system_binary_sensor_states( + hass: HomeAssistant, + mock_miele_client: MagicMock, + snapshot: SnapshotAssertion, + entity_registry: er.EntityRegistry, + setup_platform: MockConfigEntry, +) -> None: + """Test coffee system binary sensor state.""" + + await snapshot_platform(hass, entity_registry, snapshot, setup_platform.entry_id) diff --git a/tests/components/remote_calendar/test_calendar.py b/tests/components/remote_calendar/test_calendar.py index 2cc9b8adac18ec..954b43e67b7cd0 100644 --- a/tests/components/remote_calendar/test_calendar.py +++ b/tests/components/remote_calendar/test_calendar.py @@ -213,6 +213,112 @@ async def test_api_date_event( assert len(events) == 1 +CANCELLED_EVENT_ICS = textwrap.dedent( + """\ + BEGIN:VCALENDAR + VERSION:2.0 + BEGIN:VEVENT + SUMMARY:Festival International de Jazz de Montreal + LOCATION:Montreal + DTSTART:20070628 + DTEND:20070709 + STATUS:CANCELLED + END:VEVENT + END:VCALENDAR + """ +) + + +@respx.mock +async def test_cancelled_event_is_not_returned( + hass: HomeAssistant, + config_entry: MockConfigEntry, + get_events: GetEventsFn, +) -> None: + """Test that an event called off is not returned by the API.""" + respx.get(CALENDER_URL).mock( + return_value=Response(status_code=200, text=CANCELLED_EVENT_ICS) + ) + await setup_integration(hass, config_entry) + + events = await get_events("2007-06-28T00:00:00Z", "2007-07-10T00:00:00Z") + + assert events == [] + + +@pytest.mark.freeze_time(datetime(2007, 6, 28, 12)) +@respx.mock +async def test_cancelled_event_does_not_turn_the_entity_on( + hass: HomeAssistant, + config_entry: MockConfigEntry, +) -> None: + """Test that an event called off is not picked up as the current event. + + The event would be active at this time were it not cancelled, so this + covers the state path rather than the API one. + """ + respx.get(CALENDER_URL).mock( + return_value=Response(status_code=200, text=CANCELLED_EVENT_ICS) + ) + await setup_integration(hass, config_entry) + + state = hass.states.get(TEST_ENTITY) + assert state + assert state.state == STATE_OFF + + +CANCELLED_OCCURRENCE_ICS = textwrap.dedent( + """\ + BEGIN:VCALENDAR + VERSION:2.0 + BEGIN:VEVENT + SUMMARY:Daily series + UID:daily-series + DTSTART;VALUE=DATE:20261002 + DTEND;VALUE=DATE:20261003 + RRULE:FREQ=DAILY;COUNT=5 + STATUS:CONFIRMED + END:VEVENT + BEGIN:VEVENT + SUMMARY:Daily series + UID:daily-series + RECURRENCE-ID;VALUE=DATE:20261003 + DTSTART;VALUE=DATE:20261003 + DTEND;VALUE=DATE:20261004 + STATUS:CANCELLED + END:VEVENT + END:VCALENDAR + """ +) + + +@respx.mock +async def test_cancelled_occurrence_of_a_series_is_not_returned( + hass: HomeAssistant, + config_entry: MockConfigEntry, + get_events: GetEventsFn, +) -> None: + """Test that only the cancelled occurrence of a recurring series is dropped. + + The filtering has to happen after the series is expanded: dropping the + cancelled VEVENT before expansion would remove the override, and the RRULE + would then produce that day as an ordinary event again. + """ + respx.get(CALENDER_URL).mock( + return_value=Response(status_code=200, text=CANCELLED_OCCURRENCE_ICS) + ) + await setup_integration(hass, config_entry) + + events = await get_events("2026-10-01T00:00:00Z", "2026-10-08T00:00:00Z") + + assert [event["start"] for event in events] == [ + {"date": "2026-10-02"}, + {"date": "2026-10-04"}, + {"date": "2026-10-05"}, + {"date": "2026-10-06"}, + ] + + @pytest.mark.freeze_time(datetime(2007, 6, 28, 12)) @respx.mock async def test_active_event( diff --git a/tests/components/roomba/test_vacuum.py b/tests/components/roomba/test_vacuum.py index c352adaba3c692..1533ab445927c8 100644 --- a/tests/components/roomba/test_vacuum.py +++ b/tests/components/roomba/test_vacuum.py @@ -4,15 +4,29 @@ import pytest -from homeassistant.components.vacuum import VacuumActivity -from homeassistant.const import Platform +from homeassistant.components.vacuum import ( + ATTR_FAN_SPEED, + DOMAIN as VACUUM_DOMAIN, + SERVICE_SET_FAN_SPEED, + VacuumActivity, +) +from homeassistant.const import ATTR_ENTITY_ID, Platform from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ServiceValidationError from tests.common import MockConfigEntry ENTITY_ID = "vacuum.test_roomba" +async def _setup(hass: HomeAssistant, mock_config_entry: MockConfigEntry) -> None: + """Set up the vacuum platform only.""" + with patch("homeassistant.components.roomba.PLATFORMS", [Platform.VACUUM]): + mock_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + @pytest.mark.parametrize( ("phase", "cycle", "expected"), [ @@ -52,3 +66,83 @@ async def test_vacuum_activity( state = hass.states.get(ENTITY_ID) assert state is not None assert state.state == expected + + +@pytest.mark.parametrize( + ("fan_speed", "translation_key"), + [ + # Missing the "-" half entirely. + ("Standard", "invalid_fan_speed_format"), + # Spray amount present but not a number. + ("Standard-x", "spray_amount_not_a_number"), + # Well-formed, but the behavior is not one we support. + ("Bogus-1", "invalid_mop_behavior"), + # Well-formed, but the spray amount is out of range. + ("Standard-9", "invalid_spray_amount"), + ], +) +async def test_braava_set_fan_speed_invalid( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_roomba: AsyncMock, + fan_speed: str, + translation_key: str, +) -> None: + """Test that invalid Braava fan speeds raise instead of being swallowed.""" + mock_roomba.master_state["state"]["reported"]["detectedPad"] = "reusableWet" + + await _setup(hass, mock_config_entry) + + with pytest.raises(ServiceValidationError) as err: + await hass.services.async_call( + VACUUM_DOMAIN, + SERVICE_SET_FAN_SPEED, + {ATTR_ENTITY_ID: ENTITY_ID, ATTR_FAN_SPEED: fan_speed}, + blocking=True, + ) + + assert err.value.translation_domain == "roomba" + assert err.value.translation_key == translation_key + + +async def test_carpet_boost_set_fan_speed_invalid( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_roomba: AsyncMock, +) -> None: + """Test that an unknown carpet-boost fan speed raises instead of being swallowed.""" + mock_roomba.master_state["state"]["reported"]["cap"]["carpetBoost"] = 1 + + await _setup(hass, mock_config_entry) + + with pytest.raises(ServiceValidationError) as err: + await hass.services.async_call( + VACUUM_DOMAIN, + SERVICE_SET_FAN_SPEED, + {ATTR_ENTITY_ID: ENTITY_ID, ATTR_FAN_SPEED: "Turbo"}, + blocking=True, + ) + + assert err.value.translation_domain == "roomba" + assert err.value.translation_key == "invalid_fan_speed" + + +async def test_carpet_boost_set_fan_speed_valid( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_roomba: AsyncMock, +) -> None: + """Test that a valid fan speed still sets the preferences.""" + mock_roomba.master_state["state"]["reported"]["cap"]["carpetBoost"] = 1 + + await _setup(hass, mock_config_entry) + + await hass.services.async_call( + VACUUM_DOMAIN, + SERVICE_SET_FAN_SPEED, + {ATTR_ENTITY_ID: ENTITY_ID, ATTR_FAN_SPEED: "eco"}, + blocking=True, + ) + + mock_roomba.set_preference.assert_any_call("carpetBoost", "False") + mock_roomba.set_preference.assert_any_call("vacHigh", "False") diff --git a/tests/components/smartthings/fixtures/device_status/da_ac_rac_000003.json b/tests/components/smartthings/fixtures/device_status/da_ac_rac_000003.json index 42fd78cd862195..4e99d8f1e04fdd 100644 --- a/tests/components/smartthings/fixtures/device_status/da_ac_rac_000003.json +++ b/tests/components/smartthings/fixtures/device_status/da_ac_rac_000003.json @@ -93,6 +93,7 @@ "speed", "motionIndirect", "motionDirect", + "dryComfort", "windFree", "windFreeSleep" ], diff --git a/tests/components/smartthings/snapshots/test_climate.ambr b/tests/components/smartthings/snapshots/test_climate.ambr index 656e751c449710..f03223e1cf9909 100644 --- a/tests/components/smartthings/snapshots/test_climate.ambr +++ b/tests/components/smartthings/snapshots/test_climate.ambr @@ -464,6 +464,7 @@ 'boost', 'motion_indirect', 'motion_direct', + 'dry_comfort', 'wind_free', 'wind_free_sleep', ]), @@ -540,6 +541,7 @@ 'boost', 'motion_indirect', 'motion_direct', + 'dry_comfort', 'wind_free', 'wind_free_sleep', ]), diff --git a/tests/components/smtp/test_config_flow.py b/tests/components/smtp/test_config_flow.py index 211c23b36bde1b..491ea085583fd9 100644 --- a/tests/components/smtp/test_config_flow.py +++ b/tests/components/smtp/test_config_flow.py @@ -53,7 +53,7 @@ async def test_form( result["flow_id"], { **USER_INPUT, - SECTION_OPTIONS: {CONF_TIMEOUT: 60}, + SECTION_OPTIONS: {}, }, ) await hass.async_block_till_done() @@ -61,7 +61,7 @@ async def test_form( assert result["type"] is FlowResultType.CREATE_ENTRY assert result["title"] == "Home Assistant" assert result["data"] == USER_INPUT - assert result["options"] == {CONF_TIMEOUT: 60} + assert result["options"] == {} assert len(mock_setup_entry.mock_calls) == 1 await hass.async_block_till_done(wait_background_tasks=True) diff --git a/tests/components/stiebel_eltron/conftest.py b/tests/components/stiebel_eltron/conftest.py index dcef3caf1a6d37..f58277898a2c41 100644 --- a/tests/components/stiebel_eltron/conftest.py +++ b/tests/components/stiebel_eltron/conftest.py @@ -1,7 +1,7 @@ """Common fixtures for the STIEBEL ELTRON tests.""" from collections.abc import AsyncGenerator, Generator -from unittest.mock import AsyncMock, MagicMock, patch +from unittest.mock import MagicMock, patch from modbus_connection.mock import MockModbusConnection from pystiebeleltron import ControllerModel @@ -32,20 +32,16 @@ def mock_get_controller_model() -> Generator[MagicMock]: @pytest.fixture(autouse=True) -async def mock_connect_tcp( +async def mock_modbus_connection_class( mock_modbus_connection: MockModbusConnection, -) -> AsyncGenerator[AsyncMock]: - """Patch connect_tcp to return the in-memory mock connection.""" +) -> AsyncGenerator[MagicMock]: + """Let the modbus integration hand out units on the in-memory connection.""" await mock_modbus_connection.connect() - connect = AsyncMock(return_value=mock_modbus_connection) - with ( - patch("homeassistant.components.stiebel_eltron.connect_tcp", new=connect), - patch( - "homeassistant.components.stiebel_eltron.config_flow.connect_tcp", - new=connect, - ), - ): - yield connect + with patch( + "homeassistant.components.modbus.connection.ModbusConnection", + return_value=mock_modbus_connection, + ) as mock_connection_cls: + yield mock_connection_cls @pytest.fixture(autouse=True) diff --git a/tests/components/stiebel_eltron/test_config_flow.py b/tests/components/stiebel_eltron/test_config_flow.py index b325d90380a3b9..5653da2c0ff972 100644 --- a/tests/components/stiebel_eltron/test_config_flow.py +++ b/tests/components/stiebel_eltron/test_config_flow.py @@ -2,11 +2,12 @@ from unittest.mock import MagicMock -from modbus_connection import ModbusError +from modbus_connection import ModbusTcpParams from pystiebeleltron import ControllerModel, StiebelEltronModbusError import pytest -from homeassistant.components.stiebel_eltron.const import DOMAIN +from homeassistant.components.modbus import async_get_unit +from homeassistant.components.stiebel_eltron.const import DOMAIN, UNIT_ID from homeassistant.config_entries import SOURCE_DHCP, SOURCE_RECONFIGURE, SOURCE_USER from homeassistant.const import CONF_HOST, CONF_PORT from homeassistant.core import HomeAssistant @@ -42,28 +43,16 @@ async def test_full_flow(hass: HomeAssistant) -> None: assert result["data"] == USER_INPUT -@pytest.mark.parametrize( - ("failing_fixture", "side_effect"), - [ - pytest.param( - "mock_get_controller_model", StiebelEltronModbusError, id="model_read" - ), - pytest.param("mock_connect_tcp", ModbusError, id="connect"), - ], -) async def test_form_cannot_connect( hass: HomeAssistant, - request: pytest.FixtureRequest, - failing_fixture: str, - side_effect: type[Exception], + mock_get_controller_model: MagicMock, ) -> None: - """Test we handle a cannot connect error while opening or reading the device.""" - failing_mock = request.getfixturevalue(failing_fixture) + """Test we handle a cannot connect error while reading the device.""" result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_USER} ) - failing_mock.side_effect = side_effect + mock_get_controller_model.side_effect = StiebelEltronModbusError result = await hass.config_entries.flow.async_configure( result["flow_id"], @@ -73,7 +62,7 @@ async def test_form_cannot_connect( assert result["type"] is FlowResultType.FORM assert result["errors"] == {"base": "cannot_connect"} - failing_mock.side_effect = None + mock_get_controller_model.side_effect = None result = await hass.config_entries.flow.async_configure( result["flow_id"], @@ -83,6 +72,31 @@ async def test_form_cannot_connect( assert result["type"] is FlowResultType.CREATE_ENTRY +async def test_form_conflicting_link_settings(hass: HomeAssistant) -> None: + """Test we handle the device being held with incompatible link settings.""" + other_entry = MockConfigEntry(domain="modbus") + other_entry.add_to_hass(hass) + async_get_unit( + hass, + other_entry, + ModbusTcpParams( + host=USER_INPUT[CONF_HOST], port=USER_INPUT[CONF_PORT], framer="rtu" + ), + UNIT_ID, + ) + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + USER_INPUT, + ) + + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {"base": "cannot_connect"} + + async def test_form_unknown_exception( hass: HomeAssistant, mock_get_controller_model: MagicMock, diff --git a/tests/components/stiebel_eltron/test_init.py b/tests/components/stiebel_eltron/test_init.py index 5f93cc84e25c3d..dec8f65e7b31f3 100644 --- a/tests/components/stiebel_eltron/test_init.py +++ b/tests/components/stiebel_eltron/test_init.py @@ -1,17 +1,28 @@ """Tests for the STIEBEL ELTRON integration.""" -from unittest.mock import AsyncMock, MagicMock, patch +from datetime import timedelta +from typing import Any +from unittest.mock import MagicMock, patch -from modbus_connection import ModbusError, ModbusTimeoutError +from freezegun.api import FrozenDateTimeFactory +from modbus_connection import ModbusError, ModbusTcpParams from modbus_connection.mock import MockModbusConnection from pystiebeleltron import StiebelEltronModbusError - -from homeassistant.components.stiebel_eltron.const import DOMAIN +import pytest + +from homeassistant.components.modbus import async_get_unit +from homeassistant.components.stiebel_eltron.const import ( + DEFAULT_SCAN_INTERVAL, + DOMAIN, + UNIT_ID, +) from homeassistant.config_entries import ConfigEntryState -from homeassistant.const import CONF_HOST, CONF_PORT +from homeassistant.const import CONF_HOST, CONF_PORT, STATE_UNAVAILABLE from homeassistant.core import HomeAssistant -from tests.common import MockConfigEntry +from tests.common import MockConfigEntry, async_fire_time_changed + +CLIMATE_ENTITY_ID = "climate.stiebel_eltron_lwz" async def test_async_setup_entry_success( @@ -26,55 +37,62 @@ async def test_async_setup_entry_success( assert mock_config_entry.state is ConfigEntryState.LOADED -async def test_async_setup_entry_with_custom_port( - hass: HomeAssistant, - mock_connect_tcp: AsyncMock, -) -> None: - """Test setup with custom port.""" - config_entry = MockConfigEntry( - domain=DOMAIN, - title="Stiebel Eltron", - data={CONF_HOST: "192.168.1.100", CONF_PORT: 5020}, - ) - config_entry.add_to_hass(hass) - - result = await hass.config_entries.async_setup(config_entry.entry_id) - - assert result is True - mock_connect_tcp.assert_called_once_with("192.168.1.100", port=5020) - - -async def test_async_setup_entry_without_port( +@pytest.mark.parametrize( + ("entry_data", "expected_params"), + [ + pytest.param( + {CONF_HOST: "192.168.1.100", CONF_PORT: 5020}, + ModbusTcpParams(host="192.168.1.100", port=5020), + id="custom_port", + ), + pytest.param( + {CONF_HOST: "192.168.1.100"}, + ModbusTcpParams(host="192.168.1.100", port=502), + id="default_port", + ), + ], +) +async def test_async_setup_entry_requests_unit( hass: HomeAssistant, - mock_connect_tcp: AsyncMock, + mock_modbus_connection_class: MagicMock, + entry_data: dict[str, Any], + expected_params: ModbusTcpParams, ) -> None: - """Test setup without port (should use default).""" + """Test the unit is taken on a connection with the configured host and port.""" config_entry = MockConfigEntry( domain=DOMAIN, title="Stiebel Eltron", - data={CONF_HOST: "192.168.1.100"}, + data=entry_data, ) config_entry.add_to_hass(hass) result = await hass.config_entries.async_setup(config_entry.entry_id) assert result is True - mock_connect_tcp.assert_called_once_with("192.168.1.100", port=502) + mock_modbus_connection_class.assert_called_once_with(expected_params) -async def test_async_setup_entry_cannot_connect( +async def test_async_setup_entry_conflicting_link_settings( hass: HomeAssistant, mock_config_entry: MockConfigEntry, - mock_connect_tcp: AsyncMock, ) -> None: - """Test setup retries when the connection cannot be opened.""" - mock_connect_tcp.side_effect = ModbusTimeoutError("could not connect") + """Test setup fails with a reason when the device is held over other settings.""" + other_entry = MockConfigEntry(domain="modbus") + other_entry.add_to_hass(hass) + async_get_unit( + hass, + other_entry, + ModbusTcpParams(host="1.1.1.1", port=502, framer="rtu"), + UNIT_ID, + ) mock_config_entry.add_to_hass(hass) result = await hass.config_entries.async_setup(mock_config_entry.entry_id) assert result is False - assert mock_config_entry.state is ConfigEntryState.SETUP_RETRY + assert mock_config_entry.state is ConfigEntryState.SETUP_ERROR + assert mock_config_entry.reason is not None + assert "different link settings" in mock_config_entry.reason async def test_async_setup_entry_modbus_error( @@ -82,7 +100,7 @@ async def test_async_setup_entry_modbus_error( mock_config_entry: MockConfigEntry, mock_get_controller_model: MagicMock, ) -> None: - """Test setup retries when reading the controller model fails.""" + """Test setup retries when the device cannot be reached or read.""" mock_config_entry.add_to_hass(hass) mock_get_controller_model.side_effect = StiebelEltronModbusError() @@ -109,22 +127,27 @@ async def test_async_setup_entry_coordinator_update_fails( assert mock_modbus_connection.connected is False -async def test_connection_lost_reloads_entry( +async def test_entities_unavailable_when_update_fails( hass: HomeAssistant, mock_config_entry: MockConfigEntry, - mock_modbus_connection: MockModbusConnection, + mock_lwz_api: MagicMock, + freezer: FrozenDateTimeFactory, ) -> None: - """Test a lost connection schedules a reload of the config entry.""" + """Test the entities go unavailable when the device stops answering.""" mock_config_entry.add_to_hass(hass) await hass.config_entries.async_setup(mock_config_entry.entry_id) await hass.async_block_till_done() - with patch.object( - hass.config_entries, "async_schedule_reload" - ) as mock_schedule_reload: - mock_modbus_connection.simulate_connection_lost() + assert (state := hass.states.get(CLIMATE_ENTITY_ID)) + assert state.state != STATE_UNAVAILABLE + + mock_lwz_api.async_update.side_effect = ModbusError("update failed") + freezer.tick(timedelta(seconds=DEFAULT_SCAN_INTERVAL)) + async_fire_time_changed(hass) + await hass.async_block_till_done() - mock_schedule_reload.assert_called_once_with(mock_config_entry.entry_id) + assert (state := hass.states.get(CLIMATE_ENTITY_ID)) + assert state.state == STATE_UNAVAILABLE async def test_unload_entry_closes_connection( diff --git a/tests/components/stt/test_init.py b/tests/components/stt/test_init.py index 8ff45b59577958..76dc6532969379 100644 --- a/tests/components/stt/test_init.py +++ b/tests/components/stt/test_init.py @@ -644,3 +644,33 @@ async def test_audio_processing_custom(hass: HomeAssistant, tmp_path: Path) -> N assert engine.audio_processing.requires_external_vad is False assert engine.audio_processing.prefers_auto_gain_enabled is False assert engine.audio_processing.prefers_noise_reduction_enabled is False + + +@pytest.mark.parametrize( + "setup", ["mock_setup", "mock_config_entry_setup"], indirect=True +) +async def test_stream_audio_large_no_newline_block( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + setup: MockSTTProvider | MockSTTProviderEntity, +) -> None: + """Test a newline-free audio stream larger than aiohttp's line limit.""" + # 600,000 bytes of silence - larger than the 512KB LineTooLong threshold. + # This is the reproduction case from issue #180708. + test_data = b"\x00" * 600_000 + client = await hass_client() + response = await client.post( + f"/api/stt/{setup.url_path}", + headers={ + "X-Speech-Content": ( + "format=wav; codec=pcm; sample_rate=16000; bit_rate=16; channel=1;" + " language=en" + ) + }, + data=test_data, + ) + assert response.status == HTTPStatus.OK + assert await response.json() == {"text": "test_result", "result": "success"} + + received_data = b"".join(setup.received) + assert received_data == test_data diff --git a/tests/components/subaru/test_button.py b/tests/components/subaru/test_button.py index a805bf823bc01f..3c6cddd1d51b1b 100644 --- a/tests/components/subaru/test_button.py +++ b/tests/components/subaru/test_button.py @@ -26,6 +26,7 @@ MOCK_API, MOCK_API_FETCH, MOCK_API_GET_DATA, + advance_time_to_next_fetch, setup_subaru_config_entry, ) @@ -302,12 +303,12 @@ async def test_no_buttons_without_remote_start( async def test_button_unavailable_on_fetch_failure( - hass: HomeAssistant, subaru_config_entry: MockConfigEntry + hass: HomeAssistant, ev_entry: MockConfigEntry ) -> None: - """Test button goes unavailable when the coordinator fails to fetch data.""" - await setup_subaru_config_entry( - hass, subaru_config_entry, fetch_effect=SubaruException("403 Error") - ) + """Test button goes unavailable when a fetch fails after setup.""" + with patch(MOCK_API_FETCH, side_effect=SubaruException("403 Error")): + advance_time_to_next_fetch(hass) + await hass.async_block_till_done() state = hass.states.get(VEHICLE_BUTTONS[TEST_VIN_2_EV]["remote_start"]) assert state.state == STATE_UNAVAILABLE diff --git a/tests/components/subaru/test_init.py b/tests/components/subaru/test_init.py index 7d8c7ad64419fb..e658f5040ec7a9 100644 --- a/tests/components/subaru/test_init.py +++ b/tests/components/subaru/test_init.py @@ -172,7 +172,7 @@ async def test_update_disabled(hass: HomeAssistant, ev_entry) -> None: async def test_fetch_failed(hass: HomeAssistant, subaru_config_entry) -> None: - """Tests when fetch fails.""" + """Test setup retries when the first fetch fails.""" await setup_subaru_config_entry( hass, subaru_config_entry, @@ -182,8 +182,7 @@ async def test_fetch_failed(hass: HomeAssistant, subaru_config_entry) -> None: fetch_effect=SubaruException("403 Error"), ) - test_entity = hass.states.get(TEST_ENTITY_ID) - assert test_entity.state == "unavailable" + assert subaru_config_entry.state is ConfigEntryState.SETUP_RETRY async def test_unload_entry(hass: HomeAssistant, ev_entry) -> None: diff --git a/tests/components/subaru/test_lock.py b/tests/components/subaru/test_lock.py index 2984f90111c0f8..aba40b2ac69f5b 100644 --- a/tests/components/subaru/test_lock.py +++ b/tests/components/subaru/test_lock.py @@ -23,7 +23,7 @@ from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import entity_registry as er -from .conftest import MOCK_API, setup_subaru_config_entry +from .conftest import MOCK_API, MOCK_API_FETCH, advance_time_to_next_fetch from tests.common import MockConfigEntry @@ -98,12 +98,12 @@ async def test_unlock_specific_door_invalid(hass: HomeAssistant, ev_entry) -> No async def test_lock_unavailable_on_fetch_failure( - hass: HomeAssistant, subaru_config_entry: MockConfigEntry + hass: HomeAssistant, ev_entry: MockConfigEntry ) -> None: - """Test lock goes unavailable when the coordinator fails to fetch data.""" - await setup_subaru_config_entry( - hass, subaru_config_entry, fetch_effect=SubaruException("403 Error") - ) + """Test lock goes unavailable when a fetch fails after setup.""" + with patch(MOCK_API_FETCH, side_effect=SubaruException("403 Error")): + advance_time_to_next_fetch(hass) + await hass.async_block_till_done() state = hass.states.get(DEVICE_ID) assert state.state == STATE_UNAVAILABLE diff --git a/tests/helpers/test_selector.py b/tests/helpers/test_selector.py index b35cd877f69b2e..7a59a70e15d2f7 100644 --- a/tests/helpers/test_selector.py +++ b/tests/helpers/test_selector.py @@ -1848,21 +1848,7 @@ def test_theme_selector_schema(schema, valid_selections, invalid_selections) -> ) def test_media_selector_schema(schema, valid_selections, invalid_selections) -> None: """Test media selector.""" - - def drop_metadata(data): - """Drop metadata key from the input.""" - if isinstance(data, list): - return [drop_metadata(item) for item in data] - data.pop("metadata", None) - return data - - _test_selector( - "media", - schema, - valid_selections, - invalid_selections, - drop_metadata, - ) + _test_selector("media", schema, valid_selections, invalid_selections) @pytest.mark.parametrize( @@ -1876,6 +1862,7 @@ def drop_metadata(data): "entity_id": "sensor.abc", "media_content_id": "abc", "media_content_type": "def", + "metadata": {}, }, { "entity_id": "sensor.def", @@ -1888,6 +1875,7 @@ def drop_metadata(data): "entity_id": "sensor.abc", "media_content_id": "abc", "media_content_type": "def", + "metadata": {}, }, ), ( @@ -1917,6 +1905,7 @@ def drop_metadata(data): { "media_content_id": "ghi", "media_content_type": "jkl", + "metadata": {}, }, ], ), @@ -1939,19 +1928,18 @@ def test_media_selector_schema_multiple( ) -> None: """Test media selector with multiple selections.""" - def drop_metadata(data, root=True): + def ensure_list(data): if isinstance(data, list): - return [drop_metadata(item, False) for item in data] - data.pop("metadata", None) + return data # Multiple=true wraps single values in list. - return [data] if root and schema.get("multiple") else data + return [data] if schema.get("multiple") else data _test_selector( "media", schema, valid_selections, invalid_selections, - drop_metadata, + ensure_list, )