diff --git a/CODEOWNERS b/CODEOWNERS index cae17682516680..e29f2712032ad3 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -762,8 +762,8 @@ build.json @home-assistant/supervisor /homeassistant/components/intent/ @home-assistant/core @synesthesiam @arturpragacz /tests/components/intent/ @home-assistant/core @synesthesiam @arturpragacz /homeassistant/components/intesishome/ @jnimmo -/homeassistant/components/iometer/ @MaestroOnICe -/tests/components/iometer/ @MaestroOnICe +/homeassistant/components/iometer/ @jukrebs +/tests/components/iometer/ @jukrebs /homeassistant/components/ios/ @robbiet480 /tests/components/ios/ @robbiet480 /homeassistant/components/iotawatt/ @gtdiehl @jyavenard @@ -1065,8 +1065,8 @@ build.json @home-assistant/supervisor /homeassistant/components/nilu/ @hfurubotten /homeassistant/components/nina/ @DeerMaximum /tests/components/nina/ @DeerMaximum -/homeassistant/components/nintendo_parental/ @pantherale0 -/tests/components/nintendo_parental/ @pantherale0 +/homeassistant/components/nintendo_parental_controls/ @pantherale0 +/tests/components/nintendo_parental_controls/ @pantherale0 /homeassistant/components/nissan_leaf/ @filcole /homeassistant/components/noaa_tides/ @jdelaney72 /homeassistant/components/nobo_hub/ @echoromeo @oyvindwe @@ -1479,8 +1479,8 @@ build.json @home-assistant/supervisor /tests/components/snoo/ @Lash-L /homeassistant/components/snooz/ @AustinBrunkhorst /tests/components/snooz/ @AustinBrunkhorst -/homeassistant/components/solaredge/ @frenck @bdraco -/tests/components/solaredge/ @frenck @bdraco +/homeassistant/components/solaredge/ @frenck @bdraco @tronikos +/tests/components/solaredge/ @frenck @bdraco @tronikos /homeassistant/components/solaredge_local/ @drobtravels @scheric /homeassistant/components/solarlog/ @Ernst79 @dontinelli /tests/components/solarlog/ @Ernst79 @dontinelli diff --git a/Dockerfile.dev b/Dockerfile.dev index 9c1b82994286f4..d81daaa47eb594 100644 --- a/Dockerfile.dev +++ b/Dockerfile.dev @@ -36,7 +36,8 @@ COPY --from=ghcr.io/astral-sh/uv:latest /uv /usr/local/bin/uv USER vscode -RUN uv python install 3.13.2 +ENV UV_PYTHON=3.13.2 +RUN uv python install ENV VIRTUAL_ENV="/home/vscode/.local/ha-venv" RUN uv venv $VIRTUAL_ENV diff --git a/homeassistant/components/anthropic/config_flow.py b/homeassistant/components/anthropic/config_flow.py index 0c555d19bd9449..9ae377e3e9da71 100644 --- a/homeassistant/components/anthropic/config_flow.py +++ b/homeassistant/components/anthropic/config_flow.py @@ -4,12 +4,15 @@ from collections.abc import Mapping from functools import partial +import json import logging from typing import Any, cast import anthropic import voluptuous as vol +from voluptuous_openapi import convert +from homeassistant.components.zone import ENTITY_ID_HOME from homeassistant.config_entries import ( ConfigEntry, ConfigEntryState, @@ -18,7 +21,13 @@ ConfigSubentryFlow, SubentryFlowResult, ) -from homeassistant.const import CONF_API_KEY, CONF_LLM_HASS_API, CONF_NAME +from homeassistant.const import ( + ATTR_LATITUDE, + ATTR_LONGITUDE, + CONF_API_KEY, + CONF_LLM_HASS_API, + CONF_NAME, +) from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import llm from homeassistant.helpers.selector import ( @@ -37,12 +46,23 @@ CONF_RECOMMENDED, CONF_TEMPERATURE, CONF_THINKING_BUDGET, + CONF_WEB_SEARCH, + CONF_WEB_SEARCH_CITY, + CONF_WEB_SEARCH_COUNTRY, + CONF_WEB_SEARCH_MAX_USES, + CONF_WEB_SEARCH_REGION, + CONF_WEB_SEARCH_TIMEZONE, + CONF_WEB_SEARCH_USER_LOCATION, DEFAULT_CONVERSATION_NAME, DOMAIN, RECOMMENDED_CHAT_MODEL, RECOMMENDED_MAX_TOKENS, RECOMMENDED_TEMPERATURE, RECOMMENDED_THINKING_BUDGET, + RECOMMENDED_WEB_SEARCH, + RECOMMENDED_WEB_SEARCH_MAX_USES, + RECOMMENDED_WEB_SEARCH_USER_LOCATION, + WEB_SEARCH_UNSUPPORTED_MODELS, ) _LOGGER = logging.getLogger(__name__) @@ -168,6 +188,14 @@ async def async_step_set_options( CONF_THINKING_BUDGET, RECOMMENDED_THINKING_BUDGET ) >= user_input.get(CONF_MAX_TOKENS, RECOMMENDED_MAX_TOKENS): errors[CONF_THINKING_BUDGET] = "thinking_budget_too_large" + if user_input.get(CONF_WEB_SEARCH, RECOMMENDED_WEB_SEARCH): + model = user_input.get(CONF_CHAT_MODEL, RECOMMENDED_CHAT_MODEL) + if model.startswith(tuple(WEB_SEARCH_UNSUPPORTED_MODELS)): + errors[CONF_WEB_SEARCH] = "web_search_unsupported_model" + elif user_input.get( + CONF_WEB_SEARCH_USER_LOCATION, RECOMMENDED_WEB_SEARCH_USER_LOCATION + ): + user_input.update(await self._get_location_data()) if not errors: if self._is_new: @@ -215,6 +243,68 @@ async def async_step_set_options( errors=errors or None, ) + async def _get_location_data(self) -> dict[str, str]: + """Get approximate location data of the user.""" + location_data: dict[str, str] = {} + zone_home = self.hass.states.get(ENTITY_ID_HOME) + if zone_home is not None: + client = await self.hass.async_add_executor_job( + partial( + anthropic.AsyncAnthropic, + api_key=self._get_entry().data[CONF_API_KEY], + ) + ) + location_schema = vol.Schema( + { + vol.Optional( + CONF_WEB_SEARCH_CITY, + description="Free text input for the city, e.g. `San Francisco`", + ): str, + vol.Optional( + CONF_WEB_SEARCH_REGION, + description="Free text input for the region, e.g. `California`", + ): str, + } + ) + response = await client.messages.create( + model=RECOMMENDED_CHAT_MODEL, + messages=[ + { + "role": "user", + "content": "Where are the following coordinates located: " + f"({zone_home.attributes[ATTR_LATITUDE]}," + f" {zone_home.attributes[ATTR_LONGITUDE]})? Please respond " + "only with a JSON object using the following schema:\n" + f"{convert(location_schema)}", + }, + { + "role": "assistant", + "content": "{", # hints the model to skip any preamble + }, + ], + max_tokens=RECOMMENDED_MAX_TOKENS, + ) + _LOGGER.debug("Model response: %s", response.content) + location_data = location_schema( + json.loads( + "{" + + "".join( + block.text + for block in response.content + if isinstance(block, anthropic.types.TextBlock) + ) + ) + or {} + ) + + if self.hass.config.country: + location_data[CONF_WEB_SEARCH_COUNTRY] = self.hass.config.country + location_data[CONF_WEB_SEARCH_TIMEZONE] = self.hass.config.time_zone + + _LOGGER.debug("Location data: %s", location_data) + + return location_data + async_step_user = async_step_set_options async_step_reconfigure = async_step_set_options @@ -273,6 +363,18 @@ def anthropic_config_option_schema( CONF_THINKING_BUDGET, default=RECOMMENDED_THINKING_BUDGET, ): int, + vol.Optional( + CONF_WEB_SEARCH, + default=RECOMMENDED_WEB_SEARCH, + ): bool, + vol.Optional( + CONF_WEB_SEARCH_MAX_USES, + default=RECOMMENDED_WEB_SEARCH_MAX_USES, + ): int, + vol.Optional( + CONF_WEB_SEARCH_USER_LOCATION, + default=RECOMMENDED_WEB_SEARCH_USER_LOCATION, + ): bool, } ) return schema diff --git a/homeassistant/components/anthropic/const.py b/homeassistant/components/anthropic/const.py index 395f7fa8a81133..6c2342838a23a0 100644 --- a/homeassistant/components/anthropic/const.py +++ b/homeassistant/components/anthropic/const.py @@ -18,9 +18,26 @@ CONF_THINKING_BUDGET = "thinking_budget" RECOMMENDED_THINKING_BUDGET = 0 MIN_THINKING_BUDGET = 1024 +CONF_WEB_SEARCH = "web_search" +RECOMMENDED_WEB_SEARCH = False +CONF_WEB_SEARCH_USER_LOCATION = "user_location" +RECOMMENDED_WEB_SEARCH_USER_LOCATION = False +CONF_WEB_SEARCH_MAX_USES = "web_search_max_uses" +RECOMMENDED_WEB_SEARCH_MAX_USES = 5 +CONF_WEB_SEARCH_CITY = "city" +CONF_WEB_SEARCH_REGION = "region" +CONF_WEB_SEARCH_COUNTRY = "country" +CONF_WEB_SEARCH_TIMEZONE = "timezone" NON_THINKING_MODELS = [ "claude-3-5", # Both sonnet and haiku "claude-3-opus", "claude-3-haiku", ] + +WEB_SEARCH_UNSUPPORTED_MODELS = [ + "claude-3-haiku", + "claude-3-opus", + "claude-3-5-sonnet-20240620", + "claude-3-5-sonnet-20241022", +] diff --git a/homeassistant/components/anthropic/entity.py b/homeassistant/components/anthropic/entity.py index 7c58326515e902..81be27d62caf9c 100644 --- a/homeassistant/components/anthropic/entity.py +++ b/homeassistant/components/anthropic/entity.py @@ -1,12 +1,17 @@ """Base entity for Anthropic.""" from collections.abc import AsyncGenerator, Callable, Iterable +from dataclasses import dataclass, field import json from typing import Any import anthropic from anthropic import AsyncStream from anthropic.types import ( + CitationsDelta, + CitationsWebSearchResultLocation, + CitationWebSearchResultLocationParam, + ContentBlockParam, InputJSONDelta, MessageDeltaUsage, MessageParam, @@ -16,11 +21,16 @@ RawContentBlockStopEvent, RawMessageDeltaEvent, RawMessageStartEvent, + RawMessageStopEvent, RedactedThinkingBlock, RedactedThinkingBlockParam, + ServerToolUseBlock, + ServerToolUseBlockParam, SignatureDelta, TextBlock, TextBlockParam, + TextCitation, + TextCitationParam, TextDelta, ThinkingBlock, ThinkingBlockParam, @@ -29,9 +39,15 @@ ThinkingDelta, ToolParam, ToolResultBlockParam, + ToolUnionParam, ToolUseBlock, ToolUseBlockParam, Usage, + WebSearchTool20250305Param, + WebSearchToolRequestErrorParam, + WebSearchToolResultBlock, + WebSearchToolResultBlockParam, + WebSearchToolResultError, ) from anthropic.types.message_create_params import MessageCreateParamsStreaming from voluptuous_openapi import convert @@ -48,6 +64,13 @@ CONF_MAX_TOKENS, CONF_TEMPERATURE, CONF_THINKING_BUDGET, + CONF_WEB_SEARCH, + CONF_WEB_SEARCH_CITY, + CONF_WEB_SEARCH_COUNTRY, + CONF_WEB_SEARCH_MAX_USES, + CONF_WEB_SEARCH_REGION, + CONF_WEB_SEARCH_TIMEZONE, + CONF_WEB_SEARCH_USER_LOCATION, DOMAIN, LOGGER, MIN_THINKING_BUDGET, @@ -73,6 +96,69 @@ def _format_tool( ) +@dataclass(slots=True) +class CitationDetails: + """Citation details for a content part.""" + + index: int = 0 + """Start position of the text.""" + + length: int = 0 + """Length of the relevant data.""" + + citations: list[TextCitationParam] = field(default_factory=list) + """Citations for the content part.""" + + +@dataclass(slots=True) +class ContentDetails: + """Native data for AssistantContent.""" + + citation_details: list[CitationDetails] = field(default_factory=list) + + def has_content(self) -> bool: + """Check if there is any content.""" + return any(detail.length > 0 for detail in self.citation_details) + + def has_citations(self) -> bool: + """Check if there are any citations.""" + return any(detail.citations for detail in self.citation_details) + + def add_citation_detail(self) -> None: + """Add a new citation detail.""" + if not self.citation_details or self.citation_details[-1].length > 0: + self.citation_details.append( + CitationDetails( + index=self.citation_details[-1].index + + self.citation_details[-1].length + if self.citation_details + else 0 + ) + ) + + def add_citation(self, citation: TextCitation) -> None: + """Add a citation to the current detail.""" + if not self.citation_details: + self.citation_details.append(CitationDetails()) + citation_param: TextCitationParam | None = None + if isinstance(citation, CitationsWebSearchResultLocation): + citation_param = CitationWebSearchResultLocationParam( + type="web_search_result_location", + title=citation.title, + url=citation.url, + cited_text=citation.cited_text, + encrypted_index=citation.encrypted_index, + ) + if citation_param: + self.citation_details[-1].citations.append(citation_param) + + def delete_empty(self) -> None: + """Delete empty citation details.""" + self.citation_details = [ + detail for detail in self.citation_details if detail.citations + ] + + def _convert_content( chat_content: Iterable[conversation.Content], ) -> list[MessageParam]: @@ -81,15 +167,31 @@ def _convert_content( for content in chat_content: if isinstance(content, conversation.ToolResultContent): - tool_result_block = ToolResultBlockParam( - type="tool_result", - tool_use_id=content.tool_call_id, - content=json.dumps(content.tool_result), - ) - if not messages or messages[-1]["role"] != "user": + if content.tool_name == "web_search": + tool_result_block: ContentBlockParam = WebSearchToolResultBlockParam( + type="web_search_tool_result", + tool_use_id=content.tool_call_id, + content=content.tool_result["content"] + if "content" in content.tool_result + else WebSearchToolRequestErrorParam( + type="web_search_tool_result_error", + error_code=content.tool_result.get("error_code", "unavailable"), # type: ignore[typeddict-item] + ), + ) + external_tool = True + else: + tool_result_block = ToolResultBlockParam( + type="tool_result", + tool_use_id=content.tool_call_id, + content=json.dumps(content.tool_result), + ) + external_tool = False + if not messages or messages[-1]["role"] != ( + "assistant" if external_tool else "user" + ): messages.append( MessageParam( - role="user", + role="assistant" if external_tool else "user", content=[tool_result_block], ) ) @@ -151,13 +253,56 @@ def _convert_content( redacted_thinking_block ) if content.content: - messages[-1]["content"].append( # type: ignore[union-attr] - TextBlockParam(type="text", text=content.content) - ) + current_index = 0 + for detail in ( + content.native.citation_details + if isinstance(content.native, ContentDetails) + else [CitationDetails(length=len(content.content))] + ): + if detail.index > current_index: + # Add text block for any text without citations + messages[-1]["content"].append( # type: ignore[union-attr] + TextBlockParam( + type="text", + text=content.content[current_index : detail.index], + ) + ) + messages[-1]["content"].append( # type: ignore[union-attr] + TextBlockParam( + type="text", + text=content.content[ + detail.index : detail.index + detail.length + ], + citations=detail.citations, + ) + if detail.citations + else TextBlockParam( + type="text", + text=content.content[ + detail.index : detail.index + detail.length + ], + ) + ) + current_index = detail.index + detail.length + if current_index < len(content.content): + # Add text block for any remaining text without citations + messages[-1]["content"].append( # type: ignore[union-attr] + TextBlockParam( + type="text", + text=content.content[current_index:], + ) + ) if content.tool_calls: messages[-1]["content"].extend( # type: ignore[union-attr] [ - ToolUseBlockParam( + ServerToolUseBlockParam( + type="server_tool_use", + id=tool_call.id, + name="web_search", + input=tool_call.tool_args, + ) + if tool_call.external and tool_call.tool_name == "web_search" + else ToolUseBlockParam( type="tool_use", id=tool_call.id, name=tool_call.tool_name, @@ -173,10 +318,12 @@ def _convert_content( return messages -async def _transform_stream( +async def _transform_stream( # noqa: C901 - This is complex, but better to have it in one place chat_log: conversation.ChatLog, stream: AsyncStream[MessageStreamEvent], -) -> AsyncGenerator[conversation.AssistantContentDeltaDict]: +) -> AsyncGenerator[ + conversation.AssistantContentDeltaDict | conversation.ToolResultContentDeltaDict +]: """Transform the response stream into HA format. A typical stream of responses might look something like the following: @@ -209,11 +356,13 @@ async def _transform_stream( if stream is None: raise TypeError("Expected a stream of messages") - current_tool_block: ToolUseBlockParam | None = None + current_tool_block: ToolUseBlockParam | ServerToolUseBlockParam | None = None current_tool_args: str + content_details = ContentDetails() + content_details.add_citation_detail() input_usage: Usage | None = None - has_content = False has_native = False + first_block: bool async for response in stream: LOGGER.debug("Received response: %s", response) @@ -222,6 +371,7 @@ async def _transform_stream( if response.message.role != "assistant": raise ValueError("Unexpected message role") input_usage = response.message.usage + first_block = True elif isinstance(response, RawContentBlockStartEvent): if isinstance(response.content_block, ToolUseBlock): current_tool_block = ToolUseBlockParam( @@ -232,17 +382,37 @@ async def _transform_stream( ) current_tool_args = "" elif isinstance(response.content_block, TextBlock): - if has_content: + if ( # Do not start a new assistant content just for citations, concatenate consecutive blocks with citations instead. + first_block + or ( + not content_details.has_citations() + and response.content_block.citations is None + and content_details.has_content() + ) + ): + if content_details.has_citations(): + content_details.delete_empty() + yield {"native": content_details} + content_details = ContentDetails() yield {"role": "assistant"} has_native = False - has_content = True + first_block = False + content_details.add_citation_detail() if response.content_block.text: + content_details.citation_details[-1].length += len( + response.content_block.text + ) yield {"content": response.content_block.text} elif isinstance(response.content_block, ThinkingBlock): - if has_native: + if first_block or has_native: + if content_details.has_citations(): + content_details.delete_empty() + yield {"native": content_details} + content_details = ContentDetails() + content_details.add_citation_detail() yield {"role": "assistant"} has_native = False - has_content = False + first_block = False elif isinstance(response.content_block, RedactedThinkingBlock): LOGGER.debug( "Some of Claude’s internal reasoning has been automatically " @@ -250,15 +420,60 @@ async def _transform_stream( "responses" ) if has_native: + if content_details.has_citations(): + content_details.delete_empty() + yield {"native": content_details} + content_details = ContentDetails() + content_details.add_citation_detail() yield {"role": "assistant"} has_native = False - has_content = False + first_block = False yield {"native": response.content_block} has_native = True + elif isinstance(response.content_block, ServerToolUseBlock): + current_tool_block = ServerToolUseBlockParam( + type="server_tool_use", + id=response.content_block.id, + name=response.content_block.name, + input="", + ) + current_tool_args = "" + elif isinstance(response.content_block, WebSearchToolResultBlock): + if content_details.has_citations(): + content_details.delete_empty() + yield {"native": content_details} + content_details = ContentDetails() + content_details.add_citation_detail() + yield { + "role": "tool_result", + "tool_call_id": response.content_block.tool_use_id, + "tool_name": "web_search", + "tool_result": { + "type": "web_search_tool_result_error", + "error_code": response.content_block.content.error_code, + } + if isinstance( + response.content_block.content, WebSearchToolResultError + ) + else { + "content": [ + { + "type": "web_search_result", + "encrypted_content": block.encrypted_content, + "page_age": block.page_age, + "title": block.title, + "url": block.url, + } + for block in response.content_block.content + ] + }, + } + first_block = True elif isinstance(response, RawContentBlockDeltaEvent): if isinstance(response.delta, InputJSONDelta): current_tool_args += response.delta.partial_json elif isinstance(response.delta, TextDelta): + content_details.citation_details[-1].length += len(response.delta.text) yield {"content": response.delta.text} elif isinstance(response.delta, ThinkingDelta): yield {"thinking_content": response.delta.thinking} @@ -271,6 +486,8 @@ async def _transform_stream( ) } has_native = True + elif isinstance(response.delta, CitationsDelta): + content_details.add_citation(response.delta.citation) elif isinstance(response, RawContentBlockStopEvent): if current_tool_block is not None: tool_args = json.loads(current_tool_args) if current_tool_args else {} @@ -281,6 +498,7 @@ async def _transform_stream( id=current_tool_block["id"], tool_name=current_tool_block["name"], tool_args=tool_args, + external=current_tool_block["type"] == "server_tool_use", ) ] } @@ -290,6 +508,12 @@ async def _transform_stream( chat_log.async_trace(_create_token_stats(input_usage, usage)) if response.delta.stop_reason == "refusal": raise HomeAssistantError("Potential policy violation detected") + elif isinstance(response, RawMessageStopEvent): + if content_details.has_citations(): + content_details.delete_empty() + yield {"native": content_details} + content_details = ContentDetails() + content_details.add_citation_detail() def _create_token_stats( @@ -337,21 +561,11 @@ async def _async_handle_chat_log( """Generate an answer for the chat log.""" options = self.subentry.data - tools: list[ToolParam] | None = None - if chat_log.llm_api: - tools = [ - _format_tool(tool, chat_log.llm_api.custom_serializer) - for tool in chat_log.llm_api.tools - ] - system = chat_log.content[0] if not isinstance(system, conversation.SystemContent): raise TypeError("First message must be a system message") messages = _convert_content(chat_log.content[1:]) - client = self.entry.runtime_data - - thinking_budget = options.get(CONF_THINKING_BUDGET, RECOMMENDED_THINKING_BUDGET) model = options.get(CONF_CHAT_MODEL, RECOMMENDED_CHAT_MODEL) model_args = MessageCreateParamsStreaming( @@ -361,8 +575,8 @@ async def _async_handle_chat_log( system=system.content, stream=True, ) - if tools: - model_args["tools"] = tools + + thinking_budget = options.get(CONF_THINKING_BUDGET, RECOMMENDED_THINKING_BUDGET) if ( not model.startswith(tuple(NON_THINKING_MODELS)) and thinking_budget >= MIN_THINKING_BUDGET @@ -376,6 +590,34 @@ async def _async_handle_chat_log( CONF_TEMPERATURE, RECOMMENDED_TEMPERATURE ) + tools: list[ToolUnionParam] = [] + if chat_log.llm_api: + tools = [ + _format_tool(tool, chat_log.llm_api.custom_serializer) + for tool in chat_log.llm_api.tools + ] + + if options.get(CONF_WEB_SEARCH): + web_search = WebSearchTool20250305Param( + name="web_search", + type="web_search_20250305", + max_uses=options.get(CONF_WEB_SEARCH_MAX_USES), + ) + if options.get(CONF_WEB_SEARCH_USER_LOCATION): + web_search["user_location"] = { + "type": "approximate", + "city": options.get(CONF_WEB_SEARCH_CITY, ""), + "region": options.get(CONF_WEB_SEARCH_REGION, ""), + "country": options.get(CONF_WEB_SEARCH_COUNTRY, ""), + "timezone": options.get(CONF_WEB_SEARCH_TIMEZONE, ""), + } + tools.append(web_search) + + if tools: + model_args["tools"] = tools + + client = self.entry.runtime_data + # To prevent infinite loops, we limit the number of iterations for _iteration in range(MAX_TOOL_ITERATIONS): try: diff --git a/homeassistant/components/anthropic/strings.json b/homeassistant/components/anthropic/strings.json index 983260a3c95aaf..7ac8f57c491e8b 100644 --- a/homeassistant/components/anthropic/strings.json +++ b/homeassistant/components/anthropic/strings.json @@ -35,11 +35,17 @@ "temperature": "Temperature", "llm_hass_api": "[%key:common::config_flow::data::llm_hass_api%]", "recommended": "Recommended model settings", - "thinking_budget_tokens": "Thinking budget" + "thinking_budget": "Thinking budget", + "web_search": "Enable web search", + "web_search_max_uses": "Maximum web searches", + "user_location": "Include home location" }, "data_description": { "prompt": "Instruct how the LLM should respond. This can be a template.", - "thinking_budget_tokens": "The number of tokens the model can use to think about the response out of the total maximum number of tokens. Set to 1024 or greater to enable extended thinking." + "thinking_budget": "The number of tokens the model can use to think about the response out of the total maximum number of tokens. Set to 1024 or greater to enable extended thinking.", + "web_search": "The web search tool gives Claude direct access to real-time web content, allowing it to answer questions with up-to-date information beyond its knowledge cutoff", + "web_search_max_uses": "Limit the number of searches performed per response", + "user_location": "Localize search results based on home location" } } }, @@ -48,7 +54,8 @@ "entry_not_loaded": "Cannot add things while the configuration is disabled." }, "error": { - "thinking_budget_too_large": "Maximum tokens must be greater than the thinking budget." + "thinking_budget_too_large": "Maximum tokens must be greater than the thinking budget.", + "web_search_unsupported_model": "Web search is not supported by the selected model. Please choose a compatible model or disable web search." } } } diff --git a/homeassistant/components/ecovacs/manifest.json b/homeassistant/components/ecovacs/manifest.json index 8d57eda6f4cad8..8adadb66470756 100644 --- a/homeassistant/components/ecovacs/manifest.json +++ b/homeassistant/components/ecovacs/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/ecovacs", "iot_class": "cloud_push", "loggers": ["sleekxmppfs", "sucks", "deebot_client"], - "requirements": ["py-sucks==0.9.11", "deebot-client==15.0.0"] + "requirements": ["py-sucks==0.9.11", "deebot-client==15.1.0"] } diff --git a/homeassistant/components/elevenlabs/__init__.py b/homeassistant/components/elevenlabs/__init__.py index a930dea43edfc9..ea1cd9d63ac6aa 100644 --- a/homeassistant/components/elevenlabs/__init__.py +++ b/homeassistant/components/elevenlabs/__init__.py @@ -3,6 +3,7 @@ from __future__ import annotations from dataclasses import dataclass +import logging from elevenlabs import AsyncElevenLabs, Model from elevenlabs.core import ApiError @@ -18,9 +19,14 @@ ) from homeassistant.helpers.httpx_client import get_async_client -from .const import CONF_MODEL +from .const import CONF_MODEL, CONF_STT_MODEL -PLATFORMS: list[Platform] = [Platform.TTS] +_LOGGER = logging.getLogger(__name__) + +PLATFORMS: list[Platform] = [ + Platform.STT, + Platform.TTS, +] async def get_model_by_id(client: AsyncElevenLabs, model_id: str) -> Model | None: @@ -39,6 +45,7 @@ class ElevenLabsData: client: AsyncElevenLabs model: Model + stt_model: str type ElevenLabsConfigEntry = ConfigEntry[ElevenLabsData] @@ -62,7 +69,9 @@ async def async_setup_entry(hass: HomeAssistant, entry: ElevenLabsConfigEntry) - if model is None or (not model.languages): raise ConfigEntryError("Model could not be resolved") - entry.runtime_data = ElevenLabsData(client=client, model=model) + entry.runtime_data = ElevenLabsData( + client=client, model=model, stt_model=entry.options[CONF_STT_MODEL] + ) await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) return True @@ -78,3 +87,44 @@ async def update_listener( ) -> None: """Handle options update.""" await hass.config_entries.async_reload(config_entry.entry_id) + + +async def async_migrate_entry( + hass: HomeAssistant, config_entry: ElevenLabsConfigEntry +) -> bool: + """Migrate old config entry to new format.""" + + _LOGGER.debug( + "Migrating configuration from version %s.%s", + config_entry.version, + config_entry.minor_version, + ) + + if config_entry.version > 1: + # This means the user has downgraded from a future version + return False + + if config_entry.version == 1: + new_options = {**config_entry.options} + + if config_entry.minor_version < 2: + # Add defaults only if they’re not already present + if "stt_auto_language" not in new_options: + new_options["stt_auto_language"] = False + if "stt_model" not in new_options: + new_options["stt_model"] = "scribe_v1" + + hass.config_entries.async_update_entry( + config_entry, + options=new_options, + minor_version=2, + version=1, + ) + + _LOGGER.debug( + "Migration to configuration version %s.%s successful", + config_entry.version, + config_entry.minor_version, + ) + + return True # already up to date diff --git a/homeassistant/components/elevenlabs/config_flow.py b/homeassistant/components/elevenlabs/config_flow.py index fc2482358343da..6e1baec08eff90 100644 --- a/homeassistant/components/elevenlabs/config_flow.py +++ b/homeassistant/components/elevenlabs/config_flow.py @@ -25,15 +25,20 @@ CONF_MODEL, CONF_SIMILARITY, CONF_STABILITY, + CONF_STT_AUTO_LANGUAGE, + CONF_STT_MODEL, CONF_STYLE, CONF_USE_SPEAKER_BOOST, CONF_VOICE, - DEFAULT_MODEL, DEFAULT_SIMILARITY, DEFAULT_STABILITY, + DEFAULT_STT_AUTO_LANGUAGE, + DEFAULT_STT_MODEL, DEFAULT_STYLE, + DEFAULT_TTS_MODEL, DEFAULT_USE_SPEAKER_BOOST, DOMAIN, + STT_MODELS, ) USER_STEP_SCHEMA = vol.Schema({vol.Required(CONF_API_KEY): str}) @@ -68,6 +73,7 @@ class ElevenLabsConfigFlow(ConfigFlow, domain=DOMAIN): """Handle a config flow for ElevenLabs text-to-speech.""" VERSION = 1 + MINOR_VERSION = 2 async def async_step_user( self, user_input: dict[str, Any] | None = None @@ -88,7 +94,12 @@ async def async_step_user( return self.async_create_entry( title="ElevenLabs", data=user_input, - options={CONF_MODEL: DEFAULT_MODEL, CONF_VOICE: list(voices)[0]}, + options={ + CONF_MODEL: DEFAULT_TTS_MODEL, + CONF_VOICE: list(voices)[0], + CONF_STT_MODEL: DEFAULT_STT_MODEL, + CONF_STT_AUTO_LANGUAGE: False, + }, ) return self.async_show_form( step_id="user", data_schema=USER_STEP_SCHEMA, errors=errors @@ -113,6 +124,9 @@ def __init__(self, config_entry: ElevenLabsConfigEntry) -> None: self.models: dict[str, str] = {} self.model: str | None = None self.voice: str | None = None + self.stt_models: dict[str, str] = STT_MODELS + self.stt_model: str | None = None + self.auto_language: bool | None = None async def async_step_init( self, user_input: dict[str, Any] | None = None @@ -126,6 +140,8 @@ async def async_step_init( if user_input is not None: self.model = user_input[CONF_MODEL] self.voice = user_input[CONF_VOICE] + self.stt_model = user_input[CONF_STT_MODEL] + self.auto_language = user_input[CONF_STT_AUTO_LANGUAGE] configure_voice = user_input.pop(CONF_CONFIGURE_VOICE) if configure_voice: return await self.async_step_voice_settings() @@ -165,6 +181,22 @@ def elevenlabs_config_option_schema(self) -> vol.Schema: ] ) ), + vol.Required( + CONF_STT_MODEL, + ): SelectSelector( + SelectSelectorConfig( + options=[ + SelectOptionDict(label=model_name, value=model_id) + for model_id, model_name in self.stt_models.items() + ] + ) + ), + vol.Required( + CONF_STT_AUTO_LANGUAGE, + default=self.config_entry.options.get( + CONF_STT_AUTO_LANGUAGE, DEFAULT_STT_AUTO_LANGUAGE + ), + ): bool, vol.Required(CONF_CONFIGURE_VOICE, default=False): bool, } ), @@ -179,6 +211,8 @@ async def async_step_voice_settings( if user_input is not None: user_input[CONF_MODEL] = self.model user_input[CONF_VOICE] = self.voice + user_input[CONF_STT_MODEL] = self.stt_model + user_input[CONF_STT_AUTO_LANGUAGE] = self.auto_language return self.async_create_entry( title="ElevenLabs", data=user_input, diff --git a/homeassistant/components/elevenlabs/const.py b/homeassistant/components/elevenlabs/const.py index 2629e62d2fca40..5d7aab7dbb6025 100644 --- a/homeassistant/components/elevenlabs/const.py +++ b/homeassistant/components/elevenlabs/const.py @@ -7,12 +7,123 @@ CONF_CONFIGURE_VOICE = "configure_voice" CONF_STABILITY = "stability" CONF_SIMILARITY = "similarity" +CONF_STT_AUTO_LANGUAGE = "stt_auto_language" +CONF_STT_MODEL = "stt_model" CONF_STYLE = "style" CONF_USE_SPEAKER_BOOST = "use_speaker_boost" DOMAIN = "elevenlabs" -DEFAULT_MODEL = "eleven_multilingual_v2" +DEFAULT_TTS_MODEL = "eleven_multilingual_v2" DEFAULT_STABILITY = 0.5 DEFAULT_SIMILARITY = 0.75 +DEFAULT_STT_AUTO_LANGUAGE = False +DEFAULT_STT_MODEL = "scribe_v1" DEFAULT_STYLE = 0 DEFAULT_USE_SPEAKER_BOOST = True + +STT_LANGUAGES = [ + "af-ZA", # Afrikaans + "am-ET", # Amharic + "ar-SA", # Arabic + "hy-AM", # Armenian + "as-IN", # Assamese + "ast-ES", # Asturian + "az-AZ", # Azerbaijani + "be-BY", # Belarusian + "bn-IN", # Bengali + "bs-BA", # Bosnian + "bg-BG", # Bulgarian + "my-MM", # Burmese + "yue-HK", # Cantonese + "ca-ES", # Catalan + "ceb-PH", # Cebuano + "ny-MW", # Chichewa + "hr-HR", # Croatian + "cs-CZ", # Czech + "da-DK", # Danish + "nl-NL", # Dutch + "en-US", # English + "et-EE", # Estonian + "fil-PH", # Filipino + "fi-FI", # Finnish + "fr-FR", # French + "ff-SN", # Fulah + "gl-ES", # Galician + "lg-UG", # Ganda + "ka-GE", # Georgian + "de-DE", # German + "el-GR", # Greek + "gu-IN", # Gujarati + "ha-NG", # Hausa + "he-IL", # Hebrew + "hi-IN", # Hindi + "hu-HU", # Hungarian + "is-IS", # Icelandic + "ig-NG", # Igbo + "id-ID", # Indonesian + "ga-IE", # Irish + "it-IT", # Italian + "ja-JP", # Japanese + "jv-ID", # Javanese + "kea-CV", # Kabuverdianu + "kn-IN", # Kannada + "kk-KZ", # Kazakh + "km-KH", # Khmer + "ko-KR", # Korean + "ku-TR", # Kurdish + "ky-KG", # Kyrgyz + "lo-LA", # Lao + "lv-LV", # Latvian + "ln-CD", # Lingala + "lt-LT", # Lithuanian + "luo-KE", # Luo + "lb-LU", # Luxembourgish + "mk-MK", # Macedonian + "ms-MY", # Malay + "ml-IN", # Malayalam + "mt-MT", # Maltese + "zh-CN", # Mandarin Chinese + "mi-NZ", # Māori + "mr-IN", # Marathi + "mn-MN", # Mongolian + "ne-NP", # Nepali + "nso-ZA", # Northern Sotho + "no-NO", # Norwegian + "oc-FR", # Occitan + "or-IN", # Odia + "ps-AF", # Pashto + "fa-IR", # Persian + "pl-PL", # Polish + "pt-PT", # Portuguese + "pa-IN", # Punjabi + "ro-RO", # Romanian + "ru-RU", # Russian + "sr-RS", # Serbian + "sn-ZW", # Shona + "sd-PK", # Sindhi + "sk-SK", # Slovak + "sl-SI", # Slovenian + "so-SO", # Somali + "es-ES", # Spanish + "sw-KE", # Swahili + "sv-SE", # Swedish + "ta-IN", # Tamil + "tg-TJ", # Tajik + "te-IN", # Telugu + "th-TH", # Thai + "tr-TR", # Turkish + "uk-UA", # Ukrainian + "umb-AO", # Umbundu + "ur-PK", # Urdu + "uz-UZ", # Uzbek + "vi-VN", # Vietnamese + "cy-GB", # Welsh + "wo-SN", # Wolof + "xh-ZA", # Xhosa + "zu-ZA", # Zulu +] + +STT_MODELS = { + "scribe_v1": "Scribe v1", + "scribe_v1_experimental": "Scribe v1 Experimental", +} diff --git a/homeassistant/components/elevenlabs/strings.json b/homeassistant/components/elevenlabs/strings.json index eb497f1a7a6ad9..a3651cf98cddee 100644 --- a/homeassistant/components/elevenlabs/strings.json +++ b/homeassistant/components/elevenlabs/strings.json @@ -21,11 +21,15 @@ "data": { "voice": "Voice", "model": "Model", + "stt_model": "Speech-to-Text Model", + "stt_auto_language": "Auto-detect language", "configure_voice": "Configure advanced voice settings" }, "data_description": { - "voice": "Voice to use for the TTS.", + "voice": "Voice to use for text-to-speech.", "model": "ElevenLabs model to use. Please note that not all models support all languages equally well.", + "stt_model": "Speech-to-Text model to use.", + "stt_auto_language": "Automatically detect the spoken language for speech-to-text.", "configure_voice": "Configure advanced voice settings. Find more information in the ElevenLabs documentation." } }, @@ -44,5 +48,17 @@ } } } + }, + "entity": { + "tts": { + "elevenlabs_tts": { + "name": "Text-to-Speech" + } + }, + "stt": { + "elevenlabs_stt": { + "name": "Speech-to-Text" + } + } } } diff --git a/homeassistant/components/elevenlabs/stt.py b/homeassistant/components/elevenlabs/stt.py new file mode 100644 index 00000000000000..8c0779579cf185 --- /dev/null +++ b/homeassistant/components/elevenlabs/stt.py @@ -0,0 +1,207 @@ +"""Support for the ElevenLabs speech-to-text service.""" + +from __future__ import annotations + +from collections.abc import AsyncIterable +from io import BytesIO +import logging + +from elevenlabs import AsyncElevenLabs +from elevenlabs.core import ApiError +from elevenlabs.types import Model + +from homeassistant.components import stt +from homeassistant.components.stt import ( + AudioBitRates, + AudioChannels, + AudioCodecs, + AudioFormats, + AudioSampleRates, + SpeechMetadata, + SpeechResultState, + SpeechToTextEntity, +) +from homeassistant.core import HomeAssistant +from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from . import ElevenLabsConfigEntry +from .const import ( + CONF_STT_AUTO_LANGUAGE, + DEFAULT_STT_AUTO_LANGUAGE, + DOMAIN, + STT_LANGUAGES, +) + +_LOGGER = logging.getLogger(__name__) +PARALLEL_UPDATES = 10 + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ElevenLabsConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up ElevenLabs stt platform via config entry.""" + client = config_entry.runtime_data.client + auto_detect = config_entry.options.get( + CONF_STT_AUTO_LANGUAGE, DEFAULT_STT_AUTO_LANGUAGE + ) + + async_add_entities( + [ + ElevenLabsSTTEntity( + client, + config_entry.runtime_data.model, + config_entry.runtime_data.stt_model, + config_entry.entry_id, + auto_detect_language=auto_detect, + ) + ] + ) + + +class ElevenLabsSTTEntity(SpeechToTextEntity): + """The ElevenLabs STT API entity.""" + + _attr_has_entity_name = True + _attr_translation_key = "elevenlabs_stt" + + def __init__( + self, + client: AsyncElevenLabs, + model: Model, + stt_model: str, + entry_id: str, + auto_detect_language: bool = False, + ) -> None: + """Init ElevenLabs TTS service.""" + self._client = client + self._auto_detect_language = auto_detect_language + self._stt_model = stt_model + + # Entity attributes + self._attr_unique_id = entry_id + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, entry_id)}, + manufacturer="ElevenLabs", + model=model.name, + name="ElevenLabs", + entry_type=DeviceEntryType.SERVICE, + ) + + @property + def supported_languages(self) -> list[str]: + """Return a list of supported languages.""" + return STT_LANGUAGES + + @property + def supported_formats(self) -> list[AudioFormats]: + """Return a list of supported formats.""" + return [AudioFormats.WAV, AudioFormats.OGG] + + @property + def supported_codecs(self) -> list[AudioCodecs]: + """Return a list of supported codecs.""" + return [AudioCodecs.PCM, AudioCodecs.OPUS] + + @property + def supported_bit_rates(self) -> list[AudioBitRates]: + """Return a list of supported bit rates.""" + return [AudioBitRates.BITRATE_16] + + @property + def supported_sample_rates(self) -> list[AudioSampleRates]: + """Return a list of supported sample rates.""" + return [AudioSampleRates.SAMPLERATE_16000] + + @property + def supported_channels(self) -> list[AudioChannels]: + """Return a list of supported channels.""" + return [ + AudioChannels.CHANNEL_MONO, + AudioChannels.CHANNEL_STEREO, + ] + + async def async_process_audio_stream( + self, metadata: SpeechMetadata, stream: AsyncIterable[bytes] + ) -> stt.SpeechResult: + """Process an audio stream to STT service.""" + _LOGGER.debug( + "Processing audio stream for STT: model=%s, language=%s, format=%s, codec=%s, sample_rate=%s, channels=%s, bit_rate=%s", + self._stt_model, + metadata.language, + metadata.format, + metadata.codec, + metadata.sample_rate, + metadata.channel, + metadata.bit_rate, + ) + + if self._auto_detect_language: + lang_code = None + else: + language = metadata.language + if language.lower() not in [lang.lower() for lang in STT_LANGUAGES]: + _LOGGER.warning("Unsupported language: %s", language) + return stt.SpeechResult(None, SpeechResultState.ERROR) + lang_code = language.split("-")[0] + + raw_pcm_compatible = ( + metadata.codec == AudioCodecs.PCM + and metadata.sample_rate == AudioSampleRates.SAMPLERATE_16000 + and metadata.channel == AudioChannels.CHANNEL_MONO + and metadata.bit_rate == AudioBitRates.BITRATE_16 + ) + if raw_pcm_compatible: + file_format = "pcm_s16le_16" + elif metadata.codec == AudioCodecs.PCM: + _LOGGER.warning("PCM input does not meet expected raw format requirements") + return stt.SpeechResult(None, SpeechResultState.ERROR) + else: + file_format = "other" + + audio = b"" + async for chunk in stream: + audio += chunk + + _LOGGER.debug("Finished reading audio stream, total size: %d bytes", len(audio)) + if not audio: + _LOGGER.warning("No audio received in stream") + return stt.SpeechResult(None, SpeechResultState.ERROR) + + lang_display = lang_code if lang_code else "auto-detected" + + _LOGGER.debug( + "Transcribing audio (%s), format: %s, size: %d bytes", + lang_display, + file_format, + len(audio), + ) + + try: + response = await self._client.speech_to_text.convert( + file=BytesIO(audio), + file_format=file_format, + model_id=self._stt_model, + language_code=lang_code, + tag_audio_events=False, + num_speakers=1, + diarize=False, + ) + except ApiError as exc: + _LOGGER.error("Error during processing of STT request: %s", exc) + return stt.SpeechResult(None, SpeechResultState.ERROR) + + text = response.text or "" + detected_lang_code = response.language_code or "?" + detected_lang_prob = response.language_probability or "?" + + _LOGGER.debug( + "Transcribed text is in language %s (probability %s): %s", + detected_lang_code, + detected_lang_prob, + text, + ) + + return stt.SpeechResult(text, SpeechResultState.SUCCESS) diff --git a/homeassistant/components/elevenlabs/tts.py b/homeassistant/components/elevenlabs/tts.py index fc1a950d4b96d4..21da81cef6f02f 100644 --- a/homeassistant/components/elevenlabs/tts.py +++ b/homeassistant/components/elevenlabs/tts.py @@ -71,7 +71,6 @@ async def async_setup_entry( voices, default_voice_id, config_entry.entry_id, - config_entry.title, voice_settings, ) ] @@ -83,6 +82,8 @@ class ElevenLabsTTSEntity(TextToSpeechEntity): _attr_supported_options = [ATTR_VOICE, ATTR_MODEL] _attr_entity_category = EntityCategory.CONFIG + _attr_has_entity_name = True + _attr_translation_key = "elevenlabs_tts" def __init__( self, @@ -91,7 +92,6 @@ def __init__( voices: list[ElevenLabsVoice], default_voice_id: str, entry_id: str, - title: str, voice_settings: VoiceSettings, ) -> None: """Init ElevenLabs TTS service.""" @@ -112,11 +112,11 @@ def __init__( # Entity attributes self._attr_unique_id = entry_id - self._attr_name = title self._attr_device_info = DeviceInfo( identifiers={(DOMAIN, entry_id)}, manufacturer="ElevenLabs", model=model.name, + name="ElevenLabs", entry_type=DeviceEntryType.SERVICE, ) self._attr_supported_languages = [ diff --git a/homeassistant/components/fritzbox/climate.py b/homeassistant/components/fritzbox/climate.py index ec4b09a2af2cb6..c2f909c085c04b 100644 --- a/homeassistant/components/fritzbox/climate.py +++ b/homeassistant/components/fritzbox/climate.py @@ -13,27 +13,14 @@ ClimateEntityFeature, HVACMode, ) -from homeassistant.const import ( - ATTR_BATTERY_LEVEL, - ATTR_TEMPERATURE, - PRECISION_HALVES, - UnitOfTemperature, -) +from homeassistant.const import ATTR_TEMPERATURE, PRECISION_HALVES, UnitOfTemperature from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from .const import ( - ATTR_STATE_BATTERY_LOW, - ATTR_STATE_HOLIDAY_MODE, - ATTR_STATE_SUMMER_MODE, - ATTR_STATE_WINDOW_OPEN, - DOMAIN, - LOGGER, -) +from .const import DOMAIN, LOGGER from .coordinator import FritzboxConfigEntry, FritzboxDataUpdateCoordinator from .entity import FritzBoxDeviceEntity -from .model import ClimateExtraAttributes from .sensor import value_scheduled_preset HVAC_MODES = [HVACMode.HEAT, HVACMode.OFF] @@ -202,26 +189,6 @@ async def async_set_preset_mode(self, preset_mode: str) -> None: self.check_active_or_lock_mode() await self.async_set_hkr_state(PRESET_API_HKR_STATE_MAPPING[preset_mode]) - @property - def extra_state_attributes(self) -> ClimateExtraAttributes: - """Return the device specific state attributes.""" - # deprecated with #143394, can be removed in 2025.11 - attrs: ClimateExtraAttributes = { - ATTR_STATE_BATTERY_LOW: self.data.battery_low, - } - - # the following attributes are available since fritzos 7 - if self.data.battery_level is not None: - attrs[ATTR_BATTERY_LEVEL] = self.data.battery_level - if self.data.holiday_active is not None: - attrs[ATTR_STATE_HOLIDAY_MODE] = self.data.holiday_active - if self.data.summer_active is not None: - attrs[ATTR_STATE_SUMMER_MODE] = self.data.summer_active - if self.data.window_open is not None: - attrs[ATTR_STATE_WINDOW_OPEN] = self.data.window_open - - return attrs - def check_active_or_lock_mode(self) -> None: """Check if in summer/vacation mode or lock enabled.""" if self.data.holiday_active or self.data.summer_active: diff --git a/homeassistant/components/frontend/manifest.json b/homeassistant/components/frontend/manifest.json index ec5832d1ec610f..85ed62ef2e61eb 100644 --- a/homeassistant/components/frontend/manifest.json +++ b/homeassistant/components/frontend/manifest.json @@ -20,5 +20,5 @@ "documentation": "https://www.home-assistant.io/integrations/frontend", "integration_type": "system", "quality_scale": "internal", - "requirements": ["home-assistant-frontend==20251001.0"] + "requirements": ["home-assistant-frontend==20251001.2"] } diff --git a/homeassistant/components/google_assistant_sdk/config_flow.py b/homeassistant/components/google_assistant_sdk/config_flow.py index 6c010d39c43dd8..466a11bfd3e875 100644 --- a/homeassistant/components/google_assistant_sdk/config_flow.py +++ b/homeassistant/components/google_assistant_sdk/config_flow.py @@ -8,7 +8,12 @@ import voluptuous as vol -from homeassistant.config_entries import SOURCE_REAUTH, ConfigFlowResult, OptionsFlow +from homeassistant.config_entries import ( + SOURCE_REAUTH, + SOURCE_RECONFIGURE, + ConfigFlowResult, + OptionsFlow, +) from homeassistant.core import callback from homeassistant.helpers import config_entry_oauth2_flow @@ -40,6 +45,12 @@ def extra_authorize_data(self) -> dict[str, Any]: "prompt": "consent", } + async def async_step_reconfigure( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle a reconfiguration flow.""" + return await self.async_step_user(user_input) + async def async_step_reauth( self, entry_data: Mapping[str, Any] ) -> ConfigFlowResult: @@ -60,6 +71,10 @@ async def async_oauth_create_entry(self, data: dict[str, Any]) -> ConfigFlowResu return self.async_update_reload_and_abort( self._get_reauth_entry(), data=data ) + if self.source == SOURCE_RECONFIGURE: + return self.async_update_reload_and_abort( + self._get_reconfigure_entry(), data=data + ) return self.async_create_entry( title=DEFAULT_NAME, diff --git a/homeassistant/components/google_assistant_sdk/strings.json b/homeassistant/components/google_assistant_sdk/strings.json index f26fdd4a29c89e..be03cadb408846 100644 --- a/homeassistant/components/google_assistant_sdk/strings.json +++ b/homeassistant/components/google_assistant_sdk/strings.json @@ -30,7 +30,8 @@ "oauth_failed": "[%key:common::config_flow::abort::oauth2_failed%]", "already_configured": "[%key:common::config_flow::abort::already_configured_account%]", "already_in_progress": "[%key:common::config_flow::abort::already_in_progress%]", - "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]" + "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]", + "reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]" }, "create_entry": { "default": "[%key:common::config_flow::create_entry::authenticated%]" diff --git a/homeassistant/components/home_connect/const.py b/homeassistant/components/home_connect/const.py index c1aea03134fa5c..a3e9fab186e5b5 100644 --- a/homeassistant/components/home_connect/const.py +++ b/homeassistant/components/home_connect/const.py @@ -99,6 +99,20 @@ "ConsumerProducts.CleaningRobot.EnumType.CleaningModes.Silent", "ConsumerProducts.CleaningRobot.EnumType.CleaningModes.Standard", "ConsumerProducts.CleaningRobot.EnumType.CleaningModes.Power", + "ConsumerProducts.CleaningRobot.EnumType.CleaningMode.IntelligentMode", + "ConsumerProducts.CleaningRobot.EnumType.CleaningMode.VacuumOnly", + "ConsumerProducts.CleaningRobot.EnumType.CleaningMode.MopOnly", + "ConsumerProducts.CleaningRobot.EnumType.CleaningMode.VacuumAndMop", + "ConsumerProducts.CleaningRobot.EnumType.CleaningMode.MopAfterVacuum", + ) +} + +SUCTION_POWER_OPTIONS = { + bsh_key_to_translation_key(option): option + for option in ( + "ConsumerProducts.CleaningRobot.EnumType.SuctionPower.Silent", + "ConsumerProducts.CleaningRobot.EnumType.SuctionPower.Standard", + "ConsumerProducts.CleaningRobot.EnumType.SuctionPower.Max", ) } @@ -309,6 +323,10 @@ OptionKey.CONSUMER_PRODUCTS_CLEANING_ROBOT_CLEANING_MODE, CLEANING_MODE_OPTIONS, ), + ( + OptionKey.CONSUMER_PRODUCTS_CLEANING_ROBOT_SUCTION_POWER, + SUCTION_POWER_OPTIONS, + ), (OptionKey.CONSUMER_PRODUCTS_COFFEE_MAKER_BEAN_AMOUNT, BEAN_AMOUNT_OPTIONS), ( OptionKey.CONSUMER_PRODUCTS_COFFEE_MAKER_COFFEE_TEMPERATURE, diff --git a/homeassistant/components/home_connect/select.py b/homeassistant/components/home_connect/select.py index 025480828d8651..7f060ea1df50cd 100644 --- a/homeassistant/components/home_connect/select.py +++ b/homeassistant/components/home_connect/select.py @@ -30,6 +30,7 @@ INTENSIVE_LEVEL_OPTIONS, PROGRAMS_TRANSLATION_KEYS_MAP, SPIN_SPEED_OPTIONS, + SUCTION_POWER_OPTIONS, TEMPERATURE_OPTIONS, TRANSLATION_KEYS_PROGRAMS_MAP, VARIO_PERFECT_OPTIONS, @@ -168,6 +169,16 @@ class HomeConnectSelectEntityDescription(SelectEntityDescription): for translation_key, value in CLEANING_MODE_OPTIONS.items() }, ), + HomeConnectSelectEntityDescription( + key=OptionKey.CONSUMER_PRODUCTS_CLEANING_ROBOT_SUCTION_POWER, + translation_key="suction_power", + options=list(SUCTION_POWER_OPTIONS), + translation_key_values=SUCTION_POWER_OPTIONS, + values_translation_key={ + value: translation_key + for translation_key, value in SUCTION_POWER_OPTIONS.items() + }, + ), HomeConnectSelectEntityDescription( key=OptionKey.CONSUMER_PRODUCTS_COFFEE_MAKER_BEAN_AMOUNT, translation_key="bean_amount", diff --git a/homeassistant/components/home_connect/services.yaml b/homeassistant/components/home_connect/services.yaml index a1e9d1217043cd..84242b005df91e 100644 --- a/homeassistant/components/home_connect/services.yaml +++ b/homeassistant/components/home_connect/services.yaml @@ -202,6 +202,22 @@ set_program_and_options: - consumer_products_cleaning_robot_enum_type_cleaning_modes_silent - consumer_products_cleaning_robot_enum_type_cleaning_modes_standard - consumer_products_cleaning_robot_enum_type_cleaning_modes_power + - consumer_products_cleaning_robot_enum_type_cleaning_mode_intelligent_mode + - consumer_products_cleaning_robot_enum_type_cleaning_mode_vacuum_only + - consumer_products_cleaning_robot_enum_type_cleaning_mode_mop_only + - consumer_products_cleaning_robot_enum_type_cleaning_mode_vacuum_and_mop + - consumer_products_cleaning_robot_enum_type_cleaning_mode_mop_after_vacuum + consumer_products_cleaning_robot_option_suction_power: + example: consumer_products_cleaning_robot_enum_type_suction_power_standard + required: false + selector: + select: + mode: dropdown + translation_key: suction_power + options: + - consumer_products_cleaning_robot_enum_type_suction_power_silent + - consumer_products_cleaning_robot_enum_type_suction_power_standard + - consumer_products_cleaning_robot_enum_type_suction_power_max coffee_maker_options: collapsed: true fields: diff --git a/homeassistant/components/home_connect/strings.json b/homeassistant/components/home_connect/strings.json index 2ef931ec52a835..b07f0bf64f4961 100644 --- a/homeassistant/components/home_connect/strings.json +++ b/homeassistant/components/home_connect/strings.json @@ -324,7 +324,19 @@ "options": { "consumer_products_cleaning_robot_enum_type_cleaning_modes_silent": "Silent", "consumer_products_cleaning_robot_enum_type_cleaning_modes_standard": "Standard", - "consumer_products_cleaning_robot_enum_type_cleaning_modes_power": "Power" + "consumer_products_cleaning_robot_enum_type_cleaning_modes_power": "Power", + "consumer_products_cleaning_robot_enum_type_cleaning_mode_intelligent_mode": "Intelligent mode", + "consumer_products_cleaning_robot_enum_type_cleaning_mode_vacuum_only": "Vacuum only", + "consumer_products_cleaning_robot_enum_type_cleaning_mode_mop_only": "Mop only", + "consumer_products_cleaning_robot_enum_type_cleaning_mode_vacuum_and_mop": "Vacuum and mop", + "consumer_products_cleaning_robot_enum_type_cleaning_mode_mop_after_vacuum": "Mop after vacuum" + } + }, + "suction_power": { + "options": { + "consumer_products_cleaning_robot_enum_type_suction_power_silent": "Silent", + "consumer_products_cleaning_robot_enum_type_suction_power_standard": "Standard", + "consumer_products_cleaning_robot_enum_type_suction_power_max": "Max" } }, "bean_amount": { @@ -519,6 +531,10 @@ "name": "Cleaning mode", "description": "Defines the favored cleaning mode." }, + "consumer_products_cleaning_robot_option_suction_power": { + "name": "Suction power", + "description": "Defines the suction power." + }, "consumer_products_coffee_maker_option_bean_amount": { "name": "Bean amount", "description": "Describes the amount of coffee beans used in a coffee machine program." @@ -1196,7 +1212,20 @@ "state": { "consumer_products_cleaning_robot_enum_type_cleaning_modes_silent": "[%key:component::home_connect::selector::cleaning_mode::options::consumer_products_cleaning_robot_enum_type_cleaning_modes_silent%]", "consumer_products_cleaning_robot_enum_type_cleaning_modes_standard": "[%key:component::home_connect::selector::cleaning_mode::options::consumer_products_cleaning_robot_enum_type_cleaning_modes_standard%]", - "consumer_products_cleaning_robot_enum_type_cleaning_modes_power": "[%key:component::home_connect::selector::cleaning_mode::options::consumer_products_cleaning_robot_enum_type_cleaning_modes_power%]" + "consumer_products_cleaning_robot_enum_type_cleaning_modes_power": "[%key:component::home_connect::selector::cleaning_mode::options::consumer_products_cleaning_robot_enum_type_cleaning_modes_power%]", + "consumer_products_cleaning_robot_enum_type_cleaning_mode_intelligent_mode": "[%key:component::home_connect::selector::cleaning_mode::options::consumer_products_cleaning_robot_enum_type_cleaning_mode_intelligent_mode%]", + "consumer_products_cleaning_robot_enum_type_cleaning_mode_vacuum_only": "[%key:component::home_connect::selector::cleaning_mode::options::consumer_products_cleaning_robot_enum_type_cleaning_mode_vacuum_only%]", + "consumer_products_cleaning_robot_enum_type_cleaning_mode_mop_only": "[%key:component::home_connect::selector::cleaning_mode::options::consumer_products_cleaning_robot_enum_type_cleaning_mode_mop_only%]", + "consumer_products_cleaning_robot_enum_type_cleaning_mode_vacuum_and_mop": "[%key:component::home_connect::selector::cleaning_mode::options::consumer_products_cleaning_robot_enum_type_cleaning_mode_vacuum_and_mop%]", + "consumer_products_cleaning_robot_enum_type_cleaning_mode_mop_after_vacuum": "[%key:component::home_connect::selector::cleaning_mode::options::consumer_products_cleaning_robot_enum_type_cleaning_mode_mop_after_vacuum%]" + } + }, + "suction_power": { + "name": "[%key:component::home_connect::services::set_program_and_options::fields::consumer_products_cleaning_robot_option_suction_power::name%]", + "state": { + "consumer_products_cleaning_robot_enum_type_suction_power_silent": "[%key:component::home_connect::selector::suction_power::options::consumer_products_cleaning_robot_enum_type_suction_power_silent%]", + "consumer_products_cleaning_robot_enum_type_suction_power_standard": "[%key:component::home_connect::selector::suction_power::options::consumer_products_cleaning_robot_enum_type_suction_power_standard%]", + "consumer_products_cleaning_robot_enum_type_suction_power_max": "[%key:component::home_connect::selector::suction_power::options::consumer_products_cleaning_robot_enum_type_suction_power_max%]" } }, "bean_amount": { diff --git a/homeassistant/components/homeassistant_hardware/strings.json b/homeassistant/components/homeassistant_hardware/strings.json index 07ed06761fe8f0..561bbfabf7b7cd 100644 --- a/homeassistant/components/homeassistant_hardware/strings.json +++ b/homeassistant/components/homeassistant_hardware/strings.json @@ -67,7 +67,7 @@ } }, "abort": { - "not_hassio_thread": "The OpenThread Border Router add-on can only be installed with Home Assistant OS. If you would like to use the {model} as a Thread border router, please flash the firmware manually using the [web flasher]({docs_web_flasher_url}) and set up OpenThread Border Router to communicate with it.", + "not_hassio_thread": "The OpenThread Border Router add-on can only be installed with Home Assistant OS. If you would like to use the {model} as a Thread border router, please manually set up OpenThread Border Router to communicate with it.", "otbr_addon_already_running": "The OpenThread Border Router add-on is already running, it cannot be installed again.", "zha_still_using_stick": "This {model} is in use by the Zigbee Home Automation integration. Please migrate your Zigbee network to another adapter or delete the integration and try again.", "otbr_still_using_stick": "This {model} is in use by the OpenThread Border Router add-on. If you use the Thread network, make sure you have alternative border routers. Uninstall the add-on and try again.", diff --git a/homeassistant/components/iometer/manifest.json b/homeassistant/components/iometer/manifest.json index 061a2318e04ae7..73a02a98b9a936 100644 --- a/homeassistant/components/iometer/manifest.json +++ b/homeassistant/components/iometer/manifest.json @@ -1,12 +1,12 @@ { "domain": "iometer", "name": "IOmeter", - "codeowners": ["@MaestroOnICe"], + "codeowners": ["@jukrebs"], "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/iometer", "integration_type": "device", "iot_class": "local_polling", "quality_scale": "bronze", - "requirements": ["iometer==0.1.0"], + "requirements": ["iometer==0.2.0"], "zeroconf": ["_iometer._tcp.local."] } diff --git a/homeassistant/components/knx/manifest.json b/homeassistant/components/knx/manifest.json index 2f4669384152fb..4299b8b9695936 100644 --- a/homeassistant/components/knx/manifest.json +++ b/homeassistant/components/knx/manifest.json @@ -11,9 +11,9 @@ "loggers": ["xknx", "xknxproject"], "quality_scale": "silver", "requirements": [ - "xknx==3.9.0", + "xknx==3.9.1", "xknxproject==3.8.2", - "knx-frontend==2025.8.24.205840" + "knx-frontend==2025.10.9.185845" ], "single_config_entry": true } diff --git a/homeassistant/components/matter/climate.py b/homeassistant/components/matter/climate.py index 4b28fe7625b4a1..ec07a564621e07 100644 --- a/homeassistant/components/matter/climate.py +++ b/homeassistant/components/matter/climate.py @@ -2,6 +2,7 @@ from __future__ import annotations +from dataclasses import dataclass from enum import IntEnum from typing import Any @@ -26,7 +27,7 @@ from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from .entity import MatterEntity +from .entity import MatterEntity, MatterEntityDescription from .helpers import get_matter from .models import MatterDiscoverySchema @@ -182,6 +183,11 @@ async def async_setup_entry( matter.register_platform_handler(Platform.CLIMATE, async_add_entities) +@dataclass(frozen=True) +class MatterClimateEntityDescription(ClimateEntityDescription, MatterEntityDescription): + """Describe Matter Climate entities.""" + + class MatterClimate(MatterEntity, ClimateEntity): """Representation of a Matter climate entity.""" @@ -423,7 +429,7 @@ def _get_temperature_in_degrees( DISCOVERY_SCHEMAS = [ MatterDiscoverySchema( platform=Platform.CLIMATE, - entity_description=ClimateEntityDescription( + entity_description=MatterClimateEntityDescription( key="MatterThermostat", name=None, ), diff --git a/homeassistant/components/matter/cover.py b/homeassistant/components/matter/cover.py index 7bef7ea1853bc9..ac182c798d4be7 100644 --- a/homeassistant/components/matter/cover.py +++ b/homeassistant/components/matter/cover.py @@ -2,6 +2,7 @@ from __future__ import annotations +from dataclasses import dataclass from enum import IntEnum from math import floor from typing import Any @@ -22,7 +23,7 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import LOGGER -from .entity import MatterEntity +from .entity import MatterEntity, MatterEntityDescription from .helpers import get_matter from .models import MatterDiscoverySchema @@ -61,10 +62,15 @@ async def async_setup_entry( matter.register_platform_handler(Platform.COVER, async_add_entities) +@dataclass(frozen=True) +class MatterCoverEntityDescription(CoverEntityDescription, MatterEntityDescription): + """Describe Matter Cover entities.""" + + class MatterCover(MatterEntity, CoverEntity): """Representation of a Matter Cover.""" - entity_description: CoverEntityDescription + entity_description: MatterCoverEntityDescription @property def is_closed(self) -> bool | None: @@ -198,7 +204,7 @@ def _update_from_device(self) -> None: DISCOVERY_SCHEMAS = [ MatterDiscoverySchema( platform=Platform.COVER, - entity_description=CoverEntityDescription( + entity_description=MatterCoverEntityDescription( key="MatterCover", name=None, ), @@ -214,7 +220,7 @@ def _update_from_device(self) -> None: ), MatterDiscoverySchema( platform=Platform.COVER, - entity_description=CoverEntityDescription( + entity_description=MatterCoverEntityDescription( key="MatterCoverPositionAwareLift", name=None ), entity_class=MatterCover, @@ -229,7 +235,7 @@ def _update_from_device(self) -> None: ), MatterDiscoverySchema( platform=Platform.COVER, - entity_description=CoverEntityDescription( + entity_description=MatterCoverEntityDescription( key="MatterCoverPositionAwareTilt", name=None ), entity_class=MatterCover, @@ -244,7 +250,7 @@ def _update_from_device(self) -> None: ), MatterDiscoverySchema( platform=Platform.COVER, - entity_description=CoverEntityDescription( + entity_description=MatterCoverEntityDescription( key="MatterCoverPositionAwareLiftAndTilt", name=None ), entity_class=MatterCover, diff --git a/homeassistant/components/matter/event.py b/homeassistant/components/matter/event.py index fa7d96ed1aefb0..6f4205772a47eb 100644 --- a/homeassistant/components/matter/event.py +++ b/homeassistant/components/matter/event.py @@ -2,6 +2,7 @@ from __future__ import annotations +from dataclasses import dataclass from typing import Any from chip.clusters import Objects as clusters @@ -18,7 +19,7 @@ from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from .entity import MatterEntity +from .entity import MatterEntity, MatterEntityDescription from .helpers import get_matter from .models import MatterDiscoverySchema @@ -46,6 +47,11 @@ async def async_setup_entry( matter.register_platform_handler(Platform.EVENT, async_add_entities) +@dataclass(frozen=True) +class MatterEventEntityDescription(EventEntityDescription, MatterEntityDescription): + """Describe Matter Event entities.""" + + class MatterEventEntity(MatterEntity, EventEntity): """Representation of a Matter Event entity.""" @@ -132,7 +138,7 @@ def _on_matter_node_event( DISCOVERY_SCHEMAS = [ MatterDiscoverySchema( platform=Platform.EVENT, - entity_description=EventEntityDescription( + entity_description=MatterEventEntityDescription( key="GenericSwitch", device_class=EventDeviceClass.BUTTON, translation_key="button", diff --git a/homeassistant/components/matter/fan.py b/homeassistant/components/matter/fan.py index 2c9e190d58a8d2..a0e85a2df2f4de 100644 --- a/homeassistant/components/matter/fan.py +++ b/homeassistant/components/matter/fan.py @@ -2,6 +2,7 @@ from __future__ import annotations +from dataclasses import dataclass from typing import TYPE_CHECKING, Any from chip.clusters import Objects as clusters @@ -18,7 +19,7 @@ from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from .entity import MatterEntity +from .entity import MatterEntity, MatterEntityDescription from .helpers import get_matter from .models import MatterDiscoverySchema @@ -52,6 +53,11 @@ async def async_setup_entry( matter.register_platform_handler(Platform.FAN, async_add_entities) +@dataclass(frozen=True) +class MatterFanEntityDescription(FanEntityDescription, MatterEntityDescription): + """Describe Matter Fan entities.""" + + class MatterFan(MatterEntity, FanEntity): """Representation of a Matter fan.""" @@ -308,7 +314,7 @@ def _calculate_features( DISCOVERY_SCHEMAS = [ MatterDiscoverySchema( platform=Platform.FAN, - entity_description=FanEntityDescription( + entity_description=MatterFanEntityDescription( key="MatterFan", name=None, ), diff --git a/homeassistant/components/matter/light.py b/homeassistant/components/matter/light.py index e01cc54f46d3fc..0a870167d465bc 100644 --- a/homeassistant/components/matter/light.py +++ b/homeassistant/components/matter/light.py @@ -2,6 +2,7 @@ from __future__ import annotations +from dataclasses import dataclass from typing import Any from chip.clusters import Objects as clusters @@ -29,7 +30,7 @@ from homeassistant.util import color as color_util from .const import LOGGER -from .entity import MatterEntity +from .entity import MatterEntity, MatterEntityDescription from .helpers import get_matter from .models import MatterDiscoverySchema from .util import ( @@ -85,10 +86,15 @@ async def async_setup_entry( matter.register_platform_handler(Platform.LIGHT, async_add_entities) +@dataclass(frozen=True) +class MatterLightEntityDescription(LightEntityDescription, MatterEntityDescription): + """Describe Matter Light entities.""" + + class MatterLight(MatterEntity, LightEntity): """Representation of a Matter light.""" - entity_description: LightEntityDescription + entity_description: MatterLightEntityDescription _supports_brightness = False _supports_color = False _supports_color_temperature = False @@ -458,7 +464,7 @@ def _check_transition_blocklist(self) -> None: DISCOVERY_SCHEMAS = [ MatterDiscoverySchema( platform=Platform.LIGHT, - entity_description=LightEntityDescription( + entity_description=MatterLightEntityDescription( key="MatterLight", name=None, ), @@ -487,7 +493,7 @@ def _check_transition_blocklist(self) -> None: # Additional schema to match (HS Color) lights with incorrect/missing device type MatterDiscoverySchema( platform=Platform.LIGHT, - entity_description=LightEntityDescription( + entity_description=MatterLightEntityDescription( key="MatterHSColorLightFallback", name=None, ), @@ -508,7 +514,7 @@ def _check_transition_blocklist(self) -> None: # Additional schema to match (XY Color) lights with incorrect/missing device type MatterDiscoverySchema( platform=Platform.LIGHT, - entity_description=LightEntityDescription( + entity_description=MatterLightEntityDescription( key="MatterXYColorLightFallback", name=None, ), @@ -529,7 +535,7 @@ def _check_transition_blocklist(self) -> None: # Additional schema to match (color temperature) lights with incorrect/missing device type MatterDiscoverySchema( platform=Platform.LIGHT, - entity_description=LightEntityDescription( + entity_description=MatterLightEntityDescription( key="MatterColorTemperatureLightFallback", name=None, ), diff --git a/homeassistant/components/matter/lock.py b/homeassistant/components/matter/lock.py index c264ce6589633f..230714accbd7af 100644 --- a/homeassistant/components/matter/lock.py +++ b/homeassistant/components/matter/lock.py @@ -3,6 +3,7 @@ from __future__ import annotations import asyncio +from dataclasses import dataclass from typing import Any from chip.clusters import Objects as clusters @@ -19,7 +20,7 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import LOGGER -from .entity import MatterEntity +from .entity import MatterEntity, MatterEntityDescription from .helpers import get_matter from .models import MatterDiscoverySchema @@ -52,6 +53,11 @@ async def async_setup_entry( matter.register_platform_handler(Platform.LOCK, async_add_entities) +@dataclass(frozen=True) +class MatterLockEntityDescription(LockEntityDescription, MatterEntityDescription): + """Describe Matter Lock entities.""" + + class MatterLock(MatterEntity, LockEntity): """Representation of a Matter lock.""" @@ -254,7 +260,7 @@ def _calculate_features( DISCOVERY_SCHEMAS = [ MatterDiscoverySchema( platform=Platform.LOCK, - entity_description=LockEntityDescription( + entity_description=MatterLockEntityDescription( key="MatterLock", name=None, ), diff --git a/homeassistant/components/matter/models.py b/homeassistant/components/matter/models.py index acdcc53f660168..50d0a5745da549 100644 --- a/homeassistant/components/matter/models.py +++ b/homeassistant/components/matter/models.py @@ -11,7 +11,8 @@ from matter_server.client.models.node import MatterEndpoint from homeassistant.const import Platform -from homeassistant.helpers.entity import EntityDescription + +from .entity import MatterEntityDescription type SensorValueTypes = type[ clusters.uint | int | clusters.Nullable | clusters.float32 | float @@ -54,7 +55,7 @@ class MatterEntityInfo: attributes_to_watch: list[type[ClusterAttributeDescriptor]] # the entity description to use - entity_description: EntityDescription + entity_description: MatterEntityDescription # entity class to use to instantiate the entity entity_class: type @@ -80,7 +81,7 @@ class MatterDiscoverySchema: platform: Platform # platform-specific entity description - entity_description: EntityDescription + entity_description: MatterEntityDescription # entity class to use to instantiate the entity entity_class: type diff --git a/homeassistant/components/matter/switch.py b/homeassistant/components/matter/switch.py index 682285e9c97cef..d8ae8d66f2bff8 100644 --- a/homeassistant/components/matter/switch.py +++ b/homeassistant/components/matter/switch.py @@ -42,6 +42,11 @@ async def async_setup_entry( matter.register_platform_handler(Platform.SWITCH, async_add_entities) +@dataclass(frozen=True) +class MatterSwitchEntityDescription(SwitchEntityDescription, MatterEntityDescription): + """Describe Matter Switch entities.""" + + class MatterSwitch(MatterEntity, SwitchEntity): """Representation of a Matter switch.""" @@ -168,7 +173,7 @@ def _update_from_device(self) -> None: DISCOVERY_SCHEMAS = [ MatterDiscoverySchema( platform=Platform.SWITCH, - entity_description=SwitchEntityDescription( + entity_description=MatterSwitchEntityDescription( key="MatterPlug", device_class=SwitchDeviceClass.OUTLET, name=None, @@ -179,7 +184,7 @@ def _update_from_device(self) -> None: ), MatterDiscoverySchema( platform=Platform.SWITCH, - entity_description=SwitchEntityDescription( + entity_description=MatterSwitchEntityDescription( key="MatterPowerToggle", device_class=SwitchDeviceClass.SWITCH, translation_key="power", @@ -207,7 +212,7 @@ def _update_from_device(self) -> None: ), MatterDiscoverySchema( platform=Platform.SWITCH, - entity_description=SwitchEntityDescription( + entity_description=MatterSwitchEntityDescription( key="MatterSwitch", device_class=SwitchDeviceClass.OUTLET, name=None, diff --git a/homeassistant/components/matter/update.py b/homeassistant/components/matter/update.py index cea4fe0c810bae..17506fe4d4cb5c 100644 --- a/homeassistant/components/matter/update.py +++ b/homeassistant/components/matter/update.py @@ -25,7 +25,7 @@ from homeassistant.helpers.event import async_call_later from homeassistant.helpers.restore_state import ExtraStoredData -from .entity import MatterEntity +from .entity import MatterEntity, MatterEntityDescription from .helpers import get_matter from .models import MatterDiscoverySchema @@ -67,6 +67,11 @@ async def async_setup_entry( matter.register_platform_handler(Platform.UPDATE, async_add_entities) +@dataclass(frozen=True) +class MatterUpdateEntityDescription(UpdateEntityDescription, MatterEntityDescription): + """Describe Matter Update entities.""" + + class MatterUpdate(MatterEntity, UpdateEntity): """Representation of a Matter node capable of updating.""" @@ -250,7 +255,7 @@ async def async_will_remove_from_hass(self) -> None: DISCOVERY_SCHEMAS = [ MatterDiscoverySchema( platform=Platform.UPDATE, - entity_description=UpdateEntityDescription( + entity_description=MatterUpdateEntityDescription( key="MatterUpdate", device_class=UpdateDeviceClass.FIRMWARE ), entity_class=MatterUpdate, diff --git a/homeassistant/components/matter/vacuum.py b/homeassistant/components/matter/vacuum.py index cf9f26adecb4d7..2e4ee41b7ec3a2 100644 --- a/homeassistant/components/matter/vacuum.py +++ b/homeassistant/components/matter/vacuum.py @@ -2,6 +2,7 @@ from __future__ import annotations +from dataclasses import dataclass from enum import IntEnum from typing import TYPE_CHECKING, Any @@ -20,7 +21,7 @@ from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from .entity import MatterEntity +from .entity import MatterEntity, MatterEntityDescription from .helpers import get_matter from .models import MatterDiscoverySchema @@ -58,6 +59,13 @@ async def async_setup_entry( matter.register_platform_handler(Platform.VACUUM, async_add_entities) +@dataclass(frozen=True) +class MatterStateVacuumEntityDescription( + StateVacuumEntityDescription, MatterEntityDescription +): + """Describe Matter Vacuum entities.""" + + class MatterVacuum(MatterEntity, StateVacuumEntity): """Representation of a Matter Vacuum cleaner entity.""" @@ -65,7 +73,7 @@ class MatterVacuum(MatterEntity, StateVacuumEntity): _supported_run_modes: ( dict[int, clusters.RvcRunMode.Structs.ModeOptionStruct] | None ) = None - entity_description: StateVacuumEntityDescription + entity_description: MatterStateVacuumEntityDescription _platform_translation_key = "vacuum" def _get_run_mode_by_tag( @@ -212,7 +220,7 @@ def _calculate_features(self) -> None: DISCOVERY_SCHEMAS = [ MatterDiscoverySchema( platform=Platform.VACUUM, - entity_description=StateVacuumEntityDescription( + entity_description=MatterStateVacuumEntityDescription( key="MatterVacuumCleaner", name=None ), entity_class=MatterVacuum, diff --git a/homeassistant/components/matter/valve.py b/homeassistant/components/matter/valve.py index 715cdc2a09e669..be7a382307bd92 100644 --- a/homeassistant/components/matter/valve.py +++ b/homeassistant/components/matter/valve.py @@ -2,6 +2,8 @@ from __future__ import annotations +from dataclasses import dataclass + from chip.clusters import Objects as clusters from matter_server.client.models import device_types @@ -16,7 +18,7 @@ from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from .entity import MatterEntity +from .entity import MatterEntity, MatterEntityDescription from .helpers import get_matter from .models import MatterDiscoverySchema @@ -34,11 +36,16 @@ async def async_setup_entry( matter.register_platform_handler(Platform.VALVE, async_add_entities) +@dataclass(frozen=True) +class MatterValveEntityDescription(ValveEntityDescription, MatterEntityDescription): + """Describe Matter Valve entities.""" + + class MatterValve(MatterEntity, ValveEntity): """Representation of a Matter Valve.""" _feature_map: int | None = None - entity_description: ValveEntityDescription + entity_description: MatterValveEntityDescription _platform_translation_key = "valve" async def async_open_valve(self) -> None: @@ -128,7 +135,7 @@ def _calculate_features( DISCOVERY_SCHEMAS = [ MatterDiscoverySchema( platform=Platform.VALVE, - entity_description=ValveEntityDescription( + entity_description=MatterValveEntityDescription( key="MatterValve", device_class=ValveDeviceClass.WATER, name=None, diff --git a/homeassistant/components/matter/water_heater.py b/homeassistant/components/matter/water_heater.py index e453a8be067122..f761af9427d195 100644 --- a/homeassistant/components/matter/water_heater.py +++ b/homeassistant/components/matter/water_heater.py @@ -2,6 +2,7 @@ from __future__ import annotations +from dataclasses import dataclass from typing import Any, cast from chip.clusters import Objects as clusters @@ -26,7 +27,7 @@ from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from .entity import MatterEntity +from .entity import MatterEntity, MatterEntityDescription from .helpers import get_matter from .models import MatterDiscoverySchema @@ -50,6 +51,13 @@ async def async_setup_entry( matter.register_platform_handler(Platform.WATER_HEATER, async_add_entities) +@dataclass(frozen=True) +class MatterWaterHeaterEntityDescription( + WaterHeaterEntityDescription, MatterEntityDescription +): + """Describe Matter Water Heater entities.""" + + class MatterWaterHeater(MatterEntity, WaterHeaterEntity): """Representation of a Matter WaterHeater entity.""" @@ -171,7 +179,7 @@ def _get_temperature_in_degrees( DISCOVERY_SCHEMAS = [ MatterDiscoverySchema( platform=Platform.WATER_HEATER, - entity_description=WaterHeaterEntityDescription( + entity_description=MatterWaterHeaterEntityDescription( key="MatterWaterHeater", name=None, ), diff --git a/homeassistant/components/mqtt/config_flow.py b/homeassistant/components/mqtt/config_flow.py index 26b663f1c11cca..64601433e7ee25 100644 --- a/homeassistant/components/mqtt/config_flow.py +++ b/homeassistant/components/mqtt/config_flow.py @@ -458,6 +458,7 @@ Platform.LOCK, Platform.NOTIFY, Platform.NUMBER, + Platform.SELECT, Platform.SENSOR, Platform.SWITCH, ] @@ -1141,6 +1142,7 @@ def validate_sensor_platform_config( Platform.LOCK.value: None, Platform.NOTIFY.value: None, Platform.NUMBER.value: validate_number_platform_config, + Platform.SELECT: None, Platform.SENSOR.value: validate_sensor_platform_config, Platform.SWITCH.value: None, } @@ -1367,6 +1369,7 @@ class PlatformField: custom_filtering=True, ), }, + Platform.SELECT.value: {}, Platform.SENSOR.value: { CONF_DEVICE_CLASS: PlatformField( selector=SENSOR_DEVICE_CLASS_SELECTOR, required=False @@ -3103,6 +3106,34 @@ class PlatformField: ), CONF_RETAIN: PlatformField(selector=BOOLEAN_SELECTOR, required=False), }, + Platform.SELECT.value: { + CONF_COMMAND_TOPIC: PlatformField( + selector=TEXT_SELECTOR, + required=True, + validator=valid_publish_topic, + error="invalid_publish_topic", + ), + CONF_COMMAND_TEMPLATE: PlatformField( + selector=TEMPLATE_SELECTOR, + required=False, + validator=validate(cv.template), + error="invalid_template", + ), + CONF_STATE_TOPIC: PlatformField( + selector=TEXT_SELECTOR, + required=False, + validator=valid_subscribe_topic, + error="invalid_subscribe_topic", + ), + CONF_VALUE_TEMPLATE: PlatformField( + selector=TEMPLATE_SELECTOR, + required=False, + validator=validate(cv.template), + error="invalid_template", + ), + CONF_OPTIONS: PlatformField(selector=OPTIONS_SELECTOR, required=True), + CONF_RETAIN: PlatformField(selector=BOOLEAN_SELECTOR, required=False), + }, Platform.SENSOR.value: { CONF_STATE_TOPIC: PlatformField( selector=TEXT_SELECTOR, diff --git a/homeassistant/components/mqtt/strings.json b/homeassistant/components/mqtt/strings.json index fe848ea43c6816..438d64c48dfd79 100644 --- a/homeassistant/components/mqtt/strings.json +++ b/homeassistant/components/mqtt/strings.json @@ -346,6 +346,7 @@ "mode_state_template": "Operation mode value template", "on_command_type": "ON command type", "optimistic": "Optimistic", + "options": "Set options", "payload_off": "Payload \"off\"", "payload_on": "Payload \"on\"", "payload_press": "Payload \"press\"", @@ -393,6 +394,7 @@ "mode_state_template": "Defines a [template](https://www.home-assistant.io/docs/configuration/templating/#using-value-templates-with-mqtt) to extract the operation mode state. [Learn more.]({url}#mode_state_template)", "on_command_type": "Defines when the payload \"on\" is sent. Using \"Last\" (the default) will send any style (brightness, color, etc) topics first and then a payload \"on\" to the command topic. Using \"First\" will send the payload \"on\" and then any style topics. Using \"Brightness\" will only send brightness commands instead of the payload \"on\" to turn the light on.", "optimistic": "Flag that defines if the {platform} entity works in optimistic mode. [Learn more.]({url}#optimistic)", + "options": "List of options that can be selected.", "payload_off": "The payload that represents the \"off\" state.", "payload_on": "The payload that represents the \"on\" state.", "payload_press": "The payload to send when the button is triggered.", @@ -1334,6 +1336,7 @@ "lock": "[%key:component::lock::title%]", "notify": "[%key:component::notify::title%]", "number": "[%key:component::number::title%]", + "select": "[%key:component::select::title%]", "sensor": "[%key:component::sensor::title%]", "switch": "[%key:component::switch::title%]" } diff --git a/homeassistant/components/nintendo_parental/const.py b/homeassistant/components/nintendo_parental/const.py deleted file mode 100644 index 0cea2e56ac8a55..00000000000000 --- a/homeassistant/components/nintendo_parental/const.py +++ /dev/null @@ -1,5 +0,0 @@ -"""Constants for the Nintendo Switch Parental Controls integration.""" - -DOMAIN = "nintendo_parental" -CONF_UPDATE_INTERVAL = "update_interval" -CONF_SESSION_TOKEN = "session_token" diff --git a/homeassistant/components/nintendo_parental/__init__.py b/homeassistant/components/nintendo_parental_controls/__init__.py similarity index 72% rename from homeassistant/components/nintendo_parental/__init__.py rename to homeassistant/components/nintendo_parental_controls/__init__.py index 91b4ebee1cb415..974bd15ea49dc9 100644 --- a/homeassistant/components/nintendo_parental/__init__.py +++ b/homeassistant/components/nintendo_parental_controls/__init__.py @@ -1,4 +1,4 @@ -"""The Nintendo Switch Parental Controls integration.""" +"""The Nintendo Switch parental controls integration.""" from __future__ import annotations @@ -10,19 +10,19 @@ from homeassistant.const import Platform from homeassistant.core import HomeAssistant -from homeassistant.exceptions import ConfigEntryError +from homeassistant.exceptions import ConfigEntryAuthFailed from homeassistant.helpers.aiohttp_client import async_get_clientsession from .const import CONF_SESSION_TOKEN, DOMAIN -from .coordinator import NintendoParentalConfigEntry, NintendoUpdateCoordinator +from .coordinator import NintendoParentalControlsConfigEntry, NintendoUpdateCoordinator -_PLATFORMS: list[Platform] = [Platform.SENSOR] +_PLATFORMS: list[Platform] = [Platform.SENSOR, Platform.TIME] async def async_setup_entry( - hass: HomeAssistant, entry: NintendoParentalConfigEntry + hass: HomeAssistant, entry: NintendoParentalControlsConfigEntry ) -> bool: - """Set up Nintendo Switch Parental Controls from a config entry.""" + """Set up Nintendo Switch parental controls from a config entry.""" try: nintendo_auth = await Authenticator.complete_login( auth=None, @@ -31,7 +31,7 @@ async def async_setup_entry( client_session=async_get_clientsession(hass), ) except (InvalidSessionTokenException, InvalidOAuthConfigurationException) as err: - raise ConfigEntryError( + raise ConfigEntryAuthFailed( translation_domain=DOMAIN, translation_key="auth_expired", ) from err @@ -45,7 +45,7 @@ async def async_setup_entry( async def async_unload_entry( - hass: HomeAssistant, entry: NintendoParentalConfigEntry + hass: HomeAssistant, entry: NintendoParentalControlsConfigEntry ) -> bool: """Unload a config entry.""" return await hass.config_entries.async_unload_platforms(entry, _PLATFORMS) diff --git a/homeassistant/components/nintendo_parental/config_flow.py b/homeassistant/components/nintendo_parental_controls/config_flow.py similarity index 55% rename from homeassistant/components/nintendo_parental/config_flow.py rename to homeassistant/components/nintendo_parental_controls/config_flow.py index 1bb16e6bb1148b..498afc05c8fc91 100644 --- a/homeassistant/components/nintendo_parental/config_flow.py +++ b/homeassistant/components/nintendo_parental_controls/config_flow.py @@ -1,7 +1,8 @@ -"""Config flow for the Nintendo Switch Parental Controls integration.""" +"""Config flow for the Nintendo Switch parental controls integration.""" from __future__ import annotations +from collections.abc import Mapping import logging from typing import TYPE_CHECKING, Any @@ -19,7 +20,7 @@ class NintendoConfigFlow(ConfigFlow, domain=DOMAIN): - """Handle a config flow for Nintendo Switch Parental Controls.""" + """Handle a config flow for Nintendo Switch parental controls.""" def __init__(self) -> None: """Initialize a new config flow instance.""" @@ -59,3 +60,41 @@ async def async_step_user( data_schema=vol.Schema({vol.Required(CONF_API_TOKEN): str}), errors=errors, ) + + async def async_step_reauth( + self, user_input: Mapping[str, Any] + ) -> ConfigFlowResult: + """Perform reauthentication on an API error.""" + return await self.async_step_reauth_confirm() + + async def async_step_reauth_confirm( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Confirm reauth dialog.""" + errors: dict[str, str] = {} + reauth_entry = self._get_reauth_entry() + if self.auth is None: + self.auth = Authenticator.generate_login( + client_session=async_get_clientsession(self.hass) + ) + if user_input is not None: + try: + await self.auth.complete_login( + self.auth, user_input[CONF_API_TOKEN], False + ) + except (ValueError, InvalidSessionTokenException, HttpException): + errors["base"] = "invalid_auth" + else: + return self.async_update_reload_and_abort( + reauth_entry, + data={ + **reauth_entry.data, + CONF_SESSION_TOKEN: self.auth.get_session_token, + }, + ) + return self.async_show_form( + step_id="reauth_confirm", + description_placeholders={"link": self.auth.login_url}, + data_schema=vol.Schema({vol.Required(CONF_API_TOKEN): str}), + errors=errors, + ) diff --git a/homeassistant/components/nintendo_parental_controls/const.py b/homeassistant/components/nintendo_parental_controls/const.py new file mode 100644 index 00000000000000..afbe0b2a767b61 --- /dev/null +++ b/homeassistant/components/nintendo_parental_controls/const.py @@ -0,0 +1,9 @@ +"""Constants for the Nintendo Switch parental controls integration.""" + +DOMAIN = "nintendo_parental_controls" +CONF_UPDATE_INTERVAL = "update_interval" +CONF_SESSION_TOKEN = "session_token" + +BEDTIME_ALARM_MIN = "16:00" +BEDTIME_ALARM_MAX = "23:00" +BEDTIME_ALARM_DISABLE = "00:00" diff --git a/homeassistant/components/nintendo_parental/coordinator.py b/homeassistant/components/nintendo_parental_controls/coordinator.py similarity index 88% rename from homeassistant/components/nintendo_parental/coordinator.py rename to homeassistant/components/nintendo_parental_controls/coordinator.py index 49b4fae60f352a..c3bd4aa5bbbe10 100644 --- a/homeassistant/components/nintendo_parental/coordinator.py +++ b/homeassistant/components/nintendo_parental_controls/coordinator.py @@ -1,4 +1,4 @@ -"""Nintendo Parental Controls data coordinator.""" +"""Nintendo parental controls data coordinator.""" from __future__ import annotations @@ -15,7 +15,7 @@ from .const import DOMAIN -type NintendoParentalConfigEntry = ConfigEntry[NintendoUpdateCoordinator] +type NintendoParentalControlsConfigEntry = ConfigEntry[NintendoUpdateCoordinator] _LOGGER = logging.getLogger(__name__) UPDATE_INTERVAL = timedelta(seconds=60) @@ -28,7 +28,7 @@ def __init__( self, hass: HomeAssistant, authenticator: Authenticator, - config_entry: NintendoParentalConfigEntry, + config_entry: NintendoParentalControlsConfigEntry, ) -> None: """Initialize update coordinator.""" super().__init__( diff --git a/homeassistant/components/nintendo_parental/entity.py b/homeassistant/components/nintendo_parental_controls/entity.py similarity index 95% rename from homeassistant/components/nintendo_parental/entity.py rename to homeassistant/components/nintendo_parental_controls/entity.py index 74d3bcae8a766e..7c773a2226f53f 100644 --- a/homeassistant/components/nintendo_parental/entity.py +++ b/homeassistant/components/nintendo_parental_controls/entity.py @@ -1,4 +1,4 @@ -"""Base entity definition for Nintendo Parental.""" +"""Base entity definition for Nintendo parental controls.""" from __future__ import annotations diff --git a/homeassistant/components/nintendo_parental/manifest.json b/homeassistant/components/nintendo_parental_controls/manifest.json similarity index 69% rename from homeassistant/components/nintendo_parental/manifest.json rename to homeassistant/components/nintendo_parental_controls/manifest.json index 03daba3356d55e..99a717f95898af 100644 --- a/homeassistant/components/nintendo_parental/manifest.json +++ b/homeassistant/components/nintendo_parental_controls/manifest.json @@ -1,9 +1,9 @@ { - "domain": "nintendo_parental", - "name": "Nintendo Switch Parental Controls", + "domain": "nintendo_parental_controls", + "name": "Nintendo Switch parental controls", "codeowners": ["@pantherale0"], "config_flow": true, - "documentation": "https://www.home-assistant.io/integrations/nintendo_parental", + "documentation": "https://www.home-assistant.io/integrations/nintendo_parental_controls", "iot_class": "cloud_polling", "loggers": ["pynintendoparental"], "quality_scale": "bronze", diff --git a/homeassistant/components/nintendo_parental/quality_scale.yaml b/homeassistant/components/nintendo_parental_controls/quality_scale.yaml similarity index 100% rename from homeassistant/components/nintendo_parental/quality_scale.yaml rename to homeassistant/components/nintendo_parental_controls/quality_scale.yaml diff --git a/homeassistant/components/nintendo_parental/sensor.py b/homeassistant/components/nintendo_parental_controls/sensor.py similarity index 62% rename from homeassistant/components/nintendo_parental/sensor.py rename to homeassistant/components/nintendo_parental_controls/sensor.py index 803fb39bcb4af5..129c7280e5372c 100644 --- a/homeassistant/components/nintendo_parental/sensor.py +++ b/homeassistant/components/nintendo_parental_controls/sensor.py @@ -1,4 +1,4 @@ -"""Sensor platform for Nintendo Parental.""" +"""Sensor platform for Nintendo parental controls.""" from __future__ import annotations @@ -16,39 +16,39 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from .coordinator import NintendoParentalConfigEntry, NintendoUpdateCoordinator +from .coordinator import NintendoParentalControlsConfigEntry, NintendoUpdateCoordinator from .entity import Device, NintendoDevice # Coordinator is used to centralize the data updates PARALLEL_UPDATES = 0 -class NintendoParentalSensor(StrEnum): - """Store keys for Nintendo Parental sensors.""" +class NintendoParentalControlsSensor(StrEnum): + """Store keys for Nintendo parental controls sensors.""" PLAYING_TIME = "playing_time" TIME_REMAINING = "time_remaining" @dataclass(kw_only=True, frozen=True) -class NintendoParentalSensorEntityDescription(SensorEntityDescription): - """Description for Nintendo Parental sensor entities.""" +class NintendoParentalControlsSensorEntityDescription(SensorEntityDescription): + """Description for Nintendo parental controls sensor entities.""" value_fn: Callable[[Device], int | float | None] -SENSOR_DESCRIPTIONS: tuple[NintendoParentalSensorEntityDescription, ...] = ( - NintendoParentalSensorEntityDescription( - key=NintendoParentalSensor.PLAYING_TIME, - translation_key=NintendoParentalSensor.PLAYING_TIME, +SENSOR_DESCRIPTIONS: tuple[NintendoParentalControlsSensorEntityDescription, ...] = ( + NintendoParentalControlsSensorEntityDescription( + key=NintendoParentalControlsSensor.PLAYING_TIME, + translation_key=NintendoParentalControlsSensor.PLAYING_TIME, native_unit_of_measurement=UnitOfTime.MINUTES, device_class=SensorDeviceClass.DURATION, state_class=SensorStateClass.MEASUREMENT, value_fn=lambda device: device.today_playing_time, ), - NintendoParentalSensorEntityDescription( - key=NintendoParentalSensor.TIME_REMAINING, - translation_key=NintendoParentalSensor.TIME_REMAINING, + NintendoParentalControlsSensorEntityDescription( + key=NintendoParentalControlsSensor.TIME_REMAINING, + translation_key=NintendoParentalControlsSensor.TIME_REMAINING, native_unit_of_measurement=UnitOfTime.MINUTES, device_class=SensorDeviceClass.DURATION, state_class=SensorStateClass.MEASUREMENT, @@ -59,27 +59,27 @@ class NintendoParentalSensorEntityDescription(SensorEntityDescription): async def async_setup_entry( hass: HomeAssistant, - entry: NintendoParentalConfigEntry, + entry: NintendoParentalControlsConfigEntry, async_add_devices: AddConfigEntryEntitiesCallback, ) -> None: """Set up the sensor platform.""" async_add_devices( - NintendoParentalSensorEntity(entry.runtime_data, device, sensor) + NintendoParentalControlsSensorEntity(entry.runtime_data, device, sensor) for device in entry.runtime_data.api.devices.values() for sensor in SENSOR_DESCRIPTIONS ) -class NintendoParentalSensorEntity(NintendoDevice, SensorEntity): +class NintendoParentalControlsSensorEntity(NintendoDevice, SensorEntity): """Represent a single sensor.""" - entity_description: NintendoParentalSensorEntityDescription + entity_description: NintendoParentalControlsSensorEntityDescription def __init__( self, coordinator: NintendoUpdateCoordinator, device: Device, - description: NintendoParentalSensorEntityDescription, + description: NintendoParentalControlsSensorEntityDescription, ) -> None: """Initialize the sensor.""" super().__init__(coordinator=coordinator, device=device, key=description.key) diff --git a/homeassistant/components/nintendo_parental/strings.json b/homeassistant/components/nintendo_parental_controls/strings.json similarity index 53% rename from homeassistant/components/nintendo_parental/strings.json rename to homeassistant/components/nintendo_parental_controls/strings.json index f35746b41f3cbf..9416a31c5adb95 100644 --- a/homeassistant/components/nintendo_parental/strings.json +++ b/homeassistant/components/nintendo_parental_controls/strings.json @@ -9,6 +9,15 @@ "data_description": { "api_token": "The link copied from the Nintendo website" } + }, + "reauth_confirm": { + "description": "To obtain your access token, click [Nintendo Login]({link}) to sign in to your Nintendo account. Then, for the account you want to link, right-click on the red **Select this person** button and choose **Copy Link Address**.", + "data": { + "api_token": "Access token" + }, + "data_description": { + "api_token": "The link copied from the Nintendo website" + } } }, "error": { @@ -17,7 +26,8 @@ "unknown": "[%key:common::config_flow::error::unknown%]" }, "abort": { - "already_configured": "[%key:common::config_flow::abort::already_configured_account%]" + "already_configured": "[%key:common::config_flow::abort::already_configured_account%]", + "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]" } }, "entity": { @@ -28,11 +38,19 @@ "time_remaining": { "name": "Screen time remaining" } + }, + "time": { + "bedtime_alarm": { + "name": "Bedtime alarm" + } } }, "exceptions": { "auth_expired": { - "message": "Authentication expired. Please remove and re-add the integration to reconnect." + "message": "Authentication token expired." + }, + "bedtime_alarm_out_of_range": { + "message": "{value} not accepted. Bedtime Alarm must be between {bedtime_alarm_min} and {bedtime_alarm_max}. To disable, set to {bedtime_alarm_disable}." } } } diff --git a/homeassistant/components/nintendo_parental_controls/time.py b/homeassistant/components/nintendo_parental_controls/time.py new file mode 100644 index 00000000000000..ecbf5595f4f7c1 --- /dev/null +++ b/homeassistant/components/nintendo_parental_controls/time.py @@ -0,0 +1,100 @@ +"""Time platform for Nintendo parental controls.""" + +from __future__ import annotations + +from collections.abc import Callable, Coroutine +from dataclasses import dataclass +from datetime import time +from enum import StrEnum +import logging +from typing import Any + +from pynintendoparental.exceptions import BedtimeOutOfRangeError + +from homeassistant.components.time import TimeEntity, TimeEntityDescription +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ServiceValidationError +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from .const import BEDTIME_ALARM_DISABLE, BEDTIME_ALARM_MAX, BEDTIME_ALARM_MIN, DOMAIN +from .coordinator import NintendoParentalControlsConfigEntry, NintendoUpdateCoordinator +from .entity import Device, NintendoDevice + +_LOGGER = logging.getLogger(__name__) + +# Coordinator is used to centralize the data updates +PARALLEL_UPDATES = 0 + + +class NintendoParentalControlsTime(StrEnum): + """Store keys for Nintendo Parental time.""" + + BEDTIME_ALARM = "bedtime_alarm" + + +@dataclass(kw_only=True, frozen=True) +class NintendoParentalControlsTimeEntityDescription(TimeEntityDescription): + """Description for Nintendo Parental time entities.""" + + value_fn: Callable[[Device], time | None] + set_value_fn: Callable[[Device, time], Coroutine[Any, Any, None]] + + +TIME_DESCRIPTIONS: tuple[NintendoParentalControlsTimeEntityDescription, ...] = ( + NintendoParentalControlsTimeEntityDescription( + key=NintendoParentalControlsTime.BEDTIME_ALARM, + translation_key=NintendoParentalControlsTime.BEDTIME_ALARM, + value_fn=lambda device: device.bedtime_alarm, + set_value_fn=lambda device, value: device.set_bedtime_alarm(value=value), + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, + entry: NintendoParentalControlsConfigEntry, + async_add_devices: AddConfigEntryEntitiesCallback, +) -> None: + """Set up the time platform.""" + async_add_devices( + NintendoParentalControlsTimeEntity(entry.runtime_data, device, entity) + for device in entry.runtime_data.api.devices.values() + for entity in TIME_DESCRIPTIONS + ) + + +class NintendoParentalControlsTimeEntity(NintendoDevice, TimeEntity): + """Represent a single time entity.""" + + entity_description: NintendoParentalControlsTimeEntityDescription + + def __init__( + self, + coordinator: NintendoUpdateCoordinator, + device: Device, + description: NintendoParentalControlsTimeEntityDescription, + ) -> None: + """Initialize the time entity.""" + super().__init__(coordinator=coordinator, device=device, key=description.key) + self.entity_description = description + + @property + def native_value(self) -> time | None: + """Return the time.""" + return self.entity_description.value_fn(self._device) + + async def async_set_value(self, value: time) -> None: + """Update the value.""" + try: + await self.entity_description.set_value_fn(self._device, value) + except BedtimeOutOfRangeError as exc: + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="bedtime_alarm_out_of_range", + translation_placeholders={ + "value": value.strftime("%H:%M"), + "bedtime_alarm_max": BEDTIME_ALARM_MAX, + "bedtime_alarm_min": BEDTIME_ALARM_MIN, + "bedtime_alarm_disable": BEDTIME_ALARM_DISABLE, + }, + ) from exc diff --git a/homeassistant/components/number/const.py b/homeassistant/components/number/const.py index fab3d6f4276a8b..f29206df7c5544 100644 --- a/homeassistant/components/number/const.py +++ b/homeassistant/components/number/const.py @@ -124,7 +124,7 @@ class NumberDeviceClass(StrEnum): CO = "carbon_monoxide" """Carbon Monoxide gas concentration. - Unit of measurement: `ppm` (parts per million) + Unit of measurement: `ppm` (parts per million), `mg/m³` """ CO2 = "carbon_dioxide" @@ -475,7 +475,10 @@ class NumberDeviceClass(StrEnum): NumberDeviceClass.ATMOSPHERIC_PRESSURE: set(UnitOfPressure), NumberDeviceClass.BATTERY: {PERCENTAGE}, NumberDeviceClass.BLOOD_GLUCOSE_CONCENTRATION: set(UnitOfBloodGlucoseConcentration), - NumberDeviceClass.CO: {CONCENTRATION_PARTS_PER_MILLION}, + NumberDeviceClass.CO: { + CONCENTRATION_PARTS_PER_MILLION, + CONCENTRATION_MILLIGRAMS_PER_CUBIC_METER, + }, NumberDeviceClass.CO2: {CONCENTRATION_PARTS_PER_MILLION}, NumberDeviceClass.CONDUCTIVITY: set(UnitOfConductivity), NumberDeviceClass.CURRENT: set(UnitOfElectricCurrent), diff --git a/homeassistant/components/open_router/ai_task.py b/homeassistant/components/open_router/ai_task.py index fa5d8d0f68e26e..6c254b050c139e 100644 --- a/homeassistant/components/open_router/ai_task.py +++ b/homeassistant/components/open_router/ai_task.py @@ -40,7 +40,10 @@ class OpenRouterAITaskEntity( """OpenRouter AI Task entity.""" _attr_name = None - _attr_supported_features = ai_task.AITaskEntityFeature.GENERATE_DATA + _attr_supported_features = ( + ai_task.AITaskEntityFeature.GENERATE_DATA + | ai_task.AITaskEntityFeature.SUPPORT_ATTACHMENTS + ) async def _async_generate_data( self, diff --git a/homeassistant/components/open_router/entity.py b/homeassistant/components/open_router/entity.py index aa74442f7f42ae..48354a83c2281c 100644 --- a/homeassistant/components/open_router/entity.py +++ b/homeassistant/components/open_router/entity.py @@ -2,13 +2,17 @@ from __future__ import annotations +import base64 from collections.abc import AsyncGenerator, Callable import json +from mimetypes import guess_file_type +from pathlib import Path from typing import TYPE_CHECKING, Any, Literal import openai from openai.types.chat import ( ChatCompletionAssistantMessageParam, + ChatCompletionContentPartImageParam, ChatCompletionFunctionToolParam, ChatCompletionMessage, ChatCompletionMessageFunctionToolCallParam, @@ -26,6 +30,7 @@ from homeassistant.components import conversation from homeassistant.config_entries import ConfigSubentry from homeassistant.const import CONF_MODEL +from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import device_registry as dr, llm from homeassistant.helpers.entity import Entity @@ -165,6 +170,43 @@ async def _transform_response( yield data +async def async_prepare_files_for_prompt( + hass: HomeAssistant, files: list[tuple[Path, str | None]] +) -> list[ChatCompletionContentPartImageParam]: + """Append files to a prompt. + + Caller needs to ensure that the files are allowed. + """ + + def append_files_to_content() -> list[ChatCompletionContentPartImageParam]: + content: list[ChatCompletionContentPartImageParam] = [] + + for file_path, mime_type in files: + if not file_path.exists(): + raise HomeAssistantError(f"`{file_path}` does not exist") + + if mime_type is None: + mime_type = guess_file_type(file_path)[0] + + if not mime_type or not mime_type.startswith(("image/", "application/pdf")): + raise HomeAssistantError( + "Only images and PDF are supported by the OpenRouter API, " + f"`{file_path}` is not an image file or PDF" + ) + + base64_file = base64.b64encode(file_path.read_bytes()).decode("utf-8") + content.append( + { + "type": "image_url", + "image_url": {"url": f"data:{mime_type};base64,{base64_file}"}, + } + ) + + return content + + return await hass.async_add_executor_job(append_files_to_content) + + class OpenRouterEntity(Entity): """Base entity for Open Router.""" @@ -216,6 +258,24 @@ async def _async_handle_chat_log( if (m := _convert_content_to_chat_message(content)) ] + last_content = chat_log.content[-1] + + # Handle attachments by adding them to the last user message + if last_content.role == "user" and last_content.attachments: + last_message: ChatCompletionMessageParam = model_args["messages"][-1] + assert last_message["role"] == "user" and isinstance( + last_message["content"], str + ) + # Encode files with base64 and append them to the text prompt + files = await async_prepare_files_for_prompt( + self.hass, + [(a.path, a.mime_type) for a in last_content.attachments], + ) + last_message["content"] = [ + {"type": "text", "text": last_message["content"]}, + *files, + ] + if structure: if TYPE_CHECKING: assert structure_name is not None diff --git a/homeassistant/components/oralb/strings.json b/homeassistant/components/oralb/strings.json index db3b8de5965e71..bb5331f0caa035 100644 --- a/homeassistant/components/oralb/strings.json +++ b/homeassistant/components/oralb/strings.json @@ -14,7 +14,7 @@ }, "abort": { "not_supported": "Device not supported", - "no_devices_found": "[%key:common::config_flow::abort::no_devices_found%]", + "no_devices_found": "No devices found", "already_in_progress": "[%key:common::config_flow::abort::already_in_progress%]", "already_configured": "[%key:common::config_flow::abort::already_configured_device%]" } diff --git a/homeassistant/components/otbr/config_flow.py b/homeassistant/components/otbr/config_flow.py index ebdf5ddeace173..104edc71ccd29f 100644 --- a/homeassistant/components/otbr/config_flow.py +++ b/homeassistant/components/otbr/config_flow.py @@ -75,6 +75,9 @@ async def _title(hass: HomeAssistant, discovery_info: HassioServiceInfo) -> str: if device and ("Connect_ZBT-1" in device or "SkyConnect" in device): return f"Home Assistant Connect ZBT-1 ({discovery_info.name})" + if device and "Nabu_Casa_ZBT-2" in device: + return f"Home Assistant Connect ZBT-2 ({discovery_info.name})" + return discovery_info.name diff --git a/homeassistant/components/pooldose/config_flow.py b/homeassistant/components/pooldose/config_flow.py index 6deb4eafb13c7e..b5a1d0048e7e7d 100644 --- a/homeassistant/components/pooldose/config_flow.py +++ b/homeassistant/components/pooldose/config_flow.py @@ -7,11 +7,13 @@ from pooldose.client import PooldoseClient from pooldose.request_status import RequestStatus +from pooldose.type_definitions import APIVersionResponse import voluptuous as vol from homeassistant.config_entries import ConfigFlow, ConfigFlowResult from homeassistant.const import CONF_HOST, CONF_MAC from homeassistant.helpers import config_validation as cv +from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo from .const import DOMAIN @@ -38,9 +40,9 @@ def __init__(self) -> None: async def _validate_host( self, host: str - ) -> tuple[str | None, dict[str, str] | None, dict[str, str] | None]: + ) -> tuple[str | None, APIVersionResponse | None, dict[str, str] | None]: """Validate the host and return (serial_number, api_versions, errors).""" - client = PooldoseClient(host) + client = PooldoseClient(host, websession=async_get_clientsession(self.hass)) client_status = await client.connect() if client_status == RequestStatus.HOST_UNREACHABLE: return None, None, {"base": "cannot_connect"} @@ -124,7 +126,14 @@ async def async_step_user( step_id="user", data_schema=SCHEMA_DEVICE, errors=errors, - description_placeholders=api_versions, + # Handle API version info for error display; pass version info when available + # or None when api_versions is None to avoid displaying version details + description_placeholders={ + "api_version_is": api_versions.get("api_version_is") or "", + "api_version_should": api_versions.get("api_version_should") or "", + } + if api_versions + else None, ) await self.async_set_unique_id(serial_number, raise_on_progress=False) diff --git a/homeassistant/components/pooldose/coordinator.py b/homeassistant/components/pooldose/coordinator.py index cd2fa5d991d845..660c895f33decf 100644 --- a/homeassistant/components/pooldose/coordinator.py +++ b/homeassistant/components/pooldose/coordinator.py @@ -4,10 +4,10 @@ from datetime import timedelta import logging -from typing import Any from pooldose.client import PooldoseClient from pooldose.request_status import RequestStatus +from pooldose.type_definitions import DeviceInfoDict, StructuredValuesDict from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant @@ -18,10 +18,10 @@ type PooldoseConfigEntry = ConfigEntry[PooldoseCoordinator] -class PooldoseCoordinator(DataUpdateCoordinator[dict[str, Any]]): +class PooldoseCoordinator(DataUpdateCoordinator[StructuredValuesDict]): """Coordinator for PoolDose integration.""" - device_info: dict[str, Any] + device_info: DeviceInfoDict config_entry: PooldoseConfigEntry def __init__( @@ -46,7 +46,7 @@ async def _async_setup(self) -> None: self.device_info = self.client.device_info _LOGGER.debug("Device info: %s", self.device_info) - async def _async_update_data(self) -> dict[str, Any]: + async def _async_update_data(self) -> StructuredValuesDict: """Fetch data from the PoolDose API.""" try: status, instant_values = await self.client.instant_values_structured() @@ -62,7 +62,7 @@ async def _async_update_data(self) -> dict[str, Any]: if status != RequestStatus.SUCCESS: raise UpdateFailed(f"API returned status: {status}") - if instant_values is None: + if not instant_values: raise UpdateFailed("No data received from API") _LOGGER.debug("Instant values structured: %s", instant_values) diff --git a/homeassistant/components/pooldose/entity.py b/homeassistant/components/pooldose/entity.py index 06c617ad524a90..19976c1d115f4d 100644 --- a/homeassistant/components/pooldose/entity.py +++ b/homeassistant/components/pooldose/entity.py @@ -2,7 +2,9 @@ from __future__ import annotations -from typing import Any +from typing import Literal + +from pooldose.type_definitions import DeviceInfoDict, ValueDict from homeassistant.const import CONF_MAC from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC, DeviceInfo @@ -14,13 +16,13 @@ def device_info( - info: dict | None, unique_id: str, mac: str | None = None + info: DeviceInfoDict | None, unique_id: str, mac: str | None = None ) -> DeviceInfo: """Create device info for PoolDose devices.""" if info is None: info = {} - api_version = info.get("API_VERSION", "").removesuffix("/") + api_version = (info.get("API_VERSION") or "").removesuffix("/") return DeviceInfo( identifiers={(DOMAIN, unique_id)}, @@ -51,9 +53,9 @@ def __init__( self, coordinator: PooldoseCoordinator, serial_number: str, - device_properties: dict[str, Any], + device_properties: DeviceInfoDict, entity_description: EntityDescription, - platform_name: str, + platform_name: Literal["sensor", "switch", "number", "binary_sensor", "select"], ) -> None: """Initialize PoolDose entity.""" super().__init__(coordinator) @@ -66,18 +68,7 @@ def __init__( coordinator.config_entry.data.get(CONF_MAC), ) - @property - def available(self) -> bool: - """Return True if the entity is available.""" - if not super().available or self.coordinator.data is None: - return False - # Check if the entity type exists in coordinator data - platform_data = self.coordinator.data.get(self.platform_name, {}) - return self.entity_description.key in platform_data - - def get_data(self) -> dict | None: + def get_data(self) -> ValueDict | None: """Get data for this entity, only if available.""" - if not self.available: - return None - platform_data = self.coordinator.data.get(self.platform_name, {}) + platform_data = self.coordinator.data[self.platform_name] return platform_data.get(self.entity_description.key) diff --git a/homeassistant/components/pooldose/manifest.json b/homeassistant/components/pooldose/manifest.json index 5328edce1082b3..67035e1fb195a5 100644 --- a/homeassistant/components/pooldose/manifest.json +++ b/homeassistant/components/pooldose/manifest.json @@ -11,5 +11,5 @@ "documentation": "https://www.home-assistant.io/integrations/pooldose", "iot_class": "local_polling", "quality_scale": "bronze", - "requirements": ["python-pooldose==0.5.0"] + "requirements": ["python-pooldose==0.7.0"] } diff --git a/homeassistant/components/pooldose/quality_scale.yaml b/homeassistant/components/pooldose/quality_scale.yaml index 3c685e8c511e7d..b4c13bace20da2 100644 --- a/homeassistant/components/pooldose/quality_scale.yaml +++ b/homeassistant/components/pooldose/quality_scale.yaml @@ -48,7 +48,7 @@ rules: discovery: done docs-data-update: done docs-examples: todo - docs-known-limitations: todo + docs-known-limitations: done docs-supported-devices: done docs-supported-functions: done docs-troubleshooting: done @@ -72,5 +72,5 @@ rules: # Platinum async-dependency: done - inject-websession: todo + inject-websession: done strict-typing: todo diff --git a/homeassistant/components/pooldose/sensor.py b/homeassistant/components/pooldose/sensor.py index 14c2647d27be43..ccf5f11073bbda 100644 --- a/homeassistant/components/pooldose/sensor.py +++ b/homeassistant/components/pooldose/sensor.py @@ -19,8 +19,6 @@ _LOGGER = logging.getLogger(__name__) -PLATFORM_NAME = "sensor" - SENSOR_DESCRIPTIONS: tuple[SensorEntityDescription, ...] = ( SensorEntityDescription( key="temperature", @@ -146,18 +144,16 @@ async def async_setup_entry( assert config_entry.unique_id is not None coordinator = config_entry.runtime_data - data = coordinator.data + sensor_data = coordinator.data["sensor"] serial_number = config_entry.unique_id - sensor_data = data.get(PLATFORM_NAME, {}) if data else {} - async_add_entities( PooldoseSensor( coordinator, serial_number, coordinator.device_info, description, - PLATFORM_NAME, + "sensor", ) for description in SENSOR_DESCRIPTIONS if description.key in sensor_data @@ -171,16 +167,17 @@ class PooldoseSensor(PooldoseEntity, SensorEntity): def native_value(self) -> float | int | str | None: """Return the current value of the sensor.""" data = self.get_data() - if isinstance(data, dict) and "value" in data: + if data is not None: return data["value"] return None @property def native_unit_of_measurement(self) -> str | None: """Return the unit of measurement.""" - if self.entity_description.key == "temperature": - data = self.get_data() - if isinstance(data, dict) and "unit" in data and data["unit"] is not None: - return data["unit"] # °C or °F + if ( + self.entity_description.key == "temperature" + and (data := self.get_data()) is not None + ): + return data["unit"] # °C or °F return super().native_unit_of_measurement diff --git a/homeassistant/components/portainer/const.py b/homeassistant/components/portainer/const.py index b9d29a468aff43..c54bffcac5a312 100644 --- a/homeassistant/components/portainer/const.py +++ b/homeassistant/components/portainer/const.py @@ -2,3 +2,5 @@ DOMAIN = "portainer" DEFAULT_NAME = "Portainer" + +ENDPOINT_STATUS_DOWN = 2 diff --git a/homeassistant/components/portainer/coordinator.py b/homeassistant/components/portainer/coordinator.py index e10d6b3558473b..8b106e9d75df75 100644 --- a/homeassistant/components/portainer/coordinator.py +++ b/homeassistant/components/portainer/coordinator.py @@ -21,7 +21,7 @@ from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed -from .const import DOMAIN +from .const import DOMAIN, ENDPOINT_STATUS_DOWN type PortainerConfigEntry = ConfigEntry[PortainerCoordinator] @@ -110,6 +110,14 @@ async def _async_update_data(self) -> dict[int, PortainerCoordinatorData]: mapped_endpoints: dict[int, PortainerCoordinatorData] = {} for endpoint in endpoints: + if endpoint.status == ENDPOINT_STATUS_DOWN: + _LOGGER.debug( + "Skipping offline endpoint: %s (ID: %d)", + endpoint.name, + endpoint.id, + ) + continue + try: containers = await self.portainer.get_containers(endpoint.id) except PortainerConnectionError as err: diff --git a/homeassistant/components/prowl/__init__.py b/homeassistant/components/prowl/__init__.py index 1cf58a25120925..4f744e25bf7de5 100644 --- a/homeassistant/components/prowl/__init__.py +++ b/homeassistant/components/prowl/__init__.py @@ -1 +1,41 @@ """The prowl component.""" + +import logging + +import prowlpy + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_API_KEY +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryError, ConfigEntryNotReady +from homeassistant.helpers import config_validation as cv + +from .const import DOMAIN, PLATFORMS +from .helpers import async_verify_key + +_LOGGER = logging.getLogger(__name__) + +CONFIG_SCHEMA = cv.platform_only_config_schema(DOMAIN) + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Set up a Prowl service.""" + try: + if not await async_verify_key(hass, entry.data[CONF_API_KEY]): + raise ConfigEntryError( + "Unable to validate Prowl API key (Key invalid or expired)" + ) + except TimeoutError as ex: + raise ConfigEntryNotReady("API call to Prowl failed") from ex + except prowlpy.APIError as ex: + if str(ex).startswith("Not accepted: exceeded rate limit"): + raise ConfigEntryNotReady("Prowl API rate limit exceeded") from ex + raise ConfigEntryError("Failed to validate Prowl API key ({ex})") from ex + + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + return True + + +async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Unload a config entry.""" + return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/homeassistant/components/prowl/config_flow.py b/homeassistant/components/prowl/config_flow.py new file mode 100644 index 00000000000000..cea3ee6e1066b1 --- /dev/null +++ b/homeassistant/components/prowl/config_flow.py @@ -0,0 +1,68 @@ +"""The config flow for the Prowl component.""" + +from __future__ import annotations + +import logging +from typing import Any + +import prowlpy +import voluptuous as vol + +from homeassistant.config_entries import ConfigFlow, ConfigFlowResult +from homeassistant.const import CONF_API_KEY, CONF_NAME + +from .const import DOMAIN +from .helpers import async_verify_key + +_LOGGER = logging.getLogger(__name__) + + +class ProwlConfigFlow(ConfigFlow, domain=DOMAIN): + """Handle a config flow for the Prowl component.""" + + VERSION = 1 + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle user configuration.""" + errors = {} + + if user_input: + api_key = user_input[CONF_API_KEY] + self._async_abort_entries_match({CONF_API_KEY: api_key}) + + errors = await self._validate_api_key(api_key) + if not errors: + return self.async_create_entry( + title=user_input[CONF_NAME], + data={ + CONF_API_KEY: api_key, + }, + ) + + return self.async_show_form( + step_id="user", + data_schema=self.add_suggested_values_to_schema( + vol.Schema( + { + vol.Required(CONF_API_KEY): str, + vol.Required(CONF_NAME): str, + }, + ), + user_input or {CONF_NAME: "Prowl"}, + ), + errors=errors, + ) + + async def _validate_api_key(self, api_key: str) -> dict[str, str]: + """Validate the provided API key.""" + ret = {} + try: + if not await async_verify_key(self.hass, api_key): + ret = {"base": "invalid_api_key"} + except TimeoutError: + ret = {"base": "api_timeout"} + except prowlpy.APIError: + ret = {"base": "bad_api_response"} + return ret diff --git a/homeassistant/components/prowl/const.py b/homeassistant/components/prowl/const.py index 7037e29da73ef2..e353e720c6d569 100644 --- a/homeassistant/components/prowl/const.py +++ b/homeassistant/components/prowl/const.py @@ -1,3 +1,6 @@ """Constants for the Prowl Notification service.""" +from homeassistant.const import Platform + DOMAIN = "prowl" +PLATFORMS = [Platform.NOTIFY] diff --git a/homeassistant/components/prowl/helpers.py b/homeassistant/components/prowl/helpers.py new file mode 100644 index 00000000000000..89eebf2720a928 --- /dev/null +++ b/homeassistant/components/prowl/helpers.py @@ -0,0 +1,21 @@ +"""Helper functions for Prowl.""" + +import asyncio +from functools import partial + +import prowlpy + +from homeassistant.core import HomeAssistant + + +async def async_verify_key(hass: HomeAssistant, api_key: str) -> bool: + """Validate API key.""" + prowl = await hass.async_add_executor_job(partial(prowlpy.Prowl, api_key)) + try: + async with asyncio.timeout(10): + await hass.async_add_executor_job(prowl.verify_key) + return True + except prowlpy.APIError as ex: + if str(ex).startswith("Invalid API key"): + return False + raise diff --git a/homeassistant/components/prowl/manifest.json b/homeassistant/components/prowl/manifest.json index b97e65102384c0..086bc62aa829ec 100644 --- a/homeassistant/components/prowl/manifest.json +++ b/homeassistant/components/prowl/manifest.json @@ -2,6 +2,7 @@ "domain": "prowl", "name": "Prowl", "codeowners": [], + "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/prowl", "integration_type": "service", "iot_class": "cloud_push", diff --git a/homeassistant/components/prowl/notify.py b/homeassistant/components/prowl/notify.py index e236230ec5b551..c878c58271ef84 100644 --- a/homeassistant/components/prowl/notify.py +++ b/homeassistant/components/prowl/notify.py @@ -16,11 +16,14 @@ ATTR_TITLE_DEFAULT, PLATFORM_SCHEMA as NOTIFY_PLATFORM_SCHEMA, BaseNotificationService, + NotifyEntity, ) +from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_API_KEY from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import config_validation as cv +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType _LOGGER = logging.getLogger(__name__) @@ -34,19 +37,31 @@ async def async_get_service( discovery_info: DiscoveryInfoType | None = None, ) -> ProwlNotificationService: """Get the Prowl notification service.""" - prowl = await hass.async_add_executor_job( - partial(prowlpy.Prowl, apikey=config[CONF_API_KEY]) + return await hass.async_add_executor_job( + partial(ProwlNotificationService, hass, config[CONF_API_KEY]) ) - return ProwlNotificationService(hass, prowl) + + +async def async_setup_entry( + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up the notify entities.""" + prowl = ProwlNotificationEntity(hass, entry.title, entry.data[CONF_API_KEY]) + async_add_entities([prowl]) class ProwlNotificationService(BaseNotificationService): - """Implement the notification service for Prowl.""" + """Implement the notification service for Prowl. - def __init__(self, hass: HomeAssistant, prowl: prowlpy.Prowl) -> None: + This class is used for legacy configuration via configuration.yaml + """ + + def __init__(self, hass: HomeAssistant, api_key: str) -> None: """Initialize the service.""" self._hass = hass - self._prowl = prowl + self._prowl = prowlpy.Prowl(api_key) async def async_send_message(self, message: str, **kwargs: Any) -> None: """Send the message to the user.""" @@ -80,3 +95,47 @@ async def async_send_message(self, message: str, **kwargs: Any) -> None: ) from ex _LOGGER.error("Unexpected error when calling Prowl API: %s", str(ex)) raise HomeAssistantError("Unexpected error when calling Prowl API") from ex + + +class ProwlNotificationEntity(NotifyEntity): + """Implement the notification service for Prowl. + + This class is used for Prowl config entries. + """ + + def __init__(self, hass: HomeAssistant, name: str, api_key: str) -> None: + """Initialize the service.""" + self._hass = hass + self._prowl = prowlpy.Prowl(api_key) + self._attr_name = name + self._attr_unique_id = name + + async def async_send_message(self, message: str, title: str | None = None) -> None: + """Send the message.""" + _LOGGER.debug("Sending Prowl notification from entity %s", self.name) + try: + async with asyncio.timeout(10): + await self._hass.async_add_executor_job( + partial( + self._prowl.send, + application="Home-Assistant", + event=title or ATTR_TITLE_DEFAULT, + description=message, + priority=0, + url=None, + ) + ) + except TimeoutError as ex: + _LOGGER.error("Timeout accessing Prowl API") + raise HomeAssistantError("Timeout accessing Prowl API") from ex + except prowlpy.APIError as ex: + if str(ex).startswith("Invalid API key"): + _LOGGER.error("Invalid API key for Prowl service") + raise HomeAssistantError("Invalid API key for Prowl service") from ex + if str(ex).startswith("Not accepted"): + _LOGGER.error("Prowl returned: exceeded rate limit") + raise HomeAssistantError( + "Prowl service reported: exceeded rate limit" + ) from ex + _LOGGER.error("Unexpected error when calling Prowl API: %s", str(ex)) + raise HomeAssistantError("Unexpected error when calling Prowl API") from ex diff --git a/homeassistant/components/prowl/strings.json b/homeassistant/components/prowl/strings.json new file mode 100644 index 00000000000000..eb136c57999a6a --- /dev/null +++ b/homeassistant/components/prowl/strings.json @@ -0,0 +1,21 @@ +{ + "config": { + "step": { + "user": { + "description": "Enter the Prowl API key and its name.", + "data": { + "api_key": "[%key:common::config_flow::data::api_key%]", + "name": "[%key:common::config_flow::data::name%]" + } + } + }, + "error": { + "invalid_api_key": "[%key:common::config_flow::error::invalid_api_key%]", + "api_timeout": "[%key:common::config_flow::error::timeout_connect%]", + "bad_api_response": "[%key:common::config_flow::error::unknown%]" + }, + "abort": { + "already_configured": "API key is already configured" + } + } +} diff --git a/homeassistant/components/recorder/statistics.py b/homeassistant/components/recorder/statistics.py index c10808d50475e8..0c504d106ef275 100644 --- a/homeassistant/components/recorder/statistics.py +++ b/homeassistant/components/recorder/statistics.py @@ -47,6 +47,7 @@ AreaConverter, BaseUnitConverter, BloodGlucoseConcentrationConverter, + CarbonMonoxideConcentrationConverter, ConductivityConverter, DataRateConverter, DistanceConverter, @@ -220,7 +221,9 @@ def query_circular_mean(table: type[StatisticsBase]) -> tuple[Label, Label]: VolumeFlowRateConverter, ] -_SECONDARY_UNIT_CONVERTERS: list[type[BaseUnitConverter]] = [] +_SECONDARY_UNIT_CONVERTERS: list[type[BaseUnitConverter]] = [ + CarbonMonoxideConcentrationConverter, +] STATISTIC_UNIT_TO_UNIT_CONVERTER: dict[str | None, type[BaseUnitConverter]] = { unit: conv for conv in _PRIMARY_UNIT_CONVERTERS for unit in conv.VALID_UNITS diff --git a/homeassistant/components/recorder/websocket_api.py b/homeassistant/components/recorder/websocket_api.py index 2c682e2ae48a72..a4a7db15a7ce17 100644 --- a/homeassistant/components/recorder/websocket_api.py +++ b/homeassistant/components/recorder/websocket_api.py @@ -21,6 +21,7 @@ ApparentPowerConverter, AreaConverter, BloodGlucoseConcentrationConverter, + CarbonMonoxideConcentrationConverter, ConductivityConverter, DataRateConverter, DistanceConverter, @@ -71,6 +72,9 @@ vol.Optional("blood_glucose_concentration"): vol.In( BloodGlucoseConcentrationConverter.VALID_UNITS ), + vol.Optional("carbon_monoxide"): vol.In( + CarbonMonoxideConcentrationConverter.VALID_UNITS + ), vol.Optional("concentration"): vol.In( MassVolumeConcentrationConverter.VALID_UNITS ), diff --git a/homeassistant/components/renault/renault_hub.py b/homeassistant/components/renault/renault_hub.py index 5e14328eb7cb43..cd6b43f36629aa 100644 --- a/homeassistant/components/renault/renault_hub.py +++ b/homeassistant/components/renault/renault_hub.py @@ -39,6 +39,23 @@ LOGGER = logging.getLogger(__name__) +async def _get_filtered_vehicles(account: RenaultAccount) -> list[KamereonVehiclesLink]: + """Filter out vehicles with missing details. + + May be due to new purchases, or issue with the Renault servers. + """ + vehicles = await account.get_vehicles() + if not vehicles.vehicleLinks: + return [] + result: list[KamereonVehiclesLink] = [] + for link in vehicles.vehicleLinks: + if link.vehicleDetails is None: + LOGGER.warning("Ignoring vehicle with missing details: %s", link.vin) + continue + result.append(link) + return result + + class RenaultHub: """Handle account communication with Renault servers.""" @@ -84,49 +101,48 @@ async def async_initialise(self, config_entry: RenaultConfigEntry) -> None: account_id: str = config_entry.data[CONF_KAMEREON_ACCOUNT_ID] self._account = await self._client.get_api_account(account_id) - vehicles = await self._account.get_vehicles() - if vehicles.vehicleLinks: - if any( - vehicle_link.vehicleDetails is None - for vehicle_link in vehicles.vehicleLinks - ): - raise ConfigEntryNotReady( - "Failed to retrieve vehicle details from Renault servers" - ) - - num_call_per_scan = len(COORDINATORS) * len(vehicles.vehicleLinks) - scan_interval = timedelta( - seconds=(3600 * num_call_per_scan) / MAX_CALLS_PER_HOURS + vehicle_links = await _get_filtered_vehicles(self._account) + if not vehicle_links: + LOGGER.debug( + "No valid vehicle details found for account_id: %s", account_id + ) + raise ConfigEntryNotReady( + "Failed to retrieve vehicle details from Renault servers" ) - device_registry = dr.async_get(self._hass) - await asyncio.gather( - *( - self.async_initialise_vehicle( - vehicle_link, - self._account, - scan_interval, - config_entry, - device_registry, - ) - for vehicle_link in vehicles.vehicleLinks + num_call_per_scan = len(COORDINATORS) * len(vehicle_links) + scan_interval = timedelta( + seconds=(3600 * num_call_per_scan) / MAX_CALLS_PER_HOURS + ) + + device_registry = dr.async_get(self._hass) + await asyncio.gather( + *( + self.async_initialise_vehicle( + vehicle_link, + self._account, + scan_interval, + config_entry, + device_registry, ) + for vehicle_link in vehicle_links ) + ) - # all vehicles have been initiated with the right number of active coordinators - num_call_per_scan = 0 - for vehicle_link in vehicles.vehicleLinks: - vehicle = self._vehicles[str(vehicle_link.vin)] - num_call_per_scan += len(vehicle.coordinators) + # all vehicles have been initiated with the right number of active coordinators + num_call_per_scan = 0 + for vehicle_link in vehicle_links: + vehicle = self._vehicles[str(vehicle_link.vin)] + num_call_per_scan += len(vehicle.coordinators) - new_scan_interval = timedelta( - seconds=(3600 * num_call_per_scan) / MAX_CALLS_PER_HOURS - ) - if new_scan_interval != scan_interval: - # we need to change the vehicles with the right scan interval - for vehicle_link in vehicles.vehicleLinks: - vehicle = self._vehicles[str(vehicle_link.vin)] - vehicle.update_scan_interval(new_scan_interval) + new_scan_interval = timedelta( + seconds=(3600 * num_call_per_scan) / MAX_CALLS_PER_HOURS + ) + if new_scan_interval != scan_interval: + # we need to change the vehicles with the right scan interval + for vehicle_link in vehicle_links: + vehicle = self._vehicles[str(vehicle_link.vin)] + vehicle.update_scan_interval(new_scan_interval) async def async_initialise_vehicle( self, @@ -164,10 +180,10 @@ async def get_account_ids(self) -> list[str]: """Get Kamereon account ids.""" accounts = [] for account in await self._client.get_api_accounts(): - vehicles = await account.get_vehicles() + vehicle_links = await _get_filtered_vehicles(account) # Only add the account if it has linked vehicles. - if vehicles.vehicleLinks: + if vehicle_links: accounts.append(account.account_id) return accounts diff --git a/homeassistant/components/reolink/icons.json b/homeassistant/components/reolink/icons.json index 13775b5c58fae2..0894075f4dbf8a 100644 --- a/homeassistant/components/reolink/icons.json +++ b/homeassistant/components/reolink/icons.json @@ -533,6 +533,12 @@ "manual_record": { "default": "mdi:record-rec" }, + "rule": { + "default": "mdi:cctv", + "state": { + "off": "mdi:cctv-off" + } + }, "pre_record": { "default": "mdi:history" }, diff --git a/homeassistant/components/reolink/strings.json b/homeassistant/components/reolink/strings.json index dda68c6b4ad407..54ee0d54179c19 100644 --- a/homeassistant/components/reolink/strings.json +++ b/homeassistant/components/reolink/strings.json @@ -1019,6 +1019,9 @@ "manual_record": { "name": "Manual record" }, + "rule": { + "name": "Surveillance rule {name}" + }, "pre_record": { "name": "Pre-recording" }, diff --git a/homeassistant/components/reolink/switch.py b/homeassistant/components/reolink/switch.py index b7d249b6fecb63..ab3ca483ccde0f 100644 --- a/homeassistant/components/reolink/switch.py +++ b/homeassistant/components/reolink/switch.py @@ -60,6 +60,18 @@ class ReolinkChimeSwitchEntityDescription( value: Callable[[Chime], bool | None] +@dataclass(frozen=True, kw_only=True) +class ReolinkSwitchIndexEntityDescription( + SwitchEntityDescription, + ReolinkChannelEntityDescription, +): + """A class that describes switch entities with an extra index.""" + + method: Callable[[Host, int, int, bool], Any] + value: Callable[[Host, int, int], bool | None] + placeholder: Callable[[Host, int, int], str] + + SWITCH_ENTITIES = ( ReolinkSwitchEntityDescription( key="ir_lights", @@ -304,6 +316,15 @@ class ReolinkChimeSwitchEntityDescription( ), ) +RULE_SWITCH_ENTITY = ReolinkSwitchIndexEntityDescription( + key="rule", + cmd_key="rules", + translation_key="rule", + placeholder=lambda api, ch, idx: api.baichuan.rule_name(ch, idx), + value=lambda api, ch, idx: api.baichuan.rule_enabled(ch, idx), + method=lambda api, ch, idx, value: (api.baichuan.set_rule_enabled(ch, idx, value)), +) + async def async_setup_entry( hass: HomeAssistant, @@ -336,6 +357,11 @@ async def async_setup_entry( for chime in reolink_data.host.api.chime_list if chime.channel is None ) + entities.extend( + ReolinkIndexSwitchEntity(reolink_data, channel, rule_id, RULE_SWITCH_ENTITY) + for channel in reolink_data.host.api.channels + for rule_id in reolink_data.host.api.baichuan.rule_ids(channel) + ) async_add_entities(entities) @@ -469,3 +495,46 @@ async def async_turn_off(self, **kwargs: Any) -> None: """Turn the entity off.""" await self.entity_description.method(self._chime, False) self.async_write_ha_state() + + +class ReolinkIndexSwitchEntity(ReolinkChannelCoordinatorEntity, SwitchEntity): + """Base switch entity class for Reolink IP camera with an extra index.""" + + entity_description: ReolinkSwitchIndexEntityDescription + + def __init__( + self, + reolink_data: ReolinkData, + channel: int, + index: int, + entity_description: ReolinkSwitchIndexEntityDescription, + ) -> None: + """Initialize Reolink switch entity.""" + self.entity_description = entity_description + super().__init__(reolink_data, channel) + self._index = index + self._attr_translation_placeholders = { + "name": entity_description.placeholder(self._host.api, self._channel, index) + } + self._attr_unique_id = f"{self._attr_unique_id}_{index}" + + @property + def is_on(self) -> bool | None: + """Return true if switch is on.""" + return self.entity_description.value(self._host.api, self._channel, self._index) + + @raise_translated_error + async def async_turn_on(self, **kwargs: Any) -> None: + """Turn the entity on.""" + await self.entity_description.method( + self._host.api, self._channel, self._index, True + ) + self.async_write_ha_state() + + @raise_translated_error + async def async_turn_off(self, **kwargs: Any) -> None: + """Turn the entity off.""" + await self.entity_description.method( + self._host.api, self._channel, self._index, False + ) + self.async_write_ha_state() diff --git a/homeassistant/components/satel_integra/__init__.py b/homeassistant/components/satel_integra/__init__.py index 2ffcd243d39ba8..7ef7fd5708b2e1 100644 --- a/homeassistant/components/satel_integra/__init__.py +++ b/homeassistant/components/satel_integra/__init__.py @@ -245,3 +245,43 @@ async def async_unload_entry(hass: HomeAssistant, entry: SatelConfigEntry) -> bo async def update_listener(hass: HomeAssistant, entry: SatelConfigEntry) -> None: """Handle options update.""" hass.config_entries.async_schedule_reload(entry.entry_id) + + +async def async_migrate_entry( + hass: HomeAssistant, config_entry: SatelConfigEntry +) -> bool: + """Migrate old entry.""" + _LOGGER.debug( + "Migrating configuration from version %s.%s", + config_entry.version, + config_entry.minor_version, + ) + + if config_entry.version > 1: + # This means the user has downgraded from a future version + return False + + if config_entry.version == 1 and config_entry.minor_version == 1: + for subentry in config_entry.subentries.values(): + property_map = { + SUBENTRY_TYPE_PARTITION: CONF_PARTITION_NUMBER, + SUBENTRY_TYPE_ZONE: CONF_ZONE_NUMBER, + SUBENTRY_TYPE_OUTPUT: CONF_OUTPUT_NUMBER, + SUBENTRY_TYPE_SWITCHABLE_OUTPUT: CONF_SWITCHABLE_OUTPUT_NUMBER, + } + + new_title = f"{subentry.title} ({subentry.data[property_map[subentry.subentry_type]]})" + + hass.config_entries.async_update_subentry( + config_entry, subentry, title=new_title + ) + + hass.config_entries.async_update_entry(config_entry, minor_version=2) + + _LOGGER.debug( + "Migration to configuration version %s.%s successful", + config_entry.version, + config_entry.minor_version, + ) + + return True diff --git a/homeassistant/components/satel_integra/config_flow.py b/homeassistant/components/satel_integra/config_flow.py index d5427488fc74ad..47cff421a19bd9 100644 --- a/homeassistant/components/satel_integra/config_flow.py +++ b/homeassistant/components/satel_integra/config_flow.py @@ -91,6 +91,7 @@ class SatelConfigFlow(ConfigFlow, domain=DOMAIN): """Handle a Satel Integra config flow.""" VERSION = 1 + MINOR_VERSION = 2 @staticmethod @callback @@ -158,7 +159,7 @@ async def async_step_import( subentries.append( { "subentry_type": SUBENTRY_TYPE_PARTITION, - "title": partition_data[CONF_NAME], + "title": f"{partition_data[CONF_NAME]} ({partition_number})", "unique_id": f"{SUBENTRY_TYPE_PARTITION}_{partition_number}", "data": { CONF_NAME: partition_data[CONF_NAME], @@ -174,7 +175,7 @@ async def async_step_import( subentries.append( { "subentry_type": SUBENTRY_TYPE_ZONE, - "title": zone_data[CONF_NAME], + "title": f"{zone_data[CONF_NAME]} ({zone_number})", "unique_id": f"{SUBENTRY_TYPE_ZONE}_{zone_number}", "data": { CONF_NAME: zone_data[CONF_NAME], @@ -192,7 +193,7 @@ async def async_step_import( subentries.append( { "subentry_type": SUBENTRY_TYPE_OUTPUT, - "title": output_data[CONF_NAME], + "title": f"{output_data[CONF_NAME]} ({output_number})", "unique_id": f"{SUBENTRY_TYPE_OUTPUT}_{output_number}", "data": { CONF_NAME: output_data[CONF_NAME], @@ -210,7 +211,7 @@ async def async_step_import( subentries.append( { "subentry_type": SUBENTRY_TYPE_SWITCHABLE_OUTPUT, - "title": switchable_output_data[CONF_NAME], + "title": f"{switchable_output_data[CONF_NAME]} ({switchable_output_number})", "unique_id": f"{SUBENTRY_TYPE_SWITCHABLE_OUTPUT}_{switchable_output_number}", "data": { CONF_NAME: switchable_output_data[CONF_NAME], @@ -279,7 +280,9 @@ async def async_step_user( if not errors: return self.async_create_entry( - title=user_input[CONF_NAME], data=user_input, unique_id=unique_id + title=f"{user_input[CONF_NAME]} ({user_input[CONF_PARTITION_NUMBER]})", + data=user_input, + unique_id=unique_id, ) return self.async_show_form( @@ -304,7 +307,7 @@ async def async_step_reconfigure( return self.async_update_and_abort( self._get_entry(), subconfig_entry, - title=user_input[CONF_NAME], + title=f"{user_input[CONF_NAME]} ({subconfig_entry.data[CONF_PARTITION_NUMBER]})", data_updates=user_input, ) @@ -338,7 +341,9 @@ async def async_step_user( if not errors: return self.async_create_entry( - title=user_input[CONF_NAME], data=user_input, unique_id=unique_id + title=f"{user_input[CONF_NAME]} ({user_input[CONF_ZONE_NUMBER]})", + data=user_input, + unique_id=unique_id, ) return self.async_show_form( @@ -363,7 +368,7 @@ async def async_step_reconfigure( return self.async_update_and_abort( self._get_entry(), subconfig_entry, - title=user_input[CONF_NAME], + title=f"{user_input[CONF_NAME]} ({subconfig_entry.data[CONF_ZONE_NUMBER]})", data_updates=user_input, ) @@ -396,7 +401,9 @@ async def async_step_user( if not errors: return self.async_create_entry( - title=user_input[CONF_NAME], data=user_input, unique_id=unique_id + title=f"{user_input[CONF_NAME]} ({user_input[CONF_OUTPUT_NUMBER]})", + data=user_input, + unique_id=unique_id, ) return self.async_show_form( @@ -421,7 +428,7 @@ async def async_step_reconfigure( return self.async_update_and_abort( self._get_entry(), subconfig_entry, - title=user_input[CONF_NAME], + title=f"{user_input[CONF_NAME]} ({subconfig_entry.data[CONF_OUTPUT_NUMBER]})", data_updates=user_input, ) @@ -454,7 +461,9 @@ async def async_step_user( if not errors: return self.async_create_entry( - title=user_input[CONF_NAME], data=user_input, unique_id=unique_id + title=f"{user_input[CONF_NAME]} ({user_input[CONF_SWITCHABLE_OUTPUT_NUMBER]})", + data=user_input, + unique_id=unique_id, ) return self.async_show_form( @@ -479,7 +488,7 @@ async def async_step_reconfigure( return self.async_update_and_abort( self._get_entry(), subconfig_entry, - title=user_input[CONF_NAME], + title=f"{user_input[CONF_NAME]} ({subconfig_entry.data[CONF_SWITCHABLE_OUTPUT_NUMBER]})", data_updates=user_input, ) diff --git a/homeassistant/components/sensor/const.py b/homeassistant/components/sensor/const.py index 87ddf4445a015c..b91bd26d410cfc 100644 --- a/homeassistant/components/sensor/const.py +++ b/homeassistant/components/sensor/const.py @@ -51,6 +51,7 @@ AreaConverter, BaseUnitConverter, BloodGlucoseConcentrationConverter, + CarbonMonoxideConcentrationConverter, ConductivityConverter, DataRateConverter, DistanceConverter, @@ -156,7 +157,7 @@ class SensorDeviceClass(StrEnum): CO = "carbon_monoxide" """Carbon Monoxide gas concentration. - Unit of measurement: `ppm` (parts per million) + Unit of measurement: `ppm` (parts per million), `mg/m³` """ CO2 = "carbon_dioxide" @@ -543,6 +544,7 @@ class SensorStateClass(StrEnum): SensorDeviceClass.AREA: AreaConverter, SensorDeviceClass.ATMOSPHERIC_PRESSURE: PressureConverter, SensorDeviceClass.BLOOD_GLUCOSE_CONCENTRATION: BloodGlucoseConcentrationConverter, + SensorDeviceClass.CO: CarbonMonoxideConcentrationConverter, SensorDeviceClass.CONDUCTIVITY: ConductivityConverter, SensorDeviceClass.CURRENT: ElectricCurrentConverter, SensorDeviceClass.DATA_RATE: DataRateConverter, @@ -584,7 +586,10 @@ class SensorStateClass(StrEnum): SensorDeviceClass.ATMOSPHERIC_PRESSURE: set(UnitOfPressure), SensorDeviceClass.BATTERY: {PERCENTAGE}, SensorDeviceClass.BLOOD_GLUCOSE_CONCENTRATION: set(UnitOfBloodGlucoseConcentration), - SensorDeviceClass.CO: {CONCENTRATION_PARTS_PER_MILLION}, + SensorDeviceClass.CO: { + CONCENTRATION_PARTS_PER_MILLION, + CONCENTRATION_MILLIGRAMS_PER_CUBIC_METER, + }, SensorDeviceClass.CO2: {CONCENTRATION_PARTS_PER_MILLION}, SensorDeviceClass.CONDUCTIVITY: set(UnitOfConductivity), SensorDeviceClass.CURRENT: set(UnitOfElectricCurrent), diff --git a/homeassistant/components/sensor/recorder.py b/homeassistant/components/sensor/recorder.py index 07f4565a6c35e1..45ff44de0539e1 100644 --- a/homeassistant/components/sensor/recorder.py +++ b/homeassistant/components/sensor/recorder.py @@ -846,6 +846,10 @@ def _update_issues( SensorStateClass, state.attributes.get(ATTR_STATE_CLASS) ) state_unit = state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) + state_unit_class = _get_unit_class( + state.attributes.get(ATTR_DEVICE_CLASS), + state_unit, + ) if metadata := metadatas.get(entity_id): if numeric and state_class is None: @@ -867,7 +871,9 @@ def _update_issues( { "statistic_id": entity_id, "state_unit": state_unit, + "state_unit_class": state_unit_class, "metadata_unit": metadata_unit, + "metadata_unit_class": metadata[1]["unit_class"], "supported_unit": metadata_unit, }, ) @@ -881,7 +887,9 @@ def _update_issues( { "statistic_id": entity_id, "state_unit": state_unit, + "state_unit_class": state_unit_class, "metadata_unit": metadata_unit, + "metadata_unit_class": metadata[1]["unit_class"], "supported_unit": valid_units_str, }, ) diff --git a/homeassistant/components/sharkiq/manifest.json b/homeassistant/components/sharkiq/manifest.json index e5e1d1b6a8ecc2..13db3411565308 100644 --- a/homeassistant/components/sharkiq/manifest.json +++ b/homeassistant/components/sharkiq/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/sharkiq", "iot_class": "cloud_polling", "loggers": ["sharkiq"], - "requirements": ["sharkiq==1.4.0"] + "requirements": ["sharkiq==1.4.2"] } diff --git a/homeassistant/components/shelly/button.py b/homeassistant/components/shelly/button.py index 4c6d5695f33578..cf467209c39655 100644 --- a/homeassistant/components/shelly/button.py +++ b/homeassistant/components/shelly/button.py @@ -97,36 +97,6 @@ class RpcButtonDescription(RpcEntityDescription, ButtonEntityDescription): ), ] -BLU_TRV_BUTTONS: Final[list[ShellyButtonDescription]] = [ - ShellyButtonDescription[ShellyRpcCoordinator]( - key="calibrate", - name="Calibrate", - translation_key="calibrate", - entity_category=EntityCategory.CONFIG, - press_action="trigger_blu_trv_calibration", - supported=lambda coordinator: coordinator.model == MODEL_BLU_GATEWAY_G3, - ), -] - -RPC_VIRTUAL_BUTTONS = { - "button_generic": RpcButtonDescription( - key="button", - role="generic", - ), - "button_open": RpcButtonDescription( - key="button", - entity_registry_enabled_default=False, - role="open", - models={MODEL_FRANKEVER_WATER_VALVE}, - ), - "button_close": RpcButtonDescription( - key="button", - entity_registry_enabled_default=False, - role="close", - models={MODEL_FRANKEVER_WATER_VALVE}, - ), -} - @callback def async_migrate_unique_ids( @@ -218,7 +188,7 @@ async def async_setup_entry( hass, config_entry.entry_id, partial(async_migrate_unique_ids, coordinator) ) - entities: list[ShellyButton | ShellyBluTrvButton] = [] + entities: list[ShellyButton] = [] entities.extend( ShellyButton(coordinator, button) @@ -226,26 +196,16 @@ async def async_setup_entry( if button.supported(coordinator) ) + async_add_entities(entities) + if not isinstance(coordinator, ShellyRpcCoordinator): - async_add_entities(entities) return - # add virtual buttons + # add RPC buttons async_setup_entry_rpc( - hass, config_entry, async_add_entities, RPC_VIRTUAL_BUTTONS, RpcVirtualButton + hass, config_entry, async_add_entities, RPC_BUTTONS, RpcVirtualButton ) - # add BLU TRV buttons - if blutrv_key_ids := get_rpc_key_ids(coordinator.device.status, BLU_TRV_IDENTIFIER): - entities.extend( - ShellyBluTrvButton(coordinator, button, id_) - for id_ in blutrv_key_ids - for button in BLU_TRV_BUTTONS - if button.supported(coordinator) - ) - - async_add_entities(entities) - # the user can remove virtual components from the device configuration, so # we need to remove orphaned entities virtual_button_component_ids = get_virtual_component_ids( @@ -342,37 +302,35 @@ async def _press_method(self) -> None: await method() -class ShellyBluTrvButton(ShellyBaseButton): +class ShellyBluTrvButton(ShellyRpcAttributeEntity, ButtonEntity): """Represent a Shelly BLU TRV button.""" + entity_description: RpcButtonDescription + _id: int + def __init__( self, coordinator: ShellyRpcCoordinator, - description: ShellyButtonDescription, - id_: int, + key: str, + attribute: str, + description: RpcEntityDescription, ) -> None: - """Initialize.""" - super().__init__(coordinator, description) + """Initialize button.""" + super().__init__(coordinator, key, attribute, description) - key = f"{BLU_TRV_IDENTIFIER}:{id_}" config = coordinator.device.config[key] ble_addr: str = config["addr"] fw_ver = coordinator.device.status[key].get("fw_ver") - self._attr_unique_id = f"{format_ble_addr(ble_addr)}-{key}-{description.key}" + self._attr_unique_id = f"{format_ble_addr(ble_addr)}-{key}-{attribute}" self._attr_device_info = get_blu_trv_device_info( config, ble_addr, coordinator.mac, fw_ver ) - self._id = id_ - - async def _press_method(self) -> None: - """Press method.""" - method = getattr(self.coordinator.device, self.entity_description.press_action) - if TYPE_CHECKING: - assert method is not None - - await method(self._id) + @rpc_call + async def async_press(self) -> None: + """Triggers the Shelly button press service.""" + await self.coordinator.device.trigger_blu_trv_calibration(self._id) class RpcVirtualButton(ShellyRpcAttributeEntity, ButtonEntity): @@ -388,3 +346,31 @@ async def async_press(self) -> None: assert isinstance(self.coordinator, ShellyRpcCoordinator) await self.coordinator.device.button_trigger(self._id, "single_push") + + +RPC_BUTTONS = { + "button_generic": RpcButtonDescription( + key="button", + role="generic", + ), + "button_open": RpcButtonDescription( + key="button", + entity_registry_enabled_default=False, + role="open", + models={MODEL_FRANKEVER_WATER_VALVE}, + ), + "button_close": RpcButtonDescription( + key="button", + entity_registry_enabled_default=False, + role="close", + models={MODEL_FRANKEVER_WATER_VALVE}, + ), + "calibrate": RpcButtonDescription( + key="blutrv", + name="Calibrate", + translation_key="calibrate", + entity_category=EntityCategory.CONFIG, + entity_class=ShellyBluTrvButton, + models={MODEL_BLU_GATEWAY_G3}, + ), +} diff --git a/homeassistant/components/shelly/cover.py b/homeassistant/components/shelly/cover.py index bea77b8eb6863e..c7ed54752387ce 100644 --- a/homeassistant/components/shelly/cover.py +++ b/homeassistant/components/shelly/cover.py @@ -269,6 +269,8 @@ async def update_position(self) -> None: def _update_callback(self) -> None: """Handle device update. Use a task when opening/closing is in progress.""" super()._update_callback() + if not self.coordinator.device.initialized: + return if self.is_closing or self.is_opening: self.launch_update_task() diff --git a/homeassistant/components/smlight/icons.json b/homeassistant/components/smlight/icons.json index 3d086466b4f2a7..1afd7c71d6769a 100644 --- a/homeassistant/components/smlight/icons.json +++ b/homeassistant/components/smlight/icons.json @@ -10,6 +10,28 @@ "zigbee_type": { "default": "mdi:zigbee" } + }, + "switch": { + "disable_led": { + "default": "mdi:led-off" + }, + "auto_zigbee_update": { + "default": "mdi:autorenew" + }, + "night_mode": { + "default": "mdi:lightbulb-night" + }, + "vpn_enabled": { + "default": "mdi:shield-lock" + } + }, + "button": { + "zigbee_flash_mode": { + "default": "mdi:memory-arrow-down" + }, + "reconnect_zigbee_router": { + "default": "mdi:connection" + } } } } diff --git a/homeassistant/components/smlight/switch.py b/homeassistant/components/smlight/switch.py index 5cd187c009c40a..17c4a0d7dceaf3 100644 --- a/homeassistant/components/smlight/switch.py +++ b/homeassistant/components/smlight/switch.py @@ -51,7 +51,6 @@ class SmSwitchEntityDescription(SwitchEntityDescription): SmSwitchEntityDescription( key="auto_zigbee_update", translation_key="auto_zigbee_update", - entity_category=EntityCategory.CONFIG, setting=Settings.ZB_AUTOUPDATE, entity_registry_enabled_default=False, state_fn=lambda x: x.auto_zigbee, @@ -83,6 +82,7 @@ class SmSwitch(SmEntity, SwitchEntity): coordinator: SmDataUpdateCoordinator entity_description: SmSwitchEntityDescription _attr_device_class = SwitchDeviceClass.SWITCH + _attr_entity_category = EntityCategory.CONFIG def __init__( self, diff --git a/homeassistant/components/solaredge/__init__.py b/homeassistant/components/solaredge/__init__.py index 206a2499494325..d80502b3fdf5fb 100644 --- a/homeassistant/components/solaredge/__init__.py +++ b/homeassistant/components/solaredge/__init__.py @@ -7,12 +7,13 @@ from aiohttp import ClientError from aiosolaredge import SolarEdge -from homeassistant.const import CONF_API_KEY, Platform +from homeassistant.const import CONF_API_KEY, CONF_USERNAME, Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady from homeassistant.helpers.aiohttp_client import async_get_clientsession -from .const import CONF_SITE_ID, LOGGER +from .const import CONF_SITE_ID, DATA_API_CLIENT, DATA_MODULES_COORDINATOR, LOGGER +from .coordinator import SolarEdgeModulesCoordinator from .types import SolarEdgeConfigEntry, SolarEdgeData PLATFORMS = [Platform.SENSOR] @@ -20,25 +21,37 @@ async def async_setup_entry(hass: HomeAssistant, entry: SolarEdgeConfigEntry) -> bool: """Set up SolarEdge from a config entry.""" - session = async_get_clientsession(hass) - api = SolarEdge(entry.data[CONF_API_KEY], session) + entry.runtime_data = SolarEdgeData() + site_id = entry.data[CONF_SITE_ID] - try: - response = await api.get_details(entry.data[CONF_SITE_ID]) - except (TimeoutError, ClientError, socket.gaierror) as ex: - LOGGER.error("Could not retrieve details from SolarEdge API") - raise ConfigEntryNotReady from ex + # Setup for API key (sensors) + if CONF_API_KEY in entry.data: + session = async_get_clientsession(hass) + api = SolarEdge(entry.data[CONF_API_KEY], session) - if "details" not in response: - LOGGER.error("Missing details data in SolarEdge response") - raise ConfigEntryNotReady + try: + response = await api.get_details(site_id) + except (TimeoutError, ClientError, socket.gaierror) as ex: + LOGGER.error("Could not retrieve details from SolarEdge API") + raise ConfigEntryNotReady from ex - if response["details"].get("status", "").lower() != "active": - LOGGER.error("SolarEdge site is not active") - return False + if "details" not in response: + LOGGER.error("Missing details data in SolarEdge response") + raise ConfigEntryNotReady + + if response["details"].get("status", "").lower() != "active": + LOGGER.error("SolarEdge site is not active") + return False + + entry.runtime_data[DATA_API_CLIENT] = api + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + + # Setup for username/password (modules statistics) + if CONF_USERNAME in entry.data: + coordinator = SolarEdgeModulesCoordinator(hass, entry) + await coordinator.async_config_entry_first_refresh() + entry.runtime_data[DATA_MODULES_COORDINATOR] = coordinator - entry.runtime_data = SolarEdgeData(api_client=api) - await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) return True diff --git a/homeassistant/components/solaredge/config_flow.py b/homeassistant/components/solaredge/config_flow.py index 6235e22400ff13..69bd5d6cd3b927 100644 --- a/homeassistant/components/solaredge/config_flow.py +++ b/homeassistant/components/solaredge/config_flow.py @@ -5,17 +5,25 @@ import socket from typing import Any -from aiohttp import ClientError +from aiohttp import ClientError, ClientResponseError import aiosolaredge +from solaredge_web import SolarEdgeWeb import voluptuous as vol from homeassistant.config_entries import ConfigFlow, ConfigFlowResult -from homeassistant.const import CONF_API_KEY, CONF_NAME +from homeassistant.const import CONF_API_KEY, CONF_NAME, CONF_PASSWORD, CONF_USERNAME from homeassistant.core import callback +from homeassistant.data_entry_flow import section from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.util import slugify -from .const import CONF_SITE_ID, DEFAULT_NAME, DOMAIN +from .const import ( + CONF_SECTION_API_AUTH, + CONF_SECTION_WEB_AUTH, + CONF_SITE_ID, + DEFAULT_NAME, + DOMAIN, +) class SolarEdgeConfigFlow(ConfigFlow, domain=DOMAIN): @@ -50,13 +58,36 @@ async def _async_check_site(self, site_id: str, api_key: str) -> bool: self._errors[CONF_SITE_ID] = "site_not_active" return False except (TimeoutError, ClientError, socket.gaierror): - self._errors[CONF_SITE_ID] = "could_not_connect" + self._errors[CONF_SITE_ID] = "cannot_connect" return False except KeyError: self._errors[CONF_SITE_ID] = "invalid_api_key" return False return True + async def _async_check_web_login( + self, site_id: str, username: str, password: str + ) -> bool: + """Validate the user input allows us to connect to the web service.""" + api = SolarEdgeWeb( + username=username, + password=password, + site_id=site_id, + session=async_get_clientsession(self.hass), + ) + try: + await api.async_get_equipment() + except ClientResponseError as err: + if err.status in (401, 403): + self._errors["base"] = "invalid_auth" + else: + self._errors["base"] = "cannot_connect" + return False + except (TimeoutError, ClientError): + self._errors["base"] = "cannot_connect" + return False + return True + async def async_step_user( self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: @@ -64,19 +95,34 @@ async def async_step_user( self._errors = {} if user_input is not None: name = slugify(user_input.get(CONF_NAME, DEFAULT_NAME)) - if self._site_in_configuration_exists(user_input[CONF_SITE_ID]): + site_id = user_input[CONF_SITE_ID] + api_auth = user_input.get(CONF_SECTION_API_AUTH, {}) + web_auth = user_input.get(CONF_SECTION_WEB_AUTH, {}) + api_key = api_auth.get(CONF_API_KEY) + username = web_auth.get(CONF_USERNAME) + + if self._site_in_configuration_exists(site_id): self._errors[CONF_SITE_ID] = "already_configured" + elif not api_key and not username: + self._errors["base"] = "auth_missing" else: - site = user_input[CONF_SITE_ID] - api = user_input[CONF_API_KEY] - can_connect = await self._async_check_site(site, api) - if can_connect: - return self.async_create_entry( - title=name, data={CONF_SITE_ID: site, CONF_API_KEY: api} + api_key_ok = True + if api_key: + api_key_ok = await self._async_check_site(site_id, api_key) + + web_login_ok = True + if api_key_ok and username: + web_login_ok = await self._async_check_web_login( + site_id, username, web_auth[CONF_PASSWORD] ) + if api_key_ok and web_login_ok: + data = {CONF_SITE_ID: site_id} + data.update(api_auth) + data.update(web_auth) + return self.async_create_entry(title=name, data=data) else: - user_input = {CONF_NAME: DEFAULT_NAME, CONF_SITE_ID: "", CONF_API_KEY: ""} + user_input = {} return self.async_show_form( step_id="user", data_schema=vol.Schema( @@ -84,8 +130,43 @@ async def async_step_user( vol.Required( CONF_NAME, default=user_input.get(CONF_NAME, DEFAULT_NAME) ): str, - vol.Required(CONF_SITE_ID, default=user_input[CONF_SITE_ID]): str, - vol.Required(CONF_API_KEY, default=user_input[CONF_API_KEY]): str, + vol.Required( + CONF_SITE_ID, default=user_input.get(CONF_SITE_ID, "") + ): str, + vol.Optional(CONF_SECTION_API_AUTH): section( + vol.Schema( + { + vol.Optional( + CONF_API_KEY, + default=user_input.get( + CONF_SECTION_API_AUTH, {} + ).get(CONF_API_KEY, ""), + ): str, + } + ), + options={"collapsed": False}, + ), + vol.Optional(CONF_SECTION_WEB_AUTH): section( + vol.Schema( + { + vol.Inclusive( + CONF_USERNAME, + "web_account", + default=user_input.get( + CONF_SECTION_WEB_AUTH, {} + ).get(CONF_USERNAME, ""), + ): str, + vol.Inclusive( + CONF_PASSWORD, + "web_account", + default=user_input.get( + CONF_SECTION_WEB_AUTH, {} + ).get(CONF_PASSWORD, ""), + ): str, + } + ), + options={"collapsed": False}, + ), } ), errors=self._errors, diff --git a/homeassistant/components/solaredge/const.py b/homeassistant/components/solaredge/const.py index 43cabeaa369684..35a14091e68b0f 100644 --- a/homeassistant/components/solaredge/const.py +++ b/homeassistant/components/solaredge/const.py @@ -9,9 +9,12 @@ LOGGER = logging.getLogger(__package__) DATA_API_CLIENT: Final = "api_client" +DATA_MODULES_COORDINATOR: Final = "modules_coordinator" # Config for solaredge monitoring api requests. CONF_SITE_ID = "site_id" +CONF_SECTION_API_AUTH = "api_auth" +CONF_SECTION_WEB_AUTH = "web_auth" DEFAULT_NAME = "SolarEdge" OVERVIEW_UPDATE_DELAY = timedelta(minutes=15) @@ -19,5 +22,6 @@ INVENTORY_UPDATE_DELAY = timedelta(hours=12) POWER_FLOW_UPDATE_DELAY = timedelta(minutes=15) ENERGY_DETAILS_DELAY = timedelta(minutes=15) +MODULE_STATISTICS_UPDATE_DELAY = timedelta(hours=12) SCAN_INTERVAL = timedelta(minutes=15) diff --git a/homeassistant/components/solaredge/coordinator.py b/homeassistant/components/solaredge/coordinator.py index 44f015eedeb8a5..e69ed0450241a9 100644 --- a/homeassistant/components/solaredge/coordinator.py +++ b/homeassistant/components/solaredge/coordinator.py @@ -3,20 +3,40 @@ from __future__ import annotations from abc import ABC, abstractmethod +from collections.abc import Iterable from datetime import date, datetime, timedelta from typing import TYPE_CHECKING, Any from aiosolaredge import SolarEdge +from solaredge_web import EnergyData, SolarEdgeWeb, TimeUnit from stringcase import snakecase +from homeassistant.components.recorder import get_instance +from homeassistant.components.recorder.models import ( + StatisticData, + StatisticMeanType, + StatisticMetaData, +) +from homeassistant.components.recorder.statistics import ( + async_add_external_statistics, + get_last_statistics, + statistics_during_period, +) +from homeassistant.const import CONF_PASSWORD, CONF_USERNAME, UnitOfEnergy from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers import aiohttp_client from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed +from homeassistant.util import dt as dt_util +from homeassistant.util.unit_conversion import EnergyConverter from .const import ( + CONF_SITE_ID, DETAILS_UPDATE_DELAY, + DOMAIN, ENERGY_DETAILS_DELAY, INVENTORY_UPDATE_DELAY, LOGGER, + MODULE_STATISTICS_UPDATE_DELAY, OVERVIEW_UPDATE_DELAY, POWER_FLOW_UPDATE_DELAY, ) @@ -313,3 +333,155 @@ async def async_update_data(self) -> None: self.attributes[key]["soc"] = value["chargeLevel"] LOGGER.debug("Updated SolarEdge power flow: %s, %s", self.data, self.attributes) + + +class SolarEdgeModulesCoordinator(DataUpdateCoordinator[None]): + """Handle fetching SolarEdge Modules data and inserting statistics.""" + + config_entry: SolarEdgeConfigEntry + + def __init__( + self, + hass: HomeAssistant, + config_entry: SolarEdgeConfigEntry, + ) -> None: + """Initialize the data handler.""" + super().__init__( + hass, + LOGGER, + config_entry=config_entry, + name="SolarEdge Modules", + # API refreshes every 15 minutes, but since we only have statistics + # and no sensors, refresh every 12h. + update_interval=MODULE_STATISTICS_UPDATE_DELAY, + ) + self.api = SolarEdgeWeb( + username=config_entry.data[CONF_USERNAME], + password=config_entry.data[CONF_PASSWORD], + site_id=config_entry.data[CONF_SITE_ID], + session=aiohttp_client.async_get_clientsession(hass), + ) + self.site_id = config_entry.data[CONF_SITE_ID] + self.title = config_entry.title + + @callback + def _dummy_listener() -> None: + pass + + # Force the coordinator to periodically update by registering a listener. + # Needed because there are no sensors added. + self.async_add_listener(_dummy_listener) + + async def _async_update_data(self) -> None: + """Fetch data from API endpoint and update statistics.""" + equipment: dict[int, dict[str, Any]] = await self.api.async_get_equipment() + # We fetch last week's data from the API and refresh every 12h so we overwrite recent + # statistics. This is intended to allow adding any corrected/updated data from the API. + energy_data_list: list[EnergyData] = await self.api.async_get_energy_data( + TimeUnit.WEEK + ) + if not energy_data_list: + LOGGER.warning( + "No data received from SolarEdge API for site: %s", self.site_id + ) + return + last_sums = await self._async_get_last_sums( + equipment.keys(), + energy_data_list[0].start_time.replace( + tzinfo=dt_util.get_default_time_zone() + ), + ) + for equipment_id, equipment_data in equipment.items(): + display_name = equipment_data.get( + "displayName", f"Equipment {equipment_id}" + ) + statistic_id = self.get_statistic_id(equipment_id) + statistic_metadata = StatisticMetaData( + mean_type=StatisticMeanType.ARITHMETIC, + has_sum=True, + name=f"{self.title} {display_name}", + source=DOMAIN, + statistic_id=statistic_id, + unit_class=EnergyConverter.UNIT_CLASS, + unit_of_measurement=UnitOfEnergy.WATT_HOUR, + ) + statistic_sum = last_sums[statistic_id] + statistics = [] + current_hour_sum = 0.0 + current_hour_count = 0 + for energy_data in energy_data_list: + start_time = energy_data.start_time.replace( + tzinfo=dt_util.get_default_time_zone() + ) + value = energy_data.values.get(equipment_id, 0.0) + current_hour_sum += value + current_hour_count += 1 + if start_time.minute != 45: + continue + # API returns data every 15 minutes; aggregate to 1-hour statistics + # when we reach the energy_data for the last 15 minutes of the hour. + current_avg = current_hour_sum / current_hour_count + statistic_sum += current_avg + statistics.append( + StatisticData( + start=start_time - timedelta(minutes=45), + state=current_avg, + sum=statistic_sum, + ) + ) + current_hour_sum = 0.0 + current_hour_count = 0 + LOGGER.debug( + "Adding %s statistics for %s %s", + len(statistics), + statistic_id, + display_name, + ) + async_add_external_statistics(self.hass, statistic_metadata, statistics) + + def get_statistic_id(self, equipment_id: int) -> str: + """Return the statistic ID for this equipment_id.""" + return f"{DOMAIN}:{self.site_id}_{equipment_id}" + + async def _async_get_last_sums( + self, equipment_ids: Iterable[int], start_time: datetime + ) -> dict[str, float]: + """Get the last sum from the recorder before start_time for each statistic.""" + start = start_time - timedelta(hours=1) + statistic_ids = {self.get_statistic_id(eq_id) for eq_id in equipment_ids} + LOGGER.debug( + "Getting sum for %s statistic IDs at: %s", len(statistic_ids), start + ) + current_stats = await get_instance(self.hass).async_add_executor_job( + statistics_during_period, + self.hass, + start, + start + timedelta(seconds=1), + statistic_ids, + "hour", + None, + {"sum"}, + ) + result = {} + for statistic_id in statistic_ids: + if statistic_id in current_stats: + statistic_sum = current_stats[statistic_id][0]["sum"] + else: + # If no statistics found right before start_time, try to get the last statistic + # but use it only if it's before start_time. + # This is needed if the integration hasn't run successfully for at least a week. + last_stat = await get_instance(self.hass).async_add_executor_job( + get_last_statistics, self.hass, 1, statistic_id, True, {"sum"} + ) + if ( + last_stat + and last_stat[statistic_id][0]["start"] < start_time.timestamp() + ): + statistic_sum = last_stat[statistic_id][0]["sum"] + else: + # Expected for new installations or if the statistics were cleared, + # e.g. from the developer tools + statistic_sum = 0.0 + assert isinstance(statistic_sum, float) + result[statistic_id] = statistic_sum + return result diff --git a/homeassistant/components/solaredge/manifest.json b/homeassistant/components/solaredge/manifest.json index 02f96c0211f69c..15796f99ef3f2c 100644 --- a/homeassistant/components/solaredge/manifest.json +++ b/homeassistant/components/solaredge/manifest.json @@ -1,8 +1,9 @@ { "domain": "solaredge", "name": "SolarEdge", - "codeowners": ["@frenck", "@bdraco"], + "codeowners": ["@frenck", "@bdraco", "@tronikos"], "config_flow": true, + "dependencies": ["recorder"], "dhcp": [ { "hostname": "target", @@ -12,6 +13,10 @@ "documentation": "https://www.home-assistant.io/integrations/solaredge", "integration_type": "device", "iot_class": "cloud_polling", - "loggers": ["aiosolaredge"], - "requirements": ["aiosolaredge==0.2.0", "stringcase==1.2.0"] + "loggers": ["aiosolaredge", "solaredge_web"], + "requirements": [ + "aiosolaredge==0.2.0", + "stringcase==1.2.0", + "solaredge-web==0.0.1" + ] } diff --git a/homeassistant/components/solaredge/sensor.py b/homeassistant/components/solaredge/sensor.py index acb86f875c9176..6ae180ed8236da 100644 --- a/homeassistant/components/solaredge/sensor.py +++ b/homeassistant/components/solaredge/sensor.py @@ -204,7 +204,10 @@ async def async_setup_entry( async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Add an solarEdge entry.""" - # Add the needed sensors to hass + # Add sensor entities only if API key is configured + if DATA_API_CLIENT not in entry.runtime_data: + return + api = entry.runtime_data[DATA_API_CLIENT] sensor_factory = SolarEdgeSensorFactory(hass, entry, entry.data[CONF_SITE_ID], api) for service in sensor_factory.all_services: diff --git a/homeassistant/components/solaredge/strings.json b/homeassistant/components/solaredge/strings.json index 105a9282a6d015..c480f34feed0e9 100644 --- a/homeassistant/components/solaredge/strings.json +++ b/homeassistant/components/solaredge/strings.json @@ -2,19 +2,47 @@ "config": { "step": { "user": { - "title": "Define the API parameters for this installation", + "title": "Set up your SolarEdge integration", + "description": "Provide your site ID and at least one method of authentication", "data": { "name": "The name of this installation", "site_id": "The SolarEdge site ID", - "api_key": "[%key:common::config_flow::data::api_key%]" + "api_key": "[%key:common::config_flow::data::api_key%]", + "username": "[%key:common::config_flow::data::username%]", + "password": "[%key:common::config_flow::data::password%]" + }, + "data_description": { + "api_key": "Optional, used for general site sensors", + "username": "Optional, used for detailed module-level production statistics", + "password": "Required if username is provided" + }, + "sections": { + "api_auth": { + "name": "API key authentication", + "description": "Optionally provide your SolarEdge API key. Used for real-time detailed site sensors", + "data": { + "api_key": "[%key:common::config_flow::data::api_key%]" + } + }, + "web_auth": { + "name": "Web account authentication", + "description": "Optionally provide your SolarEdge web portal credentials. Used for non real-time module-level production statistics", + "data": { + "username": "[%key:common::config_flow::data::username%]", + "password": "[%key:common::config_flow::data::password%]" + } + } } } }, "error": { "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", "invalid_api_key": "[%key:common::config_flow::error::invalid_api_key%]", - "site_not_active": "The site is not active", - "could_not_connect": "Could not connect to the SolarEdge API" + "site_not_active": "The site is not active for the provided API key", + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", + "unknown": "[%key:common::config_flow::error::unknown%]", + "auth_missing": "You must provide either an API key or a username and password." }, "abort": { "already_configured": "[%key:common::config_flow::abort::already_configured_device%]" diff --git a/homeassistant/components/solaredge/types.py b/homeassistant/components/solaredge/types.py index e8b8a677726356..33192763acc7ac 100644 --- a/homeassistant/components/solaredge/types.py +++ b/homeassistant/components/solaredge/types.py @@ -8,10 +8,13 @@ from homeassistant.config_entries import ConfigEntry +from .coordinator import SolarEdgeModulesCoordinator + type SolarEdgeConfigEntry = ConfigEntry[SolarEdgeData] -class SolarEdgeData(TypedDict): +class SolarEdgeData(TypedDict, total=False): """Data for the solaredge integration.""" api_client: SolarEdge + modules_coordinator: SolarEdgeModulesCoordinator diff --git a/homeassistant/components/squeezebox/__init__.py b/homeassistant/components/squeezebox/__init__.py index c7411e935dfd47..25c7aa36d15a1c 100644 --- a/homeassistant/components/squeezebox/__init__.py +++ b/homeassistant/components/squeezebox/__init__.py @@ -196,7 +196,10 @@ async def _discovered_player(player: Player) -> None: if player.player_id in entry.runtime_data.known_player_ids: await player.async_update() async_dispatcher_send( - hass, SIGNAL_PLAYER_REDISCOVERED, player.player_id, player.connected + hass, + SIGNAL_PLAYER_REDISCOVERED + entry.entry_id, + player.player_id, + player.connected, ) else: _LOGGER.debug("Adding new entity: %s", player) @@ -206,7 +209,7 @@ async def _discovered_player(player: Player) -> None: await player_coordinator.async_refresh() entry.runtime_data.known_player_ids.add(player.player_id) async_dispatcher_send( - hass, SIGNAL_PLAYER_DISCOVERED, player_coordinator + hass, SIGNAL_PLAYER_DISCOVERED + entry.entry_id, player_coordinator ) if players := await lms.async_get_players(): diff --git a/homeassistant/components/squeezebox/button.py b/homeassistant/components/squeezebox/button.py index 887151036aaeee..788eea8f1bc426 100644 --- a/homeassistant/components/squeezebox/button.py +++ b/homeassistant/components/squeezebox/button.py @@ -132,7 +132,9 @@ async def _player_discovered( async_add_entities(entities) entry.async_on_unload( - async_dispatcher_connect(hass, SIGNAL_PLAYER_DISCOVERED, _player_discovered) + async_dispatcher_connect( + hass, SIGNAL_PLAYER_DISCOVERED + entry.entry_id, _player_discovered + ) ) diff --git a/homeassistant/components/squeezebox/coordinator.py b/homeassistant/components/squeezebox/coordinator.py index 9508420ec5fac4..c078fc377b5050 100644 --- a/homeassistant/components/squeezebox/coordinator.py +++ b/homeassistant/components/squeezebox/coordinator.py @@ -117,7 +117,9 @@ async def _async_update_data(self) -> dict[str, Any]: # start listening for restored players self._remove_dispatcher = async_dispatcher_connect( - self.hass, SIGNAL_PLAYER_REDISCOVERED, self.rediscovered + self.hass, + SIGNAL_PLAYER_REDISCOVERED + self.config_entry.entry_id, + self.rediscovered, ) alarm_dict: dict[str, Alarm] = ( diff --git a/homeassistant/components/squeezebox/media_player.py b/homeassistant/components/squeezebox/media_player.py index 0b9b54a1dcddbf..1ac08f1b4339a0 100644 --- a/homeassistant/components/squeezebox/media_player.py +++ b/homeassistant/components/squeezebox/media_player.py @@ -175,7 +175,9 @@ async def _player_discovered( async_add_entities([SqueezeBoxMediaPlayerEntity(coordinator)]) entry.async_on_unload( - async_dispatcher_connect(hass, SIGNAL_PLAYER_DISCOVERED, _player_discovered) + async_dispatcher_connect( + hass, SIGNAL_PLAYER_DISCOVERED + entry.entry_id, _player_discovered + ) ) # Register entity services diff --git a/homeassistant/components/squeezebox/switch.py b/homeassistant/components/squeezebox/switch.py index f8512124068a48..3e567b6228a944 100644 --- a/homeassistant/components/squeezebox/switch.py +++ b/homeassistant/components/squeezebox/switch.py @@ -91,7 +91,9 @@ def _async_listener() -> None: async_add_entities([SqueezeBoxAlarmsEnabledEntity(coordinator)]) entry.async_on_unload( - async_dispatcher_connect(hass, SIGNAL_PLAYER_DISCOVERED, _player_discovered) + async_dispatcher_connect( + hass, SIGNAL_PLAYER_DISCOVERED + entry.entry_id, _player_discovered + ) ) diff --git a/homeassistant/components/telegram_bot/__init__.py b/homeassistant/components/telegram_bot/__init__.py index 91bbc088744c74..be91d7b0dafa2e 100644 --- a/homeassistant/components/telegram_bot/__init__.py +++ b/homeassistant/components/telegram_bot/__init__.py @@ -8,6 +8,7 @@ from typing import Any from telegram import Bot +from telegram.constants import InputMediaType from telegram.error import InvalidToken, TelegramError import voluptuous as vol @@ -52,6 +53,7 @@ ATTR_IS_BIG, ATTR_KEYBOARD, ATTR_KEYBOARD_INLINE, + ATTR_MEDIA_TYPE, ATTR_MESSAGE, ATTR_MESSAGE_TAG, ATTR_MESSAGE_THREAD_ID, @@ -98,6 +100,7 @@ SERVICE_DELETE_MESSAGE, SERVICE_EDIT_CAPTION, SERVICE_EDIT_MESSAGE, + SERVICE_EDIT_MESSAGE_MEDIA, SERVICE_EDIT_REPLYMARKUP, SERVICE_LEAVE_CHAT, SERVICE_SEND_ANIMATION, @@ -233,6 +236,35 @@ } ) +SERVICE_SCHEMA_EDIT_MESSAGE_MEDIA = vol.Schema( + { + vol.Optional(CONF_CONFIG_ENTRY_ID): cv.string, + vol.Required(ATTR_MESSAGEID): vol.Any( + cv.positive_int, vol.All(cv.string, "last") + ), + vol.Required(ATTR_CHAT_ID): vol.Coerce(int), + vol.Optional(ATTR_TIMEOUT): cv.positive_int, + vol.Optional(ATTR_CAPTION): cv.string, + vol.Required(ATTR_MEDIA_TYPE): vol.In( + ( + str(InputMediaType.ANIMATION), + str(InputMediaType.AUDIO), + str(InputMediaType.VIDEO), + str(InputMediaType.DOCUMENT), + str(InputMediaType.PHOTO), + ) + ), + vol.Optional(ATTR_URL): cv.string, + vol.Optional(ATTR_FILE): cv.string, + vol.Optional(ATTR_USERNAME): cv.string, + vol.Optional(ATTR_PASSWORD): cv.string, + vol.Optional(ATTR_AUTHENTICATION): cv.string, + vol.Optional(ATTR_VERIFY_SSL): cv.boolean, + vol.Optional(ATTR_KEYBOARD_INLINE): cv.ensure_list, + }, + extra=vol.ALLOW_EXTRA, +) + SERVICE_SCHEMA_EDIT_CAPTION = vol.Schema( { vol.Optional(CONF_CONFIG_ENTRY_ID): cv.string, @@ -311,6 +343,7 @@ SERVICE_SEND_LOCATION: SERVICE_SCHEMA_SEND_LOCATION, SERVICE_SEND_POLL: SERVICE_SCHEMA_SEND_POLL, SERVICE_EDIT_MESSAGE: SERVICE_SCHEMA_EDIT_MESSAGE, + SERVICE_EDIT_MESSAGE_MEDIA: SERVICE_SCHEMA_EDIT_MESSAGE_MEDIA, SERVICE_EDIT_CAPTION: SERVICE_SCHEMA_EDIT_CAPTION, SERVICE_EDIT_REPLYMARKUP: SERVICE_SCHEMA_EDIT_REPLYMARKUP, SERVICE_ANSWER_CALLBACK_QUERY: SERVICE_SCHEMA_ANSWER_CALLBACK_QUERY, @@ -435,6 +468,8 @@ async def async_send_telegram_message(service: ServiceCall) -> ServiceResponse: await notify_service.leave_chat(context=service.context, **kwargs) elif msgtype == SERVICE_SET_MESSAGE_REACTION: await notify_service.set_message_reaction(context=service.context, **kwargs) + elif msgtype == SERVICE_EDIT_MESSAGE_MEDIA: + await notify_service.edit_message_media(context=service.context, **kwargs) else: await notify_service.edit_message( msgtype, context=service.context, **kwargs diff --git a/homeassistant/components/telegram_bot/bot.py b/homeassistant/components/telegram_bot/bot.py index 42bd493489b73e..f5fbfafa02b175 100644 --- a/homeassistant/components/telegram_bot/bot.py +++ b/homeassistant/components/telegram_bot/bot.py @@ -15,6 +15,12 @@ CallbackQuery, InlineKeyboardButton, InlineKeyboardMarkup, + InputMedia, + InputMediaAnimation, + InputMediaAudio, + InputMediaDocument, + InputMediaPhoto, + InputMediaVideo, InputPollOption, Message, ReplyKeyboardMarkup, @@ -22,7 +28,7 @@ Update, User, ) -from telegram.constants import ParseMode +from telegram.constants import InputMediaType, ParseMode from telegram.error import TelegramError from telegram.ext import CallbackContext, filters from telegram.request import HTTPXRequest @@ -52,6 +58,7 @@ ATTR_FILE, ATTR_FROM_FIRST, ATTR_FROM_LAST, + ATTR_INLINE_MESSAGE_ID, ATTR_KEYBOARD, ATTR_KEYBOARD_INLINE, ATTR_MESSAGE, @@ -299,7 +306,7 @@ def _get_msg_ids( ): message_id = self._last_message_id[chat_id] else: - inline_message_id = msg_data["inline_message_id"] + inline_message_id = msg_data[ATTR_INLINE_MESSAGE_ID] return message_id, inline_message_id def get_target_chat_ids(self, target: int | list[int] | None) -> list[int]: @@ -527,6 +534,63 @@ async def delete_message( self._last_message_id[chat_id] -= 1 return deleted + async def edit_message_media( + self, + media_type: str, + chat_id: int | None = None, + context: Context | None = None, + **kwargs: Any, + ) -> Any: + "Edit message media of a previously sent message." + chat_id = self.get_target_chat_ids(chat_id)[0] + message_id, inline_message_id = self._get_msg_ids(kwargs, chat_id) + params = self._get_msg_kwargs(kwargs) + _LOGGER.debug( + "Edit message media %s in chat ID %s with params: %s", + message_id or inline_message_id, + chat_id, + params, + ) + + file_content = await load_data( + self.hass, + url=kwargs.get(ATTR_URL), + filepath=kwargs.get(ATTR_FILE), + username=kwargs.get(ATTR_USERNAME, ""), + password=kwargs.get(ATTR_PASSWORD, ""), + authentication=kwargs.get(ATTR_AUTHENTICATION), + verify_ssl=( + get_default_context() + if kwargs.get(ATTR_VERIFY_SSL, False) + else get_default_no_verify_context() + ), + ) + + media: InputMedia + if media_type == InputMediaType.ANIMATION: + media = InputMediaAnimation(file_content, caption=kwargs.get(ATTR_CAPTION)) + elif media_type == InputMediaType.AUDIO: + media = InputMediaAudio(file_content, caption=kwargs.get(ATTR_CAPTION)) + elif media_type == InputMediaType.DOCUMENT: + media = InputMediaDocument(file_content, caption=kwargs.get(ATTR_CAPTION)) + elif media_type == InputMediaType.PHOTO: + media = InputMediaPhoto(file_content, caption=kwargs.get(ATTR_CAPTION)) + else: + media = InputMediaVideo(file_content, caption=kwargs.get(ATTR_CAPTION)) + + return await self._send_msg( + self.bot.edit_message_media, + "Error editing message media", + params[ATTR_MESSAGE_TAG], + media=media, + chat_id=chat_id, + message_id=message_id, + inline_message_id=inline_message_id, + reply_markup=params[ATTR_REPLYMARKUP], + read_timeout=params[ATTR_TIMEOUT], + context=context, + ) + async def edit_message( self, type_edit: str, diff --git a/homeassistant/components/telegram_bot/const.py b/homeassistant/components/telegram_bot/const.py index 34b8a476c788cf..e891e1fa6399f1 100644 --- a/homeassistant/components/telegram_bot/const.py +++ b/homeassistant/components/telegram_bot/const.py @@ -44,6 +44,7 @@ SERVICE_SEND_POLL = "send_poll" SERVICE_SET_MESSAGE_REACTION = "set_message_reaction" SERVICE_EDIT_MESSAGE = "edit_message" +SERVICE_EDIT_MESSAGE_MEDIA = "edit_message_media" SERVICE_EDIT_CAPTION = "edit_caption" SERVICE_EDIT_REPLYMARKUP = "edit_replymarkup" SERVICE_ANSWER_CALLBACK_QUERY = "answer_callback_query" @@ -96,6 +97,8 @@ ATTR_ONE_TIME_KEYBOARD = "one_time_keyboard" ATTR_KEYBOARD_INLINE = "inline_keyboard" ATTR_MESSAGEID = "message_id" +ATTR_INLINE_MESSAGE_ID = "inline_message_id" +ATTR_MEDIA_TYPE = "media_type" ATTR_MSG = "message" ATTR_MSGID = "id" ATTR_PARSER = "parse_mode" diff --git a/homeassistant/components/telegram_bot/icons.json b/homeassistant/components/telegram_bot/icons.json index 3208fdfbc3e750..0df25f97944683 100644 --- a/homeassistant/components/telegram_bot/icons.json +++ b/homeassistant/components/telegram_bot/icons.json @@ -33,6 +33,9 @@ "edit_message": { "service": "mdi:pencil" }, + "edit_message_media": { + "service": "mdi:pencil" + }, "edit_caption": { "service": "mdi:pencil" }, diff --git a/homeassistant/components/telegram_bot/quality_scale.yaml b/homeassistant/components/telegram_bot/quality_scale.yaml index cdd14554937fa3..8b7ff4eaa7276f 100644 --- a/homeassistant/components/telegram_bot/quality_scale.yaml +++ b/homeassistant/components/telegram_bot/quality_scale.yaml @@ -18,14 +18,8 @@ rules: status: exempt comment: | The integration does not provide any entities. - entity-unique-id: - status: exempt - comment: | - The integration does not provide any entities. - has-entity-name: - status: exempt - comment: | - The integration does not provide any entities. + entity-unique-id: done + has-entity-name: done runtime-data: done test-before-configure: done test-before-setup: done @@ -34,26 +28,41 @@ rules: # Silver action-exceptions: todo config-entry-unloading: done - docs-configuration-parameters: todo - docs-installation-parameters: todo - entity-unavailable: todo - integration-owner: todo - log-when-unavailable: todo - parallel-updates: todo + docs-configuration-parameters: done + docs-installation-parameters: done + entity-unavailable: + status: exempt + comment: | + There are no entities that fetch data. + integration-owner: done + log-when-unavailable: + status: exempt + comment: | + There are no entities that fetch data. + parallel-updates: + status: exempt + comment: | + There are no entities that fetch data. reauthentication-flow: done - test-coverage: todo + test-coverage: done # Gold devices: todo diagnostics: done - discovery-update-info: todo - discovery: todo + discovery-update-info: + status: exempt + comment: the service cannot be discovered + discovery: + status: exempt + comment: the service cannot be discovered docs-data-update: todo docs-examples: todo - docs-known-limitations: todo - docs-supported-devices: todo + docs-known-limitations: done + docs-supported-devices: + status: exempt + comment: the integration is a service docs-supported-functions: todo - docs-troubleshooting: todo + docs-troubleshooting: done docs-use-cases: todo dynamic-devices: todo entity-category: todo @@ -62,11 +71,13 @@ rules: entity-translations: todo exception-translations: todo icon-translations: todo - reconfiguration-flow: todo + reconfiguration-flow: done repair-issues: todo - stale-devices: todo + stale-devices: + status: exempt + comment: only one device per entry, is deleted with the entry. # Platinum async-dependency: todo inject-websession: todo - strict-typing: todo + strict-typing: done diff --git a/homeassistant/components/telegram_bot/services.yaml b/homeassistant/components/telegram_bot/services.yaml index e0e03921a932ec..b38bd23bb1d32c 100644 --- a/homeassistant/components/telegram_bot/services.yaml +++ b/homeassistant/components/telegram_bot/services.yaml @@ -746,6 +746,77 @@ edit_message: selector: object: +edit_message_media: + fields: + config_entry_id: + selector: + config_entry: + integration: telegram_bot + message_id: + required: true + example: "{{ trigger.event.data.message.message_id }}" + selector: + text: + chat_id: + required: true + example: 12345 + selector: + text: + timeout: + selector: + number: + min: 1 + max: 3600 + unit_of_measurement: seconds + media_type: + selector: + select: + options: + - "animation" + - "audio" + - "document" + - "photo" + - "video" + translation_key: "media_type" + url: + example: "http://example.org/path/to/the/image.png" + selector: + text: + file: + example: "/path/to/the/image.png" + selector: + text: + caption: + example: Document Title xy + selector: + text: + authentication: + selector: + select: + options: + - "basic" + - "digest" + - "bearer_token" + translation_key: "authentication" + username: + example: myuser + selector: + text: + password: + example: myuser_pwd + selector: + text: + type: password + verify_ssl: + selector: + boolean: + inline_keyboard: + example: + '["/button1, /button2", "/button3"] or [[["Text button1", "/button1"], + ["Text button2", "/button2"]], [["Text button3", "/button3"]]]' + selector: + object: + edit_caption: fields: config_entry_id: diff --git a/homeassistant/components/telegram_bot/strings.json b/homeassistant/components/telegram_bot/strings.json index 759b22a33683ba..1a4d8c0b30a13e 100644 --- a/homeassistant/components/telegram_bot/strings.json +++ b/homeassistant/components/telegram_bot/strings.json @@ -153,6 +153,15 @@ "record_video_note": "Recording video note", "upload_video_note": "Uploading video note" } + }, + "media_type": { + "options": { + "animation": "Animation", + "audio": "Audio", + "document": "Document", + "photo": "Photo", + "video": "Video" + } } }, "services": { @@ -814,6 +823,64 @@ } } }, + "edit_message_media": { + "name": "Edit message media", + "description": "Edits the media content of a previously sent message.", + "fields": { + "config_entry_id": { + "name": "[%key:component::telegram_bot::services::send_message::fields::config_entry_id::name%]", + "description": "The config entry representing the Telegram bot to edit the message media." + }, + "message_id": { + "name": "[%key:component::telegram_bot::services::edit_message::fields::message_id::name%]", + "description": "[%key:component::telegram_bot::services::edit_message::fields::message_id::description%]" + }, + "chat_id": { + "name": "[%key:component::telegram_bot::services::edit_message::fields::chat_id::name%]", + "description": "[%key:component::telegram_bot::services::edit_message::fields::chat_id::description%]" + }, + "media_type": { + "name": "Media type", + "description": "Type for the new media." + }, + "url": { + "name": "[%key:common::config_flow::data::url%]", + "description": "Remote path to the media." + }, + "file": { + "name": "[%key:component::telegram_bot::services::send_photo::fields::file::name%]", + "description": "Local path to the media." + }, + "caption": { + "name": "[%key:component::telegram_bot::services::send_photo::fields::caption::name%]", + "description": "The title of the media." + }, + "username": { + "name": "[%key:common::config_flow::data::username%]", + "description": "[%key:component::telegram_bot::services::send_photo::fields::username::description%]" + }, + "password": { + "name": "[%key:common::config_flow::data::password%]", + "description": "[%key:component::telegram_bot::services::send_photo::fields::password::description%]" + }, + "authentication": { + "name": "[%key:component::telegram_bot::services::send_photo::fields::authentication::name%]", + "description": "[%key:component::telegram_bot::services::send_photo::fields::authentication::description%]" + }, + "verify_ssl": { + "name": "[%key:component::telegram_bot::services::send_photo::fields::verify_ssl::name%]", + "description": "[%key:component::telegram_bot::services::send_photo::fields::verify_ssl::description%]" + }, + "timeout": { + "name": "[%key:component::telegram_bot::services::send_photo::fields::timeout::name%]", + "description": "Timeout for sending the media in seconds." + }, + "inline_keyboard": { + "name": "[%key:component::telegram_bot::services::send_message::fields::inline_keyboard::name%]", + "description": "[%key:component::telegram_bot::services::send_message::fields::inline_keyboard::description%]" + } + } + }, "edit_caption": { "name": "Edit caption", "description": "Edits the caption of a previously sent message.", diff --git a/homeassistant/components/template/binary_sensor.py b/homeassistant/components/template/binary_sensor.py index 941eda774c48c3..ee28c3228c8b33 100644 --- a/homeassistant/components/template/binary_sensor.py +++ b/homeassistant/components/template/binary_sensor.py @@ -372,6 +372,7 @@ def _handle_coordinator_update(self) -> None: def _set_state(self, state, _=None): """Set up auto off.""" self._attr_is_on = state + self._delay_cancel = None self.async_write_ha_state() if not state: diff --git a/homeassistant/components/volvo/__init__.py b/homeassistant/components/volvo/__init__.py index 403dce7bfe6128..8f380f4dbd74ba 100644 --- a/homeassistant/components/volvo/__init__.py +++ b/homeassistant/components/volvo/__init__.py @@ -37,8 +37,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: VolvoConfigEntry) -> boo """Set up Volvo from a config entry.""" api = await _async_auth_and_create_api(hass, entry) - vehicle = await _async_load_vehicle(api) - context = VolvoContext(api, vehicle) + context = await _async_create_context(api) # Order is important! Faster intervals must come first. # Different interval coordinators are in place to keep the number @@ -50,10 +49,9 @@ async def async_setup_entry(hass: HomeAssistant, entry: VolvoConfigEntry) -> boo VolvoSlowIntervalCoordinator(hass, entry, context), VolvoVerySlowIntervalCoordinator(hass, entry, context), ) - await asyncio.gather(*(c.async_config_entry_first_refresh() for c in coordinators)) - entry.runtime_data = VolvoRuntimeData(coordinators) + entry.runtime_data = VolvoRuntimeData(coordinators, context) await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) return True @@ -88,6 +86,12 @@ async def _async_auth_and_create_api( return api +async def _async_create_context(api: VolvoCarsApi) -> VolvoContext: + vehicle = await _async_load_vehicle(api) + supported_commands = await _async_load_supported_commands(api) + return VolvoContext(api, vehicle, supported_commands) + + async def _async_load_vehicle(api: VolvoCarsApi) -> VolvoCarsVehicle: try: vehicle = await api.async_get_vehicle_details() @@ -102,3 +106,16 @@ async def _async_load_vehicle(api: VolvoCarsApi) -> VolvoCarsVehicle: raise ConfigEntryError(translation_domain=DOMAIN, translation_key="no_vehicle") return vehicle + + +async def _async_load_supported_commands(api: VolvoCarsApi) -> list[str]: + try: + commands = await api.async_get_commands() + except VolvoAuthException as ex: + raise ConfigEntryAuthFailed( + translation_domain=DOMAIN, + translation_key="unauthorized", + translation_placeholders={"message": ex.message}, + ) from ex + + return [c.command for c in commands if c is not None] diff --git a/homeassistant/components/volvo/application_credentials.py b/homeassistant/components/volvo/application_credentials.py index 18dae40f8ee6dc..bfc48a1ee00a38 100644 --- a/homeassistant/components/volvo/application_credentials.py +++ b/homeassistant/components/volvo/application_credentials.py @@ -3,7 +3,7 @@ from __future__ import annotations from volvocarsapi.auth import AUTHORIZE_URL, TOKEN_URL -from volvocarsapi.scopes import DEFAULT_SCOPES +from volvocarsapi.scopes import ALL_SCOPES from homeassistant.components.application_credentials import ClientCredential from homeassistant.core import HomeAssistant @@ -33,5 +33,5 @@ class VolvoOAuth2Implementation(LocalOAuth2ImplementationWithPkce): def extra_authorize_data(self) -> dict: """Extra data that needs to be appended to the authorize url.""" return super().extra_authorize_data | { - "scope": " ".join(DEFAULT_SCOPES), + "scope": " ".join(ALL_SCOPES), } diff --git a/homeassistant/components/volvo/binary_sensor.py b/homeassistant/components/volvo/binary_sensor.py index fe8783d933409e..ed71a515226db5 100644 --- a/homeassistant/components/volvo/binary_sensor.py +++ b/homeassistant/components/volvo/binary_sensor.py @@ -16,7 +16,7 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import API_NONE_VALUE -from .coordinator import VolvoBaseCoordinator, VolvoConfigEntry +from .coordinator import VolvoConfigEntry from .entity import VolvoEntity, VolvoEntityDescription PARALLEL_UPDATES = 0 @@ -380,16 +380,6 @@ class VolvoBinarySensor(VolvoEntity, BinarySensorEntity): entity_description: VolvoBinarySensorDescription - def __init__( - self, - coordinator: VolvoBaseCoordinator, - description: VolvoBinarySensorDescription, - ) -> None: - """Initialize entity.""" - self._attr_extra_state_attributes = {} - - super().__init__(coordinator, description) - def _update_state(self, api_field: VolvoCarsApiBaseModel | None) -> None: """Update the state of the entity.""" if api_field is None: diff --git a/homeassistant/components/volvo/button.py b/homeassistant/components/volvo/button.py new file mode 100644 index 00000000000000..be290f47dabda4 --- /dev/null +++ b/homeassistant/components/volvo/button.py @@ -0,0 +1,121 @@ +"""Volvo buttons.""" + +from dataclasses import dataclass +import logging + +from volvocarsapi.models import VolvoApiException + +from homeassistant.components.button import ButtonEntity, ButtonEntityDescription +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from .const import DOMAIN +from .coordinator import VolvoConfigEntry +from .entity import VolvoBaseEntity, VolvoEntityDescription + +PARALLEL_UPDATES = 0 +_LOGGER = logging.getLogger(__name__) + + +@dataclass(frozen=True, kw_only=True) +class VolvoButtonDescription(VolvoEntityDescription, ButtonEntityDescription): + """Describes a Volvo button entity.""" + + api_command: str + required_command_key: str + + +_DESCRIPTIONS: tuple[VolvoButtonDescription, ...] = ( + VolvoButtonDescription( + key="climatization_start", + api_command="climatization-start", + required_command_key="CLIMATIZATION_START", + ), + VolvoButtonDescription( + key="climatization_stop", + api_command="climatization-stop", + required_command_key="CLIMATIZATION_STOP", + ), + VolvoButtonDescription( + key="flash", + api_command="flash", + required_command_key="FLASH", + ), + VolvoButtonDescription( + key="honk", + api_command="honk", + required_command_key="HONK", + ), + VolvoButtonDescription( + key="honk_flash", + api_command="honk-flash", + required_command_key="HONK_AND_FLASH", + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, + entry: VolvoConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up buttons.""" + async_add_entities( + [ + VolvoButton(entry, description) + for description in _DESCRIPTIONS + if description.required_command_key + in entry.runtime_data.context.supported_commands + ] + ) + + +class VolvoButton(VolvoBaseEntity, ButtonEntity): + """Volvo button.""" + + entity_description: VolvoButtonDescription + + async def async_press(self) -> None: + """Handle the button press.""" + + command = self.entity_description.api_command + _LOGGER.debug("Command %s executing", command) + + try: + result = await self.entry.runtime_data.context.api.async_execute_command( + self.entity_description.api_command + ) + except VolvoApiException as ex: + _LOGGER.debug("Command '%s' error", command) + self._raise(command, message=ex.message, exception=ex) + + status = result.invoke_status if result else "" + + if status != "COMPLETED": + self._raise( + command, status=status, message=result.message if result else "" + ) + + def _raise( + self, + command: str, + *, + status: str = "", + message: str = "", + exception: Exception | None = None, + ) -> None: + error = HomeAssistantError( + translation_domain=DOMAIN, + translation_key="command_failure", + translation_placeholders={ + "command": command, + "status": status, + "message": message, + }, + ) + + if exception: + raise error from exception + + raise error diff --git a/homeassistant/components/volvo/config_flow.py b/homeassistant/components/volvo/config_flow.py index 0ae0e54077e784..9f38c16b4feae8 100644 --- a/homeassistant/components/volvo/config_flow.py +++ b/homeassistant/components/volvo/config_flow.py @@ -9,7 +9,7 @@ import voluptuous as vol from volvocarsapi.api import VolvoCarsApi from volvocarsapi.models import VolvoApiException, VolvoCarsVehicle -from volvocarsapi.scopes import DEFAULT_SCOPES +from volvocarsapi.scopes import ALL_SCOPES from homeassistant.config_entries import ( SOURCE_REAUTH, @@ -59,7 +59,7 @@ def __init__(self) -> None: def extra_authorize_data(self) -> dict: """Extra data that needs to be appended to the authorize url.""" return super().extra_authorize_data | { - "scope": " ".join(DEFAULT_SCOPES), + "scope": " ".join(ALL_SCOPES), } @property diff --git a/homeassistant/components/volvo/const.py b/homeassistant/components/volvo/const.py index 512dc5e0804bbf..82eb87374f5fbf 100644 --- a/homeassistant/components/volvo/const.py +++ b/homeassistant/components/volvo/const.py @@ -3,7 +3,12 @@ from homeassistant.const import Platform DOMAIN = "volvo" -PLATFORMS: list[Platform] = [Platform.BINARY_SENSOR, Platform.SENSOR] +PLATFORMS: list[Platform] = [ + Platform.BINARY_SENSOR, + Platform.BUTTON, + Platform.DEVICE_TRACKER, + Platform.SENSOR, +] API_NONE_VALUE = "UNSPECIFIED" CONF_VIN = "vin" diff --git a/homeassistant/components/volvo/coordinator.py b/homeassistant/components/volvo/coordinator.py index fa4de64e05255a..d42ca6e9c94c8b 100644 --- a/homeassistant/components/volvo/coordinator.py +++ b/homeassistant/components/volvo/coordinator.py @@ -22,7 +22,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant -from homeassistant.exceptions import ConfigEntryAuthFailed +from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from .const import DATA_BATTERY_CAPACITY, DOMAIN @@ -41,13 +41,15 @@ class VolvoContext: api: VolvoCarsApi vehicle: VolvoCarsVehicle + supported_commands: list[str] @dataclass class VolvoRuntimeData: """Volvo runtime data.""" - interval_coordinators: tuple[VolvoBaseIntervalCoordinator, ...] + interval_coordinators: tuple[VolvoBaseCoordinator, ...] + context: VolvoContext type VolvoConfigEntry = ConfigEntry[VolvoRuntimeData] @@ -64,8 +66,8 @@ def _is_invalid_api_field(field: VolvoCarsApiBaseModel | None) -> bool: return False -class VolvoBaseCoordinator[T: dict = dict[str, Any]](DataUpdateCoordinator[T]): - """Volvo base coordinator.""" +class VolvoBaseCoordinator(DataUpdateCoordinator[CoordinatorData]): + """Volvo base interval coordinator.""" config_entry: VolvoConfigEntry @@ -74,7 +76,7 @@ def __init__( hass: HomeAssistant, entry: VolvoConfigEntry, context: VolvoContext, - update_interval: timedelta | None, + update_interval: timedelta, name: str, ) -> None: """Initialize the coordinator.""" @@ -87,39 +89,20 @@ def __init__( update_interval=update_interval, ) - self.context = context - - def get_api_field(self, api_field: str | None) -> VolvoCarsApiBaseModel | None: - """Get the API field based on the entity description.""" - - return self.data.get(api_field) if api_field else None - - -class VolvoBaseIntervalCoordinator(VolvoBaseCoordinator[CoordinatorData]): - """Volvo base interval coordinator.""" - - def __init__( - self, - hass: HomeAssistant, - entry: VolvoConfigEntry, - context: VolvoContext, - update_interval: timedelta, - name: str, - ) -> None: - """Initialize the coordinator.""" - - super().__init__( - hass, - entry, - context, - update_interval, - name, - ) - self._api_calls: list[Callable[[], Coroutine[Any, Any, Any]]] = [] + self.context = context async def _async_setup(self) -> None: - self._api_calls = await self._async_determine_api_calls() + try: + self._api_calls = await self._async_determine_api_calls() + except VolvoAuthException as err: + raise ConfigEntryAuthFailed( + translation_domain=DOMAIN, + translation_key="unauthorized", + translation_placeholders={"message": err.message}, + ) from err + except VolvoApiException as err: + raise ConfigEntryNotReady from err if not self._api_calls: self.update_interval = None @@ -153,7 +136,9 @@ async def _async_update_data(self) -> CoordinatorData: result.message, ) raise ConfigEntryAuthFailed( - f"Authentication failed. {result.message}" + translation_domain=DOMAIN, + translation_key="unauthorized", + translation_placeholders={"message": result.message}, ) from result if isinstance(result, VolvoApiException): @@ -192,6 +177,11 @@ async def _async_update_data(self) -> CoordinatorData: return data + def get_api_field(self, api_field: str | None) -> VolvoCarsApiBaseModel | None: + """Get the API field based on the entity description.""" + + return self.data.get(api_field) if api_field else None + @abstractmethod async def _async_determine_api_calls( self, @@ -199,7 +189,7 @@ async def _async_determine_api_calls( raise NotImplementedError -class VolvoVerySlowIntervalCoordinator(VolvoBaseIntervalCoordinator): +class VolvoVerySlowIntervalCoordinator(VolvoBaseCoordinator): """Volvo coordinator with very slow update rate.""" def __init__( @@ -247,7 +237,7 @@ async def _async_update_data(self) -> CoordinatorData: return data -class VolvoSlowIntervalCoordinator(VolvoBaseIntervalCoordinator): +class VolvoSlowIntervalCoordinator(VolvoBaseCoordinator): """Volvo coordinator with slow update rate.""" def __init__( @@ -270,17 +260,20 @@ async def _async_determine_api_calls( self, ) -> list[Callable[[], Coroutine[Any, Any, Any]]]: api = self.context.api + api_calls: list[Any] = [api.async_get_command_accessibility] + + location = await api.async_get_location() + + if location.get("location") is not None: + api_calls.append(api.async_get_location) if self.context.vehicle.has_combustion_engine(): - return [ - api.async_get_command_accessibility, - api.async_get_fuel_status, - ] + api_calls.append(api.async_get_fuel_status) - return [api.async_get_command_accessibility] + return api_calls -class VolvoMediumIntervalCoordinator(VolvoBaseIntervalCoordinator): +class VolvoMediumIntervalCoordinator(VolvoBaseCoordinator): """Volvo coordinator with medium update rate.""" def __init__( @@ -349,7 +342,7 @@ def _mark_ok( } -class VolvoFastIntervalCoordinator(VolvoBaseIntervalCoordinator): +class VolvoFastIntervalCoordinator(VolvoBaseCoordinator): """Volvo coordinator with fast update rate.""" def __init__( diff --git a/homeassistant/components/volvo/device_tracker.py b/homeassistant/components/volvo/device_tracker.py new file mode 100644 index 00000000000000..44078c9387deea --- /dev/null +++ b/homeassistant/components/volvo/device_tracker.py @@ -0,0 +1,59 @@ +"""Volvo device tracker.""" + +from dataclasses import dataclass + +from volvocarsapi.models import VolvoCarsApiBaseModel, VolvoCarsLocation + +from homeassistant.components.device_tracker.config_entry import ( + TrackerEntity, + TrackerEntityDescription, +) +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from .coordinator import VolvoConfigEntry +from .entity import VolvoEntity, VolvoEntityDescription + +PARALLEL_UPDATES = 0 + + +@dataclass(frozen=True, kw_only=True) +class VolvoTrackerDescription(VolvoEntityDescription, TrackerEntityDescription): + """Describes a Volvo Cars tracker entity.""" + + +_DESCRIPTIONS: tuple[VolvoTrackerDescription, ...] = ( + VolvoTrackerDescription( + key="location", + api_field="location", + ), +) + + +async def async_setup_entry( + _: HomeAssistant, + entry: VolvoConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up tracker.""" + + coordinators = entry.runtime_data.interval_coordinators + async_add_entities( + VolvoDeviceTracker(coordinator, description) + for coordinator in coordinators + for description in _DESCRIPTIONS + if description.api_field in coordinator.data + ) + + +class VolvoDeviceTracker(VolvoEntity, TrackerEntity): + """Volvo tracker.""" + + entity_description: VolvoTrackerDescription + + def _update_state(self, api_field: VolvoCarsApiBaseModel | None) -> None: + assert isinstance(api_field, VolvoCarsLocation) + + if api_field.geometry.coordinates and len(api_field.geometry.coordinates) > 1: + self._attr_longitude = api_field.geometry.coordinates[0] + self._attr_latitude = api_field.geometry.coordinates[1] diff --git a/homeassistant/components/volvo/entity.py b/homeassistant/components/volvo/entity.py index a8960a5f68f88c..a28c09009382c4 100644 --- a/homeassistant/components/volvo/entity.py +++ b/homeassistant/components/volvo/entity.py @@ -8,11 +8,11 @@ from homeassistant.components.sensor import SensorDeviceClass from homeassistant.core import callback from homeassistant.helpers.device_registry import DeviceInfo -from homeassistant.helpers.entity import EntityDescription +from homeassistant.helpers.entity import Entity, EntityDescription from homeassistant.helpers.update_coordinator import CoordinatorEntity -from .const import CONF_VIN, DOMAIN, MANUFACTURER -from .coordinator import VolvoBaseCoordinator +from .const import DOMAIN, MANUFACTURER +from .coordinator import VolvoBaseCoordinator, VolvoConfigEntry def get_unique_id(vin: str, key: str) -> str: @@ -29,32 +29,29 @@ def value_to_translation_key(value: str) -> str: class VolvoEntityDescription(EntityDescription): """Describes a Volvo entity.""" - api_field: str + api_field: str | None = None -class VolvoEntity(CoordinatorEntity[VolvoBaseCoordinator]): +class VolvoBaseEntity(Entity): """Volvo base entity.""" _attr_has_entity_name = True def __init__( self, - coordinator: VolvoBaseCoordinator, + entry: VolvoConfigEntry, description: VolvoEntityDescription, ) -> None: """Initialize entity.""" - super().__init__(coordinator) - self.entity_description: VolvoEntityDescription = description + self.entry = entry if description.device_class != SensorDeviceClass.BATTERY: self._attr_translation_key = description.key - self._attr_unique_id = get_unique_id( - coordinator.config_entry.data[CONF_VIN], description.key - ) + vehicle = entry.runtime_data.context.vehicle + self._attr_unique_id = get_unique_id(vehicle.vin, description.key) - vehicle = coordinator.context.vehicle model = ( f"{vehicle.description.model} ({vehicle.model_year})" if vehicle.fuel_type == "NONE" @@ -69,6 +66,19 @@ def __init__( serial_number=vehicle.vin, ) + +class VolvoEntity(CoordinatorEntity[VolvoBaseCoordinator], VolvoBaseEntity): + """Volvo base coordinator entity.""" + + def __init__( + self, + coordinator: VolvoBaseCoordinator, + description: VolvoEntityDescription, + ) -> None: + """Initialize entity.""" + CoordinatorEntity.__init__(self, coordinator) + VolvoBaseEntity.__init__(self, coordinator.config_entry, description) + self._update_state(coordinator.get_api_field(description.api_field)) @callback @@ -81,8 +91,13 @@ def _handle_coordinator_update(self) -> None: @property def available(self) -> bool: """Return if entity is available.""" - api_field = self.coordinator.get_api_field(self.entity_description.api_field) - return super().available and api_field is not None + if self.entity_description.api_field: + api_field = self.coordinator.get_api_field( + self.entity_description.api_field + ) + return super().available and api_field is not None + + return super().available @abstractmethod def _update_state(self, api_field: VolvoCarsApiBaseModel | None) -> None: diff --git a/homeassistant/components/volvo/icons.json b/homeassistant/components/volvo/icons.json index 13d1882d8482b0..94c9375e47ae95 100644 --- a/homeassistant/components/volvo/icons.json +++ b/homeassistant/components/volvo/icons.json @@ -260,6 +260,23 @@ } } }, + "button": { + "climatization_start": { + "default": "mdi:air-conditioner" + }, + "climatization_stop": { + "default": "mdi:air-conditioner" + }, + "flash": { + "default": "mdi:alarm-light-outline" + }, + "honk": { + "default": "mdi:trumpet" + }, + "honk_flash": { + "default": "mdi:alarm-light" + } + }, "sensor": { "availability": { "default": "mdi:car-connected" @@ -307,6 +324,9 @@ "dc": "mdi:current-dc" } }, + "direction": { + "default": "mdi:compass-outline" + }, "distance_to_empty_battery": { "default": "mdi:battery-outline" }, diff --git a/homeassistant/components/volvo/sensor.py b/homeassistant/components/volvo/sensor.py index f104fabf83b75e..d9d455f1cde95b 100644 --- a/homeassistant/components/volvo/sensor.py +++ b/homeassistant/components/volvo/sensor.py @@ -5,10 +5,11 @@ from collections.abc import Callable from dataclasses import dataclass import logging -from typing import Any, cast +from typing import cast from volvocarsapi.models import ( VolvoCarsApiBaseModel, + VolvoCarsLocation, VolvoCarsValue, VolvoCarsValueField, VolvoCarsValueStatusField, @@ -21,6 +22,7 @@ SensorStateClass, ) from homeassistant.const import ( + DEGREE, PERCENTAGE, EntityCategory, UnitOfElectricCurrent, @@ -34,6 +36,7 @@ ) from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback +from homeassistant.helpers.typing import StateType from .const import API_NONE_VALUE, DATA_BATTERY_CAPACITY from .coordinator import VolvoConfigEntry @@ -47,25 +50,31 @@ class VolvoSensorDescription(VolvoEntityDescription, SensorEntityDescription): """Describes a Volvo sensor entity.""" - value_fn: Callable[[VolvoCarsValue], Any] | None = None + value_fn: Callable[[VolvoCarsApiBaseModel], StateType] | None = None -def _availability_status(field: VolvoCarsValue) -> str: +def _availability_status(field: VolvoCarsApiBaseModel) -> str: reason = field.get("unavailable_reason") - return reason if reason else str(field.value) + if reason: + return str(reason) -def _calculate_time_to_service(field: VolvoCarsValue) -> int: - value = int(field.value) + if isinstance(field, VolvoCarsValue): + return str(field.value) + + return "" - # Always express value in days - if isinstance(field, VolvoCarsValueField) and field.unit == "months": - return value * 30 - return value +def _calculate_time_to_service(field: VolvoCarsApiBaseModel) -> int: + if not isinstance(field, VolvoCarsValueField): + return 0 + + value = int(field.value) + # Always express value in days + return value * 30 if field.unit == "months" else value -def _charging_power_value(field: VolvoCarsValue) -> int: +def _charging_power_value(field: VolvoCarsApiBaseModel) -> int: return ( field.value if isinstance(field, VolvoCarsValueStatusField) and isinstance(field.value, int) @@ -73,8 +82,8 @@ def _charging_power_value(field: VolvoCarsValue) -> int: ) -def _charging_power_status_value(field: VolvoCarsValue) -> str | None: - status = cast(str, field.value) +def _charging_power_status_value(field: VolvoCarsApiBaseModel) -> str | None: + status = cast(str, field.value) if isinstance(field, VolvoCarsValue) else "" if status.lower() in _CHARGING_POWER_STATUS_OPTIONS: return status @@ -86,6 +95,10 @@ def _charging_power_status_value(field: VolvoCarsValue) -> str | None: return None +def _direction_value(field: VolvoCarsApiBaseModel) -> str | None: + return field.properties.heading if isinstance(field, VolvoCarsLocation) else None + + _CHARGING_POWER_STATUS_OPTIONS = [ "fault", "power_available_but_not_activated", @@ -245,6 +258,14 @@ def _charging_power_status_value(field: VolvoCarsValue) -> str | None: "none", ], ), + # location endpoint + VolvoSensorDescription( + key="direction", + api_field="location", + native_unit_of_measurement=DEGREE, + suggested_display_precision=0, + value_fn=_direction_value, + ), # statistics endpoint # We're not using `electricRange` from the energy state endpoint because # the official app seems to use `distanceToEmptyBattery`. @@ -380,13 +401,12 @@ def _update_state(self, api_field: VolvoCarsApiBaseModel | None) -> None: self._attr_native_value = None return - assert isinstance(api_field, VolvoCarsValue) + native_value = None - native_value = ( - api_field.value - if self.entity_description.value_fn is None - else self.entity_description.value_fn(api_field) - ) + if self.entity_description.value_fn: + native_value = self.entity_description.value_fn(api_field) + elif isinstance(api_field, VolvoCarsValue): + native_value = api_field.value if self.device_class == SensorDeviceClass.ENUM and native_value: # Entities having an "unknown" value should report None as the state diff --git a/homeassistant/components/volvo/strings.json b/homeassistant/components/volvo/strings.json index f10888ac325c7a..8cce41a839f59c 100644 --- a/homeassistant/components/volvo/strings.json +++ b/homeassistant/components/volvo/strings.json @@ -187,6 +187,23 @@ "name": "Window rear right" } }, + "button": { + "climatization_start": { + "name": "Start climatization" + }, + "climatization_stop": { + "name": "Stop climatization" + }, + "flash": { + "name": "Flash" + }, + "honk": { + "name": "Honk" + }, + "honk_flash": { + "name": "Honk & flash" + } + }, "sensor": { "availability": { "name": "Car connection", @@ -268,6 +285,9 @@ "none": "None" } }, + "direction": { + "name": "Direction" + }, "distance_to_empty_battery": { "name": "Distance to empty battery" }, @@ -304,6 +324,9 @@ } }, "exceptions": { + "command_failure": { + "message": "Command {command} failed. Status: {status}. Message: {message}" + }, "no_vehicle": { "message": "Unable to retrieve vehicle details." }, diff --git a/homeassistant/core.py b/homeassistant/core.py index 299a7d32306d13..ca2551bb5c4baa 100644 --- a/homeassistant/core.py +++ b/homeassistant/core.py @@ -86,7 +86,6 @@ ) from .helpers.deprecation import ( DeferredDeprecatedAlias, - EnumWithDeprecatedMembers, all_with_deprecated_constants, check_if_deprecated_constant, dir_with_deprecated_constants, @@ -135,24 +134,6 @@ type EntityServiceResponse = dict[str, ServiceResponse] -class ConfigSource( - enum.StrEnum, - metaclass=EnumWithDeprecatedMembers, - deprecated={ - "DEFAULT": ("core_config.ConfigSource.DEFAULT", "2025.11.0"), - "DISCOVERED": ("core_config.ConfigSource.DISCOVERED", "2025.11.0"), - "STORAGE": ("core_config.ConfigSource.STORAGE", "2025.11.0"), - "YAML": ("core_config.ConfigSource.YAML", "2025.11.0"), - }, -): - """Source of core configuration.""" - - DEFAULT = "default" - DISCOVERED = "discovered" - STORAGE = "storage" - YAML = "yaml" - - class EventStateEventData(TypedDict): """Base class for EVENT_STATE_CHANGED and EVENT_STATE_REPORTED data.""" diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index 3127aa4e661865..4c5bcb21143547 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -441,7 +441,7 @@ "nightscout", "niko_home_control", "nina", - "nintendo_parental", + "nintendo_parental_controls", "nmap_tracker", "nmbs", "nobo_hub", @@ -512,6 +512,7 @@ "profiler", "progettihwsw", "prosegur", + "prowl", "proximity", "prusalink", "ps4", diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index 821a5892cfc156..6bae0268e7a588 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -4460,8 +4460,8 @@ "iot_class": "cloud_polling", "single_config_entry": true }, - "nintendo_parental": { - "name": "Nintendo Switch Parental Controls", + "nintendo_parental_controls": { + "name": "Nintendo Switch parental controls", "integration_type": "hub", "config_flow": true, "iot_class": "cloud_polling" @@ -5185,7 +5185,7 @@ "prowl": { "name": "Prowl", "integration_type": "service", - "config_flow": false, + "config_flow": true, "iot_class": "cloud_push" }, "proximity": { diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 4c6d8c2d579c74..c1f38ee4a24f75 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -39,7 +39,7 @@ habluetooth==5.7.0 hass-nabucasa==1.2.0 hassil==3.2.0 home-assistant-bluetooth==1.13.1 -home-assistant-frontend==20251001.0 +home-assistant-frontend==20251001.2 home-assistant-intents==2025.10.1 httpx==0.28.1 ifaddr==0.2.0 @@ -50,7 +50,7 @@ orjson==3.11.3 packaging>=23.1 paho-mqtt==2.1.0 Pillow==11.3.0 -propcache==0.4.0 +propcache==0.4.1 psutil-home-assistant==0.0.1 PyJWT==2.10.1 pymicro-vad==1.0.1 diff --git a/homeassistant/util/unit_conversion.py b/homeassistant/util/unit_conversion.py index dba858c07bffbd..322410f61eccfc 100644 --- a/homeassistant/util/unit_conversion.py +++ b/homeassistant/util/unit_conversion.py @@ -91,6 +91,17 @@ _FLUID_OUNCE_TO_CUBIC_METER = _GALLON_TO_CUBIC_METER / 128 # 128 fl. oz. in a US gallon _CUBIC_FOOT_TO_CUBIC_METER = pow(_FOOT_TO_M, 3) +# Gas concentration conversion constants +_IDEAL_GAS_CONSTANT = 8.31446261815324 # m3⋅Pa⋅K⁻¹⋅mol⁻¹ +# Ambient constants based on European Commission recommendations (20 °C and 1013mb) +_AMBIENT_TEMPERATURE = 293.15 # K (20 °C) +_AMBIENT_PRESSURE = 101325 # Pa (1 atm) +_AMBIENT_IDEAL_GAS_MOLAR_VOLUME = ( # m3⋅mol⁻¹ + _IDEAL_GAS_CONSTANT * _AMBIENT_TEMPERATURE / _AMBIENT_PRESSURE +) +# Molar masses in g⋅mol⁻¹ +_CARBON_MONOXIDE_MOLAR_MASS = 28.01 + class BaseUnitConverter: """Define the format of a conversion utility.""" @@ -168,6 +179,25 @@ def _are_unit_inverses(cls, from_unit: str | None, to_unit: str | None) -> bool: return (from_unit in cls._UNIT_INVERSES) != (to_unit in cls._UNIT_INVERSES) +class CarbonMonoxideConcentrationConverter(BaseUnitConverter): + """Convert carbon monoxide ratio to mass per volume. + + Using ambient temperature of 20°C and pressure of 1 ATM. + """ + + UNIT_CLASS = "carbon_monoxide" + _UNIT_CONVERSION: dict[str | None, float] = { + CONCENTRATION_PARTS_PER_MILLION: 1e6, + CONCENTRATION_MILLIGRAMS_PER_CUBIC_METER: ( + _CARBON_MONOXIDE_MOLAR_MASS / _AMBIENT_IDEAL_GAS_MOLAR_VOLUME * 1e3 + ), + } + VALID_UNITS = { + CONCENTRATION_PARTS_PER_MILLION, + CONCENTRATION_MILLIGRAMS_PER_CUBIC_METER, + } + + class DataRateConverter(BaseUnitConverter): """Utility to convert data rate values.""" diff --git a/pyproject.toml b/pyproject.toml index cc62bb15051c8c..79f974b6d33ba9 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -59,7 +59,7 @@ dependencies = [ # PyJWT has loose dependency. We want the latest one. "cryptography==46.0.2", "Pillow==11.3.0", - "propcache==0.4.0", + "propcache==0.4.1", "pyOpenSSL==25.3.0", "orjson==3.11.3", "packaging>=23.1", diff --git a/requirements.txt b/requirements.txt index 7412151259b126..c258062aa03662 100644 --- a/requirements.txt +++ b/requirements.txt @@ -31,7 +31,7 @@ lru-dict==1.3.0 PyJWT==2.10.1 cryptography==46.0.2 Pillow==11.3.0 -propcache==0.4.0 +propcache==0.4.1 pyOpenSSL==25.3.0 orjson==3.11.3 packaging>=23.1 diff --git a/requirements_all.txt b/requirements_all.txt index 4b25e80b14d927..6c9b1525718097 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -782,7 +782,7 @@ decora-wifi==1.4 # decora==0.6 # homeassistant.components.ecovacs -deebot-client==15.0.0 +deebot-client==15.1.0 # homeassistant.components.ihc # homeassistant.components.namecheapdns @@ -1186,7 +1186,7 @@ hole==0.9.0 holidays==0.82 # homeassistant.components.frontend -home-assistant-frontend==20251001.0 +home-assistant-frontend==20251001.2 # homeassistant.components.conversation home-assistant-intents==2025.10.1 @@ -1270,7 +1270,7 @@ insteon-frontend-home-assistant==0.5.0 intellifire4py==4.1.9 # homeassistant.components.iometer -iometer==0.1.0 +iometer==0.2.0 # homeassistant.components.iotty iottycloud==0.3.0 @@ -1319,7 +1319,7 @@ kiwiki-client==0.1.1 knocki==0.4.2 # homeassistant.components.knx -knx-frontend==2025.8.24.205840 +knx-frontend==2025.10.9.185845 # homeassistant.components.konnected konnected==1.2.0 @@ -2209,7 +2209,7 @@ pynetio==0.1.9.1 # homeassistant.components.nina pynina==0.3.6 -# homeassistant.components.nintendo_parental +# homeassistant.components.nintendo_parental_controls pynintendoparental==1.1.1 # homeassistant.components.nobo_hub @@ -2541,7 +2541,7 @@ python-overseerr==0.7.1 python-picnic-api2==1.3.1 # homeassistant.components.pooldose -python-pooldose==0.5.0 +python-pooldose==0.7.0 # homeassistant.components.rabbitair python-rabbitair==0.0.8 @@ -2810,7 +2810,7 @@ sentry-sdk==1.45.1 sfrbox-api==0.0.12 # homeassistant.components.sharkiq -sharkiq==1.4.0 +sharkiq==1.4.2 # homeassistant.components.aquostv sharp_aquos_rc==0.3.2 @@ -2854,6 +2854,9 @@ soco==0.30.12 # homeassistant.components.solaredge_local solaredge-local==0.2.3 +# homeassistant.components.solaredge +solaredge-web==0.0.1 + # homeassistant.components.solarlog solarlog_cli==0.6.0 @@ -3180,7 +3183,7 @@ xbox-webapi==2.1.0 xiaomi-ble==1.2.0 # homeassistant.components.knx -xknx==3.9.0 +xknx==3.9.1 # homeassistant.components.knx xknxproject==3.8.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 1e4101584b45d1..807f4b56cdf128 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -682,7 +682,7 @@ debugpy==1.8.16 # decora==0.6 # homeassistant.components.ecovacs -deebot-client==15.0.0 +deebot-client==15.1.0 # homeassistant.components.ihc # homeassistant.components.namecheapdns @@ -1035,7 +1035,7 @@ hole==0.9.0 holidays==0.82 # homeassistant.components.frontend -home-assistant-frontend==20251001.0 +home-assistant-frontend==20251001.2 # homeassistant.components.conversation home-assistant-intents==2025.10.1 @@ -1107,7 +1107,7 @@ insteon-frontend-home-assistant==0.5.0 intellifire4py==4.1.9 # homeassistant.components.iometer -iometer==0.1.0 +iometer==0.2.0 # homeassistant.components.iotty iottycloud==0.3.0 @@ -1144,7 +1144,7 @@ kegtron-ble==0.4.0 knocki==0.4.2 # homeassistant.components.knx -knx-frontend==2025.8.24.205840 +knx-frontend==2025.10.9.185845 # homeassistant.components.konnected konnected==1.2.0 @@ -1845,7 +1845,7 @@ pynetgear==0.10.10 # homeassistant.components.nina pynina==0.3.6 -# homeassistant.components.nintendo_parental +# homeassistant.components.nintendo_parental_controls pynintendoparental==1.1.1 # homeassistant.components.nobo_hub @@ -2114,7 +2114,7 @@ python-overseerr==0.7.1 python-picnic-api2==1.3.1 # homeassistant.components.pooldose -python-pooldose==0.5.0 +python-pooldose==0.7.0 # homeassistant.components.rabbitair python-rabbitair==0.0.8 @@ -2335,7 +2335,7 @@ sentry-sdk==1.45.1 sfrbox-api==0.0.12 # homeassistant.components.sharkiq -sharkiq==1.4.0 +sharkiq==1.4.2 # homeassistant.components.simplefin simplefin4py==0.0.18 @@ -2364,6 +2364,9 @@ snapcast==2.3.6 # homeassistant.components.sonos soco==0.30.12 +# homeassistant.components.solaredge +solaredge-web==0.0.1 + # homeassistant.components.solarlog solarlog_cli==0.6.0 @@ -2636,7 +2639,7 @@ xbox-webapi==2.1.0 xiaomi-ble==1.2.0 # homeassistant.components.knx -xknx==3.9.0 +xknx==3.9.1 # homeassistant.components.knx xknxproject==3.8.2 diff --git a/tests/components/anthropic/conftest.py b/tests/components/anthropic/conftest.py index 53e00447a2ea7a..b73262ebeef628 100644 --- a/tests/components/anthropic/conftest.py +++ b/tests/components/anthropic/conftest.py @@ -6,7 +6,16 @@ import pytest from homeassistant.components.anthropic import CONF_CHAT_MODEL -from homeassistant.components.anthropic.const import DEFAULT_CONVERSATION_NAME +from homeassistant.components.anthropic.const import ( + CONF_WEB_SEARCH, + CONF_WEB_SEARCH_CITY, + CONF_WEB_SEARCH_COUNTRY, + CONF_WEB_SEARCH_MAX_USES, + CONF_WEB_SEARCH_REGION, + CONF_WEB_SEARCH_TIMEZONE, + CONF_WEB_SEARCH_USER_LOCATION, + DEFAULT_CONVERSATION_NAME, +) from homeassistant.const import CONF_LLM_HASS_API from homeassistant.core import HomeAssistant from homeassistant.helpers import llm @@ -55,7 +64,7 @@ def mock_config_entry_with_assist( def mock_config_entry_with_extended_thinking( hass: HomeAssistant, mock_config_entry: MockConfigEntry ) -> MockConfigEntry: - """Mock a config entry with assist.""" + """Mock a config entry with extended thinking.""" hass.config_entries.async_update_subentry( mock_config_entry, next(iter(mock_config_entry.subentries.values())), @@ -67,6 +76,29 @@ def mock_config_entry_with_extended_thinking( return mock_config_entry +@pytest.fixture +def mock_config_entry_with_web_search( + hass: HomeAssistant, mock_config_entry: MockConfigEntry +) -> MockConfigEntry: + """Mock a config entry with server tools enabled.""" + hass.config_entries.async_update_subentry( + mock_config_entry, + next(iter(mock_config_entry.subentries.values())), + data={ + CONF_LLM_HASS_API: llm.LLM_API_ASSIST, + CONF_CHAT_MODEL: "claude-sonnet-4-5", + CONF_WEB_SEARCH: True, + CONF_WEB_SEARCH_MAX_USES: 5, + CONF_WEB_SEARCH_USER_LOCATION: True, + CONF_WEB_SEARCH_CITY: "San Francisco", + CONF_WEB_SEARCH_REGION: "California", + CONF_WEB_SEARCH_COUNTRY: "US", + CONF_WEB_SEARCH_TIMEZONE: "America/Los_Angeles", + }, + ) + return mock_config_entry + + @pytest.fixture async def mock_init_component( hass: HomeAssistant, mock_config_entry: MockConfigEntry diff --git a/tests/components/anthropic/snapshots/test_conversation.ambr b/tests/components/anthropic/snapshots/test_conversation.ambr index 8f7a3c43f5ea4e..1e6158006074cb 100644 --- a/tests/components/anthropic/snapshots/test_conversation.ambr +++ b/tests/components/anthropic/snapshots/test_conversation.ambr @@ -338,6 +338,123 @@ }), ]) # --- +# name: test_history_conversion[content5] + list([ + dict({ + 'content': "What's on the news today?", + 'role': 'user', + }), + dict({ + 'content': list([ + dict({ + 'signature': 'ErU/V+ayA==', + 'thinking': "The user is asking about today's news, which requires current, real-time information. This is clearly something that requires recent information beyond my knowledge cutoff. I should use the web_search tool to find today's news.", + 'type': 'thinking', + }), + dict({ + 'text': "To get today's news, I'll perform a web search", + 'type': 'text', + }), + dict({ + 'id': 'srvtoolu_12345ABC', + 'input': dict({ + 'query': "today's news", + }), + 'name': 'web_search', + 'type': 'server_tool_use', + }), + dict({ + 'content': list([ + dict({ + 'encrypted_content': 'ABCDEFG', + 'page_age': '2 days ago', + 'title': "Today's News - Example.com", + 'type': 'web_search_result', + 'url': 'https://www.example.com/todays-news', + }), + dict({ + 'encrypted_content': 'ABCDEFG', + 'page_age': None, + 'title': 'Breaking News - NewsSite.com', + 'type': 'web_search_result', + 'url': 'https://www.newssite.com/breaking-news', + }), + ]), + 'tool_use_id': 'srvtoolu_12345ABC', + 'type': 'web_search_tool_result', + }), + dict({ + 'text': ''' + Here's what I found on the web about today's news: + 1. + ''', + 'type': 'text', + }), + dict({ + 'citations': list([ + dict({ + 'cited_text': 'This release iterates on some of the features we introduced in the last couple of releases, but also...', + 'encrypted_index': 'AAA==', + 'title': 'Home Assistant Release', + 'type': 'web_search_result_location', + 'url': 'https://www.example.com/todays-news', + }), + ]), + 'text': 'New Home Assistant release', + 'type': 'text', + }), + dict({ + 'text': ''' + + 2. + ''', + 'type': 'text', + }), + dict({ + 'citations': list([ + dict({ + 'cited_text': 'Breaking news from around the world today includes major events in technology, politics, and culture...', + 'encrypted_index': 'AQE=', + 'title': 'Breaking News', + 'type': 'web_search_result_location', + 'url': 'https://www.newssite.com/breaking-news', + }), + dict({ + 'cited_text': 'Well, this happened...', + 'encrypted_index': 'AgI=', + 'title': 'Breaking News', + 'type': 'web_search_result_location', + 'url': 'https://www.newssite.com/breaking-news', + }), + ]), + 'text': 'Something incredible happened', + 'type': 'text', + }), + dict({ + 'text': ''' + + Those are the main headlines making news today. + ''', + 'type': 'text', + }), + ]), + 'role': 'assistant', + }), + dict({ + 'content': 'Are you sure?', + 'role': 'user', + }), + dict({ + 'content': list([ + dict({ + 'text': 'Yes, I am sure!', + 'type': 'text', + }), + ]), + 'role': 'assistant', + }), + ]) +# --- # name: test_redacted_thinking list([ dict({ @@ -405,3 +522,102 @@ ), }) # --- +# name: test_web_search + list([ + dict({ + 'attachments': None, + 'content': "What's on the news today?", + 'role': 'user', + }), + dict({ + 'agent_id': 'conversation.claude_conversation', + 'content': "To get today's news, I'll perform a web search", + 'native': ThinkingBlock(signature='ErUBCkYIARgCIkCYXaVNJShe3A86Hp7XUzh9YsCYBbJTbQsrklTAPtJ2sP/NoB6tSzpK/nTL6CjSo2R6n0KNBIg5MH6asM2R/kmaEgyB/X1FtZq5OQAC7jUaDEPWCdcwGQ4RaBy5wiIwmRxExIlDhoY6tILoVPnOExkC/0igZxHEwxK8RU/fmw0b+o+TwAarzUitwzbo21E5Kh3pa3I6yqVROf1t2F8rFocNUeCegsWV/ytwYV+ayA==', thinking='', type='thinking'), + 'role': 'assistant', + 'thinking_content': "The user is asking about today's news, which requires current, real-time information. This is clearly something that requires recent information beyond my knowledge cutoff. I should use the web_search tool to find today's news.", + 'tool_calls': list([ + dict({ + 'external': True, + 'id': 'srvtoolu_12345ABC', + 'tool_args': dict({ + 'query': "today's news", + }), + 'tool_name': 'web_search', + }), + ]), + }), + dict({ + 'agent_id': 'conversation.claude_conversation', + 'role': 'tool_result', + 'tool_call_id': 'srvtoolu_12345ABC', + 'tool_name': 'web_search', + 'tool_result': dict({ + 'content': list([ + dict({ + 'encrypted_content': 'ABCDEFG', + 'page_age': '2 days ago', + 'title': "Today's News - Example.com", + 'type': 'web_search_result', + 'url': 'https://www.example.com/todays-news', + }), + dict({ + 'encrypted_content': 'ABCDEFG', + 'page_age': None, + 'title': 'Breaking News - NewsSite.com', + 'type': 'web_search_result', + 'url': 'https://www.newssite.com/breaking-news', + }), + ]), + }), + }), + dict({ + 'agent_id': 'conversation.claude_conversation', + 'content': ''' + Here's what I found on the web about today's news: + 1. New Home Assistant release + 2. Something incredible happened + Those are the main headlines making news today. + ''', + 'native': dict({ + 'citation_details': list([ + dict({ + 'citations': list([ + dict({ + 'cited_text': 'This release iterates on some of the features we introduced in the last couple of releases, but also...', + 'encrypted_index': 'AAA==', + 'title': 'Home Assistant Release', + 'type': 'web_search_result_location', + 'url': 'https://www.example.com/todays-news', + }), + ]), + 'index': 54, + 'length': 26, + }), + dict({ + 'citations': list([ + dict({ + 'cited_text': 'Breaking news from around the world today includes major events in technology, politics, and culture...', + 'encrypted_index': 'AQE=', + 'title': 'Breaking News', + 'type': 'web_search_result_location', + 'url': 'https://www.newssite.com/breaking-news', + }), + dict({ + 'cited_text': 'Well, this happened...', + 'encrypted_index': 'AgI=', + 'title': 'Breaking News', + 'type': 'web_search_result_location', + 'url': 'https://www.newssite.com/breaking-news', + }), + ]), + 'index': 84, + 'length': 29, + }), + ]), + }), + 'role': 'assistant', + 'thinking_content': None, + 'tool_calls': None, + }), + ]) +# --- diff --git a/tests/components/anthropic/test_config_flow.py b/tests/components/anthropic/test_config_flow.py index 2eac125f5c3356..56e6a13a056493 100644 --- a/tests/components/anthropic/test_config_flow.py +++ b/tests/components/anthropic/test_config_flow.py @@ -9,6 +9,7 @@ AuthenticationError, BadRequestError, InternalServerError, + types, ) from httpx import URL, Request, Response import pytest @@ -22,6 +23,9 @@ CONF_RECOMMENDED, CONF_TEMPERATURE, CONF_THINKING_BUDGET, + CONF_WEB_SEARCH, + CONF_WEB_SEARCH_MAX_USES, + CONF_WEB_SEARCH_USER_LOCATION, DEFAULT_CONVERSATION_NAME, DOMAIN, RECOMMENDED_CHAT_MODEL, @@ -256,6 +260,103 @@ async def test_form_invalid_auth(hass: HomeAssistant, side_effect, error) -> Non assert result2["errors"] == {"base": error} +async def test_subentry_web_search_unsupported_model( + hass: HomeAssistant, mock_config_entry, mock_init_component +) -> None: + """Test error when enabling web search with unsupported model.""" + subentry = next(iter(mock_config_entry.subentries.values())) + options_flow = await mock_config_entry.start_subentry_reconfigure_flow( + hass, subentry.subentry_id + ) + options = await hass.config_entries.subentries.async_configure( + options_flow["flow_id"], + { + "prompt": "You are a helpful assistant", + "max_tokens": 8192, + "chat_model": "claude-3-haiku-20240307", + "recommended": False, + "web_search": True, + "web_search_max_uses": 5, + }, + ) + await hass.async_block_till_done() + assert options["type"] is FlowResultType.FORM + assert options["errors"] == {"web_search": "web_search_unsupported_model"} + + +async def test_subentry_web_search_user_location( + hass: HomeAssistant, mock_config_entry, mock_init_component +) -> None: + """Test fetching user location.""" + subentry = next(iter(mock_config_entry.subentries.values())) + options_flow = await mock_config_entry.start_subentry_reconfigure_flow( + hass, subentry.subentry_id + ) + + hass.config.country = "US" + hass.config.time_zone = "America/Los_Angeles" + hass.states.async_set( + "zone.home", "0", {"latitude": 37.7749, "longitude": -122.4194} + ) + + with patch( + "anthropic.resources.messages.AsyncMessages.create", + new_callable=AsyncMock, + return_value=types.Message( + type="message", + id="mock_message_id", + role="assistant", + model="claude-sonnet-4-0", + usage=types.Usage(input_tokens=100, output_tokens=100), + content=[ + types.TextBlock( + type="text", text='"city": "San Francisco", "region": "California"}' + ) + ], + ), + ) as mock_create: + options = await hass.config_entries.subentries.async_configure( + options_flow["flow_id"], + { + "prompt": "You are a helpful assistant", + "max_tokens": 8192, + "chat_model": "claude-sonnet-4-5", + "recommended": False, + "web_search": True, + "web_search_max_uses": 5, + "user_location": True, + }, + ) + await hass.async_block_till_done() + + assert ( + mock_create.call_args.kwargs["messages"][0]["content"] == "Where are the " + "following coordinates located: (37.7749, -122.4194)? Please respond only " + "with a JSON object using the following schema:\n" + "{'type': 'object', 'properties': {'city': {'type': 'string', 'description': " + "'Free text input for the city, e.g. `San Francisco`'}, 'region': {'type': " + "'string', 'description': 'Free text input for the region, e.g. `California`'" + "}}, 'required': []}" + ) + assert options["type"] is FlowResultType.ABORT + assert options["reason"] == "reconfigure_successful" + assert subentry.data == { + "chat_model": "claude-sonnet-4-5", + "city": "San Francisco", + "country": "US", + "max_tokens": 8192, + "prompt": "You are a helpful assistant", + "recommended": False, + "region": "California", + "temperature": 1.0, + "thinking_budget": 0, + "timezone": "America/Los_Angeles", + "user_location": True, + "web_search": True, + "web_search_max_uses": 5, + } + + @pytest.mark.parametrize( ("current_options", "new_options", "expected_options"), [ @@ -277,6 +378,9 @@ async def test_form_invalid_auth(hass: HomeAssistant, side_effect, error) -> Non CONF_CHAT_MODEL: RECOMMENDED_CHAT_MODEL, CONF_MAX_TOKENS: RECOMMENDED_MAX_TOKENS, CONF_THINKING_BUDGET: RECOMMENDED_THINKING_BUDGET, + CONF_WEB_SEARCH: False, + CONF_WEB_SEARCH_MAX_USES: 5, + CONF_WEB_SEARCH_USER_LOCATION: False, }, ), ( @@ -287,6 +391,9 @@ async def test_form_invalid_auth(hass: HomeAssistant, side_effect, error) -> Non CONF_CHAT_MODEL: RECOMMENDED_CHAT_MODEL, CONF_MAX_TOKENS: RECOMMENDED_MAX_TOKENS, CONF_THINKING_BUDGET: RECOMMENDED_THINKING_BUDGET, + CONF_WEB_SEARCH: False, + CONF_WEB_SEARCH_MAX_USES: 5, + CONF_WEB_SEARCH_USER_LOCATION: False, }, { CONF_RECOMMENDED: True, diff --git a/tests/components/anthropic/test_conversation.py b/tests/components/anthropic/test_conversation.py index f8cccd786fc3ea..7d1a49e9f0018c 100644 --- a/tests/components/anthropic/test_conversation.py +++ b/tests/components/anthropic/test_conversation.py @@ -6,6 +6,9 @@ from anthropic import RateLimitError from anthropic.types import ( + CitationsDelta, + CitationsWebSearchResultLocation, + CitationWebSearchResultLocationParam, InputJSONDelta, Message, MessageDeltaUsage, @@ -17,13 +20,17 @@ RawMessageStopEvent, RawMessageStreamEvent, RedactedThinkingBlock, + ServerToolUseBlock, SignatureDelta, TextBlock, + TextCitation, TextDelta, ThinkingBlock, ThinkingDelta, ToolUseBlock, Usage, + WebSearchResultBlock, + WebSearchToolResultBlock, ) from anthropic.types.raw_message_delta_event import Delta from freezegun import freeze_time @@ -33,6 +40,7 @@ import voluptuous as vol from homeassistant.components import conversation +from homeassistant.components.anthropic.entity import CitationDetails, ContentDetails from homeassistant.const import CONF_LLM_HASS_API from homeassistant.core import Context, HomeAssistant from homeassistant.exceptions import HomeAssistantError @@ -78,15 +86,25 @@ def create_messages( def create_content_block( - index: int, text_parts: list[str] + index: int, text_parts: list[str], citations: list[TextCitation] | None = None ) -> list[RawMessageStreamEvent]: """Create a text content block with the specified deltas.""" return [ RawContentBlockStartEvent( type="content_block_start", - content_block=TextBlock(text="", type="text"), + content_block=TextBlock( + text="", type="text", citations=[] if citations else None + ), index=index, ), + *[ + RawContentBlockDeltaEvent( + delta=CitationsDelta(citation=citation, type="citations_delta"), + index=index, + type="content_block_delta", + ) + for citation in (citations or []) + ], *[ RawContentBlockDeltaEvent( delta=TextDelta(text=text_part, type="text_delta"), @@ -174,6 +192,46 @@ def create_tool_use_block( ] +def create_web_search_block( + index: int, id: str, query_parts: list[str] +) -> list[RawMessageStreamEvent]: + """Create a server tool use block for web search.""" + return [ + RawContentBlockStartEvent( + type="content_block_start", + content_block=ServerToolUseBlock( + type="server_tool_use", id=id, input={}, name="web_search" + ), + index=index, + ), + *[ + RawContentBlockDeltaEvent( + delta=InputJSONDelta(type="input_json_delta", partial_json=query_part), + index=index, + type="content_block_delta", + ) + for query_part in query_parts + ], + RawContentBlockStopEvent(index=index, type="content_block_stop"), + ] + + +def create_web_search_result_block( + index: int, id: str, results: list[WebSearchResultBlock] +) -> list[RawMessageStreamEvent]: + """Create a server tool result block for web search results.""" + return [ + RawContentBlockStartEvent( + type="content_block_start", + content_block=WebSearchToolResultBlock( + type="web_search_tool_result", tool_use_id=id, content=results + ), + index=index, + ), + RawContentBlockStopEvent(index=index, type="content_block_stop"), + ] + + async def test_entity( hass: HomeAssistant, mock_config_entry: MockConfigEntry, @@ -850,6 +908,119 @@ def completion_result(*args, messages, **kwargs): assert mock_create.mock_calls[1][2]["messages"] == snapshot +async def test_web_search( + hass: HomeAssistant, + mock_config_entry_with_web_search: MockConfigEntry, + mock_init_component, + snapshot: SnapshotAssertion, +) -> None: + """Test web search.""" + web_search_results = [ + WebSearchResultBlock( + type="web_search_result", + title="Today's News - Example.com", + url="https://www.example.com/todays-news", + page_age="2 days ago", + encrypted_content="ABCDEFG", + ), + WebSearchResultBlock( + type="web_search_result", + title="Breaking News - NewsSite.com", + url="https://www.newssite.com/breaking-news", + page_age=None, + encrypted_content="ABCDEFG", + ), + ] + with patch( + "anthropic.resources.messages.AsyncMessages.create", + new_callable=AsyncMock, + return_value=stream_generator( + create_messages( + [ + *create_thinking_block( + 0, + [ + "The user is", + " asking about today's news, which", + " requires current, real-time information", + ". This is clearly something that requires recent", + " information beyond my knowledge cutoff.", + " I should use the web", + "_search tool to fin", + "d today's news.", + ], + ), + *create_content_block( + 1, ["To get today's news, I'll perform a web search"] + ), + *create_web_search_block( + 2, + "srvtoolu_12345ABC", + ["", '{"que', 'ry"', ": \"today's", ' news"}'], + ), + *create_web_search_result_block( + 3, "srvtoolu_12345ABC", web_search_results + ), + *create_content_block( + 4, + ["Here's what I found on the web about today's news:\n", "1. "], + ), + *create_content_block( + 5, + ["New Home Assistant release"], + citations=[ + CitationsWebSearchResultLocation( + type="web_search_result_location", + cited_text="This release iterates on some of the features we introduced in the last couple of releases, but also...", + encrypted_index="AAA==", + title="Home Assistant Release", + url="https://www.example.com/todays-news", + ) + ], + ), + *create_content_block(6, ["\n2. "]), + *create_content_block( + 7, + ["Something incredible happened"], + citations=[ + CitationsWebSearchResultLocation( + type="web_search_result_location", + cited_text="Breaking news from around the world today includes major events in technology, politics, and culture...", + encrypted_index="AQE=", + title="Breaking News", + url="https://www.newssite.com/breaking-news", + ), + CitationsWebSearchResultLocation( + type="web_search_result_location", + cited_text="Well, this happened...", + encrypted_index="AgI=", + title="Breaking News", + url="https://www.newssite.com/breaking-news", + ), + ], + ), + *create_content_block( + 8, ["\nThose are the main headlines making news today."] + ), + ] + ), + ), + ): + result = await conversation.async_converse( + hass, + "What's on the news today?", + None, + Context(), + agent_id="conversation.claude_conversation", + ) + + chat_log = hass.data.get(conversation.chat_log.DATA_CHAT_LOGS).get( + result.conversation_id + ) + # Don't test the prompt because it's not deterministic + assert chat_log.content[1:] == snapshot + + @pytest.mark.parametrize( "content", [ @@ -929,6 +1100,93 @@ def completion_result(*args, messages, **kwargs): content="Should I add milk to the shopping list?", ), ], + [ + conversation.chat_log.SystemContent("You are a helpful assistant."), + conversation.chat_log.UserContent("What's on the news today?"), + conversation.chat_log.AssistantContent( + agent_id="conversation.claude_conversation", + content="To get today's news, I'll perform a web search", + thinking_content="The user is asking about today's news, which requires current, real-time information. This is clearly something that requires recent information beyond my knowledge cutoff. I should use the web_search tool to find today's news.", + native=ThinkingBlock( + signature="ErU/V+ayA==", thinking="", type="thinking" + ), + tool_calls=[ + llm.ToolInput( + id="srvtoolu_12345ABC", + tool_name="web_search", + tool_args={"query": "today's news"}, + external=True, + ), + ], + ), + conversation.chat_log.ToolResultContent( + agent_id="conversation.claude_conversation", + tool_call_id="srvtoolu_12345ABC", + tool_name="web_search", + tool_result={ + "content": [ + { + "type": "web_search_result", + "title": "Today's News - Example.com", + "url": "https://www.example.com/todays-news", + "page_age": "2 days ago", + "encrypted_content": "ABCDEFG", + }, + { + "type": "web_search_result", + "title": "Breaking News - NewsSite.com", + "url": "https://www.newssite.com/breaking-news", + "page_age": None, + "encrypted_content": "ABCDEFG", + }, + ] + }, + ), + conversation.chat_log.AssistantContent( + agent_id="conversation.claude_conversation", + content="Here's what I found on the web about today's news:\n" + "1. New Home Assistant release\n" + "2. Something incredible happened\n" + "Those are the main headlines making news today.", + native=ContentDetails( + citation_details=[ + CitationDetails( + index=54, + length=26, + citations=[ + CitationWebSearchResultLocationParam( + type="web_search_result_location", + cited_text="This release iterates on some of the features we introduced in the last couple of releases, but also...", + encrypted_index="AAA==", + title="Home Assistant Release", + url="https://www.example.com/todays-news", + ), + ], + ), + CitationDetails( + index=84, + length=29, + citations=[ + CitationWebSearchResultLocationParam( + type="web_search_result_location", + cited_text="Breaking news from around the world today includes major events in technology, politics, and culture...", + encrypted_index="AQE=", + title="Breaking News", + url="https://www.newssite.com/breaking-news", + ), + CitationWebSearchResultLocationParam( + type="web_search_result_location", + cited_text="Well, this happened...", + encrypted_index="AgI=", + title="Breaking News", + url="https://www.newssite.com/breaking-news", + ), + ], + ), + ], + ), + ), + ], ], ) async def test_history_conversion( diff --git a/tests/components/elevenlabs/conftest.py b/tests/components/elevenlabs/conftest.py index c47017b88e986e..aa5d7fa229298c 100644 --- a/tests/components/elevenlabs/conftest.py +++ b/tests/components/elevenlabs/conftest.py @@ -1,6 +1,7 @@ """Common fixtures for the ElevenLabs text-to-speech tests.""" from collections.abc import Generator +from typing import Any from unittest.mock import AsyncMock, patch from elevenlabs.core import ApiError @@ -8,8 +9,16 @@ from httpx import ConnectError import pytest -from homeassistant.components.elevenlabs.const import CONF_MODEL, CONF_VOICE +from homeassistant.components.elevenlabs.const import ( + CONF_MODEL, + CONF_STT_AUTO_LANGUAGE, + CONF_STT_MODEL, + CONF_VOICE, + DEFAULT_SIMILARITY, + DOMAIN, +) from homeassistant.const import CONF_API_KEY +from homeassistant.core import HomeAssistant from .const import MOCK_MODELS, MOCK_VOICES @@ -136,6 +145,55 @@ def mock_async_client_connect_error() -> Generator[AsyncMock]: yield mock_async_client +async def mock_config_entry_setup( + hass: HomeAssistant, config_data: dict[str, Any], config_options: dict[str, Any] +) -> None: + """Mock config entry setup.""" + default_config_data = { + CONF_API_KEY: "api_key", + } + default_config_options = { + CONF_VOICE: "voice1", + CONF_MODEL: "model1", + CONF_STT_MODEL: "stt_model1", + CONF_STT_AUTO_LANGUAGE: False, + } + config_entry = MockConfigEntry( + domain=DOMAIN, + data=default_config_data | config_data, + options=default_config_options | config_options, + ) + config_entry.add_to_hass(hass) + client_mock = AsyncMock() + client_mock.voices.get_all.return_value = GetVoicesResponse(voices=MOCK_VOICES) + client_mock.models.list.return_value = MOCK_MODELS + stt_mock = AsyncMock() + stt_mock.convert.return_value = AsyncMock( + text="hello world", language_code="en", language_probability=0.95 + ) + client_mock.speech_to_text = stt_mock + with patch( + "homeassistant.components.elevenlabs.AsyncElevenLabs", return_value=client_mock + ): + assert await hass.config_entries.async_setup(config_entry.entry_id) + + +@pytest.fixture(name="setup") +async def setup_fixture( + hass: HomeAssistant, + config_data: dict[str, Any], + config_options: dict[str, Any], + mock_async_client: AsyncMock, +) -> AsyncMock: + """Set up the test environment.""" + + await mock_config_entry_setup(hass, config_data, config_options) + + await hass.async_block_till_done() + + return mock_async_client + + @pytest.fixture def mock_entry() -> MockConfigEntry: """Mock a config entry.""" @@ -144,11 +202,35 @@ def mock_entry() -> MockConfigEntry: data={ CONF_API_KEY: "api_key", }, - options={CONF_MODEL: "model1", CONF_VOICE: "voice1"}, + options={ + CONF_MODEL: "model1", + CONF_VOICE: "voice1", + CONF_STT_MODEL: "stt_model1", + CONF_STT_AUTO_LANGUAGE: False, + }, ) entry.models = { "model1": "model1", } entry.voices = {"voice1": "voice1"} + entry.stt_models = {"stt_model1": "stt_model1"} return entry + + +@pytest.fixture(name="config_data") +def config_data_fixture() -> dict[str, Any]: + """Return config data.""" + return {} + + +@pytest.fixture(name="config_options") +def config_options_fixture() -> dict[str, Any]: + """Return config options.""" + return {} + + +@pytest.fixture +def mock_similarity(): + """Mock similarity.""" + return DEFAULT_SIMILARITY / 2 diff --git a/tests/components/elevenlabs/const.py b/tests/components/elevenlabs/const.py index e16e1fd1334a89..968182ba16f7a6 100644 --- a/tests/components/elevenlabs/const.py +++ b/tests/components/elevenlabs/const.py @@ -2,7 +2,7 @@ from elevenlabs.types import LanguageResponse, Model, Voice -from homeassistant.components.elevenlabs.const import DEFAULT_MODEL +from homeassistant.components.elevenlabs.const import DEFAULT_TTS_MODEL MOCK_VOICES = [ Voice( @@ -39,8 +39,8 @@ ], ), Model( - model_id=DEFAULT_MODEL, - name=DEFAULT_MODEL, + model_id=DEFAULT_TTS_MODEL, + name=DEFAULT_TTS_MODEL, can_do_text_to_speech=True, languages=[ LanguageResponse(language_id="en", name="English"), diff --git a/tests/components/elevenlabs/test_config_flow.py b/tests/components/elevenlabs/test_config_flow.py index eccd5d49d92c8c..e4c5209e848c27 100644 --- a/tests/components/elevenlabs/test_config_flow.py +++ b/tests/components/elevenlabs/test_config_flow.py @@ -9,13 +9,16 @@ CONF_MODEL, CONF_SIMILARITY, CONF_STABILITY, + CONF_STT_AUTO_LANGUAGE, + CONF_STT_MODEL, CONF_STYLE, CONF_USE_SPEAKER_BOOST, CONF_VOICE, - DEFAULT_MODEL, DEFAULT_SIMILARITY, DEFAULT_STABILITY, + DEFAULT_STT_MODEL, DEFAULT_STYLE, + DEFAULT_TTS_MODEL, DEFAULT_USE_SPEAKER_BOOST, DOMAIN, ) @@ -50,7 +53,12 @@ async def test_user_step( assert result["data"] == { "api_key": "api_key", } - assert result["options"] == {CONF_MODEL: DEFAULT_MODEL, CONF_VOICE: "voice1"} + assert result["options"] == { + CONF_MODEL: DEFAULT_TTS_MODEL, + CONF_VOICE: "voice1", + CONF_STT_MODEL: DEFAULT_STT_MODEL, + CONF_STT_AUTO_LANGUAGE: False, + } mock_setup_entry.assert_called_once() @@ -94,7 +102,12 @@ async def test_invalid_api_key( assert result["data"] == { "api_key": "api_key", } - assert result["options"] == {CONF_MODEL: DEFAULT_MODEL, CONF_VOICE: "voice1"} + assert result["options"] == { + CONF_MODEL: DEFAULT_TTS_MODEL, + CONF_VOICE: "voice1", + CONF_STT_MODEL: DEFAULT_STT_MODEL, + CONF_STT_AUTO_LANGUAGE: False, + } mock_setup_entry.assert_called_once() @@ -138,7 +151,12 @@ async def test_voices_error( assert result["data"] == { "api_key": "api_key", } - assert result["options"] == {CONF_MODEL: DEFAULT_MODEL, CONF_VOICE: "voice1"} + assert result["options"] == { + CONF_MODEL: DEFAULT_TTS_MODEL, + CONF_VOICE: "voice1", + CONF_STT_MODEL: DEFAULT_STT_MODEL, + CONF_STT_AUTO_LANGUAGE: False, + } mock_setup_entry.assert_called_once() @@ -182,7 +200,12 @@ async def test_models_error( assert result["data"] == { "api_key": "api_key", } - assert result["options"] == {CONF_MODEL: DEFAULT_MODEL, CONF_VOICE: "voice1"} + assert result["options"] == { + CONF_MODEL: DEFAULT_TTS_MODEL, + CONF_VOICE: "voice1", + CONF_STT_MODEL: DEFAULT_STT_MODEL, + CONF_STT_AUTO_LANGUAGE: False, + } mock_setup_entry.assert_called_once() @@ -205,13 +228,20 @@ async def test_options_flow_init( result = await hass.config_entries.options.async_configure( result["flow_id"], - user_input={CONF_MODEL: "model1", CONF_VOICE: "voice1"}, + user_input={ + CONF_MODEL: "model1", + CONF_VOICE: "voice1", + CONF_STT_MODEL: "scribe_v1_experimental", + CONF_STT_AUTO_LANGUAGE: True, + }, ) assert result["type"] is FlowResultType.CREATE_ENTRY assert mock_entry.options == { CONF_MODEL: "model1", CONF_VOICE: "voice1", + CONF_STT_MODEL: "scribe_v1_experimental", + CONF_STT_AUTO_LANGUAGE: True, } mock_setup_entry.assert_called_once() @@ -237,6 +267,8 @@ async def test_options_flow_voice_settings_default( user_input={ CONF_MODEL: "model1", CONF_VOICE: "voice1", + CONF_STT_MODEL: "scribe_v1_experimental", + CONF_STT_AUTO_LANGUAGE: False, CONF_CONFIGURE_VOICE: True, }, ) @@ -252,6 +284,8 @@ async def test_options_flow_voice_settings_default( assert mock_entry.options == { CONF_MODEL: "model1", CONF_VOICE: "voice1", + CONF_STT_MODEL: "scribe_v1_experimental", + CONF_STT_AUTO_LANGUAGE: False, CONF_SIMILARITY: DEFAULT_SIMILARITY, CONF_STABILITY: DEFAULT_STABILITY, CONF_STYLE: DEFAULT_STYLE, diff --git a/tests/components/elevenlabs/test_stt.py b/tests/components/elevenlabs/test_stt.py new file mode 100644 index 00000000000000..64ac741a10be2c --- /dev/null +++ b/tests/components/elevenlabs/test_stt.py @@ -0,0 +1,223 @@ +"""Tests for the ElevenLabs STT integration.""" + +from unittest.mock import AsyncMock + +from elevenlabs.core import ApiError +import pytest + +from homeassistant.components import stt +from homeassistant.components.elevenlabs.const import ( + CONF_MODEL, + CONF_STT_AUTO_LANGUAGE, + CONF_VOICE, +) +from homeassistant.core import HomeAssistant + +# === Fixtures === + + +@pytest.fixture +def default_metadata() -> stt.SpeechMetadata: + """Return default metadata for valid PCM WAV input.""" + return stt.SpeechMetadata( + language="en-US", + format=stt.AudioFormats.WAV, + codec=stt.AudioCodecs.PCM, + sample_rate=stt.AudioSampleRates.SAMPLERATE_16000, + channel=stt.AudioChannels.CHANNEL_MONO, + bit_rate=stt.AudioBitRates.BITRATE_16, + ) + + +# === Stream Fixtures === + + +@pytest.fixture +def two_chunk_stream(): + """Return a stream that yields two chunks of audio.""" + + async def _stream(): + yield b"chunk1" + yield b"chunk2" + + return _stream + + +@pytest.fixture +def simple_stream(): + """Return a basic stream yielding one audio chunk.""" + + async def _stream(): + yield b"data" + + return _stream + + +@pytest.fixture +def empty_stream(): + """Return an empty stream.""" + + async def _stream(): + return + yield # This makes it an async generator + + return _stream + + +# === Metadata Fixtures for Edge Cases === + + +@pytest.fixture +def unsupported_language_metadata() -> stt.SpeechMetadata: + """Return metadata with unsupported language code.""" + return stt.SpeechMetadata( + language="xx-XX", + format=stt.AudioFormats.WAV, + codec=stt.AudioCodecs.PCM, + sample_rate=stt.AudioSampleRates.SAMPLERATE_16000, + channel=stt.AudioChannels.CHANNEL_MONO, + bit_rate=stt.AudioBitRates.BITRATE_16, + ) + + +@pytest.fixture +def incompatible_pcm_metadata() -> stt.SpeechMetadata: + """Return metadata that is PCM but not raw-compatible (e.g., stereo).""" + return stt.SpeechMetadata( + language="en-US", + format=stt.AudioFormats.WAV, + codec=stt.AudioCodecs.PCM, + sample_rate=stt.AudioSampleRates.SAMPLERATE_16000, + channel=stt.AudioChannels.CHANNEL_STEREO, + bit_rate=stt.AudioBitRates.BITRATE_16, + ) + + +@pytest.fixture +def opus_metadata() -> stt.SpeechMetadata: + """Return valid metadata using OPUS codec.""" + return stt.SpeechMetadata( + language="en-US", + format=stt.AudioFormats.OGG, + codec=stt.AudioCodecs.OPUS, + sample_rate=stt.AudioSampleRates.SAMPLERATE_16000, + channel=stt.AudioChannels.CHANNEL_MONO, + bit_rate=stt.AudioBitRates.BITRATE_16, + ) + + +# === SUCCESS TESTS === + + +async def test_stt_transcription_success( + hass: HomeAssistant, + setup: AsyncMock, + default_metadata: stt.SpeechMetadata, + two_chunk_stream, +) -> None: + """Test successful transcription with valid PCM/WAV input.""" + entity = stt.async_get_speech_to_text_engine(hass, "stt.elevenlabs_speech_to_text") + assert entity is not None + result = await entity.async_process_audio_stream( + default_metadata, two_chunk_stream() + ) + assert result.result == stt.SpeechResultState.SUCCESS + assert result.text == "hello world" + entity._client.speech_to_text.convert.assert_called_once() + + +@pytest.mark.parametrize( + "config_options", + [ + { + CONF_VOICE: "voice1", + CONF_MODEL: "model1", + CONF_STT_AUTO_LANGUAGE: True, + } + ], +) +async def test_stt_transcription_success_auto_language( + hass: HomeAssistant, + setup: AsyncMock, + simple_stream, +) -> None: + """Test successful transcription when auto language detection is enabled.""" + metadata = stt.SpeechMetadata( + language="na", + format=stt.AudioFormats.WAV, + codec=stt.AudioCodecs.PCM, + sample_rate=stt.AudioSampleRates.SAMPLERATE_16000, + channel=stt.AudioChannels.CHANNEL_MONO, + bit_rate=stt.AudioBitRates.BITRATE_16, + ) + entity = stt.async_get_speech_to_text_engine(hass, "stt.elevenlabs_speech_to_text") + assert entity is not None + result = await entity.async_process_audio_stream(metadata, simple_stream()) + assert result.result == stt.SpeechResultState.SUCCESS + assert result.text == "hello world" + entity._client.speech_to_text.convert.assert_called_once() + + +# === ERROR CASES (PARAMETRIZED) === + + +@pytest.mark.parametrize( + ("metadata_fixture", "stream_fixture"), + [ + ("unsupported_language_metadata", "simple_stream"), + ("incompatible_pcm_metadata", "simple_stream"), + ("opus_metadata", "empty_stream"), + ], +) +async def test_stt_edge_cases( + hass: HomeAssistant, + setup: AsyncMock, + request: pytest.FixtureRequest, + metadata_fixture: str, + stream_fixture: str, +) -> None: + """Test various error scenarios like unsupported language or bad format.""" + entity = stt.async_get_speech_to_text_engine(hass, "stt.elevenlabs_speech_to_text") + assert entity is not None + metadata = request.getfixturevalue(metadata_fixture) + stream = request.getfixturevalue(stream_fixture) + assert not entity._auto_detect_language + result = await entity.async_process_audio_stream(metadata, stream()) + assert result.result == stt.SpeechResultState.ERROR + assert result.text is None + + +async def test_stt_convert_api_error( + hass: HomeAssistant, + setup: AsyncMock, + default_metadata: stt.SpeechMetadata, + simple_stream, +) -> None: + """Test that API errors during convert are handled properly.""" + entity = stt.async_get_speech_to_text_engine(hass, "stt.elevenlabs_speech_to_text") + assert entity is not None + entity._client.speech_to_text.convert.side_effect = ApiError() + result = await entity.async_process_audio_stream(default_metadata, simple_stream()) + assert result.result == stt.SpeechResultState.ERROR + assert result.text is None + + +# === SUPPORTED PROPERTIES === + + +async def test_supported_properties( + hass: HomeAssistant, + setup: AsyncMock, +) -> None: + """Test the advertised capabilities of the ElevenLabs STT entity.""" + entity = stt.async_get_speech_to_text_engine(hass, "stt.elevenlabs_speech_to_text") + assert entity is not None + assert set(entity.supported_formats) == {stt.AudioFormats.WAV, stt.AudioFormats.OGG} + assert set(entity.supported_codecs) == {stt.AudioCodecs.PCM, stt.AudioCodecs.OPUS} + assert set(entity.supported_bit_rates) == {stt.AudioBitRates.BITRATE_16} + assert set(entity.supported_sample_rates) == {stt.AudioSampleRates.SAMPLERATE_16000} + assert set(entity.supported_channels) == { + stt.AudioChannels.CHANNEL_MONO, + stt.AudioChannels.CHANNEL_STEREO, + } + assert "en-US" in entity.supported_languages diff --git a/tests/components/elevenlabs/test_tts.py b/tests/components/elevenlabs/test_tts.py index f25a03f2824686..c5e7529e5a0ce3 100644 --- a/tests/components/elevenlabs/test_tts.py +++ b/tests/components/elevenlabs/test_tts.py @@ -5,39 +5,34 @@ from http import HTTPStatus from pathlib import Path from typing import Any -from unittest.mock import AsyncMock, MagicMock, patch +from unittest.mock import AsyncMock, MagicMock from elevenlabs.core import ApiError -from elevenlabs.types import GetVoicesResponse, VoiceSettings +from elevenlabs.types import VoiceSettings import pytest from homeassistant.components import tts from homeassistant.components.elevenlabs.const import ( ATTR_MODEL, - CONF_MODEL, CONF_SIMILARITY, CONF_STABILITY, CONF_STYLE, CONF_USE_SPEAKER_BOOST, - CONF_VOICE, DEFAULT_SIMILARITY, DEFAULT_STABILITY, DEFAULT_STYLE, DEFAULT_USE_SPEAKER_BOOST, - DOMAIN, ) from homeassistant.components.media_player import ( ATTR_MEDIA_CONTENT_ID, DOMAIN as DOMAIN_MP, SERVICE_PLAY_MEDIA, ) -from homeassistant.const import ATTR_ENTITY_ID, CONF_API_KEY +from homeassistant.const import ATTR_ENTITY_ID from homeassistant.core import HomeAssistant, ServiceCall from homeassistant.core_config import async_process_ha_core_config -from .const import MOCK_MODELS, MOCK_VOICES - -from tests.common import MockConfigEntry, async_mock_service +from tests.common import async_mock_service from tests.components.tts.common import retrieve_media from tests.typing import ClientSessionGenerator @@ -79,83 +74,6 @@ async def setup_internal_url(hass: HomeAssistant) -> None: ) -@pytest.fixture -def mock_similarity(): - """Mock similarity.""" - return DEFAULT_SIMILARITY / 2 - - -@pytest.fixture(name="setup") -async def setup_fixture( - hass: HomeAssistant, - config_data: dict[str, Any], - config_options: dict[str, Any], - config_options_voice: dict[str, Any], - request: pytest.FixtureRequest, - mock_async_client: AsyncMock, -) -> AsyncMock: - """Set up the test environment.""" - if request.param == "mock_config_entry_setup": - await mock_config_entry_setup(hass, config_data, config_options) - elif request.param == "mock_config_entry_setup_voice": - await mock_config_entry_setup(hass, config_data, config_options_voice) - else: - raise RuntimeError("Invalid setup fixture") - - await hass.async_block_till_done() - - return mock_async_client - - -@pytest.fixture(name="config_data") -def config_data_fixture() -> dict[str, Any]: - """Return config data.""" - return {} - - -@pytest.fixture(name="config_options") -def config_options_fixture() -> dict[str, Any]: - """Return config options.""" - return {} - - -@pytest.fixture(name="config_options_voice") -def config_options_voice_fixture(mock_similarity) -> dict[str, Any]: - """Return config options.""" - return { - CONF_SIMILARITY: mock_similarity, - CONF_STABILITY: DEFAULT_STABILITY, - CONF_STYLE: DEFAULT_STYLE, - CONF_USE_SPEAKER_BOOST: DEFAULT_USE_SPEAKER_BOOST, - } - - -async def mock_config_entry_setup( - hass: HomeAssistant, config_data: dict[str, Any], config_options: dict[str, Any] -) -> None: - """Mock config entry setup.""" - default_config_data = { - CONF_API_KEY: "api_key", - } - default_config_options = { - CONF_VOICE: "voice1", - CONF_MODEL: "model1", - } - config_entry = MockConfigEntry( - domain=DOMAIN, - data=default_config_data | config_data, - options=default_config_options | config_options, - ) - config_entry.add_to_hass(hass) - client_mock = AsyncMock() - client_mock.voices.get_all.return_value = GetVoicesResponse(voices=MOCK_VOICES) - client_mock.models.list.return_value = MOCK_MODELS - with patch( - "homeassistant.components.elevenlabs.AsyncElevenLabs", return_value=client_mock - ): - assert await hass.config_entries.async_setup(config_entry.entry_id) - - @pytest.mark.parametrize( "config_data", [ @@ -173,7 +91,7 @@ async def mock_config_entry_setup( "mock_config_entry_setup", "speak", { - ATTR_ENTITY_ID: "tts.mock_title", + ATTR_ENTITY_ID: "tts.elevenlabs_text_to_speech", tts.ATTR_MEDIA_PLAYER_ENTITY_ID: "media_player.something", tts.ATTR_MESSAGE: "There is a person at the front door.", tts.ATTR_OPTIONS: {}, @@ -183,7 +101,7 @@ async def mock_config_entry_setup( "mock_config_entry_setup", "speak", { - ATTR_ENTITY_ID: "tts.mock_title", + ATTR_ENTITY_ID: "tts.elevenlabs_text_to_speech", tts.ATTR_MEDIA_PLAYER_ENTITY_ID: "media_player.something", tts.ATTR_MESSAGE: "There is a person at the front door.", tts.ATTR_OPTIONS: {tts.ATTR_VOICE: "voice2"}, @@ -193,7 +111,7 @@ async def mock_config_entry_setup( "mock_config_entry_setup", "speak", { - ATTR_ENTITY_ID: "tts.mock_title", + ATTR_ENTITY_ID: "tts.elevenlabs_text_to_speech", tts.ATTR_MEDIA_PLAYER_ENTITY_ID: "media_player.something", tts.ATTR_MESSAGE: "There is a person at the front door.", tts.ATTR_OPTIONS: {ATTR_MODEL: "model2"}, @@ -203,7 +121,7 @@ async def mock_config_entry_setup( "mock_config_entry_setup", "speak", { - ATTR_ENTITY_ID: "tts.mock_title", + ATTR_ENTITY_ID: "tts.elevenlabs_text_to_speech", tts.ATTR_MEDIA_PLAYER_ENTITY_ID: "media_player.something", tts.ATTR_MESSAGE: "There is a person at the front door.", tts.ATTR_OPTIONS: {tts.ATTR_VOICE: "voice2", ATTR_MODEL: "model2"}, @@ -263,7 +181,7 @@ async def test_tts_service_speak( "mock_config_entry_setup", "speak", { - ATTR_ENTITY_ID: "tts.mock_title", + ATTR_ENTITY_ID: "tts.elevenlabs_text_to_speech", tts.ATTR_MEDIA_PLAYER_ENTITY_ID: "media_player.something", tts.ATTR_MESSAGE: "There is a person at the front door.", tts.ATTR_LANGUAGE: "de", @@ -274,7 +192,7 @@ async def test_tts_service_speak( "mock_config_entry_setup", "speak", { - ATTR_ENTITY_ID: "tts.mock_title", + ATTR_ENTITY_ID: "tts.elevenlabs_text_to_speech", tts.ATTR_MEDIA_PLAYER_ENTITY_ID: "media_player.something", tts.ATTR_MESSAGE: "There is a person at the front door.", tts.ATTR_LANGUAGE: "es", @@ -326,7 +244,7 @@ async def test_tts_service_speak_lang_config( "mock_config_entry_setup", "speak", { - ATTR_ENTITY_ID: "tts.mock_title", + ATTR_ENTITY_ID: "tts.elevenlabs_text_to_speech", tts.ATTR_MEDIA_PLAYER_ENTITY_ID: "media_player.something", tts.ATTR_MESSAGE: "There is a person at the front door.", tts.ATTR_OPTIONS: {tts.ATTR_VOICE: "voice1"}, @@ -382,20 +300,24 @@ async def test_tts_service_speak_error( ], ) @pytest.mark.parametrize( - ("setup", "tts_service", "service_data"), + ("config_options", "tts_service", "service_data"), [ ( - "mock_config_entry_setup_voice", + { + CONF_SIMILARITY: DEFAULT_SIMILARITY / 2, + CONF_STABILITY: DEFAULT_STABILITY, + CONF_STYLE: DEFAULT_STYLE, + CONF_USE_SPEAKER_BOOST: DEFAULT_USE_SPEAKER_BOOST, + }, "speak", { - ATTR_ENTITY_ID: "tts.mock_title", + ATTR_ENTITY_ID: "tts.elevenlabs_text_to_speech", tts.ATTR_MEDIA_PLAYER_ENTITY_ID: "media_player.something", tts.ATTR_MESSAGE: "There is a person at the front door.", tts.ATTR_OPTIONS: {tts.ATTR_VOICE: "voice2"}, }, ), ], - indirect=["setup"], ) async def test_tts_service_speak_voice_settings( setup: AsyncMock, @@ -413,7 +335,7 @@ async def test_tts_service_speak_voice_settings( ) assert tts_entity._voice_settings == VoiceSettings( stability=DEFAULT_STABILITY, - similarity_boost=mock_similarity, + similarity_boost=DEFAULT_SIMILARITY / 2, style=DEFAULT_STYLE, use_speaker_boost=DEFAULT_USE_SPEAKER_BOOST, ) @@ -446,7 +368,7 @@ async def test_tts_service_speak_voice_settings( "mock_config_entry_setup", "speak", { - ATTR_ENTITY_ID: "tts.mock_title", + ATTR_ENTITY_ID: "tts.elevenlabs_text_to_speech", tts.ATTR_MEDIA_PLAYER_ENTITY_ID: "media_player.something", tts.ATTR_MESSAGE: "There is a person at the front door.", tts.ATTR_OPTIONS: {}, diff --git a/tests/components/fritzbox/snapshots/test_climate.ambr b/tests/components/fritzbox/snapshots/test_climate.ambr index 423472c078ee39..8a0e47a4a24536 100644 --- a/tests/components/fritzbox/snapshots/test_climate.ambr +++ b/tests/components/fritzbox/snapshots/test_climate.ambr @@ -49,11 +49,8 @@ # name: test_setup[climate.fake_name-state] StateSnapshot({ 'attributes': ReadOnlyDict({ - 'battery_level': 23, - 'battery_low': True, 'current_temperature': 18.0, 'friendly_name': 'fake_name', - 'holiday_mode': False, 'hvac_modes': list([ , , @@ -66,10 +63,8 @@ 'comfort', 'boost', ]), - 'summer_mode': False, 'supported_features': , 'temperature': 19.5, - 'window_open': 'fake_window', }), 'context': , 'entity_id': 'climate.fake_name', diff --git a/tests/components/fritzbox/test_climate.py b/tests/components/fritzbox/test_climate.py index 3853e9275c8619..09b69b16e798ac 100644 --- a/tests/components/fritzbox/test_climate.py +++ b/tests/components/fritzbox/test_climate.py @@ -31,11 +31,7 @@ PRESET_HOLIDAY, PRESET_SUMMER, ) -from homeassistant.components.fritzbox.const import ( - ATTR_STATE_HOLIDAY_MODE, - ATTR_STATE_SUMMER_MODE, - DOMAIN, -) +from homeassistant.components.fritzbox.const import DOMAIN from homeassistant.config_entries import ConfigEntryState from homeassistant.const import ATTR_ENTITY_ID, ATTR_TEMPERATURE, CONF_DEVICES, Platform from homeassistant.core import HomeAssistant @@ -588,8 +584,6 @@ async def test_holidy_summer_mode( # initial state state = hass.states.get(ENTITY_ID) assert state - assert state.attributes[ATTR_STATE_HOLIDAY_MODE] is False - assert state.attributes[ATTR_STATE_SUMMER_MODE] is False assert state.attributes[ATTR_HVAC_MODES] == [HVACMode.HEAT, HVACMode.OFF] assert state.attributes[ATTR_PRESET_MODE] is None assert state.attributes[ATTR_PRESET_MODES] == [ @@ -607,8 +601,6 @@ async def test_holidy_summer_mode( state = hass.states.get(ENTITY_ID) assert state - assert state.attributes[ATTR_STATE_HOLIDAY_MODE] - assert state.attributes[ATTR_STATE_SUMMER_MODE] is False assert state.attributes[ATTR_HVAC_MODES] == [HVACMode.HEAT, HVACMode.OFF] assert state.attributes[ATTR_PRESET_MODE] == PRESET_HOLIDAY assert state.attributes[ATTR_PRESET_MODES] == [PRESET_HOLIDAY] @@ -643,8 +635,6 @@ async def test_holidy_summer_mode( state = hass.states.get(ENTITY_ID) assert state - assert state.attributes[ATTR_STATE_HOLIDAY_MODE] is False - assert state.attributes[ATTR_STATE_SUMMER_MODE] assert state.attributes[ATTR_HVAC_MODES] == [HVACMode.HEAT, HVACMode.OFF] assert state.attributes[ATTR_PRESET_MODE] == PRESET_SUMMER assert state.attributes[ATTR_PRESET_MODES] == [PRESET_SUMMER] @@ -679,8 +669,6 @@ async def test_holidy_summer_mode( state = hass.states.get(ENTITY_ID) assert state - assert state.attributes[ATTR_STATE_HOLIDAY_MODE] is False - assert state.attributes[ATTR_STATE_SUMMER_MODE] is False assert state.attributes[ATTR_HVAC_MODES] == [HVACMode.HEAT, HVACMode.OFF] assert state.attributes[ATTR_PRESET_MODE] is None assert state.attributes[ATTR_PRESET_MODES] == [ diff --git a/tests/components/google_assistant_sdk/test_config_flow.py b/tests/components/google_assistant_sdk/test_config_flow.py index c911a2a084da97..dbecabdb35f1db 100644 --- a/tests/components/google_assistant_sdk/test_config_flow.py +++ b/tests/components/google_assistant_sdk/test_config_flow.py @@ -157,6 +157,62 @@ async def test_reauth( assert config_entry.data["token"].get("refresh_token") == "mock-refresh-token" +@pytest.mark.usefixtures("current_request_with_host") +async def test_reconfigure( + hass: HomeAssistant, + hass_client_no_auth: ClientSessionGenerator, + aioclient_mock: AiohttpClientMocker, + setup_credentials: None, + config_entry: MockConfigEntry, +) -> None: + """Test the reconfiguration flow updates the existing config entry.""" + config_entry.add_to_hass(hass) + + result = await config_entry.start_reconfigure_flow(hass) + + assert result["type"] is FlowResultType.EXTERNAL_STEP + + state = config_entry_oauth2_flow._encode_jwt( + hass, + { + "flow_id": result["flow_id"], + "redirect_uri": "https://example.com/auth/external/callback", + }, + ) + assert result["url"] == ( + f"{GOOGLE_AUTH_URI}?response_type=code&client_id={CLIENT_ID}" + "&redirect_uri=https://example.com/auth/external/callback" + f"&state={state}&scope=https://www.googleapis.com/auth/assistant-sdk-prototype" + "&access_type=offline&prompt=consent" + ) + client = await hass_client_no_auth() + resp = await client.get(f"/auth/external/callback?code=abcd&state={state}") + assert resp.status == 200 + assert resp.headers["content-type"] == "text/html; charset=utf-8" + + aioclient_mock.post( + GOOGLE_TOKEN_URI, + json={ + "refresh_token": "mock-refresh-token", + "access_token": "updated-access-token", + "type": "Bearer", + "expires_in": 60, + }, + ) + + result = await hass.config_entries.flow.async_configure(result["flow_id"]) + + assert result.get("type") is FlowResultType.ABORT + assert result.get("reason") == "reconfigure_successful" + + assert len(hass.config_entries.async_entries(DOMAIN)) == 1 + assert config_entry.unique_id is None + assert "token" in config_entry.data + # Verify access token is refreshed + assert config_entry.data["token"].get("access_token") == "updated-access-token" + assert config_entry.data["token"].get("refresh_token") == "mock-refresh-token" + + @pytest.mark.usefixtures("current_request_with_host") async def test_single_instance_allowed( hass: HomeAssistant, diff --git a/tests/components/kitchen_sink/snapshots/test_init.ambr b/tests/components/kitchen_sink/snapshots/test_init.ambr index c7161a3d284695..1d1769ba00c6c7 100644 --- a/tests/components/kitchen_sink/snapshots/test_init.ambr +++ b/tests/components/kitchen_sink/snapshots/test_init.ambr @@ -5,7 +5,9 @@ dict({ 'data': dict({ 'metadata_unit': 'm³', + 'metadata_unit_class': 'volume', 'state_unit': 'W', + 'state_unit_class': 'power', 'statistic_id': 'sensor.statistics_issues_issue_1', 'supported_unit': 'CCF, L, MCF, fl. oz., ft³, gal, mL, m³', }), @@ -16,7 +18,9 @@ dict({ 'data': dict({ 'metadata_unit': 'cats', + 'metadata_unit_class': None, 'state_unit': 'dogs', + 'state_unit_class': None, 'statistic_id': 'sensor.statistics_issues_issue_2', 'supported_unit': 'cats', }), @@ -33,7 +37,9 @@ dict({ 'data': dict({ 'metadata_unit': 'm³', + 'metadata_unit_class': 'volume', 'state_unit': 'W', + 'state_unit_class': 'power', 'statistic_id': 'sensor.statistics_issues_issue_3', 'supported_unit': 'CCF, L, MCF, fl. oz., ft³, gal, mL, m³', }), diff --git a/tests/components/mqtt/common.py b/tests/components/mqtt/common.py index 762cb98ad29dd1..dce45e207c3674 100644 --- a/tests/components/mqtt/common.py +++ b/tests/components/mqtt/common.py @@ -517,6 +517,20 @@ "entity_picture": "https://example.com/f9261f6feed443e7b7d5f3fbe2a47414", }, } +MOCK_SUBENTRY_SELECT_COMPONENT = { + "fa261f6feed443e7b7d5f3fbe2a47414": { + "platform": "select", + "name": "Mode", + "entity_category": None, + "command_topic": "test-topic", + "command_template": "{{ value }}", + "state_topic": "test-topic", + "options": ["beer", "milk"], + "value_template": "{{ value_json.value }}", + "retain": False, + "entity_picture": "https://example.com/fa261f6feed443e7b7d5f3fbe2a47414", + }, +} MOCK_SUBENTRY_SENSOR_COMPONENT = { "e9261f6feed443e7b7d5f3fbe2a47412": { "platform": "sensor", @@ -668,6 +682,10 @@ "device": MOCK_SUBENTRY_DEVICE_DATA | {"mqtt_settings": {"qos": 0}}, "components": MOCK_SUBENTRY_NUMBER_COMPONENT_NO_UNIT, } +MOCK_SELECT_SUBENTRY_DATA = { + "device": MOCK_SUBENTRY_DEVICE_DATA | {"mqtt_settings": {"qos": 0}}, + "components": MOCK_SUBENTRY_SELECT_COMPONENT, +} MOCK_SENSOR_SUBENTRY_DATA_SINGLE = { "device": MOCK_SUBENTRY_DEVICE_DATA | {"mqtt_settings": {"qos": 0}}, "components": MOCK_SUBENTRY_SENSOR_COMPONENT, diff --git a/tests/components/mqtt/test_config_flow.py b/tests/components/mqtt/test_config_flow.py index a0faef9c6995af..9dfc30091366f7 100644 --- a/tests/components/mqtt/test_config_flow.py +++ b/tests/components/mqtt/test_config_flow.py @@ -53,6 +53,7 @@ MOCK_NUMBER_SUBENTRY_DATA_CUSTOM_UNIT, MOCK_NUMBER_SUBENTRY_DATA_DEVICE_CLASS_UNIT, MOCK_NUMBER_SUBENTRY_DATA_NO_UNIT, + MOCK_SELECT_SUBENTRY_DATA, MOCK_SENSOR_SUBENTRY_DATA_SINGLE, MOCK_SENSOR_SUBENTRY_DATA_SINGLE_LAST_RESET_TEMPLATE, MOCK_SENSOR_SUBENTRY_DATA_SINGLE_STATE_CLASS, @@ -3553,6 +3554,24 @@ async def test_migrate_of_incompatible_config_entry( "Milk notifier Speed", id="number_no_unit", ), + pytest.param( + MOCK_SELECT_SUBENTRY_DATA, + {"name": "Milk notifier", "mqtt_settings": {"qos": 0}}, + {"name": "Mode"}, + {}, + (), + { + "command_topic": "test-topic", + "command_template": "{{ value }}", + "state_topic": "test-topic", + "options": ["beer", "milk"], + "value_template": "{{ value_json.value }}", + "retain": False, + }, + (), + "Milk notifier Mode", + id="select", + ), pytest.param( MOCK_SENSOR_SUBENTRY_DATA_SINGLE, {"name": "Milk notifier", "mqtt_settings": {"qos": 0}}, diff --git a/tests/components/nintendo_parental/__init__.py b/tests/components/nintendo_parental/__init__.py deleted file mode 100644 index 89853538f8e5ab..00000000000000 --- a/tests/components/nintendo_parental/__init__.py +++ /dev/null @@ -1 +0,0 @@ -"""Tests for the Nintendo Switch Parental Controls integration.""" diff --git a/tests/components/nintendo_parental/conftest.py b/tests/components/nintendo_parental/conftest.py deleted file mode 100644 index 7b930589b4b575..00000000000000 --- a/tests/components/nintendo_parental/conftest.py +++ /dev/null @@ -1,63 +0,0 @@ -"""Common fixtures for the Nintendo Switch Parental Controls tests.""" - -from collections.abc import Generator -from datetime import datetime -from unittest.mock import AsyncMock, MagicMock, patch - -import pytest - -from homeassistant.components.nintendo_parental.const import DOMAIN - -from .const import ACCOUNT_ID, API_TOKEN, LOGIN_URL - -from tests.common import MockConfigEntry - - -@pytest.fixture -def mock_config_entry() -> MockConfigEntry: - """Return a mock config entry.""" - return MockConfigEntry( - domain=DOMAIN, - data={"session_token": API_TOKEN}, - unique_id=ACCOUNT_ID, - ) - - -@pytest.fixture -def mock_nintendo_authenticator() -> Generator[MagicMock]: - """Mock Nintendo Authenticator.""" - with ( - patch( - "homeassistant.components.nintendo_parental.Authenticator", - autospec=True, - ) as mock_auth_class, - patch( - "homeassistant.components.nintendo_parental.config_flow.Authenticator", - new=mock_auth_class, - ), - patch( - "homeassistant.components.nintendo_parental.coordinator.NintendoParental.update", - return_value=None, - ), - ): - mock_auth = MagicMock() - mock_auth._id_token = API_TOKEN - mock_auth._at_expiry = datetime(2099, 12, 31, 23, 59, 59) - mock_auth.account_id = ACCOUNT_ID - mock_auth.login_url = LOGIN_URL - mock_auth.get_session_token = API_TOKEN - # Patch complete_login as an AsyncMock on both instance and class as this is a class method - mock_auth.complete_login = AsyncMock() - type(mock_auth).complete_login = mock_auth.complete_login - mock_auth_class.generate_login.return_value = mock_auth - yield mock_auth - - -@pytest.fixture -def mock_setup_entry() -> Generator[AsyncMock]: - """Override async_setup_entry.""" - with patch( - "homeassistant.components.nintendo_parental.async_setup_entry", - return_value=True, - ) as mock_setup_entry: - yield mock_setup_entry diff --git a/tests/components/nintendo_parental_controls/__init__.py b/tests/components/nintendo_parental_controls/__init__.py new file mode 100644 index 00000000000000..f99dc1e6618772 --- /dev/null +++ b/tests/components/nintendo_parental_controls/__init__.py @@ -0,0 +1,14 @@ +"""Tests for the Nintendo Switch parental controls integration.""" + +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry + + +async def setup_integration( + hass: HomeAssistant, mock_config_entry: MockConfigEntry +) -> None: + """Set up the integration for testing platforms.""" + mock_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() diff --git a/tests/components/nintendo_parental_controls/conftest.py b/tests/components/nintendo_parental_controls/conftest.py new file mode 100644 index 00000000000000..65ae46603f750b --- /dev/null +++ b/tests/components/nintendo_parental_controls/conftest.py @@ -0,0 +1,98 @@ +"""Common fixtures for the Nintendo Switch parental controls tests.""" + +from collections.abc import Generator +from datetime import datetime, time +from unittest.mock import AsyncMock, MagicMock, patch + +from pynintendoparental import NintendoParental +from pynintendoparental.device import Device +import pytest + +from homeassistant.components.nintendo_parental_controls.const import DOMAIN + +from .const import ACCOUNT_ID, API_TOKEN, LOGIN_URL + +from tests.common import MockConfigEntry + + +@pytest.fixture +def mock_config_entry() -> MockConfigEntry: + """Return a mock config entry.""" + return MockConfigEntry( + domain=DOMAIN, + data={"session_token": API_TOKEN}, + unique_id=ACCOUNT_ID, + ) + + +@pytest.fixture +def mock_nintendo_device() -> Device: + """Return a mocked device.""" + mock = AsyncMock(spec=Device) + mock.device_id = "testdevid" + mock.name = "Home Assistant Test" + mock.extra = {"firmwareVersion": {"displayedVersion": "99.99.99"}} + mock.limit_time = 120 + mock.today_playing_time = 110 + mock.bedtime_alarm = time(hour=19) + mock.set_bedtime_alarm.return_value = None + return mock + + +@pytest.fixture +def mock_nintendo_authenticator() -> Generator[MagicMock]: + """Mock Nintendo Authenticator.""" + with ( + patch( + "homeassistant.components.nintendo_parental_controls.Authenticator", + autospec=True, + ) as mock_auth_class, + patch( + "homeassistant.components.nintendo_parental_controls.config_flow.Authenticator", + new=mock_auth_class, + ), + patch( + "homeassistant.components.nintendo_parental_controls.coordinator.NintendoParental.update", + return_value=None, + ), + ): + mock_auth = MagicMock() + mock_auth._id_token = API_TOKEN + mock_auth._at_expiry = datetime(2099, 12, 31, 23, 59, 59) + mock_auth.account_id = ACCOUNT_ID + mock_auth.login_url = LOGIN_URL + mock_auth.get_session_token = API_TOKEN + # Patch complete_login as an AsyncMock on both instance and class as this is a class method + mock_auth.complete_login = AsyncMock() + type(mock_auth).complete_login = mock_auth.complete_login + mock_auth_class.generate_login.return_value = mock_auth + yield mock_auth + + +@pytest.fixture +def mock_nintendo_client( + mock_nintendo_device: Device, mock_nintendo_authenticator: MagicMock +) -> Generator[AsyncMock]: + """Mock a Nintendo client.""" + # Create a mock instance with our device(s) first + mock_client_instance = AsyncMock(spec=NintendoParental) + mock_client_instance.devices = {"testdevid": mock_nintendo_device} + # Now patch the NintendoParental class in the coordinator with our mock instance + with patch( + "homeassistant.components.nintendo_parental_controls.coordinator.NintendoParental", + autospec=True, + ) as mock_client_class: + mock_client_class.return_value = mock_client_instance + mock_client_instance.update.return_value = None + + yield mock_client_instance + + +@pytest.fixture +def mock_setup_entry() -> Generator[AsyncMock]: + """Override async_setup_entry.""" + with patch( + "homeassistant.components.nintendo_parental_controls.async_setup_entry", + return_value=True, + ) as mock_setup_entry: + yield mock_setup_entry diff --git a/tests/components/nintendo_parental/const.py b/tests/components/nintendo_parental_controls/const.py similarity index 59% rename from tests/components/nintendo_parental/const.py rename to tests/components/nintendo_parental_controls/const.py index 5d8e3f7b7134c5..39590305a7d6f9 100644 --- a/tests/components/nintendo_parental/const.py +++ b/tests/components/nintendo_parental_controls/const.py @@ -1,4 +1,4 @@ -"""Constants for the Nintendo Parental Controls test suite.""" +"""Constants for the Nintendo parental controls test suite.""" ACCOUNT_ID = "aabbccddee112233" API_TOKEN = "valid_token" diff --git a/tests/components/nintendo_parental_controls/snapshots/test_time.ambr b/tests/components/nintendo_parental_controls/snapshots/test_time.ambr new file mode 100644 index 00000000000000..cc642718d9bb05 --- /dev/null +++ b/tests/components/nintendo_parental_controls/snapshots/test_time.ambr @@ -0,0 +1,49 @@ +# serializer version: 1 +# name: test_time[time.home_assistant_test_bedtime_alarm-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'time', + 'entity_category': None, + 'entity_id': 'time.home_assistant_test_bedtime_alarm', + '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': 'Bedtime alarm', + 'platform': 'nintendo_parental_controls', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': , + 'unique_id': 'testdevid_bedtime_alarm', + 'unit_of_measurement': None, + }) +# --- +# name: test_time[time.home_assistant_test_bedtime_alarm-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Home Assistant Test Bedtime alarm', + }), + 'context': , + 'entity_id': 'time.home_assistant_test_bedtime_alarm', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '19:00:00', + }) +# --- diff --git a/tests/components/nintendo_parental/test_config_flow.py b/tests/components/nintendo_parental_controls/test_config_flow.py similarity index 61% rename from tests/components/nintendo_parental/test_config_flow.py rename to tests/components/nintendo_parental_controls/test_config_flow.py index 7cccf1bf3da98f..1b41a7746fdffa 100644 --- a/tests/components/nintendo_parental/test_config_flow.py +++ b/tests/components/nintendo_parental_controls/test_config_flow.py @@ -1,11 +1,14 @@ -"""Test the Nintendo Switch Parental Controls config flow.""" +"""Test the Nintendo Switch parental controls config flow.""" from unittest.mock import AsyncMock from pynintendoparental.exceptions import InvalidSessionTokenException from homeassistant import config_entries -from homeassistant.components.nintendo_parental.const import CONF_SESSION_TOKEN, DOMAIN +from homeassistant.components.nintendo_parental_controls.const import ( + CONF_SESSION_TOKEN, + DOMAIN, +) from homeassistant.const import CONF_API_TOKEN from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType @@ -99,3 +102,59 @@ async def test_invalid_auth( assert result["title"] == ACCOUNT_ID assert result["data"][CONF_SESSION_TOKEN] == API_TOKEN assert result["result"].unique_id == ACCOUNT_ID + + +async def test_reauthentication_success( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_nintendo_authenticator: AsyncMock, +) -> None: + """Test successful reauthentication.""" + mock_config_entry.add_to_hass(hass) + result = await mock_config_entry.start_reauth_flow(hass) + assert result["step_id"] == "reauth_confirm" + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {} + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={CONF_API_TOKEN: API_TOKEN} + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reauth_successful" + + +async def test_reauthentication_fail( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_nintendo_authenticator: AsyncMock, +) -> None: + """Test failed reauthentication.""" + mock_config_entry.add_to_hass(hass) + result = await mock_config_entry.start_reauth_flow(hass) + assert result["step_id"] == "reauth_confirm" + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {} + + # Simulate invalid authentication by raising an exception + mock_nintendo_authenticator.complete_login.side_effect = ( + InvalidSessionTokenException(status_code=401, message="Test") + ) + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={CONF_API_TOKEN: API_TOKEN} + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "reauth_confirm" + assert result["errors"] == {"base": "invalid_auth"} + + # Now ensure that the flow can be recovered + mock_nintendo_authenticator.complete_login.side_effect = None + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={CONF_API_TOKEN: API_TOKEN} + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reauth_successful" diff --git a/tests/components/nintendo_parental_controls/test_time.py b/tests/components/nintendo_parental_controls/test_time.py new file mode 100644 index 00000000000000..c0674e27ab417d --- /dev/null +++ b/tests/components/nintendo_parental_controls/test_time.py @@ -0,0 +1,85 @@ +"""Test the time platform.""" + +from unittest.mock import AsyncMock, patch + +from pynintendoparental.exceptions import BedtimeOutOfRangeError +import pytest +from syrupy.assertion import SnapshotAssertion + +from homeassistant.components.time import ( + ATTR_TIME, + DOMAIN as TIME_DOMAIN, + SERVICE_SET_VALUE, +) +from homeassistant.const import ATTR_ENTITY_ID, Platform +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ServiceValidationError +from homeassistant.helpers import entity_registry as er + +from . import setup_integration + +from tests.common import MockConfigEntry, snapshot_platform + + +async def test_time( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_nintendo_client: AsyncMock, + entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, +) -> None: + """Test time platform.""" + with patch( + "homeassistant.components.nintendo_parental_controls._PLATFORMS", + [Platform.TIME], + ): + await setup_integration(hass, mock_config_entry) + + await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) + + +async def test_set_time( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_nintendo_client: AsyncMock, + mock_nintendo_device: AsyncMock, +) -> None: + """Test time platform service validation errors.""" + with patch( + "homeassistant.components.nintendo_parental_controls._PLATFORMS", + [Platform.TIME], + ): + await setup_integration(hass, mock_config_entry) + await hass.services.async_call( + TIME_DOMAIN, + SERVICE_SET_VALUE, + service_data={ATTR_TIME: "20:00:00"}, + target={ATTR_ENTITY_ID: "time.home_assistant_test_bedtime_alarm"}, + blocking=True, + ) + assert len(mock_nintendo_device.set_bedtime_alarm.mock_calls) == 1 + + +async def test_set_time_service_exceptions( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_nintendo_client: AsyncMock, + mock_nintendo_device: AsyncMock, +) -> None: + """Test time platform service validation errors.""" + mock_nintendo_device.set_bedtime_alarm.side_effect = BedtimeOutOfRangeError(None) + with patch( + "homeassistant.components.nintendo_parental_controls._PLATFORMS", + [Platform.TIME], + ): + await setup_integration(hass, mock_config_entry) + with pytest.raises(ServiceValidationError) as err: + await hass.services.async_call( + TIME_DOMAIN, + SERVICE_SET_VALUE, + service_data={ATTR_TIME: "01:00:00"}, + target={ATTR_ENTITY_ID: "time.home_assistant_test_bedtime_alarm"}, + blocking=True, + ) + assert len(mock_nintendo_device.set_bedtime_alarm.mock_calls) == 1 + assert err.value.translation_key == "bedtime_alarm_out_of_range" diff --git a/tests/components/open_router/snapshots/test_ai_task.ambr b/tests/components/open_router/snapshots/test_ai_task.ambr index 0839f6fef9bf15..9d20deac600628 100644 --- a/tests/components/open_router/snapshots/test_ai_task.ambr +++ b/tests/components/open_router/snapshots/test_ai_task.ambr @@ -31,7 +31,7 @@ 'platform': 'open_router', 'previous_unique_id': None, 'suggested_object_id': None, - 'supported_features': , + 'supported_features': , 'translation_key': None, 'unique_id': 'ABCDEG', 'unit_of_measurement': None, @@ -41,7 +41,7 @@ StateSnapshot({ 'attributes': ReadOnlyDict({ 'friendly_name': 'Gemini 1.5 Pro', - 'supported_features': , + 'supported_features': , }), 'context': , 'entity_id': 'ai_task.gemini_1_5_pro', diff --git a/tests/components/open_router/test_ai_task.py b/tests/components/open_router/test_ai_task.py index 0b6c2933be7d46..ac7db878af04fb 100644 --- a/tests/components/open_router/test_ai_task.py +++ b/tests/components/open_router/test_ai_task.py @@ -1,5 +1,6 @@ """Test AI Task structured data generation.""" +from pathlib import Path from unittest.mock import AsyncMock, patch from openai.types import CompletionUsage @@ -9,7 +10,7 @@ from syrupy.assertion import SnapshotAssertion import voluptuous as vol -from homeassistant.components import ai_task +from homeassistant.components import ai_task, media_source from homeassistant.const import Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError @@ -208,3 +209,102 @@ async def test_generate_invalid_structured_data( }, ), ) + + +async def test_generate_data_with_attachments( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_openai_client: AsyncMock, +) -> None: + """Test AI Task data generation with attachments.""" + await setup_integration(hass, mock_config_entry) + + entity_id = "ai_task.gemini_1_5_pro" + + mock_openai_client.chat.completions.create = AsyncMock( + return_value=ChatCompletion( + id="chatcmpl-1234567890ABCDEFGHIJKLMNOPQRS", + choices=[ + Choice( + finish_reason="stop", + index=0, + message=ChatCompletionMessage( + content="Hi there!", + role="assistant", + function_call=None, + tool_calls=None, + ), + ) + ], + created=1700000000, + model="x-ai/grok-3", + object="chat.completion", + system_fingerprint=None, + usage=CompletionUsage( + completion_tokens=9, prompt_tokens=8, total_tokens=17 + ), + ) + ) + + # Test with attachments + with ( + patch( + "homeassistant.components.media_source.async_resolve_media", + side_effect=[ + media_source.PlayMedia( + url="http://example.com/doorbell_snapshot.jpg", + mime_type="image/jpeg", + path=Path("doorbell_snapshot.jpg"), + ), + media_source.PlayMedia( + url="http://example.com/context.pdf", + mime_type="application/pdf", + path=Path("context.pdf"), + ), + ], + ), + patch("pathlib.Path.exists", return_value=True), + patch( + "homeassistant.components.open_router.entity.guess_file_type", + return_value=("image/jpeg", None), + ), + patch("pathlib.Path.read_bytes", return_value=b"fake_image_data"), + ): + result = await ai_task.async_generate_data( + hass, + task_name="Test Task", + entity_id=entity_id, + instructions="Test prompt", + attachments=[ + {"media_content_id": "media-source://media/doorbell_snapshot.jpg"}, + {"media_content_id": "media-source://media/context.pdf"}, + ], + ) + + assert result.data == "Hi there!" + + # Verify that the create was called with the correct parameters + # The last call should have the user message with attachments + call_args = mock_openai_client.chat.completions.create.call_args + assert call_args is not None + + # Check that the input includes the attachments + input_messages = call_args[1]["messages"] + assert len(input_messages) > 0 + + # Find the user message with attachments + user_message_with_attachments = input_messages[-2] + + assert user_message_with_attachments is not None + assert len(user_message_with_attachments["content"]) == 3 # Text + attachments + assert user_message_with_attachments["content"] == [ + {"type": "text", "text": "Test prompt"}, + { + "type": "image_url", + "image_url": {"url": "data:image/jpeg;base64,ZmFrZV9pbWFnZV9kYXRh"}, + }, + { + "type": "image_url", + "image_url": {"url": "data:application/pdf;base64,ZmFrZV9pbWFnZV9kYXRh"}, + }, + ] diff --git a/tests/components/otbr/test_config_flow.py b/tests/components/otbr/test_config_flow.py index 1b6926a863c129..6df5681d9e1cd1 100644 --- a/tests/components/otbr/test_config_flow.py +++ b/tests/components/otbr/test_config_flow.py @@ -461,6 +461,10 @@ async def test_hassio_discovery_flow_yellow( "/dev/serial/by-id/usb-Nabu_Casa_Home_Assistant_Connect_ZBT-1_9e2adbd75b8beb119fe564a0f320645d-if00-port0", "Home Assistant Connect ZBT-1 (Silicon Labs Multiprotocol)", ), + ( + "/dev/serial/by-id/usb-Nabu_Casa_ZBT-2_10B41DE58A94-if00", + "Home Assistant Connect ZBT-2 (Silicon Labs Multiprotocol)", + ), ], ) @pytest.mark.usefixtures("get_border_agent_id") diff --git a/tests/components/pooldose/test_sensor.py b/tests/components/pooldose/test_sensor.py index 1c7c2ce1555780..00d557741c0f8f 100644 --- a/tests/components/pooldose/test_sensor.py +++ b/tests/components/pooldose/test_sensor.py @@ -13,7 +13,6 @@ from homeassistant.const import ( ATTR_UNIT_OF_MEASUREMENT, STATE_UNAVAILABLE, - STATE_UNKNOWN, Platform, UnitOfTemperature, ) @@ -156,37 +155,6 @@ async def test_sensor_entity_unavailable_no_coordinator_data( assert temp_state.state == STATE_UNAVAILABLE -async def test_sensor_entity_unavailable_missing_platform_data( - hass: HomeAssistant, - mock_config_entry: MockConfigEntry, - mock_pooldose_client: AsyncMock, - freezer: FrozenDateTimeFactory, -) -> None: - """Test sensor entity becomes unavailable when platform data is missing.""" - mock_config_entry.add_to_hass(hass) - - await hass.config_entries.async_setup(mock_config_entry.entry_id) - await hass.async_block_till_done() - - # Verify initial working state - temp_state = hass.states.get("sensor.pool_device_temperature") - assert temp_state.state == "25" - - # Remove sensor platform data by making API return data without sensors - mock_pooldose_client.instant_values_structured.return_value = ( - RequestStatus.SUCCESS, - {"other_platform": {}}, # No sensor data - ) - - freezer.tick(timedelta(minutes=10)) - async_fire_time_changed(hass) - await hass.async_block_till_done() - - # Check sensor becomes unavailable - temp_state = hass.states.get("sensor.pool_device_temperature") - assert temp_state.state == STATE_UNAVAILABLE - - @pytest.mark.usefixtures("mock_pooldose_client") async def test_temperature_sensor_dynamic_unit( hass: HomeAssistant, @@ -224,33 +192,3 @@ async def test_temperature_sensor_dynamic_unit( # After reload, the original fixture data is restored, so we expect °C assert temp_state.attributes["unit_of_measurement"] == UnitOfTemperature.CELSIUS assert temp_state.state == "25.0" # Original fixture value - - -async def test_native_value_with_non_dict_data( - hass: HomeAssistant, - mock_config_entry: MockConfigEntry, - mock_pooldose_client: AsyncMock, - freezer: FrozenDateTimeFactory, -) -> None: - """Test native_value returns None when data is not a dict.""" - mock_config_entry.add_to_hass(hass) - await hass.config_entries.async_setup(mock_config_entry.entry_id) - await hass.async_block_till_done() - - # Mock get_data to return non-dict value - instant_values_raw = await async_load_fixture(hass, "instantvalues.json", DOMAIN) - malformed_data = json.loads(instant_values_raw) - malformed_data["sensor"]["temperature"] = "not_a_dict" - - mock_pooldose_client.instant_values_structured.return_value = ( - RequestStatus.SUCCESS, - malformed_data, - ) - - freezer.tick(timedelta(minutes=10)) - async_fire_time_changed(hass) - await hass.async_block_till_done() - - # Should handle non-dict data gracefully - temp_state = hass.states.get("sensor.pool_device_temperature") - assert temp_state.state == STATE_UNKNOWN diff --git a/tests/components/portainer/fixtures/endpoints.json b/tests/components/portainer/fixtures/endpoints.json index 95e728a4ac32a9..03d9e63c36b30b 100644 --- a/tests/components/portainer/fixtures/endpoints.json +++ b/tests/components/portainer/fixtures/endpoints.json @@ -191,5 +191,196 @@ "allowVolumeBrowserForRegularUsers": true, "enableHostManagementFeatures": true } + }, + { + "AMTDeviceGUID": "4c4c4544-004b-3910-8037-b6c04f504633", + "AuthorizedTeams": [1], + "AuthorizedUsers": [1], + "AzureCredentials": { + "ApplicationID": "eag7cdo9-o09l-9i83-9dO9-f0b23oe78db4", + "AuthenticationKey": "cOrXoK/1D35w8YQ8nH1/8ZGwzz45JIYD5jxHKXEQknk=", + "TenantID": "34ddc78d-4fel-2358-8cc1-df84c8o839f5" + }, + "ComposeSyntaxMaxVersion": "3.5", + "ContainerEngine": "podman", + "EdgeCheckinInterval": 90, + "EdgeID": "edge-offline-01", + "EdgeKey": "f3a6d201-6d6f-4f37-ae6b-0fcbefd3d1b9", + "EnableGPUManagement": false, + "Gpus": [], + "GroupId": 3, + "Heartbeat": false, + "Id": 42, + "IsEdgeDevice": false, + "Kubernetes": { + "Configuration": { + "AllowNoneIngressClass": false, + "EnableResourceOverCommit": false, + "IngressAvailabilityPerNamespace": false, + "IngressClasses": [ + { + "Blocked": false, + "BlockedNamespaces": [], + "Name": "public", + "Type": "nginx" + } + ], + "ResourceOverCommitPercentage": 25, + "RestrictDefaultNamespace": false, + "StorageClasses": [ + { + "AccessModes": ["ReadWriteOnce"], + "AllowVolumeExpansion": false, + "Name": "standard", + "Provisioner": "kubernetes.io/no-provisioner" + } + ], + "UseLoadBalancer": false, + "UseServerMetrics": false + }, + "Flags": { + "IsServerIngressClassDetected": false, + "IsServerMetricsDetected": false, + "IsServerStorageDetected": true + }, + "Snapshots": [ + { + "DiagnosticsData": { + "DNS": { + "additionalProp1": "string", + "additionalProp2": "string", + "additionalProp3": "string" + }, + "Log": "string", + "Proxy": { + "additionalProp1": "string", + "additionalProp2": "string", + "additionalProp3": "string" + }, + "Telnet": { + "additionalProp1": "string", + "additionalProp2": "string", + "additionalProp3": "string" + } + }, + "KubernetesVersion": "1.28.3", + "NodeCount": 2, + "Time": 1726512000, + "TotalCPU": 6, + "TotalMemory": 16384 + } + ] + }, + "Name": "my-edge-offline", + "PostInitMigrations": { + "MigrateGPUs": true, + "MigrateIngresses": true + }, + "PublicURL": "podman-offline.mydomain.tld:2375", + "Snapshots": [ + { + "ContainerCount": 7, + "DiagnosticsData": { + "DNS": { + "additionalProp1": "string", + "additionalProp2": "string", + "additionalProp3": "string" + }, + "Log": "string", + "Proxy": { + "additionalProp1": "string", + "additionalProp2": "string", + "additionalProp3": "string" + }, + "Telnet": { + "additionalProp1": "string", + "additionalProp2": "string", + "additionalProp3": "string" + } + }, + "DockerSnapshotRaw": { + "LayerCount": 12, + "SizeBytes": 482344960 + }, + "DockerVersion": "25.0.1", + "GpuUseAll": false, + "GpuUseList": [], + "HealthyContainerCount": 4, + "ImageCount": 18, + "IsPodman": false, + "NodeCount": 1, + "RunningContainerCount": 3, + "ServiceCount": 4, + "StackCount": 2, + "StoppedContainerCount": 4, + "Swarm": false, + "Time": 1726604800, + "TotalCPU": 4, + "TotalMemory": 8192, + "UnhealthyContainerCount": 1, + "VolumeCount": 6 + } + ], + "Status": 2, + "TLS": true, + "TLSCACert": "string", + "TLSCert": "string", + "TLSConfig": { + "TLS": true, + "TLSCACert": "/data/tls/ca.pem", + "TLSCert": "/data/tls/cert.pem", + "TLSKey": "/data/tls/key.pem", + "TLSSkipVerify": false + }, + "TLSKey": "string", + "TagIds": [1], + "Tags": ["string"], + "TeamAccessPolicies": { + "additionalProp1": { + "RoleId": 1 + }, + "additionalProp2": { + "RoleId": 1 + }, + "additionalProp3": { + "RoleId": 1 + } + }, + "Type": 2, + "URL": "podman-offline.mydomain.tld:2375", + "UserAccessPolicies": { + "additionalProp1": { + "RoleId": 1 + }, + "additionalProp2": { + "RoleId": 1 + }, + "additionalProp3": { + "RoleId": 1 + } + }, + "UserTrusted": true, + "agent": { + "version": "2.1.4" + }, + "edge": { + "CommandInterval": 300, + "PingInterval": 180, + "SnapshotInterval": 900, + "asyncMode": false + }, + "lastCheckInDate": 1726601100, + "queryDate": 1726604700, + "securitySettings": { + "allowBindMountsForRegularUsers": false, + "allowContainerCapabilitiesForRegularUsers": false, + "allowDeviceMappingForRegularUsers": false, + "allowHostNamespaceForRegularUsers": false, + "allowPrivilegedModeForRegularUsers": false, + "allowStackManagementForRegularUsers": false, + "allowSysctlSettingForRegularUsers": false, + "allowVolumeBrowserForRegularUsers": false, + "enableHostManagementFeatures": false + } } ] diff --git a/tests/components/prowl/conftest.py b/tests/components/prowl/conftest.py index 874d6e36a3b022..9374115bdd6e4b 100644 --- a/tests/components/prowl/conftest.py +++ b/tests/components/prowl/conftest.py @@ -7,10 +7,22 @@ from homeassistant.components.notify import DOMAIN as NOTIFY_DOMAIN from homeassistant.components.prowl.const import DOMAIN +from homeassistant.const import CONF_API_KEY, CONF_NAME from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component +from tests.common import MockConfigEntry + +TEST_NAME = "TestProwl" +TEST_SERVICE = TEST_NAME.lower() +ENTITY_ID = f"{NOTIFY_DOMAIN}.{TEST_SERVICE}" TEST_API_KEY = "f00f" * 10 +OTHER_API_KEY = "beef" * 10 +CONF_INPUT = {CONF_API_KEY: TEST_API_KEY, CONF_NAME: TEST_NAME} +CONF_INPUT_NEW_KEY = {CONF_API_KEY: OTHER_API_KEY} +INVALID_API_KEY_ERROR = {"base": "invalid_api_key"} +TIMEOUT_ERROR = {"base": "api_timeout"} +BAD_API_RESPONSE = {"base": "bad_api_response"} @pytest.fixture @@ -34,6 +46,20 @@ async def configure_prowl_through_yaml( await hass.async_block_till_done() +@pytest.fixture +async def prowl_notification_entity( + hass: HomeAssistant, mock_prowlpy: Mock, mock_prowlpy_config_entry: MockConfigEntry +) -> Generator[MockConfigEntry]: + """Configure a Prowl Notification Entity.""" + mock_prowlpy.verify_key.return_value = True + + mock_prowlpy_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_prowlpy_config_entry.entry_id) + await hass.async_block_till_done() + + return mock_prowlpy_config_entry + + @pytest.fixture def mock_prowlpy() -> Generator[Mock]: """Mock the prowlpy library.""" @@ -41,3 +67,11 @@ def mock_prowlpy() -> Generator[Mock]: with patch("homeassistant.components.prowl.notify.prowlpy.Prowl") as MockProwl: mock_instance = MockProwl.return_value yield mock_instance + + +@pytest.fixture +async def mock_prowlpy_config_entry(hass: HomeAssistant) -> MockConfigEntry: + """Fixture to create a mocked ConfigEntry.""" + return MockConfigEntry( + title=TEST_NAME, domain=DOMAIN, data={CONF_API_KEY: TEST_API_KEY} + ) diff --git a/tests/components/prowl/test_config_flow.py b/tests/components/prowl/test_config_flow.py new file mode 100644 index 00000000000000..72fa0380622161 --- /dev/null +++ b/tests/components/prowl/test_config_flow.py @@ -0,0 +1,106 @@ +"""Test Prowl config flow.""" + +from unittest.mock import Mock + +import prowlpy + +from homeassistant import config_entries +from homeassistant.components.prowl.const import DOMAIN +from homeassistant.const import CONF_API_KEY, CONF_NAME +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType + +from .conftest import BAD_API_RESPONSE, CONF_INPUT, INVALID_API_KEY_ERROR, TIMEOUT_ERROR + + +async def test_flow_user(hass: HomeAssistant, mock_prowlpy: Mock) -> None: + """Test user initialized flow.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_USER}, + ) + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input=CONF_INPUT, + ) + + assert mock_prowlpy.verify_key.call_count > 0 + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == CONF_INPUT[CONF_NAME] + assert result["data"] == {CONF_API_KEY: CONF_INPUT[CONF_API_KEY]} + + +async def test_flow_duplicate_api_key(hass: HomeAssistant, mock_prowlpy: Mock) -> None: + """Test user initialized flow.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_USER}, + ) + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input=CONF_INPUT, + ) + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_USER}, + ) + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input=CONF_INPUT, + ) + assert result["type"] is FlowResultType.ABORT + + +async def test_flow_user_bad_key(hass: HomeAssistant, mock_prowlpy: Mock) -> None: + """Test user submitting a bad API key.""" + mock_prowlpy.verify_key.side_effect = prowlpy.APIError("Invalid API key") + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_USER}, + ) + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input=CONF_INPUT, + ) + + assert mock_prowlpy.verify_key.call_count > 0 + assert result["type"] is FlowResultType.FORM + assert result["errors"] == INVALID_API_KEY_ERROR + + +async def test_flow_user_prowl_timeout(hass: HomeAssistant, mock_prowlpy: Mock) -> None: + """Test Prowl API timeout.""" + mock_prowlpy.verify_key.side_effect = TimeoutError + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_USER}, + ) + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input=CONF_INPUT, + ) + + assert mock_prowlpy.verify_key.call_count > 0 + assert result["type"] is FlowResultType.FORM + assert result["errors"] == TIMEOUT_ERROR + + +async def test_flow_api_failure(hass: HomeAssistant, mock_prowlpy: Mock) -> None: + """Test Prowl API failure.""" + mock_prowlpy.verify_key.side_effect = prowlpy.APIError(BAD_API_RESPONSE) + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_USER}, + ) + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input=CONF_INPUT, + ) + + assert mock_prowlpy.verify_key.call_count > 0 + assert result["type"] is FlowResultType.FORM + assert result["errors"] == BAD_API_RESPONSE diff --git a/tests/components/prowl/test_init.py b/tests/components/prowl/test_init.py new file mode 100644 index 00000000000000..67c38423581de2 --- /dev/null +++ b/tests/components/prowl/test_init.py @@ -0,0 +1,91 @@ +"""Testing the Prowl initialisation.""" + +from unittest.mock import Mock + +import prowlpy +import pytest + +from homeassistant.components import notify +from homeassistant.components.prowl.const import DOMAIN +from homeassistant.config_entries import ConfigEntryState +from homeassistant.core import HomeAssistant + +from .conftest import ENTITY_ID, TEST_API_KEY + +from tests.common import MockConfigEntry + + +async def test_load_reload_unload_config_entry( + hass: HomeAssistant, + mock_prowlpy_config_entry: MockConfigEntry, + mock_prowlpy: Mock, +) -> None: + """Test the Prowl configuration entry loading/reloading/unloading.""" + mock_prowlpy_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_prowlpy_config_entry.entry_id) + await hass.async_block_till_done() + + assert mock_prowlpy_config_entry.state is ConfigEntryState.LOADED + assert mock_prowlpy.verify_key.call_count > 0 + + await hass.config_entries.async_reload(mock_prowlpy_config_entry.entry_id) + await hass.async_block_till_done() + assert mock_prowlpy_config_entry.state is ConfigEntryState.LOADED + + await hass.config_entries.async_unload(mock_prowlpy_config_entry.entry_id) + await hass.async_block_till_done() + + assert not hass.data.get(DOMAIN) + assert mock_prowlpy_config_entry.state is ConfigEntryState.NOT_LOADED + + +@pytest.mark.parametrize( + ("prowlpy_side_effect", "expected_config_state"), + [ + (TimeoutError, ConfigEntryState.SETUP_RETRY), + ( + prowlpy.APIError(f"Invalid API key: {TEST_API_KEY}"), + ConfigEntryState.SETUP_ERROR, + ), + ( + prowlpy.APIError("Not accepted: exceeded rate limit"), + ConfigEntryState.SETUP_RETRY, + ), + (prowlpy.APIError("Internal server error"), ConfigEntryState.SETUP_ERROR), + ], +) +async def test_config_entry_failures( + hass: HomeAssistant, + mock_prowlpy_config_entry: MockConfigEntry, + mock_prowlpy: Mock, + prowlpy_side_effect, + expected_config_state: ConfigEntryState, +) -> None: + """Test the Prowl configuration entry dealing with bad API key.""" + mock_prowlpy.verify_key.side_effect = prowlpy_side_effect + + mock_prowlpy_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_prowlpy_config_entry.entry_id) + await hass.async_block_till_done() + + assert mock_prowlpy_config_entry.state is expected_config_state + assert mock_prowlpy.verify_key.call_count > 0 + + +@pytest.mark.usefixtures("configure_prowl_through_yaml") +async def test_both_yaml_and_config_entry( + hass: HomeAssistant, + mock_prowlpy_config_entry: MockConfigEntry, +) -> None: + """Test having both YAML config and a config entry works.""" + mock_prowlpy_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_prowlpy_config_entry.entry_id) + await hass.async_block_till_done() + + assert mock_prowlpy_config_entry.state is ConfigEntryState.LOADED + + # Ensure we have the YAML entity service + assert hass.services.has_service(notify.DOMAIN, DOMAIN) + + # Ensure we have the config entry entity service + assert hass.states.get(ENTITY_ID) is not None diff --git a/tests/components/prowl/test_notify.py b/tests/components/prowl/test_notify.py index 8047ed177e669a..231d067b180054 100644 --- a/tests/components/prowl/test_notify.py +++ b/tests/components/prowl/test_notify.py @@ -6,12 +6,14 @@ import prowlpy import pytest -from homeassistant.components.notify import DOMAIN as NOTIFY_DOMAIN +from homeassistant.components import notify from homeassistant.components.prowl.const import DOMAIN from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError -from .conftest import TEST_API_KEY +from .conftest import ENTITY_ID, TEST_API_KEY + +from tests.common import MockConfigEntry SERVICE_DATA = {"message": "Test Notification", "title": "Test Title"} @@ -30,9 +32,9 @@ async def test_send_notification_service( mock_prowlpy: Mock, ) -> None: """Set up Prowl, call notify service, and check API call.""" - assert hass.services.has_service(NOTIFY_DOMAIN, DOMAIN) + assert hass.services.has_service(notify.DOMAIN, DOMAIN) await hass.services.async_call( - NOTIFY_DOMAIN, + notify.DOMAIN, DOMAIN, SERVICE_DATA, blocking=True, @@ -41,6 +43,94 @@ async def test_send_notification_service( mock_prowlpy.send.assert_called_once_with(**EXPECTED_SEND_PARAMETERS) +async def test_send_notification_entity_service( + hass: HomeAssistant, + mock_prowlpy: Mock, + mock_prowlpy_config_entry: MockConfigEntry, +) -> None: + """Set up Prowl via config entry, call notify service, and check API call.""" + mock_prowlpy_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_prowlpy_config_entry.entry_id) + await hass.async_block_till_done() + + assert hass.services.has_service(notify.DOMAIN, notify.SERVICE_SEND_MESSAGE) + await hass.services.async_call( + notify.DOMAIN, + notify.SERVICE_SEND_MESSAGE, + { + "entity_id": ENTITY_ID, + notify.ATTR_MESSAGE: SERVICE_DATA["message"], + notify.ATTR_TITLE: SERVICE_DATA["title"], + }, + blocking=True, + ) + + mock_prowlpy.send.assert_called_once_with(**EXPECTED_SEND_PARAMETERS) + + +@pytest.mark.parametrize( + ("prowlpy_side_effect", "raised_exception", "exception_message"), + [ + ( + prowlpy.APIError("Internal server error"), + HomeAssistantError, + "Unexpected error when calling Prowl API", + ), + ( + TimeoutError, + HomeAssistantError, + "Timeout accessing Prowl API", + ), + ( + prowlpy.APIError(f"Invalid API key: {TEST_API_KEY}"), + HomeAssistantError, + "Invalid API key for Prowl service", + ), + ( + prowlpy.APIError( + "Not accepted: Your IP address has exceeded the API limit" + ), + HomeAssistantError, + "Prowl service reported: exceeded rate limit", + ), + ( + SyntaxError(), + SyntaxError, + None, + ), + ], +) +async def test_fail_send_notification_entity_service( + hass: HomeAssistant, + mock_prowlpy: Mock, + mock_prowlpy_config_entry: MockConfigEntry, + prowlpy_side_effect: Exception, + raised_exception: type[Exception], + exception_message: str | None, +) -> None: + """Set up Prowl via config entry, call notify service, and check API call.""" + mock_prowlpy_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_prowlpy_config_entry.entry_id) + await hass.async_block_till_done() + + mock_prowlpy.send.side_effect = prowlpy_side_effect + + assert hass.services.has_service(notify.DOMAIN, notify.SERVICE_SEND_MESSAGE) + with pytest.raises(raised_exception, match=exception_message): + await hass.services.async_call( + notify.DOMAIN, + notify.SERVICE_SEND_MESSAGE, + { + "entity_id": ENTITY_ID, + notify.ATTR_MESSAGE: SERVICE_DATA["message"], + notify.ATTR_TITLE: SERVICE_DATA["title"], + }, + blocking=True, + ) + + mock_prowlpy.send.assert_called_once_with(**EXPECTED_SEND_PARAMETERS) + + @pytest.mark.parametrize( ("prowlpy_side_effect", "raised_exception", "exception_message"), [ @@ -84,10 +174,10 @@ async def test_fail_send_notification( """Sending a message via Prowl with a failure.""" mock_prowlpy.send.side_effect = prowlpy_side_effect - assert hass.services.has_service(NOTIFY_DOMAIN, DOMAIN) + assert hass.services.has_service(notify.DOMAIN, DOMAIN) with pytest.raises(raised_exception, match=exception_message): await hass.services.async_call( - NOTIFY_DOMAIN, + notify.DOMAIN, DOMAIN, SERVICE_DATA, blocking=True, @@ -121,13 +211,13 @@ async def test_other_exception_send_notification( """Sending a message via Prowl with a general unhandled exception.""" mock_prowlpy.send.side_effect = SyntaxError - assert hass.services.has_service(NOTIFY_DOMAIN, DOMAIN) + assert hass.services.has_service(notify.DOMAIN, DOMAIN) with pytest.raises(SyntaxError): await hass.services.async_call( - NOTIFY_DOMAIN, + notify.DOMAIN, DOMAIN, SERVICE_DATA, blocking=True, ) - mock_prowlpy.send.assert_called_once_with(**EXPECTED_SEND_PARAMETERS) + mock_prowlpy.send.assert_called_once_with(**expected_send_parameters) diff --git a/tests/components/recorder/test_statistics.py b/tests/components/recorder/test_statistics.py index 8468865d058a34..303780eacc339d 100644 --- a/tests/components/recorder/test_statistics.py +++ b/tests/components/recorder/test_statistics.py @@ -20,6 +20,8 @@ process_timestamp, ) from homeassistant.components.recorder.statistics import ( + _PRIMARY_UNIT_CONVERTERS, + _SECONDARY_UNIT_CONVERTERS, STATISTIC_UNIT_TO_UNIT_CONVERTER, PlatformCompiledStatistics, _generate_max_mean_min_statistic_in_sub_period_stmt, @@ -63,7 +65,6 @@ from tests.common import MockPlatform, MockUser, mock_platform from tests.typing import RecorderInstanceContextManager, WebSocketGenerator -from tests.util.test_unit_conversion import _ALL_CONVERTERS POWER_SENSOR_KW_ATTRIBUTES = { "device_class": "power", @@ -114,7 +115,13 @@ async def _setup_mock_domain( def test_converters_align_with_sensor() -> None: """Ensure STATISTIC_UNIT_TO_UNIT_CONVERTER is aligned with UNIT_CONVERTERS.""" for converter in UNIT_CONVERTERS.values(): - assert converter in STATISTIC_UNIT_TO_UNIT_CONVERTER.values() + assert ( + converter in STATISTIC_UNIT_TO_UNIT_CONVERTER.values() + or converter in _SECONDARY_UNIT_CONVERTERS + ) + + for converter in _SECONDARY_UNIT_CONVERTERS: + assert converter not in STATISTIC_UNIT_TO_UNIT_CONVERTER.values() for converter in STATISTIC_UNIT_TO_UNIT_CONVERTER.values(): assert converter in UNIT_CONVERTERS.values() @@ -3998,7 +4005,7 @@ def test_STATISTIC_UNIT_TO_UNIT_CONVERTER(uom: str) -> None: if other := next( ( c - for c in _ALL_CONVERTERS + for c in _PRIMARY_UNIT_CONVERTERS if unit_converter is not c and uom in c.VALID_UNITS ), None, diff --git a/tests/components/renault/fixtures/vehicle_zoe_40.json b/tests/components/renault/fixtures/vehicle_zoe_40.json index ea7faf4e109714..c7bba4ccccc681 100644 --- a/tests/components/renault/fixtures/vehicle_zoe_40.json +++ b/tests/components/renault/fixtures/vehicle_zoe_40.json @@ -184,6 +184,18 @@ "engineEnergyType": "ELEC", "radioCode": "1234" } + }, + { + "brand": "", + "vin": "VYSP0100875XXXXXX", + "status": "ACTIVE", + "linkType": "USER", + "garageBrand": "renault", + "startDate": "2025-09-12", + "createdDate": "2025-09-12T15:06:02.726341Z", + "lastModifiedDate": "2025-09-12T15:09:46.727038Z", + "cancellationReason": {}, + "preferredDealer": {} } ] } diff --git a/tests/components/renault/test_config_flow.py b/tests/components/renault/test_config_flow.py index 94422ab0e2a977..7d1200466e6041 100644 --- a/tests/components/renault/test_config_flow.py +++ b/tests/components/renault/test_config_flow.py @@ -162,6 +162,9 @@ async def test_config_flow_multiple_accounts( "account_id_2", websession=aiohttp_client.async_get_clientsession(hass), ) + renault_vehicles = schemas.KamereonVehiclesResponseSchema.loads( + await async_load_fixture(hass, "renault/vehicle_zoe_40.json") + ) # Multiple accounts with ( @@ -170,7 +173,10 @@ async def test_config_flow_multiple_accounts( "renault_api.renault_client.RenaultClient.get_api_accounts", return_value=[renault_account_1, renault_account_2], ), - patch("renault_api.renault_account.RenaultAccount.get_vehicles"), + patch( + "renault_api.renault_account.RenaultAccount.get_vehicles", + return_value=renault_vehicles, + ), ): result = await hass.config_entries.flow.async_configure( result["flow_id"], @@ -217,13 +223,19 @@ async def test_config_flow_duplicate( "account_id_1", websession=aiohttp_client.async_get_clientsession(hass), ) + renault_vehicles = schemas.KamereonVehiclesResponseSchema.loads( + await async_load_fixture(hass, "renault/vehicle_zoe_50.json") + ) with ( patch("renault_api.renault_session.RenaultSession.login"), patch( "renault_api.renault_client.RenaultClient.get_api_accounts", return_value=[renault_account], ), - patch("renault_api.renault_account.RenaultAccount.get_vehicles"), + patch( + "renault_api.renault_account.RenaultAccount.get_vehicles", + return_value=renault_vehicles, + ), ): result = await hass.config_entries.flow.async_configure( result["flow_id"], diff --git a/tests/components/reolink/test_switch.py b/tests/components/reolink/test_switch.py index 83840cace97762..a8354b32b2260e 100644 --- a/tests/components/reolink/test_switch.py +++ b/tests/components/reolink/test_switch.py @@ -226,3 +226,68 @@ async def test_chime_switch( {ATTR_ENTITY_ID: entity_id}, blocking=True, ) + + +async def test_rule_switch( + hass: HomeAssistant, + config_entry: MockConfigEntry, + freezer: FrozenDateTimeFactory, + reolink_host: MagicMock, +) -> None: + """Test reolink switch entity with extra index.""" + reolink_host.baichuan.rule_ids.return_value = [1] + reolink_host.baichuan.rule_name.return_value = "Test" + reolink_host.baichuan.rule_enabled.return_value = True + + with patch("homeassistant.components.reolink.PLATFORMS", [Platform.SWITCH]): + assert await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + assert config_entry.state is ConfigEntryState.LOADED + + entity_id = f"{Platform.SWITCH}.{TEST_CAM_NAME}_surveillance_rule_test" + assert hass.states.get(entity_id).state == STATE_ON + + reolink_host.baichuan.rule_enabled.return_value = False + freezer.tick(DEVICE_UPDATE_INTERVAL) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + assert hass.states.get(entity_id).state == STATE_OFF + + # test switch turn on + reolink_host.baichuan.set_rule_enabled = AsyncMock() + await hass.services.async_call( + SWITCH_DOMAIN, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: entity_id}, + blocking=True, + ) + reolink_host.baichuan.set_rule_enabled.assert_called_with(0, 1, True) + + reolink_host.baichuan.set_rule_enabled.side_effect = ReolinkError("Test error") + with pytest.raises(HomeAssistantError): + await hass.services.async_call( + SWITCH_DOMAIN, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: entity_id}, + blocking=True, + ) + + # test switch turn off + reolink_host.baichuan.set_rule_enabled.reset_mock(side_effect=True) + await hass.services.async_call( + SWITCH_DOMAIN, + SERVICE_TURN_OFF, + {ATTR_ENTITY_ID: entity_id}, + blocking=True, + ) + reolink_host.baichuan.set_rule_enabled.assert_called_with(0, 1, False) + + reolink_host.baichuan.set_rule_enabled.side_effect = ReolinkError("Test error") + with pytest.raises(HomeAssistantError): + await hass.services.async_call( + SWITCH_DOMAIN, + SERVICE_TURN_OFF, + {ATTR_ENTITY_ID: entity_id}, + blocking=True, + ) diff --git a/tests/components/satel_integra/__init__.py b/tests/components/satel_integra/__init__.py index 97b8b4be49385f..f339aa4e424f6f 100644 --- a/tests/components/satel_integra/__init__.py +++ b/tests/components/satel_integra/__init__.py @@ -24,7 +24,7 @@ subentry_type=SUBENTRY_TYPE_PARTITION, subentry_id="ID_PARTITION", unique_id="partition_1", - title="Home", + title="Home (1)", data={ CONF_NAME: "Home", CONF_ARM_HOME_MODE: 1, @@ -36,9 +36,9 @@ subentry_type=SUBENTRY_TYPE_ZONE, subentry_id="ID_ZONE", unique_id="zone_1", - title="Zone 1", + title="Zone (1)", data={ - CONF_NAME: "Zone 1", + CONF_NAME: "Zone", CONF_ZONE_TYPE: BinarySensorDeviceClass.MOTION, CONF_ZONE_NUMBER: 1, }, @@ -48,9 +48,9 @@ subentry_type=SUBENTRY_TYPE_OUTPUT, subentry_id="ID_OUTPUT", unique_id="output_1", - title="Output 1", + title="Output (1)", data={ - CONF_NAME: "Output 1", + CONF_NAME: "Output", CONF_ZONE_TYPE: BinarySensorDeviceClass.SAFETY, CONF_OUTPUT_NUMBER: 1, }, @@ -60,9 +60,9 @@ subentry_type=SUBENTRY_TYPE_SWITCHABLE_OUTPUT, subentry_id="ID_SWITCHABLE_OUTPUT", unique_id="switchable_output_1", - title="Switchable Output 1", + title="Switchable Output (1)", data={ - CONF_NAME: "Switchable Output 1", + CONF_NAME: "Switchable Output", CONF_SWITCHABLE_OUTPUT_NUMBER: 1, }, ) diff --git a/tests/components/satel_integra/conftest.py b/tests/components/satel_integra/conftest.py index a468ecd18d8b2c..ca3c34dd2c98ba 100644 --- a/tests/components/satel_integra/conftest.py +++ b/tests/components/satel_integra/conftest.py @@ -59,6 +59,8 @@ def mock_config_entry() -> MockConfigEntry: data=MOCK_CONFIG_DATA, options=MOCK_CONFIG_OPTIONS, entry_id="SATEL_INTEGRA_CONFIG_ENTRY_1", + version=1, + minor_version=2, ) diff --git a/tests/components/satel_integra/snapshots/test_diagnostics.ambr b/tests/components/satel_integra/snapshots/test_diagnostics.ambr index b6c99772c80040..4668191ec0fc04 100644 --- a/tests/components/satel_integra/snapshots/test_diagnostics.ambr +++ b/tests/components/satel_integra/snapshots/test_diagnostics.ambr @@ -11,13 +11,13 @@ 'subentries': dict({ 'ID_OUTPUT': dict({ 'data': dict({ - 'name': 'Output 1', + 'name': 'Output', 'output_number': 1, 'type': 'safety', }), 'subentry_id': 'ID_OUTPUT', 'subentry_type': 'output', - 'title': 'Output 1', + 'title': 'Output (1)', 'unique_id': 'output_1', }), 'ID_PARTITION': dict({ @@ -28,28 +28,28 @@ }), 'subentry_id': 'ID_PARTITION', 'subentry_type': 'partition', - 'title': 'Home', + 'title': 'Home (1)', 'unique_id': 'partition_1', }), 'ID_SWITCHABLE_OUTPUT': dict({ 'data': dict({ - 'name': 'Switchable Output 1', + 'name': 'Switchable Output', 'switchable_output_number': 1, }), 'subentry_id': 'ID_SWITCHABLE_OUTPUT', 'subentry_type': 'switchable_output', - 'title': 'Switchable Output 1', + 'title': 'Switchable Output (1)', 'unique_id': 'switchable_output_1', }), 'ID_ZONE': dict({ 'data': dict({ - 'name': 'Zone 1', + 'name': 'Zone', 'type': 'motion', 'zone_number': 1, }), 'subentry_id': 'ID_ZONE', 'subentry_type': 'zone', - 'title': 'Zone 1', + 'title': 'Zone (1)', 'unique_id': 'zone_1', }), }), diff --git a/tests/components/satel_integra/snapshots/test_init.ambr b/tests/components/satel_integra/snapshots/test_init.ambr new file mode 100644 index 00000000000000..fe629bac344136 --- /dev/null +++ b/tests/components/satel_integra/snapshots/test_init.ambr @@ -0,0 +1,69 @@ +# serializer version: 1 +# name: test_config_flow_migration_version_1_2 + ConfigEntrySnapshot({ + 'data': dict({ + 'host': '192.168.0.2', + 'port': 7094, + }), + 'disabled_by': None, + 'discovery_keys': dict({ + }), + 'domain': 'satel_integra', + 'entry_id': , + 'minor_version': 2, + 'options': dict({ + 'code': '1234', + }), + 'pref_disable_new_entities': False, + 'pref_disable_polling': False, + 'source': 'user', + 'subentries': list([ + dict({ + 'data': dict({ + 'arm_home_mode': 1, + 'name': 'Home', + 'partition_number': 1, + }), + 'subentry_id': 'ID_PARTITION', + 'subentry_type': 'partition', + 'title': 'Home (1) (1)', + 'unique_id': 'partition_1', + }), + dict({ + 'data': dict({ + 'name': 'Zone', + 'type': , + 'zone_number': 1, + }), + 'subentry_id': 'ID_ZONE', + 'subentry_type': 'zone', + 'title': 'Zone (1) (1)', + 'unique_id': 'zone_1', + }), + dict({ + 'data': dict({ + 'name': 'Output', + 'output_number': 1, + 'type': , + }), + 'subentry_id': 'ID_OUTPUT', + 'subentry_type': 'output', + 'title': 'Output (1) (1)', + 'unique_id': 'output_1', + }), + dict({ + 'data': dict({ + 'name': 'Switchable Output', + 'switchable_output_number': 1, + }), + 'subentry_id': 'ID_SWITCHABLE_OUTPUT', + 'subentry_type': 'switchable_output', + 'title': 'Switchable Output (1) (1)', + 'unique_id': 'switchable_output_1', + }), + ]), + 'title': '192.168.0.2', + 'unique_id': None, + 'version': 1, + }) +# --- diff --git a/tests/components/satel_integra/test_config_flow.py b/tests/components/satel_integra/test_config_flow.py index 84b4aef20094d3..3f92408bb6824f 100644 --- a/tests/components/satel_integra/test_config_flow.py +++ b/tests/components/satel_integra/test_config_flow.py @@ -273,15 +273,18 @@ async def test_subentry_creation( ( "user_input", "subentry", + "number_property", ), [ ( {CONF_NAME: "New Home", CONF_ARM_HOME_MODE: 3}, MOCK_PARTITION_SUBENTRY, + CONF_PARTITION_NUMBER, ), ( {CONF_NAME: "Backdoor", CONF_ZONE_TYPE: BinarySensorDeviceClass.DOOR}, MOCK_ZONE_SUBENTRY, + CONF_ZONE_NUMBER, ), ( { @@ -289,10 +292,12 @@ async def test_subentry_creation( CONF_ZONE_TYPE: BinarySensorDeviceClass.PROBLEM, }, MOCK_OUTPUT_SUBENTRY, + CONF_OUTPUT_NUMBER, ), ( {CONF_NAME: "Gate Lock"}, MOCK_SWITCHABLE_OUTPUT_SUBENTRY, + CONF_SWITCHABLE_OUTPUT_NUMBER, ), ], ) @@ -303,6 +308,7 @@ async def test_subentry_reconfigure( mock_config_entry_with_subentries: MockConfigEntry, user_input: dict[str, Any], subentry: ConfigSubentry, + number_property: str, ) -> None: """Test subentry reconfiguration.""" @@ -339,7 +345,7 @@ async def test_subentry_reconfigure( subentry_result = { **subentry.as_dict(), "data": {**subentry.data, **user_input}, - "title": user_input.get(CONF_NAME), + "title": f"{user_input.get(CONF_NAME)} ({subentry.data[number_property]})", } assert mock_config_entry_with_subentries.subentries.get( @@ -361,7 +367,7 @@ async def test_cannot_create_same_subentry( mock_satel: AsyncMock, mock_setup_entry: AsyncMock, mock_config_entry_with_subentries: MockConfigEntry, - subentry: dict[str, Any], + subentry: ConfigSubentry, error_field: str, ) -> None: """Test subentry reconfiguration.""" diff --git a/tests/components/satel_integra/test_init.py b/tests/components/satel_integra/test_init.py new file mode 100644 index 00000000000000..566348430cb607 --- /dev/null +++ b/tests/components/satel_integra/test_init.py @@ -0,0 +1,56 @@ +"""Test init of Satel Integra integration.""" + +from copy import deepcopy +from unittest.mock import AsyncMock + +from syrupy.assertion import SnapshotAssertion + +from homeassistant.components.satel_integra.const import DOMAIN +from homeassistant.core import HomeAssistant + +from . import ( + MOCK_CONFIG_DATA, + MOCK_CONFIG_OPTIONS, + MOCK_OUTPUT_SUBENTRY, + MOCK_PARTITION_SUBENTRY, + MOCK_SWITCHABLE_OUTPUT_SUBENTRY, + MOCK_ZONE_SUBENTRY, +) + +from tests.common import MockConfigEntry + + +async def test_config_flow_migration_version_1_2( + hass: HomeAssistant, + snapshot: SnapshotAssertion, + mock_satel: AsyncMock, +) -> None: + """Test that the unique ID is migrated to the new format.""" + + config_entry = MockConfigEntry( + domain=DOMAIN, + title="192.168.0.2", + data=MOCK_CONFIG_DATA, + options=MOCK_CONFIG_OPTIONS, + entry_id="SATEL_INTEGRA_CONFIG_ENTRY_1", + version=1, + minor_version=1, + ) + config_entry.subentries = deepcopy( + { + MOCK_PARTITION_SUBENTRY.subentry_id: MOCK_PARTITION_SUBENTRY, + MOCK_ZONE_SUBENTRY.subentry_id: MOCK_ZONE_SUBENTRY, + MOCK_OUTPUT_SUBENTRY.subentry_id: MOCK_OUTPUT_SUBENTRY, + MOCK_SWITCHABLE_OUTPUT_SUBENTRY.subentry_id: MOCK_SWITCHABLE_OUTPUT_SUBENTRY, + } + ) + + config_entry.add_to_hass(hass) + + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + assert config_entry.version == 1 + assert config_entry.minor_version == 2 + + assert config_entry == snapshot diff --git a/tests/components/sensor/test_init.py b/tests/components/sensor/test_init.py index 594e01034289ca..05e24d1f9b2cad 100644 --- a/tests/components/sensor/test_init.py +++ b/tests/components/sensor/test_init.py @@ -3065,7 +3065,6 @@ def test_device_class_converters_are_complete() -> None: no_converter_device_classes = { SensorDeviceClass.AQI, SensorDeviceClass.BATTERY, - SensorDeviceClass.CO, SensorDeviceClass.CO2, SensorDeviceClass.DATE, SensorDeviceClass.ENUM, diff --git a/tests/components/sensor/test_recorder.py b/tests/components/sensor/test_recorder.py index 5dadc5bd4edec4..4d857376efe9b2 100644 --- a/tests/components/sensor/test_recorder.py +++ b/tests/components/sensor/test_recorder.py @@ -248,18 +248,17 @@ async def assert_validation_result( ("area", "mi²", "mi²", "mi²", "area", 13.050847, -10, 30), ("battery", "%", "%", "%", "unitless", 13.050847, -10, 30), ("battery", None, None, None, "unitless", 13.050847, -10, 30), - # We can't yet convert carbon_monoxide ( "carbon_monoxide", "mg/m³", "mg/m³", "mg/m³", - "concentration", + "carbon_monoxide", 13.050847, -10, 30, ), - ("carbon_monoxide", "ppm", "ppm", "ppm", "unitless", 13.050847, -10, 30), + ("carbon_monoxide", "ppm", "ppm", "ppm", "carbon_monoxide", 13.050847, -10, 30), ("distance", "m", "m", "m", "distance", 13.050847, -10, 30), ("distance", "mi", "mi", "mi", "distance", 13.050847, -10, 30), ("humidity", "%", "%", "%", "unitless", 13.050847, -10, 30), @@ -3276,9 +3275,6 @@ async def test_list_statistic_ids_unsupported( (None, "ft³", "ft3", "volume", 13.050847, -10, 30), (None, "ft³/min", "ft³/m", "volume_flow_rate", 13.050847, -10, 30), (None, "m³", "m3", "volume", 13.050847, -10, 30), - # Can't yet convert carbon_monoxide - ("carbon_monoxide", "ppm", "mg/m³", "unitless", 13.050847, -10, 30), - ("carbon_monoxide", "mg/m³", "ppm", "concentration", 13.050847, -10, 30), ], ) async def test_compile_hourly_statistics_changing_units_1( @@ -3622,6 +3618,26 @@ async def test_compile_hourly_statistics_changing_units_3( (None, None, "ppm", "unitless", 13.050847, -10, 30, 1000000), (None, "g/m³", "mg/m³", "concentration", 13.050847, -10, 30, 1000), (None, "mg/m³", "g/m³", "concentration", 13.050847, -10, 30, 0.001), + ( + "carbon_monoxide", + "ppm", + "mg/m³", + "carbon_monoxide", + 13.050847, + -10, + 30, + 1.16441, + ), + ( + "carbon_monoxide", + "mg/m³", + "ppm", + "carbon_monoxide", + 13.050847, + -10, + 30, + 1 / 1.16441, + ), (None, "%", None, "unitless", 13.050847, -10, 30, 0.01), (None, "W", "kW", "power", 13.050847, -10, 30, 0.001), (None, "kW", "W", "power", 13.050847, -10, 30, 1000), @@ -4969,11 +4985,12 @@ def set_state(entity_id, state, **kwargs): @pytest.mark.parametrize( - ("units", "attributes", "unit", "unit2", "supported_unit"), + ("units", "attributes", "unit_class", "unit", "unit2", "supported_unit"), [ ( US_CUSTOMARY_SYSTEM, POWER_SENSOR_ATTRIBUTES, + "power", "W", "kW", "BTU/h, GW, MW, TW, W, kW, mW", @@ -4981,6 +4998,7 @@ def set_state(entity_id, state, **kwargs): ( METRIC_SYSTEM, POWER_SENSOR_ATTRIBUTES, + "power", "W", "kW", "BTU/h, GW, MW, TW, W, kW, mW", @@ -4988,14 +5006,23 @@ def set_state(entity_id, state, **kwargs): ( US_CUSTOMARY_SYSTEM, TEMPERATURE_SENSOR_ATTRIBUTES, + "temperature", "°F", "K", "K, °C, °F", ), - (METRIC_SYSTEM, TEMPERATURE_SENSOR_ATTRIBUTES, "°C", "K", "K, °C, °F"), + ( + METRIC_SYSTEM, + TEMPERATURE_SENSOR_ATTRIBUTES, + "temperature", + "°C", + "K", + "K, °C, °F", + ), ( US_CUSTOMARY_SYSTEM, PRESSURE_SENSOR_ATTRIBUTES, + "pressure", "psi", "bar", "Pa, bar, cbar, hPa, inHg, inH₂O, kPa, mbar, mmHg, psi", @@ -5003,6 +5030,7 @@ def set_state(entity_id, state, **kwargs): ( METRIC_SYSTEM, PRESSURE_SENSOR_ATTRIBUTES, + "pressure", "Pa", "bar", "Pa, bar, cbar, hPa, inHg, inH₂O, kPa, mbar, mmHg, psi", @@ -5014,6 +5042,7 @@ async def test_validate_unit_change_convertible( hass_ws_client: WebSocketGenerator, units, attributes, + unit_class, unit, unit2, supported_unit, @@ -5072,7 +5101,9 @@ async def test_validate_unit_change_convertible( { "data": { "metadata_unit": unit, + "metadata_unit_class": unit_class, "state_unit": "dogs", + "state_unit_class": None, "statistic_id": "sensor.test", "supported_unit": supported_unit, }, @@ -5193,11 +5224,12 @@ async def test_validate_statistics_unit_ignore_device_class( @pytest.mark.parametrize( - ("units", "attributes", "unit", "unit2", "supported_unit"), + ("units", "attributes", "unit_class", "unit", "unit2", "supported_unit"), [ ( US_CUSTOMARY_SYSTEM, POWER_SENSOR_ATTRIBUTES, + "power", "W", "kW", "BTU/h, GW, MW, TW, W, kW, mW", @@ -5205,6 +5237,7 @@ async def test_validate_statistics_unit_ignore_device_class( ( METRIC_SYSTEM, POWER_SENSOR_ATTRIBUTES, + "power", "W", "kW", "BTU/h, GW, MW, TW, W, kW, mW", @@ -5212,14 +5245,23 @@ async def test_validate_statistics_unit_ignore_device_class( ( US_CUSTOMARY_SYSTEM, TEMPERATURE_SENSOR_ATTRIBUTES, + "temperature", "°F", "K", "K, °C, °F", ), - (METRIC_SYSTEM, TEMPERATURE_SENSOR_ATTRIBUTES, "°C", "K", "K, °C, °F"), + ( + METRIC_SYSTEM, + TEMPERATURE_SENSOR_ATTRIBUTES, + "temperature", + "°C", + "K", + "K, °C, °F", + ), ( US_CUSTOMARY_SYSTEM, PRESSURE_SENSOR_ATTRIBUTES, + "pressure", "psi", "bar", "Pa, bar, cbar, hPa, inHg, inH₂O, kPa, mbar, mmHg, psi", @@ -5227,6 +5269,7 @@ async def test_validate_statistics_unit_ignore_device_class( ( METRIC_SYSTEM, PRESSURE_SENSOR_ATTRIBUTES, + "pressure", "Pa", "bar", "Pa, bar, cbar, hPa, inHg, inH₂O, kPa, mbar, mmHg, psi", @@ -5234,6 +5277,7 @@ async def test_validate_statistics_unit_ignore_device_class( ( METRIC_SYSTEM, BATTERY_SENSOR_ATTRIBUTES, + "unitless", "%", None, "%, , ppb, ppm", @@ -5245,6 +5289,7 @@ async def test_validate_statistics_unit_change_no_device_class( hass_ws_client: WebSocketGenerator, units, attributes, + unit_class, unit, unit2, supported_unit, @@ -5303,7 +5348,9 @@ async def test_validate_statistics_unit_change_no_device_class( { "data": { "metadata_unit": unit, + "metadata_unit_class": unit_class, "state_unit": "dogs", + "state_unit_class": None, "statistic_id": "sensor.test", "supported_unit": supported_unit, }, @@ -5736,7 +5783,9 @@ async def test_validate_statistics_unit_change_no_conversion( { "data": { "metadata_unit": unit1, + "metadata_unit_class": None, "state_unit": unit2, + "state_unit_class": None, "statistic_id": "sensor.test", "supported_unit": unit1, }, @@ -5869,24 +5918,50 @@ async def test_validate_statistics_unit_change_equivalent_units( @pytest.mark.parametrize( - ("attributes", "unit1", "unit2", "supported_unit"), + ("attributes", "unit_class", "unit1", "unit2", "supported_unit"), [ - (NONE_SENSOR_ATTRIBUTES, "m³", "m3", "CCF, L, MCF, fl. oz., ft³, gal, mL, m³"), - (NONE_SENSOR_ATTRIBUTES, "\u03bcV", "\u00b5V", "MV, V, kV, mV, \u03bcV"), - (NONE_SENSOR_ATTRIBUTES, "\u03bcS/cm", "\u00b5S/cm", "S/cm, mS/cm, \u03bcS/cm"), ( NONE_SENSOR_ATTRIBUTES, + "volume", + "m³", + "m3", + "CCF, L, MCF, fl. oz., ft³, gal, mL, m³", + ), + ( + NONE_SENSOR_ATTRIBUTES, + "voltage", + "\u03bcV", + "\u00b5V", + "MV, V, kV, mV, \u03bcV", + ), + ( + NONE_SENSOR_ATTRIBUTES, + "conductivity", + "\u03bcS/cm", + "\u00b5S/cm", + "S/cm, mS/cm, \u03bcS/cm", + ), + ( + NONE_SENSOR_ATTRIBUTES, + "mass", "\u03bcg", "\u00b5g", "g, kg, lb, mg, oz, st, \u03bcg", ), - (NONE_SENSOR_ATTRIBUTES, "\u03bcs", "\u00b5s", "d, h, min, ms, s, w, \u03bcs"), + ( + NONE_SENSOR_ATTRIBUTES, + "duration", + "\u03bcs", + "\u00b5s", + "d, h, min, ms, s, w, \u03bcs", + ), ], ) async def test_validate_statistics_unit_change_equivalent_units_2( hass: HomeAssistant, hass_ws_client: WebSocketGenerator, attributes, + unit_class, unit1, unit2, supported_unit, @@ -5934,7 +6009,9 @@ async def test_validate_statistics_unit_change_equivalent_units_2( { "data": { "metadata_unit": unit1, + "metadata_unit_class": unit_class, "state_unit": unit2, + "state_unit_class": None, "statistic_id": "sensor.test", "supported_unit": supported_unit, }, diff --git a/tests/components/shelly/test_button.py b/tests/components/shelly/test_button.py index dd1f56872e108a..b59227acf7f00c 100644 --- a/tests/components/shelly/test_button.py +++ b/tests/components/shelly/test_button.py @@ -219,7 +219,7 @@ async def test_rpc_blu_trv_button( {ATTR_ENTITY_ID: entity_id}, blocking=True, ) - assert mock_blu_trv.trigger_blu_trv_calibration.call_count == 1 + mock_blu_trv.trigger_blu_trv_calibration.assert_called_once_with(200) @pytest.mark.parametrize( diff --git a/tests/components/shelly/test_cover.py b/tests/components/shelly/test_cover.py index 637adaed225086..63dac548ed1548 100644 --- a/tests/components/shelly/test_cover.py +++ b/tests/components/shelly/test_cover.py @@ -23,7 +23,7 @@ CoverState, ) from homeassistant.components.shelly.const import RPC_COVER_UPDATE_TIME_SEC -from homeassistant.const import ATTR_ENTITY_ID, Platform +from homeassistant.const import ATTR_ENTITY_ID, STATE_UNAVAILABLE, Platform from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_registry import EntityRegistry @@ -429,3 +429,20 @@ async def test_update_position_no_movement( assert (state := hass.states.get(entity_id)) assert state.state == CoverState.OPEN assert state.attributes[ATTR_CURRENT_POSITION] == 100 + + +async def test_rpc_not_initialized_update( + hass: HomeAssistant, mock_rpc_device: Mock, monkeypatch: pytest.MonkeyPatch +) -> None: + """Test update not called when device is not initialized.""" + entity_id = "cover.test_name_test_cover_0" + await init_integration(hass, 2) + + assert (state := hass.states.get(entity_id)) + assert state.state == CoverState.OPEN + + monkeypatch.setattr(mock_rpc_device, "initialized", False) + mock_rpc_device.mock_update() + + assert (state := hass.states.get(entity_id)) + assert state.state == STATE_UNAVAILABLE diff --git a/tests/components/smlight/snapshots/test_switch.ambr b/tests/components/smlight/snapshots/test_switch.ambr index 85084c736093a4..01bd9d9950642b 100644 --- a/tests/components/smlight/snapshots/test_switch.ambr +++ b/tests/components/smlight/snapshots/test_switch.ambr @@ -60,7 +60,7 @@ 'device_id': , 'disabled_by': None, 'domain': 'switch', - 'entity_category': None, + 'entity_category': , 'entity_id': 'switch.mock_title_disable_leds', 'has_entity_name': True, 'hidden_by': None, @@ -109,7 +109,7 @@ 'device_id': , 'disabled_by': None, 'domain': 'switch', - 'entity_category': None, + 'entity_category': , 'entity_id': 'switch.mock_title_led_night_mode', 'has_entity_name': True, 'hidden_by': None, @@ -158,7 +158,7 @@ 'device_id': , 'disabled_by': None, 'domain': 'switch', - 'entity_category': None, + 'entity_category': , 'entity_id': 'switch.mock_title_vpn_enabled', 'has_entity_name': True, 'hidden_by': None, diff --git a/tests/components/solaredge/conftest.py b/tests/components/solaredge/conftest.py new file mode 100644 index 00000000000000..664716be1cf67c --- /dev/null +++ b/tests/components/solaredge/conftest.py @@ -0,0 +1,60 @@ +"""Common fixtures for the SolarEdge tests.""" + +from collections.abc import Generator +from unittest.mock import AsyncMock, Mock, patch + +import pytest + +SITE_ID = "1a2b3c4d5e6f7g8h" +API_KEY = "a1b2c3d4e5f6g7h8" +USERNAME = "test-username" +PASSWORD = "test-password" + + +@pytest.fixture +def mock_setup_entry() -> Generator[AsyncMock]: + """Override async_setup_entry.""" + with patch( + "homeassistant.components.solaredge.async_setup_entry", + return_value=True, + ) as mock_setup_entry: + yield mock_setup_entry + + +@pytest.fixture(name="solaredge_api") +def mock_solaredge_api_fixture() -> Generator[Mock]: + """Mock a successful SolarEdge Monitoring API.""" + api = Mock() + api.get_details = AsyncMock(return_value={"details": {"status": "active"}}) + with ( + patch( + "homeassistant.components.solaredge.config_flow.aiosolaredge.SolarEdge", + return_value=api, + ), + patch( + "homeassistant.components.solaredge.SolarEdge", + return_value=api, + ), + ): + yield api + + +@pytest.fixture(name="solaredge_web_api") +def mock_solaredge_web_api_fixture() -> Generator[AsyncMock]: + """Mock a successful SolarEdge Web API.""" + with ( + patch( + "homeassistant.components.solaredge.config_flow.SolarEdgeWeb", autospec=True + ) as mock_web_api_flow, + patch( + "homeassistant.components.solaredge.coordinator.SolarEdgeWeb", autospec=True + ) as mock_web_api_coord, + ): + # Ensure both patches use the same mock instance + api = mock_web_api_flow.return_value + mock_web_api_coord.return_value = api + api.async_get_equipment.return_value = { + 1001: {"displayName": "1.1"}, + 1002: {"displayName": "1.2"}, + } + yield api diff --git a/tests/components/solaredge/test_config_flow.py b/tests/components/solaredge/test_config_flow.py index 759a4d6b421a6a..cb4ec76c674ab6 100644 --- a/tests/components/solaredge/test_config_flow.py +++ b/tests/components/solaredge/test_config_flow.py @@ -1,77 +1,172 @@ """Tests for the SolarEdge config flow.""" -from unittest.mock import AsyncMock, Mock, patch +from unittest.mock import AsyncMock, Mock -from aiohttp import ClientError +from aiohttp import ClientError, ClientResponseError import pytest -from homeassistant.components.solaredge.const import CONF_SITE_ID, DEFAULT_NAME, DOMAIN +from homeassistant.components.recorder import Recorder +from homeassistant.components.solaredge.const import ( + CONF_SECTION_API_AUTH, + CONF_SECTION_WEB_AUTH, + CONF_SITE_ID, + DEFAULT_NAME, + DOMAIN, +) from homeassistant.config_entries import SOURCE_IGNORE, SOURCE_USER -from homeassistant.const import CONF_API_KEY, CONF_NAME +from homeassistant.const import CONF_API_KEY, CONF_NAME, CONF_PASSWORD, CONF_USERNAME from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType +from .conftest import API_KEY, PASSWORD, SITE_ID, USERNAME + from tests.common import MockConfigEntry NAME = "solaredge site 1 2 3" -SITE_ID = "1a2b3c4d5e6f7g8h" -API_KEY = "a1b2c3d4e5f6g7h8" -@pytest.fixture(name="test_api") -def mock_controller(): - """Mock a successful Solaredge API.""" - api = Mock() - api.get_details = AsyncMock(return_value={"details": {"status": "active"}}) - with patch( - "homeassistant.components.solaredge.config_flow.aiosolaredge.SolarEdge", - return_value=api, - ): - yield api +@pytest.fixture(autouse=True) +def solaredge_api_fixture(solaredge_api: Mock) -> None: + """Mock the solaredge API.""" + + +@pytest.fixture(autouse=True) +def solaredge_web_api_fixture(solaredge_web_api: AsyncMock) -> None: + """Mock the solaredge web API.""" -async def test_user(hass: HomeAssistant, test_api: Mock) -> None: - """Test user config.""" +async def test_user_api_key( + recorder_mock: Recorder, + hass: HomeAssistant, + solaredge_api: Mock, + mock_setup_entry: AsyncMock, +) -> None: + """Test user config with API key.""" result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_USER} ) assert result.get("type") is FlowResultType.FORM assert result.get("step_id") == "user" - # test with all provided + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_NAME: NAME, + CONF_SITE_ID: SITE_ID, + CONF_SECTION_API_AUTH: {CONF_API_KEY: API_KEY}, + }, + ) + assert result.get("type") is FlowResultType.CREATE_ENTRY + assert result.get("title") == "solaredge_site_1_2_3" + + data = result.get("data") + assert data + assert data[CONF_SITE_ID] == SITE_ID + assert data[CONF_API_KEY] == API_KEY + assert CONF_USERNAME not in data + assert CONF_PASSWORD not in data + + assert len(mock_setup_entry.mock_calls) == 1 + + +async def test_user_web_login( + recorder_mock: Recorder, + hass: HomeAssistant, + solaredge_web_api: AsyncMock, + mock_setup_entry: AsyncMock, +) -> None: + """Test user config with web login.""" result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": SOURCE_USER}, - data={CONF_NAME: NAME, CONF_API_KEY: API_KEY, CONF_SITE_ID: SITE_ID}, + DOMAIN, context={"source": SOURCE_USER} ) + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_NAME: NAME, + CONF_SITE_ID: SITE_ID, + CONF_SECTION_WEB_AUTH: { + CONF_USERNAME: USERNAME, + CONF_PASSWORD: PASSWORD, + }, + }, + ) + assert result.get("type") is FlowResultType.CREATE_ENTRY assert result.get("title") == "solaredge_site_1_2_3" + data = result.get("data") + assert data + assert data[CONF_SITE_ID] == SITE_ID + assert data[CONF_USERNAME] == USERNAME + assert data[CONF_PASSWORD] == PASSWORD + assert CONF_API_KEY not in data + + assert len(mock_setup_entry.mock_calls) == 1 + solaredge_web_api.async_get_equipment.assert_awaited_once() + + +async def test_user_both_auth( + recorder_mock: Recorder, + hass: HomeAssistant, + solaredge_api: Mock, + solaredge_web_api: AsyncMock, + mock_setup_entry: AsyncMock, +) -> None: + """Test user config with both API key and web login.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_NAME: NAME, + CONF_SITE_ID: SITE_ID, + CONF_SECTION_API_AUTH: {CONF_API_KEY: API_KEY}, + CONF_SECTION_WEB_AUTH: { + CONF_USERNAME: USERNAME, + CONF_PASSWORD: PASSWORD, + }, + }, + ) + + assert result.get("type") is FlowResultType.CREATE_ENTRY data = result.get("data") assert data assert data[CONF_SITE_ID] == SITE_ID assert data[CONF_API_KEY] == API_KEY + assert data[CONF_USERNAME] == USERNAME + assert data[CONF_PASSWORD] == PASSWORD + + assert len(mock_setup_entry.mock_calls) == 1 -async def test_abort_if_already_setup(hass: HomeAssistant, test_api: str) -> None: +async def test_abort_if_already_setup( + recorder_mock: Recorder, hass: HomeAssistant +) -> None: """Test we abort if the site_id is already setup.""" MockConfigEntry( - domain="solaredge", + domain=DOMAIN, data={CONF_NAME: DEFAULT_NAME, CONF_SITE_ID: SITE_ID, CONF_API_KEY: API_KEY}, ).add_to_hass(hass) - # user: Should fail, same SITE_ID + # Should fail, same SITE_ID result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_USER}, - data={CONF_NAME: "test", CONF_SITE_ID: SITE_ID, CONF_API_KEY: "test"}, + data={ + CONF_NAME: "test", + CONF_SITE_ID: SITE_ID, + CONF_SECTION_API_AUTH: {CONF_API_KEY: "test"}, + }, ) assert result.get("type") is FlowResultType.FORM assert result.get("errors") == {CONF_SITE_ID: "already_configured"} async def test_ignored_entry_does_not_cause_error( - hass: HomeAssistant, test_api: str + recorder_mock: Recorder, hass: HomeAssistant ) -> None: """Test an ignored entry does not cause and error and we can still create an new entry.""" MockConfigEntry( @@ -80,11 +175,15 @@ async def test_ignored_entry_does_not_cause_error( source=SOURCE_IGNORE, ).add_to_hass(hass) - # user: Should fail, same SITE_ID + # Should not fail, same SITE_ID result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_USER}, - data={CONF_NAME: "test", CONF_SITE_ID: SITE_ID, CONF_API_KEY: "test"}, + data={ + CONF_NAME: "test", + CONF_SITE_ID: SITE_ID, + CONF_SECTION_API_AUTH: {CONF_API_KEY: "test"}, + }, ) assert result["type"] is FlowResultType.CREATE_ENTRY assert result["title"] == "test" @@ -95,46 +194,116 @@ async def test_ignored_entry_does_not_cause_error( assert data[CONF_API_KEY] == "test" -async def test_asserts(hass: HomeAssistant, test_api: Mock) -> None: - """Test the _site_in_configuration_exists method.""" - - # test with inactive site - test_api.get_details.return_value = {"details": {"status": "NOK"}} - +async def test_no_auth_provided(recorder_mock: Recorder, hass: HomeAssistant) -> None: + """Test error when no authentication method is provided.""" result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": SOURCE_USER}, - data={CONF_NAME: NAME, CONF_API_KEY: API_KEY, CONF_SITE_ID: SITE_ID}, + DOMAIN, context={"source": SOURCE_USER} + ) + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_NAME: NAME, CONF_SITE_ID: SITE_ID}, ) assert result.get("type") is FlowResultType.FORM - assert result.get("errors") == {CONF_SITE_ID: "site_not_active"} + assert result.get("errors") == {"base": "auth_missing"} + + +@pytest.mark.parametrize( + ("get_details_setup", "expected_error"), + [ + (AsyncMock(return_value={"details": {"status": "NOK"}}), "site_not_active"), + (AsyncMock(return_value={}), "invalid_api_key"), + (AsyncMock(side_effect=TimeoutError()), "cannot_connect"), + (AsyncMock(side_effect=ClientError()), "cannot_connect"), + ], +) +async def test_api_key_errors( + recorder_mock: Recorder, + hass: HomeAssistant, + mock_setup_entry: AsyncMock, + solaredge_api: Mock, + get_details_setup: AsyncMock, + expected_error: str, +) -> None: + """Test API key validation errors.""" + solaredge_api.get_details = get_details_setup - # test with api_failure - test_api.get_details.return_value = {} + user_input = { + CONF_NAME: NAME, + CONF_SITE_ID: SITE_ID, + CONF_SECTION_API_AUTH: {CONF_API_KEY: API_KEY}, + } result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_USER}, - data={CONF_NAME: NAME, CONF_API_KEY: API_KEY, CONF_SITE_ID: SITE_ID}, + data=user_input, ) + assert result.get("type") is FlowResultType.FORM - assert result.get("errors") == {CONF_SITE_ID: "invalid_api_key"} + assert result.get("errors") == {CONF_SITE_ID: expected_error} - # test with ConnectionTimeout - test_api.get_details = AsyncMock(side_effect=TimeoutError()) - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": SOURCE_USER}, - data={CONF_NAME: NAME, CONF_API_KEY: API_KEY, CONF_SITE_ID: SITE_ID}, + # Make sure the config flow is able to recover from above error + solaredge_api.get_details = AsyncMock( + return_value={"details": {"status": "active"}} ) - assert result.get("type") is FlowResultType.FORM - assert result.get("errors") == {CONF_SITE_ID: "could_not_connect"} + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input + ) + + assert result.get("type") is FlowResultType.CREATE_ENTRY + assert result.get("data") == {CONF_SITE_ID: SITE_ID, CONF_API_KEY: API_KEY} + assert len(mock_setup_entry.mock_calls) == 1 - # test with HTTPError - test_api.get_details = AsyncMock(side_effect=ClientError()) + +@pytest.mark.parametrize( + ("api_exception", "expected_error"), + [ + (ClientResponseError(None, None, status=401), "invalid_auth"), + (ClientResponseError(None, None, status=403), "invalid_auth"), + (ClientResponseError(None, None, status=400), "cannot_connect"), + (ClientResponseError(None, None, status=500), "cannot_connect"), + (TimeoutError(), "cannot_connect"), + (ClientError(), "cannot_connect"), + ], +) +async def test_web_login_errors( + recorder_mock: Recorder, + hass: HomeAssistant, + mock_setup_entry: AsyncMock, + solaredge_web_api: AsyncMock, + api_exception: Exception, + expected_error: str, +) -> None: + """Test web login validation errors.""" result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": SOURCE_USER}, - data={CONF_NAME: NAME, CONF_API_KEY: API_KEY, CONF_SITE_ID: SITE_ID}, + DOMAIN, context={"source": SOURCE_USER} + ) + + solaredge_web_api.async_get_equipment.side_effect = api_exception + user_input = { + CONF_NAME: NAME, + CONF_SITE_ID: SITE_ID, + CONF_SECTION_WEB_AUTH: { + CONF_USERNAME: USERNAME, + CONF_PASSWORD: PASSWORD, + }, + } + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input ) + assert result.get("type") is FlowResultType.FORM - assert result.get("errors") == {CONF_SITE_ID: "could_not_connect"} + assert result.get("errors") == {"base": expected_error} + + # Make sure the config flow is able to recover from above error + solaredge_web_api.async_get_equipment.side_effect = None + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input + ) + + assert result.get("type") is FlowResultType.CREATE_ENTRY + assert result.get("data") == { + CONF_SITE_ID: SITE_ID, + CONF_USERNAME: USERNAME, + CONF_PASSWORD: PASSWORD, + } + assert len(mock_setup_entry.mock_calls) == 1 diff --git a/tests/components/solaredge/test_coordinator.py b/tests/components/solaredge/test_coordinator.py index 984c343a657c4f..5e21a39febcd23 100644 --- a/tests/components/solaredge/test_coordinator.py +++ b/tests/components/solaredge/test_coordinator.py @@ -1,23 +1,37 @@ """Tests for the SolarEdge coordinator services.""" +import asyncio +from datetime import datetime, timedelta from unittest.mock import AsyncMock, patch from freezegun.api import FrozenDateTimeFactory import pytest +from solaredge_web import EnergyData +from homeassistant.components.recorder import Recorder +from homeassistant.components.recorder.statistics import statistics_during_period from homeassistant.components.solaredge.const import ( CONF_SITE_ID, + DATA_MODULES_COORDINATOR, DEFAULT_NAME, DOMAIN, OVERVIEW_UPDATE_DELAY, ) -from homeassistant.const import CONF_API_KEY, CONF_NAME, STATE_UNKNOWN +from homeassistant.components.solaredge.coordinator import SolarEdgeModulesCoordinator +from homeassistant.const import ( + CONF_API_KEY, + CONF_NAME, + CONF_PASSWORD, + CONF_USERNAME, + STATE_UNKNOWN, +) from homeassistant.core import HomeAssistant +from homeassistant.util import dt as dt_util -from tests.common import MockConfigEntry, async_fire_time_changed +from .conftest import API_KEY, PASSWORD, SITE_ID, USERNAME -SITE_ID = "1a2b3c4d5e6f7g8h" -API_KEY = "a1b2c3d4e5f6g7h8" +from tests.common import MockConfigEntry, async_fire_time_changed +from tests.components.recorder.common import async_wait_recording_done @pytest.fixture(autouse=True) @@ -27,7 +41,10 @@ def enable_all_entities(entity_registry_enabled_by_default: None) -> None: @patch("homeassistant.components.solaredge.SolarEdge") async def test_solaredgeoverviewdataservice_energy_values_validity( - mock_solaredge, hass: HomeAssistant, freezer: FrozenDateTimeFactory + mock_solaredge, + recorder_mock: Recorder, + hass: HomeAssistant, + freezer: FrozenDateTimeFactory, ) -> None: """Test overview energy data validity.""" mock_config_entry = MockConfigEntry( @@ -110,3 +127,293 @@ async def test_solaredgeoverviewdataservice_energy_values_validity( state = hass.states.get("sensor.solaredge_lifetime_energy") assert state assert state.state == str(mock_overview_data["overview"]["lifeTimeData"]["energy"]) + + +async def _trigger_and_wait_for_refresh( + hass: HomeAssistant, + freezer: FrozenDateTimeFactory, + coordinator: SolarEdgeModulesCoordinator, +) -> None: + """Trigger a coordinator refresh and wait for it to complete.""" + # The coordinator refresh runs in the background. + # To reliably assert the result, we need to wait for the refresh to complete. + # We patch the coordinator's update method to signal completion via an asyncio.Event. + refresh_done = asyncio.Event() + original_update = coordinator._async_update_data + + async def wrapped_update_data() -> None: + """Wrap original update and set event.""" + await original_update() + refresh_done.set() + + with patch.object( + coordinator, + "_async_update_data", + side_effect=wrapped_update_data, + autospec=True, + ): + freezer.tick(timedelta(hours=12)) + async_fire_time_changed(hass) + await asyncio.wait_for(refresh_done.wait(), timeout=5) + + +@pytest.fixture +def mock_solar_edge_web() -> AsyncMock: + """Mock SolarEdgeWeb.""" + with patch( + "homeassistant.components.solaredge.coordinator.SolarEdgeWeb", autospec=True + ) as mock_api: + api = mock_api.return_value + api.async_get_equipment.return_value = { + 1001: {"displayName": "1.1"}, + 1002: {"displayName": "1.2"}, + } + api.async_get_energy_data.return_value = [ + EnergyData( + start_time=dt_util.as_utc(datetime(2025, 1, 1, 10, 0)), + values={1001: 10.0, 1002: 20.0}, + ), + EnergyData( + start_time=dt_util.as_utc(datetime(2025, 1, 1, 10, 15)), + values={1001: 11.0, 1002: 21.0}, + ), + EnergyData( + start_time=dt_util.as_utc(datetime(2025, 1, 1, 10, 30)), + values={1001: 12.0, 1002: 22.0}, + ), + EnergyData( + start_time=dt_util.as_utc(datetime(2025, 1, 1, 10, 45)), + values={1001: 13.0, 1002: 23.0}, + ), + EnergyData( + start_time=dt_util.as_utc(datetime(2025, 1, 1, 11, 0)), + values={1001: 14.0, 1002: 24.0}, + ), + EnergyData( + start_time=dt_util.as_utc(datetime(2025, 1, 1, 11, 15)), + values={1001: 15.0, 1002: 25.0}, + ), + EnergyData( + start_time=dt_util.as_utc(datetime(2025, 1, 1, 11, 30)), + values={1001: 16.0, 1002: 26.0}, + ), + EnergyData( + start_time=dt_util.as_utc(datetime(2025, 1, 1, 11, 45)), + values={1001: 17.0, 1002: 27.0}, + ), + ] + yield api + + +async def test_modules_coordinator_first_run( + recorder_mock: Recorder, + hass: HomeAssistant, + mock_solar_edge_web: AsyncMock, +) -> None: + """Test the modules coordinator on its first run with no existing statistics.""" + entry = MockConfigEntry( + domain=DOMAIN, + data={CONF_SITE_ID: SITE_ID, CONF_USERNAME: USERNAME, CONF_PASSWORD: PASSWORD}, + ) + entry.add_to_hass(hass) + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + await async_wait_recording_done(hass) + stats = await hass.async_add_executor_job( + statistics_during_period, + hass, + dt_util.as_utc(datetime(1970, 1, 1, 0, 0)), + None, + {f"{DOMAIN}:{SITE_ID}_1001", f"{DOMAIN}:{SITE_ID}_1002"}, + "hour", + None, + {"state", "sum"}, + ) + assert stats == { + f"{DOMAIN}:{SITE_ID}_1001": [ + {"start": 1735783200.0, "end": 1735786800.0, "state": 11.5, "sum": 11.5}, + {"start": 1735786800.0, "end": 1735790400.0, "state": 15.5, "sum": 27.0}, + ], + f"{DOMAIN}:{SITE_ID}_1002": [ + {"start": 1735783200.0, "end": 1735786800.0, "state": 21.5, "sum": 21.5}, + {"start": 1735786800.0, "end": 1735790400.0, "state": 25.5, "sum": 47.0}, + ], + } + + +async def test_modules_coordinator_subsequent_run( + recorder_mock: Recorder, + hass: HomeAssistant, + freezer: FrozenDateTimeFactory, + mock_solar_edge_web: AsyncMock, +) -> None: + """Test the coordinator correctly updates statistics on subsequent runs.""" + mock_solar_edge_web.async_get_equipment.return_value = { + 1001: {"displayName": "1.1"}, + } + entry = MockConfigEntry( + domain=DOMAIN, + data={CONF_SITE_ID: SITE_ID, CONF_USERNAME: USERNAME, CONF_PASSWORD: PASSWORD}, + ) + entry.add_to_hass(hass) + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + await async_wait_recording_done(hass) + + mock_solar_edge_web.async_get_energy_data.return_value = [ + # Updated values, different from the first run + EnergyData( + start_time=dt_util.as_utc(datetime(2025, 1, 1, 11, 0)), + values={1001: 24.0}, + ), + EnergyData( + start_time=dt_util.as_utc(datetime(2025, 1, 1, 11, 15)), + values={1001: 25.0}, + ), + EnergyData( + start_time=dt_util.as_utc(datetime(2025, 1, 1, 11, 30)), + values={1001: 26.0}, + ), + EnergyData( + start_time=dt_util.as_utc(datetime(2025, 1, 1, 11, 45)), + values={1001: 27.0}, + ), + # New values for the next hour + EnergyData( + start_time=dt_util.as_utc(datetime(2025, 1, 1, 12, 0)), + values={1001: 28.0}, + ), + EnergyData( + start_time=dt_util.as_utc(datetime(2025, 1, 1, 12, 15)), + values={1001: 29.0}, + ), + EnergyData( + start_time=dt_util.as_utc(datetime(2025, 1, 1, 12, 30)), + values={1001: 30.0}, + ), + EnergyData( + start_time=dt_util.as_utc(datetime(2025, 1, 1, 12, 45)), + values={1001: 31.0}, + ), + ] + + coordinator: SolarEdgeModulesCoordinator = entry.runtime_data[ + DATA_MODULES_COORDINATOR + ] + await _trigger_and_wait_for_refresh(hass, freezer, coordinator) + await async_wait_recording_done(hass) + + stats = await hass.async_add_executor_job( + statistics_during_period, + hass, + dt_util.as_utc(datetime(1970, 1, 1, 0, 0)), + None, + {f"{DOMAIN}:{SITE_ID}_1001"}, + "hour", + None, + {"state", "sum"}, + ) + assert stats == { + f"{DOMAIN}:{SITE_ID}_1001": [ + {"start": 1735783200.0, "end": 1735786800.0, "state": 11.5, "sum": 11.5}, + {"start": 1735786800.0, "end": 1735790400.0, "state": 25.5, "sum": 37.0}, + {"start": 1735790400.0, "end": 1735794000.0, "state": 29.5, "sum": 66.5}, + ] + } + + +async def test_modules_coordinator_subsequent_run_with_gap( + recorder_mock: Recorder, + hass: HomeAssistant, + freezer: FrozenDateTimeFactory, + mock_solar_edge_web: AsyncMock, +) -> None: + """Test the coordinator correctly updates statistics on subsequent runs with a gap in data.""" + mock_solar_edge_web.async_get_equipment.return_value = { + 1001: {"displayName": "1.1"}, + } + entry = MockConfigEntry( + domain=DOMAIN, + data={CONF_SITE_ID: SITE_ID, CONF_USERNAME: USERNAME, CONF_PASSWORD: PASSWORD}, + ) + entry.add_to_hass(hass) + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + await async_wait_recording_done(hass) + + mock_solar_edge_web.async_get_energy_data.return_value = [ + # New values a month later, simulating a gap in data + EnergyData( + start_time=dt_util.as_utc(datetime(2025, 2, 1, 11, 0)), + values={1001: 24.0}, + ), + EnergyData( + start_time=dt_util.as_utc(datetime(2025, 2, 1, 11, 15)), + values={1001: 25.0}, + ), + EnergyData( + start_time=dt_util.as_utc(datetime(2025, 2, 1, 11, 30)), + values={1001: 26.0}, + ), + EnergyData( + start_time=dt_util.as_utc(datetime(2025, 2, 1, 11, 45)), + values={1001: 27.0}, + ), + ] + + coordinator: SolarEdgeModulesCoordinator = entry.runtime_data[ + DATA_MODULES_COORDINATOR + ] + await _trigger_and_wait_for_refresh(hass, freezer, coordinator) + await async_wait_recording_done(hass) + stats = await hass.async_add_executor_job( + statistics_during_period, + hass, + dt_util.as_utc(datetime(1970, 1, 1, 0, 0)), + None, + {f"{DOMAIN}:{SITE_ID}_1001"}, + "hour", + None, + {"state", "sum"}, + ) + assert stats == { + f"{DOMAIN}:{SITE_ID}_1001": [ + {"start": 1735783200.0, "end": 1735786800.0, "state": 11.5, "sum": 11.5}, + {"start": 1735786800.0, "end": 1735790400.0, "state": 15.5, "sum": 27.0}, + {"start": 1738465200.0, "end": 1738468800.0, "state": 25.5, "sum": 52.5}, + ] + } + + +async def test_modules_coordinator_no_energy_data( + recorder_mock: Recorder, + hass: HomeAssistant, + mock_solar_edge_web: AsyncMock, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test the coordinator handles an empty energy data response from the API.""" + mock_solar_edge_web.async_get_energy_data.return_value = [] + entry = MockConfigEntry( + domain=DOMAIN, + data={CONF_SITE_ID: SITE_ID, CONF_USERNAME: USERNAME, CONF_PASSWORD: PASSWORD}, + ) + entry.add_to_hass(hass) + + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + assert "No data received from SolarEdge API" in caplog.text + + await async_wait_recording_done(hass) + stats = await hass.async_add_executor_job( + statistics_during_period, + hass, + dt_util.as_utc(datetime(1970, 1, 1, 0, 0)), + None, + {f"{DOMAIN}:{SITE_ID}_1001", f"{DOMAIN}:{SITE_ID}_1002"}, + "hour", + None, + {"state", "sum"}, + ) + assert not stats diff --git a/tests/components/solaredge/test_init.py b/tests/components/solaredge/test_init.py new file mode 100644 index 00000000000000..2c009f09555734 --- /dev/null +++ b/tests/components/solaredge/test_init.py @@ -0,0 +1,131 @@ +"""Tests for the SolarEdge integration.""" + +from unittest.mock import AsyncMock, Mock + +from aiohttp import ClientError + +from homeassistant.components.recorder import Recorder +from homeassistant.components.solaredge.const import CONF_SITE_ID, DOMAIN +from homeassistant.config_entries import ConfigEntryState +from homeassistant.const import CONF_API_KEY, CONF_PASSWORD, CONF_USERNAME +from homeassistant.core import HomeAssistant + +from .conftest import API_KEY, PASSWORD, SITE_ID, USERNAME + +from tests.common import MockConfigEntry + + +async def test_setup_unload_api_key( + recorder_mock: Recorder, hass: HomeAssistant, solaredge_api: Mock +) -> None: + """Test successful setup and unload of a config entry with API key.""" + entry = MockConfigEntry( + domain=DOMAIN, + data={CONF_SITE_ID: SITE_ID, CONF_API_KEY: API_KEY}, + ) + entry.add_to_hass(hass) + + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + assert entry.state is ConfigEntryState.LOADED + assert solaredge_api.get_details.await_count == 2 + + assert await hass.config_entries.async_unload(entry.entry_id) + await hass.async_block_till_done() + assert entry.state is ConfigEntryState.NOT_LOADED + + +async def test_setup_unload_web_login( + recorder_mock: Recorder, hass: HomeAssistant, solaredge_web_api: AsyncMock +) -> None: + """Test successful setup and unload of a config entry with web login.""" + entry = MockConfigEntry( + domain=DOMAIN, + data={ + CONF_SITE_ID: SITE_ID, + CONF_USERNAME: USERNAME, + CONF_PASSWORD: PASSWORD, + }, + ) + entry.add_to_hass(hass) + + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + assert entry.state is ConfigEntryState.LOADED + solaredge_web_api.async_get_equipment.assert_awaited_once() + solaredge_web_api.async_get_energy_data.assert_awaited_once() + + assert await hass.config_entries.async_unload(entry.entry_id) + await hass.async_block_till_done() + assert entry.state is ConfigEntryState.NOT_LOADED + + +async def test_setup_unload_both( + recorder_mock: Recorder, + hass: HomeAssistant, + solaredge_api: Mock, + solaredge_web_api: AsyncMock, +) -> None: + """Test successful setup and unload of a config entry with both auth methods.""" + entry = MockConfigEntry( + domain=DOMAIN, + data={ + CONF_SITE_ID: SITE_ID, + CONF_API_KEY: API_KEY, + CONF_USERNAME: USERNAME, + CONF_PASSWORD: PASSWORD, + }, + ) + entry.add_to_hass(hass) + + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + assert entry.state is ConfigEntryState.LOADED + assert solaredge_api.get_details.await_count == 2 + solaredge_web_api.async_get_equipment.assert_awaited_once() + solaredge_web_api.async_get_energy_data.assert_awaited_once() + + assert await hass.config_entries.async_unload(entry.entry_id) + await hass.async_block_till_done() + assert entry.state is ConfigEntryState.NOT_LOADED + + +async def test_api_key_config_not_ready( + recorder_mock: Recorder, hass: HomeAssistant, solaredge_api: Mock +) -> None: + """Test for setup failure with API key.""" + solaredge_api.get_details.side_effect = ClientError() + entry = MockConfigEntry( + domain=DOMAIN, + data={CONF_SITE_ID: SITE_ID, CONF_API_KEY: API_KEY}, + ) + entry.add_to_hass(hass) + + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + assert entry.state is ConfigEntryState.SETUP_RETRY + + +async def test_web_login_config_not_ready( + recorder_mock: Recorder, hass: HomeAssistant, solaredge_web_api: AsyncMock +) -> None: + """Test for setup failure with web login.""" + solaredge_web_api.async_get_equipment.side_effect = ClientError() + entry = MockConfigEntry( + domain=DOMAIN, + data={ + CONF_SITE_ID: SITE_ID, + CONF_USERNAME: USERNAME, + CONF_PASSWORD: PASSWORD, + }, + ) + entry.add_to_hass(hass) + + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + assert entry.state is ConfigEntryState.SETUP_RETRY diff --git a/tests/components/telegram_bot/test_telegram_bot.py b/tests/components/telegram_bot/test_telegram_bot.py index cda2583e74bb84..ba8bf41b52be4e 100644 --- a/tests/components/telegram_bot/test_telegram_bot.py +++ b/tests/components/telegram_bot/test_telegram_bot.py @@ -8,7 +8,7 @@ import pytest from telegram import Chat, InlineKeyboardButton, InlineKeyboardMarkup, Message, Update -from telegram.constants import ChatType, ParseMode +from telegram.constants import ChatType, InputMediaType, ParseMode from telegram.error import ( InvalidToken, NetworkError, @@ -33,6 +33,7 @@ ATTR_FILE, ATTR_KEYBOARD, ATTR_KEYBOARD_INLINE, + ATTR_MEDIA_TYPE, ATTR_MESSAGE, ATTR_MESSAGE_TAG, ATTR_MESSAGE_THREAD_ID, @@ -59,6 +60,7 @@ SERVICE_DELETE_MESSAGE, SERVICE_EDIT_CAPTION, SERVICE_EDIT_MESSAGE, + SERVICE_EDIT_MESSAGE_MEDIA, SERVICE_EDIT_REPLYMARKUP, SERVICE_LEAVE_CHAT, SERVICE_SEND_ANIMATION, @@ -888,6 +890,77 @@ async def test_delete_message( mock.assert_called_once() +@pytest.mark.parametrize( + ("media_type", "expected_media_class"), + [ + ( + InputMediaType.ANIMATION, + "InputMediaAnimation", + ), + ( + InputMediaType.AUDIO, + "InputMediaAudio", + ), + ( + InputMediaType.DOCUMENT, + "InputMediaDocument", + ), + ( + InputMediaType.PHOTO, + "InputMediaPhoto", + ), + ( + InputMediaType.VIDEO, + "InputMediaVideo", + ), + ], +) +async def test_edit_message_media( + hass: HomeAssistant, + mock_broadcast_config_entry: MockConfigEntry, + mock_external_calls: None, + media_type: str, + expected_media_class: str, +) -> None: + """Test edit message media.""" + mock_broadcast_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_broadcast_config_entry.entry_id) + await hass.async_block_till_done() + + hass.config.allowlist_external_dirs.add("/tmp/") # noqa: S108 + write_utf8_file("/tmp/mock", "mock file contents") # noqa: S108 + + with patch( + "homeassistant.components.telegram_bot.bot.Bot.edit_message_media", + AsyncMock(return_value=True), + ) as mock: + await hass.services.async_call( + DOMAIN, + SERVICE_EDIT_MESSAGE_MEDIA, + { + ATTR_CAPTION: "mock caption", + ATTR_FILE: "/tmp/mock", # noqa: S108 + ATTR_MEDIA_TYPE: media_type, + ATTR_MESSAGEID: 12345, + ATTR_CHAT_ID: 123456, + ATTR_TIMEOUT: 10, + ATTR_KEYBOARD_INLINE: "/mock", + }, + blocking=True, + ) + + await hass.async_block_till_done() + mock.assert_called_once() + assert mock.call_args[1]["media"].__class__.__name__ == expected_media_class + assert mock.call_args[1]["media"].caption == "mock caption" + assert mock.call_args[1]["chat_id"] == 123456 + assert mock.call_args[1]["message_id"] == 12345 + assert mock.call_args[1]["reply_markup"] == InlineKeyboardMarkup( + [[InlineKeyboardButton(callback_data="/mock", text="MOCK")]] + ) + assert mock.call_args[1]["read_timeout"] == 10 + + async def test_edit_message( hass: HomeAssistant, mock_broadcast_config_entry: MockConfigEntry, diff --git a/tests/components/template/test_binary_sensor.py b/tests/components/template/test_binary_sensor.py index 575bad4b9425fe..bdff422b8d1495 100644 --- a/tests/components/template/test_binary_sensor.py +++ b/tests/components/template/test_binary_sensor.py @@ -1186,6 +1186,128 @@ async def test_template_with_trigger_templated_auto_off( assert state.state == final_state +@pytest.mark.parametrize( + ("count", "style", "state_template", "extra_config"), + [ + ( + 1, + ConfigurationStyle.TRIGGER, + _BEER_TRIGGER_VALUE_TEMPLATE, + { + "device_class": "motion", + "delay_on": "00:00:02", + "auto_off": "00:00:01", + }, + ) + ], +) +@pytest.mark.usefixtures("setup_binary_sensor") +async def test_template_trigger_delay_on_and_auto_off( + hass: HomeAssistant, + freezer: FrozenDateTimeFactory, +) -> None: + """Test binary sensor template with delay_on, auto_off, and multiple triggers.""" + state = hass.states.get(TEST_ENTITY_ID) + assert state.state == STATE_UNKNOWN + + context = Context() + hass.bus.async_fire("test_event", {"beer": 2}, context=context) + await hass.async_block_till_done() + + # State should still be unknown + state = hass.states.get(TEST_ENTITY_ID) + assert state.state == STATE_UNKNOWN + last_state = STATE_UNKNOWN + + for _ in range(5): + # Now wait and trigger again to test that the 2 second on_delay is not recreated + freezer.tick(timedelta(seconds=1)) + hass.bus.async_fire("test_event", {"beer": 2}, context=context) + await hass.async_block_till_done() + + state = hass.states.get(TEST_ENTITY_ID) + assert state.state == last_state + + # Now wait for the on delay + freezer.tick(timedelta(seconds=1)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + state = hass.states.get(TEST_ENTITY_ID) + assert state.state == STATE_ON + + # Now wait for the auto-off + freezer.tick(timedelta(seconds=1)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + state = hass.states.get(TEST_ENTITY_ID) + assert state.state == STATE_OFF + + # Now wait to trigger again + freezer.tick(timedelta(seconds=1)) + hass.bus.async_fire("test_event", {"beer": 2}, context=context) + await hass.async_block_till_done() + + # State should still be off + state = hass.states.get(TEST_ENTITY_ID) + assert state.state == STATE_OFF + + last_state = STATE_OFF + + +@pytest.mark.parametrize( + ("count", "style", "state_template", "extra_config"), + [ + ( + 1, + ConfigurationStyle.MODERN, + "{{ states('binary_sensor.test_state') }}", + { + "device_class": "motion", + "delay_on": "00:00:02", + }, + ) + ], +) +@pytest.mark.usefixtures("setup_binary_sensor") +async def test_template_multiple_states_delay_on( + hass: HomeAssistant, + freezer: FrozenDateTimeFactory, +) -> None: + """Test binary sensor template with delay_on and multiple state changes.""" + state = hass.states.get(TEST_ENTITY_ID) + assert state.state == STATE_OFF + + hass.states.async_set(TEST_STATE_ENTITY_ID, STATE_ON) + await hass.async_block_till_done() + + # State should be off + state = hass.states.get(TEST_ENTITY_ID) + assert state.state == STATE_OFF + + for _ in range(5): + # Now wait for the on delay + freezer.tick(timedelta(seconds=2)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + state = hass.states.get(TEST_ENTITY_ID) + assert state.state == STATE_ON + + freezer.tick(timedelta(seconds=1)) + hass.states.async_set(TEST_STATE_ENTITY_ID, STATE_OFF) + await hass.async_block_till_done() + + freezer.tick(timedelta(seconds=1)) + hass.states.async_set(TEST_STATE_ENTITY_ID, STATE_ON) + await hass.async_block_till_done() + + # State should still be off + state = hass.states.get(TEST_ENTITY_ID) + assert state.state == STATE_OFF + + @pytest.mark.parametrize( ("count", "style", "state_template", "extra_config"), [ diff --git a/tests/components/volvo/conftest.py b/tests/components/volvo/conftest.py index 92aa563d88aabe..4a4d9350a36be4 100644 --- a/tests/components/volvo/conftest.py +++ b/tests/components/volvo/conftest.py @@ -9,6 +9,7 @@ from volvocarsapi.auth import TOKEN_URL from volvocarsapi.models import ( VolvoCarsAvailableCommand, + VolvoCarsCommandResult, VolvoCarsLocation, VolvoCarsValueField, VolvoCarsValueStatusField, @@ -50,6 +51,7 @@ class MockApiData: """Container for mock API data.""" vehicle: VolvoCarsVehicle + command_result: VolvoCarsCommandResult commands: list[VolvoCarsAvailableCommand] location: dict[str, VolvoCarsLocation] availability: dict[str, VolvoCarsValueField] @@ -125,6 +127,7 @@ async def mock_api( "homeassistant.components.volvo.VolvoCarsApi", return_value=api, ): + api.async_execute_command = AsyncMock(return_value=mock_api_data.command_result) api.async_get_brakes_status = AsyncMock(return_value=mock_api_data.brakes) api.async_get_command_accessibility = AsyncMock( return_value=mock_api_data.availability @@ -205,6 +208,7 @@ async def _async_load_mock_api_data( vehicle_data = await async_load_fixture_as_json(hass, "vehicle", full_model) vehicle = VolvoCarsVehicle.from_dict(vehicle_data) + command_result = VolvoCarsCommandResult(DEFAULT_VIN, "COMPLETED", message="") commands_data = ( await async_load_fixture_as_json(hass, "commands", full_model) ).get("data") @@ -251,6 +255,7 @@ async def _async_load_mock_api_data( return MockApiData( vehicle=vehicle, + command_result=command_result, commands=commands, location=location, availability=availability, diff --git a/tests/components/volvo/snapshots/test_button.ambr b/tests/components/volvo/snapshots/test_button.ambr new file mode 100644 index 00000000000000..38e3564600db37 --- /dev/null +++ b/tests/components/volvo/snapshots/test_button.ambr @@ -0,0 +1,961 @@ +# serializer version: 1 +# name: test_button[ex30_2024][button.volvo_ex30_flash-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'button', + 'entity_category': None, + 'entity_id': 'button.volvo_ex30_flash', + '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': 'Flash', + 'platform': 'volvo', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'flash', + 'unique_id': 'yv1abcdefg1234567_flash', + 'unit_of_measurement': None, + }) +# --- +# name: test_button[ex30_2024][button.volvo_ex30_flash-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Volvo EX30 Flash', + }), + 'context': , + 'entity_id': 'button.volvo_ex30_flash', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_button[ex30_2024][button.volvo_ex30_honk-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'button', + 'entity_category': None, + 'entity_id': 'button.volvo_ex30_honk', + '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': 'Honk', + 'platform': 'volvo', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'honk', + 'unique_id': 'yv1abcdefg1234567_honk', + 'unit_of_measurement': None, + }) +# --- +# name: test_button[ex30_2024][button.volvo_ex30_honk-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Volvo EX30 Honk', + }), + 'context': , + 'entity_id': 'button.volvo_ex30_honk', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_button[ex30_2024][button.volvo_ex30_honk_flash-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'button', + 'entity_category': None, + 'entity_id': 'button.volvo_ex30_honk_flash', + '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': 'Honk & flash', + 'platform': 'volvo', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'honk_flash', + 'unique_id': 'yv1abcdefg1234567_honk_flash', + 'unit_of_measurement': None, + }) +# --- +# name: test_button[ex30_2024][button.volvo_ex30_honk_flash-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Volvo EX30 Honk & flash', + }), + 'context': , + 'entity_id': 'button.volvo_ex30_honk_flash', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_button[ex30_2024][button.volvo_ex30_start_climatization-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'button', + 'entity_category': None, + 'entity_id': 'button.volvo_ex30_start_climatization', + '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': 'Start climatization', + 'platform': 'volvo', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'climatization_start', + 'unique_id': 'yv1abcdefg1234567_climatization_start', + 'unit_of_measurement': None, + }) +# --- +# name: test_button[ex30_2024][button.volvo_ex30_start_climatization-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Volvo EX30 Start climatization', + }), + 'context': , + 'entity_id': 'button.volvo_ex30_start_climatization', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_button[ex30_2024][button.volvo_ex30_stop_climatization-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'button', + 'entity_category': None, + 'entity_id': 'button.volvo_ex30_stop_climatization', + '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': 'Stop climatization', + 'platform': 'volvo', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'climatization_stop', + 'unique_id': 'yv1abcdefg1234567_climatization_stop', + 'unit_of_measurement': None, + }) +# --- +# name: test_button[ex30_2024][button.volvo_ex30_stop_climatization-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Volvo EX30 Stop climatization', + }), + 'context': , + 'entity_id': 'button.volvo_ex30_stop_climatization', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_button[s90_diesel_2018][button.volvo_s90_flash-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'button', + 'entity_category': None, + 'entity_id': 'button.volvo_s90_flash', + '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': 'Flash', + 'platform': 'volvo', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'flash', + 'unique_id': 'yv1abcdefg1234567_flash', + 'unit_of_measurement': None, + }) +# --- +# name: test_button[s90_diesel_2018][button.volvo_s90_flash-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Volvo S90 Flash', + }), + 'context': , + 'entity_id': 'button.volvo_s90_flash', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_button[s90_diesel_2018][button.volvo_s90_honk-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'button', + 'entity_category': None, + 'entity_id': 'button.volvo_s90_honk', + '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': 'Honk', + 'platform': 'volvo', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'honk', + 'unique_id': 'yv1abcdefg1234567_honk', + 'unit_of_measurement': None, + }) +# --- +# name: test_button[s90_diesel_2018][button.volvo_s90_honk-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Volvo S90 Honk', + }), + 'context': , + 'entity_id': 'button.volvo_s90_honk', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_button[s90_diesel_2018][button.volvo_s90_honk_flash-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'button', + 'entity_category': None, + 'entity_id': 'button.volvo_s90_honk_flash', + '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': 'Honk & flash', + 'platform': 'volvo', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'honk_flash', + 'unique_id': 'yv1abcdefg1234567_honk_flash', + 'unit_of_measurement': None, + }) +# --- +# name: test_button[s90_diesel_2018][button.volvo_s90_honk_flash-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Volvo S90 Honk & flash', + }), + 'context': , + 'entity_id': 'button.volvo_s90_honk_flash', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_button[s90_diesel_2018][button.volvo_s90_start_climatization-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'button', + 'entity_category': None, + 'entity_id': 'button.volvo_s90_start_climatization', + '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': 'Start climatization', + 'platform': 'volvo', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'climatization_start', + 'unique_id': 'yv1abcdefg1234567_climatization_start', + 'unit_of_measurement': None, + }) +# --- +# name: test_button[s90_diesel_2018][button.volvo_s90_start_climatization-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Volvo S90 Start climatization', + }), + 'context': , + 'entity_id': 'button.volvo_s90_start_climatization', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_button[s90_diesel_2018][button.volvo_s90_stop_climatization-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'button', + 'entity_category': None, + 'entity_id': 'button.volvo_s90_stop_climatization', + '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': 'Stop climatization', + 'platform': 'volvo', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'climatization_stop', + 'unique_id': 'yv1abcdefg1234567_climatization_stop', + 'unit_of_measurement': None, + }) +# --- +# name: test_button[s90_diesel_2018][button.volvo_s90_stop_climatization-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Volvo S90 Stop climatization', + }), + 'context': , + 'entity_id': 'button.volvo_s90_stop_climatization', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_button[xc40_electric_2024][button.volvo_xc40_flash-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'button', + 'entity_category': None, + 'entity_id': 'button.volvo_xc40_flash', + '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': 'Flash', + 'platform': 'volvo', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'flash', + 'unique_id': 'yv1abcdefg1234567_flash', + 'unit_of_measurement': None, + }) +# --- +# name: test_button[xc40_electric_2024][button.volvo_xc40_flash-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Volvo XC40 Flash', + }), + 'context': , + 'entity_id': 'button.volvo_xc40_flash', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_button[xc40_electric_2024][button.volvo_xc40_honk-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'button', + 'entity_category': None, + 'entity_id': 'button.volvo_xc40_honk', + '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': 'Honk', + 'platform': 'volvo', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'honk', + 'unique_id': 'yv1abcdefg1234567_honk', + 'unit_of_measurement': None, + }) +# --- +# name: test_button[xc40_electric_2024][button.volvo_xc40_honk-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Volvo XC40 Honk', + }), + 'context': , + 'entity_id': 'button.volvo_xc40_honk', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_button[xc40_electric_2024][button.volvo_xc40_honk_flash-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'button', + 'entity_category': None, + 'entity_id': 'button.volvo_xc40_honk_flash', + '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': 'Honk & flash', + 'platform': 'volvo', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'honk_flash', + 'unique_id': 'yv1abcdefg1234567_honk_flash', + 'unit_of_measurement': None, + }) +# --- +# name: test_button[xc40_electric_2024][button.volvo_xc40_honk_flash-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Volvo XC40 Honk & flash', + }), + 'context': , + 'entity_id': 'button.volvo_xc40_honk_flash', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_button[xc40_electric_2024][button.volvo_xc40_start_climatization-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'button', + 'entity_category': None, + 'entity_id': 'button.volvo_xc40_start_climatization', + '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': 'Start climatization', + 'platform': 'volvo', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'climatization_start', + 'unique_id': 'yv1abcdefg1234567_climatization_start', + 'unit_of_measurement': None, + }) +# --- +# name: test_button[xc40_electric_2024][button.volvo_xc40_start_climatization-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Volvo XC40 Start climatization', + }), + 'context': , + 'entity_id': 'button.volvo_xc40_start_climatization', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_button[xc40_electric_2024][button.volvo_xc40_stop_climatization-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'button', + 'entity_category': None, + 'entity_id': 'button.volvo_xc40_stop_climatization', + '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': 'Stop climatization', + 'platform': 'volvo', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'climatization_stop', + 'unique_id': 'yv1abcdefg1234567_climatization_stop', + 'unit_of_measurement': None, + }) +# --- +# name: test_button[xc40_electric_2024][button.volvo_xc40_stop_climatization-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Volvo XC40 Stop climatization', + }), + 'context': , + 'entity_id': 'button.volvo_xc40_stop_climatization', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_button[xc90_petrol_2019][button.volvo_xc90_flash-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'button', + 'entity_category': None, + 'entity_id': 'button.volvo_xc90_flash', + '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': 'Flash', + 'platform': 'volvo', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'flash', + 'unique_id': 'yv1abcdefg1234567_flash', + 'unit_of_measurement': None, + }) +# --- +# name: test_button[xc90_petrol_2019][button.volvo_xc90_flash-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Volvo XC90 Flash', + }), + 'context': , + 'entity_id': 'button.volvo_xc90_flash', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_button[xc90_petrol_2019][button.volvo_xc90_honk-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'button', + 'entity_category': None, + 'entity_id': 'button.volvo_xc90_honk', + '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': 'Honk', + 'platform': 'volvo', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'honk', + 'unique_id': 'yv1abcdefg1234567_honk', + 'unit_of_measurement': None, + }) +# --- +# name: test_button[xc90_petrol_2019][button.volvo_xc90_honk-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Volvo XC90 Honk', + }), + 'context': , + 'entity_id': 'button.volvo_xc90_honk', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_button[xc90_petrol_2019][button.volvo_xc90_honk_flash-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'button', + 'entity_category': None, + 'entity_id': 'button.volvo_xc90_honk_flash', + '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': 'Honk & flash', + 'platform': 'volvo', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'honk_flash', + 'unique_id': 'yv1abcdefg1234567_honk_flash', + 'unit_of_measurement': None, + }) +# --- +# name: test_button[xc90_petrol_2019][button.volvo_xc90_honk_flash-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Volvo XC90 Honk & flash', + }), + 'context': , + 'entity_id': 'button.volvo_xc90_honk_flash', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_button[xc90_petrol_2019][button.volvo_xc90_start_climatization-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'button', + 'entity_category': None, + 'entity_id': 'button.volvo_xc90_start_climatization', + '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': 'Start climatization', + 'platform': 'volvo', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'climatization_start', + 'unique_id': 'yv1abcdefg1234567_climatization_start', + 'unit_of_measurement': None, + }) +# --- +# name: test_button[xc90_petrol_2019][button.volvo_xc90_start_climatization-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Volvo XC90 Start climatization', + }), + 'context': , + 'entity_id': 'button.volvo_xc90_start_climatization', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_button[xc90_petrol_2019][button.volvo_xc90_stop_climatization-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'button', + 'entity_category': None, + 'entity_id': 'button.volvo_xc90_stop_climatization', + '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': 'Stop climatization', + 'platform': 'volvo', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'climatization_stop', + 'unique_id': 'yv1abcdefg1234567_climatization_stop', + 'unit_of_measurement': None, + }) +# --- +# name: test_button[xc90_petrol_2019][button.volvo_xc90_stop_climatization-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Volvo XC90 Stop climatization', + }), + 'context': , + 'entity_id': 'button.volvo_xc90_stop_climatization', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- diff --git a/tests/components/volvo/snapshots/test_device_tracker.ambr b/tests/components/volvo/snapshots/test_device_tracker.ambr new file mode 100644 index 00000000000000..dd76a630125fe7 --- /dev/null +++ b/tests/components/volvo/snapshots/test_device_tracker.ambr @@ -0,0 +1,209 @@ +# serializer version: 1 +# name: test_device_tracker[ex30_2024][device_tracker.volvo_ex30_none-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'device_tracker', + 'entity_category': , + 'entity_id': 'device_tracker.volvo_ex30_none', + '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': 'volvo', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'location', + 'unique_id': 'yv1abcdefg1234567_location', + 'unit_of_measurement': None, + }) +# --- +# name: test_device_tracker[ex30_2024][device_tracker.volvo_ex30_none-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Volvo EX30 None', + 'gps_accuracy': 0, + 'latitude': 57.72537482589284, + 'longitude': 11.849843629550225, + 'source_type': , + }), + 'context': , + 'entity_id': 'device_tracker.volvo_ex30_none', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'not_home', + }) +# --- +# name: test_device_tracker[s90_diesel_2018][device_tracker.volvo_s90_none-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'device_tracker', + 'entity_category': , + 'entity_id': 'device_tracker.volvo_s90_none', + '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': 'volvo', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'location', + 'unique_id': 'yv1abcdefg1234567_location', + 'unit_of_measurement': None, + }) +# --- +# name: test_device_tracker[s90_diesel_2018][device_tracker.volvo_s90_none-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Volvo S90 None', + 'gps_accuracy': 0, + 'latitude': 57.72537482589284, + 'longitude': 11.849843629550225, + 'source_type': , + }), + 'context': , + 'entity_id': 'device_tracker.volvo_s90_none', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'not_home', + }) +# --- +# name: test_device_tracker[xc40_electric_2024][device_tracker.volvo_xc40_none-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'device_tracker', + 'entity_category': , + 'entity_id': 'device_tracker.volvo_xc40_none', + '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': 'volvo', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'location', + 'unique_id': 'yv1abcdefg1234567_location', + 'unit_of_measurement': None, + }) +# --- +# name: test_device_tracker[xc40_electric_2024][device_tracker.volvo_xc40_none-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Volvo XC40 None', + 'gps_accuracy': 0, + 'latitude': 57.72537482589284, + 'longitude': 11.849843629550225, + 'source_type': , + }), + 'context': , + 'entity_id': 'device_tracker.volvo_xc40_none', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'not_home', + }) +# --- +# name: test_device_tracker[xc90_petrol_2019][device_tracker.volvo_xc90_none-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'device_tracker', + 'entity_category': , + 'entity_id': 'device_tracker.volvo_xc90_none', + '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': 'volvo', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'location', + 'unique_id': 'yv1abcdefg1234567_location', + 'unit_of_measurement': None, + }) +# --- +# name: test_device_tracker[xc90_petrol_2019][device_tracker.volvo_xc90_none-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Volvo XC90 None', + 'gps_accuracy': 0, + 'latitude': 57.72537482589284, + 'longitude': 11.849843629550225, + 'source_type': , + }), + 'context': , + 'entity_id': 'device_tracker.volvo_xc90_none', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'not_home', + }) +# --- diff --git a/tests/components/volvo/snapshots/test_diagnostics.ambr b/tests/components/volvo/snapshots/test_diagnostics.ambr index 67f59c44a1912b..8b98a697829696 100644 --- a/tests/components/volvo/snapshots/test_diagnostics.ambr +++ b/tests/components/volvo/snapshots/test_diagnostics.ambr @@ -194,6 +194,23 @@ 'unit': None, 'value': 'AVAILABLE', }), + 'location': dict({ + 'extra_data': dict({ + }), + 'geometry': dict({ + 'coordinates': '**REDACTED**', + 'extra_data': dict({ + 'type': 'Point', + }), + }), + 'properties': dict({ + 'extra_data': dict({ + }), + 'heading': '**REDACTED**', + 'timestamp': '2024-12-30T15:00:00+00:00', + }), + 'type': 'Feature', + }), }), 'Volvo very slow interval coordinator': dict({ 'averageEnergyConsumption': dict({ diff --git a/tests/components/volvo/snapshots/test_sensor.ambr b/tests/components/volvo/snapshots/test_sensor.ambr index a8c1f10357a7e4..6075f7400dc010 100644 --- a/tests/components/volvo/snapshots/test_sensor.ambr +++ b/tests/components/volvo/snapshots/test_sensor.ambr @@ -476,6 +476,58 @@ 'state': 'ac', }) # --- +# name: test_sensor[ex30_2024][sensor.volvo_ex30_direction-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.volvo_ex30_direction', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Direction', + 'platform': 'volvo', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'direction', + 'unique_id': 'yv1abcdefg1234567_direction', + 'unit_of_measurement': '°', + }) +# --- +# name: test_sensor[ex30_2024][sensor.volvo_ex30_direction-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Volvo EX30 Direction', + 'unit_of_measurement': '°', + }), + 'context': , + 'entity_id': 'sensor.volvo_ex30_direction', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '90', + }) +# --- # name: test_sensor[ex30_2024][sensor.volvo_ex30_distance_to_empty_battery-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -1263,6 +1315,58 @@ 'state': 'available', }) # --- +# name: test_sensor[s90_diesel_2018][sensor.volvo_s90_direction-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.volvo_s90_direction', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Direction', + 'platform': 'volvo', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'direction', + 'unique_id': 'yv1abcdefg1234567_direction', + 'unit_of_measurement': '°', + }) +# --- +# name: test_sensor[s90_diesel_2018][sensor.volvo_s90_direction-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Volvo S90 Direction', + 'unit_of_measurement': '°', + }), + 'context': , + 'entity_id': 'sensor.volvo_s90_direction', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '90', + }) +# --- # name: test_sensor[s90_diesel_2018][sensor.volvo_s90_distance_to_empty_tank-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -2411,6 +2515,58 @@ 'state': 'ac', }) # --- +# name: test_sensor[xc40_electric_2024][sensor.volvo_xc40_direction-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.volvo_xc40_direction', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Direction', + 'platform': 'volvo', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'direction', + 'unique_id': 'yv1abcdefg1234567_direction', + 'unit_of_measurement': '°', + }) +# --- +# name: test_sensor[xc40_electric_2024][sensor.volvo_xc40_direction-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Volvo XC40 Direction', + 'unit_of_measurement': '°', + }), + 'context': , + 'entity_id': 'sensor.volvo_xc40_direction', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '90', + }) +# --- # name: test_sensor[xc40_electric_2024][sensor.volvo_xc40_distance_to_empty_battery-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -3377,6 +3533,58 @@ 'state': 'idle', }) # --- +# name: test_sensor[xc60_phev_2020][sensor.volvo_xc60_direction-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.volvo_xc60_direction', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Direction', + 'platform': 'volvo', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'direction', + 'unique_id': 'yv1abcdefg1234567_direction', + 'unit_of_measurement': '°', + }) +# --- +# name: test_sensor[xc60_phev_2020][sensor.volvo_xc60_direction-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Volvo XC60 Direction', + 'unit_of_measurement': '°', + }), + 'context': , + 'entity_id': 'sensor.volvo_xc60_direction', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '90', + }) +# --- # name: test_sensor[xc60_phev_2020][sensor.volvo_xc60_distance_to_empty_battery-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -4164,6 +4372,58 @@ 'state': 'available', }) # --- +# name: test_sensor[xc90_petrol_2019][sensor.volvo_xc90_direction-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.volvo_xc90_direction', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Direction', + 'platform': 'volvo', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'direction', + 'unique_id': 'yv1abcdefg1234567_direction', + 'unit_of_measurement': '°', + }) +# --- +# name: test_sensor[xc90_petrol_2019][sensor.volvo_xc90_direction-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Volvo XC90 Direction', + 'unit_of_measurement': '°', + }), + 'context': , + 'entity_id': 'sensor.volvo_xc90_direction', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '90', + }) +# --- # name: test_sensor[xc90_petrol_2019][sensor.volvo_xc90_distance_to_empty_tank-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -5200,6 +5460,58 @@ 'state': 'none', }) # --- +# name: test_sensor[xc90_phev_2024][sensor.volvo_xc90_direction-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.volvo_xc90_direction', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Direction', + 'platform': 'volvo', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'direction', + 'unique_id': 'yv1abcdefg1234567_direction', + 'unit_of_measurement': '°', + }) +# --- +# name: test_sensor[xc90_phev_2024][sensor.volvo_xc90_direction-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Volvo XC90 Direction', + 'unit_of_measurement': '°', + }), + 'context': , + 'entity_id': 'sensor.volvo_xc90_direction', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '90', + }) +# --- # name: test_sensor[xc90_phev_2024][sensor.volvo_xc90_distance_to_empty_battery-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ diff --git a/tests/components/volvo/test_button.py b/tests/components/volvo/test_button.py new file mode 100644 index 00000000000000..b22ac4077ac067 --- /dev/null +++ b/tests/components/volvo/test_button.py @@ -0,0 +1,128 @@ +"""Test Volvo buttons.""" + +from collections.abc import Awaitable, Callable +from unittest.mock import patch + +import pytest +from syrupy.assertion import SnapshotAssertion +from volvocarsapi.api import VolvoCarsApi +from volvocarsapi.models import VolvoApiException, VolvoCarsCommandResult + +from homeassistant.components.button import DOMAIN as BUTTON_DOMAIN, SERVICE_PRESS +from homeassistant.const import ATTR_ENTITY_ID, Platform +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers import entity_registry as er + +from . import configure_mock +from .const import DEFAULT_VIN + +from tests.common import MockConfigEntry, snapshot_platform + + +@pytest.mark.usefixtures("mock_api", "full_model") +@pytest.mark.parametrize( + "full_model", + ["ex30_2024", "s90_diesel_2018", "xc40_electric_2024", "xc90_petrol_2019"], +) +async def test_button( + hass: HomeAssistant, + setup_integration: Callable[[], Awaitable[bool]], + entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, + mock_config_entry: MockConfigEntry, +) -> None: + """Test button.""" + + with patch("homeassistant.components.volvo.PLATFORMS", [Platform.BUTTON]): + assert await setup_integration() + + await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) + + +@pytest.mark.usefixtures("full_model") +@pytest.mark.parametrize( + "command", + ["start_climatization", "stop_climatization", "flash", "honk", "honk_flash"], +) +async def test_button_press( + hass: HomeAssistant, + setup_integration: Callable[[], Awaitable[bool]], + mock_api: VolvoCarsApi, + command: str, +) -> None: + """Test button press.""" + + with patch("homeassistant.components.volvo.PLATFORMS", [Platform.BUTTON]): + assert await setup_integration() + + entity_id = f"button.volvo_xc40_{command}" + + await hass.services.async_call( + BUTTON_DOMAIN, + SERVICE_PRESS, + {ATTR_ENTITY_ID: entity_id}, + blocking=True, + ) + await hass.async_block_till_done() + + assert len(mock_api.async_execute_command.mock_calls) == 1 + + +@pytest.mark.usefixtures("full_model") +@pytest.mark.parametrize( + "command", + ["start_climatization", "stop_climatization", "flash", "honk", "honk_flash"], +) +async def test_button_press_error( + hass: HomeAssistant, + setup_integration: Callable[[], Awaitable[bool]], + mock_api: VolvoCarsApi, + command: str, +) -> None: + """Test button press with error response.""" + + with patch("homeassistant.components.volvo.PLATFORMS", [Platform.BUTTON]): + assert await setup_integration() + + configure_mock(mock_api.async_execute_command, side_effect=VolvoApiException) + + with pytest.raises(HomeAssistantError): + await hass.services.async_call( + BUTTON_DOMAIN, + SERVICE_PRESS, + {ATTR_ENTITY_ID: f"button.volvo_xc40_{command}"}, + blocking=True, + ) + + +@pytest.mark.usefixtures("full_model") +@pytest.mark.parametrize( + "command", + ["start_climatization", "stop_climatization", "flash", "honk", "honk_flash"], +) +async def test_button_press_failure( + hass: HomeAssistant, + setup_integration: Callable[[], Awaitable[bool]], + mock_api: VolvoCarsApi, + command: str, +) -> None: + """Test button press with business logic failure.""" + + with patch("homeassistant.components.volvo.PLATFORMS", [Platform.BUTTON]): + assert await setup_integration() + + configure_mock( + mock_api.async_execute_command, + return_value=VolvoCarsCommandResult( + vin=DEFAULT_VIN, invoke_status="CONNECTION_FAILURE", message="" + ), + ) + + with pytest.raises(HomeAssistantError): + await hass.services.async_call( + BUTTON_DOMAIN, + SERVICE_PRESS, + {ATTR_ENTITY_ID: f"button.volvo_xc40_{command}"}, + blocking=True, + ) diff --git a/tests/components/volvo/test_config_flow.py b/tests/components/volvo/test_config_flow.py index 3129b1383fea0b..c33331d5629117 100644 --- a/tests/components/volvo/test_config_flow.py +++ b/tests/components/volvo/test_config_flow.py @@ -7,7 +7,7 @@ from volvocarsapi.api import VolvoCarsApi from volvocarsapi.auth import AUTHORIZE_URL, TOKEN_URL from volvocarsapi.models import VolvoApiException, VolvoCarsVehicle -from volvocarsapi.scopes import DEFAULT_SCOPES +from volvocarsapi.scopes import ALL_SCOPES from yarl import URL from homeassistant import config_entries @@ -251,7 +251,7 @@ async def config_flow( assert result_url.query["state"] == state assert result_url.query["code_challenge"] assert result_url.query["code_challenge_method"] == "S256" - assert result_url.query["scope"] == " ".join(DEFAULT_SCOPES) + assert result_url.query["scope"] == " ".join(ALL_SCOPES) client = await hass_client_no_auth() resp = await client.get(f"/auth/external/callback?code=abcd&state={state}") diff --git a/tests/components/volvo/test_coordinator.py b/tests/components/volvo/test_coordinator.py index 271693a18d10d1..8d1a8f0a725a6d 100644 --- a/tests/components/volvo/test_coordinator.py +++ b/tests/components/volvo/test_coordinator.py @@ -13,6 +13,7 @@ VolvoCarsValueField, ) +from homeassistant.components.volvo.const import DOMAIN from homeassistant.components.volvo.coordinator import VERY_SLOW_INTERVAL from homeassistant.const import STATE_UNAVAILABLE from homeassistant.core import HomeAssistant @@ -123,8 +124,10 @@ async def test_update_coordinator_all_error( freezer.tick(timedelta(minutes=VERY_SLOW_INTERVAL)) async_fire_time_changed(hass) await hass.async_block_till_done(wait_background_tasks=True) - for state in hass.states.async_all(): - assert state.state == STATE_UNAVAILABLE + + for state in hass.states.async_all(domain_filter=DOMAIN): + if state.domain != "button": + assert state.state == STATE_UNAVAILABLE def _mock_api_failure(mock_api: VolvoCarsApi) -> AsyncMock: diff --git a/tests/components/volvo/test_device_tracker.py b/tests/components/volvo/test_device_tracker.py new file mode 100644 index 00000000000000..5d0d7148e4903a --- /dev/null +++ b/tests/components/volvo/test_device_tracker.py @@ -0,0 +1,33 @@ +"""Test Volvo device tracker.""" + +from collections.abc import Awaitable, Callable +from unittest.mock import patch + +import pytest +from syrupy.assertion import SnapshotAssertion + +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from tests.common import MockConfigEntry, snapshot_platform + + +@pytest.mark.usefixtures("mock_api", "full_model") +@pytest.mark.parametrize( + "full_model", + ["ex30_2024", "s90_diesel_2018", "xc40_electric_2024", "xc90_petrol_2019"], +) +async def test_device_tracker( + hass: HomeAssistant, + setup_integration: Callable[[], Awaitable[bool]], + entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, + mock_config_entry: MockConfigEntry, +) -> None: + """Test device tracker.""" + + with patch("homeassistant.components.volvo.PLATFORMS", [Platform.DEVICE_TRACKER]): + assert await setup_integration() + + await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) diff --git a/tests/components/volvo/test_sensor.py b/tests/components/volvo/test_sensor.py index 05571ff8cac18f..ca577c12892a53 100644 --- a/tests/components/volvo/test_sensor.py +++ b/tests/components/volvo/test_sensor.py @@ -1,13 +1,19 @@ """Test Volvo sensors.""" from collections.abc import Awaitable, Callable -from unittest.mock import patch +from unittest.mock import AsyncMock, patch import pytest from syrupy.assertion import SnapshotAssertion +from volvocarsapi.api import VolvoCarsApi +from volvocarsapi.models import ( + VolvoCarsErrorResult, + VolvoCarsValue, + VolvoCarsValueField, +) from homeassistant.components.volvo.const import DOMAIN -from homeassistant.const import Platform +from homeassistant.const import STATE_UNKNOWN, Platform from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er @@ -116,3 +122,96 @@ async def test_unique_ids( assert await setup_integration() assert f"Platform {DOMAIN} does not generate unique IDs" not in caplog.text + + +async def test_availability_status_reason( + hass: HomeAssistant, + setup_integration: Callable[[], Awaitable[bool]], + mock_api: VolvoCarsApi, +) -> None: + """Test availability_status entity returns unavailable reason.""" + + mock_method: AsyncMock = mock_api.async_get_command_accessibility + mock_method.return_value["availabilityStatus"] = VolvoCarsValue( + value="UNAVAILABLE", extra_data={"unavailable_reason": "no_internet"} + ) + + with patch("homeassistant.components.volvo.PLATFORMS", [Platform.SENSOR]): + assert await setup_integration() + + state = hass.states.get("sensor.volvo_xc40_car_connection") + assert state.state == "no_internet" + + +async def test_time_to_service_non_value_field( + hass: HomeAssistant, + setup_integration: Callable[[], Awaitable[bool]], + mock_api: VolvoCarsApi, +) -> None: + """Test time_to_service entity with non-VolvoCarsValueField returns 0.""" + + mock_method: AsyncMock = mock_api.async_get_diagnostics + mock_method.return_value["timeToService"] = VolvoCarsErrorResult(message="invalid") + + with patch("homeassistant.components.volvo.PLATFORMS", [Platform.SENSOR]): + assert await setup_integration() + + state = hass.states.get("sensor.volvo_xc40_time_to_service") + assert state.state == "0" + + +async def test_time_to_service_months_conversion( + hass: HomeAssistant, + setup_integration: Callable[[], Awaitable[bool]], + mock_api: VolvoCarsApi, +) -> None: + """Test time_to_service entity converts months to days.""" + + mock_method: AsyncMock = mock_api.async_get_diagnostics + mock_method.return_value["timeToService"] = VolvoCarsValueField( + value=3, unit="months" + ) + + with patch("homeassistant.components.volvo.PLATFORMS", [Platform.SENSOR]): + assert await setup_integration() + + state = hass.states.get("sensor.volvo_xc40_time_to_service") + assert state.state == "90" + + +async def test_charging_power_value_fallback( + hass: HomeAssistant, + setup_integration: Callable[[], Awaitable[bool]], + mock_api: VolvoCarsApi, +) -> None: + """Test charging_power entity returns 0 for invalid field types.""" + + mock_method: AsyncMock = mock_api.async_get_energy_state + mock_method.return_value["chargingPower"] = VolvoCarsErrorResult(message="invalid") + + with patch("homeassistant.components.volvo.PLATFORMS", [Platform.SENSOR]): + assert await setup_integration() + + state = hass.states.get("sensor.volvo_xc40_charging_power") + assert state.state == "0" + + +async def test_charging_power_status_unknown_value( + hass: HomeAssistant, + setup_integration: Callable[[], Awaitable[bool]], + mock_api: VolvoCarsApi, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test charging_power_status entity with unknown status logs warning.""" + + mock_method: AsyncMock = mock_api.async_get_energy_state + mock_method.return_value["chargerPowerStatus"] = VolvoCarsValue( + value="unknown_status" + ) + + with patch("homeassistant.components.volvo.PLATFORMS", [Platform.SENSOR]): + assert await setup_integration() + + state = hass.states.get("sensor.volvo_xc40_charging_power_status") + assert state.state == STATE_UNKNOWN + assert "Unknown value 'unknown_status' for charging_power_status" in caplog.text diff --git a/tests/util/test_unit_conversion.py b/tests/util/test_unit_conversion.py index 7b7c37527294c0..824d9708f7fa95 100644 --- a/tests/util/test_unit_conversion.py +++ b/tests/util/test_unit_conversion.py @@ -44,6 +44,7 @@ AreaConverter, BaseUnitConverter, BloodGlucoseConcentrationConverter, + CarbonMonoxideConcentrationConverter, ConductivityConverter, DataRateConverter, DistanceConverter, @@ -78,6 +79,7 @@ AreaConverter, BloodGlucoseConcentrationConverter, MassVolumeConcentrationConverter, + CarbonMonoxideConcentrationConverter, ConductivityConverter, DataRateConverter, DistanceConverter, @@ -114,6 +116,11 @@ UnitOfBloodGlucoseConcentration.MILLIMOLE_PER_LITER, 18, ), + CarbonMonoxideConcentrationConverter: ( + CONCENTRATION_MILLIGRAMS_PER_CUBIC_METER, + CONCENTRATION_PARTS_PER_MILLION, + 1.16441, + ), ConductivityConverter: ( UnitOfConductivity.MICROSIEMENS_PER_CM, UnitOfConductivity.MILLISIEMENS_PER_CM, @@ -280,6 +287,20 @@ UnitOfBloodGlucoseConcentration.MILLIGRAMS_PER_DECILITER, ), ], + CarbonMonoxideConcentrationConverter: [ + ( + 1, + CONCENTRATION_PARTS_PER_MILLION, + 1.16441, + CONCENTRATION_MILLIGRAMS_PER_CUBIC_METER, + ), + ( + 120, + CONCENTRATION_MILLIGRAMS_PER_CUBIC_METER, + 103.05655, + CONCENTRATION_PARTS_PER_MILLION, + ), + ], ConductivityConverter: [ ( 5,