diff --git a/homeassistant/components/ai_task/task.py b/homeassistant/components/ai_task/task.py index 372ac650add040..5cd57395d9dbf2 100644 --- a/homeassistant/components/ai_task/task.py +++ b/homeassistant/components/ai_task/task.py @@ -19,7 +19,6 @@ from homeassistant.helpers import llm from homeassistant.helpers.chat_session import ChatSession, async_get_chat_session from homeassistant.helpers.event import async_call_later -from homeassistant.helpers.network import get_url from homeassistant.util import RE_SANITIZE_FILENAME, slugify from .const import ( @@ -249,7 +248,7 @@ def _purge_image(filename: str, now: datetime) -> None: if IMAGE_EXPIRY_TIME > 0: async_call_later(hass, IMAGE_EXPIRY_TIME, partial(_purge_image, filename)) - service_result["url"] = get_url(hass) + async_sign_path( + service_result["url"] = async_sign_path( hass, f"/api/{DOMAIN}/images/{filename}", timedelta(seconds=IMAGE_EXPIRY_TIME or 1800), diff --git a/homeassistant/components/cloud/manifest.json b/homeassistant/components/cloud/manifest.json index 43cdf17740a8ad..0625054869d885 100644 --- a/homeassistant/components/cloud/manifest.json +++ b/homeassistant/components/cloud/manifest.json @@ -13,6 +13,6 @@ "integration_type": "system", "iot_class": "cloud_push", "loggers": ["acme", "hass_nabucasa", "snitun"], - "requirements": ["hass-nabucasa==1.1.0"], + "requirements": ["hass-nabucasa==1.1.1"], "single_config_entry": true } diff --git a/homeassistant/components/fritz/strings.json b/homeassistant/components/fritz/strings.json index 87ee28196ad1eb..5ff8dd37d332b0 100644 --- a/homeassistant/components/fritz/strings.json +++ b/homeassistant/components/fritz/strings.json @@ -176,7 +176,7 @@ "name": "Max connection upload throughput" }, "cpu_temperature": { - "name": "CPU Temperature" + "name": "CPU temperature" } } }, diff --git a/homeassistant/components/google_generative_ai_conversation/entity.py b/homeassistant/components/google_generative_ai_conversation/entity.py index cbb493e29b8830..45ef4aad2d44d1 100644 --- a/homeassistant/components/google_generative_ai_conversation/entity.py +++ b/homeassistant/components/google_generative_ai_conversation/entity.py @@ -3,12 +3,13 @@ from __future__ import annotations import asyncio +import base64 import codecs from collections.abc import AsyncGenerator, AsyncIterator, Callable -from dataclasses import replace +from dataclasses import dataclass, replace import mimetypes from pathlib import Path -from typing import TYPE_CHECKING, Any, cast +from typing import TYPE_CHECKING, Any, Literal, cast from google.genai import Client from google.genai.errors import APIError, ClientError @@ -27,6 +28,7 @@ PartUnionDict, SafetySetting, Schema, + ThinkingConfig, Tool, ToolListUnion, ) @@ -201,6 +203,30 @@ def _create_google_tool_response_content( ) +@dataclass(slots=True) +class PartDetails: + """Additional data for a content part.""" + + part_type: Literal["text", "thought", "function_call"] + """The part type for which this data is relevant for.""" + + index: int + """Start position or number of the tool.""" + + length: int = 0 + """Length of the relevant data.""" + + thought_signature: str | None = None + """Base64 encoded thought signature, if available.""" + + +@dataclass(slots=True) +class ContentDetails: + """Native data for AssistantContent.""" + + part_details: list[PartDetails] + + def _convert_content( content: ( conversation.UserContent @@ -209,32 +235,91 @@ def _convert_content( ), ) -> Content: """Convert HA content to Google content.""" - if content.role != "assistant" or not content.tool_calls: - role = "model" if content.role == "assistant" else content.role + if content.role != "assistant": return Content( - role=role, - parts=[ - Part.from_text(text=content.content if content.content else ""), - ], + role=content.role, + parts=[Part.from_text(text=content.content if content.content else "")], ) # Handle the Assistant content with tool calls. assert type(content) is conversation.AssistantContent parts: list[Part] = [] + part_details: list[PartDetails] = ( + content.native.part_details + if isinstance(content.native, ContentDetails) + else [] + ) + details: PartDetails | None = None if content.content: - parts.append(Part.from_text(text=content.content)) + index = 0 + for details in part_details: + if details.part_type == "text": + if index < details.index: + parts.append( + Part.from_text(text=content.content[index : details.index]) + ) + index = details.index + parts.append( + Part.from_text( + text=content.content[index : index + details.length], + ) + ) + if details.thought_signature: + parts[-1].thought_signature = base64.b64decode( + details.thought_signature + ) + index += details.length + if index < len(content.content): + parts.append(Part.from_text(text=content.content[index:])) + + if content.thinking_content: + index = 0 + for details in part_details: + if details.part_type == "thought": + if index < details.index: + parts.append( + Part.from_text( + text=content.thinking_content[index : details.index] + ) + ) + parts[-1].thought = True + index = details.index + parts.append( + Part.from_text( + text=content.thinking_content[index : index + details.length], + ) + ) + parts[-1].thought = True + if details.thought_signature: + parts[-1].thought_signature = base64.b64decode( + details.thought_signature + ) + index += details.length + if index < len(content.thinking_content): + parts.append(Part.from_text(text=content.thinking_content[index:])) + parts[-1].thought = True if content.tool_calls: - parts.extend( - [ + for index, tool_call in enumerate(content.tool_calls): + parts.append( Part.from_function_call( name=tool_call.tool_name, args=_escape_decode(tool_call.tool_args), ) - for tool_call in content.tool_calls - ] - ) + ) + if details := next( + ( + d + for d in part_details + if d.part_type == "function_call" and d.index == index + ), + None, + ): + if details.thought_signature: + parts[-1].thought_signature = base64.b64decode( + details.thought_signature + ) return Content(role="model", parts=parts) @@ -243,14 +328,20 @@ async def _transform_stream( result: AsyncIterator[GenerateContentResponse], ) -> AsyncGenerator[conversation.AssistantContentDeltaDict]: new_message = True + part_details: list[PartDetails] = [] try: async for response in result: LOGGER.debug("Received response chunk: %s", response) - chunk: conversation.AssistantContentDeltaDict = {} if new_message: - chunk["role"] = "assistant" + if part_details: + yield {"native": ContentDetails(part_details=part_details)} + part_details = [] + yield {"role": "assistant"} new_message = False + content_index = 0 + thinking_content_index = 0 + tool_call_index = 0 # According to the API docs, this would mean no candidate is returned, so we can safely throw an error here. if response.prompt_feedback or not response.candidates: @@ -284,23 +375,62 @@ async def _transform_stream( else [] ) - content = "".join([part.text for part in response_parts if part.text]) - tool_calls = [] for part in response_parts: - if not part.function_call: - continue - tool_call = part.function_call - tool_name = tool_call.name if tool_call.name else "" - tool_args = _escape_decode(tool_call.args) - tool_calls.append( - llm.ToolInput(tool_name=tool_name, tool_args=tool_args) - ) + chunk: conversation.AssistantContentDeltaDict = {} + + if part.text: + if part.thought: + chunk["thinking_content"] = part.text + if part.thought_signature: + part_details.append( + PartDetails( + part_type="thought", + index=thinking_content_index, + length=len(part.text), + thought_signature=base64.b64encode( + part.thought_signature + ).decode("utf-8"), + ) + ) + thinking_content_index += len(part.text) + else: + chunk["content"] = part.text + if part.thought_signature: + part_details.append( + PartDetails( + part_type="text", + index=content_index, + length=len(part.text), + thought_signature=base64.b64encode( + part.thought_signature + ).decode("utf-8"), + ) + ) + content_index += len(part.text) + + if part.function_call: + tool_call = part.function_call + tool_name = tool_call.name if tool_call.name else "" + tool_args = _escape_decode(tool_call.args) + chunk["tool_calls"] = [ + llm.ToolInput(tool_name=tool_name, tool_args=tool_args) + ] + if part.thought_signature: + part_details.append( + PartDetails( + part_type="function_call", + index=tool_call_index, + thought_signature=base64.b64encode( + part.thought_signature + ).decode("utf-8"), + ) + ) + + yield chunk - if tool_calls: - chunk["tool_calls"] = tool_calls + if part_details: + yield {"native": ContentDetails(part_details=part_details)} - chunk["content"] = content - yield chunk except ( APIError, ValueError, @@ -522,6 +652,7 @@ def create_generate_content_config(self) -> GenerateContentConfig: ), ), ], + thinking_config=ThinkingConfig(include_thoughts=True), ) diff --git a/homeassistant/components/homeassistant/exposed_entities.py b/homeassistant/components/homeassistant/exposed_entities.py index b7e420dedde522..135e6cdd3763be 100644 --- a/homeassistant/components/homeassistant/exposed_entities.py +++ b/homeassistant/components/homeassistant/exposed_entities.py @@ -406,7 +406,7 @@ def ws_expose_entity( hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg: dict[str, Any] ) -> None: """Expose an entity to an assistant.""" - entity_ids: str = msg["entity_ids"] + entity_ids: list[str] = msg["entity_ids"] if blocked := next( ( diff --git a/homeassistant/components/knx/light.py b/homeassistant/components/knx/light.py index 1ab6883a4371c7..bd54e5f75d98c3 100644 --- a/homeassistant/components/knx/light.py +++ b/homeassistant/components/knx/light.py @@ -285,13 +285,19 @@ def _create_ui_light(xknx: XKNX, knx_config: ConfigType, name: str) -> XknxLight group_address_switch_green_state=conf.get_state_and_passive( CONF_COLOR, CONF_GA_GREEN_SWITCH ), - group_address_brightness_green=conf.get_write(CONF_GA_GREEN_BRIGHTNESS), + group_address_brightness_green=conf.get_write( + CONF_COLOR, CONF_GA_GREEN_BRIGHTNESS + ), group_address_brightness_green_state=conf.get_state_and_passive( CONF_COLOR, CONF_GA_GREEN_BRIGHTNESS ), - group_address_switch_blue=conf.get_write(CONF_GA_BLUE_SWITCH), - group_address_switch_blue_state=conf.get_state_and_passive(CONF_GA_BLUE_SWITCH), - group_address_brightness_blue=conf.get_write(CONF_GA_BLUE_BRIGHTNESS), + group_address_switch_blue=conf.get_write(CONF_COLOR, CONF_GA_BLUE_SWITCH), + group_address_switch_blue_state=conf.get_state_and_passive( + CONF_COLOR, CONF_GA_BLUE_SWITCH + ), + group_address_brightness_blue=conf.get_write( + CONF_COLOR, CONF_GA_BLUE_BRIGHTNESS + ), group_address_brightness_blue_state=conf.get_state_and_passive( CONF_COLOR, CONF_GA_BLUE_BRIGHTNESS ), diff --git a/homeassistant/components/knx/storage/entity_store_schema.py b/homeassistant/components/knx/storage/entity_store_schema.py index fe0dbf31b6bb70..21252e35f3a7ab 100644 --- a/homeassistant/components/knx/storage/entity_store_schema.py +++ b/homeassistant/components/knx/storage/entity_store_schema.py @@ -240,19 +240,19 @@ class LightColorMode(StrEnum): write_required=True, valid_dpt="5.001" ), "section_blue": KNXSectionFlat(), - vol.Required(CONF_GA_BLUE_BRIGHTNESS): GASelector( - write_required=True, valid_dpt="5.001" - ), vol.Optional(CONF_GA_BLUE_SWITCH): GASelector( write_required=False, valid_dpt="1" ), - "section_white": KNXSectionFlat(), - vol.Optional(CONF_GA_WHITE_BRIGHTNESS): GASelector( + vol.Required(CONF_GA_BLUE_BRIGHTNESS): GASelector( write_required=True, valid_dpt="5.001" ), + "section_white": KNXSectionFlat(), vol.Optional(CONF_GA_WHITE_SWITCH): GASelector( write_required=False, valid_dpt="1" ), + vol.Optional(CONF_GA_WHITE_BRIGHTNESS): GASelector( + write_required=True, valid_dpt="5.001" + ), }, ), GroupSelectOption( diff --git a/homeassistant/components/met/coordinator.py b/homeassistant/components/met/coordinator.py index 8b6243d9dafe90..b2c43cb1361a65 100644 --- a/homeassistant/components/met/coordinator.py +++ b/homeassistant/components/met/coordinator.py @@ -83,7 +83,9 @@ async def fetch_data(self) -> Self: self.current_weather_data = self._weather_data.get_current_weather() time_zone = dt_util.get_default_time_zone() self.daily_forecast = self._weather_data.get_forecast(time_zone, False, 0) - self.hourly_forecast = self._weather_data.get_forecast(time_zone, True) + self.hourly_forecast = self._weather_data.get_forecast( + time_zone, True, range_stop=49 + ) return self diff --git a/homeassistant/components/miele/sensor.py b/homeassistant/components/miele/sensor.py index 8e4b903d0b3fb4..0c157e42656bd1 100644 --- a/homeassistant/components/miele/sensor.py +++ b/homeassistant/components/miele/sensor.py @@ -270,6 +270,7 @@ class MieleSensorDefinition: device_class=SensorDeviceClass.ENERGY, state_class=SensorStateClass.TOTAL_INCREASING, native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, + suggested_display_precision=1, entity_category=EntityCategory.DIAGNOSTIC, ), ), @@ -307,6 +308,7 @@ class MieleSensorDefinition: device_class=SensorDeviceClass.WATER, state_class=SensorStateClass.TOTAL_INCREASING, native_unit_of_measurement=UnitOfVolume.LITERS, + suggested_display_precision=0, entity_category=EntityCategory.DIAGNOSTIC, ), ), @@ -618,6 +620,8 @@ def _get_entity_class(definition: MieleSensorDefinition) -> type[MieleSensor]: "state_elapsed_time": MieleTimeSensor, "state_remaining_time": MieleTimeSensor, "state_start_time": MieleTimeSensor, + "current_energy_consumption": MieleConsumptionSensor, + "current_water_consumption": MieleConsumptionSensor, }.get(definition.description.key, MieleSensor) def _is_entity_registered(unique_id: str) -> bool: @@ -924,3 +928,58 @@ def _update_last_value(self) -> None: # otherwise, cache value and return it else: self._last_value = current_value + + +class MieleConsumptionSensor(MieleRestorableSensor): + """Representation of consumption sensors keeping state from cache.""" + + _is_reporting: bool = False + + def _update_last_value(self) -> None: + """Update the last value of the sensor.""" + current_value = self.entity_description.value_fn(self.device) + current_status = StateStatus(self.device.state_status) + last_value = ( + float(cast(str, self._last_value)) + if self._last_value is not None and self._last_value != STATE_UNKNOWN + else 0 + ) + + # force unknown when appliance is not able to report consumption + if current_status in ( + StateStatus.ON, + StateStatus.OFF, + StateStatus.PROGRAMMED, + StateStatus.WAITING_TO_START, + StateStatus.IDLE, + StateStatus.SERVICE, + ): + self._is_reporting = False + self._last_value = None + + # appliance might report the last value for consumption of previous cycle and it will report 0 + # only after a while, so it is necessary to force 0 until we see the 0 value coming from API, unless + # we already saw a valid value in this cycle from cache + elif ( + current_status in (StateStatus.IN_USE, StateStatus.PAUSE) + and not self._is_reporting + and last_value > 0 + ): + self._last_value = current_value + self._is_reporting = True + + elif ( + current_status in (StateStatus.IN_USE, StateStatus.PAUSE) + and not self._is_reporting + and current_value is not None + and cast(int, current_value) > 0 + ): + self._last_value = 0 + + # keep value when program ends + elif current_status == StateStatus.PROGRAM_ENDED: + pass + + else: + self._last_value = current_value + self._is_reporting = True diff --git a/homeassistant/components/ntfy/event.py b/homeassistant/components/ntfy/event.py index 8075e051ba4e8c..ecb081f0beb1a6 100644 --- a/homeassistant/components/ntfy/event.py +++ b/homeassistant/components/ntfy/event.py @@ -146,20 +146,20 @@ async def ws_connect(self) -> None: ) self._attr_available = False finally: - if self._ws is None or self._ws.done(): - self._ws = self.config_entry.async_create_background_task( - self.hass, - target=self.ntfy.subscribe( - topics=[self.topic], - callback=self._async_handle_event, - title=self.subentry.data.get(CONF_TITLE), - message=self.subentry.data.get(CONF_MESSAGE), - priority=self.subentry.data.get(CONF_PRIORITY), - tags=self.subentry.data.get(CONF_TAGS), - ), - name="ntfy_websocket", - ) self.async_write_ha_state() + if self._ws is None or self._ws.done(): + self._ws = self.config_entry.async_create_background_task( + self.hass, + target=self.ntfy.subscribe( + topics=[self.topic], + callback=self._async_handle_event, + title=self.subentry.data.get(CONF_TITLE), + message=self.subentry.data.get(CONF_MESSAGE), + priority=self.subentry.data.get(CONF_PRIORITY), + tags=self.subentry.data.get(CONF_TAGS), + ), + name="ntfy_websocket", + ) await asyncio.sleep(RECONNECT_INTERVAL) @property diff --git a/homeassistant/components/number/const.py b/homeassistant/components/number/const.py index a9333212fa4566..9a227102ad755d 100644 --- a/homeassistant/components/number/const.py +++ b/homeassistant/components/number/const.py @@ -328,6 +328,7 @@ class NumberDeviceClass(StrEnum): - `Pa`, `hPa`, `kPa` - `inHg` - `psi` + - `inH₂O` """ REACTIVE_ENERGY = "reactive_energy" diff --git a/homeassistant/components/openweathermap/weather.py b/homeassistant/components/openweathermap/weather.py index f182b083b90681..56f44fa46fb7ec 100644 --- a/homeassistant/components/openweathermap/weather.py +++ b/homeassistant/components/openweathermap/weather.py @@ -181,7 +181,7 @@ def wind_bearing(self) -> float | str | None: return self.coordinator.data[ATTR_API_CURRENT].get(ATTR_API_WIND_BEARING) @property - def visibility(self) -> float | str | None: + def native_visibility(self) -> float | None: """Return visibility.""" return self.coordinator.data[ATTR_API_CURRENT].get(ATTR_API_VISIBILITY_DISTANCE) diff --git a/homeassistant/components/reolink/strings.json b/homeassistant/components/reolink/strings.json index b0a54c1dd5d44a..cdb10b7c687a62 100644 --- a/homeassistant/components/reolink/strings.json +++ b/homeassistant/components/reolink/strings.json @@ -50,7 +50,7 @@ "protocol": "Protocol" }, "data_description": { - "protocol": "Streaming protocol to use for the camera entities. RTSP supports 4K streams (h265 encoding) while RTMP and FLV do not. FLV is the least demanding on the camera." + "protocol": "Streaming protocol to use for the camera entities. RTSP supports 4K streams (H.265 encoding) while RTMP and FLV do not. FLV is the least demanding on the camera." } } } diff --git a/homeassistant/components/sensor/const.py b/homeassistant/components/sensor/const.py index 12d9595d059859..7d21b68019d030 100644 --- a/homeassistant/components/sensor/const.py +++ b/homeassistant/components/sensor/const.py @@ -361,6 +361,7 @@ class SensorDeviceClass(StrEnum): - `Pa`, `hPa`, `kPa` - `inHg` - `psi` + - `inH₂O` """ REACTIVE_ENERGY = "reactive_energy" diff --git a/homeassistant/components/switchbot/__init__.py b/homeassistant/components/switchbot/__init__.py index 08df5dc50f0850..f5e587f0d9c65c 100644 --- a/homeassistant/components/switchbot/__init__.py +++ b/homeassistant/components/switchbot/__init__.py @@ -97,6 +97,7 @@ SupportedModels.STRIP_LIGHT_3.value: [Platform.LIGHT, Platform.SENSOR], SupportedModels.RGBICWW_FLOOR_LAMP.value: [Platform.LIGHT, Platform.SENSOR], SupportedModels.RGBICWW_STRIP_LIGHT.value: [Platform.LIGHT, Platform.SENSOR], + SupportedModels.PLUG_MINI_EU.value: [Platform.SWITCH, Platform.SENSOR], } CLASS_BY_DEVICE = { SupportedModels.CEILING_LIGHT.value: switchbot.SwitchbotCeilingLight, @@ -127,6 +128,7 @@ SupportedModels.STRIP_LIGHT_3.value: switchbot.SwitchbotStripLight3, SupportedModels.RGBICWW_FLOOR_LAMP.value: switchbot.SwitchbotRgbicLight, SupportedModels.RGBICWW_STRIP_LIGHT.value: switchbot.SwitchbotRgbicLight, + SupportedModels.PLUG_MINI_EU.value: switchbot.SwitchbotRelaySwitch, } diff --git a/homeassistant/components/switchbot/config_flow.py b/homeassistant/components/switchbot/config_flow.py index b207440d796c39..5c856bc216c841 100644 --- a/homeassistant/components/switchbot/config_flow.py +++ b/homeassistant/components/switchbot/config_flow.py @@ -11,12 +11,15 @@ SwitchbotApiError, SwitchbotAuthenticationError, SwitchbotModel, + fetch_cloud_devices, parse_advertisement_data, ) import voluptuous as vol from homeassistant.components.bluetooth import ( + BluetoothScanningMode, BluetoothServiceInfoBleak, + async_current_scanners, async_discovered_service_info, ) from homeassistant.config_entries import ( @@ -87,6 +90,8 @@ def __init__(self) -> None: """Initialize the config flow.""" self._discovered_adv: SwitchBotAdvertisement | None = None self._discovered_advs: dict[str, SwitchBotAdvertisement] = {} + self._cloud_username: str | None = None + self._cloud_password: str | None = None async def async_step_bluetooth( self, discovery_info: BluetoothServiceInfoBleak @@ -176,9 +181,17 @@ async def async_step_encrypted_auth( self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: """Handle the SwitchBot API auth step.""" - errors = {} + errors: dict[str, str] = {} assert self._discovered_adv is not None - description_placeholders = {} + description_placeholders: dict[str, str] = {} + + # If we have saved credentials from cloud login, try them first + if user_input is None and self._cloud_username and self._cloud_password: + user_input = { + CONF_USERNAME: self._cloud_username, + CONF_PASSWORD: self._cloud_password, + } + if user_input is not None: model: SwitchbotModel = self._discovered_adv.data["modelName"] cls = ENCRYPTED_SWITCHBOT_MODEL_TO_CLASS[model] @@ -200,6 +213,9 @@ async def async_step_encrypted_auth( _LOGGER.debug("Authentication failed: %s", ex, exc_info=True) errors = {"base": "auth_failed"} description_placeholders = {"error_detail": str(ex)} + # Clear saved credentials if auth failed + self._cloud_username = None + self._cloud_password = None else: return await self.async_step_encrypted_key(key_details) @@ -239,7 +255,7 @@ async def async_step_encrypted_key( self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: """Handle the encryption key step.""" - errors = {} + errors: dict[str, str] = {} assert self._discovered_adv is not None if user_input is not None: model: SwitchbotModel = self._discovered_adv.data["modelName"] @@ -308,7 +324,73 @@ async def _async_set_device(self, discovery: SwitchBotAdvertisement) -> None: async def async_step_user( self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: - """Handle the user step to pick discovered device.""" + """Handle the user step to choose cloud login or direct discovery.""" + # Check if all scanners are in active mode + # If so, skip the menu and go directly to device selection + scanners = async_current_scanners(self.hass) + if scanners and all( + scanner.current_mode == BluetoothScanningMode.ACTIVE for scanner in scanners + ): + # All scanners are active, skip the menu + return await self.async_step_select_device() + + return self.async_show_menu( + step_id="user", + menu_options=["cloud_login", "select_device"], + ) + + async def async_step_cloud_login( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle the cloud login step.""" + errors: dict[str, str] = {} + description_placeholders: dict[str, str] = {} + + if user_input is not None: + try: + await fetch_cloud_devices( + async_get_clientsession(self.hass), + user_input[CONF_USERNAME], + user_input[CONF_PASSWORD], + ) + except (SwitchbotApiError, SwitchbotAccountConnectionError) as ex: + _LOGGER.debug( + "Failed to connect to SwitchBot API: %s", ex, exc_info=True + ) + raise AbortFlow( + "api_error", description_placeholders={"error_detail": str(ex)} + ) from ex + except SwitchbotAuthenticationError as ex: + _LOGGER.debug("Authentication failed: %s", ex, exc_info=True) + errors = {"base": "auth_failed"} + description_placeholders = {"error_detail": str(ex)} + else: + # Save credentials temporarily for the duration of this flow + # to avoid re-prompting if encrypted device auth is needed + # These will be discarded when the flow completes + self._cloud_username = user_input[CONF_USERNAME] + self._cloud_password = user_input[CONF_PASSWORD] + return await self.async_step_select_device() + + user_input = user_input or {} + return self.async_show_form( + step_id="cloud_login", + errors=errors, + data_schema=vol.Schema( + { + vol.Required( + CONF_USERNAME, default=user_input.get(CONF_USERNAME) + ): str, + vol.Required(CONF_PASSWORD): str, + } + ), + description_placeholders=description_placeholders, + ) + + async def async_step_select_device( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle the step to pick discovered device.""" errors: dict[str, str] = {} device_adv: SwitchBotAdvertisement | None = None if user_input is not None: @@ -333,7 +415,7 @@ async def async_step_user( return await self.async_step_confirm() return self.async_show_form( - step_id="user", + step_id="select_device", data_schema=vol.Schema( { vol.Required(CONF_ADDRESS): vol.In( diff --git a/homeassistant/components/switchbot/const.py b/homeassistant/components/switchbot/const.py index 5cdb3d9dd4e637..549a602c3ff569 100644 --- a/homeassistant/components/switchbot/const.py +++ b/homeassistant/components/switchbot/const.py @@ -53,6 +53,7 @@ class SupportedModels(StrEnum): STRIP_LIGHT_3 = "strip_light_3" RGBICWW_STRIP_LIGHT = "rgbicww_strip_light" RGBICWW_FLOOR_LAMP = "rgbicww_floor_lamp" + PLUG_MINI_EU = "plug_mini_eu" CONNECTABLE_SUPPORTED_MODEL_TYPES = { @@ -85,6 +86,7 @@ class SupportedModels(StrEnum): SwitchbotModel.STRIP_LIGHT_3: SupportedModels.STRIP_LIGHT_3, SwitchbotModel.RGBICWW_STRIP_LIGHT: SupportedModels.RGBICWW_STRIP_LIGHT, SwitchbotModel.RGBICWW_FLOOR_LAMP: SupportedModels.RGBICWW_FLOOR_LAMP, + SwitchbotModel.PLUG_MINI_EU: SupportedModels.PLUG_MINI_EU, } NON_CONNECTABLE_SUPPORTED_MODEL_TYPES = { @@ -118,6 +120,7 @@ class SupportedModels(StrEnum): SwitchbotModel.STRIP_LIGHT_3, SwitchbotModel.RGBICWW_STRIP_LIGHT, SwitchbotModel.RGBICWW_FLOOR_LAMP, + SwitchbotModel.PLUG_MINI_EU, } ENCRYPTED_SWITCHBOT_MODEL_TO_CLASS: dict[ @@ -136,6 +139,7 @@ class SupportedModels(StrEnum): SwitchbotModel.STRIP_LIGHT_3: switchbot.SwitchbotStripLight3, SwitchbotModel.RGBICWW_STRIP_LIGHT: switchbot.SwitchbotRgbicLight, SwitchbotModel.RGBICWW_FLOOR_LAMP: switchbot.SwitchbotRgbicLight, + SwitchbotModel.PLUG_MINI_EU: switchbot.SwitchbotRelaySwitch, } HASS_SENSOR_TYPE_TO_SWITCHBOT_MODEL = { diff --git a/homeassistant/components/switchbot/strings.json b/homeassistant/components/switchbot/strings.json index 961204ee88d9e6..b2e2d2dc4b19fe 100644 --- a/homeassistant/components/switchbot/strings.json +++ b/homeassistant/components/switchbot/strings.json @@ -3,6 +3,24 @@ "flow_title": "{name} ({address})", "step": { "user": { + "description": "One or more of your Bluetooth adapters is using passive scanning, which may not discover all SwitchBot devices. Would you like to sign in to your SwitchBot account to download device information and automate discovery? If you're not sure, we recommend signing in.", + "menu_options": { + "cloud_login": "Sign in to SwitchBot account", + "select_device": "Continue without signing in" + } + }, + "cloud_login": { + "description": "Please provide your SwitchBot app username and password. This data won't be saved and is only used to retrieve device model information to automate discovery. Usernames and passwords are case-sensitive.", + "data": { + "username": "[%key:common::config_flow::data::username%]", + "password": "[%key:common::config_flow::data::password%]" + }, + "data_description": { + "username": "[%key:component::switchbot::config::step::encrypted_auth::data_description::username%]", + "password": "[%key:component::switchbot::config::step::encrypted_auth::data_description::password%]" + } + }, + "select_device": { "data": { "address": "MAC address" }, diff --git a/homeassistant/components/switchbot_cloud/__init__.py b/homeassistant/components/switchbot_cloud/__init__.py index 0beba5dfc14d50..536273df28f26f 100644 --- a/homeassistant/components/switchbot_cloud/__init__.py +++ b/homeassistant/components/switchbot_cloud/__init__.py @@ -31,6 +31,7 @@ Platform.CLIMATE, Platform.COVER, Platform.FAN, + Platform.HUMIDIFIER, Platform.LIGHT, Platform.LOCK, Platform.SENSOR, @@ -57,6 +58,7 @@ class SwitchbotDevices: locks: list[tuple[Device, SwitchBotCoordinator]] = field(default_factory=list) fans: list[tuple[Device, SwitchBotCoordinator]] = field(default_factory=list) lights: list[tuple[Device, SwitchBotCoordinator]] = field(default_factory=list) + humidifiers: list[tuple[Device, SwitchBotCoordinator]] = field(default_factory=list) @dataclass @@ -255,6 +257,19 @@ async def make_device_data( ) devices_data.lights.append((device, coordinator)) + if isinstance(device, Device) and device.device_type == "Humidifier2": + coordinator = await coordinator_for_device( + hass, entry, api, device, coordinators_by_id + ) + devices_data.humidifiers.append((device, coordinator)) + + if isinstance(device, Device) and device.device_type == "Humidifier": + coordinator = await coordinator_for_device( + hass, entry, api, device, coordinators_by_id + ) + devices_data.humidifiers.append((device, coordinator)) + devices_data.sensors.append((device, coordinator)) + async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up SwitchBot via API from a config entry.""" diff --git a/homeassistant/components/switchbot_cloud/const.py b/homeassistant/components/switchbot_cloud/const.py index 23a212075c4ec4..4f70e5f594b2a3 100644 --- a/homeassistant/components/switchbot_cloud/const.py +++ b/homeassistant/components/switchbot_cloud/const.py @@ -20,6 +20,12 @@ AFTER_COMMAND_REFRESH = 5 COVER_ENTITY_AFTER_COMMAND_REFRESH = 10 +HUMIDITY_LEVELS = { + 34: 101, # Low humidity mode + 67: 102, # Medium humidity mode + 100: 103, # High humidity mode +} + class AirPurifierMode(Enum): """Air Purifier Modes.""" @@ -33,3 +39,21 @@ class AirPurifierMode(Enum): def get_modes(cls) -> list[str]: """Return a list of available air purifier modes as lowercase strings.""" return [mode.name.lower() for mode in cls] + + +class Humidifier2Mode(Enum): + """Enumerates the available modes for a SwitchBot humidifier2.""" + + HIGH = 1 + MEDIUM = 2 + LOW = 3 + QUIET = 4 + TARGET_HUMIDITY = 5 + SLEEP = 6 + AUTO = 7 + DRYING_FILTER = 8 + + @classmethod + def get_modes(cls) -> list[str]: + """Return a list of available humidifier2 modes as lowercase strings.""" + return [mode.name.lower() for mode in cls] diff --git a/homeassistant/components/switchbot_cloud/humidifier.py b/homeassistant/components/switchbot_cloud/humidifier.py new file mode 100644 index 00000000000000..dc4824bd890102 --- /dev/null +++ b/homeassistant/components/switchbot_cloud/humidifier.py @@ -0,0 +1,155 @@ +"""Support for Switchbot humidifier.""" + +import asyncio +from typing import Any + +from switchbot_api import CommonCommands, HumidifierCommands, HumidifierV2Commands + +from homeassistant.components.humidifier import ( + MODE_AUTO, + MODE_NORMAL, + HumidifierDeviceClass, + HumidifierEntity, + HumidifierEntityFeature, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import STATE_ON +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from . import SwitchbotCloudData +from .const import AFTER_COMMAND_REFRESH, DOMAIN, HUMIDITY_LEVELS, Humidifier2Mode +from .entity import SwitchBotCloudEntity + +PARALLEL_UPDATES = 0 + + +async def async_setup_entry( + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up Switchbot based on a config entry.""" + data: SwitchbotCloudData = hass.data[DOMAIN][entry.entry_id] + async_add_entities( + SwitchBotHumidifier(data.api, device, coordinator) + if device.device_type == "Humidifier" + else SwitchBotEvaporativeHumidifier(data.api, device, coordinator) + for device, coordinator in data.devices.humidifiers + ) + + +class SwitchBotHumidifier(SwitchBotCloudEntity, HumidifierEntity): + """Representation of a Switchbot humidifier.""" + + _attr_supported_features = HumidifierEntityFeature.MODES + _attr_device_class = HumidifierDeviceClass.HUMIDIFIER + _attr_available_modes = [MODE_NORMAL, MODE_AUTO] + _attr_min_humidity = 1 + _attr_translation_key = "humidifier" + _attr_name = None + _attr_target_humidity = 50 + + def _set_attributes(self) -> None: + """Set attributes from coordinator data.""" + if coord_data := self.coordinator.data: + self._attr_is_on = coord_data.get("power") == STATE_ON + self._attr_mode = MODE_AUTO if coord_data.get("auto") else MODE_NORMAL + self._attr_current_humidity = coord_data.get("humidity") + + async def async_set_humidity(self, humidity: int) -> None: + """Set new target humidity.""" + self.target_humidity, parameters = self._map_humidity_to_supported_level( + humidity + ) + await self.send_api_command( + HumidifierCommands.SET_MODE, parameters=str(parameters) + ) + await asyncio.sleep(AFTER_COMMAND_REFRESH) + await self.coordinator.async_request_refresh() + + async def async_set_mode(self, mode: str) -> None: + """Set new target humidity.""" + if mode == MODE_AUTO: + await self.send_api_command(HumidifierCommands.SET_MODE, parameters=mode) + else: + await self.send_api_command( + HumidifierCommands.SET_MODE, parameters=str(102) + ) + await asyncio.sleep(AFTER_COMMAND_REFRESH) + await self.coordinator.async_request_refresh() + + async def async_turn_on(self, **kwargs: Any) -> None: + """Turn the device on.""" + await self.send_api_command(CommonCommands.ON) + await asyncio.sleep(AFTER_COMMAND_REFRESH) + await self.coordinator.async_request_refresh() + + async def async_turn_off(self, **kwargs: Any) -> None: + """Turn the device off.""" + await self.send_api_command(CommonCommands.OFF) + await asyncio.sleep(AFTER_COMMAND_REFRESH) + await self.coordinator.async_request_refresh() + + def _map_humidity_to_supported_level(self, humidity: int) -> tuple[int, int]: + """Map any humidity to the closest supported level and its parameter.""" + if humidity <= 34: + return 34, HUMIDITY_LEVELS[34] + if humidity <= 67: + return 67, HUMIDITY_LEVELS[67] + return 100, HUMIDITY_LEVELS[100] + + +class SwitchBotEvaporativeHumidifier(SwitchBotCloudEntity, HumidifierEntity): + """Representation of a Switchbot humidifier v2.""" + + _attr_supported_features = HumidifierEntityFeature.MODES + _attr_device_class = HumidifierDeviceClass.HUMIDIFIER + _attr_available_modes = Humidifier2Mode.get_modes() + _attr_translation_key = "evaporative_humidifier" + _attr_name = None + _attr_target_humidity = 50 + + def _set_attributes(self) -> None: + """Set attributes from coordinator data.""" + if coord_data := self.coordinator.data: + self._attr_is_on = coord_data.get("power") == STATE_ON + self._attr_mode = ( + Humidifier2Mode(coord_data.get("mode")).name.lower() + if coord_data.get("mode") is not None + else None + ) + self._attr_current_humidity = ( + coord_data.get("humidity") + if coord_data.get("humidity") != 127 + else None + ) + + async def async_set_humidity(self, humidity: int) -> None: + """Set new target humidity.""" + assert self.coordinator.data is not None + self._attr_target_humidity = humidity + params = {"mode": self.coordinator.data["mode"], "humidity": humidity} + await self.send_api_command(HumidifierV2Commands.SET_MODE, parameters=params) + await asyncio.sleep(AFTER_COMMAND_REFRESH) + await self.coordinator.async_request_refresh() + + async def async_set_mode(self, mode: str) -> None: + """Set new target mode.""" + assert self.coordinator.data is not None + params = {"mode": Humidifier2Mode[mode.upper()].value} + await self.send_api_command(HumidifierV2Commands.SET_MODE, parameters=params) + await asyncio.sleep(AFTER_COMMAND_REFRESH) + await self.coordinator.async_request_refresh() + + async def async_turn_on(self, **kwargs: Any) -> None: + """Turn the device on.""" + await self.send_api_command(CommonCommands.ON) + await asyncio.sleep(AFTER_COMMAND_REFRESH) + await self.coordinator.async_request_refresh() + + async def async_turn_off(self, **kwargs: Any) -> None: + """Turn the device off.""" + await self.send_api_command(CommonCommands.OFF) + await asyncio.sleep(AFTER_COMMAND_REFRESH) + await self.coordinator.async_request_refresh() diff --git a/homeassistant/components/switchbot_cloud/icons.json b/homeassistant/components/switchbot_cloud/icons.json index 2a468d40a5d37c..c7624d3f83d1f6 100644 --- a/homeassistant/components/switchbot_cloud/icons.json +++ b/homeassistant/components/switchbot_cloud/icons.json @@ -34,6 +34,22 @@ "10": "mdi:brightness-7" } } + }, + "humidifier": { + "evaporative_humidifier": { + "state_attributes": { + "mode": { + "state": { + "high": "mdi:water-plus", + "medium": "mdi:water", + "low": "mdi:water-outline", + "quiet": "mdi:volume-off", + "target_humidity": "mdi:target", + "drying_filter": "mdi:water-remove" + } + } + } + } } } } diff --git a/homeassistant/components/switchbot_cloud/sensor.py b/homeassistant/components/switchbot_cloud/sensor.py index d5ff5b0e8e7293..b2d375573efebf 100644 --- a/homeassistant/components/switchbot_cloud/sensor.py +++ b/homeassistant/components/switchbot_cloud/sensor.py @@ -160,6 +160,7 @@ "Motion Sensor": (BATTERY_DESCRIPTION,), "Contact Sensor": (BATTERY_DESCRIPTION,), "Water Detector": (BATTERY_DESCRIPTION,), + "Humidifier": (TEMPERATURE_DESCRIPTION,), } diff --git a/homeassistant/components/switchbot_cloud/strings.json b/homeassistant/components/switchbot_cloud/strings.json index 7ab6ff06792217..928e2e1e01b54f 100644 --- a/homeassistant/components/switchbot_cloud/strings.json +++ b/homeassistant/components/switchbot_cloud/strings.json @@ -36,6 +36,22 @@ "light_level": { "name": "Light level" } + }, + "humidifier": { + "evaporative_humidifier": { + "state_attributes": { + "mode": { + "state": { + "high": "[%key:common::state::high%]", + "medium": "[%key:common::state::medium%]", + "low": "[%key:common::state::low%]", + "quiet": "Quiet", + "target_humidity": "Target humidity", + "drying_filter": "Drying filter" + } + } + } + } } } } diff --git a/homeassistant/components/teslemetry/icons.json b/homeassistant/components/teslemetry/icons.json index f50f5a75f70c32..46b63fc2c73370 100644 --- a/homeassistant/components/teslemetry/icons.json +++ b/homeassistant/components/teslemetry/icons.json @@ -752,6 +752,9 @@ }, "vehicle_state_valet_mode": { "default": "mdi:speedometer-slow" + }, + "guest_mode_enabled": { + "default": "mdi:account-group" } } }, diff --git a/homeassistant/components/teslemetry/strings.json b/homeassistant/components/teslemetry/strings.json index 510e2b45a02c18..b78f2d00f60ffb 100644 --- a/homeassistant/components/teslemetry/strings.json +++ b/homeassistant/components/teslemetry/strings.json @@ -1084,6 +1084,9 @@ }, "vehicle_state_valet_mode": { "name": "Valet mode" + }, + "guest_mode_enabled": { + "name": "Guest mode" } }, "update": { diff --git a/homeassistant/components/teslemetry/switch.py b/homeassistant/components/teslemetry/switch.py index aae973cf315b5c..c0ad058ee2c47b 100644 --- a/homeassistant/components/teslemetry/switch.py +++ b/homeassistant/components/teslemetry/switch.py @@ -4,7 +4,6 @@ from collections.abc import Awaitable, Callable from dataclasses import dataclass -from itertools import chain from typing import Any from tesla_fleet_api.const import AutoSeat, Scope @@ -38,6 +37,7 @@ class TeslemetrySwitchEntityDescription(SwitchEntityDescription): """Describes Teslemetry Switch entity.""" + polling: bool = False on_func: Callable[[Vehicle], Awaitable[dict[str, Any]]] off_func: Callable[[Vehicle], Awaitable[dict[str, Any]]] scopes: list[Scope] @@ -53,6 +53,7 @@ class TeslemetrySwitchEntityDescription(SwitchEntityDescription): VEHICLE_DESCRIPTIONS: tuple[TeslemetrySwitchEntityDescription, ...] = ( TeslemetrySwitchEntityDescription( key="vehicle_state_sentry_mode", + polling=True, streaming_listener=lambda vehicle, callback: vehicle.listen_SentryMode( lambda value: callback(None if value is None else value != "Off") ), @@ -62,6 +63,7 @@ class TeslemetrySwitchEntityDescription(SwitchEntityDescription): ), TeslemetrySwitchEntityDescription( key="vehicle_state_valet_mode", + polling=True, streaming_listener=lambda vehicle, value: vehicle.listen_ValetModeEnabled( value ), @@ -72,6 +74,7 @@ class TeslemetrySwitchEntityDescription(SwitchEntityDescription): ), TeslemetrySwitchEntityDescription( key="climate_state_auto_seat_climate_left", + polling=True, streaming_listener=lambda vehicle, callback: vehicle.listen_AutoSeatClimateLeft( callback ), @@ -85,6 +88,7 @@ class TeslemetrySwitchEntityDescription(SwitchEntityDescription): ), TeslemetrySwitchEntityDescription( key="climate_state_auto_seat_climate_right", + polling=True, streaming_listener=lambda vehicle, callback: vehicle.listen_AutoSeatClimateRight(callback), on_func=lambda api: api.remote_auto_seat_climate_request( @@ -97,6 +101,7 @@ class TeslemetrySwitchEntityDescription(SwitchEntityDescription): ), TeslemetrySwitchEntityDescription( key="climate_state_auto_steering_wheel_heat", + polling=True, streaming_listener=lambda vehicle, callback: vehicle.listen_HvacSteeringWheelHeatAuto(callback), on_func=lambda api: api.remote_auto_steering_wheel_heat_climate_request( @@ -109,6 +114,7 @@ class TeslemetrySwitchEntityDescription(SwitchEntityDescription): ), TeslemetrySwitchEntityDescription( key="climate_state_defrost_mode", + polling=True, streaming_listener=lambda vehicle, callback: vehicle.listen_DefrostMode( lambda value: callback(None if value is None else value != "Off") ), @@ -120,6 +126,7 @@ class TeslemetrySwitchEntityDescription(SwitchEntityDescription): ), TeslemetrySwitchEntityDescription( key="charge_state_charging_state", + polling=True, unique_id="charge_state_user_charge_enable_request", value_func=lambda state: state in {"Starting", "Charging"}, streaming_listener=lambda vehicle, callback: vehicle.listen_DetailedChargeState( @@ -131,6 +138,17 @@ class TeslemetrySwitchEntityDescription(SwitchEntityDescription): off_func=lambda api: api.charge_stop(), scopes=[Scope.VEHICLE_CMDS, Scope.VEHICLE_CHARGING_CMDS], ), + TeslemetrySwitchEntityDescription( + key="guest_mode_enabled", + polling=False, + unique_id="guest_mode_enabled", + streaming_listener=lambda vehicle, callback: vehicle.listen_GuestModeEnabled( + callback + ), + on_func=lambda api: api.guest_mode(True), + off_func=lambda api: api.guest_mode(False), + scopes=[Scope.VEHICLE_CMDS], + ), ) @@ -141,35 +159,40 @@ async def async_setup_entry( ) -> None: """Set up the Teslemetry Switch platform from a config entry.""" - async_add_entities( - chain( - ( - TeslemetryVehiclePollingVehicleSwitchEntity( - vehicle, description, entry.runtime_data.scopes + entities: list[SwitchEntity] = [] + + for vehicle in entry.runtime_data.vehicles: + for description in VEHICLE_DESCRIPTIONS: + if vehicle.poll or vehicle.firmware < description.streaming_firmware: + if description.polling: + entities.append( + TeslemetryVehiclePollingVehicleSwitchEntity( + vehicle, description, entry.runtime_data.scopes + ) + ) + else: + entities.append( + TeslemetryStreamingVehicleSwitchEntity( + vehicle, description, entry.runtime_data.scopes + ) ) - if vehicle.poll or vehicle.firmware < description.streaming_firmware - else TeslemetryStreamingVehicleSwitchEntity( - vehicle, description, entry.runtime_data.scopes - ) - for vehicle in entry.runtime_data.vehicles - for description in VEHICLE_DESCRIPTIONS - ), - ( - TeslemetryChargeFromGridSwitchEntity( - energysite, - entry.runtime_data.scopes, - ) - for energysite in entry.runtime_data.energysites - if energysite.info_coordinator.data.get("components_battery") - and energysite.info_coordinator.data.get("components_solar") - ), - ( - TeslemetryStormModeSwitchEntity(energysite, entry.runtime_data.scopes) - for energysite in entry.runtime_data.energysites - if energysite.info_coordinator.data.get("components_storm_mode_capable") - ), + + entities.extend( + TeslemetryChargeFromGridSwitchEntity( + energysite, + entry.runtime_data.scopes, ) + for energysite in entry.runtime_data.energysites + if energysite.info_coordinator.data.get("components_battery") + and energysite.info_coordinator.data.get("components_solar") ) + entities.extend( + TeslemetryStormModeSwitchEntity(energysite, entry.runtime_data.scopes) + for energysite in entry.runtime_data.energysites + if energysite.info_coordinator.data.get("components_storm_mode_capable") + ) + + async_add_entities(entities) class TeslemetryVehicleSwitchEntity(TeslemetryRootEntity, SwitchEntity): diff --git a/homeassistant/const.py b/homeassistant/const.py index d61945e2ef82cd..913ef5e177f89a 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -749,6 +749,7 @@ class UnitOfPressure(StrEnum): MBAR = "mbar" MMHG = "mmHg" INHG = "inHg" + INH2O = "inH₂O" PSI = "psi" diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 8ac50fa3a22d22..dee918c3f66cec 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -35,7 +35,7 @@ fnv-hash-fast==1.5.0 go2rtc-client==0.2.1 ha-ffmpeg==3.2.2 habluetooth==5.6.2 -hass-nabucasa==1.1.0 +hass-nabucasa==1.1.1 hassil==3.2.0 home-assistant-bluetooth==1.13.1 home-assistant-frontend==20250903.3 diff --git a/homeassistant/util/unit_conversion.py b/homeassistant/util/unit_conversion.py index 1bd40a12d3d960..5502163472def6 100644 --- a/homeassistant/util/unit_conversion.py +++ b/homeassistant/util/unit_conversion.py @@ -82,6 +82,7 @@ # Pressure conversion constants _STANDARD_GRAVITY = 9.80665 _MERCURY_DENSITY = 13.5951 +_INH2O_TO_PA = 249.0889083333348 # 1 inH₂O = 249.0889083333348 Pa at 4°C # Volume conversion constants _L_TO_CUBIC_METER = 0.001 # 1 L = 0.001 m³ @@ -435,6 +436,7 @@ class PressureConverter(BaseUnitConverter): UnitOfPressure.MBAR: 1 / 100, UnitOfPressure.INHG: 1 / (_IN_TO_M * 1000 * _STANDARD_GRAVITY * _MERCURY_DENSITY), + UnitOfPressure.INH2O: 1 / _INH2O_TO_PA, UnitOfPressure.PSI: 1 / 6894.757, UnitOfPressure.MMHG: 1 / (_MM_TO_M * 1000 * _STANDARD_GRAVITY * _MERCURY_DENSITY), @@ -447,6 +449,7 @@ class PressureConverter(BaseUnitConverter): UnitOfPressure.CBAR, UnitOfPressure.MBAR, UnitOfPressure.INHG, + UnitOfPressure.INH2O, UnitOfPressure.PSI, UnitOfPressure.MMHG, } diff --git a/homeassistant/util/unit_system.py b/homeassistant/util/unit_system.py index 934cd6d4b693c5..d86beb8b7e7cca 100644 --- a/homeassistant/util/unit_system.py +++ b/homeassistant/util/unit_system.py @@ -296,6 +296,7 @@ def _deprecated_unit_system(value: str) -> str: # Convert non-metric pressure ("pressure", UnitOfPressure.PSI): UnitOfPressure.KPA, ("pressure", UnitOfPressure.INHG): UnitOfPressure.HPA, + ("pressure", UnitOfPressure.INH2O): UnitOfPressure.KPA, # Convert non-metric speeds except knots to km/h ("speed", UnitOfSpeed.FEET_PER_SECOND): UnitOfSpeed.KILOMETERS_PER_HOUR, ("speed", UnitOfSpeed.INCHES_PER_SECOND): UnitOfSpeed.MILLIMETERS_PER_SECOND, @@ -379,6 +380,7 @@ def _deprecated_unit_system(value: str) -> str: ("pressure", UnitOfPressure.HPA): UnitOfPressure.PSI, ("pressure", UnitOfPressure.KPA): UnitOfPressure.PSI, ("pressure", UnitOfPressure.MMHG): UnitOfPressure.INHG, + ("pressure", UnitOfPressure.INH2O): UnitOfPressure.PSI, # Convert non-USCS speeds, except knots, to mph ("speed", UnitOfSpeed.METERS_PER_SECOND): UnitOfSpeed.MILES_PER_HOUR, ("speed", UnitOfSpeed.MILLIMETERS_PER_SECOND): UnitOfSpeed.INCHES_PER_SECOND, diff --git a/pyproject.toml b/pyproject.toml index eefeb59d5a3c94..007cda7fad47c1 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -47,7 +47,7 @@ dependencies = [ "fnv-hash-fast==1.5.0", # hass-nabucasa is imported by helpers which don't depend on the cloud # integration - "hass-nabucasa==1.1.0", + "hass-nabucasa==1.1.1", # When bumping httpx, please check the version pins of # httpcore, anyio, and h11 in gen_requirements_all "httpx==0.28.1", diff --git a/requirements.txt b/requirements.txt index 05d4cc0fc928a6..8ba1d7be73634a 100644 --- a/requirements.txt +++ b/requirements.txt @@ -22,7 +22,7 @@ certifi>=2021.5.30 ciso8601==2.3.3 cronsim==2.6 fnv-hash-fast==1.5.0 -hass-nabucasa==1.1.0 +hass-nabucasa==1.1.1 httpx==0.28.1 home-assistant-bluetooth==1.13.1 ifaddr==0.2.0 diff --git a/requirements_all.txt b/requirements_all.txt index ac5102e60184d1..d875524f13e0c6 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1140,7 +1140,7 @@ habiticalib==0.4.5 habluetooth==5.6.2 # homeassistant.components.cloud -hass-nabucasa==1.1.0 +hass-nabucasa==1.1.1 # homeassistant.components.splunk hass-splunk==0.1.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index e9f0735703e80e..531a4fee327632 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1001,7 +1001,7 @@ habiticalib==0.4.5 habluetooth==5.6.2 # homeassistant.components.cloud -hass-nabucasa==1.1.0 +hass-nabucasa==1.1.1 # homeassistant.components.assist_satellite # homeassistant.components.conversation diff --git a/tests/components/ai_task/test_task.py b/tests/components/ai_task/test_task.py index 780f44b7e50129..bc8bff4e632b33 100644 --- a/tests/components/ai_task/test_task.py +++ b/tests/components/ai_task/test_task.py @@ -286,7 +286,7 @@ async def test_generate_image( assert "image_data" not in result assert result["media_source_id"].startswith("media-source://ai_task/images/") assert result["media_source_id"].endswith("_test_task.png") - assert result["url"].startswith("http://10.10.10.10:8123/api/ai_task/images/") + assert result["url"].startswith("/api/ai_task/images/") assert result["url"].count("_test_task.png?authSig=") == 1 assert result["mime_type"] == "image/png" assert result["model"] == "mock_model" diff --git a/tests/components/fritz/snapshots/test_sensor.ambr b/tests/components/fritz/snapshots/test_sensor.ambr index ac437b28d9959a..ae3bf6d68895d7 100644 --- a/tests/components/fritz/snapshots/test_sensor.ambr +++ b/tests/components/fritz/snapshots/test_sensor.ambr @@ -855,7 +855,7 @@ }), 'original_device_class': , 'original_icon': None, - 'original_name': 'CPU Temperature', + 'original_name': 'CPU temperature', 'platform': 'fritz', 'previous_unique_id': None, 'suggested_object_id': None, @@ -869,7 +869,7 @@ StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'temperature', - 'friendly_name': 'Mock Title CPU Temperature', + 'friendly_name': 'Mock Title CPU temperature', 'state_class': , 'unit_of_measurement': , }), diff --git a/tests/components/google_generative_ai_conversation/snapshots/test_conversation.ambr b/tests/components/google_generative_ai_conversation/snapshots/test_conversation.ambr new file mode 100644 index 00000000000000..c8b1dd93be4745 --- /dev/null +++ b/tests/components/google_generative_ai_conversation/snapshots/test_conversation.ambr @@ -0,0 +1,66 @@ +# serializer version: 1 +# name: test_function_call + list([ + Content( + parts=[ + Part( + text='Please call the test function' + ), + ], + role='user' + ), + Content( + parts=[ + Part( + text='Hi there!', + thought_signature=b'_thought_signature_2' + ), + Part( + text='The user asked me to call a function', + thought=True, + thought_signature=b'_thought_signature_1' + ), + Part( + function_call=FunctionCall( + args={ + 'param1': [ + 'test_value', + "param1's value", + ], + 'param2': 2.7 + }, + name='test_tool' + ), + thought_signature=b'_thought_signature_3' + ), + ], + role='model' + ), + Content( + parts=[ + Part( + function_response=FunctionResponse( + name='test_tool', + response={ + 'result': 'Test response' + } + ) + ), + ], + role='user' + ), + Content( + parts=[ + Part( + text="I've called the ", + thought_signature=b'_thought_signature_4' + ), + Part( + text='test function with the provided parameters.', + thought_signature=b'_thought_signature_5' + ), + ], + role='model' + ), + ]) +# --- diff --git a/tests/components/google_generative_ai_conversation/test_conversation.py b/tests/components/google_generative_ai_conversation/test_conversation.py index ab8c10e933be84..9085e90f6342e2 100644 --- a/tests/components/google_generative_ai_conversation/test_conversation.py +++ b/tests/components/google_generative_ai_conversation/test_conversation.py @@ -5,6 +5,7 @@ from freezegun import freeze_time from google.genai.types import GenerateContentResponse import pytest +from syrupy.assertion import SnapshotAssertion from homeassistant.components import conversation from homeassistant.components.conversation import UserContent @@ -80,6 +81,7 @@ async def test_function_call( mock_config_entry_with_assist: MockConfigEntry, mock_chat_log: MockChatLog, # noqa: F811 mock_send_message_stream: AsyncMock, + snapshot: SnapshotAssertion, ) -> None: """Test function calling.""" agent_id = "conversation.google_ai_conversation" @@ -93,9 +95,15 @@ async def test_function_call( { "content": { "parts": [ + { + "text": "The user asked me to call a function", + "thought": True, + "thought_signature": b"_thought_signature_1", + }, { "text": "Hi there!", - } + "thought_signature": b"_thought_signature_2", + }, ], "role": "model", } @@ -118,6 +126,7 @@ async def test_function_call( "param2": 2.7, }, }, + "thought_signature": b"_thought_signature_3", } ], "role": "model", @@ -136,6 +145,7 @@ async def test_function_call( "parts": [ { "text": "I've called the ", + "thought_signature": b"_thought_signature_4", } ], "role": "model", @@ -150,6 +160,25 @@ async def test_function_call( "parts": [ { "text": "test function with the provided parameters.", + "thought_signature": b"_thought_signature_5", + } + ], + "role": "model", + }, + "finish_reason": "STOP", + } + ], + ), + ], + # Follow-up response + [ + GenerateContentResponse( + candidates=[ + { + "content": { + "parts": [ + { + "text": "You are welcome!", } ], "role": "model", @@ -205,6 +234,22 @@ async def test_function_call( "video_metadata": None, } + # Test history conversion for multi-turn conversation + with patch( + "google.genai.chats.AsyncChats.create", return_value=AsyncMock() + ) as mock_create: + mock_create.return_value.send_message_stream = mock_send_message_stream + await conversation.async_converse( + hass, + "Thank you!", + mock_chat_log.conversation_id, + context, + agent_id=agent_id, + device_id="test_device", + ) + + assert mock_create.call_args[1].get("history") == snapshot + @pytest.mark.usefixtures("mock_init_component") @pytest.mark.usefixtures("mock_ulid_tools") diff --git a/tests/components/google_generative_ai_conversation/test_tts.py b/tests/components/google_generative_ai_conversation/test_tts.py index 87fc4fe8a7689d..271a209f79f33f 100644 --- a/tests/components/google_generative_ai_conversation/test_tts.py +++ b/tests/components/google_generative_ai_conversation/test_tts.py @@ -208,6 +208,7 @@ async def test_tts_service_speak( threshold=RECOMMENDED_HARM_BLOCK_THRESHOLD, ), ], + thinking_config=types.ThinkingConfig(include_thoughts=True), ), ) @@ -276,5 +277,6 @@ async def test_tts_service_speak_error( threshold=RECOMMENDED_HARM_BLOCK_THRESHOLD, ), ], + thinking_config=types.ThinkingConfig(include_thoughts=True), ), ) diff --git a/tests/components/knx/snapshots/test_websocket.ambr b/tests/components/knx/snapshots/test_websocket.ambr index b99196c8769b0a..6dc651195aeb59 100644 --- a/tests/components/knx/snapshots/test_websocket.ambr +++ b/tests/components/knx/snapshots/test_websocket.ambr @@ -575,7 +575,8 @@ 'type': 'knx_section_flat', }), dict({ - 'name': 'ga_blue_brightness', + 'name': 'ga_blue_switch', + 'optional': True, 'options': dict({ 'passive': True, 'state': dict({ @@ -583,20 +584,19 @@ }), 'validDPTs': list([ dict({ - 'main': 5, - 'sub': 1, + 'main': 1, + 'sub': None, }), ]), 'write': dict({ - 'required': True, + 'required': False, }), }), - 'required': True, + 'required': False, 'type': 'knx_group_address', }), dict({ - 'name': 'ga_blue_switch', - 'optional': True, + 'name': 'ga_blue_brightness', 'options': dict({ 'passive': True, 'state': dict({ @@ -604,15 +604,15 @@ }), 'validDPTs': list([ dict({ - 'main': 1, - 'sub': None, + 'main': 5, + 'sub': 1, }), ]), 'write': dict({ - 'required': False, + 'required': True, }), }), - 'required': False, + 'required': True, 'type': 'knx_group_address', }), dict({ @@ -622,7 +622,7 @@ 'type': 'knx_section_flat', }), dict({ - 'name': 'ga_white_brightness', + 'name': 'ga_white_switch', 'optional': True, 'options': dict({ 'passive': True, @@ -631,19 +631,19 @@ }), 'validDPTs': list([ dict({ - 'main': 5, - 'sub': 1, + 'main': 1, + 'sub': None, }), ]), 'write': dict({ - 'required': True, + 'required': False, }), }), 'required': False, 'type': 'knx_group_address', }), dict({ - 'name': 'ga_white_switch', + 'name': 'ga_white_brightness', 'optional': True, 'options': dict({ 'passive': True, @@ -652,12 +652,12 @@ }), 'validDPTs': list([ dict({ - 'main': 1, - 'sub': None, + 'main': 5, + 'sub': 1, }), ]), 'write': dict({ - 'required': False, + 'required': True, }), }), 'required': False, diff --git a/tests/components/miele/snapshots/test_sensor.ambr b/tests/components/miele/snapshots/test_sensor.ambr index 9bb68f1d5ae9b0..641ab1759526df 100644 --- a/tests/components/miele/snapshots/test_sensor.ambr +++ b/tests/components/miele/snapshots/test_sensor.ambr @@ -3904,7 +3904,7 @@ 'name': None, 'options': dict({ 'sensor': dict({ - 'suggested_display_precision': 2, + 'suggested_display_precision': 1, }), }), 'original_device_class': , @@ -3932,7 +3932,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '0.0', + 'state': 'unknown', }) # --- # name: test_sensor_states[platforms0][sensor.washing_machine_energy_forecast-entry] @@ -4501,7 +4501,7 @@ 'name': None, 'options': dict({ 'sensor': dict({ - 'suggested_display_precision': 2, + 'suggested_display_precision': 0, }), }), 'original_device_class': , @@ -4529,7 +4529,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '0.0', + 'state': 'unknown', }) # --- # name: test_sensor_states[platforms0][sensor.washing_machine_water_forecast-entry] @@ -6050,7 +6050,7 @@ 'name': None, 'options': dict({ 'sensor': dict({ - 'suggested_display_precision': 2, + 'suggested_display_precision': 1, }), }), 'original_device_class': , @@ -6078,7 +6078,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '0.0', + 'state': 'unknown', }) # --- # name: test_sensor_states_api_push[platforms0][sensor.washing_machine_energy_forecast-entry] @@ -6647,7 +6647,7 @@ 'name': None, 'options': dict({ 'sensor': dict({ - 'suggested_display_precision': 2, + 'suggested_display_precision': 0, }), }), 'original_device_class': , @@ -6675,7 +6675,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '0.0', + 'state': 'unknown', }) # --- # name: test_sensor_states_api_push[platforms0][sensor.washing_machine_water_forecast-entry] diff --git a/tests/components/miele/test_sensor.py b/tests/components/miele/test_sensor.py index 69aacd95e62b50..d8c054683fa878 100644 --- a/tests/components/miele/test_sensor.py +++ b/tests/components/miele/test_sensor.py @@ -315,6 +315,13 @@ async def test_laundry_wash_scenario( check_sensor_state(hass, "sensor.washing_machine_remaining_time", "unknown", step) # OFF -> elapsed forced to unknown (some devices continue reporting last value of last cycle) check_sensor_state(hass, "sensor.washing_machine_elapsed_time", "unknown", step) + # consumption sensors have to report "unknown" when the device is not working + check_sensor_state( + hass, "sensor.washing_machine_energy_consumption", "unknown", step + ) + check_sensor_state( + hass, "sensor.washing_machine_water_consumption", "unknown", step + ) # Simulate program started device_fixture["DummyWasher"]["state"]["status"]["value_raw"] = 5 @@ -337,10 +344,41 @@ async def test_laundry_wash_scenario( device_fixture["DummyWasher"]["state"]["elapsedTime"][1] = 12 device_fixture["DummyWasher"]["state"]["spinningSpeed"]["value_raw"] = 1200 device_fixture["DummyWasher"]["state"]["spinningSpeed"]["value_localized"] = "1200" + device_fixture["DummyWasher"]["state"]["ecoFeedback"] = { + "currentEnergyConsumption": { + "value": 0.9, + "unit": "kWh", + }, + "currentWaterConsumption": { + "value": 52, + "unit": "l", + }, + } freezer.tick(timedelta(seconds=130)) async_fire_time_changed(hass) await hass.async_block_till_done() + + # at this point, appliance is working, but it started reporting a value from last cycle, so it is forced to 0 + check_sensor_state(hass, "sensor.washing_machine_energy_consumption", "0", step) + check_sensor_state(hass, "sensor.washing_machine_water_consumption", "0", step) + + # intermediate step, only to report new consumption values + device_fixture["DummyWasher"]["state"]["ecoFeedback"] = { + "currentEnergyConsumption": { + "value": 0.0, + "unit": "kWh", + }, + "currentWaterConsumption": { + "value": 0, + "unit": "l", + }, + } + + freezer.tick(timedelta(seconds=130)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + step += 1 check_sensor_state(hass, "sensor.washing_machine", "in_use", step) @@ -351,6 +389,28 @@ async def test_laundry_wash_scenario( # IN_USE -> elapsed, remaining time from API (normal case) check_sensor_state(hass, "sensor.washing_machine_remaining_time", "105", step) check_sensor_state(hass, "sensor.washing_machine_elapsed_time", "12", step) + check_sensor_state(hass, "sensor.washing_machine_energy_consumption", "0.0", step) + check_sensor_state(hass, "sensor.washing_machine_water_consumption", "0", step) + + # intermediate step, only to report new consumption values + device_fixture["DummyWasher"]["state"]["ecoFeedback"] = { + "currentEnergyConsumption": { + "value": 0.1, + "unit": "kWh", + }, + "currentWaterConsumption": { + "value": 7, + "unit": "l", + }, + } + + freezer.tick(timedelta(seconds=130)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + # at this point, it starts reporting value from API + check_sensor_state(hass, "sensor.washing_machine_energy_consumption", "0.1", step) + check_sensor_state(hass, "sensor.washing_machine_water_consumption", "7", step) # Simulate rinse hold phase device_fixture["DummyWasher"]["state"]["status"]["value_raw"] = 11 @@ -389,6 +449,7 @@ async def test_laundry_wash_scenario( device_fixture["DummyWasher"]["state"]["remainingTime"][1] = 0 device_fixture["DummyWasher"]["state"]["elapsedTime"][0] = 0 device_fixture["DummyWasher"]["state"]["elapsedTime"][1] = 0 + device_fixture["DummyWasher"]["state"]["ecoFeedback"] = None freezer.tick(timedelta(seconds=130)) async_fire_time_changed(hass) @@ -406,6 +467,9 @@ async def test_laundry_wash_scenario( check_sensor_state(hass, "sensor.washing_machine_remaining_time", "0", step) # PROGRAM_ENDED -> elapsed time kept from last program (some devices immediately go to 0) check_sensor_state(hass, "sensor.washing_machine_elapsed_time", "109", step) + # consumption values now are reporting last known value, API might start reporting null object + check_sensor_state(hass, "sensor.washing_machine_energy_consumption", "0.1", step) + check_sensor_state(hass, "sensor.washing_machine_water_consumption", "7", step) # Simulate when door is opened after program ended device_fixture["DummyWasher"]["state"]["status"]["value_raw"] = 3 diff --git a/tests/components/openweathermap/snapshots/test_weather.ambr b/tests/components/openweathermap/snapshots/test_weather.ambr index 073715c87ec68f..be3db7bc59434a 100644 --- a/tests/components/openweathermap/snapshots/test_weather.ambr +++ b/tests/components/openweathermap/snapshots/test_weather.ambr @@ -72,6 +72,7 @@ 'pressure_unit': , 'temperature': 6.8, 'temperature_unit': , + 'visibility': 10.0, 'visibility_unit': , 'wind_bearing': 199, 'wind_gust_speed': 42.52, @@ -136,6 +137,7 @@ 'supported_features': , 'temperature': 6.8, 'temperature_unit': , + 'visibility': 10.0, 'visibility_unit': , 'wind_bearing': 199, 'wind_gust_speed': 42.52, @@ -200,6 +202,7 @@ 'supported_features': , 'temperature': 6.8, 'temperature_unit': , + 'visibility': 10.0, 'visibility_unit': , 'wind_bearing': 199, 'wind_gust_speed': 42.52, diff --git a/tests/components/plugwise/snapshots/test_climate.ambr b/tests/components/plugwise/snapshots/test_climate.ambr new file mode 100644 index 00000000000000..0edb29fabda381 --- /dev/null +++ b/tests/components/plugwise/snapshots/test_climate.ambr @@ -0,0 +1,826 @@ +# serializer version: 1 +# name: test_adam_2_climate_snapshot[platforms0-False-m_adam_heating][climate.bathroom-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'hvac_modes': list([ + , + , + , + ]), + 'max_temp': 35.0, + 'min_temp': 0.0, + 'preset_modes': list([ + 'no_frost', + 'asleep', + 'vacation', + 'home', + 'away', + ]), + 'target_temp_step': 0.1, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'climate', + 'entity_category': None, + 'entity_id': 'climate.bathroom', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': None, + 'platform': 'plugwise', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': , + 'translation_key': 'plugwise', + 'unique_id': 'f871b8c4d63549319221e294e4f88074-climate', + 'unit_of_measurement': None, + }) +# --- +# name: test_adam_2_climate_snapshot[platforms0-False-m_adam_heating][climate.bathroom-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'current_temperature': 17.9, + 'friendly_name': 'Bathroom', + 'hvac_action': , + 'hvac_modes': list([ + , + , + , + ]), + 'max_temp': 35.0, + 'min_temp': 0.0, + 'preset_mode': 'home', + 'preset_modes': list([ + 'no_frost', + 'asleep', + 'vacation', + 'home', + 'away', + ]), + 'supported_features': , + 'target_temp_step': 0.1, + 'temperature': 15.0, + }), + 'context': , + 'entity_id': 'climate.bathroom', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'auto', + }) +# --- +# name: test_adam_2_climate_snapshot[platforms0-False-m_adam_heating][climate.living_room-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'hvac_modes': list([ + , + , + , + ]), + 'max_temp': 35.0, + 'min_temp': 1.0, + 'preset_modes': list([ + 'no_frost', + 'asleep', + 'vacation', + 'home', + 'away', + ]), + 'target_temp_step': 0.1, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'climate', + 'entity_category': None, + 'entity_id': 'climate.living_room', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': None, + 'platform': 'plugwise', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': , + 'translation_key': 'plugwise', + 'unique_id': 'f2bf9048bef64cc5b6d5110154e33c81-climate', + 'unit_of_measurement': None, + }) +# --- +# name: test_adam_2_climate_snapshot[platforms0-False-m_adam_heating][climate.living_room-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'current_temperature': 19.1, + 'friendly_name': 'Living room', + 'hvac_action': , + 'hvac_modes': list([ + , + , + , + ]), + 'max_temp': 35.0, + 'min_temp': 1.0, + 'preset_mode': 'home', + 'preset_modes': list([ + 'no_frost', + 'asleep', + 'vacation', + 'home', + 'away', + ]), + 'supported_features': , + 'target_temp_step': 0.1, + 'temperature': 20.0, + }), + 'context': , + 'entity_id': 'climate.living_room', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'heat', + }) +# --- +# name: test_adam_climate_snapshot[platforms0][climate.badkamer-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'hvac_modes': list([ + , + , + ]), + 'max_temp': 35.0, + 'min_temp': 0.0, + 'preset_modes': list([ + 'home', + 'asleep', + 'away', + 'vacation', + 'no_frost', + ]), + 'target_temp_step': 0.1, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'climate', + 'entity_category': None, + 'entity_id': 'climate.badkamer', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': None, + 'platform': 'plugwise', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': , + 'translation_key': 'plugwise', + 'unique_id': '08963fec7c53423ca5680aa4cb502c63-climate', + 'unit_of_measurement': None, + }) +# --- +# name: test_adam_climate_snapshot[platforms0][climate.badkamer-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'current_temperature': 18.9, + 'friendly_name': 'Badkamer', + 'hvac_action': , + 'hvac_modes': list([ + , + , + ]), + 'max_temp': 35.0, + 'min_temp': 0.0, + 'preset_mode': 'away', + 'preset_modes': list([ + 'home', + 'asleep', + 'away', + 'vacation', + 'no_frost', + ]), + 'supported_features': , + 'target_temp_step': 0.1, + 'temperature': 14.0, + }), + 'context': , + 'entity_id': 'climate.badkamer', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'auto', + }) +# --- +# name: test_adam_climate_snapshot[platforms0][climate.bios-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'hvac_modes': list([ + , + , + ]), + 'max_temp': 35.0, + 'min_temp': 0.0, + 'preset_modes': list([ + 'home', + 'asleep', + 'away', + 'vacation', + 'no_frost', + ]), + 'target_temp_step': 0.1, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'climate', + 'entity_category': None, + 'entity_id': 'climate.bios', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': None, + 'platform': 'plugwise', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': , + 'translation_key': 'plugwise', + 'unique_id': '12493538af164a409c6a1c79e38afe1c-climate', + 'unit_of_measurement': None, + }) +# --- +# name: test_adam_climate_snapshot[platforms0][climate.bios-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'current_temperature': 16.5, + 'friendly_name': 'Bios', + 'hvac_action': , + 'hvac_modes': list([ + , + , + ]), + 'max_temp': 35.0, + 'min_temp': 0.0, + 'preset_mode': 'away', + 'preset_modes': list([ + 'home', + 'asleep', + 'away', + 'vacation', + 'no_frost', + ]), + 'supported_features': , + 'target_temp_step': 0.1, + 'temperature': 13.0, + }), + 'context': , + 'entity_id': 'climate.bios', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'heat', + }) +# --- +# name: test_adam_climate_snapshot[platforms0][climate.garage-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'hvac_modes': list([ + , + ]), + 'max_temp': 35.0, + 'min_temp': 0.0, + 'preset_modes': list([ + 'home', + 'asleep', + 'away', + 'vacation', + 'no_frost', + ]), + 'target_temp_step': 0.1, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'climate', + 'entity_category': None, + 'entity_id': 'climate.garage', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': None, + 'platform': 'plugwise', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': , + 'translation_key': 'plugwise', + 'unique_id': '446ac08dd04d4eff8ac57489757b7314-climate', + 'unit_of_measurement': None, + }) +# --- +# name: test_adam_climate_snapshot[platforms0][climate.garage-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'current_temperature': 15.6, + 'friendly_name': 'Garage', + 'hvac_action': , + 'hvac_modes': list([ + , + ]), + 'max_temp': 35.0, + 'min_temp': 0.0, + 'preset_mode': 'no_frost', + 'preset_modes': list([ + 'home', + 'asleep', + 'away', + 'vacation', + 'no_frost', + ]), + 'supported_features': , + 'target_temp_step': 0.1, + 'temperature': 5.5, + }), + 'context': , + 'entity_id': 'climate.garage', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'heat', + }) +# --- +# name: test_adam_climate_snapshot[platforms0][climate.jessie-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'hvac_modes': list([ + , + , + ]), + 'max_temp': 35.0, + 'min_temp': 0.0, + 'preset_modes': list([ + 'home', + 'asleep', + 'away', + 'vacation', + 'no_frost', + ]), + 'target_temp_step': 0.1, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'climate', + 'entity_category': None, + 'entity_id': 'climate.jessie', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': None, + 'platform': 'plugwise', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': , + 'translation_key': 'plugwise', + 'unique_id': '82fa13f017d240daa0d0ea1775420f24-climate', + 'unit_of_measurement': None, + }) +# --- +# name: test_adam_climate_snapshot[platforms0][climate.jessie-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'current_temperature': 17.2, + 'friendly_name': 'Jessie', + 'hvac_action': , + 'hvac_modes': list([ + , + , + ]), + 'max_temp': 35.0, + 'min_temp': 0.0, + 'preset_mode': 'asleep', + 'preset_modes': list([ + 'home', + 'asleep', + 'away', + 'vacation', + 'no_frost', + ]), + 'supported_features': , + 'target_temp_step': 0.1, + 'temperature': 15.0, + }), + 'context': , + 'entity_id': 'climate.jessie', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'auto', + }) +# --- +# name: test_adam_climate_snapshot[platforms0][climate.woonkamer-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'hvac_modes': list([ + , + , + ]), + 'max_temp': 35.0, + 'min_temp': 0.0, + 'preset_modes': list([ + 'home', + 'asleep', + 'away', + 'vacation', + 'no_frost', + ]), + 'target_temp_step': 0.1, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'climate', + 'entity_category': None, + 'entity_id': 'climate.woonkamer', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': None, + 'platform': 'plugwise', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': , + 'translation_key': 'plugwise', + 'unique_id': 'c50f167537524366a5af7aa3942feb1e-climate', + 'unit_of_measurement': None, + }) +# --- +# name: test_adam_climate_snapshot[platforms0][climate.woonkamer-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'current_temperature': 20.9, + 'friendly_name': 'Woonkamer', + 'hvac_action': , + 'hvac_modes': list([ + , + , + ]), + 'max_temp': 35.0, + 'min_temp': 0.0, + 'preset_mode': 'home', + 'preset_modes': list([ + 'home', + 'asleep', + 'away', + 'vacation', + 'no_frost', + ]), + 'supported_features': , + 'target_temp_step': 0.1, + 'temperature': 21.5, + }), + 'context': , + 'entity_id': 'climate.woonkamer', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'auto', + }) +# --- +# name: test_anna_2_climate_snapshot[platforms0-True-m_anna_heatpump_cooling][climate.anna-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'hvac_modes': list([ + , + , + ]), + 'max_temp': 30.0, + 'min_temp': 4.0, + 'preset_modes': list([ + 'no_frost', + 'home', + 'away', + 'asleep', + 'vacation', + ]), + 'target_temp_step': 0.1, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'climate', + 'entity_category': None, + 'entity_id': 'climate.anna', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': None, + 'platform': 'plugwise', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': , + 'translation_key': 'plugwise', + 'unique_id': '3cb70739631c4d17a86b8b12e8a5161b-climate', + 'unit_of_measurement': None, + }) +# --- +# name: test_anna_2_climate_snapshot[platforms0-True-m_anna_heatpump_cooling][climate.anna-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'current_temperature': 26.3, + 'friendly_name': 'Anna', + 'hvac_action': , + 'hvac_modes': list([ + , + , + ]), + 'max_temp': 30.0, + 'min_temp': 4.0, + 'preset_mode': 'home', + 'preset_modes': list([ + 'no_frost', + 'home', + 'away', + 'asleep', + 'vacation', + ]), + 'supported_features': , + 'target_temp_high': 30.0, + 'target_temp_low': 20.5, + 'target_temp_step': 0.1, + }), + 'context': , + 'entity_id': 'climate.anna', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'auto', + }) +# --- +# name: test_anna_3_climate_snapshot[platforms0-True-m_anna_heatpump_idle][climate.anna-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'hvac_modes': list([ + , + , + ]), + 'max_temp': 30.0, + 'min_temp': 4.0, + 'preset_modes': list([ + 'no_frost', + 'home', + 'away', + 'asleep', + 'vacation', + ]), + 'target_temp_step': 0.1, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'climate', + 'entity_category': None, + 'entity_id': 'climate.anna', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': None, + 'platform': 'plugwise', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': , + 'translation_key': 'plugwise', + 'unique_id': '3cb70739631c4d17a86b8b12e8a5161b-climate', + 'unit_of_measurement': None, + }) +# --- +# name: test_anna_3_climate_snapshot[platforms0-True-m_anna_heatpump_idle][climate.anna-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'current_temperature': 23.0, + 'friendly_name': 'Anna', + 'hvac_action': , + 'hvac_modes': list([ + , + , + ]), + 'max_temp': 30.0, + 'min_temp': 4.0, + 'preset_mode': 'home', + 'preset_modes': list([ + 'no_frost', + 'home', + 'away', + 'asleep', + 'vacation', + ]), + 'supported_features': , + 'target_temp_high': 30.0, + 'target_temp_low': 20.5, + 'target_temp_step': 0.1, + }), + 'context': , + 'entity_id': 'climate.anna', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'auto', + }) +# --- +# name: test_anna_climate_snapshot[platforms0-True-anna_heatpump_heating][climate.anna-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'hvac_modes': list([ + , + , + ]), + 'max_temp': 30.0, + 'min_temp': 4.0, + 'preset_modes': list([ + 'no_frost', + 'home', + 'away', + 'asleep', + 'vacation', + ]), + 'target_temp_step': 0.1, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'climate', + 'entity_category': None, + 'entity_id': 'climate.anna', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': None, + 'platform': 'plugwise', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': , + 'translation_key': 'plugwise', + 'unique_id': '3cb70739631c4d17a86b8b12e8a5161b-climate', + 'unit_of_measurement': None, + }) +# --- +# name: test_anna_climate_snapshot[platforms0-True-anna_heatpump_heating][climate.anna-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'current_temperature': 19.3, + 'friendly_name': 'Anna', + 'hvac_action': , + 'hvac_modes': list([ + , + , + ]), + 'max_temp': 30.0, + 'min_temp': 4.0, + 'preset_mode': 'home', + 'preset_modes': list([ + 'no_frost', + 'home', + 'away', + 'asleep', + 'vacation', + ]), + 'supported_features': , + 'target_temp_high': 30.0, + 'target_temp_low': 20.5, + 'target_temp_step': 0.1, + }), + 'context': , + 'entity_id': 'climate.anna', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'auto', + }) +# --- diff --git a/tests/components/plugwise/test_climate.py b/tests/components/plugwise/test_climate.py index b8554f9a5cc49c..084eaa63d286c9 100644 --- a/tests/components/plugwise/test_climate.py +++ b/tests/components/plugwise/test_climate.py @@ -6,180 +6,46 @@ from freezegun.api import FrozenDateTimeFactory from plugwise.exceptions import PlugwiseError import pytest +from syrupy.assertion import SnapshotAssertion from homeassistant.components.climate import ( - ATTR_CURRENT_TEMPERATURE, ATTR_HVAC_ACTION, ATTR_HVAC_MODE, ATTR_HVAC_MODES, - ATTR_MAX_TEMP, - ATTR_MIN_TEMP, ATTR_PRESET_MODE, - ATTR_PRESET_MODES, ATTR_TARGET_TEMP_HIGH, ATTR_TARGET_TEMP_LOW, - ATTR_TARGET_TEMP_STEP, DOMAIN as CLIMATE_DOMAIN, PRESET_AWAY, - PRESET_HOME, SERVICE_SET_HVAC_MODE, SERVICE_SET_PRESET_MODE, SERVICE_SET_TEMPERATURE, HVACAction, HVACMode, ) -from homeassistant.const import ( - ATTR_ENTITY_ID, - ATTR_SUPPORTED_FEATURES, - ATTR_TEMPERATURE, -) +from homeassistant.const import ATTR_ENTITY_ID, ATTR_TEMPERATURE from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError, ServiceValidationError +from homeassistant.helpers import entity_registry as er -from tests.common import MockConfigEntry, async_fire_time_changed +from tests.common import MockConfigEntry, async_fire_time_changed, snapshot_platform HA_PLUGWISE_SMILE_ASYNC_UPDATE = ( "homeassistant.components.plugwise.coordinator.Smile.async_update" ) -async def test_adam_climate_entity_attributes( - hass: HomeAssistant, mock_smile_adam: MagicMock, init_integration: MockConfigEntry -) -> None: - """Test creation of adam climate device environment.""" - state = hass.states.get("climate.woonkamer") - assert state - assert state.state == HVACMode.AUTO - assert state.attributes[ATTR_HVAC_ACTION] == HVACAction.HEATING - assert state.attributes[ATTR_HVAC_MODES] == [HVACMode.AUTO, HVACMode.HEAT] - assert ATTR_PRESET_MODES in state.attributes - assert "no_frost" in state.attributes[ATTR_PRESET_MODES] - assert PRESET_HOME in state.attributes[ATTR_PRESET_MODES] - assert state.attributes[ATTR_PRESET_MODE] == PRESET_HOME - assert state.attributes[ATTR_CURRENT_TEMPERATURE] == 20.9 - assert state.attributes[ATTR_SUPPORTED_FEATURES] == 17 - assert state.attributes[ATTR_TEMPERATURE] == 21.5 - assert state.attributes[ATTR_MIN_TEMP] == 0.0 - assert state.attributes[ATTR_MAX_TEMP] == 35.0 - assert state.attributes[ATTR_TARGET_TEMP_STEP] == 0.1 - - state = hass.states.get("climate.jessie") - assert state - assert state.state == HVACMode.AUTO - assert state.attributes[ATTR_HVAC_ACTION] == HVACAction.IDLE - assert state.attributes[ATTR_HVAC_MODES] == [HVACMode.AUTO, HVACMode.HEAT] - assert ATTR_PRESET_MODES in state.attributes - assert "no_frost" in state.attributes[ATTR_PRESET_MODES] - assert PRESET_HOME in state.attributes[ATTR_PRESET_MODES] - assert state.attributes[ATTR_PRESET_MODE] == "asleep" - assert state.attributes[ATTR_CURRENT_TEMPERATURE] == 17.2 - assert state.attributes[ATTR_TEMPERATURE] == 15.0 - assert state.attributes[ATTR_MIN_TEMP] == 0.0 - assert state.attributes[ATTR_MAX_TEMP] == 35.0 - assert state.attributes[ATTR_TARGET_TEMP_STEP] == 0.1 - - -@pytest.mark.parametrize("chosen_env", ["m_adam_heating"], indirect=True) -@pytest.mark.parametrize("cooling_present", [False], indirect=True) -async def test_adam_2_climate_entity_attributes( - hass: HomeAssistant, - mock_smile_adam_heat_cool: MagicMock, - init_integration: MockConfigEntry, -) -> None: - """Test creation of adam climate device environment.""" - state = hass.states.get("climate.living_room") - assert state - assert state.state == HVACMode.HEAT - assert state.attributes[ATTR_HVAC_ACTION] == HVACAction.PREHEATING - assert state.attributes[ATTR_HVAC_MODES] == [ - HVACMode.OFF, - HVACMode.AUTO, - HVACMode.HEAT, - ] - - state = hass.states.get("climate.bathroom") - assert state - assert state.state == HVACMode.AUTO - assert state.attributes[ATTR_HVAC_ACTION] == HVACAction.IDLE - assert state.attributes[ATTR_HVAC_MODES] == [ - HVACMode.OFF, - HVACMode.AUTO, - HVACMode.HEAT, - ] - - -@pytest.mark.parametrize("chosen_env", ["m_adam_cooling"], indirect=True) -@pytest.mark.parametrize("cooling_present", [True], indirect=True) -async def test_adam_3_climate_entity_attributes( +@pytest.mark.parametrize("platforms", [(CLIMATE_DOMAIN,)]) +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +async def test_adam_climate_snapshot( hass: HomeAssistant, - mock_smile_adam_heat_cool: MagicMock, - init_integration: MockConfigEntry, - freezer: FrozenDateTimeFactory, -) -> None: - """Test creation of adam climate device environment.""" - state = hass.states.get("climate.living_room") - assert state - assert state.state == HVACMode.COOL - assert state.attributes[ATTR_HVAC_ACTION] == HVACAction.COOLING - assert state.attributes[ATTR_HVAC_MODES] == [ - HVACMode.OFF, - HVACMode.AUTO, - HVACMode.COOL, - ] - data = mock_smile_adam_heat_cool.async_update.return_value - data["da224107914542988a88561b4452b0f6"]["select_regulation_mode"] = "heating" - data["f2bf9048bef64cc5b6d5110154e33c81"]["control_state"] = HVACAction.HEATING - data["056ee145a816487eaa69243c3280f8bf"]["binary_sensors"]["cooling_state"] = False - data["056ee145a816487eaa69243c3280f8bf"]["binary_sensors"]["heating_state"] = True - with patch(HA_PLUGWISE_SMILE_ASYNC_UPDATE, return_value=data): - freezer.tick(timedelta(minutes=1)) - async_fire_time_changed(hass) - await hass.async_block_till_done() - - state = hass.states.get("climate.living_room") - assert state - assert state.state == HVACMode.HEAT - assert state.attributes[ATTR_HVAC_ACTION] == HVACAction.HEATING - assert state.attributes[ATTR_HVAC_MODES] == [ - HVACMode.OFF, - HVACMode.AUTO, - HVACMode.HEAT, - ] - - data = mock_smile_adam_heat_cool.async_update.return_value - data["da224107914542988a88561b4452b0f6"]["select_regulation_mode"] = "cooling" - data["f2bf9048bef64cc5b6d5110154e33c81"]["control_state"] = HVACAction.COOLING - data["056ee145a816487eaa69243c3280f8bf"]["binary_sensors"]["cooling_state"] = True - data["056ee145a816487eaa69243c3280f8bf"]["binary_sensors"]["heating_state"] = False - with patch(HA_PLUGWISE_SMILE_ASYNC_UPDATE, return_value=data): - freezer.tick(timedelta(minutes=1)) - async_fire_time_changed(hass) - await hass.async_block_till_done() - - state = hass.states.get("climate.living_room") - assert state - assert state.state == HVACMode.COOL - assert state.attributes[ATTR_HVAC_ACTION] == HVACAction.COOLING - assert state.attributes[ATTR_HVAC_MODES] == [ - HVACMode.OFF, - HVACMode.AUTO, - HVACMode.COOL, - ] - - -async def test_adam_climate_adjust_negative_testing( - hass: HomeAssistant, mock_smile_adam: MagicMock, init_integration: MockConfigEntry + mock_smile_adam: MagicMock, + snapshot: SnapshotAssertion, + entity_registry: er.EntityRegistry, + setup_platform: MockConfigEntry, ) -> None: - """Test PlugwiseError exception.""" - mock_smile_adam.set_temperature.side_effect = PlugwiseError - - with pytest.raises(HomeAssistantError): - await hass.services.async_call( - CLIMATE_DOMAIN, - SERVICE_SET_TEMPERATURE, - {ATTR_ENTITY_ID: "climate.woonkamer", ATTR_TEMPERATURE: 25}, - blocking=True, - ) + """Test Adam climate snapshot.""" + await snapshot_platform(hass, entity_registry, snapshot, setup_platform.entry_id) async def test_adam_climate_entity_climate_changes( @@ -257,6 +123,95 @@ async def test_adam_climate_entity_climate_changes( ) +async def test_adam_climate_adjust_negative_testing( + hass: HomeAssistant, mock_smile_adam: MagicMock, init_integration: MockConfigEntry +) -> None: + """Test PlugwiseError exception.""" + mock_smile_adam.set_temperature.side_effect = PlugwiseError + + with pytest.raises(HomeAssistantError): + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_TEMPERATURE, + {ATTR_ENTITY_ID: "climate.woonkamer", ATTR_TEMPERATURE: 25}, + blocking=True, + ) + + +@pytest.mark.parametrize("chosen_env", ["m_adam_heating"], indirect=True) +@pytest.mark.parametrize("cooling_present", [False], indirect=True) +@pytest.mark.parametrize("platforms", [(CLIMATE_DOMAIN,)]) +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +async def test_adam_2_climate_snapshot( + hass: HomeAssistant, + mock_smile_adam_heat_cool: MagicMock, + snapshot: SnapshotAssertion, + entity_registry: er.EntityRegistry, + setup_platform: MockConfigEntry, +) -> None: + """Test Adam 2 climate snapshot.""" + await snapshot_platform(hass, entity_registry, snapshot, setup_platform.entry_id) + + +@pytest.mark.parametrize("chosen_env", ["m_adam_cooling"], indirect=True) +@pytest.mark.parametrize("cooling_present", [True], indirect=True) +async def test_adam_3_climate_entity_attributes( + hass: HomeAssistant, + mock_smile_adam_heat_cool: MagicMock, + init_integration: MockConfigEntry, + freezer: FrozenDateTimeFactory, +) -> None: + """Test creation of adam climate device environment.""" + state = hass.states.get("climate.living_room") + assert state + assert state.state == HVACMode.COOL + assert state.attributes[ATTR_HVAC_ACTION] == HVACAction.COOLING + assert state.attributes[ATTR_HVAC_MODES] == [ + HVACMode.OFF, + HVACMode.AUTO, + HVACMode.COOL, + ] + data = mock_smile_adam_heat_cool.async_update.return_value + data["da224107914542988a88561b4452b0f6"]["select_regulation_mode"] = "heating" + data["f2bf9048bef64cc5b6d5110154e33c81"]["control_state"] = HVACAction.HEATING + data["056ee145a816487eaa69243c3280f8bf"]["binary_sensors"]["cooling_state"] = False + data["056ee145a816487eaa69243c3280f8bf"]["binary_sensors"]["heating_state"] = True + with patch(HA_PLUGWISE_SMILE_ASYNC_UPDATE, return_value=data): + freezer.tick(timedelta(minutes=1)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + state = hass.states.get("climate.living_room") + assert state + assert state.state == HVACMode.HEAT + assert state.attributes[ATTR_HVAC_ACTION] == HVACAction.HEATING + assert state.attributes[ATTR_HVAC_MODES] == [ + HVACMode.OFF, + HVACMode.AUTO, + HVACMode.HEAT, + ] + + data = mock_smile_adam_heat_cool.async_update.return_value + data["da224107914542988a88561b4452b0f6"]["select_regulation_mode"] = "cooling" + data["f2bf9048bef64cc5b6d5110154e33c81"]["control_state"] = HVACAction.COOLING + data["056ee145a816487eaa69243c3280f8bf"]["binary_sensors"]["cooling_state"] = True + data["056ee145a816487eaa69243c3280f8bf"]["binary_sensors"]["heating_state"] = False + with patch(HA_PLUGWISE_SMILE_ASYNC_UPDATE, return_value=data): + freezer.tick(timedelta(minutes=1)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + state = hass.states.get("climate.living_room") + assert state + assert state.state == HVACMode.COOL + assert state.attributes[ATTR_HVAC_ACTION] == HVACAction.COOLING + assert state.attributes[ATTR_HVAC_MODES] == [ + HVACMode.OFF, + HVACMode.AUTO, + HVACMode.COOL, + ] + + async def test_adam_climate_off_mode_change( hass: HomeAssistant, mock_smile_adam_jip: MagicMock, @@ -313,68 +268,17 @@ async def test_adam_climate_off_mode_change( @pytest.mark.parametrize("chosen_env", ["anna_heatpump_heating"], indirect=True) @pytest.mark.parametrize("cooling_present", [True], indirect=True) -async def test_anna_climate_entity_attributes( - hass: HomeAssistant, - mock_smile_anna: MagicMock, - init_integration: MockConfigEntry, -) -> None: - """Test creation of anna climate device environment.""" - state = hass.states.get("climate.anna") - assert state - assert state.state == HVACMode.AUTO - assert state.attributes[ATTR_HVAC_ACTION] == HVACAction.HEATING - assert state.attributes[ATTR_HVAC_MODES] == [HVACMode.AUTO, HVACMode.HEAT_COOL] - - assert "no_frost" in state.attributes[ATTR_PRESET_MODES] - assert PRESET_HOME in state.attributes[ATTR_PRESET_MODES] - - assert state.attributes[ATTR_CURRENT_TEMPERATURE] == 19.3 - assert state.attributes[ATTR_PRESET_MODE] == PRESET_HOME - assert state.attributes[ATTR_SUPPORTED_FEATURES] == 18 - assert state.attributes[ATTR_TARGET_TEMP_HIGH] == 30 - assert state.attributes[ATTR_TARGET_TEMP_LOW] == 20.5 - assert state.attributes[ATTR_MIN_TEMP] == 4 - assert state.attributes[ATTR_MAX_TEMP] == 30 - assert state.attributes[ATTR_TARGET_TEMP_STEP] == 0.1 - - -@pytest.mark.parametrize("chosen_env", ["m_anna_heatpump_cooling"], indirect=True) -@pytest.mark.parametrize("cooling_present", [True], indirect=True) -async def test_anna_2_climate_entity_attributes( - hass: HomeAssistant, - mock_smile_anna: MagicMock, - init_integration: MockConfigEntry, -) -> None: - """Test creation of anna climate device environment.""" - state = hass.states.get("climate.anna") - assert state - assert state.state == HVACMode.AUTO - assert state.attributes[ATTR_HVAC_ACTION] == HVACAction.COOLING - assert state.attributes[ATTR_HVAC_MODES] == [ - HVACMode.AUTO, - HVACMode.HEAT_COOL, - ] - assert state.attributes[ATTR_SUPPORTED_FEATURES] == 18 - assert state.attributes[ATTR_TARGET_TEMP_HIGH] == 30 - assert state.attributes[ATTR_TARGET_TEMP_LOW] == 20.5 - - -@pytest.mark.parametrize("chosen_env", ["m_anna_heatpump_idle"], indirect=True) -@pytest.mark.parametrize("cooling_present", [True], indirect=True) -async def test_anna_3_climate_entity_attributes( +@pytest.mark.parametrize("platforms", [(CLIMATE_DOMAIN,)]) +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +async def test_anna_climate_snapshot( hass: HomeAssistant, mock_smile_anna: MagicMock, - init_integration: MockConfigEntry, + snapshot: SnapshotAssertion, + entity_registry: er.EntityRegistry, + setup_platform: MockConfigEntry, ) -> None: - """Test creation of anna climate device environment.""" - state = hass.states.get("climate.anna") - assert state - assert state.state == HVACMode.AUTO - assert state.attributes[ATTR_HVAC_ACTION] == HVACAction.IDLE - assert state.attributes[ATTR_HVAC_MODES] == [ - HVACMode.AUTO, - HVACMode.HEAT_COOL, - ] + """Test Anna climate snapshot.""" + await snapshot_platform(hass, entity_registry, snapshot, setup_platform.entry_id) @pytest.mark.parametrize("chosen_env", ["anna_heatpump_heating"], indirect=True) @@ -446,3 +350,33 @@ async def test_anna_climate_entity_climate_changes( state = hass.states.get("climate.anna") assert state.state == HVACMode.HEAT_COOL assert state.attributes[ATTR_HVAC_MODES] == [HVACMode.HEAT_COOL] + + +@pytest.mark.parametrize("chosen_env", ["m_anna_heatpump_cooling"], indirect=True) +@pytest.mark.parametrize("cooling_present", [True], indirect=True) +@pytest.mark.parametrize("platforms", [(CLIMATE_DOMAIN,)]) +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +async def test_anna_2_climate_snapshot( + hass: HomeAssistant, + mock_smile_anna: MagicMock, + snapshot: SnapshotAssertion, + entity_registry: er.EntityRegistry, + setup_platform: MockConfigEntry, +) -> None: + """Test Anna 2 climate snapshot.""" + await snapshot_platform(hass, entity_registry, snapshot, setup_platform.entry_id) + + +@pytest.mark.parametrize("chosen_env", ["m_anna_heatpump_idle"], indirect=True) +@pytest.mark.parametrize("cooling_present", [True], indirect=True) +@pytest.mark.parametrize("platforms", [(CLIMATE_DOMAIN,)]) +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +async def test_anna_3_climate_snapshot( + hass: HomeAssistant, + mock_smile_anna: MagicMock, + snapshot: SnapshotAssertion, + entity_registry: er.EntityRegistry, + setup_platform: MockConfigEntry, +) -> None: + """Test Anna 3 climate snapshot.""" + await snapshot_platform(hass, entity_registry, snapshot, setup_platform.entry_id) diff --git a/tests/components/sensor/test_recorder.py b/tests/components/sensor/test_recorder.py index 645c4754a7bd86..df38a246a7a95c 100644 --- a/tests/components/sensor/test_recorder.py +++ b/tests/components/sensor/test_recorder.py @@ -4957,14 +4957,14 @@ def set_state(entity_id, state, **kwargs): PRESSURE_SENSOR_ATTRIBUTES, "psi", "bar", - "Pa, bar, cbar, hPa, inHg, kPa, mbar, mmHg, psi", + "Pa, bar, cbar, hPa, inHg, inH₂O, kPa, mbar, mmHg, psi", ), ( METRIC_SYSTEM, PRESSURE_SENSOR_ATTRIBUTES, "Pa", "bar", - "Pa, bar, cbar, hPa, inHg, kPa, mbar, mmHg, psi", + "Pa, bar, cbar, hPa, inHg, inH₂O, kPa, mbar, mmHg, psi", ), ], ) @@ -5175,14 +5175,14 @@ async def test_validate_statistics_unit_ignore_device_class( PRESSURE_SENSOR_ATTRIBUTES, "psi", "bar", - "Pa, bar, cbar, hPa, inHg, kPa, mbar, mmHg, psi", + "Pa, bar, cbar, hPa, inHg, inH₂O, kPa, mbar, mmHg, psi", ), ( METRIC_SYSTEM, PRESSURE_SENSOR_ATTRIBUTES, "Pa", "bar", - "Pa, bar, cbar, hPa, inHg, kPa, mbar, mmHg, psi", + "Pa, bar, cbar, hPa, inHg, inH₂O, kPa, mbar, mmHg, psi", ), ( METRIC_SYSTEM, diff --git a/tests/components/switchbot/__init__.py b/tests/components/switchbot/__init__.py index 184ec1a9ae3ae1..0cbab0f13bd369 100644 --- a/tests/components/switchbot/__init__.py +++ b/tests/components/switchbot/__init__.py @@ -1056,3 +1056,27 @@ def make_advertisement( connectable=True, tx_power=-127, ) + +PLUG_MINI_EU_SERVICE_INFO = BluetoothServiceInfoBleak( + name="Plug Mini (EU)", + manufacturer_data={ + 2409: b"\x94\xa9\x90T\x85^?\xa1\x00\x00\x04\xe6\x00\x00\x00\x00", + }, + service_data={"0000fd3d-0000-1000-8000-00805f9b34fb": b"?\x00\x00\x00"}, + service_uuids=["cba20d00-224d-11e6-9fb8-0002a5d5c51b"], + address="AA:BB:CC:DD:EE:FF", + rssi=-60, + source="local", + advertisement=generate_advertisement_data( + local_name="Plug Mini (EU)", + manufacturer_data={ + 2409: b"\x94\xa9\x90T\x85^?\xa1\x00\x00\x04\xe6\x00\x00\x00\x00", + }, + service_data={"0000fd3d-0000-1000-8000-00805f9b34fb": b"?\x00\x00\x00"}, + service_uuids=["cba20d00-224d-11e6-9fb8-0002a5d5c51b"], + ), + device=generate_ble_device("AA:BB:CC:DD:EE:FF", "Plug Mini (EU)"), + time=0, + connectable=True, + tx_power=-127, +) diff --git a/tests/components/switchbot/test_config_flow.py b/tests/components/switchbot/test_config_flow.py index 1038bd318f580b..7ad08d5a7a7e31 100644 --- a/tests/components/switchbot/test_config_flow.py +++ b/tests/components/switchbot/test_config_flow.py @@ -1,9 +1,12 @@ """Test the switchbot config flow.""" -from unittest.mock import patch +from collections.abc import Generator +from unittest.mock import Mock, patch +import pytest from switchbot import SwitchbotAccountConnectionError, SwitchbotAuthenticationError +from homeassistant.components.bluetooth import BluetoothScanningMode from homeassistant.components.switchbot.const import ( CONF_ENCRYPTION_KEY, CONF_KEY_ID, @@ -41,6 +44,30 @@ DOMAIN = "switchbot" +@pytest.fixture +def mock_scanners_all_active() -> Generator[None]: + """Mock all scanners as active mode.""" + mock_scanner = Mock() + mock_scanner.current_mode = BluetoothScanningMode.ACTIVE + with patch( + "homeassistant.components.switchbot.config_flow.async_current_scanners", + return_value=[mock_scanner], + ): + yield + + +@pytest.fixture +def mock_scanners_all_passive() -> Generator[None]: + """Mock all scanners as passive mode.""" + mock_scanner = Mock() + mock_scanner.current_mode = BluetoothScanningMode.PASSIVE + with patch( + "homeassistant.components.switchbot.config_flow.async_current_scanners", + return_value=[mock_scanner], + ): + yield + + async def test_bluetooth_discovery(hass: HomeAssistant) -> None: """Test discovery via bluetooth with a valid device.""" result = await hass.config_entries.flow.async_init( @@ -248,15 +275,23 @@ async def test_async_step_bluetooth_not_connectable(hass: HomeAssistant) -> None assert result["reason"] == "not_supported" +@pytest.mark.usefixtures("mock_scanners_all_passive") async def test_user_setup_wohand(hass: HomeAssistant) -> None: """Test the user initiated form with password and valid mac.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + assert result["type"] is FlowResultType.MENU + assert result["step_id"] == "user" + with patch( "homeassistant.components.switchbot.config_flow.async_discovered_service_info", return_value=[WOHAND_SERVICE_INFO], ): - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": SOURCE_USER} + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {"next_step_id": "select_device"}, ) assert result["type"] is FlowResultType.FORM assert result["step_id"] == "confirm" @@ -279,6 +314,7 @@ async def test_user_setup_wohand(hass: HomeAssistant) -> None: assert len(mock_setup_entry.mock_calls) == 1 +@pytest.mark.usefixtures("mock_scanners_all_passive") async def test_user_setup_wohand_already_configured(hass: HomeAssistant) -> None: """Test the user initiated form with password and valid mac.""" entry = MockConfigEntry( @@ -292,29 +328,46 @@ async def test_user_setup_wohand_already_configured(hass: HomeAssistant) -> None unique_id="aabbccddeeff", ) entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + assert result["type"] is FlowResultType.MENU + assert result["step_id"] == "user" + with patch( "homeassistant.components.switchbot.config_flow.async_discovered_service_info", return_value=[WOHAND_SERVICE_INFO], ): - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": SOURCE_USER} + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {"next_step_id": "select_device"}, ) assert result["type"] is FlowResultType.ABORT assert result["reason"] == "no_devices_found" +@pytest.mark.usefixtures("mock_scanners_all_passive") async def test_user_setup_wohand_replaces_ignored(hass: HomeAssistant) -> None: """Test setting up a switchbot replaces an ignored entry.""" entry = MockConfigEntry( domain=DOMAIN, data={}, unique_id="aabbccddeeff", source=SOURCE_IGNORE ) entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + assert result["type"] is FlowResultType.MENU + assert result["step_id"] == "user" + with patch( "homeassistant.components.switchbot.config_flow.async_discovered_service_info", return_value=[WOHAND_SERVICE_INFO], ): - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": SOURCE_USER} + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {"next_step_id": "select_device"}, ) assert result["type"] is FlowResultType.FORM assert result["step_id"] == "confirm" @@ -336,15 +389,23 @@ async def test_user_setup_wohand_replaces_ignored(hass: HomeAssistant) -> None: assert len(mock_setup_entry.mock_calls) == 1 +@pytest.mark.usefixtures("mock_scanners_all_passive") async def test_user_setup_wocurtain(hass: HomeAssistant) -> None: """Test the user initiated form with password and valid mac.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + assert result["type"] is FlowResultType.MENU + assert result["step_id"] == "user" + with patch( "homeassistant.components.switchbot.config_flow.async_discovered_service_info", return_value=[WOCURTAIN_SERVICE_INFO], ): - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": SOURCE_USER} + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {"next_step_id": "select_device"}, ) assert result["type"] is FlowResultType.FORM assert result["step_id"] == "confirm" @@ -367,9 +428,16 @@ async def test_user_setup_wocurtain(hass: HomeAssistant) -> None: assert len(mock_setup_entry.mock_calls) == 1 +@pytest.mark.usefixtures("mock_scanners_all_passive") async def test_user_setup_wocurtain_or_bot(hass: HomeAssistant) -> None: """Test the user initiated form with valid address.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + assert result["type"] is FlowResultType.MENU + assert result["step_id"] == "user" + with patch( "homeassistant.components.switchbot.config_flow.async_discovered_service_info", return_value=[ @@ -379,11 +447,12 @@ async def test_user_setup_wocurtain_or_bot(hass: HomeAssistant) -> None: WOHAND_SERVICE_INFO_NOT_CONNECTABLE, ], ): - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": SOURCE_USER} + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {"next_step_id": "select_device"}, ) assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "user" + assert result["step_id"] == "select_device" assert result["errors"] == {} with patch_async_setup_entry() as mock_setup_entry: @@ -403,9 +472,16 @@ async def test_user_setup_wocurtain_or_bot(hass: HomeAssistant) -> None: assert len(mock_setup_entry.mock_calls) == 1 +@pytest.mark.usefixtures("mock_scanners_all_passive") async def test_user_setup_wocurtain_or_bot_with_password(hass: HomeAssistant) -> None: """Test the user initiated form and valid address and a bot with a password.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + assert result["type"] is FlowResultType.MENU + assert result["step_id"] == "user" + with patch( "homeassistant.components.switchbot.config_flow.async_discovered_service_info", return_value=[ @@ -414,11 +490,12 @@ async def test_user_setup_wocurtain_or_bot_with_password(hass: HomeAssistant) -> WOHAND_SERVICE_INFO_NOT_CONNECTABLE, ], ): - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": SOURCE_USER} + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {"next_step_id": "select_device"}, ) assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "user" + assert result["step_id"] == "select_device" assert result["errors"] == {} result2 = await hass.config_entries.flow.async_configure( @@ -447,15 +524,23 @@ async def test_user_setup_wocurtain_or_bot_with_password(hass: HomeAssistant) -> assert len(mock_setup_entry.mock_calls) == 1 +@pytest.mark.usefixtures("mock_scanners_all_passive") async def test_user_setup_single_bot_with_password(hass: HomeAssistant) -> None: """Test the user initiated form for a bot with a password.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + assert result["type"] is FlowResultType.MENU + assert result["step_id"] == "user" + with patch( "homeassistant.components.switchbot.config_flow.async_discovered_service_info", return_value=[WOHAND_ENCRYPTED_SERVICE_INFO], ): - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": SOURCE_USER} + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {"next_step_id": "select_device"}, ) assert result["type"] is FlowResultType.FORM assert result["step_id"] == "password" @@ -479,15 +564,23 @@ async def test_user_setup_single_bot_with_password(hass: HomeAssistant) -> None: assert len(mock_setup_entry.mock_calls) == 1 +@pytest.mark.usefixtures("mock_scanners_all_passive") async def test_user_setup_woencrypted_key(hass: HomeAssistant) -> None: """Test the user initiated form for a lock.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + assert result["type"] is FlowResultType.MENU + assert result["step_id"] == "user" + with patch( "homeassistant.components.switchbot.config_flow.async_discovered_service_info", return_value=[WOLOCK_SERVICE_INFO], ): - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": SOURCE_USER} + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {"next_step_id": "select_device"}, ) assert result["type"] is FlowResultType.MENU assert result["step_id"] == "encrypted_choose_method" @@ -545,15 +638,23 @@ async def test_user_setup_woencrypted_key(hass: HomeAssistant) -> None: assert len(mock_setup_entry.mock_calls) == 1 +@pytest.mark.usefixtures("mock_scanners_all_passive") async def test_user_setup_woencrypted_auth(hass: HomeAssistant) -> None: """Test the user initiated form for a lock.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + assert result["type"] is FlowResultType.MENU + assert result["step_id"] == "user" + with patch( "homeassistant.components.switchbot.config_flow.async_discovered_service_info", return_value=[WOLOCK_SERVICE_INFO], ): - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": SOURCE_USER} + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {"next_step_id": "select_device"}, ) assert result["type"] is FlowResultType.MENU assert result["step_id"] == "encrypted_choose_method" @@ -618,17 +719,25 @@ async def test_user_setup_woencrypted_auth(hass: HomeAssistant) -> None: assert len(mock_setup_entry.mock_calls) == 1 +@pytest.mark.usefixtures("mock_scanners_all_passive") async def test_user_setup_woencrypted_auth_switchbot_api_down( hass: HomeAssistant, ) -> None: """Test the user initiated form for a lock when the switchbot api is down.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + assert result["type"] is FlowResultType.MENU + assert result["step_id"] == "user" + with patch( "homeassistant.components.switchbot.config_flow.async_discovered_service_info", return_value=[WOLOCK_SERVICE_INFO], ): - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": SOURCE_USER} + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {"next_step_id": "select_device"}, ) assert result["type"] is FlowResultType.MENU assert result["step_id"] == "encrypted_choose_method" @@ -658,9 +767,16 @@ async def test_user_setup_woencrypted_auth_switchbot_api_down( assert result["description_placeholders"] == {"error_detail": "Switchbot API down"} +@pytest.mark.usefixtures("mock_scanners_all_passive") async def test_user_setup_wolock_or_bot(hass: HomeAssistant) -> None: """Test the user initiated form for a lock.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + assert result["type"] is FlowResultType.MENU + assert result["step_id"] == "user" + with patch( "homeassistant.components.switchbot.config_flow.async_discovered_service_info", return_value=[ @@ -668,11 +784,12 @@ async def test_user_setup_wolock_or_bot(hass: HomeAssistant) -> None: WOHAND_SERVICE_ALT_ADDRESS_INFO, ], ): - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": SOURCE_USER} + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {"next_step_id": "select_device"}, ) assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "user" + assert result["step_id"] == "select_device" assert result["errors"] == {} result = await hass.config_entries.flow.async_configure( @@ -719,14 +836,22 @@ async def test_user_setup_wolock_or_bot(hass: HomeAssistant) -> None: assert len(mock_setup_entry.mock_calls) == 1 +@pytest.mark.usefixtures("mock_scanners_all_passive") async def test_user_setup_wosensor(hass: HomeAssistant) -> None: """Test the user initiated form with password and valid mac.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + assert result["type"] is FlowResultType.MENU + assert result["step_id"] == "user" + with patch( "homeassistant.components.switchbot.config_flow.async_discovered_service_info", return_value=[WOSENSORTH_SERVICE_INFO], ): - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": SOURCE_USER} + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {"next_step_id": "select_device"}, ) assert result["type"] is FlowResultType.FORM assert result["step_id"] == "confirm" @@ -749,19 +874,236 @@ async def test_user_setup_wosensor(hass: HomeAssistant) -> None: assert len(mock_setup_entry.mock_calls) == 1 +@pytest.mark.usefixtures("mock_scanners_all_passive") +async def test_user_cloud_login(hass: HomeAssistant) -> None: + """Test the cloud login flow.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + assert result["type"] is FlowResultType.MENU + assert result["step_id"] == "user" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {"next_step_id": "cloud_login"}, + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "cloud_login" + + # Test successful cloud login + with ( + patch( + "homeassistant.components.switchbot.config_flow.fetch_cloud_devices", + return_value=None, + ), + patch( + "homeassistant.components.switchbot.config_flow.async_discovered_service_info", + return_value=[WOHAND_SERVICE_INFO], + ), + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_USERNAME: "test@example.com", + CONF_PASSWORD: "testpass", + }, + ) + + # Should proceed to device selection with single device, so go to confirm + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "confirm" + + # Confirm device setup + with patch_async_setup_entry(): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {}, + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == "Bot EEFF" + assert result["data"] == { + CONF_ADDRESS: "AA:BB:CC:DD:EE:FF", + CONF_SENSOR_TYPE: "bot", + } + + +@pytest.mark.usefixtures("mock_scanners_all_passive") +async def test_user_cloud_login_auth_failed(hass: HomeAssistant) -> None: + """Test the cloud login flow with authentication failure.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + assert result["type"] is FlowResultType.MENU + assert result["step_id"] == "user" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {"next_step_id": "cloud_login"}, + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "cloud_login" + + # Test authentication failure + with patch( + "homeassistant.components.switchbot.config_flow.fetch_cloud_devices", + side_effect=SwitchbotAuthenticationError("Invalid credentials"), + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_USERNAME: "test@example.com", + CONF_PASSWORD: "wrongpass", + }, + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "cloud_login" + assert result["errors"] == {"base": "auth_failed"} + assert "Invalid credentials" in result["description_placeholders"]["error_detail"] + + +@pytest.mark.usefixtures("mock_scanners_all_passive") +async def test_user_cloud_login_api_error(hass: HomeAssistant) -> None: + """Test the cloud login flow with API error.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + assert result["type"] is FlowResultType.MENU + assert result["step_id"] == "user" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {"next_step_id": "cloud_login"}, + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "cloud_login" + + # Test API connection error + with patch( + "homeassistant.components.switchbot.config_flow.fetch_cloud_devices", + side_effect=SwitchbotAccountConnectionError("API is down"), + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_USERNAME: "test@example.com", + CONF_PASSWORD: "testpass", + }, + ) + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "api_error" + assert result["description_placeholders"] == {"error_detail": "API is down"} + + +@pytest.mark.usefixtures("mock_scanners_all_passive") +async def test_user_cloud_login_then_encrypted_device(hass: HomeAssistant) -> None: + """Test cloud login followed by encrypted device setup using saved credentials.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + assert result["type"] is FlowResultType.MENU + assert result["step_id"] == "user" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {"next_step_id": "cloud_login"}, + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "cloud_login" + + with ( + patch( + "homeassistant.components.switchbot.config_flow.fetch_cloud_devices", + return_value=None, + ), + patch( + "homeassistant.components.switchbot.config_flow.async_discovered_service_info", + return_value=[WOLOCK_SERVICE_INFO], + ), + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_USERNAME: "test@example.com", + CONF_PASSWORD: "testpass", + }, + ) + + # Should go to encrypted device choice menu + assert result["type"] is FlowResultType.MENU + assert result["step_id"] == "encrypted_choose_method" + + # Choose encrypted auth + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={"next_step_id": "encrypted_auth"} + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "encrypted_auth" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + None, + ) + await hass.async_block_till_done() + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "encrypted_auth" + + with ( + patch_async_setup_entry() as mock_setup_entry, + patch( + "switchbot.SwitchbotLock.async_retrieve_encryption_key", + return_value={ + CONF_KEY_ID: "ff", + CONF_ENCRYPTION_KEY: "ffffffffffffffffffffffffffffffff", + }, + ), + patch("switchbot.SwitchbotLock.verify_encryption_key", return_value=True), + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_USERNAME: "test@example.com", + CONF_PASSWORD: "testpass", + }, + ) + await hass.async_block_till_done() + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == "Lock EEFF" + assert result["data"] == { + CONF_ADDRESS: "aa:bb:cc:dd:ee:ff", + CONF_KEY_ID: "ff", + CONF_ENCRYPTION_KEY: "ffffffffffffffffffffffffffffffff", + CONF_SENSOR_TYPE: "lock", + } + + assert len(mock_setup_entry.mock_calls) == 1 + + +@pytest.mark.usefixtures("mock_scanners_all_passive") async def test_user_no_devices(hass: HomeAssistant) -> None: """Test the user initiated form with password and valid mac.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + assert result["type"] is FlowResultType.MENU + assert result["step_id"] == "user" + with patch( "homeassistant.components.switchbot.config_flow.async_discovered_service_info", return_value=[], ): - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": SOURCE_USER} + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {"next_step_id": "select_device"}, ) assert result["type"] is FlowResultType.ABORT assert result["reason"] == "no_devices_found" +@pytest.mark.usefixtures("mock_scanners_all_passive") async def test_async_step_user_takes_precedence_over_discovery( hass: HomeAssistant, ) -> None: @@ -774,13 +1116,20 @@ async def test_async_step_user_takes_precedence_over_discovery( assert result["type"] is FlowResultType.FORM assert result["step_id"] == "confirm" + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_USER}, + ) + assert result["type"] is FlowResultType.MENU + assert result["step_id"] == "user" + with patch( "homeassistant.components.switchbot.config_flow.async_discovered_service_info", return_value=[WOCURTAIN_SERVICE_INFO], ): - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": SOURCE_USER}, + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {"next_step_id": "select_device"}, ) assert result["type"] is FlowResultType.FORM @@ -928,15 +1277,23 @@ async def test_options_flow_lock_pro(hass: HomeAssistant) -> None: assert entry.options[CONF_LOCK_NIGHTLATCH] is True +@pytest.mark.usefixtures("mock_scanners_all_passive") async def test_user_setup_worelay_switch_1pm_key(hass: HomeAssistant) -> None: """Test the user initiated form for a relay switch 1pm.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + assert result["type"] is FlowResultType.MENU + assert result["step_id"] == "user" + with patch( "homeassistant.components.switchbot.config_flow.async_discovered_service_info", return_value=[WORELAY_SWITCH_1PM_SERVICE_INFO], ): - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": SOURCE_USER} + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {"next_step_id": "select_device"}, ) assert result["type"] is FlowResultType.MENU assert result["step_id"] == "encrypted_choose_method" @@ -976,15 +1333,23 @@ async def test_user_setup_worelay_switch_1pm_key(hass: HomeAssistant) -> None: assert len(mock_setup_entry.mock_calls) == 1 +@pytest.mark.usefixtures("mock_scanners_all_passive") async def test_user_setup_worelay_switch_1pm_auth(hass: HomeAssistant) -> None: """Test the user initiated form for a relay switch 1pm.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + assert result["type"] is FlowResultType.MENU + assert result["step_id"] == "user" + with patch( "homeassistant.components.switchbot.config_flow.async_discovered_service_info", return_value=[WORELAY_SWITCH_1PM_SERVICE_INFO], ): - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": SOURCE_USER} + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {"next_step_id": "select_device"}, ) assert result["type"] is FlowResultType.MENU assert result["step_id"] == "encrypted_choose_method" @@ -1048,17 +1413,25 @@ async def test_user_setup_worelay_switch_1pm_auth(hass: HomeAssistant) -> None: assert len(mock_setup_entry.mock_calls) == 1 +@pytest.mark.usefixtures("mock_scanners_all_passive") async def test_user_setup_worelay_switch_1pm_auth_switchbot_api_down( hass: HomeAssistant, ) -> None: """Test the user initiated form for a relay switch 1pm when the switchbot api is down.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + assert result["type"] is FlowResultType.MENU + assert result["step_id"] == "user" + with patch( "homeassistant.components.switchbot.config_flow.async_discovered_service_info", return_value=[WORELAY_SWITCH_1PM_SERVICE_INFO], ): - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": SOURCE_USER} + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {"next_step_id": "select_device"}, ) assert result["type"] is FlowResultType.MENU assert result["step_id"] == "encrypted_choose_method" @@ -1086,3 +1459,128 @@ async def test_user_setup_worelay_switch_1pm_auth_switchbot_api_down( assert result["type"] is FlowResultType.ABORT assert result["reason"] == "api_error" assert result["description_placeholders"] == {"error_detail": "Switchbot API down"} + + +@pytest.mark.usefixtures("mock_scanners_all_active") +async def test_user_skip_menu_when_all_scanners_active(hass: HomeAssistant) -> None: + """Test that menu is skipped when all scanners are in active mode.""" + with ( + patch( + "homeassistant.components.switchbot.config_flow.async_discovered_service_info", + return_value=[WOHAND_SERVICE_INFO], + ), + patch_async_setup_entry() as mock_setup_entry, + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + + # Should skip menu and go directly to select_device -> confirm + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "confirm" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={} + ) + await hass.async_block_till_done() + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == "Bot EEFF" + assert result["data"] == { + CONF_ADDRESS: "AA:BB:CC:DD:EE:FF", + CONF_SENSOR_TYPE: "bot", + } + assert len(mock_setup_entry.mock_calls) == 1 + + +async def test_user_show_menu_when_passive_scanner_present(hass: HomeAssistant) -> None: + """Test that menu is shown when any scanner is in passive mode.""" + mock_scanner_active = Mock() + mock_scanner_active.current_mode = BluetoothScanningMode.ACTIVE + mock_scanner_passive = Mock() + mock_scanner_passive.current_mode = BluetoothScanningMode.PASSIVE + + with ( + patch( + "homeassistant.components.switchbot.config_flow.async_current_scanners", + return_value=[mock_scanner_active, mock_scanner_passive], + ), + patch( + "homeassistant.components.switchbot.config_flow.async_discovered_service_info", + return_value=[WOHAND_SERVICE_INFO], + ), + patch_async_setup_entry() as mock_setup_entry, + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + + # Should show menu since not all scanners are active + assert result["type"] is FlowResultType.MENU + assert result["step_id"] == "user" + assert set(result["menu_options"]) == {"cloud_login", "select_device"} + + # Choose select_device from menu + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {"next_step_id": "select_device"} + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "confirm" + + # Confirm the device + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={} + ) + await hass.async_block_till_done() + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == "Bot EEFF" + assert result["data"] == { + CONF_ADDRESS: "AA:BB:CC:DD:EE:FF", + CONF_SENSOR_TYPE: "bot", + } + assert len(mock_setup_entry.mock_calls) == 1 + + +async def test_user_show_menu_when_no_scanners(hass: HomeAssistant) -> None: + """Test that menu is shown when no scanners are available.""" + with ( + patch( + "homeassistant.components.switchbot.config_flow.async_current_scanners", + return_value=[], + ), + patch( + "homeassistant.components.switchbot.config_flow.async_discovered_service_info", + return_value=[WOHAND_SERVICE_INFO], + ), + patch_async_setup_entry() as mock_setup_entry, + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + + # Should show menu when no scanners are available + assert result["type"] is FlowResultType.MENU + assert result["step_id"] == "user" + assert set(result["menu_options"]) == {"cloud_login", "select_device"} + + # Choose select_device from menu + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {"next_step_id": "select_device"} + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "confirm" + + # Confirm the device + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={} + ) + await hass.async_block_till_done() + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == "Bot EEFF" + assert result["data"] == { + CONF_ADDRESS: "AA:BB:CC:DD:EE:FF", + CONF_SENSOR_TYPE: "bot", + } + assert len(mock_setup_entry.mock_calls) == 1 diff --git a/tests/components/switchbot/test_sensor.py b/tests/components/switchbot/test_sensor.py index 645eb5d1ab31c1..c9c28b7d94eb95 100644 --- a/tests/components/switchbot/test_sensor.py +++ b/tests/components/switchbot/test_sensor.py @@ -28,6 +28,7 @@ HUB3_SERVICE_INFO, HUBMINI_MATTER_SERVICE_INFO, LEAK_SERVICE_INFO, + PLUG_MINI_EU_SERVICE_INFO, REMOTE_SERVICE_INFO, WOHAND_SERVICE_INFO, WOHUB2_SERVICE_INFO, @@ -542,3 +543,77 @@ async def test_evaporative_humidifier_sensor(hass: HomeAssistant) -> None: assert await hass.config_entries.async_unload(entry.entry_id) await hass.async_block_till_done() + + +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +async def test_plug_mini_eu_sensor(hass: HomeAssistant) -> None: + """Test setting up creates the plug mini eu sensor.""" + await async_setup_component(hass, DOMAIN, {}) + inject_bluetooth_service_info(hass, PLUG_MINI_EU_SERVICE_INFO) + + with patch( + "homeassistant.components.switchbot.switch.switchbot.SwitchbotRelaySwitch.get_basic_info", + new=AsyncMock( + return_value={ + "power": 500, + "current": 0.5, + "voltage": 230, + "energy": 0.4, + } + ), + ): + entry = MockConfigEntry( + domain=DOMAIN, + data={ + CONF_ADDRESS: "aa:bb:cc:dd:ee:ff", + CONF_NAME: "test-name", + CONF_SENSOR_TYPE: "plug_mini_eu", + CONF_KEY_ID: "ff", + CONF_ENCRYPTION_KEY: "ffffffffffffffffffffffffffffffff", + }, + unique_id="aabbccddeeaa", + ) + entry.add_to_hass(hass) + + assert await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + assert len(hass.states.async_all("sensor")) == 5 + + power_sensor = hass.states.get("sensor.test_name_power") + power_sensor_attrs = power_sensor.attributes + assert power_sensor.state == "500" + assert power_sensor_attrs[ATTR_FRIENDLY_NAME] == "test-name Power" + assert power_sensor_attrs[ATTR_UNIT_OF_MEASUREMENT] == "W" + assert power_sensor_attrs[ATTR_STATE_CLASS] == "measurement" + + voltage_sensor = hass.states.get("sensor.test_name_voltage") + voltage_sensor_attrs = voltage_sensor.attributes + assert voltage_sensor.state == "230" + assert voltage_sensor_attrs[ATTR_FRIENDLY_NAME] == "test-name Voltage" + assert voltage_sensor_attrs[ATTR_UNIT_OF_MEASUREMENT] == "V" + assert voltage_sensor_attrs[ATTR_STATE_CLASS] == "measurement" + + current_sensor = hass.states.get("sensor.test_name_current") + current_sensor_attrs = current_sensor.attributes + assert current_sensor.state == "0.5" + assert current_sensor_attrs[ATTR_FRIENDLY_NAME] == "test-name Current" + assert current_sensor_attrs[ATTR_UNIT_OF_MEASUREMENT] == "A" + assert current_sensor_attrs[ATTR_STATE_CLASS] == "measurement" + + energy_sensor = hass.states.get("sensor.test_name_energy") + energy_sensor_attrs = energy_sensor.attributes + assert energy_sensor.state == "0.4" + assert energy_sensor_attrs[ATTR_FRIENDLY_NAME] == "test-name Energy" + assert energy_sensor_attrs[ATTR_UNIT_OF_MEASUREMENT] == "kWh" + assert energy_sensor_attrs[ATTR_STATE_CLASS] == "total_increasing" + + rssi_sensor = hass.states.get("sensor.test_name_bluetooth_signal") + rssi_sensor_attrs = rssi_sensor.attributes + assert rssi_sensor.state == "-60" + assert rssi_sensor_attrs[ATTR_FRIENDLY_NAME] == "test-name Bluetooth signal" + assert rssi_sensor_attrs[ATTR_UNIT_OF_MEASUREMENT] == "dBm" + assert rssi_sensor_attrs[ATTR_STATE_CLASS] == "measurement" + + assert await hass.config_entries.async_unload(entry.entry_id) + await hass.async_block_till_done() diff --git a/tests/components/switchbot/test_switch.py b/tests/components/switchbot/test_switch.py index be28b2a02a8513..c3740eb8b8e762 100644 --- a/tests/components/switchbot/test_switch.py +++ b/tests/components/switchbot/test_switch.py @@ -6,6 +6,7 @@ import pytest from switchbot.devices.device import SwitchbotOperationError +from homeassistant.components.bluetooth import BluetoothServiceInfoBleak from homeassistant.components.switch import ( DOMAIN as SWITCH_DOMAIN, SERVICE_TURN_OFF, @@ -16,7 +17,7 @@ from homeassistant.core import HomeAssistant, State from homeassistant.exceptions import HomeAssistantError -from . import WOHAND_SERVICE_INFO +from . import PLUG_MINI_EU_SERVICE_INFO, WOHAND_SERVICE_INFO from tests.common import MockConfigEntry, mock_restore_cache from tests.components.bluetooth import inject_bluetooth_service_info @@ -103,3 +104,51 @@ async def test_exception_handling_switch( {ATTR_ENTITY_ID: entity_id}, blocking=True, ) + + +@pytest.mark.parametrize( + ("sensor_type", "service_info"), + [ + ("plug_mini_eu", PLUG_MINI_EU_SERVICE_INFO), + ], +) +@pytest.mark.parametrize( + ("service", "mock_method"), + [ + (SERVICE_TURN_ON, "turn_on"), + (SERVICE_TURN_OFF, "turn_off"), + ], +) +async def test_relay_switch_control( + hass: HomeAssistant, + mock_entry_encrypted_factory: Callable[[str], MockConfigEntry], + sensor_type: str, + service_info: BluetoothServiceInfoBleak, + service: str, + mock_method: str, +) -> None: + """Test Relay Switch control.""" + inject_bluetooth_service_info(hass, service_info) + + entry = mock_entry_encrypted_factory(sensor_type=sensor_type) + entry.add_to_hass(hass) + + mocked_instance = AsyncMock(return_value=True) + with patch.multiple( + "homeassistant.components.switchbot.switch.switchbot.SwitchbotRelaySwitch", + update=AsyncMock(return_value=None), + **{mock_method: mocked_instance}, + ): + assert await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + entity_id = "switch.test_name" + + await hass.services.async_call( + SWITCH_DOMAIN, + service, + {ATTR_ENTITY_ID: entity_id}, + blocking=True, + ) + + mocked_instance.assert_awaited_once() diff --git a/tests/components/switchbot_cloud/__init__.py b/tests/components/switchbot_cloud/__init__.py index 397c62d32c15d7..2fd82faa3b812e 100644 --- a/tests/components/switchbot_cloud/__init__.py +++ b/tests/components/switchbot_cloud/__init__.py @@ -41,7 +41,6 @@ async def configure_integration(hass: HomeAssistant) -> MockConfigEntry: hubDeviceId="test-hub-id", ) - METER_INFO = Device( version="V1.0", deviceId="meter-id-1", @@ -81,3 +80,19 @@ async def configure_integration(hass: HomeAssistant) -> MockConfigEntry: deviceType="Water Detector", hubDeviceId="test-hub-id", ) + +HUMIDIFIER_INFO = Device( + version="V1.0", + deviceId="humidifier-id-1", + deviceName="humidifier-1", + deviceType="Humidifier", + hubDeviceId="test-hub-id", +) + +HUMIDIFIER2_INFO = Device( + version="V1.0", + deviceId="humidifier2-id-1", + deviceName="humidifier2-1", + deviceType="Humidifier2", + hubDeviceId="test-hub-id", +) diff --git a/tests/components/switchbot_cloud/fixtures/status.json b/tests/components/switchbot_cloud/fixtures/status.json index 87eae6cc93e90d..16b56d386ecdd1 100644 --- a/tests/components/switchbot_cloud/fixtures/status.json +++ b/tests/components/switchbot_cloud/fixtures/status.json @@ -44,5 +44,24 @@ "deviceId": "water-detector-id", "deviceType": "Water Detector", "hubDeviceId": "test-hub-id" + }, + { + "version": "V1.8", + "power": "on", + "auto": false, + "humidity": 50, + "temperature": 24.3, + "deviceId": "test-id-1", + "deviceType": "Humidifier", + "hubDeviceId": "test-hub-id" + }, + { + "version": "V1.0", + "power": "on", + "mode": 1, + "humidity": 50, + "deviceId": "test-id-1", + "deviceType": "Humidifier2", + "hubDeviceId": "test-hub-id" } ] diff --git a/tests/components/switchbot_cloud/snapshots/test_humidifier.ambr b/tests/components/switchbot_cloud/snapshots/test_humidifier.ambr new file mode 100644 index 00000000000000..d369b0f3b48d08 --- /dev/null +++ b/tests/components/switchbot_cloud/snapshots/test_humidifier.ambr @@ -0,0 +1,145 @@ +# serializer version: 1 +# name: test_humidifier[device_info0-6][humidifier.humidifier_1-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'available_modes': list([ + 'normal', + 'auto', + ]), + 'max_humidity': 100, + 'min_humidity': 1, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'humidifier', + 'entity_category': None, + 'entity_id': 'humidifier.humidifier_1', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': None, + 'platform': 'switchbot_cloud', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': , + 'translation_key': 'humidifier', + 'unique_id': 'humidifier-id-1', + 'unit_of_measurement': None, + }) +# --- +# name: test_humidifier[device_info0-6][humidifier.humidifier_1-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'available_modes': list([ + 'normal', + 'auto', + ]), + 'current_humidity': 50, + 'device_class': 'humidifier', + 'friendly_name': 'humidifier-1', + 'humidity': 50, + 'max_humidity': 100, + 'min_humidity': 1, + 'mode': 'normal', + 'supported_features': , + }), + 'context': , + 'entity_id': 'humidifier.humidifier_1', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_humidifier[device_info1-7][humidifier.humidifier2_1-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'available_modes': list([ + 'high', + 'medium', + 'low', + 'quiet', + 'target_humidity', + 'sleep', + 'auto', + 'drying_filter', + ]), + 'max_humidity': 100, + 'min_humidity': 0, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'humidifier', + 'entity_category': None, + 'entity_id': 'humidifier.humidifier2_1', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': None, + 'platform': 'switchbot_cloud', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': , + 'translation_key': 'evaporative_humidifier', + 'unique_id': 'humidifier2-id-1', + 'unit_of_measurement': None, + }) +# --- +# name: test_humidifier[device_info1-7][humidifier.humidifier2_1-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'available_modes': list([ + 'high', + 'medium', + 'low', + 'quiet', + 'target_humidity', + 'sleep', + 'auto', + 'drying_filter', + ]), + 'current_humidity': 50, + 'device_class': 'humidifier', + 'friendly_name': 'humidifier2-1', + 'humidity': 50, + 'max_humidity': 100, + 'min_humidity': 0, + 'mode': 'high', + 'supported_features': , + }), + 'context': , + 'entity_id': 'humidifier.humidifier2_1', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- diff --git a/tests/components/switchbot_cloud/test_humidifier.py b/tests/components/switchbot_cloud/test_humidifier.py new file mode 100644 index 00000000000000..7b4b3caa065a77 --- /dev/null +++ b/tests/components/switchbot_cloud/test_humidifier.py @@ -0,0 +1,221 @@ +"""Test for the switchbot_cloud humidifiers.""" + +from unittest.mock import patch + +import pytest +import switchbot_api +from switchbot_api import Device +from syrupy.assertion import SnapshotAssertion + +from homeassistant.components.humidifier import DOMAIN as HUMIDIFIER_DOMAIN +from homeassistant.components.switchbot_cloud import SwitchBotAPI +from homeassistant.components.switchbot_cloud.const import DOMAIN +from homeassistant.const import ATTR_ENTITY_ID, Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from . import HUMIDIFIER2_INFO, HUMIDIFIER_INFO, configure_integration + +from tests.common import async_load_json_array_fixture, snapshot_platform + + +@pytest.mark.parametrize( + ("device_info", "index"), + [ + (HUMIDIFIER_INFO, 6), + (HUMIDIFIER2_INFO, 7), + ], +) +async def test_humidifier( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, + mock_list_devices, + mock_get_status, + device_info: Device, + index: int, +) -> None: + """Test humidifier sensors.""" + + mock_list_devices.return_value = [device_info] + json_data = await async_load_json_array_fixture(hass, "status.json", DOMAIN) + mock_get_status.return_value = json_data[index] + + with patch( + "homeassistant.components.switchbot_cloud.PLATFORMS", [Platform.HUMIDIFIER] + ): + entry = await configure_integration(hass) + + await snapshot_platform(hass, entity_registry, snapshot, entry.entry_id) + + +@pytest.mark.parametrize( + ("service", "service_data", "expected_call_args"), + [ + ( + "turn_on", + {}, + ( + "humidifier-id-1", + switchbot_api.CommonCommands.ON, + "command", + "default", + ), + ), + ( + "turn_off", + {}, + ( + "humidifier-id-1", + switchbot_api.CommonCommands.OFF, + "command", + "default", + ), + ), + ( + "set_humidity", + {"humidity": 15}, + ( + "humidifier-id-1", + switchbot_api.HumidifierCommands.SET_MODE, + "command", + "101", + ), + ), + ( + "set_humidity", + {"humidity": 60}, + ( + "humidifier-id-1", + switchbot_api.HumidifierCommands.SET_MODE, + "command", + "102", + ), + ), + ( + "set_humidity", + {"humidity": 80}, + ( + "humidifier-id-1", + switchbot_api.HumidifierCommands.SET_MODE, + "command", + "103", + ), + ), + ( + "set_mode", + {"mode": "auto"}, + ( + "humidifier-id-1", + switchbot_api.HumidifierCommands.SET_MODE, + "command", + "auto", + ), + ), + ( + "set_mode", + {"mode": "normal"}, + ( + "humidifier-id-1", + switchbot_api.HumidifierCommands.SET_MODE, + "command", + "102", + ), + ), + ], +) +async def test_humidifier_controller( + hass: HomeAssistant, + mock_list_devices, + mock_get_status, + service: str, + service_data: dict, + expected_call_args: tuple, +) -> None: + """Test controlling the humidifier with mocked delay.""" + mock_list_devices.return_value = [HUMIDIFIER_INFO] + mock_get_status.return_value = {"power": "OFF", "mode": 2} + + await configure_integration(hass) + humidifier_id = "humidifier.humidifier_1" + + with patch.object(SwitchBotAPI, "send_command") as mocked_send_command: + await hass.services.async_call( + HUMIDIFIER_DOMAIN, + service, + {**service_data, ATTR_ENTITY_ID: humidifier_id}, + blocking=True, + ) + + mocked_send_command.assert_awaited_once_with(*expected_call_args) + + +@pytest.mark.parametrize( + ("service", "service_data", "expected_call_args"), + [ + ( + "turn_on", + {}, + ( + "humidifier2-id-1", + switchbot_api.CommonCommands.ON, + "command", + "default", + ), + ), + ( + "turn_off", + {}, + ( + "humidifier2-id-1", + switchbot_api.CommonCommands.OFF, + "command", + "default", + ), + ), + ( + "set_humidity", + {"humidity": 50}, + ( + "humidifier2-id-1", + switchbot_api.HumidifierV2Commands.SET_MODE, + "command", + {"mode": 2, "humidity": 50}, + ), + ), + ( + "set_mode", + {"mode": "auto"}, + ( + "humidifier2-id-1", + switchbot_api.HumidifierV2Commands.SET_MODE, + "command", + {"mode": 7}, + ), + ), + ], +) +async def test_humidifier2_controller( + hass: HomeAssistant, + mock_list_devices, + mock_get_status, + service: str, + service_data: dict, + expected_call_args: tuple, +) -> None: + """Test controlling the humidifier2 with mocked delay.""" + mock_list_devices.return_value = [HUMIDIFIER2_INFO] + mock_get_status.return_value = {"power": "off", "mode": 2} + + await configure_integration(hass) + humidifier_id = "humidifier.humidifier2_1" + + with patch.object(SwitchBotAPI, "send_command") as mocked_send_command: + await hass.services.async_call( + HUMIDIFIER_DOMAIN, + service, + {**service_data, ATTR_ENTITY_ID: humidifier_id}, + blocking=True, + ) + + mocked_send_command.assert_awaited_once_with(*expected_call_args) diff --git a/tests/util/test_unit_conversion.py b/tests/util/test_unit_conversion.py index 3fe0078aabfa3b..c25a40f5fc0e9e 100644 --- a/tests/util/test_unit_conversion.py +++ b/tests/util/test_unit_conversion.py @@ -672,12 +672,21 @@ (1000, UnitOfPressure.HPA, 100, UnitOfPressure.KPA), (1000, UnitOfPressure.HPA, 1000, UnitOfPressure.MBAR), (1000, UnitOfPressure.HPA, 100, UnitOfPressure.CBAR), + (1000, UnitOfPressure.HPA, 401.46307866177, UnitOfPressure.INH2O), (100, UnitOfPressure.KPA, 14.5037743897, UnitOfPressure.PSI), (100, UnitOfPressure.KPA, 29.5299801647, UnitOfPressure.INHG), (100, UnitOfPressure.KPA, 100000, UnitOfPressure.PA), (100, UnitOfPressure.KPA, 1000, UnitOfPressure.HPA), (100, UnitOfPressure.KPA, 1000, UnitOfPressure.MBAR), (100, UnitOfPressure.KPA, 100, UnitOfPressure.CBAR), + (100, UnitOfPressure.INH2O, 3.6127291827353996, UnitOfPressure.PSI), + (100, UnitOfPressure.INH2O, 186.83201548767, UnitOfPressure.MMHG), + (100, UnitOfPressure.INH2O, 7.3555912463681, UnitOfPressure.INHG), + (100, UnitOfPressure.INH2O, 24908.890833333, UnitOfPressure.PA), + (100, UnitOfPressure.INH2O, 249.08890833333, UnitOfPressure.HPA), + (100, UnitOfPressure.INH2O, 249.08890833333, UnitOfPressure.MBAR), + (100, UnitOfPressure.INH2O, 24.908890833333, UnitOfPressure.KPA), + (100, UnitOfPressure.INH2O, 24.908890833333, UnitOfPressure.CBAR), (30, UnitOfPressure.INHG, 14.7346266155, UnitOfPressure.PSI), (30, UnitOfPressure.INHG, 101.59167, UnitOfPressure.KPA), (30, UnitOfPressure.INHG, 1015.9167, UnitOfPressure.HPA), @@ -685,6 +694,7 @@ (30, UnitOfPressure.INHG, 1015.9167, UnitOfPressure.MBAR), (30, UnitOfPressure.INHG, 101.59167, UnitOfPressure.CBAR), (30, UnitOfPressure.INHG, 762, UnitOfPressure.MMHG), + (30, UnitOfPressure.INHG, 407.85300589959, UnitOfPressure.INH2O), (30, UnitOfPressure.MMHG, 0.580103, UnitOfPressure.PSI), (30, UnitOfPressure.MMHG, 3.99967, UnitOfPressure.KPA), (30, UnitOfPressure.MMHG, 39.9967, UnitOfPressure.HPA), @@ -692,6 +702,7 @@ (30, UnitOfPressure.MMHG, 39.9967, UnitOfPressure.MBAR), (30, UnitOfPressure.MMHG, 3.99967, UnitOfPressure.CBAR), (30, UnitOfPressure.MMHG, 1.181102, UnitOfPressure.INHG), + (30, UnitOfPressure.MMHG, 16.0572051431838, UnitOfPressure.INH2O), (5, UnitOfPressure.BAR, 72.51887, UnitOfPressure.PSI), ], ReactiveEnergyConverter: [