diff --git a/homeassistant/components/airzone/manifest.json b/homeassistant/components/airzone/manifest.json index 01fde7eb2fbf95..95ed9d200f4238 100644 --- a/homeassistant/components/airzone/manifest.json +++ b/homeassistant/components/airzone/manifest.json @@ -11,5 +11,5 @@ "documentation": "https://www.home-assistant.io/integrations/airzone", "iot_class": "local_polling", "loggers": ["aioairzone"], - "requirements": ["aioairzone==0.9.7"] + "requirements": ["aioairzone==0.9.9"] } diff --git a/homeassistant/components/aprilaire/humidifier.py b/homeassistant/components/aprilaire/humidifier.py index 254cc0ac789097..8a173e5e95efc7 100644 --- a/homeassistant/components/aprilaire/humidifier.py +++ b/homeassistant/components/aprilaire/humidifier.py @@ -50,7 +50,7 @@ async def async_setup_entry( descriptions: list[AprilaireHumidifierDescription] = [] - if coordinator.data.get(Attribute.HUMIDIFICATION_AVAILABLE) in (0, 1, 2): + if coordinator.data.get(Attribute.HUMIDIFICATION_AVAILABLE) in (1, 2): descriptions.append( AprilaireHumidifierDescription( key="humidifier", @@ -67,7 +67,7 @@ async def async_setup_entry( ) ) - if coordinator.data.get(Attribute.DEHUMIDIFICATION_AVAILABLE) in (0, 1): + if coordinator.data.get(Attribute.DEHUMIDIFICATION_AVAILABLE) == 1: descriptions.append( AprilaireHumidifierDescription( key="dehumidifier", diff --git a/homeassistant/components/assist_pipeline/pipeline.py b/homeassistant/components/assist_pipeline/pipeline.py index 7dda24c4023ad0..d6a0d77ec5566a 100644 --- a/homeassistant/components/assist_pipeline/pipeline.py +++ b/homeassistant/components/assist_pipeline/pipeline.py @@ -1017,9 +1017,18 @@ async def recognize_intent( raise RuntimeError("Recognize intent was not prepared") if self.pipeline.conversation_language == MATCH_ALL: - # LLMs support all languages ('*') so use pipeline language for - # intent fallback. - input_language = self.pipeline.language + # LLMs support all languages ('*') so use languages from the + # pipeline for intent fallback. + # + # We prioritize the STT and TTS languages because they may be more + # specific, such as "zh-CN" instead of just "zh". This is necessary + # for languages whose intents are split out by region when + # preferring local intent matching. + input_language = ( + self.pipeline.stt_language + or self.pipeline.tts_language + or self.pipeline.language + ) else: input_language = self.pipeline.conversation_language diff --git a/homeassistant/components/aussie_broadband/manifest.json b/homeassistant/components/aussie_broadband/manifest.json index 456b8962461303..ea402f03b0ea42 100644 --- a/homeassistant/components/aussie_broadband/manifest.json +++ b/homeassistant/components/aussie_broadband/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/aussie_broadband", "iot_class": "cloud_polling", "loggers": ["aussiebb"], - "requirements": ["pyaussiebb==0.1.4"] + "requirements": ["pyaussiebb==0.1.5"] } diff --git a/homeassistant/components/bring/strings.json b/homeassistant/components/bring/strings.json index 7331f68a161e86..e65f9607afb4d7 100644 --- a/homeassistant/components/bring/strings.json +++ b/homeassistant/components/bring/strings.json @@ -111,7 +111,7 @@ "services": { "send_message": { "name": "[%key:component::notify::services::notify::name%]", - "description": "Send a mobile push notification to members of a shared Bring! list.", + "description": "Sends a mobile push notification to members of a shared Bring! list.", "fields": { "entity_id": { "name": "List", @@ -122,8 +122,8 @@ "description": "Type of push notification to send to list members." }, "item": { - "name": "Article (Required if message type `Urgent Message` selected)", - "description": "Article name to include in an urgent message e.g. `Urgent Message - Please buy Cilantro urgently`" + "name": "Article (Required if notification type `Urgent message` is selected)", + "description": "Article name to include in an urgent message e.g. `Urgent message - Please buy Cilantro urgently`" } } } @@ -134,7 +134,7 @@ "going_shopping": "I'm going shopping! - Last chance to make changes", "changed_list": "List updated - Take a look at the articles", "shopping_done": "Shopping done - The fridge is well stocked", - "urgent_message": "Urgent Message - Please buy `Article name` urgently" + "urgent_message": "Urgent message - Please buy `Article` urgently" } } } diff --git a/homeassistant/components/ecovacs/manifest.json b/homeassistant/components/ecovacs/manifest.json index 67d18c4784cd84..157d5b4a5eac45 100644 --- a/homeassistant/components/ecovacs/manifest.json +++ b/homeassistant/components/ecovacs/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/ecovacs", "iot_class": "cloud_push", "loggers": ["sleekxmppfs", "sucks", "deebot_client"], - "requirements": ["py-sucks==0.9.10", "deebot-client==10.1.0"] + "requirements": ["py-sucks==0.9.10", "deebot-client==11.0.0"] } diff --git a/homeassistant/components/elkm1/manifest.json b/homeassistant/components/elkm1/manifest.json index 7822307e12eddf..12c22e23ff067f 100644 --- a/homeassistant/components/elkm1/manifest.json +++ b/homeassistant/components/elkm1/manifest.json @@ -15,5 +15,5 @@ "documentation": "https://www.home-assistant.io/integrations/elkm1", "iot_class": "local_push", "loggers": ["elkm1_lib"], - "requirements": ["elkm1-lib==2.2.10"] + "requirements": ["elkm1-lib==2.2.11"] } diff --git a/homeassistant/components/ezviz/image.py b/homeassistant/components/ezviz/image.py index 0fbc5cc6a6838e..73c0924422228f 100644 --- a/homeassistant/components/ezviz/image.py +++ b/homeassistant/components/ezviz/image.py @@ -8,7 +8,7 @@ from pyezviz.utils import decrypt_image from homeassistant.components.image import Image, ImageEntity, ImageEntityDescription -from homeassistant.config_entries import ConfigEntry +from homeassistant.config_entries import SOURCE_IGNORE, ConfigEntry from homeassistant.const import CONF_PASSWORD from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddEntitiesCallback @@ -57,7 +57,9 @@ def __init__( ) camera = hass.config_entries.async_entry_for_domain_unique_id(DOMAIN, serial) self.alarm_image_password = ( - camera.data[CONF_PASSWORD] if camera is not None else None + camera.data[CONF_PASSWORD] + if camera and camera.source != SOURCE_IGNORE + else None ) async def _async_load_image_from_url(self, url: str) -> Image | None: diff --git a/homeassistant/components/freebox/manifest.json b/homeassistant/components/freebox/manifest.json index 46422cee1055b7..0cfe37c7a3198e 100644 --- a/homeassistant/components/freebox/manifest.json +++ b/homeassistant/components/freebox/manifest.json @@ -7,6 +7,6 @@ "documentation": "https://www.home-assistant.io/integrations/freebox", "iot_class": "local_polling", "loggers": ["freebox_api"], - "requirements": ["freebox-api==1.2.1"], + "requirements": ["freebox-api==1.2.2"], "zeroconf": ["_fbx-api._tcp.local."] } diff --git a/homeassistant/components/hassio/backup.py b/homeassistant/components/hassio/backup.py index 537588e856adc9..23a0b5bd5d84e4 100644 --- a/homeassistant/components/hassio/backup.py +++ b/homeassistant/components/hassio/backup.py @@ -227,11 +227,12 @@ async def async_create_backup( include_addons_set = supervisor_backups.AddonSet.ALL elif include_addons: include_addons_set = set(include_addons) - include_folders_set = ( - {supervisor_backups.Folder(folder) for folder in include_folders} - if include_folders - else None - ) + include_folders_set = { + supervisor_backups.Folder(folder) for folder in include_folders or [] + } + # Always include SSL if Home Assistant is included + if include_homeassistant: + include_folders_set.add(supervisor_backups.Folder.SSL) hassio_agents: list[SupervisorBackupAgent] = [ cast(SupervisorBackupAgent, manager.backup_agents[agent_id]) diff --git a/homeassistant/components/hive/manifest.json b/homeassistant/components/hive/manifest.json index 870223f8fe6e6b..f68478516abbfd 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.16"] + "requirements": ["pyhive-integration==1.0.1"] } diff --git a/homeassistant/components/homekit/type_lights.py b/homeassistant/components/homekit/type_lights.py index cde80178c5ee7c..212b3228154db7 100644 --- a/homeassistant/components/homekit/type_lights.py +++ b/homeassistant/components/homekit/type_lights.py @@ -52,6 +52,7 @@ PROP_MIN_VALUE, SERV_LIGHTBULB, ) +from .util import get_min_max _LOGGER = logging.getLogger(__name__) @@ -120,12 +121,14 @@ def __init__(self, *args: Any) -> None: self.char_brightness = serv_light.configure_char(CHAR_BRIGHTNESS, value=100) if CHAR_COLOR_TEMPERATURE in self.chars: - self.min_mireds = color_temperature_kelvin_to_mired( + min_mireds = color_temperature_kelvin_to_mired( attributes.get(ATTR_MAX_COLOR_TEMP_KELVIN, DEFAULT_MAX_COLOR_TEMP) ) - self.max_mireds = color_temperature_kelvin_to_mired( + max_mireds = color_temperature_kelvin_to_mired( attributes.get(ATTR_MIN_COLOR_TEMP_KELVIN, DEFAULT_MIN_COLOR_TEMP) ) + # Ensure min is less than max + self.min_mireds, self.max_mireds = get_min_max(min_mireds, max_mireds) if not self.color_temp_supported and not self.rgbww_supported: self.max_mireds = self.min_mireds self.char_color_temp = serv_light.configure_char( @@ -282,7 +285,11 @@ def async_update_state(self, new_state: State) -> None: hue, saturation = color_temperature_to_hs(color_temp) elif color_mode == ColorMode.WHITE: hue, saturation = 0, 0 - elif hue_sat := attributes.get(ATTR_HS_COLOR): + elif ( + (hue_sat := attributes.get(ATTR_HS_COLOR)) + and isinstance(hue_sat, (list, tuple)) + and len(hue_sat) == 2 + ): hue, saturation = hue_sat else: hue = None diff --git a/homeassistant/components/homekit/type_thermostats.py b/homeassistant/components/homekit/type_thermostats.py index 91bab2d470a616..4dda495ce77284 100644 --- a/homeassistant/components/homekit/type_thermostats.py +++ b/homeassistant/components/homekit/type_thermostats.py @@ -14,6 +14,7 @@ ATTR_HVAC_ACTION, ATTR_HVAC_MODE, ATTR_HVAC_MODES, + ATTR_MAX_HUMIDITY, ATTR_MAX_TEMP, ATTR_MIN_HUMIDITY, ATTR_MIN_TEMP, @@ -21,6 +22,7 @@ ATTR_SWING_MODES, ATTR_TARGET_TEMP_HIGH, ATTR_TARGET_TEMP_LOW, + DEFAULT_MAX_HUMIDITY, DEFAULT_MAX_TEMP, DEFAULT_MIN_HUMIDITY, DEFAULT_MIN_TEMP, @@ -90,7 +92,7 @@ SERV_FANV2, SERV_THERMOSTAT, ) -from .util import temperature_to_homekit, temperature_to_states +from .util import get_min_max, temperature_to_homekit, temperature_to_states _LOGGER = logging.getLogger(__name__) @@ -208,7 +210,10 @@ def __init__(self, *args: Any) -> None: self.fan_chars: list[str] = [] attributes = state.attributes - min_humidity = attributes.get(ATTR_MIN_HUMIDITY, DEFAULT_MIN_HUMIDITY) + min_humidity, _ = get_min_max( + attributes.get(ATTR_MIN_HUMIDITY, DEFAULT_MIN_HUMIDITY), + attributes.get(ATTR_MAX_HUMIDITY, DEFAULT_MAX_HUMIDITY), + ) features = attributes.get(ATTR_SUPPORTED_FEATURES, 0) if features & ClimateEntityFeature.TARGET_TEMPERATURE_RANGE: @@ -839,6 +844,9 @@ def _get_temperature_range_from_state( else: max_temp = default_max + # Handle reversed temperature range + min_temp, max_temp = get_min_max(min_temp, max_temp) + # Homekit only supports 10-38, overwriting # the max to appears to work, but less than 0 causes # a crash on the home app diff --git a/homeassistant/components/homekit/util.py b/homeassistant/components/homekit/util.py index d339aa6aded34a..443b8b8a3100a6 100644 --- a/homeassistant/components/homekit/util.py +++ b/homeassistant/components/homekit/util.py @@ -655,3 +655,14 @@ def state_changed_event_is_same_state(event: Event[EventStateChangedData]) -> bo old_state = event_data["old_state"] new_state = event_data["new_state"] return bool(new_state and old_state and new_state.state == old_state.state) + + +def get_min_max(value1: float, value2: float) -> tuple[float, float]: + """Return the minimum and maximum of two values. + + HomeKit will go unavailable if the min and max are reversed + so we make sure the min is always the min and the max is always the max + as any mistakes made in integrations will cause the entire + bridge to go unavailable. + """ + return min(value1, value2), max(value1, value2) diff --git a/homeassistant/components/knx/manifest.json b/homeassistant/components/knx/manifest.json index 8d18f11c79801f..73a61be68eeb11 100644 --- a/homeassistant/components/knx/manifest.json +++ b/homeassistant/components/knx/manifest.json @@ -12,7 +12,7 @@ "requirements": [ "xknx==3.4.0", "xknxproject==3.8.1", - "knx-frontend==2024.12.26.233449" + "knx-frontend==2025.1.18.164225" ], "single_config_entry": true } diff --git a/homeassistant/components/lametric/manifest.json b/homeassistant/components/lametric/manifest.json index f66ffb0c6aeeaf..4c4359d0ddbe33 100644 --- a/homeassistant/components/lametric/manifest.json +++ b/homeassistant/components/lametric/manifest.json @@ -13,7 +13,7 @@ "integration_type": "device", "iot_class": "local_polling", "loggers": ["demetriek"], - "requirements": ["demetriek==1.1.1"], + "requirements": ["demetriek==1.2.0"], "ssdp": [ { "deviceType": "urn:schemas-upnp-org:device:LaMetric:1" diff --git a/homeassistant/components/lametric/number.py b/homeassistant/components/lametric/number.py index a1d922c2d80d8b..ccfd48a3abfd9a 100644 --- a/homeassistant/components/lametric/number.py +++ b/homeassistant/components/lametric/number.py @@ -6,7 +6,7 @@ from dataclasses import dataclass from typing import Any -from demetriek import Device, LaMetricDevice +from demetriek import Device, LaMetricDevice, Range from homeassistant.components.number import NumberEntity, NumberEntityDescription from homeassistant.config_entries import ConfigEntry @@ -25,6 +25,7 @@ class LaMetricNumberEntityDescription(NumberEntityDescription): """Class describing LaMetric number entities.""" value_fn: Callable[[Device], int | None] + range_fn: Callable[[Device], Range | None] has_fn: Callable[[Device], bool] = lambda device: True set_value_fn: Callable[[LaMetricDevice, float], Awaitable[Any]] @@ -33,11 +34,9 @@ class LaMetricNumberEntityDescription(NumberEntityDescription): LaMetricNumberEntityDescription( key="brightness", translation_key="brightness", - name="Brightness", entity_category=EntityCategory.CONFIG, native_step=1, - native_min_value=0, - native_max_value=100, + range_fn=lambda device: device.display.brightness_limit, native_unit_of_measurement=PERCENTAGE, value_fn=lambda device: device.display.brightness, set_value_fn=lambda device, bri: device.display(brightness=int(bri)), @@ -45,11 +44,10 @@ class LaMetricNumberEntityDescription(NumberEntityDescription): LaMetricNumberEntityDescription( key="volume", translation_key="volume", - name="Volume", entity_category=EntityCategory.CONFIG, native_step=1, - native_min_value=0, - native_max_value=100, + range_fn=lambda device: device.audio.volume_range if device.audio else None, + native_unit_of_measurement=PERCENTAGE, has_fn=lambda device: bool(device.audio and device.audio.available), value_fn=lambda device: device.audio.volume if device.audio else 0, set_value_fn=lambda api, volume: api.audio(volume=int(volume)), @@ -93,6 +91,20 @@ def native_value(self) -> int | None: """Return the number value.""" return self.entity_description.value_fn(self.coordinator.data) + @property + def native_min_value(self) -> int: + """Return the min range.""" + if limits := self.entity_description.range_fn(self.coordinator.data): + return limits.range_min + return 0 + + @property + def native_max_value(self) -> int: + """Return the max range.""" + if limits := self.entity_description.range_fn(self.coordinator.data): + return limits.range_max + return 100 + @lametric_exception_handler async def async_set_native_value(self, value: float) -> None: """Change to new number value.""" diff --git a/homeassistant/components/lametric/strings.json b/homeassistant/components/lametric/strings.json index 0fd6f5a12dc1ab..01e7823c76b5f4 100644 --- a/homeassistant/components/lametric/strings.json +++ b/homeassistant/components/lametric/strings.json @@ -66,6 +66,14 @@ "name": "Dismiss all notifications" } }, + "number": { + "brightness": { + "name": "Brightness" + }, + "volume": { + "name": "Volume" + } + }, "sensor": { "rssi": { "name": "Wi-Fi signal" diff --git a/homeassistant/components/media_extractor/manifest.json b/homeassistant/components/media_extractor/manifest.json index 144904fe58c5dc..becca8e6da8d92 100644 --- a/homeassistant/components/media_extractor/manifest.json +++ b/homeassistant/components/media_extractor/manifest.json @@ -8,6 +8,6 @@ "iot_class": "calculated", "loggers": ["yt_dlp"], "quality_scale": "internal", - "requirements": ["yt-dlp[default]==2024.12.23"], + "requirements": ["yt-dlp[default]==2025.01.15"], "single_config_entry": true } diff --git a/homeassistant/components/mqtt/number.py b/homeassistant/components/mqtt/number.py index a9bf1829b6392a..9b47a3ad23ae26 100644 --- a/homeassistant/components/mqtt/number.py +++ b/homeassistant/components/mqtt/number.py @@ -179,14 +179,14 @@ def _message_received(self, msg: ReceiveMessage) -> None: return if num_value is not None and ( - num_value < self.min_value or num_value > self.max_value + num_value < self.native_min_value or num_value > self.native_max_value ): _LOGGER.error( "Invalid value for %s: %s (range %s - %s)", self.entity_id, num_value, - self.min_value, - self.max_value, + self.native_min_value, + self.native_max_value, ) return diff --git a/homeassistant/components/niko_home_control/light.py b/homeassistant/components/niko_home_control/light.py index 69d4e71c755c4f..80f47e56438878 100644 --- a/homeassistant/components/niko_home_control/light.py +++ b/homeassistant/components/niko_home_control/light.py @@ -112,7 +112,7 @@ def __init__( def turn_on(self, **kwargs: Any) -> None: """Instruct the light to turn on.""" - self._action.turn_on(kwargs.get(ATTR_BRIGHTNESS, 255) / 2.55) + self._action.turn_on(round(kwargs.get(ATTR_BRIGHTNESS, 255) / 2.55)) def turn_off(self, **kwargs: Any) -> None: """Instruct the light to turn off.""" diff --git a/homeassistant/components/niko_home_control/manifest.json b/homeassistant/components/niko_home_control/manifest.json index d252a11b38e277..a75b0d72dca875 100644 --- a/homeassistant/components/niko_home_control/manifest.json +++ b/homeassistant/components/niko_home_control/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/niko_home_control", "iot_class": "local_push", "loggers": ["nikohomecontrol"], - "requirements": ["nhc==0.3.2"] + "requirements": ["nhc==0.3.4"] } diff --git a/homeassistant/components/nmap_tracker/manifest.json b/homeassistant/components/nmap_tracker/manifest.json index 5b2dab50812ece..ebee6b116e6066 100644 --- a/homeassistant/components/nmap_tracker/manifest.json +++ b/homeassistant/components/nmap_tracker/manifest.json @@ -7,5 +7,5 @@ "documentation": "https://www.home-assistant.io/integrations/nmap_tracker", "iot_class": "local_polling", "loggers": ["nmap"], - "requirements": ["netmap==0.7.0.2", "getmac==0.9.4", "aiooui==0.1.7"] + "requirements": ["netmap==0.7.0.2", "getmac==0.9.4", "aiooui==0.1.9"] } diff --git a/homeassistant/components/ollama/const.py b/homeassistant/components/ollama/const.py index 69c0a3d62965f8..857f0bff34a2a5 100644 --- a/homeassistant/components/ollama/const.py +++ b/homeassistant/components/ollama/const.py @@ -61,7 +61,8 @@ "goliath", "granite-code", "granite3-dense", - "granite3-guardian" "granite3-moe", + "granite3-guardian", + "granite3-moe", "hermes3", "internlm2", "llama-guard3", diff --git a/homeassistant/components/onvif/device.py b/homeassistant/components/onvif/device.py index f51b1b74686b2a..f15f6637ab942c 100644 --- a/homeassistant/components/onvif/device.py +++ b/homeassistant/components/onvif/device.py @@ -263,16 +263,22 @@ async def async_check_date_and_time(self) -> None: LOGGER.warning("%s: Could not retrieve date/time on this camera", self.name) return - cam_date = dt.datetime( - cdate.Date.Year, - cdate.Date.Month, - cdate.Date.Day, - cdate.Time.Hour, - cdate.Time.Minute, - cdate.Time.Second, - 0, - tzone, - ) + try: + cam_date = dt.datetime( + cdate.Date.Year, + cdate.Date.Month, + cdate.Date.Day, + cdate.Time.Hour, + cdate.Time.Minute, + cdate.Time.Second, + 0, + tzone, + ) + except ValueError as err: + LOGGER.warning( + "%s: Could not parse date/time from camera: %s", self.name, err + ) + return cam_date_utc = cam_date.astimezone(dt_util.UTC) diff --git a/homeassistant/components/onvif/manifest.json b/homeassistant/components/onvif/manifest.json index 02ef16b678792a..6aa005ba539b1e 100644 --- a/homeassistant/components/onvif/manifest.json +++ b/homeassistant/components/onvif/manifest.json @@ -8,5 +8,5 @@ "documentation": "https://www.home-assistant.io/integrations/onvif", "iot_class": "local_push", "loggers": ["onvif", "wsdiscovery", "zeep"], - "requirements": ["onvif-zeep-async==3.1.13", "WSDiscovery==2.0.0"] + "requirements": ["onvif-zeep-async==3.2.3", "WSDiscovery==2.0.0"] } diff --git a/homeassistant/components/overkiz/climate/hitachi_air_to_water_heating_zone.py b/homeassistant/components/overkiz/climate/hitachi_air_to_water_heating_zone.py index 8410e50873d767..c5465128bba430 100644 --- a/homeassistant/components/overkiz/climate/hitachi_air_to_water_heating_zone.py +++ b/homeassistant/components/overkiz/climate/hitachi_air_to_water_heating_zone.py @@ -119,5 +119,5 @@ async def async_set_temperature(self, **kwargs: Any) -> None: temperature = cast(float, kwargs.get(ATTR_TEMPERATURE)) await self.executor.async_execute_command( - OverkizCommand.SET_THERMOSTAT_SETTING_CONTROL_ZONE_1, int(temperature) + OverkizCommand.SET_THERMOSTAT_SETTING_CONTROL_ZONE_1, float(temperature) ) diff --git a/homeassistant/components/rainforest_raven/manifest.json b/homeassistant/components/rainforest_raven/manifest.json index 49bd11e8880008..3a902377c2e977 100644 --- a/homeassistant/components/rainforest_raven/manifest.json +++ b/homeassistant/components/rainforest_raven/manifest.json @@ -6,7 +6,7 @@ "dependencies": ["usb"], "documentation": "https://www.home-assistant.io/integrations/rainforest_raven", "iot_class": "local_polling", - "requirements": ["aioraven==0.7.0"], + "requirements": ["aioraven==0.7.1"], "usb": [ { "vid": "0403", diff --git a/homeassistant/components/rfxtrx/config_flow.py b/homeassistant/components/rfxtrx/config_flow.py index 866d9ecb1bb217..6ce7d88f9f0711 100644 --- a/homeassistant/components/rfxtrx/config_flow.py +++ b/homeassistant/components/rfxtrx/config_flow.py @@ -209,10 +209,7 @@ async def async_step_set_device_options( except ValueError: errors[CONF_COMMAND_OFF] = "invalid_input_2262_off" - try: - off_delay = none_or_int(user_input.get(CONF_OFF_DELAY), 10) - except ValueError: - errors[CONF_OFF_DELAY] = "invalid_input_off_delay" + off_delay = user_input.get(CONF_OFF_DELAY) if not errors: devices = {} @@ -252,11 +249,11 @@ async def async_step_set_device_options( vol.Optional( CONF_OFF_DELAY, description={"suggested_value": device_data[CONF_OFF_DELAY]}, - ): str, + ): int, } else: off_delay_schema = { - vol.Optional(CONF_OFF_DELAY): str, + vol.Optional(CONF_OFF_DELAY): int, } data_schema.update(off_delay_schema) diff --git a/homeassistant/components/rfxtrx/strings.json b/homeassistant/components/rfxtrx/strings.json index aeb4b2395d3398..735ed6c4542c04 100644 --- a/homeassistant/components/rfxtrx/strings.json +++ b/homeassistant/components/rfxtrx/strings.json @@ -68,7 +68,6 @@ "invalid_event_code": "Invalid event code", "invalid_input_2262_on": "Invalid input for command on", "invalid_input_2262_off": "Invalid input for command off", - "invalid_input_off_delay": "Invalid input for off delay", "unknown": "[%key:common::config_flow::error::unknown%]" } }, diff --git a/homeassistant/components/smartthings/__init__.py b/homeassistant/components/smartthings/__init__.py index bcc752ff173998..2914851ccbfa4a 100644 --- a/homeassistant/components/smartthings/__init__.py +++ b/homeassistant/components/smartthings/__init__.py @@ -10,12 +10,16 @@ from aiohttp.client_exceptions import ClientConnectionError, ClientResponseError from pysmartapp.event import EVENT_TYPE_DEVICE -from pysmartthings import Attribute, Capability, SmartThings +from pysmartthings import APIInvalidGrant, Attribute, Capability, SmartThings from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry from homeassistant.const import CONF_ACCESS_TOKEN, CONF_CLIENT_ID, CONF_CLIENT_SECRET from homeassistant.core import HomeAssistant -from homeassistant.exceptions import ConfigEntryNotReady +from homeassistant.exceptions import ( + ConfigEntryAuthFailed, + ConfigEntryError, + ConfigEntryNotReady, +) from homeassistant.helpers import config_validation as cv from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.dispatcher import async_dispatcher_send @@ -106,7 +110,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: # to import the modules. await async_get_loaded_integration(hass, DOMAIN).async_get_platforms(PLATFORMS) - remove_entry = False try: # See if the app is already setup. This occurs when there are # installs in multiple SmartThings locations (valid use-case) @@ -175,34 +178,19 @@ async def retrieve_device_status(device): broker.connect() hass.data[DOMAIN][DATA_BROKERS][entry.entry_id] = broker + except APIInvalidGrant as ex: + raise ConfigEntryAuthFailed from ex except ClientResponseError as ex: if ex.status in (HTTPStatus.UNAUTHORIZED, HTTPStatus.FORBIDDEN): - _LOGGER.exception( - ( - "Unable to setup configuration entry '%s' - please reconfigure the" - " integration" - ), - entry.title, - ) - remove_entry = True - else: - _LOGGER.debug(ex, exc_info=True) - raise ConfigEntryNotReady from ex + raise ConfigEntryError( + "The access token is no longer valid. Please remove the integration and set up again." + ) from ex + _LOGGER.debug(ex, exc_info=True) + raise ConfigEntryNotReady from ex except (ClientConnectionError, RuntimeWarning) as ex: _LOGGER.debug(ex, exc_info=True) raise ConfigEntryNotReady from ex - if remove_entry: - hass.async_create_task(hass.config_entries.async_remove(entry.entry_id)) - # only create new flow if there isn't a pending one for SmartThings. - if not hass.config_entries.flow.async_progress_by_handler(DOMAIN): - hass.async_create_task( - hass.config_entries.flow.async_init( - DOMAIN, context={"source": SOURCE_IMPORT} - ) - ) - return False - await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) return True diff --git a/homeassistant/components/smartthings/config_flow.py b/homeassistant/components/smartthings/config_flow.py index 081f833787e08c..7b49854740a9bd 100644 --- a/homeassistant/components/smartthings/config_flow.py +++ b/homeassistant/components/smartthings/config_flow.py @@ -1,5 +1,6 @@ """Config flow to configure SmartThings.""" +from collections.abc import Mapping from http import HTTPStatus import logging from typing import Any @@ -9,7 +10,7 @@ from pysmartthings.installedapp import format_install_url import voluptuous as vol -from homeassistant.config_entries import ConfigFlow, ConfigFlowResult +from homeassistant.config_entries import SOURCE_REAUTH, ConfigFlow, ConfigFlowResult from homeassistant.const import CONF_ACCESS_TOKEN, CONF_CLIENT_ID, CONF_CLIENT_SECRET from homeassistant.helpers.aiohttp_client import async_get_clientsession @@ -213,7 +214,10 @@ async def async_step_authorize( url = format_install_url(self.app_id, self.location_id) return self.async_external_step(step_id="authorize", url=url) - return self.async_external_step_done(next_step_id="install") + next_step_id = "install" + if self.source == SOURCE_REAUTH: + next_step_id = "update" + return self.async_external_step_done(next_step_id=next_step_id) def _show_step_pat(self, errors): if self.access_token is None: @@ -240,6 +244,41 @@ def _show_step_pat(self, errors): }, ) + async def async_step_reauth( + self, entry_data: Mapping[str, Any] + ) -> ConfigFlowResult: + """Handle re-authentication of an existing config entry.""" + return await self.async_step_reauth_confirm() + + async def async_step_reauth_confirm( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle re-authentication of an existing config entry.""" + if user_input is None: + return self.async_show_form(step_id="reauth_confirm") + self.app_id = self._get_reauth_entry().data[CONF_APP_ID] + self.location_id = self._get_reauth_entry().data[CONF_LOCATION_ID] + self._set_confirm_only() + return await self.async_step_authorize() + + async def async_step_update( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle re-authentication of an existing config entry.""" + return await self.async_step_update_confirm() + + async def async_step_update_confirm( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle re-authentication of an existing config entry.""" + if user_input is None: + self._set_confirm_only() + return self.async_show_form(step_id="update_confirm") + entry = self._get_reauth_entry() + return self.async_update_reload_and_abort( + entry, data_updates={CONF_REFRESH_TOKEN: self.refresh_token} + ) + async def async_step_install( self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: diff --git a/homeassistant/components/smartthings/smartapp.py b/homeassistant/components/smartthings/smartapp.py index 6b0da00b132d85..76b6804075fdcb 100644 --- a/homeassistant/components/smartthings/smartapp.py +++ b/homeassistant/components/smartthings/smartapp.py @@ -1,5 +1,7 @@ """SmartApp functionality to receive cloud-push notifications.""" +from __future__ import annotations + import asyncio import functools import logging @@ -27,6 +29,7 @@ ) from homeassistant.components import cloud, webhook +from homeassistant.config_entries import ConfigFlowResult from homeassistant.const import CONF_WEBHOOK_ID from homeassistant.core import HomeAssistant from homeassistant.helpers.aiohttp_client import async_get_clientsession @@ -400,7 +403,7 @@ async def delete_subscription(sub: SubscriptionEntity): _LOGGER.debug("Subscriptions for app '%s' are up-to-date", installed_app_id) -async def _continue_flow( +async def _find_and_continue_flow( hass: HomeAssistant, app_id: str, location_id: str, @@ -418,24 +421,34 @@ async def _continue_flow( None, ) if flow is not None: - await hass.config_entries.flow.async_configure( - flow["flow_id"], - { - CONF_INSTALLED_APP_ID: installed_app_id, - CONF_REFRESH_TOKEN: refresh_token, - }, - ) - _LOGGER.debug( - "Continued config flow '%s' for SmartApp '%s' under parent app '%s'", - flow["flow_id"], - installed_app_id, - app_id, - ) + await _continue_flow(hass, app_id, installed_app_id, refresh_token, flow) + + +async def _continue_flow( + hass: HomeAssistant, + app_id: str, + installed_app_id: str, + refresh_token: str, + flow: ConfigFlowResult, +) -> None: + await hass.config_entries.flow.async_configure( + flow["flow_id"], + { + CONF_INSTALLED_APP_ID: installed_app_id, + CONF_REFRESH_TOKEN: refresh_token, + }, + ) + _LOGGER.debug( + "Continued config flow '%s' for SmartApp '%s' under parent app '%s'", + flow["flow_id"], + installed_app_id, + app_id, + ) async def smartapp_install(hass: HomeAssistant, req, resp, app): """Handle a SmartApp installation and continue the config flow.""" - await _continue_flow( + await _find_and_continue_flow( hass, app.app_id, req.location_id, req.installed_app_id, req.refresh_token ) _LOGGER.debug( @@ -447,6 +460,27 @@ async def smartapp_install(hass: HomeAssistant, req, resp, app): async def smartapp_update(hass: HomeAssistant, req, resp, app): """Handle a SmartApp update and either update the entry or continue the flow.""" + unique_id = format_unique_id(app.app_id, req.location_id) + flow = next( + ( + flow + for flow in hass.config_entries.flow.async_progress_by_handler(DOMAIN) + if flow["context"].get("unique_id") == unique_id + and flow["step_id"] == "authorize" + ), + None, + ) + if flow is not None: + await _continue_flow( + hass, app.app_id, req.installed_app_id, req.refresh_token, flow + ) + _LOGGER.debug( + "Continued reauth flow '%s' for SmartApp '%s' under parent app '%s'", + flow["flow_id"], + req.installed_app_id, + app.app_id, + ) + return entry = next( ( entry @@ -466,7 +500,7 @@ async def smartapp_update(hass: HomeAssistant, req, resp, app): app.app_id, ) - await _continue_flow( + await _find_and_continue_flow( hass, app.app_id, req.location_id, req.installed_app_id, req.refresh_token ) _LOGGER.debug( diff --git a/homeassistant/components/smartthings/strings.json b/homeassistant/components/smartthings/strings.json index de94e5adfcd635..31a552be149598 100644 --- a/homeassistant/components/smartthings/strings.json +++ b/homeassistant/components/smartthings/strings.json @@ -7,7 +7,7 @@ }, "pat": { "title": "Enter Personal Access Token", - "description": "Please enter a SmartThings [Personal Access Token]({token_url}) that has been created per the [instructions]({component_url}). This will be used to create the Home Assistant integration within your SmartThings account.", + "description": "Please enter a SmartThings [Personal Access Token]({token_url}) that has been created per the [instructions]({component_url}). This will be used to create the Home Assistant integration within your SmartThings account.\n\n**Please note that all Personal Access Tokens created after 30 December 2024 are only valid for 24 hours, after which the integration will stop working. We are working on a fix.**", "data": { "access_token": "[%key:common::config_flow::data::access_token%]" } @@ -17,11 +17,20 @@ "description": "Please select the SmartThings Location you wish to add to Home Assistant. We will then open a new window and ask you to login and authorize installation of the Home Assistant integration into the selected location.", "data": { "location_id": "[%key:common::config_flow::data::location%]" } }, - "authorize": { "title": "Authorize Home Assistant" } + "authorize": { "title": "Authorize Home Assistant" }, + "reauth_confirm": { + "title": "Reauthorize Home Assistant", + "description": "You are about to reauthorize Home Assistant with SmartThings. This will require you to log in and authorize the integration again." + }, + "update_confirm": { + "title": "Finish reauthentication", + "description": "You have almost successfully reauthorized Home Assistant with SmartThings. Please press the button down below to finish the process." + } }, "abort": { "invalid_webhook_url": "Home Assistant is not configured correctly to receive updates from SmartThings. The webhook URL is invalid:\n> {webhook_url}\n\nPlease update your configuration per the [instructions]({component_url}), restart Home Assistant, and try again.", - "no_available_locations": "There are no available SmartThings Locations to set up in Home Assistant." + "no_available_locations": "There are no available SmartThings Locations to set up in Home Assistant.", + "reauth_successful": "Home Assistant has been successfully reauthorized with SmartThings." }, "error": { "token_invalid_format": "The token must be in the UID/GUID format", diff --git a/homeassistant/components/switchbot_cloud/manifest.json b/homeassistant/components/switchbot_cloud/manifest.json index eb08d2183b11ca..99f909e91ab9c6 100644 --- a/homeassistant/components/switchbot_cloud/manifest.json +++ b/homeassistant/components/switchbot_cloud/manifest.json @@ -6,6 +6,6 @@ "documentation": "https://www.home-assistant.io/integrations/switchbot_cloud", "integration_type": "hub", "iot_class": "cloud_polling", - "loggers": ["switchbot-api"], - "requirements": ["switchbot-api==2.2.1"] + "loggers": ["switchbot_api"], + "requirements": ["switchbot-api==2.3.1"] } diff --git a/homeassistant/components/unifiprotect/event.py b/homeassistant/components/unifiprotect/event.py index c8bce183e34a97..78fdf7746deeb8 100644 --- a/homeassistant/components/unifiprotect/event.py +++ b/homeassistant/components/unifiprotect/event.py @@ -181,7 +181,6 @@ def _async_update_device_from_protect(self, device: ProtectDeviceType) -> None: ProtectEventEntityDescription( key="nfc", translation_key="nfc", - device_class=EventDeviceClass.DOORBELL, icon="mdi:nfc", ufp_required_field="feature_flags.support_nfc", ufp_event_obj="last_nfc_card_scanned_event", @@ -191,7 +190,6 @@ def _async_update_device_from_protect(self, device: ProtectDeviceType) -> None: ProtectEventEntityDescription( key="fingerprint", translation_key="fingerprint", - device_class=EventDeviceClass.DOORBELL, icon="mdi:fingerprint", ufp_required_field="feature_flags.has_fingerprint_sensor", ufp_event_obj="last_fingerprint_identified_event", diff --git a/homeassistant/components/watergate/sensor.py b/homeassistant/components/watergate/sensor.py index 638bf2974158e8..6782a93541b9e7 100644 --- a/homeassistant/components/watergate/sensor.py +++ b/homeassistant/components/watergate/sensor.py @@ -90,7 +90,7 @@ class WatergateSensorEntityDescription(SensorEntityDescription): WatergateSensorEntityDescription( value_fn=lambda data: ( dt_util.as_utc( - dt_util.now() - timedelta(microseconds=data.networking.wifi_uptime) + dt_util.now() - timedelta(milliseconds=data.networking.wifi_uptime) ) if data.networking else None @@ -104,7 +104,7 @@ class WatergateSensorEntityDescription(SensorEntityDescription): WatergateSensorEntityDescription( value_fn=lambda data: ( dt_util.as_utc( - dt_util.now() - timedelta(microseconds=data.networking.mqtt_uptime) + dt_util.now() - timedelta(milliseconds=data.networking.mqtt_uptime) ) if data.networking else None @@ -158,7 +158,11 @@ class WatergateSensorEntityDescription(SensorEntityDescription): ), WatergateSensorEntityDescription( value_fn=lambda data: ( - PowerSupplyMode(data.state.power_supply.replace("+", "_")) + PowerSupplyMode( + data.state.power_supply.replace("+", "_").replace( + "external_battery", "battery_external" + ) + ) if data.state else None ), diff --git a/homeassistant/components/withings/__init__.py b/homeassistant/components/withings/__init__.py index 1c196bd4b92b01..59c3ed8433f837 100644 --- a/homeassistant/components/withings/__init__.py +++ b/homeassistant/components/withings/__init__.py @@ -16,6 +16,7 @@ from aiohttp.hdrs import METH_POST from aiohttp.web import Request, Response from aiowithings import NotificationCategory, WithingsClient +from aiowithings.exceptions import WithingsError from aiowithings.util import to_enum from yarl import URL @@ -223,10 +224,13 @@ async def unregister_webhook( "Unregister Withings webhook (%s)", self.entry.data[CONF_WEBHOOK_ID] ) webhook_unregister(self.hass, self.entry.data[CONF_WEBHOOK_ID]) - await async_unsubscribe_webhooks(self.withings_data.client) for coordinator in self.withings_data.coordinators: coordinator.webhook_subscription_listener(False) self._webhooks_registered = False + try: + await async_unsubscribe_webhooks(self.withings_data.client) + except WithingsError as ex: + LOGGER.warning("Failed to unsubscribe from Withings webhook: %s", ex) async def register_webhook( self, diff --git a/homeassistant/const.py b/homeassistant/const.py index 9f25ff3f80ab8c..f5046b510f8192 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -25,7 +25,7 @@ APPLICATION_NAME: Final = "HomeAssistant" MAJOR_VERSION: Final = 2025 MINOR_VERSION: Final = 1 -PATCH_VERSION: Final = "2" +PATCH_VERSION: Final = "3" __short_version__: Final = f"{MAJOR_VERSION}.{MINOR_VERSION}" __version__: Final = f"{__short_version__}.{PATCH_VERSION}" REQUIRED_PYTHON_VER: Final[tuple[int, int, int]] = (3, 12, 0) diff --git a/homeassistant/helpers/config_entry_flow.py b/homeassistant/helpers/config_entry_flow.py index b047e1aef813f0..60f2cd6e1a156d 100644 --- a/homeassistant/helpers/config_entry_flow.py +++ b/homeassistant/helpers/config_entry_flow.py @@ -67,9 +67,11 @@ async def async_step_confirm( in_progress = self._async_in_progress() if not (has_devices := bool(in_progress)): - has_devices = await cast( - "asyncio.Future[bool]", self._discovery_function(self.hass) - ) + discovery_result = self._discovery_function(self.hass) + if isinstance(discovery_result, bool): + has_devices = discovery_result + else: + has_devices = await cast("asyncio.Future[bool]", discovery_result) if not has_devices: return self.async_abort(reason="no_devices_found") diff --git a/homeassistant/helpers/script.py b/homeassistant/helpers/script.py index a67ef60c799533..5e866cddc7945d 100644 --- a/homeassistant/helpers/script.py +++ b/homeassistant/helpers/script.py @@ -1589,6 +1589,9 @@ def _find_referenced_target( target, referenced, script[CONF_SEQUENCE] ) + elif action == cv.SCRIPT_ACTION_SEQUENCE: + Script._find_referenced_target(target, referenced, step[CONF_SEQUENCE]) + @cached_property def referenced_devices(self) -> set[str]: """Return a set of referenced devices.""" @@ -1636,6 +1639,9 @@ def _find_referenced_devices( for script in step[CONF_PARALLEL]: Script._find_referenced_devices(referenced, script[CONF_SEQUENCE]) + elif action == cv.SCRIPT_ACTION_SEQUENCE: + Script._find_referenced_devices(referenced, step[CONF_SEQUENCE]) + @cached_property def referenced_entities(self) -> set[str]: """Return a set of referenced entities.""" @@ -1684,6 +1690,9 @@ def _find_referenced_entities( for script in step[CONF_PARALLEL]: Script._find_referenced_entities(referenced, script[CONF_SEQUENCE]) + elif action == cv.SCRIPT_ACTION_SEQUENCE: + Script._find_referenced_entities(referenced, step[CONF_SEQUENCE]) + def run( self, variables: _VarsType | None = None, context: Context | None = None ) -> None: diff --git a/pyproject.toml b/pyproject.toml index 4d88c5641fab64..e24dbcd58e5f82 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "homeassistant" -version = "2025.1.2" +version = "2025.1.3" license = {text = "Apache-2.0"} description = "Open-source home automation platform running on Python 3." readme = "README.rst" diff --git a/requirements_all.txt b/requirements_all.txt index f11d3b691e3666..28269469a78cfc 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -182,7 +182,7 @@ aioairq==0.4.3 aioairzone-cloud==0.6.10 # homeassistant.components.airzone -aioairzone==0.9.7 +aioairzone==0.9.9 # homeassistant.components.ambient_network # homeassistant.components.ambient_station @@ -318,7 +318,7 @@ aiooncue==0.3.7 aioopenexchangerates==0.6.8 # homeassistant.components.nmap_tracker -aiooui==0.1.7 +aiooui==0.1.9 # homeassistant.components.pegel_online aiopegelonline==0.1.1 @@ -344,7 +344,7 @@ aiopyarr==23.4.0 aioqsw==0.4.1 # homeassistant.components.rainforest_raven -aioraven==0.7.0 +aioraven==0.7.1 # homeassistant.components.recollect_waste aiorecollect==2023.09.0 @@ -738,7 +738,7 @@ debugpy==1.8.11 # decora==0.6 # homeassistant.components.ecovacs -deebot-client==10.1.0 +deebot-client==11.0.0 # homeassistant.components.ihc # homeassistant.components.namecheapdns @@ -749,7 +749,7 @@ defusedxml==0.7.1 deluge-client==1.10.2 # homeassistant.components.lametric -demetriek==1.1.1 +demetriek==1.2.0 # homeassistant.components.denonavr denonavr==1.0.1 @@ -824,7 +824,7 @@ elgato==5.1.2 eliqonline==1.2.2 # homeassistant.components.elkm1 -elkm1-lib==2.2.10 +elkm1-lib==2.2.11 # homeassistant.components.elmax elmax-api==0.0.6.4rc0 @@ -940,7 +940,7 @@ forecast-solar==4.0.0 fortiosapi==1.0.5 # homeassistant.components.freebox -freebox-api==1.2.1 +freebox-api==1.2.2 # homeassistant.components.free_mobile freesms==0.2.0 @@ -1260,7 +1260,7 @@ kiwiki-client==0.1.1 knocki==0.4.2 # homeassistant.components.knx -knx-frontend==2024.12.26.233449 +knx-frontend==2025.1.18.164225 # homeassistant.components.konnected konnected==1.2.0 @@ -1467,7 +1467,7 @@ nextcord==2.6.0 nextdns==4.0.0 # homeassistant.components.niko_home_control -nhc==0.3.2 +nhc==0.3.4 # homeassistant.components.nibe_heatpump nibe==2.14.0 @@ -1537,7 +1537,7 @@ omnilogic==0.4.5 ondilo==0.5.0 # homeassistant.components.onvif -onvif-zeep-async==3.1.13 +onvif-zeep-async==3.2.3 # homeassistant.components.opengarage open-garage==0.2.0 @@ -1794,7 +1794,7 @@ pyatmo==8.1.0 pyatv==0.16.0 # homeassistant.components.aussie_broadband -pyaussiebb==0.1.4 +pyaussiebb==0.1.5 # homeassistant.components.balboa pybalboa==1.0.2 @@ -1965,7 +1965,7 @@ pyhaversion==22.8.0 pyheos==0.7.2 # homeassistant.components.hive -pyhiveapi==0.5.16 +pyhive-integration==1.0.1 # homeassistant.components.homematic pyhomematic==0.1.77 @@ -2782,7 +2782,7 @@ surepy==0.9.0 swisshydrodata==0.1.0 # homeassistant.components.switchbot_cloud -switchbot-api==2.2.1 +switchbot-api==2.3.1 # homeassistant.components.synology_srm synology-srm==0.2.0 @@ -3082,7 +3082,7 @@ youless-api==2.1.2 youtubeaio==1.1.5 # homeassistant.components.media_extractor -yt-dlp[default]==2024.12.23 +yt-dlp[default]==2025.01.15 # homeassistant.components.zabbix zabbix-utils==2.0.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 11260d55e599b8..265390966db887 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -170,7 +170,7 @@ aioairq==0.4.3 aioairzone-cloud==0.6.10 # homeassistant.components.airzone -aioairzone==0.9.7 +aioairzone==0.9.9 # homeassistant.components.ambient_network # homeassistant.components.ambient_station @@ -300,7 +300,7 @@ aiooncue==0.3.7 aioopenexchangerates==0.6.8 # homeassistant.components.nmap_tracker -aiooui==0.1.7 +aiooui==0.1.9 # homeassistant.components.pegel_online aiopegelonline==0.1.1 @@ -326,7 +326,7 @@ aiopyarr==23.4.0 aioqsw==0.4.1 # homeassistant.components.rainforest_raven -aioraven==0.7.0 +aioraven==0.7.1 # homeassistant.components.recollect_waste aiorecollect==2023.09.0 @@ -628,7 +628,7 @@ dbus-fast==2.24.3 debugpy==1.8.11 # homeassistant.components.ecovacs -deebot-client==10.1.0 +deebot-client==11.0.0 # homeassistant.components.ihc # homeassistant.components.namecheapdns @@ -639,7 +639,7 @@ defusedxml==0.7.1 deluge-client==1.10.2 # homeassistant.components.lametric -demetriek==1.1.1 +demetriek==1.2.0 # homeassistant.components.denonavr denonavr==1.0.1 @@ -699,7 +699,7 @@ elevenlabs==1.9.0 elgato==5.1.2 # homeassistant.components.elkm1 -elkm1-lib==2.2.10 +elkm1-lib==2.2.11 # homeassistant.components.elmax elmax-api==0.0.6.4rc0 @@ -796,7 +796,7 @@ foobot_async==1.0.0 forecast-solar==4.0.0 # homeassistant.components.freebox -freebox-api==1.2.1 +freebox-api==1.2.2 # homeassistant.components.fritz # homeassistant.components.fritzbox_callmonitor @@ -1062,7 +1062,7 @@ kegtron-ble==0.4.0 knocki==0.4.2 # homeassistant.components.knx -knx-frontend==2024.12.26.233449 +knx-frontend==2025.1.18.164225 # homeassistant.components.konnected konnected==1.2.0 @@ -1230,7 +1230,7 @@ nextcord==2.6.0 nextdns==4.0.0 # homeassistant.components.niko_home_control -nhc==0.3.2 +nhc==0.3.4 # homeassistant.components.nibe_heatpump nibe==2.14.0 @@ -1285,7 +1285,7 @@ omnilogic==0.4.5 ondilo==0.5.0 # homeassistant.components.onvif -onvif-zeep-async==3.1.13 +onvif-zeep-async==3.2.3 # homeassistant.components.opengarage open-garage==0.2.0 @@ -1474,7 +1474,7 @@ pyatmo==8.1.0 pyatv==0.16.0 # homeassistant.components.aussie_broadband -pyaussiebb==0.1.4 +pyaussiebb==0.1.5 # homeassistant.components.balboa pybalboa==1.0.2 @@ -1594,7 +1594,7 @@ pyhaversion==22.8.0 pyheos==0.7.2 # homeassistant.components.hive -pyhiveapi==0.5.16 +pyhive-integration==1.0.1 # homeassistant.components.homematic pyhomematic==0.1.77 @@ -2237,7 +2237,7 @@ sunweg==3.0.2 surepy==0.9.0 # homeassistant.components.switchbot_cloud -switchbot-api==2.2.1 +switchbot-api==2.3.1 # homeassistant.components.system_bridge systembridgeconnector==4.1.5 @@ -2477,7 +2477,7 @@ youless-api==2.1.2 youtubeaio==1.1.5 # homeassistant.components.media_extractor -yt-dlp[default]==2024.12.23 +yt-dlp[default]==2025.01.15 # homeassistant.components.zamg zamg==0.3.6 diff --git a/tests/components/airzone/snapshots/test_diagnostics.ambr b/tests/components/airzone/snapshots/test_diagnostics.ambr index fb4f6530b1e053..bb44a0abeb180d 100644 --- a/tests/components/airzone/snapshots/test_diagnostics.ambr +++ b/tests/components/airzone/snapshots/test_diagnostics.ambr @@ -140,6 +140,7 @@ 'heatStages': 1, 'heatangle': 0, 'humidity': 40, + 'master_zoneID': None, 'maxTemp': 30, 'minTemp': 15, 'mode': 3, diff --git a/tests/components/airzone/util.py b/tests/components/airzone/util.py index 278663b7a9754e..b51dfb890e4cc6 100644 --- a/tests/components/airzone/util.py +++ b/tests/components/airzone/util.py @@ -28,6 +28,7 @@ API_HEAT_STAGES, API_HUMIDITY, API_MAC, + API_MASTER_ZONE_ID, API_MAX_TEMP, API_MIN_TEMP, API_MODE, @@ -214,6 +215,7 @@ API_FLOOR_DEMAND: 0, API_HEAT_ANGLE: 0, API_COLD_ANGLE: 0, + API_MASTER_ZONE_ID: None, }, ] }, diff --git a/tests/components/assist_pipeline/snapshots/test_init.ambr b/tests/components/assist_pipeline/snapshots/test_init.ambr index f63a28efbb765a..171014fdc4acbf 100644 --- a/tests/components/assist_pipeline/snapshots/test_init.ambr +++ b/tests/components/assist_pipeline/snapshots/test_init.ambr @@ -474,6 +474,108 @@ }), ]) # --- +# name: test_stt_language_used_instead_of_conversation_language + list([ + dict({ + 'data': dict({ + 'language': 'en', + 'pipeline': , + }), + 'type': , + }), + dict({ + 'data': dict({ + 'conversation_id': None, + 'device_id': None, + 'engine': 'conversation.home_assistant', + 'intent_input': 'test input', + 'language': 'en-US', + 'prefer_local_intents': False, + }), + 'type': , + }), + dict({ + 'data': 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({ + }), + }), + }), + 'processed_locally': True, + }), + 'type': , + }), + dict({ + 'data': None, + 'type': , + }), + ]) +# --- +# name: test_tts_language_used_instead_of_conversation_language + list([ + dict({ + 'data': dict({ + 'language': 'en', + 'pipeline': , + }), + 'type': , + }), + dict({ + 'data': dict({ + 'conversation_id': None, + 'device_id': None, + 'engine': 'conversation.home_assistant', + 'intent_input': 'test input', + 'language': 'en-us', + 'prefer_local_intents': False, + }), + 'type': , + }), + dict({ + 'data': 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({ + }), + }), + }), + 'processed_locally': True, + }), + 'type': , + }), + dict({ + 'data': None, + 'type': , + }), + ]) +# --- # name: test_wake_word_detection_aborted list([ dict({ diff --git a/tests/components/assist_pipeline/test_init.py b/tests/components/assist_pipeline/test_init.py index d4cce4e2e9818a..a2cb9ef382aff1 100644 --- a/tests/components/assist_pipeline/test_init.py +++ b/tests/components/assist_pipeline/test_init.py @@ -1102,13 +1102,13 @@ async def async_handle( ) -async def test_pipeline_language_used_instead_of_conversation_language( +async def test_stt_language_used_instead_of_conversation_language( hass: HomeAssistant, hass_ws_client: WebSocketGenerator, init_components, snapshot: SnapshotAssertion, ) -> None: - """Test that the pipeline language is used when the conversation language is '*' (all languages).""" + """Test that the STT language is used first when the conversation language is '*' (all languages).""" client = await hass_ws_client(hass) events: list[assist_pipeline.PipelineEvent] = [] @@ -1165,7 +1165,155 @@ async def test_pipeline_language_used_instead_of_conversation_language( assert intent_start is not None - # Pipeline language (en) should be used instead of '*' + # STT language (en-US) should be used instead of '*' + assert intent_start.data.get("language") == pipeline.stt_language + + # Check input to async_converse + mock_async_converse.assert_called_once() + assert ( + mock_async_converse.call_args_list[0].kwargs.get("language") + == pipeline.stt_language + ) + + +async def test_tts_language_used_instead_of_conversation_language( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + init_components, + snapshot: SnapshotAssertion, +) -> None: + """Test that the TTS language is used after STT when the conversation language is '*' (all languages).""" + client = await hass_ws_client(hass) + + events: list[assist_pipeline.PipelineEvent] = [] + + await client.send_json_auto_id( + { + "type": "assist_pipeline/pipeline/create", + "conversation_engine": "homeassistant", + "conversation_language": MATCH_ALL, + "language": "en", + "name": "test_name", + "stt_engine": None, + "stt_language": None, + "tts_engine": None, + "tts_language": "en-us", + "tts_voice": "Arnold Schwarzenegger", + "wake_word_entity": None, + "wake_word_id": None, + } + ) + msg = await client.receive_json() + assert msg["success"] + pipeline_id = msg["result"]["id"] + pipeline = assist_pipeline.async_get_pipeline(hass, pipeline_id) + + pipeline_input = assist_pipeline.pipeline.PipelineInput( + intent_input="test input", + run=assist_pipeline.pipeline.PipelineRun( + hass, + context=Context(), + pipeline=pipeline, + start_stage=assist_pipeline.PipelineStage.INTENT, + end_stage=assist_pipeline.PipelineStage.INTENT, + event_callback=events.append, + ), + ) + await pipeline_input.validate() + + with patch( + "homeassistant.components.assist_pipeline.pipeline.conversation.async_converse", + return_value=conversation.ConversationResult( + intent.IntentResponse(pipeline.language) + ), + ) as mock_async_converse: + await pipeline_input.execute() + + # Check intent start event + assert process_events(events) == snapshot + intent_start: assist_pipeline.PipelineEvent | None = None + for event in events: + if event.type == assist_pipeline.PipelineEventType.INTENT_START: + intent_start = event + break + + assert intent_start is not None + + # STT language (en-US) should be used instead of '*' + assert intent_start.data.get("language") == pipeline.tts_language + + # Check input to async_converse + mock_async_converse.assert_called_once() + assert ( + mock_async_converse.call_args_list[0].kwargs.get("language") + == pipeline.tts_language + ) + + +async def test_pipeline_language_used_instead_of_conversation_language( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + init_components, + snapshot: SnapshotAssertion, +) -> None: + """Test that the pipeline language is used last when the conversation language is '*' (all languages).""" + client = await hass_ws_client(hass) + + events: list[assist_pipeline.PipelineEvent] = [] + + await client.send_json_auto_id( + { + "type": "assist_pipeline/pipeline/create", + "conversation_engine": "homeassistant", + "conversation_language": MATCH_ALL, + "language": "en", + "name": "test_name", + "stt_engine": None, + "stt_language": None, + "tts_engine": None, + "tts_language": None, + "tts_voice": None, + "wake_word_entity": None, + "wake_word_id": None, + } + ) + msg = await client.receive_json() + assert msg["success"] + pipeline_id = msg["result"]["id"] + pipeline = assist_pipeline.async_get_pipeline(hass, pipeline_id) + + pipeline_input = assist_pipeline.pipeline.PipelineInput( + intent_input="test input", + run=assist_pipeline.pipeline.PipelineRun( + hass, + context=Context(), + pipeline=pipeline, + start_stage=assist_pipeline.PipelineStage.INTENT, + end_stage=assist_pipeline.PipelineStage.INTENT, + event_callback=events.append, + ), + ) + await pipeline_input.validate() + + with patch( + "homeassistant.components.assist_pipeline.pipeline.conversation.async_converse", + return_value=conversation.ConversationResult( + intent.IntentResponse(pipeline.language) + ), + ) as mock_async_converse: + await pipeline_input.execute() + + # Check intent start event + assert process_events(events) == snapshot + intent_start: assist_pipeline.PipelineEvent | None = None + for event in events: + if event.type == assist_pipeline.PipelineEventType.INTENT_START: + intent_start = event + break + + assert intent_start is not None + + # STT language (en-US) should be used instead of '*' assert intent_start.data.get("language") == pipeline.language # Check input to async_converse diff --git a/tests/components/hassio/test_backup.py b/tests/components/hassio/test_backup.py index 10a804d983fc00..40ab253b7e6a62 100644 --- a/tests/components/hassio/test_backup.py +++ b/tests/components/hassio/test_backup.py @@ -673,7 +673,7 @@ async def test_agents_notify_on_mount_added_removed( "instance_id": ANY, "with_automatic_settings": False, }, - folders=None, + folders={"ssl"}, homeassistant_exclude_database=False, homeassistant=True, location=[None], @@ -704,7 +704,7 @@ async def test_agents_notify_on_mount_added_removed( ), ( {"include_folders": ["media", "share"]}, - replace(DEFAULT_BACKUP_OPTIONS, folders={"media", "share"}), + replace(DEFAULT_BACKUP_OPTIONS, folders={"media", "share", "ssl"}), ), ( { diff --git a/tests/components/homekit/test_type_lights.py b/tests/components/homekit/test_type_lights.py index fb059b93a13d5e..c1870cecd9c5bb 100644 --- a/tests/components/homekit/test_type_lights.py +++ b/tests/components/homekit/test_type_lights.py @@ -1,6 +1,7 @@ """Test different accessory types: Lights.""" from datetime import timedelta +import sys from pyhap.const import HAP_REPR_AID, HAP_REPR_CHARS, HAP_REPR_IID, HAP_REPR_VALUE import pytest @@ -540,6 +541,422 @@ async def test_light_color_temperature_and_rgb_color( assert acc.char_saturation.value == 100 +async def test_light_invalid_hs_color( + hass: HomeAssistant, hk_driver, events: list[Event] +) -> None: + """Test light that starts out with an invalid hs color.""" + entity_id = "light.demo" + + hass.states.async_set( + entity_id, + STATE_ON, + { + ATTR_SUPPORTED_COLOR_MODES: ["color_temp", "hs"], + ATTR_COLOR_MODE: "hs", + ATTR_HS_COLOR: 260, + }, + ) + await hass.async_block_till_done() + acc = Light(hass, hk_driver, "Light", entity_id, 1, None) + hk_driver.add_accessory(acc) + + assert acc.char_color_temp.value == 153 + assert acc.char_hue.value == 0 + assert acc.char_saturation.value == 75 + + assert hasattr(acc, "char_color_temp") + + hass.states.async_set(entity_id, STATE_ON, {ATTR_COLOR_TEMP_KELVIN: 4464}) + await hass.async_block_till_done() + acc.run() + await hass.async_block_till_done() + assert acc.char_color_temp.value == 224 + assert acc.char_hue.value == 27 + assert acc.char_saturation.value == 27 + + hass.states.async_set(entity_id, STATE_ON, {ATTR_COLOR_TEMP_KELVIN: 2840}) + await hass.async_block_till_done() + acc.run() + await hass.async_block_till_done() + assert acc.char_color_temp.value == 352 + assert acc.char_hue.value == 28 + assert acc.char_saturation.value == 61 + + char_on_iid = acc.char_on.to_HAP()[HAP_REPR_IID] + char_brightness_iid = acc.char_brightness.to_HAP()[HAP_REPR_IID] + char_hue_iid = acc.char_hue.to_HAP()[HAP_REPR_IID] + char_saturation_iid = acc.char_saturation.to_HAP()[HAP_REPR_IID] + char_color_temp_iid = acc.char_color_temp.to_HAP()[HAP_REPR_IID] + + # Set from HomeKit + call_turn_on = async_mock_service(hass, LIGHT_DOMAIN, "turn_on") + + hk_driver.set_characteristics( + { + HAP_REPR_CHARS: [ + {HAP_REPR_AID: acc.aid, HAP_REPR_IID: char_on_iid, HAP_REPR_VALUE: 1}, + { + HAP_REPR_AID: acc.aid, + HAP_REPR_IID: char_brightness_iid, + HAP_REPR_VALUE: 20, + }, + { + HAP_REPR_AID: acc.aid, + HAP_REPR_IID: char_color_temp_iid, + HAP_REPR_VALUE: 250, + }, + { + HAP_REPR_AID: acc.aid, + HAP_REPR_IID: char_hue_iid, + HAP_REPR_VALUE: 50, + }, + { + HAP_REPR_AID: acc.aid, + HAP_REPR_IID: char_saturation_iid, + HAP_REPR_VALUE: 50, + }, + ] + }, + "mock_addr", + ) + await _wait_for_light_coalesce(hass) + assert call_turn_on[0] + assert call_turn_on[0].data[ATTR_ENTITY_ID] == entity_id + assert call_turn_on[0].data[ATTR_BRIGHTNESS_PCT] == 20 + assert call_turn_on[0].data[ATTR_COLOR_TEMP_KELVIN] == 4000 + + assert len(events) == 1 + assert ( + events[-1].data[ATTR_VALUE] + == f"Set state to 1, brightness at 20{PERCENTAGE}, color temperature at 250" + ) + + # Only set Hue + hk_driver.set_characteristics( + { + HAP_REPR_CHARS: [ + { + HAP_REPR_AID: acc.aid, + HAP_REPR_IID: char_hue_iid, + HAP_REPR_VALUE: 30, + } + ] + }, + "mock_addr", + ) + await _wait_for_light_coalesce(hass) + assert call_turn_on[1] + assert call_turn_on[1].data[ATTR_HS_COLOR] == (30, 50) + + assert events[-1].data[ATTR_VALUE] == "set color at (30, 50)" + + # Only set Saturation + hk_driver.set_characteristics( + { + HAP_REPR_CHARS: [ + { + HAP_REPR_AID: acc.aid, + HAP_REPR_IID: char_saturation_iid, + HAP_REPR_VALUE: 20, + } + ] + }, + "mock_addr", + ) + await _wait_for_light_coalesce(hass) + assert call_turn_on[2] + assert call_turn_on[2].data[ATTR_HS_COLOR] == (30, 20) + + assert events[-1].data[ATTR_VALUE] == "set color at (30, 20)" + + # Generate a conflict by setting hue and then color temp + hk_driver.set_characteristics( + { + HAP_REPR_CHARS: [ + { + HAP_REPR_AID: acc.aid, + HAP_REPR_IID: char_hue_iid, + HAP_REPR_VALUE: 80, + } + ] + }, + "mock_addr", + ) + hk_driver.set_characteristics( + { + HAP_REPR_CHARS: [ + { + HAP_REPR_AID: acc.aid, + HAP_REPR_IID: char_color_temp_iid, + HAP_REPR_VALUE: 320, + } + ] + }, + "mock_addr", + ) + await _wait_for_light_coalesce(hass) + assert call_turn_on[3] + assert call_turn_on[3].data[ATTR_COLOR_TEMP_KELVIN] == 3125 + assert events[-1].data[ATTR_VALUE] == "color temperature at 320" + + # Generate a conflict by setting color temp then saturation + hk_driver.set_characteristics( + { + HAP_REPR_CHARS: [ + { + HAP_REPR_AID: acc.aid, + HAP_REPR_IID: char_color_temp_iid, + HAP_REPR_VALUE: 404, + } + ] + }, + "mock_addr", + ) + hk_driver.set_characteristics( + { + HAP_REPR_CHARS: [ + { + HAP_REPR_AID: acc.aid, + HAP_REPR_IID: char_saturation_iid, + HAP_REPR_VALUE: 35, + } + ] + }, + "mock_addr", + ) + await _wait_for_light_coalesce(hass) + assert call_turn_on[4] + assert call_turn_on[4].data[ATTR_HS_COLOR] == (80, 35) + assert events[-1].data[ATTR_VALUE] == "set color at (80, 35)" + + # Set from HASS + hass.states.async_set(entity_id, STATE_ON, {ATTR_HS_COLOR: (100, 100)}) + await hass.async_block_till_done() + acc.run() + await hass.async_block_till_done() + assert acc.char_color_temp.value == 404 + assert acc.char_hue.value == 100 + assert acc.char_saturation.value == 100 + + +async def test_light_invalid_values( + hass: HomeAssistant, hk_driver, events: list[Event] +) -> None: + """Test light with a variety of invalid values.""" + entity_id = "light.demo" + + hass.states.async_set( + entity_id, + STATE_ON, + { + ATTR_SUPPORTED_COLOR_MODES: ["color_temp", "hs"], + ATTR_COLOR_MODE: "hs", + ATTR_HS_COLOR: (-1, -1), + }, + ) + await hass.async_block_till_done() + acc = Light(hass, hk_driver, "Light", entity_id, 1, None) + hk_driver.add_accessory(acc) + + assert acc.char_color_temp.value == 153 + assert acc.char_hue.value == 0 + assert acc.char_saturation.value == 0 + hass.states.async_set( + entity_id, + STATE_ON, + { + ATTR_SUPPORTED_COLOR_MODES: ["color_temp", "hs"], + ATTR_COLOR_MODE: "color_temp", + ATTR_COLOR_TEMP_KELVIN: -1, + }, + ) + await hass.async_block_till_done() + acc.run() + + assert acc.char_color_temp.value == 153 + assert acc.char_hue.value == 16 + assert acc.char_saturation.value == 100 + hass.states.async_set( + entity_id, + STATE_ON, + { + ATTR_SUPPORTED_COLOR_MODES: ["color_temp", "hs"], + ATTR_COLOR_MODE: "color_temp", + ATTR_COLOR_TEMP_KELVIN: sys.maxsize, + }, + ) + await hass.async_block_till_done() + + assert acc.char_color_temp.value == 153 + assert acc.char_hue.value == 220 + assert acc.char_saturation.value == 41 + + hass.states.async_set( + entity_id, + STATE_ON, + { + ATTR_SUPPORTED_COLOR_MODES: ["color_temp", "hs"], + ATTR_COLOR_MODE: "color_temp", + ATTR_COLOR_TEMP_KELVIN: 2000, + }, + ) + await hass.async_block_till_done() + + assert acc.char_color_temp.value == 500 + assert acc.char_hue.value == 31 + assert acc.char_saturation.value == 95 + + +async def test_light_out_of_range_color_temp(hass: HomeAssistant, hk_driver) -> None: + """Test light with an out of range color temp.""" + entity_id = "light.demo" + + hass.states.async_set( + entity_id, + STATE_ON, + { + ATTR_SUPPORTED_COLOR_MODES: ["color_temp", "hs"], + ATTR_COLOR_MODE: "hs", + ATTR_COLOR_TEMP_KELVIN: 2000, + ATTR_MAX_COLOR_TEMP_KELVIN: 4000, + ATTR_MIN_COLOR_TEMP_KELVIN: 3000, + ATTR_HS_COLOR: (-1, -1), + }, + ) + await hass.async_block_till_done() + acc = Light(hass, hk_driver, "Light", entity_id, 1, None) + hk_driver.add_accessory(acc) + + assert acc.char_color_temp.value == 333 + assert acc.char_color_temp.properties[PROP_MAX_VALUE] == 333 + assert acc.char_color_temp.properties[PROP_MIN_VALUE] == 250 + assert acc.char_hue.value == 31 + assert acc.char_saturation.value == 95 + hass.states.async_set( + entity_id, + STATE_ON, + { + ATTR_SUPPORTED_COLOR_MODES: ["color_temp", "hs"], + ATTR_COLOR_MODE: "color_temp", + ATTR_MAX_COLOR_TEMP_KELVIN: 4000, + ATTR_MIN_COLOR_TEMP_KELVIN: 3000, + ATTR_COLOR_TEMP_KELVIN: -1, + }, + ) + await hass.async_block_till_done() + acc.run() + + assert acc.char_color_temp.value == 250 + assert acc.char_hue.value == 16 + assert acc.char_saturation.value == 100 + hass.states.async_set( + entity_id, + STATE_ON, + { + ATTR_SUPPORTED_COLOR_MODES: ["color_temp", "hs"], + ATTR_COLOR_MODE: "color_temp", + ATTR_MAX_COLOR_TEMP_KELVIN: 4000, + ATTR_MIN_COLOR_TEMP_KELVIN: 3000, + ATTR_COLOR_TEMP_KELVIN: sys.maxsize, + }, + ) + await hass.async_block_till_done() + + assert acc.char_color_temp.value == 250 + assert acc.char_hue.value == 220 + assert acc.char_saturation.value == 41 + + hass.states.async_set( + entity_id, + STATE_ON, + { + ATTR_SUPPORTED_COLOR_MODES: ["color_temp", "hs"], + ATTR_COLOR_MODE: "color_temp", + ATTR_COLOR_TEMP_KELVIN: 2000, + }, + ) + await hass.async_block_till_done() + + assert acc.char_color_temp.value == 250 + assert acc.char_hue.value == 220 + assert acc.char_saturation.value == 41 + + +async def test_reversed_color_temp_min_max(hass: HomeAssistant, hk_driver) -> None: + """Test light with a reversed color temp min max.""" + entity_id = "light.demo" + + hass.states.async_set( + entity_id, + STATE_ON, + { + ATTR_SUPPORTED_COLOR_MODES: ["color_temp", "hs"], + ATTR_COLOR_MODE: "hs", + ATTR_COLOR_TEMP_KELVIN: 2000, + ATTR_MAX_COLOR_TEMP_KELVIN: 3000, + ATTR_MIN_COLOR_TEMP_KELVIN: 4000, + ATTR_HS_COLOR: (-1, -1), + }, + ) + await hass.async_block_till_done() + acc = Light(hass, hk_driver, "Light", entity_id, 1, None) + hk_driver.add_accessory(acc) + + assert acc.char_color_temp.value == 333 + assert acc.char_color_temp.properties[PROP_MAX_VALUE] == 333 + assert acc.char_color_temp.properties[PROP_MIN_VALUE] == 250 + assert acc.char_hue.value == 31 + assert acc.char_saturation.value == 95 + hass.states.async_set( + entity_id, + STATE_ON, + { + ATTR_SUPPORTED_COLOR_MODES: ["color_temp", "hs"], + ATTR_COLOR_MODE: "color_temp", + ATTR_MAX_COLOR_TEMP_KELVIN: 4000, + ATTR_MIN_COLOR_TEMP_KELVIN: 3000, + ATTR_COLOR_TEMP_KELVIN: -1, + }, + ) + await hass.async_block_till_done() + acc.run() + + assert acc.char_color_temp.value == 250 + assert acc.char_hue.value == 16 + assert acc.char_saturation.value == 100 + hass.states.async_set( + entity_id, + STATE_ON, + { + ATTR_SUPPORTED_COLOR_MODES: ["color_temp", "hs"], + ATTR_COLOR_MODE: "color_temp", + ATTR_MAX_COLOR_TEMP_KELVIN: 4000, + ATTR_MIN_COLOR_TEMP_KELVIN: 3000, + ATTR_COLOR_TEMP_KELVIN: sys.maxsize, + }, + ) + await hass.async_block_till_done() + + assert acc.char_color_temp.value == 250 + assert acc.char_hue.value == 220 + assert acc.char_saturation.value == 41 + + hass.states.async_set( + entity_id, + STATE_ON, + { + ATTR_SUPPORTED_COLOR_MODES: ["color_temp", "hs"], + ATTR_COLOR_MODE: "color_temp", + ATTR_COLOR_TEMP_KELVIN: 2000, + }, + ) + await hass.async_block_till_done() + + assert acc.char_color_temp.value == 250 + assert acc.char_hue.value == 220 + assert acc.char_saturation.value == 41 + + @pytest.mark.parametrize( "supported_color_modes", [[ColorMode.HS], [ColorMode.RGB], [ColorMode.XY]] ) diff --git a/tests/components/homekit/test_type_thermostats.py b/tests/components/homekit/test_type_thermostats.py index e99db8f6234aa0..fc4cfa78ca408d 100644 --- a/tests/components/homekit/test_type_thermostats.py +++ b/tests/components/homekit/test_type_thermostats.py @@ -26,6 +26,7 @@ ATTR_TARGET_TEMP_STEP, DEFAULT_MAX_TEMP, DEFAULT_MIN_HUMIDITY, + DEFAULT_MIN_TEMP, DOMAIN as DOMAIN_CLIMATE, FAN_AUTO, FAN_HIGH, @@ -2009,8 +2010,8 @@ async def test_thermostat_with_temp_clamps(hass: HomeAssistant, hk_driver) -> No ATTR_SUPPORTED_FEATURES: ClimateEntityFeature.TARGET_TEMPERATURE | ClimateEntityFeature.TARGET_TEMPERATURE_RANGE, ATTR_HVAC_MODES: [HVACMode.HEAT_COOL, HVACMode.AUTO], - ATTR_MAX_TEMP: 50, - ATTR_MIN_TEMP: 100, + ATTR_MAX_TEMP: 100, + ATTR_MIN_TEMP: 50, } hass.states.async_set( entity_id, @@ -2024,14 +2025,14 @@ async def test_thermostat_with_temp_clamps(hass: HomeAssistant, hk_driver) -> No acc.run() await hass.async_block_till_done() - assert acc.char_cooling_thresh_temp.value == 100 - assert acc.char_heating_thresh_temp.value == 100 + assert acc.char_cooling_thresh_temp.value == 50 + assert acc.char_heating_thresh_temp.value == 50 assert acc.char_cooling_thresh_temp.properties[PROP_MAX_VALUE] == 100 - assert acc.char_cooling_thresh_temp.properties[PROP_MIN_VALUE] == 100 + assert acc.char_cooling_thresh_temp.properties[PROP_MIN_VALUE] == 50 assert acc.char_cooling_thresh_temp.properties[PROP_MIN_STEP] == 0.1 assert acc.char_heating_thresh_temp.properties[PROP_MAX_VALUE] == 100 - assert acc.char_heating_thresh_temp.properties[PROP_MIN_VALUE] == 100 + assert acc.char_heating_thresh_temp.properties[PROP_MIN_VALUE] == 50 assert acc.char_heating_thresh_temp.properties[PROP_MIN_STEP] == 0.1 assert acc.char_target_heat_cool.value == 3 @@ -2048,7 +2049,7 @@ async def test_thermostat_with_temp_clamps(hass: HomeAssistant, hk_driver) -> No }, ) await hass.async_block_till_done() - assert acc.char_heating_thresh_temp.value == 100.0 + assert acc.char_heating_thresh_temp.value == 50.0 assert acc.char_cooling_thresh_temp.value == 100.0 assert acc.char_current_heat_cool.value == 1 assert acc.char_target_heat_cool.value == 3 @@ -2633,3 +2634,44 @@ async def test_thermostat_handles_unknown_state(hass: HomeAssistant, hk_driver) assert call_set_hvac_mode assert call_set_hvac_mode[1].data[ATTR_ENTITY_ID] == entity_id assert call_set_hvac_mode[1].data[ATTR_HVAC_MODE] == HVACMode.HEAT + + +async def test_thermostat_reversed_min_max(hass: HomeAssistant, hk_driver) -> None: + """Test reversed min/max temperatures.""" + entity_id = "climate.test" + base_attrs = { + ATTR_SUPPORTED_FEATURES: ClimateEntityFeature.TARGET_TEMPERATURE + | ClimateEntityFeature.TARGET_TEMPERATURE_RANGE, + ATTR_HVAC_MODES: [ + HVACMode.HEAT, + HVACMode.HEAT_COOL, + HVACMode.FAN_ONLY, + HVACMode.COOL, + HVACMode.OFF, + HVACMode.AUTO, + ], + ATTR_MAX_TEMP: DEFAULT_MAX_TEMP, + ATTR_MIN_TEMP: DEFAULT_MIN_TEMP, + } + # support_auto = True + hass.states.async_set( + entity_id, + HVACMode.OFF, + base_attrs, + ) + await hass.async_block_till_done() + acc = Thermostat(hass, hk_driver, "Climate", entity_id, 1, None) + hk_driver.add_accessory(acc) + + acc.run() + await hass.async_block_till_done() + + assert acc.char_cooling_thresh_temp.value == 23.0 + assert acc.char_heating_thresh_temp.value == 19.0 + + assert acc.char_cooling_thresh_temp.properties[PROP_MAX_VALUE] == DEFAULT_MAX_TEMP + assert acc.char_cooling_thresh_temp.properties[PROP_MIN_VALUE] == 7.0 + assert acc.char_cooling_thresh_temp.properties[PROP_MIN_STEP] == 0.1 + assert acc.char_heating_thresh_temp.properties[PROP_MAX_VALUE] == DEFAULT_MAX_TEMP + assert acc.char_heating_thresh_temp.properties[PROP_MIN_VALUE] == 7.0 + assert acc.char_heating_thresh_temp.properties[PROP_MIN_STEP] == 0.1 diff --git a/tests/components/lametric/fixtures/computer_powered.json b/tests/components/lametric/fixtures/computer_powered.json new file mode 100644 index 00000000000000..0465dd4dd3a7a4 --- /dev/null +++ b/tests/components/lametric/fixtures/computer_powered.json @@ -0,0 +1,68 @@ +{ + "audio": { + "available": true, + "volume": 53, + "volume_limit": { + "max": 100, + "min": 0 + }, + "volume_range": { + "max": 100, + "min": 0 + } + }, + "bluetooth": { + "active": false, + "address": "40:F4:C9:AA:AA:AA", + "available": true, + "discoverable": true, + "mac": "40:F4:C9:AA:AA:AA", + "name": "LM8367", + "pairable": false + }, + "display": { + "brightness": 75, + "brightness_limit": { + "max": 76, + "min": 2 + }, + "brightness_mode": "manual", + "brightness_range": { + "max": 100, + "min": 0 + }, + "height": 8, + "on": true, + "screensaver": { + "enabled": true, + "modes": { + "time_based": { + "enabled": false + }, + "when_dark": { + "enabled": true + } + }, + "widget": "1_com.lametric.clock" + }, + "type": "mixed", + "width": 37 + }, + "id": "67790", + "mode": "manual", + "model": "sa8", + "name": "TIME", + "os_version": "3.1.3", + "serial_number": "SA840700836700W00BAA", + "wifi": { + "active": true, + "mac": "40:F4:C9:AA:AA:AA", + "available": true, + "encryption": "WPA", + "ssid": "My wifi", + "ip": "10.0.0.99", + "mode": "dhcp", + "netmask": "255.255.255.0", + "rssi": 78 + } +} diff --git a/tests/components/lametric/snapshots/test_diagnostics.ambr b/tests/components/lametric/snapshots/test_diagnostics.ambr index 8b8f98b5806b3d..d8f21424216ea9 100644 --- a/tests/components/lametric/snapshots/test_diagnostics.ambr +++ b/tests/components/lametric/snapshots/test_diagnostics.ambr @@ -24,7 +24,15 @@ 'device_id': '**REDACTED**', 'display': dict({ 'brightness': 100, + 'brightness_limit': dict({ + 'range_max': 100, + 'range_min': 2, + }), 'brightness_mode': 'auto', + 'brightness_range': dict({ + 'range_max': 100, + 'range_min': 0, + }), 'display_type': 'mixed', 'height': 8, 'on': None, diff --git a/tests/components/lametric/test_number.py b/tests/components/lametric/test_number.py index 681abf850d2779..811078289c1bb9 100644 --- a/tests/components/lametric/test_number.py +++ b/tests/components/lametric/test_number.py @@ -42,7 +42,7 @@ async def test_brightness( assert state.attributes.get(ATTR_DEVICE_CLASS) is None assert state.attributes.get(ATTR_FRIENDLY_NAME) == "Frenck's LaMetric Brightness" assert state.attributes.get(ATTR_MAX) == 100 - assert state.attributes.get(ATTR_MIN) == 0 + assert state.attributes.get(ATTR_MIN) == 2 assert state.attributes.get(ATTR_STEP) == 1 assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == PERCENTAGE assert state.state == "100" @@ -183,3 +183,16 @@ async def test_number_connection_error( state = hass.states.get("number.frenck_s_lametric_volume") assert state assert state.state == STATE_UNAVAILABLE + + +@pytest.mark.parametrize("device_fixture", ["computer_powered"]) +async def test_computer_powered_devices( + hass: HomeAssistant, + mock_lametric: MagicMock, +) -> None: + """Test Brightness is properly limited for computer powered devices.""" + state = hass.states.get("number.time_brightness") + assert state + assert state.state == "75" + assert state.attributes[ATTR_MIN] == 2 + assert state.attributes[ATTR_MAX] == 76 diff --git a/tests/components/mqtt/test_number.py b/tests/components/mqtt/test_number.py index 48aaa11f672178..7bdd39e81a711f 100644 --- a/tests/components/mqtt/test_number.py +++ b/tests/components/mqtt/test_number.py @@ -29,6 +29,7 @@ UnitOfTemperature, ) from homeassistant.core import HomeAssistant, State +from homeassistant.util.unit_system import US_CUSTOMARY_SYSTEM from .test_common import ( help_custom_config, @@ -157,6 +158,101 @@ async def test_run_number_setup( assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == unit_of_measurement +@pytest.mark.parametrize( + "hass_config", + [ + { + mqtt.DOMAIN: { + number.DOMAIN: { + "state_topic": "test/state_number", + "command_topic": "test/cmd_number", + "name": "Test Number", + "min": 15, + "max": 28, + "device_class": "temperature", + "unit_of_measurement": UnitOfTemperature.CELSIUS.value, + } + } + } + ], +) +async def test_native_value_validation( + hass: HomeAssistant, + mqtt_mock_entry: MqttMockHAClientGenerator, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test state validation and native value conversion.""" + mqtt_mock = await mqtt_mock_entry() + + async_fire_mqtt_message(hass, "test/state_number", "23.5") + state = hass.states.get("number.test_number") + assert state is not None + assert state.attributes.get(ATTR_MIN) == 15 + assert state.attributes.get(ATTR_MAX) == 28 + assert ( + state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) + == UnitOfTemperature.CELSIUS.value + ) + assert state.state == "23.5" + + # Test out of range validation + async_fire_mqtt_message(hass, "test/state_number", "29.5") + state = hass.states.get("number.test_number") + assert state is not None + assert state.attributes.get(ATTR_MIN) == 15 + assert state.attributes.get(ATTR_MAX) == 28 + assert ( + state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) + == UnitOfTemperature.CELSIUS.value + ) + assert state.state == "23.5" + assert ( + "Invalid value for number.test_number: 29.5 (range 15.0 - 28.0)" in caplog.text + ) + caplog.clear() + + # Check if validation still works when changing unit system + hass.config.units = US_CUSTOMARY_SYSTEM + await hass.async_block_till_done() + + async_fire_mqtt_message(hass, "test/state_number", "24.5") + state = hass.states.get("number.test_number") + assert state is not None + assert state.attributes.get(ATTR_MIN) == 59.0 + assert state.attributes.get(ATTR_MAX) == 82.4 + assert ( + state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) + == UnitOfTemperature.FAHRENHEIT.value + ) + assert state.state == "76.1" + + # Test out of range validation again + async_fire_mqtt_message(hass, "test/state_number", "29.5") + state = hass.states.get("number.test_number") + assert state is not None + assert state.attributes.get(ATTR_MIN) == 59.0 + assert state.attributes.get(ATTR_MAX) == 82.4 + assert ( + state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) + == UnitOfTemperature.FAHRENHEIT.value + ) + assert state.state == "76.1" + assert ( + "Invalid value for number.test_number: 29.5 (range 15.0 - 28.0)" in caplog.text + ) + caplog.clear() + + await hass.services.async_call( + NUMBER_DOMAIN, + SERVICE_SET_VALUE, + {ATTR_ENTITY_ID: "number.test_number", ATTR_VALUE: 68}, + blocking=True, + ) + + mqtt_mock.async_publish.assert_called_once_with("test/cmd_number", "20", 0, False) + mqtt_mock.async_publish.reset_mock() + + @pytest.mark.parametrize( "hass_config", [ diff --git a/tests/components/niko_home_control/test_light.py b/tests/components/niko_home_control/test_light.py index a61cc5204f6899..865e1303cb0b75 100644 --- a/tests/components/niko_home_control/test_light.py +++ b/tests/components/niko_home_control/test_light.py @@ -42,11 +42,11 @@ async def test_entities( @pytest.mark.parametrize( ("light_id", "data", "set_brightness"), [ - (0, {ATTR_ENTITY_ID: "light.light"}, 100.0), + (0, {ATTR_ENTITY_ID: "light.light"}, 100), ( 1, {ATTR_ENTITY_ID: "light.dimmable_light", ATTR_BRIGHTNESS: 50}, - 19.607843137254903, + 20, ), ], ) diff --git a/tests/components/rfxtrx/test_config_flow.py b/tests/components/rfxtrx/test_config_flow.py index 1e23bdaf9820a6..5957319306bab1 100644 --- a/tests/components/rfxtrx/test_config_flow.py +++ b/tests/components/rfxtrx/test_config_flow.py @@ -726,7 +726,6 @@ async def test_options_add_and_configure_device( result["flow_id"], user_input={ "data_bits": 4, - "off_delay": "abcdef", "command_on": "xyz", "command_off": "xyz", }, @@ -735,7 +734,6 @@ async def test_options_add_and_configure_device( assert result["type"] is FlowResultType.FORM assert result["step_id"] == "set_device_options" assert result["errors"] - assert result["errors"]["off_delay"] == "invalid_input_off_delay" assert result["errors"]["command_on"] == "invalid_input_2262_on" assert result["errors"]["command_off"] == "invalid_input_2262_off" @@ -745,7 +743,7 @@ async def test_options_add_and_configure_device( "data_bits": 4, "command_on": "0xE", "command_off": "0x7", - "off_delay": "9", + "off_delay": 9, }, ) diff --git a/tests/components/smartthings/test_config_flow.py b/tests/components/smartthings/test_config_flow.py index 3621e58bc3d614..05ddc3a71de6aa 100644 --- a/tests/components/smartthings/test_config_flow.py +++ b/tests/components/smartthings/test_config_flow.py @@ -14,6 +14,7 @@ CONF_APP_ID, CONF_INSTALLED_APP_ID, CONF_LOCATION_ID, + CONF_REFRESH_TOKEN, DOMAIN, ) from homeassistant.const import CONF_ACCESS_TOKEN, CONF_CLIENT_ID, CONF_CLIENT_SECRET @@ -757,3 +758,56 @@ async def test_no_available_locations_aborts( ) assert result["type"] is FlowResultType.ABORT assert result["reason"] == "no_available_locations" + + +async def test_reauth( + hass: HomeAssistant, app, app_oauth_client, location, smartthings_mock +) -> None: + """Test reauth flow.""" + token = str(uuid4()) + installed_app_id = str(uuid4()) + refresh_token = str(uuid4()) + smartthings_mock.apps.return_value = [] + smartthings_mock.create_app.return_value = (app, app_oauth_client) + smartthings_mock.locations.return_value = [location] + request = Mock() + request.installed_app_id = installed_app_id + request.auth_token = token + request.location_id = location.location_id + request.refresh_token = refresh_token + + entry = MockConfigEntry( + domain=DOMAIN, + data={ + CONF_APP_ID: app.app_id, + CONF_CLIENT_ID: app_oauth_client.client_id, + CONF_CLIENT_SECRET: app_oauth_client.client_secret, + CONF_LOCATION_ID: location.location_id, + CONF_INSTALLED_APP_ID: installed_app_id, + CONF_ACCESS_TOKEN: token, + CONF_REFRESH_TOKEN: "abc", + }, + unique_id=smartapp.format_unique_id(app.app_id, location.location_id), + ) + entry.add_to_hass(hass) + result = await entry.start_reauth_flow(hass) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "reauth_confirm" + + result = await hass.config_entries.flow.async_configure(result["flow_id"], {}) + assert result["type"] is FlowResultType.EXTERNAL_STEP + assert result["step_id"] == "authorize" + assert result["url"] == format_install_url(app.app_id, location.location_id) + + await smartapp.smartapp_update(hass, request, None, app) + + result = await hass.config_entries.flow.async_configure(result["flow_id"]) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "update_confirm" + + result = await hass.config_entries.flow.async_configure(result["flow_id"], {}) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reauth_successful" + + assert entry.data[CONF_REFRESH_TOKEN] == refresh_token diff --git a/tests/components/smartthings/test_init.py b/tests/components/smartthings/test_init.py index e518f84aecb8c8..83372b5822865e 100644 --- a/tests/components/smartthings/test_init.py +++ b/tests/components/smartthings/test_init.py @@ -23,6 +23,7 @@ PLATFORMS, SIGNAL_SMARTTHINGS_UPDATE, ) +from homeassistant.config_entries import ConfigEntryState from homeassistant.core import HomeAssistant from homeassistant.core_config import async_process_ha_core_config from homeassistant.exceptions import ConfigEntryNotReady @@ -68,17 +69,10 @@ async def test_unrecoverable_api_errors_create_new_flow( ) # Assert setup returns false - result = await smartthings.async_setup_entry(hass, config_entry) + result = await hass.config_entries.async_setup(config_entry.entry_id) assert not result - # Assert entry was removed and new flow created - await hass.async_block_till_done() - assert not hass.config_entries.async_entries(DOMAIN) - flows = hass.config_entries.flow.async_progress() - assert len(flows) == 1 - assert flows[0]["handler"] == "smartthings" - assert flows[0]["context"] == {"source": config_entries.SOURCE_IMPORT} - hass.config_entries.flow.async_abort(flows[0]["flow_id"]) + assert config_entry.state == ConfigEntryState.SETUP_ERROR async def test_recoverable_api_errors_raise_not_ready( diff --git a/tests/components/watergate/snapshots/test_sensor.ambr b/tests/components/watergate/snapshots/test_sensor.ambr index 479a879a583592..a58c7c0eab8d6a 100644 --- a/tests/components/watergate/snapshots/test_sensor.ambr +++ b/tests/components/watergate/snapshots/test_sensor.ambr @@ -43,7 +43,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '2021-01-09T11:59:59+00:00', + 'state': '2021-01-09T11:59:58+00:00', }) # --- # name: test_sensor[sensor.sonic_power_supply_mode-entry] @@ -501,6 +501,6 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '2021-01-09T11:59:59+00:00', + 'state': '2021-01-09T11:59:57+00:00', }) # --- diff --git a/tests/components/watergate/test_sensor.py b/tests/components/watergate/test_sensor.py index 58632c7548bc72..78e375857ed69b 100644 --- a/tests/components/watergate/test_sensor.py +++ b/tests/components/watergate/test_sensor.py @@ -140,11 +140,11 @@ async def test_power_supply_webhook( power_supply_change_data = { "type": "power-supply-changed", - "data": {"supply": "external"}, + "data": {"supply": "external_battery"}, } client = await hass_client_no_auth() await client.post(f"/api/webhook/{MOCK_WEBHOOK_ID}", json=power_supply_change_data) await hass.async_block_till_done() - assert hass.states.get(entity_id).state == "external" + assert hass.states.get(entity_id).state == "battery_external" diff --git a/tests/components/withings/test_init.py b/tests/components/withings/test_init.py index e07e1f90cb46cf..d88af39488b2d1 100644 --- a/tests/components/withings/test_init.py +++ b/tests/components/withings/test_init.py @@ -10,6 +10,7 @@ from aiowithings import ( NotificationCategory, WithingsAuthenticationFailedError, + WithingsConnectionError, WithingsUnauthorizedError, ) from freezegun.api import FrozenDateTimeFactory @@ -532,6 +533,59 @@ async def test_cloud_disconnect_retry( assert mock_async_active_subscription.call_count == 4 +async def test_internet_timeout_then_restore( + hass: HomeAssistant, + withings: AsyncMock, + webhook_config_entry: MockConfigEntry, + hass_client_no_auth: ClientSessionGenerator, + freezer: FrozenDateTimeFactory, +) -> None: + """Test we can recover from internet disconnects.""" + await mock_cloud(hass) + await hass.async_block_till_done() + + with ( + patch("homeassistant.components.cloud.async_is_logged_in", return_value=True), + patch.object(cloud, "async_is_connected", return_value=True), + patch.object(cloud, "async_active_subscription", return_value=True), + patch( + "homeassistant.components.cloud.async_create_cloudhook", + return_value="https://hooks.nabu.casa/ABCD", + ), + patch( + "homeassistant.components.withings.async_get_config_entry_implementation", + ), + patch( + "homeassistant.components.cloud.async_delete_cloudhook", + ), + patch( + "homeassistant.components.withings.webhook_generate_url", + ), + ): + await setup_integration(hass, webhook_config_entry) + await prepare_webhook_setup(hass, freezer) + + assert cloud.async_active_subscription(hass) is True + assert cloud.async_is_connected(hass) is True + assert withings.revoke_notification_configurations.call_count == 3 + assert withings.subscribe_notification.call_count == 6 + + await hass.async_block_till_done() + + withings.list_notification_configurations.side_effect = WithingsConnectionError + + async_mock_cloud_connection_status(hass, False) + await hass.async_block_till_done() + + assert withings.revoke_notification_configurations.call_count == 3 + withings.list_notification_configurations.side_effect = None + + async_mock_cloud_connection_status(hass, True) + await hass.async_block_till_done() + + assert withings.subscribe_notification.call_count == 12 + + @pytest.mark.parametrize( ("body", "expected_code"), [ diff --git a/tests/helpers/test_config_entry_flow.py b/tests/helpers/test_config_entry_flow.py index 13e28bb8840bcb..172aa393538768 100644 --- a/tests/helpers/test_config_entry_flow.py +++ b/tests/helpers/test_config_entry_flow.py @@ -1,6 +1,8 @@ """Tests for the Config Entry Flow helper.""" -from collections.abc import Generator +import asyncio +from collections.abc import Callable, Generator +from contextlib import contextmanager from unittest.mock import Mock, PropertyMock, patch import pytest @@ -13,20 +15,42 @@ from tests.common import MockConfigEntry, MockModule, mock_integration, mock_platform +@contextmanager +def _make_discovery_flow_conf( + has_discovered_devices: Callable[[], asyncio.Future[bool] | bool], +) -> Generator[None]: + with patch.dict(config_entries.HANDLERS): + config_entry_flow.register_discovery_flow( + "test", "Test", has_discovered_devices + ) + yield + + @pytest.fixture -def discovery_flow_conf(hass: HomeAssistant) -> Generator[dict[str, bool]]: - """Register a handler.""" +def async_discovery_flow_conf(hass: HomeAssistant) -> Generator[dict[str, bool]]: + """Register a handler with an async discovery function.""" handler_conf = {"discovered": False} async def has_discovered_devices(hass: HomeAssistant) -> bool: """Mock if we have discovered devices.""" return handler_conf["discovered"] - with patch.dict(config_entries.HANDLERS): - config_entry_flow.register_discovery_flow( - "test", "Test", has_discovered_devices - ) + with _make_discovery_flow_conf(has_discovered_devices): + yield handler_conf + + +@pytest.fixture +def discovery_flow_conf(hass: HomeAssistant) -> Generator[dict[str, bool]]: + """Register a handler with a async friendly callback function.""" + handler_conf = {"discovered": False} + + def has_discovered_devices(hass: HomeAssistant) -> bool: + """Mock if we have discovered devices.""" + return handler_conf["discovered"] + + with _make_discovery_flow_conf(has_discovered_devices): yield handler_conf + handler_conf = {"discovered": False} @pytest.fixture @@ -95,6 +119,33 @@ async def test_user_has_confirmation( assert result["type"] == data_entry_flow.FlowResultType.CREATE_ENTRY +async def test_user_has_confirmation_async_discovery_flow( + hass: HomeAssistant, async_discovery_flow_conf: dict[str, bool] +) -> None: + """Test user requires confirmation to setup with an async has_discovered_devices.""" + async_discovery_flow_conf["discovered"] = True + mock_platform(hass, "test.config_flow", None) + + result = await hass.config_entries.flow.async_init( + "test", context={"source": config_entries.SOURCE_USER}, data={} + ) + + assert result["type"] == data_entry_flow.FlowResultType.FORM + assert result["step_id"] == "confirm" + + progress = hass.config_entries.flow.async_progress() + assert len(progress) == 1 + assert progress[0]["flow_id"] == result["flow_id"] + assert progress[0]["context"] == { + "confirm_only": True, + "source": config_entries.SOURCE_USER, + "unique_id": "test", + } + + result = await hass.config_entries.flow.async_configure(result["flow_id"], {}) + assert result["type"] == data_entry_flow.FlowResultType.CREATE_ENTRY + + @pytest.mark.parametrize( "source", [ diff --git a/tests/helpers/test_script.py b/tests/helpers/test_script.py index c438e333ae630a..d7c00e90bd6c41 100644 --- a/tests/helpers/test_script.py +++ b/tests/helpers/test_script.py @@ -4118,6 +4118,14 @@ async def test_referenced_labels(hass: HomeAssistant) -> None: } ], }, + { + "sequence": [ + { + "action": "test.script", + "data": {"label_id": "label_sequence"}, + } + ], + }, ] ), "Test Name", @@ -4135,6 +4143,7 @@ async def test_referenced_labels(hass: HomeAssistant) -> None: "label_if_then", "label_if_else", "label_parallel", + "label_sequence", } # Test we cache results. assert script_obj.referenced_labels is script_obj.referenced_labels @@ -4220,6 +4229,14 @@ async def test_referenced_floors(hass: HomeAssistant) -> None: } ], }, + { + "sequence": [ + { + "action": "test.script", + "data": {"floor_id": "floor_sequence"}, + } + ], + }, ] ), "Test Name", @@ -4236,6 +4253,7 @@ async def test_referenced_floors(hass: HomeAssistant) -> None: "floor_if_then", "floor_if_else", "floor_parallel", + "floor_sequence", } # Test we cache results. assert script_obj.referenced_floors is script_obj.referenced_floors @@ -4321,6 +4339,14 @@ async def test_referenced_areas(hass: HomeAssistant) -> None: } ], }, + { + "sequence": [ + { + "action": "test.script", + "data": {"area_id": "area_sequence"}, + } + ], + }, ] ), "Test Name", @@ -4337,6 +4363,7 @@ async def test_referenced_areas(hass: HomeAssistant) -> None: "area_if_then", "area_if_else", "area_parallel", + "area_sequence", # 'area_service_template', # no area extraction from template } # Test we cache results. @@ -4437,6 +4464,14 @@ async def test_referenced_entities(hass: HomeAssistant) -> None: } ], }, + { + "sequence": [ + { + "action": "test.script", + "data": {"entity_id": "light.sequence"}, + } + ], + }, ] ), "Test Name", @@ -4456,6 +4491,7 @@ async def test_referenced_entities(hass: HomeAssistant) -> None: "light.if_then", "light.if_else", "light.parallel", + "light.sequence", # "light.service_template", # no entity extraction from template "scene.hello", "sensor.condition", @@ -4554,6 +4590,14 @@ async def test_referenced_devices(hass: HomeAssistant) -> None: } ], }, + { + "sequence": [ + { + "action": "test.script", + "target": {"device_id": "sequence-device"}, + } + ], + }, ] ), "Test Name", @@ -4575,6 +4619,7 @@ async def test_referenced_devices(hass: HomeAssistant) -> None: "if-then", "if-else", "parallel-device", + "sequence-device", } # Test we cache results. assert script_obj.referenced_devices is script_obj.referenced_devices