diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 805e3ac4dbd..a059710d3d7 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -8,7 +8,7 @@ repos: - id: ruff-format files: ^((homeassistant|pylint|script|tests)/.+)?[^/]+\.(py|pyi)$ - repo: https://github.com/codespell-project/codespell - rev: v2.3.0 + rev: v2.4.1 hooks: - id: codespell args: diff --git a/CODEOWNERS b/CODEOWNERS index 635f53d346f..e510eec6dfa 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -731,6 +731,8 @@ build.json @home-assistant/supervisor /homeassistant/components/intent/ @home-assistant/core @synesthesiam /tests/components/intent/ @home-assistant/core @synesthesiam /homeassistant/components/intesishome/ @jnimmo +/homeassistant/components/iometer/ @MaestroOnICe +/tests/components/iometer/ @MaestroOnICe /homeassistant/components/ios/ @robbiet480 /tests/components/ios/ @robbiet480 /homeassistant/components/iotawatt/ @gtdiehl @jyavenard @@ -765,8 +767,8 @@ build.json @home-assistant/supervisor /tests/components/ituran/ @shmuelzon /homeassistant/components/izone/ @Swamp-Ig /tests/components/izone/ @Swamp-Ig -/homeassistant/components/jellyfin/ @j-stienstra @ctalkington -/tests/components/jellyfin/ @j-stienstra @ctalkington +/homeassistant/components/jellyfin/ @RunC0deRun @ctalkington +/tests/components/jellyfin/ @RunC0deRun @ctalkington /homeassistant/components/jewish_calendar/ @tsvi /tests/components/jewish_calendar/ @tsvi /homeassistant/components/juicenet/ @jesserockz diff --git a/Dockerfile b/Dockerfile index 171d08731a9..19b2c97b181 100644 --- a/Dockerfile +++ b/Dockerfile @@ -13,7 +13,7 @@ ENV \ ARG QEMU_CPU # Install uv -RUN pip3 install uv==0.5.21 +RUN pip3 install uv==0.5.27 WORKDIR /usr/src diff --git a/homeassistant/components/airthings_ble/config_flow.py b/homeassistant/components/airthings_ble/config_flow.py index 48c7219cbaf..3e7b659bff1 100644 --- a/homeassistant/components/airthings_ble/config_flow.py +++ b/homeassistant/components/airthings_ble/config_flow.py @@ -144,7 +144,7 @@ class AirthingsConfigFlow(ConfigFlow, domain=DOMAIN): return self.async_create_entry(title=discovery.name, data={}) - current_addresses = self._async_current_ids() + current_addresses = self._async_current_ids(include_ignore=False) for discovery_info in async_discovered_service_info(self.hass): address = discovery_info.address if address in current_addresses or address in self._discovered_devices: diff --git a/homeassistant/components/analytics_insights/manifest.json b/homeassistant/components/analytics_insights/manifest.json index bf99d89e073..ab3c2e2fe24 100644 --- a/homeassistant/components/analytics_insights/manifest.json +++ b/homeassistant/components/analytics_insights/manifest.json @@ -7,6 +7,6 @@ "integration_type": "service", "iot_class": "cloud_polling", "loggers": ["python_homeassistant_analytics"], - "requirements": ["python-homeassistant-analytics==0.8.1"], + "requirements": ["python-homeassistant-analytics==0.9.0"], "single_config_entry": true } diff --git a/homeassistant/components/anthropic/conversation.py b/homeassistant/components/anthropic/conversation.py index e45e849adf6..259d1295809 100644 --- a/homeassistant/components/anthropic/conversation.py +++ b/homeassistant/components/anthropic/conversation.py @@ -272,6 +272,7 @@ class AnthropicConversationEntity( continue tool_input = llm.ToolInput( + id=tool_call.id, tool_name=tool_call.name, tool_args=cast(dict[str, Any], tool_call.input), ) diff --git a/homeassistant/components/apple_tv/config_flow.py b/homeassistant/components/apple_tv/config_flow.py index 5c317755d05..76c4681a30d 100644 --- a/homeassistant/components/apple_tv/config_flow.py +++ b/homeassistant/components/apple_tv/config_flow.py @@ -134,7 +134,7 @@ class AppleTVConfigFlow(ConfigFlow, domain=DOMAIN): unique_id for said entry. When a new (zeroconf) service or device is discovered, the identifier is first used to look up if it belongs to an existing config entry. If that's the case, the unique_id from that entry is - re-used, otherwise the newly discovered identifier is used instead. + reused, otherwise the newly discovered identifier is used instead. """ assert self.atv all_identifiers = set(self.atv.all_identifiers) diff --git a/homeassistant/components/aranet/config_flow.py b/homeassistant/components/aranet/config_flow.py index db89124c54d..876b175126e 100644 --- a/homeassistant/components/aranet/config_flow.py +++ b/homeassistant/components/aranet/config_flow.py @@ -92,7 +92,7 @@ class AranetConfigFlow(ConfigFlow, domain=DOMAIN): title=self._discovered_devices[address][0], data={} ) - current_addresses = self._async_current_ids() + current_addresses = self._async_current_ids(include_ignore=False) for discovery_info in async_discovered_service_info(self.hass, False): address = discovery_info.address if address in current_addresses or address in self._discovered_devices: diff --git a/homeassistant/components/aranet/manifest.json b/homeassistant/components/aranet/manifest.json index ac45e352bb6..3131b00cda6 100644 --- a/homeassistant/components/aranet/manifest.json +++ b/homeassistant/components/aranet/manifest.json @@ -19,5 +19,5 @@ "documentation": "https://www.home-assistant.io/integrations/aranet", "integration_type": "device", "iot_class": "local_push", - "requirements": ["aranet4==2.5.0"] + "requirements": ["aranet4==2.5.1"] } diff --git a/homeassistant/components/assist_pipeline/__init__.py b/homeassistant/components/assist_pipeline/__init__.py index cc7ecc1c426..9a32821e3a0 100644 --- a/homeassistant/components/assist_pipeline/__init__.py +++ b/homeassistant/components/assist_pipeline/__init__.py @@ -9,6 +9,7 @@ import voluptuous as vol from homeassistant.components import stt from homeassistant.core import Context, HomeAssistant +from homeassistant.helpers import chat_session from homeassistant.helpers.typing import ConfigType from .const import ( @@ -114,24 +115,25 @@ async def async_pipeline_from_audio_stream( Raises PipelineNotFound if no pipeline is found. """ - pipeline_input = PipelineInput( - conversation_id=conversation_id, - device_id=device_id, - stt_metadata=stt_metadata, - stt_stream=stt_stream, - wake_word_phrase=wake_word_phrase, - conversation_extra_system_prompt=conversation_extra_system_prompt, - run=PipelineRun( - hass, - context=context, - pipeline=async_get_pipeline(hass, pipeline_id=pipeline_id), - start_stage=start_stage, - end_stage=end_stage, - event_callback=event_callback, - tts_audio_output=tts_audio_output, - wake_word_settings=wake_word_settings, - audio_settings=audio_settings or AudioSettings(), - ), - ) - await pipeline_input.validate() - await pipeline_input.execute() + with chat_session.async_get_chat_session(hass, conversation_id) as session: + pipeline_input = PipelineInput( + conversation_id=session.conversation_id, + device_id=device_id, + stt_metadata=stt_metadata, + stt_stream=stt_stream, + wake_word_phrase=wake_word_phrase, + conversation_extra_system_prompt=conversation_extra_system_prompt, + run=PipelineRun( + hass, + context=context, + pipeline=async_get_pipeline(hass, pipeline_id=pipeline_id), + start_stage=start_stage, + end_stage=end_stage, + event_callback=event_callback, + tts_audio_output=tts_audio_output, + wake_word_settings=wake_word_settings, + audio_settings=audio_settings or AudioSettings(), + ), + ) + await pipeline_input.validate() + await pipeline_input.execute() diff --git a/homeassistant/components/assist_pipeline/pipeline.py b/homeassistant/components/assist_pipeline/pipeline.py index 1d320d79bf2..94e2b04d7ae 100644 --- a/homeassistant/components/assist_pipeline/pipeline.py +++ b/homeassistant/components/assist_pipeline/pipeline.py @@ -33,7 +33,7 @@ from homeassistant.components.tts import ( from homeassistant.const import MATCH_ALL from homeassistant.core import Context, HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers import intent +from homeassistant.helpers import chat_session, intent from homeassistant.helpers.collection import ( CHANGE_UPDATED, CollectionError, @@ -624,7 +624,7 @@ class PipelineRun: return pipeline_data.pipeline_debug[self.pipeline.id][self.id].events.append(event) - def start(self, device_id: str | None) -> None: + def start(self, conversation_id: str, device_id: str | None) -> None: """Emit run start event.""" self._device_id = device_id self._start_debug_recording_thread() @@ -632,6 +632,7 @@ class PipelineRun: data = { "pipeline": self.pipeline.id, "language": self.language, + "conversation_id": conversation_id, } if self.runner_data is not None: data["runner_data"] = self.runner_data @@ -1015,7 +1016,7 @@ class PipelineRun: async def recognize_intent( self, intent_input: str, - conversation_id: str | None, + conversation_id: str, device_id: str | None, conversation_extra_system_prompt: str | None, ) -> str: @@ -1063,11 +1064,11 @@ class PipelineRun: agent_id=self.intent_agent, extra_system_prompt=conversation_extra_system_prompt, ) - processed_locally = self.intent_agent == conversation.HOME_ASSISTANT_AGENT - agent_id = user_input.agent_id + agent_id = self.intent_agent + processed_locally = agent_id == conversation.HOME_ASSISTANT_AGENT intent_response: intent.IntentResponse | None = None - if user_input.agent_id != conversation.HOME_ASSISTANT_AGENT: + if not processed_locally: # Sentence triggers override conversation agent if ( trigger_response_text @@ -1094,22 +1095,26 @@ class PipelineRun: # It was already handled, create response and add to chat history if intent_response is not None: - async with conversation.async_get_chat_session( - self.hass, user_input - ) as chat_session: + with ( + chat_session.async_get_chat_session( + self.hass, user_input.conversation_id + ) as session, + conversation.async_get_chat_log( + self.hass, session, user_input + ) as chat_log, + ): speech: str = intent_response.speech.get("plain", {}).get( "speech", "" ) - chat_session.async_add_message( - conversation.Content( - role="assistant", + chat_log.async_add_assistant_content_without_tools( + conversation.AssistantContent( agent_id=agent_id, content=speech, ) ) conversation_result = conversation.ConversationResult( response=intent_response, - conversation_id=chat_session.conversation_id, + conversation_id=session.conversation_id, ) else: @@ -1404,12 +1409,15 @@ def _pipeline_debug_recording_thread_proc( wav_writer.close() -@dataclass +@dataclass(kw_only=True) class PipelineInput: """Input to a pipeline run.""" run: PipelineRun + conversation_id: str + """Identifier for the conversation.""" + stt_metadata: stt.SpeechMetadata | None = None """Metadata of stt input audio. Required when start_stage = stt.""" @@ -1425,9 +1433,6 @@ class PipelineInput: tts_input: str | None = None """Input for text-to-speech. Required when start_stage = tts.""" - conversation_id: str | None = None - """Identifier for the conversation.""" - conversation_extra_system_prompt: str | None = None """Extra prompt information for the conversation agent.""" @@ -1436,7 +1441,7 @@ class PipelineInput: async def execute(self) -> None: """Run pipeline.""" - self.run.start(device_id=self.device_id) + self.run.start(conversation_id=self.conversation_id, device_id=self.device_id) current_stage: PipelineStage | None = self.run.start_stage stt_audio_buffer: list[EnhancedAudioChunk] = [] stt_processed_stream: AsyncIterable[EnhancedAudioChunk] | None = None diff --git a/homeassistant/components/assist_pipeline/websocket_api.py b/homeassistant/components/assist_pipeline/websocket_api.py index 69f917fcf83..d2d54a1b7c3 100644 --- a/homeassistant/components/assist_pipeline/websocket_api.py +++ b/homeassistant/components/assist_pipeline/websocket_api.py @@ -14,7 +14,11 @@ import voluptuous as vol from homeassistant.components import conversation, stt, tts, websocket_api from homeassistant.const import ATTR_DEVICE_ID, ATTR_SECONDS, MATCH_ALL from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers import config_validation as cv, entity_registry as er +from homeassistant.helpers import ( + chat_session, + config_validation as cv, + entity_registry as er, +) from homeassistant.util import language as language_util from .const import ( @@ -145,7 +149,6 @@ async def websocket_run( # Arguments to PipelineInput input_args: dict[str, Any] = { - "conversation_id": msg.get("conversation_id"), "device_id": msg.get("device_id"), } @@ -233,38 +236,42 @@ async def websocket_run( audio_settings=audio_settings or AudioSettings(), ) - pipeline_input = PipelineInput(**input_args) + with chat_session.async_get_chat_session( + hass, msg.get("conversation_id") + ) as session: + input_args["conversation_id"] = session.conversation_id + pipeline_input = PipelineInput(**input_args) - try: - await pipeline_input.validate() - except PipelineError as error: - # Report more specific error when possible - connection.send_error(msg["id"], error.code, error.message) - return + try: + await pipeline_input.validate() + except PipelineError as error: + # Report more specific error when possible + connection.send_error(msg["id"], error.code, error.message) + return - # Confirm subscription - connection.send_result(msg["id"]) + # Confirm subscription + connection.send_result(msg["id"]) - run_task = hass.async_create_task(pipeline_input.execute()) + run_task = hass.async_create_task(pipeline_input.execute()) - # Cancel pipeline if user unsubscribes - connection.subscriptions[msg["id"]] = run_task.cancel + # Cancel pipeline if user unsubscribes + connection.subscriptions[msg["id"]] = run_task.cancel - try: - # Task contains a timeout - async with asyncio.timeout(timeout): - await run_task - except TimeoutError: - pipeline_input.run.process_event( - PipelineEvent( - PipelineEventType.ERROR, - {"code": "timeout", "message": "Timeout running pipeline"}, + try: + # Task contains a timeout + async with asyncio.timeout(timeout): + await run_task + except TimeoutError: + pipeline_input.run.process_event( + PipelineEvent( + PipelineEventType.ERROR, + {"code": "timeout", "message": "Timeout running pipeline"}, + ) ) - ) - finally: - if unregister_handler is not None: - # Unregister binary handler - unregister_handler() + finally: + if unregister_handler is not None: + # Unregister binary handler + unregister_handler() @callback diff --git a/homeassistant/components/assist_satellite/entity.py b/homeassistant/components/assist_satellite/entity.py index 0229e0358b1..e43abb4539c 100644 --- a/homeassistant/components/assist_satellite/entity.py +++ b/homeassistant/components/assist_satellite/entity.py @@ -8,7 +8,7 @@ from dataclasses import dataclass from enum import StrEnum import logging import time -from typing import Any, Final, Literal, final +from typing import Any, Literal, final from homeassistant.components import conversation, media_source, stt, tts from homeassistant.components.assist_pipeline import ( @@ -28,14 +28,12 @@ from homeassistant.components.tts import ( ) from homeassistant.core import Context, callback from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers import entity +from homeassistant.helpers import chat_session, entity from homeassistant.helpers.entity import EntityDescription from .const import AssistSatelliteEntityFeature from .errors import AssistSatelliteError, SatelliteBusyError -_CONVERSATION_TIMEOUT_SEC: Final = 5 * 60 # 5 minutes - _LOGGER = logging.getLogger(__name__) @@ -114,7 +112,6 @@ class AssistSatelliteEntity(entity.Entity): _attr_vad_sensitivity_entity_id: str | None = None _conversation_id: str | None = None - _conversation_id_time: float | None = None _run_has_tts: bool = False _is_announcing = False @@ -260,8 +257,27 @@ class AssistSatelliteEntity(entity.Entity): else: self._extra_system_prompt = start_message or None + with ( + # Not passing in a conversation ID will force a new one to be created + chat_session.async_get_chat_session(self.hass) as session, + conversation.async_get_chat_log(self.hass, session) as chat_log, + ): + self._conversation_id = session.conversation_id + + if start_message: + chat_log.async_add_assistant_content_without_tools( + conversation.AssistantContent( + agent_id=self.entity_id, content=start_message + ) + ) + try: await self.async_start_conversation(announcement) + except Exception: + # Clear prompt on error + self._conversation_id = None + self._extra_system_prompt = None + raise finally: self._is_announcing = False @@ -325,51 +341,52 @@ class AssistSatelliteEntity(entity.Entity): assert self._context is not None - # Reset conversation id if necessary - if self._conversation_id_time and ( - (time.monotonic() - self._conversation_id_time) > _CONVERSATION_TIMEOUT_SEC - ): - self._conversation_id = None - self._conversation_id_time = None - # Set entity state based on pipeline events self._run_has_tts = False assert self.platform.config_entry is not None - self._pipeline_task = self.platform.config_entry.async_create_background_task( - self.hass, - async_pipeline_from_audio_stream( - self.hass, - context=self._context, - event_callback=self._internal_on_pipeline_event, - stt_metadata=stt.SpeechMetadata( - language="", # set in async_pipeline_from_audio_stream - format=stt.AudioFormats.WAV, - codec=stt.AudioCodecs.PCM, - bit_rate=stt.AudioBitRates.BITRATE_16, - sample_rate=stt.AudioSampleRates.SAMPLERATE_16000, - channel=stt.AudioChannels.CHANNEL_MONO, - ), - stt_stream=audio_stream, - pipeline_id=self._resolve_pipeline(), - conversation_id=self._conversation_id, - device_id=device_id, - tts_audio_output=self.tts_options, - wake_word_phrase=wake_word_phrase, - audio_settings=AudioSettings( - silence_seconds=self._resolve_vad_sensitivity() - ), - start_stage=start_stage, - end_stage=end_stage, - conversation_extra_system_prompt=extra_system_prompt, - ), - f"{self.entity_id}_pipeline", - ) - try: - await self._pipeline_task - finally: - self._pipeline_task = None + with chat_session.async_get_chat_session( + self.hass, self._conversation_id + ) as session: + # Store the conversation ID. If it is no longer valid, get_chat_session will reset it + self._conversation_id = session.conversation_id + self._pipeline_task = ( + self.platform.config_entry.async_create_background_task( + self.hass, + async_pipeline_from_audio_stream( + self.hass, + context=self._context, + event_callback=self._internal_on_pipeline_event, + stt_metadata=stt.SpeechMetadata( + language="", # set in async_pipeline_from_audio_stream + format=stt.AudioFormats.WAV, + codec=stt.AudioCodecs.PCM, + bit_rate=stt.AudioBitRates.BITRATE_16, + sample_rate=stt.AudioSampleRates.SAMPLERATE_16000, + channel=stt.AudioChannels.CHANNEL_MONO, + ), + stt_stream=audio_stream, + pipeline_id=self._resolve_pipeline(), + conversation_id=session.conversation_id, + device_id=device_id, + tts_audio_output=self.tts_options, + wake_word_phrase=wake_word_phrase, + audio_settings=AudioSettings( + silence_seconds=self._resolve_vad_sensitivity() + ), + start_stage=start_stage, + end_stage=end_stage, + conversation_extra_system_prompt=extra_system_prompt, + ), + f"{self.entity_id}_pipeline", + ) + ) + + try: + await self._pipeline_task + finally: + self._pipeline_task = None async def _cancel_running_pipeline(self) -> None: """Cancel the current pipeline if it's running.""" @@ -393,11 +410,6 @@ class AssistSatelliteEntity(entity.Entity): self._set_state(AssistSatelliteState.LISTENING) elif event.type is PipelineEventType.INTENT_START: self._set_state(AssistSatelliteState.PROCESSING) - elif event.type is PipelineEventType.INTENT_END: - assert event.data is not None - # Update timeout - self._conversation_id_time = time.monotonic() - self._conversation_id = event.data["intent_output"]["conversation_id"] elif event.type is PipelineEventType.TTS_START: # Wait until tts_response_finished is called to return to waiting state self._run_has_tts = True diff --git a/homeassistant/components/august/manifest.json b/homeassistant/components/august/manifest.json index 652f1a7b966..5e16a22af76 100644 --- a/homeassistant/components/august/manifest.json +++ b/homeassistant/components/august/manifest.json @@ -28,5 +28,5 @@ "documentation": "https://www.home-assistant.io/integrations/august", "iot_class": "cloud_push", "loggers": ["pubnub", "yalexs"], - "requirements": ["yalexs==8.10.0", "yalexs-ble==2.5.6"] + "requirements": ["yalexs==8.10.0", "yalexs-ble==2.5.7"] } diff --git a/homeassistant/components/backup/__init__.py b/homeassistant/components/backup/__init__.py index 86e5b95d196..71a4f5ea41a 100644 --- a/homeassistant/components/backup/__init__.py +++ b/homeassistant/components/backup/__init__.py @@ -26,15 +26,18 @@ from .manager import ( BackupReaderWriterError, CoreBackupReaderWriter, CreateBackupEvent, + CreateBackupStage, + CreateBackupState, IdleEvent, IncorrectPasswordError, ManagerBackup, NewBackup, RestoreBackupEvent, + RestoreBackupStage, RestoreBackupState, WrittenBackup, ) -from .models import AddonInfo, AgentBackup, Folder +from .models import AddonInfo, AgentBackup, BackupNotFound, Folder from .util import suggested_filename, suggested_filename_from_name_date from .websocket import async_register_websocket_handlers @@ -45,10 +48,13 @@ __all__ = [ "BackupAgentError", "BackupAgentPlatformProtocol", "BackupManagerError", + "BackupNotFound", "BackupPlatformProtocol", "BackupReaderWriter", "BackupReaderWriterError", "CreateBackupEvent", + "CreateBackupStage", + "CreateBackupState", "Folder", "IdleEvent", "IncorrectPasswordError", @@ -56,6 +62,7 @@ __all__ = [ "ManagerBackup", "NewBackup", "RestoreBackupEvent", + "RestoreBackupStage", "RestoreBackupState", "WrittenBackup", "async_get_manager", diff --git a/homeassistant/components/backup/agent.py b/homeassistant/components/backup/agent.py index 297ccd6f685..9530f386c7b 100644 --- a/homeassistant/components/backup/agent.py +++ b/homeassistant/components/backup/agent.py @@ -11,13 +11,7 @@ from propcache.api import cached_property from homeassistant.core import HomeAssistant, callback -from .models import AgentBackup, BackupError - - -class BackupAgentError(BackupError): - """Base class for backup agent errors.""" - - error_code = "backup_agent_error" +from .models import AgentBackup, BackupAgentError class BackupAgentUnreachableError(BackupAgentError): @@ -27,12 +21,6 @@ class BackupAgentUnreachableError(BackupAgentError): _message = "The backup agent is unreachable." -class BackupNotFound(BackupAgentError): - """Raised when a backup is not found.""" - - error_code = "backup_not_found" - - class BackupAgent(abc.ABC): """Backup agent interface.""" diff --git a/homeassistant/components/backup/backup.py b/homeassistant/components/backup/backup.py index b6282186c06..c3a46a6ab1f 100644 --- a/homeassistant/components/backup/backup.py +++ b/homeassistant/components/backup/backup.py @@ -11,9 +11,9 @@ from typing import Any from homeassistant.core import HomeAssistant from homeassistant.helpers.hassio import is_hassio -from .agent import BackupAgent, BackupNotFound, LocalBackupAgent +from .agent import BackupAgent, LocalBackupAgent from .const import DOMAIN, LOGGER -from .models import AgentBackup +from .models import AgentBackup, BackupNotFound from .util import read_backup, suggested_filename diff --git a/homeassistant/components/backup/http.py b/homeassistant/components/backup/http.py index 6b06db4601d..58f44d4a449 100644 --- a/homeassistant/components/backup/http.py +++ b/homeassistant/components/backup/http.py @@ -21,6 +21,7 @@ from . import util from .agent import BackupAgent from .const import DATA_MANAGER from .manager import BackupManager +from .models import BackupNotFound @callback @@ -69,13 +70,16 @@ class DownloadBackupView(HomeAssistantView): CONTENT_DISPOSITION: f"attachment; filename={slugify(backup.name)}.tar" } - if not password or not backup.protected: - return await self._send_backup_no_password( - request, headers, backup_id, agent_id, agent, manager + try: + if not password or not backup.protected: + return await self._send_backup_no_password( + request, headers, backup_id, agent_id, agent, manager + ) + return await self._send_backup_with_password( + hass, request, headers, backup_id, agent_id, password, agent, manager ) - return await self._send_backup_with_password( - hass, request, headers, backup_id, agent_id, password, agent, manager - ) + except BackupNotFound: + return Response(status=HTTPStatus.NOT_FOUND) async def _send_backup_no_password( self, diff --git a/homeassistant/components/backup/manager.py b/homeassistant/components/backup/manager.py index 42b5f522ecd..fa9ca956c22 100644 --- a/homeassistant/components/backup/manager.py +++ b/homeassistant/components/backup/manager.py @@ -50,7 +50,14 @@ from .const import ( EXCLUDE_FROM_BACKUP, LOGGER, ) -from .models import AgentBackup, BackupError, BackupManagerError, BaseBackup, Folder +from .models import ( + AgentBackup, + BackupError, + BackupManagerError, + BackupReaderWriterError, + BaseBackup, + Folder, +) from .store import BackupStore from .util import ( AsyncIteratorReader, @@ -274,12 +281,6 @@ class BackupReaderWriter(abc.ABC): """Get restore events after core restart.""" -class BackupReaderWriterError(BackupError): - """Backup reader/writer error.""" - - error_code = "backup_reader_writer_error" - - class IncorrectPasswordError(BackupReaderWriterError): """Raised when the password is incorrect.""" diff --git a/homeassistant/components/backup/models.py b/homeassistant/components/backup/models.py index 1543d577964..95c5ef9809d 100644 --- a/homeassistant/components/backup/models.py +++ b/homeassistant/components/backup/models.py @@ -41,12 +41,6 @@ class BaseBackup: homeassistant_version: str | None # None if homeassistant_included is False name: str - def as_frontend_json(self) -> dict: - """Return a dict representation of this backup for sending to frontend.""" - return { - key: val for key, val in asdict(self).items() if key != "extra_metadata" - } - @dataclass(frozen=True, kw_only=True) class AgentBackup(BaseBackup): @@ -83,7 +77,25 @@ class BackupError(HomeAssistantError): error_code = "unknown" +class BackupAgentError(BackupError): + """Base class for backup agent errors.""" + + error_code = "backup_agent_error" + + class BackupManagerError(BackupError): """Backup manager error.""" error_code = "backup_manager_error" + + +class BackupReaderWriterError(BackupError): + """Backup reader/writer error.""" + + error_code = "backup_reader_writer_error" + + +class BackupNotFound(BackupAgentError, BackupManagerError): + """Raised when a backup is not found.""" + + error_code = "backup_not_found" diff --git a/homeassistant/components/backup/util.py b/homeassistant/components/backup/util.py index fbb13b4721a..b920c66a9b8 100644 --- a/homeassistant/components/backup/util.py +++ b/homeassistant/components/backup/util.py @@ -4,6 +4,7 @@ from __future__ import annotations import asyncio from collections.abc import AsyncIterator, Callable, Coroutine +from concurrent.futures import CancelledError, Future import copy from dataclasses import dataclass, replace from io import BytesIO @@ -12,6 +13,7 @@ import os from pathlib import Path, PurePath from queue import SimpleQueue import tarfile +import threading from typing import IO, Any, Self, cast import aiohttp @@ -22,7 +24,6 @@ from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.util import dt as dt_util from homeassistant.util.json import JsonObjectType, json_loads_object -from homeassistant.util.thread import ThreadWithException from .const import BUF_SIZE, LOGGER from .models import AddonInfo, AgentBackup, Folder @@ -167,23 +168,38 @@ class AsyncIteratorReader: def __init__(self, hass: HomeAssistant, stream: AsyncIterator[bytes]) -> None: """Initialize the wrapper.""" + self._aborted = False self._hass = hass self._stream = stream self._buffer: bytes | None = None + self._next_future: Future[bytes | None] | None = None self._pos: int = 0 async def _next(self) -> bytes | None: """Get the next chunk from the iterator.""" return await anext(self._stream, None) + def abort(self) -> None: + """Abort the reader.""" + self._aborted = True + if self._next_future is not None: + self._next_future.cancel() + def read(self, n: int = -1, /) -> bytes: """Read data from the iterator.""" result = bytearray() while n < 0 or len(result) < n: if not self._buffer: - self._buffer = asyncio.run_coroutine_threadsafe( + self._next_future = asyncio.run_coroutine_threadsafe( self._next(), self._hass.loop - ).result() + ) + if self._aborted: + self._next_future.cancel() + raise AbortCipher + try: + self._buffer = self._next_future.result() + except CancelledError as err: + raise AbortCipher from err self._pos = 0 if not self._buffer: # The stream is exhausted @@ -205,9 +221,11 @@ class AsyncIteratorWriter: def __init__(self, hass: HomeAssistant) -> None: """Initialize the wrapper.""" + self._aborted = False self._hass = hass self._pos: int = 0 self._queue: asyncio.Queue[bytes | None] = asyncio.Queue(maxsize=1) + self._write_future: Future[bytes | None] | None = None def __aiter__(self) -> Self: """Return the iterator.""" @@ -219,13 +237,28 @@ class AsyncIteratorWriter: return data raise StopAsyncIteration + def abort(self) -> None: + """Abort the writer.""" + self._aborted = True + if self._write_future is not None: + self._write_future.cancel() + def tell(self) -> int: """Return the current position in the iterator.""" return self._pos def write(self, s: bytes, /) -> int: """Write data to the iterator.""" - asyncio.run_coroutine_threadsafe(self._queue.put(s), self._hass.loop).result() + self._write_future = asyncio.run_coroutine_threadsafe( + self._queue.put(s), self._hass.loop + ) + if self._aborted: + self._write_future.cancel() + raise AbortCipher + try: + self._write_future.result() + except CancelledError as err: + raise AbortCipher from err self._pos += len(s) return len(s) @@ -415,7 +448,9 @@ def _encrypt_backup( class _CipherWorkerStatus: done: asyncio.Event error: Exception | None = None - thread: ThreadWithException + reader: AsyncIteratorReader + thread: threading.Thread + writer: AsyncIteratorWriter class _CipherBackupStreamer: @@ -468,11 +503,13 @@ class _CipherBackupStreamer: stream = await self._open_stream() reader = AsyncIteratorReader(self._hass, stream) writer = AsyncIteratorWriter(self._hass) - worker = ThreadWithException( + worker = threading.Thread( target=self._cipher_func, args=[reader, writer, self._password, on_done, self.size(), self._nonces], ) - worker_status = _CipherWorkerStatus(done=asyncio.Event(), thread=worker) + worker_status = _CipherWorkerStatus( + done=asyncio.Event(), reader=reader, thread=worker, writer=writer + ) self._workers.append(worker_status) worker.start() return writer @@ -480,9 +517,8 @@ class _CipherBackupStreamer: async def wait(self) -> None: """Wait for the worker threads to finish.""" for worker in self._workers: - if not worker.thread.is_alive(): - continue - worker.thread.raise_exc(AbortCipher) + worker.reader.abort() + worker.writer.abort() await asyncio.gather(*(worker.done.wait() for worker in self._workers)) diff --git a/homeassistant/components/backup/websocket.py b/homeassistant/components/backup/websocket.py index 93dd81c3c14..b6d092e1913 100644 --- a/homeassistant/components/backup/websocket.py +++ b/homeassistant/components/backup/websocket.py @@ -15,7 +15,7 @@ from .manager import ( IncorrectPasswordError, ManagerStateEvent, ) -from .models import Folder +from .models import BackupNotFound, Folder @callback @@ -57,7 +57,7 @@ async def handle_info( "agent_errors": { agent_id: str(err) for agent_id, err in agent_errors.items() }, - "backups": [backup.as_frontend_json() for backup in backups.values()], + "backups": list(backups.values()), "last_attempted_automatic_backup": manager.config.data.last_attempted_automatic_backup, "last_completed_automatic_backup": manager.config.data.last_completed_automatic_backup, "last_non_idle_event": manager.last_non_idle_event, @@ -91,7 +91,7 @@ async def handle_details( "agent_errors": { agent_id: str(err) for agent_id, err in agent_errors.items() }, - "backup": backup.as_frontend_json() if backup else None, + "backup": backup, }, ) @@ -151,6 +151,8 @@ async def handle_restore( restore_folders=msg.get("restore_folders"), restore_homeassistant=msg["restore_homeassistant"], ) + except BackupNotFound: + connection.send_error(msg["id"], "backup_not_found", "Backup not found") except IncorrectPasswordError: connection.send_error(msg["id"], "password_incorrect", "Incorrect password") else: @@ -179,6 +181,8 @@ async def handle_can_decrypt_on_download( agent_id=msg["agent_id"], password=msg.get("password"), ) + except BackupNotFound: + connection.send_error(msg["id"], "backup_not_found", "Backup not found") except IncorrectPasswordError: connection.send_error(msg["id"], "password_incorrect", "Incorrect password") except DecryptOnDowloadNotSupported: diff --git a/homeassistant/components/bang_olufsen/event.py b/homeassistant/components/bang_olufsen/event.py index 80ad4060c5e..99e5c8bb6fd 100644 --- a/homeassistant/components/bang_olufsen/event.py +++ b/homeassistant/components/bang_olufsen/event.py @@ -19,6 +19,8 @@ from .const import ( ) from .entity import BangOlufsenEntity +PARALLEL_UPDATES = 0 + async def async_setup_entry( hass: HomeAssistant, diff --git a/homeassistant/components/bluesound/strings.json b/homeassistant/components/bluesound/strings.json index c85014fedc3..b50c01a11bf 100644 --- a/homeassistant/components/bluesound/strings.json +++ b/homeassistant/components/bluesound/strings.json @@ -28,7 +28,7 @@ "services": { "join": { "name": "Join", - "description": "Group player together.", + "description": "Groups players together under a single master speaker.", "fields": { "master": { "name": "Master", @@ -36,23 +36,23 @@ }, "entity_id": { "name": "Entity", - "description": "Name of entity that will coordinate the grouping. Platform dependent." + "description": "Name of entity that will group to master speaker. Platform dependent." } } }, "unjoin": { "name": "Unjoin", - "description": "Unjoin the player from a group.", + "description": "Separates a player from a group.", "fields": { "entity_id": { "name": "Entity", - "description": "Name of entity that will be unjoined from their group. Platform dependent." + "description": "Name of entity that will be separated from their group. Platform dependent." } } }, "set_sleep_timer": { "name": "Set sleep timer", - "description": "Set a Bluesound timer. It will increase timer in steps: 15, 30, 45, 60, 90, 0.", + "description": "Sets a Bluesound timer that will turn off the speaker. It will increase in steps: 15, 30, 45, 60, 90, 0.", "fields": { "entity_id": { "name": "Entity", @@ -62,7 +62,7 @@ }, "clear_sleep_timer": { "name": "Clear sleep timer", - "description": "Clear a Bluesound timer.", + "description": "Clears a Bluesound timer.", "fields": { "entity_id": { "name": "Entity", diff --git a/homeassistant/components/bluetooth/__init__.py b/homeassistant/components/bluetooth/__init__.py index 5edec1ccc23..c46ef22803e 100644 --- a/homeassistant/components/bluetooth/__init__.py +++ b/homeassistant/components/bluetooth/__init__.py @@ -5,7 +5,7 @@ from __future__ import annotations import datetime import logging import platform -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, Any from bleak_retry_connector import BleakSlotManager from bluetooth_adapters import ( @@ -80,6 +80,7 @@ from .const import ( CONF_DETAILS, CONF_PASSIVE, CONF_SOURCE_CONFIG_ENTRY_ID, + CONF_SOURCE_DEVICE_ID, CONF_SOURCE_DOMAIN, CONF_SOURCE_MODEL, DOMAIN, @@ -297,7 +298,11 @@ async def async_discover_adapters( async def async_update_device( - hass: HomeAssistant, entry: ConfigEntry, adapter: str, details: AdapterDetails + hass: HomeAssistant, + entry: ConfigEntry, + adapter: str, + details: AdapterDetails, + via_device_id: str | None = None, ) -> None: """Update device registry entry. @@ -306,7 +311,8 @@ async def async_update_device( update the device with the new location so they can figure out where the adapter is. """ - dr.async_get(hass).async_get_or_create( + device_registry = dr.async_get(hass) + device_entry = device_registry.async_get_or_create( config_entry_id=entry.entry_id, name=adapter_human_name(adapter, details[ADAPTER_ADDRESS]), connections={(dr.CONNECTION_BLUETOOTH, details[ADAPTER_ADDRESS])}, @@ -315,6 +321,11 @@ async def async_update_device( sw_version=details.get(ADAPTER_SW_VERSION), hw_version=details.get(ADAPTER_HW_VERSION), ) + if via_device_id and (via_device_entry := device_registry.async_get(via_device_id)): + kwargs: dict[str, Any] = {"via_device_id": via_device_id} + if not device_entry.area_id and via_device_entry.area_id: + kwargs["area_id"] = via_device_entry.area_id + device_registry.async_update_device(device_entry.id, **kwargs) async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: @@ -349,6 +360,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: entry, source_entry.title, details, + entry.data.get(CONF_SOURCE_DEVICE_ID), ) return True manager = _get_manager(hass) diff --git a/homeassistant/components/bluetooth/api.py b/homeassistant/components/bluetooth/api.py index 9db570c4cba..00e585fa266 100644 --- a/homeassistant/components/bluetooth/api.py +++ b/homeassistant/components/bluetooth/api.py @@ -181,10 +181,16 @@ def async_register_scanner( source_domain: str | None = None, source_model: str | None = None, source_config_entry_id: str | None = None, + source_device_id: str | None = None, ) -> CALLBACK_TYPE: """Register a BleakScanner.""" return _get_manager(hass).async_register_hass_scanner( - scanner, connection_slots, source_domain, source_model, source_config_entry_id + scanner, + connection_slots, + source_domain, + source_model, + source_config_entry_id, + source_device_id, ) diff --git a/homeassistant/components/bluetooth/config_flow.py b/homeassistant/components/bluetooth/config_flow.py index 6425aabe12f..e76277306f5 100644 --- a/homeassistant/components/bluetooth/config_flow.py +++ b/homeassistant/components/bluetooth/config_flow.py @@ -37,6 +37,7 @@ from .const import ( CONF_PASSIVE, CONF_SOURCE, CONF_SOURCE_CONFIG_ENTRY_ID, + CONF_SOURCE_DEVICE_ID, CONF_SOURCE_DOMAIN, CONF_SOURCE_MODEL, DOMAIN, @@ -139,7 +140,7 @@ class BluetoothConfigFlow(ConfigFlow, domain=DOMAIN): title=adapter_title(adapter, details), data={} ) - configured_addresses = self._async_current_ids() + configured_addresses = self._async_current_ids(include_ignore=False) bluetooth_adapters = get_adapters() await bluetooth_adapters.refresh() self._adapters = bluetooth_adapters.adapters @@ -154,12 +155,8 @@ class BluetoothConfigFlow(ConfigFlow, domain=DOMAIN): and not (system == "Linux" and details[ADAPTER_ADDRESS] == DEFAULT_ADDRESS) ] if not unconfigured_adapters: - ignored_adapters = len( - self._async_current_entries(include_ignore=True) - ) - len(self._async_current_entries(include_ignore=False)) return self.async_abort( reason="no_adapters", - description_placeholders={"ignored_adapters": str(ignored_adapters)}, ) if len(unconfigured_adapters) == 1: self._adapter = list(self._adapters)[0] @@ -194,6 +191,7 @@ class BluetoothConfigFlow(ConfigFlow, domain=DOMAIN): CONF_SOURCE_MODEL: user_input[CONF_SOURCE_MODEL], CONF_SOURCE_DOMAIN: user_input[CONF_SOURCE_DOMAIN], CONF_SOURCE_CONFIG_ENTRY_ID: user_input[CONF_SOURCE_CONFIG_ENTRY_ID], + CONF_SOURCE_DEVICE_ID: user_input[CONF_SOURCE_DEVICE_ID], } self._abort_if_unique_id_configured(updates=data) manager = get_manager() diff --git a/homeassistant/components/bluetooth/const.py b/homeassistant/components/bluetooth/const.py index d4b187d4605..22c885b4f8b 100644 --- a/homeassistant/components/bluetooth/const.py +++ b/homeassistant/components/bluetooth/const.py @@ -22,7 +22,7 @@ CONF_SOURCE: Final = "source" CONF_SOURCE_DOMAIN: Final = "source_domain" CONF_SOURCE_MODEL: Final = "source_model" CONF_SOURCE_CONFIG_ENTRY_ID: Final = "source_config_entry_id" - +CONF_SOURCE_DEVICE_ID: Final = "source_device_id" SOURCE_LOCAL: Final = "local" diff --git a/homeassistant/components/bluetooth/manager.py b/homeassistant/components/bluetooth/manager.py index 09be8f960e9..46c5425c730 100644 --- a/homeassistant/components/bluetooth/manager.py +++ b/homeassistant/components/bluetooth/manager.py @@ -25,6 +25,7 @@ from homeassistant.helpers.dispatcher import async_dispatcher_connect from .const import ( CONF_SOURCE, CONF_SOURCE_CONFIG_ENTRY_ID, + CONF_SOURCE_DEVICE_ID, CONF_SOURCE_DOMAIN, CONF_SOURCE_MODEL, DOMAIN, @@ -254,6 +255,7 @@ class HomeAssistantBluetoothManager(BluetoothManager): source_domain: str | None = None, source_model: str | None = None, source_config_entry_id: str | None = None, + source_device_id: str | None = None, ) -> CALLBACK_TYPE: """Register a scanner.""" cancel = self.async_register_scanner(scanner, connection_slots) @@ -261,9 +263,6 @@ class HomeAssistantBluetoothManager(BluetoothManager): isinstance(scanner, BaseHaRemoteScanner) and source_domain and source_config_entry_id - and not self.hass.config_entries.async_entry_for_domain_unique_id( - DOMAIN, scanner.source - ) ): self.hass.async_create_task( self.hass.config_entries.flow.async_init( @@ -274,6 +273,7 @@ class HomeAssistantBluetoothManager(BluetoothManager): CONF_SOURCE_DOMAIN: source_domain, CONF_SOURCE_MODEL: source_model, CONF_SOURCE_CONFIG_ENTRY_ID: source_config_entry_id, + CONF_SOURCE_DEVICE_ID: source_device_id, }, ) ) diff --git a/homeassistant/components/bluetooth/manifest.json b/homeassistant/components/bluetooth/manifest.json index f43940821a1..a0405eb5ef5 100644 --- a/homeassistant/components/bluetooth/manifest.json +++ b/homeassistant/components/bluetooth/manifest.json @@ -16,11 +16,11 @@ "quality_scale": "internal", "requirements": [ "bleak==0.22.3", - "bleak-retry-connector==3.8.0", - "bluetooth-adapters==0.21.1", + "bleak-retry-connector==3.8.1", + "bluetooth-adapters==0.21.4", "bluetooth-auto-recovery==1.4.2", - "bluetooth-data-tools==1.22.0", - "dbus-fast==2.30.2", - "habluetooth==3.20.1" + "bluetooth-data-tools==1.23.4", + "dbus-fast==2.32.0", + "habluetooth==3.21.1" ] } diff --git a/homeassistant/components/bluetooth/strings.json b/homeassistant/components/bluetooth/strings.json index 5f9a380d631..866b76c0985 100644 --- a/homeassistant/components/bluetooth/strings.json +++ b/homeassistant/components/bluetooth/strings.json @@ -23,7 +23,7 @@ }, "abort": { "already_configured": "[%key:common::config_flow::abort::already_configured_service%]", - "no_adapters": "No unconfigured Bluetooth adapters found. There are {ignored_adapters} ignored adapters." + "no_adapters": "No unconfigured Bluetooth adapters found." } }, "options": { diff --git a/homeassistant/components/bthome/config_flow.py b/homeassistant/components/bthome/config_flow.py index 24fdddf2cc7..524365c1183 100644 --- a/homeassistant/components/bthome/config_flow.py +++ b/homeassistant/components/bthome/config_flow.py @@ -132,7 +132,7 @@ class BTHomeConfigFlow(ConfigFlow, domain=DOMAIN): return self._async_get_or_create_entry() - current_addresses = self._async_current_ids() + current_addresses = self._async_current_ids(include_ignore=False) for discovery_info in async_discovered_service_info(self.hass, False): address = discovery_info.address if address in current_addresses or address in self._discovered_devices: diff --git a/homeassistant/components/bthome/manifest.json b/homeassistant/components/bthome/manifest.json index c8577113804..4130606ff5c 100644 --- a/homeassistant/components/bthome/manifest.json +++ b/homeassistant/components/bthome/manifest.json @@ -20,5 +20,5 @@ "dependencies": ["bluetooth_adapters"], "documentation": "https://www.home-assistant.io/integrations/bthome", "iot_class": "local_push", - "requirements": ["bthome-ble==3.12.3"] + "requirements": ["bthome-ble==3.12.4"] } diff --git a/homeassistant/components/bthome/sensor.py b/homeassistant/components/bthome/sensor.py index e46cbbea700..23a058b0b0c 100644 --- a/homeassistant/components/bthome/sensor.py +++ b/homeassistant/components/bthome/sensor.py @@ -67,6 +67,11 @@ SENSOR_DESCRIPTIONS = { state_class=SensorStateClass.MEASUREMENT, entity_category=EntityCategory.DIAGNOSTIC, ), + # Channel (-) + (BTHomeExtendedSensorDeviceClass.CHANNEL, None): SensorEntityDescription( + key=str(BTHomeExtendedSensorDeviceClass.CHANNEL), + state_class=SensorStateClass.MEASUREMENT, + ), # Conductivity (µS/cm) ( BTHomeSensorDeviceClass.CONDUCTIVITY, diff --git a/homeassistant/components/cloud/http_api.py b/homeassistant/components/cloud/http_api.py index 473f553593a..b1a845ef8b0 100644 --- a/homeassistant/components/cloud/http_api.py +++ b/homeassistant/components/cloud/http_api.py @@ -29,6 +29,7 @@ from homeassistant.components.google_assistant import helpers as google_helpers from homeassistant.components.homeassistant import exposed_entities from homeassistant.components.http import KEY_HASS, HomeAssistantView, require_admin from homeassistant.components.http.data_validator import RequestDataValidator +from homeassistant.components.system_health import get_info as get_system_health_info from homeassistant.const import CLOUD_NEVER_EXPOSED_ENTITIES from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError @@ -107,6 +108,7 @@ def async_setup(hass: HomeAssistant) -> None: hass.http.register_view(CloudRegisterView) hass.http.register_view(CloudResendConfirmView) hass.http.register_view(CloudForgotPasswordView) + hass.http.register_view(DownloadSupportPackageView) _CLOUD_ERRORS.update( { @@ -389,6 +391,59 @@ class CloudForgotPasswordView(HomeAssistantView): return self.json_message("ok") +class DownloadSupportPackageView(HomeAssistantView): + """Download support package view.""" + + url = "/api/cloud/support_package" + name = "api:cloud:support_package" + + def _generate_markdown( + self, hass_info: dict[str, Any], domains_info: dict[str, dict[str, str]] + ) -> str: + def get_domain_table_markdown(domain_info: dict[str, Any]) -> str: + if len(domain_info) == 0: + return "No information available\n" + + markdown = "" + first = True + for key, value in domain_info.items(): + markdown += f"{key} | {value}\n" + if first: + markdown += "--- | ---\n" + first = False + return markdown + "\n" + + markdown = "## System Information\n\n" + markdown += get_domain_table_markdown(hass_info) + + for domain, domain_info in domains_info.items(): + domain_info_md = get_domain_table_markdown(domain_info) + markdown += ( + f"
{domain}\n\n" + f"{domain_info_md}" + "
\n\n" + ) + + return markdown + + async def get(self, request: web.Request) -> web.Response: + """Download support package file.""" + + hass = request.app[KEY_HASS] + domain_health = await get_system_health_info(hass) + + hass_info = domain_health.pop("homeassistant", {}) + markdown = self._generate_markdown(hass_info, domain_health) + + return web.Response( + body=markdown, + content_type="text/markdown", + headers={ + "Content-Disposition": 'attachment; filename="support_package.md"' + }, + ) + + @websocket_api.require_admin @websocket_api.websocket_command({vol.Required("type"): "cloud/remove_data"}) @websocket_api.async_response diff --git a/homeassistant/components/cloud/manifest.json b/homeassistant/components/cloud/manifest.json index 0f415b1738a..8e8ff4335db 100644 --- a/homeassistant/components/cloud/manifest.json +++ b/homeassistant/components/cloud/manifest.json @@ -13,6 +13,6 @@ "integration_type": "system", "iot_class": "cloud_push", "loggers": ["hass_nabucasa"], - "requirements": ["hass-nabucasa==0.88.1"], + "requirements": ["hass-nabucasa==0.89.0"], "single_config_entry": true } diff --git a/homeassistant/components/cloud/tts.py b/homeassistant/components/cloud/tts.py index 645ff4f9e75..63f36554c65 100644 --- a/homeassistant/components/cloud/tts.py +++ b/homeassistant/components/cloud/tts.py @@ -38,6 +38,156 @@ ATTR_GENDER = "gender" DEPRECATED_VOICES = {"XiaoxuanNeural": "XiaozhenNeural"} SUPPORT_LANGUAGES = list(TTS_VOICES) +DEFAULT_VOICES = { + "af-ZA": "AdriNeural", + "am-ET": "MekdesNeural", + "ar-AE": "FatimaNeural", + "ar-BH": "LailaNeural", + "ar-DZ": "AminaNeural", + "ar-EG": "SalmaNeural", + "ar-IQ": "RanaNeural", + "ar-JO": "SanaNeural", + "ar-KW": "NouraNeural", + "ar-LB": "LaylaNeural", + "ar-LY": "ImanNeural", + "ar-MA": "MounaNeural", + "ar-OM": "AbdullahNeural", + "ar-QA": "AmalNeural", + "ar-SA": "ZariyahNeural", + "ar-SY": "AmanyNeural", + "ar-TN": "ReemNeural", + "ar-YE": "MaryamNeural", + "az-AZ": "BabekNeural", + "bg-BG": "KalinaNeural", + "bn-BD": "NabanitaNeural", + "bn-IN": "TanishaaNeural", + "bs-BA": "GoranNeural", + "ca-ES": "JoanaNeural", + "cs-CZ": "VlastaNeural", + "cy-GB": "NiaNeural", + "da-DK": "ChristelNeural", + "de-AT": "IngridNeural", + "de-CH": "LeniNeural", + "de-DE": "KatjaNeural", + "el-GR": "AthinaNeural", + "en-AU": "NatashaNeural", + "en-CA": "ClaraNeural", + "en-GB": "LibbyNeural", + "en-HK": "YanNeural", + "en-IE": "EmilyNeural", + "en-IN": "NeerjaNeural", + "en-KE": "AsiliaNeural", + "en-NG": "EzinneNeural", + "en-NZ": "MollyNeural", + "en-PH": "RosaNeural", + "en-SG": "LunaNeural", + "en-TZ": "ImaniNeural", + "en-US": "JennyNeural", + "en-ZA": "LeahNeural", + "es-AR": "ElenaNeural", + "es-BO": "SofiaNeural", + "es-CL": "CatalinaNeural", + "es-CO": "SalomeNeural", + "es-CR": "MariaNeural", + "es-CU": "BelkysNeural", + "es-DO": "RamonaNeural", + "es-EC": "AndreaNeural", + "es-ES": "ElviraNeural", + "es-GQ": "TeresaNeural", + "es-GT": "MartaNeural", + "es-HN": "KarlaNeural", + "es-MX": "DaliaNeural", + "es-NI": "YolandaNeural", + "es-PA": "MargaritaNeural", + "es-PE": "CamilaNeural", + "es-PR": "KarinaNeural", + "es-PY": "TaniaNeural", + "es-SV": "LorenaNeural", + "es-US": "PalomaNeural", + "es-UY": "ValentinaNeural", + "es-VE": "PaolaNeural", + "et-EE": "AnuNeural", + "eu-ES": "AinhoaNeural", + "fa-IR": "DilaraNeural", + "fi-FI": "SelmaNeural", + "fil-PH": "BlessicaNeural", + "fr-BE": "CharlineNeural", + "fr-CA": "SylvieNeural", + "fr-CH": "ArianeNeural", + "fr-FR": "DeniseNeural", + "ga-IE": "OrlaNeural", + "gl-ES": "SabelaNeural", + "gu-IN": "DhwaniNeural", + "he-IL": "HilaNeural", + "hi-IN": "SwaraNeural", + "hr-HR": "GabrijelaNeural", + "hu-HU": "NoemiNeural", + "hy-AM": "AnahitNeural", + "id-ID": "GadisNeural", + "is-IS": "GudrunNeural", + "it-IT": "ElsaNeural", + "ja-JP": "NanamiNeural", + "jv-ID": "SitiNeural", + "ka-GE": "EkaNeural", + "kk-KZ": "AigulNeural", + "km-KH": "SreymomNeural", + "kn-IN": "SapnaNeural", + "ko-KR": "SunHiNeural", + "lo-LA": "KeomanyNeural", + "lt-LT": "OnaNeural", + "lv-LV": "EveritaNeural", + "mk-MK": "MarijaNeural", + "ml-IN": "SobhanaNeural", + "mn-MN": "BataaNeural", + "mr-IN": "AarohiNeural", + "ms-MY": "YasminNeural", + "mt-MT": "GraceNeural", + "my-MM": "NilarNeural", + "nb-NO": "IselinNeural", + "ne-NP": "HemkalaNeural", + "nl-BE": "DenaNeural", + "nl-NL": "ColetteNeural", + "pl-PL": "AgnieszkaNeural", + "ps-AF": "LatifaNeural", + "pt-BR": "FranciscaNeural", + "pt-PT": "RaquelNeural", + "ro-RO": "AlinaNeural", + "ru-RU": "SvetlanaNeural", + "si-LK": "ThiliniNeural", + "sk-SK": "ViktoriaNeural", + "sl-SI": "PetraNeural", + "so-SO": "UbaxNeural", + "sq-AL": "AnilaNeural", + "sr-RS": "SophieNeural", + "su-ID": "TutiNeural", + "sv-SE": "SofieNeural", + "sw-KE": "ZuriNeural", + "sw-TZ": "RehemaNeural", + "ta-IN": "PallaviNeural", + "ta-LK": "SaranyaNeural", + "ta-MY": "KaniNeural", + "ta-SG": "VenbaNeural", + "te-IN": "ShrutiNeural", + "th-TH": "AcharaNeural", + "tr-TR": "EmelNeural", + "uk-UA": "PolinaNeural", + "ur-IN": "GulNeural", + "ur-PK": "UzmaNeural", + "uz-UZ": "MadinaNeural", + "vi-VN": "HoaiMyNeural", + "wuu-CN": "XiaotongNeural", + "yue-CN": "XiaoMinNeural", + "zh-CN": "XiaoxiaoNeural", + "zh-CN-henan": "YundengNeural", + "zh-CN-liaoning": "XiaobeiNeural", + "zh-CN-shaanxi": "XiaoniNeural", + "zh-CN-shandong": "YunxiangNeural", + "zh-CN-sichuan": "YunxiNeural", + "zh-HK": "HiuMaanNeural", + "zh-TW": "HsiaoChenNeural", + "zu-ZA": "ThandoNeural", +} + _LOGGER = logging.getLogger(__name__) @@ -186,12 +336,13 @@ class CloudTTSEntity(TextToSpeechEntity): """Load TTS from Home Assistant Cloud.""" gender: Gender | str | None = options.get(ATTR_GENDER) gender = handle_deprecated_gender(self.hass, gender) - original_voice: str | None = options.get(ATTR_VOICE) - if original_voice is None and language == self._language: - original_voice = self._voice + original_voice: str = options.get( + ATTR_VOICE, + self._voice if language == self._language else DEFAULT_VOICES[language], + ) voice = handle_deprecated_voice(self.hass, original_voice) if voice not in TTS_VOICES[language]: - default_voice = TTS_VOICES[language][0] + default_voice = DEFAULT_VOICES[language] _LOGGER.debug( "Unsupported voice %s detected, falling back to default %s for %s", voice, @@ -266,12 +417,13 @@ class CloudProvider(Provider): assert self.hass is not None gender: Gender | str | None = options.get(ATTR_GENDER) gender = handle_deprecated_gender(self.hass, gender) - original_voice: str | None = options.get(ATTR_VOICE) - if original_voice is None and language == self._language: - original_voice = self._voice + original_voice: str = options.get( + ATTR_VOICE, + self._voice if language == self._language else DEFAULT_VOICES[language], + ) voice = handle_deprecated_voice(self.hass, original_voice) if voice not in TTS_VOICES[language]: - default_voice = TTS_VOICES[language][0] + default_voice = DEFAULT_VOICES[language] _LOGGER.debug( "Unsupported voice %s detected, falling back to default %s for %s", voice, diff --git a/homeassistant/components/config/config_entries.py b/homeassistant/components/config/config_entries.py index 4a070a87734..52e3346002e 100644 --- a/homeassistant/components/config/config_entries.py +++ b/homeassistant/components/config/config_entries.py @@ -302,7 +302,8 @@ def config_entries_progress( [ flw for flw in hass.config_entries.flow.async_progress() - if flw["context"]["source"] != config_entries.SOURCE_USER + if flw["context"]["source"] + not in (config_entries.SOURCE_RECONFIGURE, config_entries.SOURCE_USER) ], ) diff --git a/homeassistant/components/conversation/__init__.py b/homeassistant/components/conversation/__init__.py index b110d53540c..69e738205c5 100644 --- a/homeassistant/components/conversation/__init__.py +++ b/homeassistant/components/conversation/__init__.py @@ -30,6 +30,16 @@ from .agent_manager import ( async_get_agent, get_agent_manager, ) +from .chat_log import ( + AssistantContent, + ChatLog, + Content, + ConverseError, + SystemContent, + ToolResultContent, + UserContent, + async_get_chat_log, +) from .const import ( ATTR_AGENT_ID, ATTR_CONVERSATION_ID, @@ -48,20 +58,14 @@ from .default_agent import DefaultAgent, async_setup_default_agent from .entity import ConversationEntity from .http import async_setup as async_setup_conversation_http from .models import AbstractConversationAgent, ConversationInput, ConversationResult -from .session import ( - ChatSession, - Content, - ConverseError, - NativeContent, - async_get_chat_session, -) from .trace import ConversationTraceEventType, async_conversation_trace_append __all__ = [ "DOMAIN", "HOME_ASSISTANT_AGENT", "OLD_HOME_ASSISTANT_AGENT", - "ChatSession", + "AssistantContent", + "ChatLog", "Content", "ConversationEntity", "ConversationEntityFeature", @@ -69,11 +73,13 @@ __all__ = [ "ConversationResult", "ConversationTraceEventType", "ConverseError", - "NativeContent", + "SystemContent", + "ToolResultContent", + "UserContent", "async_conversation_trace_append", "async_converse", "async_get_agent_info", - "async_get_chat_session", + "async_get_chat_log", "async_set_agent", "async_setup", "async_unset_agent", diff --git a/homeassistant/components/conversation/chat_log.py b/homeassistant/components/conversation/chat_log.py new file mode 100644 index 00000000000..53e248d0a98 --- /dev/null +++ b/homeassistant/components/conversation/chat_log.py @@ -0,0 +1,299 @@ +"""Conversation history.""" + +from __future__ import annotations + +from collections.abc import AsyncGenerator, Generator +from contextlib import contextmanager +from dataclasses import dataclass, field, replace +import logging + +import voluptuous as vol + +from homeassistant.core import HomeAssistant, callback +from homeassistant.exceptions import HomeAssistantError, TemplateError +from homeassistant.helpers import chat_session, intent, llm, template +from homeassistant.util.hass_dict import HassKey +from homeassistant.util.json import JsonObjectType + +from . import trace +from .const import DOMAIN +from .models import ConversationInput, ConversationResult + +DATA_CHAT_HISTORY: HassKey[dict[str, ChatLog]] = HassKey("conversation_chat_log") + +LOGGER = logging.getLogger(__name__) + + +@contextmanager +def async_get_chat_log( + hass: HomeAssistant, + session: chat_session.ChatSession, + user_input: ConversationInput | None = None, +) -> Generator[ChatLog]: + """Return chat log for a specific chat session.""" + all_history = hass.data.get(DATA_CHAT_HISTORY) + if all_history is None: + all_history = {} + hass.data[DATA_CHAT_HISTORY] = all_history + + history = all_history.get(session.conversation_id) + + if history: + history = replace(history, content=history.content.copy()) + else: + history = ChatLog(hass, session.conversation_id) + + @callback + def do_cleanup() -> None: + """Handle cleanup.""" + all_history.pop(session.conversation_id) + + session.async_on_cleanup(do_cleanup) + + if user_input is not None: + history.async_add_user_content(UserContent(content=user_input.text)) + + last_message = history.content[-1] + + yield history + + if history.content[-1] is last_message: + LOGGER.debug( + "History opened but no assistant message was added, ignoring update" + ) + return + + all_history[session.conversation_id] = history + + +class ConverseError(HomeAssistantError): + """Error during initialization of conversation. + + Will not be stored in the history. + """ + + def __init__( + self, message: str, conversation_id: str, response: intent.IntentResponse + ) -> None: + """Initialize the error.""" + super().__init__(message) + self.conversation_id = conversation_id + self.response = response + + def as_conversation_result(self) -> ConversationResult: + """Return the error as a conversation result.""" + return ConversationResult( + response=self.response, + conversation_id=self.conversation_id, + ) + + +@dataclass(frozen=True) +class SystemContent: + """Base class for chat messages.""" + + role: str = field(init=False, default="system") + content: str + + +@dataclass(frozen=True) +class UserContent: + """Assistant content.""" + + role: str = field(init=False, default="user") + content: str + + +@dataclass(frozen=True) +class AssistantContent: + """Assistant content.""" + + role: str = field(init=False, default="assistant") + agent_id: str + content: str + tool_calls: list[llm.ToolInput] | None = None + + +@dataclass(frozen=True) +class ToolResultContent: + """Tool result content.""" + + role: str = field(init=False, default="tool_result") + agent_id: str + tool_call_id: str + tool_name: str + tool_result: JsonObjectType + + +Content = SystemContent | UserContent | AssistantContent | ToolResultContent + + +@dataclass +class ChatLog: + """Class holding the chat history of a specific conversation.""" + + hass: HomeAssistant + conversation_id: str + content: list[Content] = field(default_factory=lambda: [SystemContent(content="")]) + extra_system_prompt: str | None = None + llm_api: llm.APIInstance | None = None + + @callback + def async_add_user_content(self, content: UserContent) -> None: + """Add user content to the log.""" + self.content.append(content) + + @callback + def async_add_assistant_content_without_tools( + self, content: AssistantContent + ) -> None: + """Add assistant content to the log.""" + if content.tool_calls is not None: + raise ValueError("Tool calls not allowed") + self.content.append(content) + + async def async_add_assistant_content( + self, content: AssistantContent + ) -> AsyncGenerator[ToolResultContent]: + """Add assistant content.""" + self.content.append(content) + + if content.tool_calls is None: + return + + if self.llm_api is None: + raise ValueError("No LLM API configured") + + for tool_input in content.tool_calls: + LOGGER.debug( + "Tool call: %s(%s)", tool_input.tool_name, tool_input.tool_args + ) + + try: + tool_result = await self.llm_api.async_call_tool(tool_input) + except (HomeAssistantError, vol.Invalid) as e: + tool_result = {"error": type(e).__name__} + if str(e): + tool_result["error_text"] = str(e) + LOGGER.debug("Tool response: %s", tool_result) + + response_content = ToolResultContent( + agent_id=content.agent_id, + tool_call_id=tool_input.id, + tool_name=tool_input.tool_name, + tool_result=tool_result, + ) + self.content.append(response_content) + yield response_content + + async def async_update_llm_data( + self, + conversing_domain: str, + user_input: ConversationInput, + user_llm_hass_api: str | None = None, + user_llm_prompt: str | None = None, + ) -> None: + """Set the LLM system prompt.""" + llm_context = llm.LLMContext( + platform=conversing_domain, + context=user_input.context, + user_prompt=user_input.text, + language=user_input.language, + assistant=DOMAIN, + device_id=user_input.device_id, + ) + + llm_api: llm.APIInstance | None = None + + if user_llm_hass_api: + try: + llm_api = await llm.async_get_api( + self.hass, + user_llm_hass_api, + llm_context, + ) + except HomeAssistantError as err: + LOGGER.error( + "Error getting LLM API %s for %s: %s", + user_llm_hass_api, + conversing_domain, + err, + ) + intent_response = intent.IntentResponse(language=user_input.language) + intent_response.async_set_error( + intent.IntentResponseErrorCode.UNKNOWN, + "Error preparing LLM API", + ) + raise ConverseError( + f"Error getting LLM API {user_llm_hass_api}", + conversation_id=self.conversation_id, + response=intent_response, + ) from err + + user_name: str | None = None + + if ( + user_input.context + and user_input.context.user_id + and ( + user := await self.hass.auth.async_get_user(user_input.context.user_id) + ) + ): + user_name = user.name + + try: + prompt_parts = [ + template.Template( + llm.BASE_PROMPT + + (user_llm_prompt or llm.DEFAULT_INSTRUCTIONS_PROMPT), + self.hass, + ).async_render( + { + "ha_name": self.hass.config.location_name, + "user_name": user_name, + "llm_context": llm_context, + }, + parse_result=False, + ) + ] + + except TemplateError as err: + LOGGER.error("Error rendering prompt: %s", err) + intent_response = intent.IntentResponse(language=user_input.language) + intent_response.async_set_error( + intent.IntentResponseErrorCode.UNKNOWN, + "Sorry, I had a problem with my template", + ) + raise ConverseError( + "Error rendering prompt", + conversation_id=self.conversation_id, + response=intent_response, + ) from err + + if llm_api: + prompt_parts.append(llm_api.api_prompt) + + extra_system_prompt = ( + # Take new system prompt if one was given + user_input.extra_system_prompt or self.extra_system_prompt + ) + + if extra_system_prompt: + prompt_parts.append(extra_system_prompt) + + prompt = "\n".join(prompt_parts) + + self.llm_api = llm_api + self.extra_system_prompt = extra_system_prompt + self.content[0] = SystemContent(content=prompt) + + LOGGER.debug("Prompt: %s", self.content) + LOGGER.debug("Tools: %s", self.llm_api.tools if self.llm_api else None) + + trace.async_conversation_trace_append( + trace.ConversationTraceEventType.AGENT_DETAIL, + { + "messages": self.content, + "tools": self.llm_api.tools if self.llm_api else None, + }, + ) diff --git a/homeassistant/components/conversation/default_agent.py b/homeassistant/components/conversation/default_agent.py index be0387555dc..bd7450e5a0f 100644 --- a/homeassistant/components/conversation/default_agent.py +++ b/homeassistant/components/conversation/default_agent.py @@ -42,6 +42,7 @@ from homeassistant.components.homeassistant.exposed_entities import ( from homeassistant.const import EVENT_STATE_CHANGED, MATCH_ALL from homeassistant.helpers import ( area_registry as ar, + chat_session, device_registry as dr, entity_registry as er, floor_registry as fr, @@ -54,6 +55,7 @@ from homeassistant.helpers.entity_component import EntityComponent from homeassistant.helpers.event import async_track_state_added_domain from homeassistant.util.json import JsonObjectType, json_loads_object +from .chat_log import AssistantContent, async_get_chat_log from .const import ( DATA_DEFAULT_ENTITY, DEFAULT_EXPOSED_ATTRIBUTES, @@ -62,7 +64,6 @@ from .const import ( ) from .entity import ConversationEntity from .models import ConversationInput, ConversationResult -from .session import Content, async_get_chat_session from .trace import ConversationTraceEventType, async_conversation_trace_append _LOGGER = logging.getLogger(__name__) @@ -348,7 +349,12 @@ class DefaultAgent(ConversationEntity): async def async_process(self, user_input: ConversationInput) -> ConversationResult: """Process a sentence.""" response: intent.IntentResponse | None = None - async with async_get_chat_session(self.hass, user_input) as chat_session: + with ( + chat_session.async_get_chat_session( + self.hass, user_input.conversation_id + ) as session, + async_get_chat_log(self.hass, session, user_input) as chat_log, + ): # Check if a trigger matched if trigger_result := await self.async_recognize_sentence_trigger( user_input @@ -373,16 +379,15 @@ class DefaultAgent(ConversationEntity): ) speech: str = response.speech.get("plain", {}).get("speech", "") - chat_session.async_add_message( - Content( - role="assistant", - agent_id=user_input.agent_id, + chat_log.async_add_assistant_content_without_tools( + AssistantContent( + agent_id=user_input.agent_id, # type: ignore[arg-type] content=speech, ) ) return ConversationResult( - response=response, conversation_id=chat_session.conversation_id + response=response, conversation_id=session.conversation_id ) async def _async_process_intent_result( diff --git a/homeassistant/components/conversation/session.py b/homeassistant/components/conversation/session.py deleted file mode 100644 index 43f4cbf427c..00000000000 --- a/homeassistant/components/conversation/session.py +++ /dev/null @@ -1,359 +0,0 @@ -"""Conversation history.""" - -from __future__ import annotations - -from collections.abc import AsyncGenerator -from contextlib import asynccontextmanager -from dataclasses import dataclass, field, replace -from datetime import datetime, timedelta -import logging -from typing import Literal - -import voluptuous as vol - -from homeassistant.const import EVENT_HOMEASSISTANT_STOP -from homeassistant.core import ( - CALLBACK_TYPE, - Event, - HassJob, - HassJobType, - HomeAssistant, - callback, -) -from homeassistant.exceptions import HomeAssistantError, TemplateError -from homeassistant.helpers import intent, llm, template -from homeassistant.helpers.event import async_call_later -from homeassistant.util import dt as dt_util, ulid as ulid_util -from homeassistant.util.hass_dict import HassKey -from homeassistant.util.json import JsonObjectType - -from . import trace -from .const import DOMAIN -from .models import ConversationInput, ConversationResult - -DATA_CHAT_HISTORY: HassKey[dict[str, ChatSession]] = HassKey( - "conversation_chat_session" -) -DATA_CHAT_HISTORY_CLEANUP: HassKey[SessionCleanup] = HassKey( - "conversation_chat_session_cleanup" -) - -LOGGER = logging.getLogger(__name__) -CONVERSATION_TIMEOUT = timedelta(minutes=5) - - -class SessionCleanup: - """Helper to clean up the history.""" - - unsub: CALLBACK_TYPE | None = None - - def __init__(self, hass: HomeAssistant) -> None: - """Initialize the history cleanup.""" - self.hass = hass - hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, self._on_hass_stop) - self.cleanup_job = HassJob( - self._cleanup, "conversation_history_cleanup", job_type=HassJobType.Callback - ) - - @callback - def schedule(self) -> None: - """Schedule the cleanup.""" - if self.unsub: - return - self.unsub = async_call_later( - self.hass, - CONVERSATION_TIMEOUT.total_seconds() + 1, - self.cleanup_job, - ) - - @callback - def _on_hass_stop(self, event: Event) -> None: - """Cancel the cleanup on shutdown.""" - if self.unsub: - self.unsub() - self.unsub = None - - @callback - def _cleanup(self, now: datetime) -> None: - """Clean up the history and schedule follow-up if necessary.""" - self.unsub = None - all_history = self.hass.data[DATA_CHAT_HISTORY] - - # We mutate original object because current commands could be - # yielding history based on it. - for conversation_id, history in list(all_history.items()): - if history.last_updated + CONVERSATION_TIMEOUT < now: - del all_history[conversation_id] - - # Still conversations left, check again in timeout time. - if all_history: - self.schedule() - - -@asynccontextmanager -async def async_get_chat_session( - hass: HomeAssistant, - user_input: ConversationInput, -) -> AsyncGenerator[ChatSession]: - """Return chat session.""" - all_history = hass.data.get(DATA_CHAT_HISTORY) - if all_history is None: - all_history = {} - hass.data[DATA_CHAT_HISTORY] = all_history - hass.data[DATA_CHAT_HISTORY_CLEANUP] = SessionCleanup(hass) - - history: ChatSession | None = None - - if user_input.conversation_id is None: - conversation_id = ulid_util.ulid_now() - - elif history := all_history.get(user_input.conversation_id): - conversation_id = user_input.conversation_id - - else: - # Conversation IDs are ULIDs. We generate a new one if not provided. - # If an old OLID is passed in, we will generate a new one to indicate - # a new conversation was started. If the user picks their own, they - # want to track a conversation and we respect it. - try: - ulid_util.ulid_to_bytes(user_input.conversation_id) - conversation_id = ulid_util.ulid_now() - except ValueError: - conversation_id = user_input.conversation_id - - if history: - history = replace(history, messages=history.messages.copy()) - else: - history = ChatSession(hass, conversation_id, user_input.agent_id) - - message: Content = Content( - role="user", - agent_id=user_input.agent_id, - content=user_input.text, - ) - history.async_add_message(message) - - yield history - - if history.messages[-1] is message: - LOGGER.debug( - "History opened but no assistant message was added, ignoring update" - ) - return - - history.last_updated = dt_util.utcnow() - all_history[conversation_id] = history - hass.data[DATA_CHAT_HISTORY_CLEANUP].schedule() - - -class ConverseError(HomeAssistantError): - """Error during initialization of conversation. - - Will not be stored in the history. - """ - - def __init__( - self, message: str, conversation_id: str, response: intent.IntentResponse - ) -> None: - """Initialize the error.""" - super().__init__(message) - self.conversation_id = conversation_id - self.response = response - - def as_conversation_result(self) -> ConversationResult: - """Return the error as a conversation result.""" - return ConversationResult( - response=self.response, - conversation_id=self.conversation_id, - ) - - -@dataclass -class Content: - """Base class for chat messages.""" - - role: Literal["system", "assistant", "user"] - agent_id: str | None - content: str - - -@dataclass(frozen=True) -class NativeContent[_NativeT]: - """Native content.""" - - role: str = field(init=False, default="native") - agent_id: str - content: _NativeT - - -@dataclass -class ChatSession[_NativeT]: - """Class holding all information for a specific conversation.""" - - hass: HomeAssistant - conversation_id: str - agent_id: str | None - user_name: str | None = None - messages: list[Content | NativeContent[_NativeT]] = field( - default_factory=lambda: [Content(role="system", agent_id=None, content="")] - ) - extra_system_prompt: str | None = None - llm_api: llm.APIInstance | None = None - last_updated: datetime = field(default_factory=dt_util.utcnow) - - @callback - def async_add_message(self, message: Content | NativeContent[_NativeT]) -> None: - """Process intent.""" - if message.role == "system": - raise ValueError("Cannot add system messages to history") - if message.role != "native" and self.messages[-1].role == message.role: - raise ValueError("Cannot add two assistant or user messages in a row") - - self.messages.append(message) - - @callback - def async_get_messages( - self, agent_id: str | None = None - ) -> list[Content | NativeContent[_NativeT]]: - """Get messages for a specific agent ID. - - This will filter out any native message tied to other agent IDs. - It can still include assistant/user messages generated by other agents. - """ - return [ - message - for message in self.messages - if message.role != "native" or message.agent_id == agent_id - ] - - async def async_update_llm_data( - self, - conversing_domain: str, - user_input: ConversationInput, - user_llm_hass_api: str | None = None, - user_llm_prompt: str | None = None, - ) -> None: - """Set the LLM system prompt.""" - llm_context = llm.LLMContext( - platform=conversing_domain, - context=user_input.context, - user_prompt=user_input.text, - language=user_input.language, - assistant=DOMAIN, - device_id=user_input.device_id, - ) - - llm_api: llm.APIInstance | None = None - - if user_llm_hass_api: - try: - llm_api = await llm.async_get_api( - self.hass, - user_llm_hass_api, - llm_context, - ) - except HomeAssistantError as err: - LOGGER.error( - "Error getting LLM API %s for %s: %s", - user_llm_hass_api, - conversing_domain, - err, - ) - intent_response = intent.IntentResponse(language=user_input.language) - intent_response.async_set_error( - intent.IntentResponseErrorCode.UNKNOWN, - "Error preparing LLM API", - ) - raise ConverseError( - f"Error getting LLM API {user_llm_hass_api}", - conversation_id=self.conversation_id, - response=intent_response, - ) from err - - user_name: str | None = None - - if ( - user_input.context - and user_input.context.user_id - and ( - user := await self.hass.auth.async_get_user(user_input.context.user_id) - ) - ): - user_name = user.name - - try: - prompt_parts = [ - template.Template( - llm.BASE_PROMPT - + (user_llm_prompt or llm.DEFAULT_INSTRUCTIONS_PROMPT), - self.hass, - ).async_render( - { - "ha_name": self.hass.config.location_name, - "user_name": user_name, - "llm_context": llm_context, - }, - parse_result=False, - ) - ] - - except TemplateError as err: - LOGGER.error("Error rendering prompt: %s", err) - intent_response = intent.IntentResponse(language=user_input.language) - intent_response.async_set_error( - intent.IntentResponseErrorCode.UNKNOWN, - "Sorry, I had a problem with my template", - ) - raise ConverseError( - "Error rendering prompt", - conversation_id=self.conversation_id, - response=intent_response, - ) from err - - if llm_api: - prompt_parts.append(llm_api.api_prompt) - - extra_system_prompt = ( - # Take new system prompt if one was given - user_input.extra_system_prompt or self.extra_system_prompt - ) - - if extra_system_prompt: - prompt_parts.append(extra_system_prompt) - - prompt = "\n".join(prompt_parts) - - self.llm_api = llm_api - self.user_name = user_name - self.extra_system_prompt = extra_system_prompt - self.messages[0] = Content( - role="system", - agent_id=user_input.agent_id, - content=prompt, - ) - - LOGGER.debug("Prompt: %s", self.messages) - LOGGER.debug("Tools: %s", self.llm_api.tools if self.llm_api else None) - - trace.async_conversation_trace_append( - trace.ConversationTraceEventType.AGENT_DETAIL, - { - "messages": self.messages, - "tools": self.llm_api.tools if self.llm_api else None, - }, - ) - - async def async_call_tool(self, tool_input: llm.ToolInput) -> JsonObjectType: - """Invoke LLM tool for the configured LLM API.""" - if not self.llm_api: - raise ValueError("No LLM API configured") - LOGGER.debug("Tool call: %s(%s)", tool_input.tool_name, tool_input.tool_args) - - try: - tool_response = await self.llm_api.async_call_tool(tool_input) - except (HomeAssistantError, vol.Invalid) as e: - tool_response = {"error": type(e).__name__} - if str(e): - tool_response["error_text"] = str(e) - LOGGER.debug("Tool response: %s", tool_response) - return tool_response diff --git a/homeassistant/components/dhcp/manifest.json b/homeassistant/components/dhcp/manifest.json index ba773782e1c..45af4f1b5dd 100644 --- a/homeassistant/components/dhcp/manifest.json +++ b/homeassistant/components/dhcp/manifest.json @@ -14,8 +14,8 @@ ], "quality_scale": "internal", "requirements": [ - "aiodhcpwatcher==1.0.2", - "aiodiscover==2.1.0", + "aiodhcpwatcher==1.1.0", + "aiodiscover==2.2.2", "cached-ipaddress==0.8.0" ] } diff --git a/homeassistant/components/dwd_weather_warnings/sensor.py b/homeassistant/components/dwd_weather_warnings/sensor.py index c6aa5727b74..0aaf1f2a801 100644 --- a/homeassistant/components/dwd_weather_warnings/sensor.py +++ b/homeassistant/components/dwd_weather_warnings/sensor.py @@ -3,7 +3,7 @@ Data is fetched from DWD: https://rcccm.dwd.de/DE/wetter/warnungen_aktuell/objekt_einbindung/objekteinbindung.html -Warnungen vor extremem Unwetter (Stufe 4) # codespell:ignore vor +Warnungen vor extremem Unwetter (Stufe 4) # codespell:ignore vor,extremem Unwetterwarnungen (Stufe 3) Warnungen vor markantem Wetter (Stufe 2) # codespell:ignore vor Wetterwarnungen (Stufe 1) diff --git a/homeassistant/components/econet/manifest.json b/homeassistant/components/econet/manifest.json index 6586af92d1f..bda52ee3d07 100644 --- a/homeassistant/components/econet/manifest.json +++ b/homeassistant/components/econet/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/econet", "iot_class": "cloud_push", "loggers": ["paho_mqtt", "pyeconet"], - "requirements": ["pyeconet==0.1.23"] + "requirements": ["pyeconet==0.1.26"] } diff --git a/homeassistant/components/ecovacs/manifest.json b/homeassistant/components/ecovacs/manifest.json index 16929e1741a..33a251c22dc 100644 --- a/homeassistant/components/ecovacs/manifest.json +++ b/homeassistant/components/ecovacs/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/ecovacs", "iot_class": "cloud_push", "loggers": ["sleekxmppfs", "sucks", "deebot_client"], - "requirements": ["py-sucks==0.9.10", "deebot-client==11.1.0b2"] + "requirements": ["py-sucks==0.9.10", "deebot-client==12.0.0"] } diff --git a/homeassistant/components/eq3btsmart/manifest.json b/homeassistant/components/eq3btsmart/manifest.json index 3a55730c60f..0bc3ae55236 100644 --- a/homeassistant/components/eq3btsmart/manifest.json +++ b/homeassistant/components/eq3btsmart/manifest.json @@ -22,5 +22,5 @@ "integration_type": "device", "iot_class": "local_polling", "loggers": ["eq3btsmart"], - "requirements": ["eq3btsmart==1.4.1", "bleak-esphome==2.6.0"] + "requirements": ["eq3btsmart==1.4.1", "bleak-esphome==2.7.1"] } diff --git a/homeassistant/components/esphome/bluetooth.py b/homeassistant/components/esphome/bluetooth.py index da342913d3d..27abb19909f 100644 --- a/homeassistant/components/esphome/bluetooth.py +++ b/homeassistant/components/esphome/bluetooth.py @@ -28,6 +28,7 @@ def async_connect_scanner( entry_data: RuntimeEntryData, cli: APIClient, device_info: DeviceInfo, + device_id: str, ) -> CALLBACK_TYPE: """Connect scanner.""" client_data = connect_scanner(cli, device_info, entry_data.available) @@ -45,6 +46,7 @@ def async_connect_scanner( source_domain=DOMAIN, source_model=device_info.model, source_config_entry_id=entry_data.entry_id, + source_device_id=device_id, ), scanner.async_setup(), ], diff --git a/homeassistant/components/esphome/manager.py b/homeassistant/components/esphome/manager.py index 93d6c53e590..5f5ee1241f7 100644 --- a/homeassistant/components/esphome/manager.py +++ b/homeassistant/components/esphome/manager.py @@ -425,7 +425,9 @@ class ESPHomeManager: if device_info.bluetooth_proxy_feature_flags_compat(api_version): entry_data.disconnect_callbacks.add( - async_connect_scanner(hass, entry_data, cli, device_info) + async_connect_scanner( + hass, entry_data, cli, device_info, self.device_id + ) ) else: bluetooth.async_remove_scanner(hass, device_info.mac_address) @@ -571,7 +573,9 @@ def _async_setup_device_registry( configuration_url = None if device_info.webserver_port > 0: - configuration_url = f"http://{entry.data['host']}:{device_info.webserver_port}" + entry_host = entry.data["host"] + host = f"[{entry_host}]" if ":" in entry_host else entry_host + configuration_url = f"http://{host}:{device_info.webserver_port}" elif ( (dashboard := async_get_dashboard(hass)) and dashboard.data diff --git a/homeassistant/components/esphome/manifest.json b/homeassistant/components/esphome/manifest.json index 9585be72c63..185f9ea5cf0 100644 --- a/homeassistant/components/esphome/manifest.json +++ b/homeassistant/components/esphome/manifest.json @@ -18,7 +18,7 @@ "requirements": [ "aioesphomeapi==29.0.0", "esphome-dashboard-api==1.2.3", - "bleak-esphome==2.6.0" + "bleak-esphome==2.7.1" ], "zeroconf": ["_esphomelib._tcp.local."] } diff --git a/homeassistant/components/faa_delays/__init__.py b/homeassistant/components/faa_delays/__init__.py index 750b1f4a833..e33ccc9fe48 100644 --- a/homeassistant/components/faa_delays/__init__.py +++ b/homeassistant/components/faa_delays/__init__.py @@ -1,33 +1,27 @@ """The FAA Delays integration.""" -from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_ID, Platform from homeassistant.core import HomeAssistant -from .const import DOMAIN -from .coordinator import FAADataUpdateCoordinator +from .coordinator import FAAConfigEntry, FAADataUpdateCoordinator PLATFORMS = [Platform.BINARY_SENSOR] -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_setup_entry(hass: HomeAssistant, entry: FAAConfigEntry) -> bool: """Set up FAA Delays from a config entry.""" code = entry.data[CONF_ID] - coordinator = FAADataUpdateCoordinator(hass, code) + coordinator = FAADataUpdateCoordinator(hass, entry, code) await coordinator.async_config_entry_first_refresh() - hass.data.setdefault(DOMAIN, {}) - hass.data[DOMAIN][entry.entry_id] = coordinator + entry.runtime_data = coordinator await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) return True -async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_unload_entry(hass: HomeAssistant, entry: FAAConfigEntry) -> bool: """Unload a config entry.""" - unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) - if unload_ok: - hass.data[DOMAIN].pop(entry.entry_id) - return unload_ok + return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/homeassistant/components/faa_delays/binary_sensor.py b/homeassistant/components/faa_delays/binary_sensor.py index 6a01bf6ebed..0fbc028f111 100644 --- a/homeassistant/components/faa_delays/binary_sensor.py +++ b/homeassistant/components/faa_delays/binary_sensor.py @@ -12,13 +12,12 @@ from homeassistant.components.binary_sensor import ( BinarySensorEntity, BinarySensorEntityDescription, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity -from . import FAADataUpdateCoordinator +from . import FAAConfigEntry, FAADataUpdateCoordinator from .const import DOMAIN @@ -84,10 +83,10 @@ FAA_BINARY_SENSORS: tuple[FaaDelaysBinarySensorEntityDescription, ...] = ( async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, entry: FAAConfigEntry, async_add_entities: AddEntitiesCallback ) -> None: """Set up a FAA sensor based on a config entry.""" - coordinator = hass.data[DOMAIN][entry.entry_id] + coordinator = entry.runtime_data entities = [ FAABinarySensor(coordinator, entry.entry_id, description) diff --git a/homeassistant/components/faa_delays/coordinator.py b/homeassistant/components/faa_delays/coordinator.py index 9de10b2ebbb..aefc8d72487 100644 --- a/homeassistant/components/faa_delays/coordinator.py +++ b/homeassistant/components/faa_delays/coordinator.py @@ -7,6 +7,7 @@ import logging from aiohttp import ClientConnectionError from faadelays import Airport +from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers import aiohttp_client from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed @@ -15,14 +16,20 @@ from .const import DOMAIN _LOGGER = logging.getLogger(__name__) +type FAAConfigEntry = ConfigEntry[FAADataUpdateCoordinator] + class FAADataUpdateCoordinator(DataUpdateCoordinator[Airport]): """Class to manage fetching FAA API data from a single endpoint.""" - def __init__(self, hass: HomeAssistant, code: str) -> None: + def __init__(self, hass: HomeAssistant, entry: FAAConfigEntry, code: str) -> None: """Initialize the coordinator.""" super().__init__( - hass, _LOGGER, name=DOMAIN, update_interval=timedelta(minutes=1) + hass, + _LOGGER, + config_entry=entry, + name=DOMAIN, + update_interval=timedelta(minutes=1), ) self.session = aiohttp_client.async_get_clientsession(hass) self.data = Airport(code, self.session) diff --git a/homeassistant/components/fastdotcom/__init__.py b/homeassistant/components/fastdotcom/__init__.py index 967e7ef8e35..59cb3f984d2 100644 --- a/homeassistant/components/fastdotcom/__init__.py +++ b/homeassistant/components/fastdotcom/__init__.py @@ -4,20 +4,20 @@ from __future__ import annotations import logging -from homeassistant.config_entries import ConfigEntry, ConfigEntryState +from homeassistant.config_entries import ConfigEntryState from homeassistant.core import HomeAssistant from homeassistant.helpers.start import async_at_started -from .const import DOMAIN, PLATFORMS -from .coordinator import FastdotcomDataUpdateCoordinator +from .const import PLATFORMS +from .coordinator import FastdotcomConfigEntry, FastdotcomDataUpdateCoordinator _LOGGER = logging.getLogger(__name__) -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_setup_entry(hass: HomeAssistant, entry: FastdotcomConfigEntry) -> bool: """Set up Fast.com from a config entry.""" - coordinator = FastdotcomDataUpdateCoordinator(hass) - hass.data.setdefault(DOMAIN, {})[entry.entry_id] = coordinator + coordinator = FastdotcomDataUpdateCoordinator(hass, entry) + entry.runtime_data = coordinator await hass.config_entries.async_forward_entry_setups( entry, @@ -36,8 +36,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: return True -async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_unload_entry(hass: HomeAssistant, entry: FastdotcomConfigEntry) -> bool: """Unload Fast.com config entry.""" - if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS): - hass.data[DOMAIN].pop(entry.entry_id) - return unload_ok + return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/homeassistant/components/fastdotcom/coordinator.py b/homeassistant/components/fastdotcom/coordinator.py index 75ac55b8314..8365692804c 100644 --- a/homeassistant/components/fastdotcom/coordinator.py +++ b/homeassistant/components/fastdotcom/coordinator.py @@ -6,20 +6,24 @@ from datetime import timedelta from fastdotcom import fast_com +from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from .const import DEFAULT_INTERVAL, DOMAIN, LOGGER +type FastdotcomConfigEntry = ConfigEntry[FastdotcomDataUpdateCoordinator] + class FastdotcomDataUpdateCoordinator(DataUpdateCoordinator[float]): """Class to manage fetching Fast.com data API.""" - def __init__(self, hass: HomeAssistant) -> None: + def __init__(self, hass: HomeAssistant, entry: FastdotcomConfigEntry) -> None: """Initialize the coordinator for Fast.com.""" super().__init__( hass, LOGGER, + config_entry=entry, name=DOMAIN, update_interval=timedelta(hours=DEFAULT_INTERVAL), ) diff --git a/homeassistant/components/fastdotcom/diagnostics.py b/homeassistant/components/fastdotcom/diagnostics.py index d7383ef0c6a..42f4e32f49e 100644 --- a/homeassistant/components/fastdotcom/diagnostics.py +++ b/homeassistant/components/fastdotcom/diagnostics.py @@ -4,21 +4,13 @@ from __future__ import annotations from typing import Any -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant -from .const import DOMAIN -from .coordinator import FastdotcomDataUpdateCoordinator +from .coordinator import FastdotcomConfigEntry async def async_get_config_entry_diagnostics( - hass: HomeAssistant, config_entry: ConfigEntry + hass: HomeAssistant, config_entry: FastdotcomConfigEntry ) -> dict[str, Any]: """Return diagnostics for the config entry.""" - coordinator: FastdotcomDataUpdateCoordinator = hass.data[DOMAIN][ - config_entry.entry_id - ] - - return { - "coordinator_data": coordinator.data, - } + return {"coordinator_data": config_entry.runtime_data.data} diff --git a/homeassistant/components/fastdotcom/sensor.py b/homeassistant/components/fastdotcom/sensor.py index 721290e8c0d..b633cb25628 100644 --- a/homeassistant/components/fastdotcom/sensor.py +++ b/homeassistant/components/fastdotcom/sensor.py @@ -7,7 +7,6 @@ from homeassistant.components.sensor import ( SensorEntity, SensorStateClass, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import UnitOfDataRate from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo @@ -15,17 +14,16 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import DOMAIN -from .coordinator import FastdotcomDataUpdateCoordinator +from .coordinator import FastdotcomConfigEntry, FastdotcomDataUpdateCoordinator async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: FastdotcomConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: """Set up the Fast.com sensor.""" - coordinator: FastdotcomDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] - async_add_entities([SpeedtestSensor(entry.entry_id, coordinator)]) + async_add_entities([SpeedtestSensor(entry.entry_id, entry.runtime_data)]) class SpeedtestSensor(CoordinatorEntity[FastdotcomDataUpdateCoordinator], SensorEntity): diff --git a/homeassistant/components/file_upload/__init__.py b/homeassistant/components/file_upload/__init__.py index 97b3f83d5bc..6b0a1423e49 100644 --- a/homeassistant/components/file_upload/__init__.py +++ b/homeassistant/components/file_upload/__init__.py @@ -21,9 +21,11 @@ from homeassistant.core import Event, HomeAssistant, callback from homeassistant.helpers import config_validation as cv from homeassistant.helpers.typing import ConfigType from homeassistant.util import raise_if_invalid_filename +from homeassistant.util.hass_dict import HassKey from homeassistant.util.ulid import ulid_hex DOMAIN = "file_upload" +_DATA: HassKey[FileUploadData] = HassKey(DOMAIN) ONE_MEGABYTE = 1024 * 1024 MAX_SIZE = 100 * ONE_MEGABYTE @@ -41,7 +43,7 @@ def process_uploaded_file(hass: HomeAssistant, file_id: str) -> Iterator[Path]: if DOMAIN not in hass.data: raise ValueError("File does not exist") - file_upload_data: FileUploadData = hass.data[DOMAIN] + file_upload_data = hass.data[_DATA] if not file_upload_data.has_file(file_id): raise ValueError("File does not exist") @@ -149,10 +151,10 @@ class FileUploadView(HomeAssistantView): hass = request.app[KEY_HASS] file_id = ulid_hex() - if DOMAIN not in hass.data: - hass.data[DOMAIN] = await FileUploadData.create(hass) + if _DATA not in hass.data: + hass.data[_DATA] = await FileUploadData.create(hass) - file_upload_data: FileUploadData = hass.data[DOMAIN] + file_upload_data = hass.data[_DATA] file_dir = file_upload_data.file_dir(file_id) queue: SimpleQueue[tuple[bytes, asyncio.Future[None] | None] | None] = ( SimpleQueue() @@ -206,7 +208,7 @@ class FileUploadView(HomeAssistantView): raise web.HTTPNotFound file_id = data["file_id"] - file_upload_data: FileUploadData = hass.data[DOMAIN] + file_upload_data = hass.data[_DATA] if file_upload_data.files.pop(file_id, None) is None: raise web.HTTPNotFound diff --git a/homeassistant/components/frontend/manifest.json b/homeassistant/components/frontend/manifest.json index 2ecb165554a..b584fe5e2f0 100644 --- a/homeassistant/components/frontend/manifest.json +++ b/homeassistant/components/frontend/manifest.json @@ -21,5 +21,5 @@ "documentation": "https://www.home-assistant.io/integrations/frontend", "integration_type": "system", "quality_scale": "internal", - "requirements": ["home-assistant-frontend==20250131.0"] + "requirements": ["home-assistant-frontend==20250204.0"] } diff --git a/homeassistant/components/frontier_silicon/media_player.py b/homeassistant/components/frontier_silicon/media_player.py index 8407e0a869d..52998e03703 100644 --- a/homeassistant/components/frontier_silicon/media_player.py +++ b/homeassistant/components/frontier_silicon/media_player.py @@ -244,7 +244,7 @@ class AFSAPIDevice(MediaPlayerEntity): """Send volume up command.""" volume = await self.fs_device.get_volume() volume = int(volume or 0) + 1 - await self.fs_device.set_volume(min(volume, self._max_volume)) + await self.fs_device.set_volume(min(volume, self._max_volume or 1)) async def async_volume_down(self) -> None: """Send volume down command.""" diff --git a/homeassistant/components/fully_kiosk/sensor.py b/homeassistant/components/fully_kiosk/sensor.py index ed95323547f..d92c5c17341 100644 --- a/homeassistant/components/fully_kiosk/sensor.py +++ b/homeassistant/components/fully_kiosk/sensor.py @@ -4,7 +4,7 @@ from __future__ import annotations from collections.abc import Callable from dataclasses import dataclass -from typing import Any +from typing import TYPE_CHECKING, Any from homeassistant.components.sensor import ( SensorDeviceClass, @@ -152,6 +152,8 @@ class FullySensor(FullyKioskEntity, SensorEntity): value, extra_state_attributes = self.entity_description.state_fn(value) if self.entity_description.round_state_value: + if TYPE_CHECKING: + assert isinstance(value, int) value = round_storage(value) self._attr_native_value = value diff --git a/homeassistant/components/generic/strings.json b/homeassistant/components/generic/strings.json index 854ceb93b3e..4a5d672fcde 100644 --- a/homeassistant/components/generic/strings.json +++ b/homeassistant/components/generic/strings.json @@ -28,14 +28,14 @@ "user": { "description": "Enter the settings to connect to the camera.", "data": { - "still_image_url": "Still Image URL (e.g. http://...)", - "stream_source": "Stream Source URL (e.g. rtsp://...)", + "still_image_url": "Still image URL (e.g. http://...)", + "stream_source": "Stream source URL (e.g. rtsp://...)", "rtsp_transport": "RTSP transport protocol", "authentication": "Authentication", - "limit_refetch_to_url_change": "Limit refetch to url change", + "limit_refetch_to_url_change": "Limit refetch to URL change", "password": "[%key:common::config_flow::data::password%]", "username": "[%key:common::config_flow::data::username%]", - "framerate": "Frame Rate (Hz)", + "framerate": "Frame rate (Hz)", "verify_ssl": "[%key:common::config_flow::data::verify_ssl%]" } }, diff --git a/homeassistant/components/google_generative_ai_conversation/conversation.py b/homeassistant/components/google_generative_ai_conversation/conversation.py index db2df9cddd3..8a6c5563601 100644 --- a/homeassistant/components/google_generative_ai_conversation/conversation.py +++ b/homeassistant/components/google_generative_ai_conversation/conversation.py @@ -4,7 +4,7 @@ from __future__ import annotations import codecs from collections.abc import Callable -from typing import Any, Literal +from typing import Any, Literal, cast from google.api_core.exceptions import GoogleAPIError import google.generativeai as genai @@ -18,7 +18,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_LLM_HASS_API, MATCH_ALL from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers import device_registry as dr, intent, llm +from homeassistant.helpers import chat_session, device_registry as dr, intent, llm from homeassistant.helpers.entity_platform import AddEntitiesCallback from .const import ( @@ -149,15 +149,53 @@ def _escape_decode(value: Any) -> Any: return value -def _chat_message_convert( - message: conversation.Content | conversation.NativeContent[genai_types.ContentDict], -) -> genai_types.ContentDict: - """Convert any native chat message for this agent to the native format.""" - if message.role == "native": - return message.content +def _create_google_tool_response_content( + content: list[conversation.ToolResultContent], +) -> protos.Content: + """Create a Google tool response content.""" + return protos.Content( + parts=[ + protos.Part( + function_response=protos.FunctionResponse( + name=tool_result.tool_name, response=tool_result.tool_result + ) + ) + for tool_result in content + ] + ) - role = "model" if message.role == "assistant" else message.role - return {"role": role, "parts": message.content} + +def _convert_content( + content: conversation.UserContent + | conversation.AssistantContent + | conversation.SystemContent, +) -> genai_types.ContentDict: + """Convert HA content to Google content.""" + if content.role != "assistant" or not content.tool_calls: # type: ignore[union-attr] + role = "model" if content.role == "assistant" else content.role + return {"role": role, "parts": content.content} + + # Handle the Assistant content with tool calls. + assert type(content) is conversation.AssistantContent + parts = [] + + if content.content: + parts.append(protos.Part(text=content.content)) + + if content.tool_calls: + parts.extend( + [ + protos.Part( + function_call=protos.FunctionCall( + name=tool_call.tool_name, + args=_escape_decode(tool_call.tool_args), + ) + ) + for tool_call in content.tool_calls + ] + ) + + return protos.Content({"role": "model", "parts": parts}) class GoogleGenerativeAIConversationEntity( @@ -209,15 +247,18 @@ class GoogleGenerativeAIConversationEntity( self, user_input: conversation.ConversationInput ) -> conversation.ConversationResult: """Process a sentence.""" - async with conversation.async_get_chat_session( - self.hass, user_input - ) as session: - return await self._async_handle_message(user_input, session) + with ( + chat_session.async_get_chat_session( + self.hass, user_input.conversation_id + ) as session, + conversation.async_get_chat_log(self.hass, session, user_input) as chat_log, + ): + return await self._async_handle_message(user_input, chat_log) async def _async_handle_message( self, user_input: conversation.ConversationInput, - session: conversation.ChatSession[genai_types.ContentDict], + chat_log: conversation.ChatLog, ) -> conversation.ConversationResult: """Call the API.""" @@ -225,7 +266,7 @@ class GoogleGenerativeAIConversationEntity( options = self.entry.options try: - await session.async_update_llm_data( + await chat_log.async_update_llm_data( DOMAIN, user_input, options.get(CONF_LLM_HASS_API), @@ -235,10 +276,10 @@ class GoogleGenerativeAIConversationEntity( return err.as_conversation_result() tools: list[dict[str, Any]] | None = None - if session.llm_api: + if chat_log.llm_api: tools = [ - _format_tool(tool, session.llm_api.custom_serializer) - for tool in session.llm_api.tools + _format_tool(tool, chat_log.llm_api.custom_serializer) + for tool in chat_log.llm_api.tools ] model_name = self.entry.options.get(CONF_CHAT_MODEL, RECOMMENDED_CHAT_MODEL) @@ -249,9 +290,36 @@ class GoogleGenerativeAIConversationEntity( "gemini-1.0" not in model_name and "gemini-pro" not in model_name ) - prompt, *messages = [ - _chat_message_convert(message) for message in session.async_get_messages() - ] + prompt = chat_log.content[0].content # type: ignore[union-attr] + messages: list[genai_types.ContentDict] = [] + + # Google groups tool results, we do not. Group them before sending. + tool_results: list[conversation.ToolResultContent] = [] + + for chat_content in chat_log.content[1:]: + if chat_content.role == "tool_result": + # mypy doesn't like picking a type based on checking shared property 'role' + tool_results.append(cast(conversation.ToolResultContent, chat_content)) + continue + + if tool_results: + messages.append(_create_google_tool_response_content(tool_results)) + tool_results.clear() + + messages.append( + _convert_content( + cast( + conversation.UserContent + | conversation.SystemContent + | conversation.AssistantContent, + chat_content, + ) + ) + ) + + if tool_results: + messages.append(_create_google_tool_response_content(tool_results)) + model = genai.GenerativeModel( model_name=model_name, generation_config={ @@ -279,12 +347,12 @@ class GoogleGenerativeAIConversationEntity( ), }, tools=tools or None, - system_instruction=prompt["parts"] if supports_system_instruction else None, + system_instruction=prompt if supports_system_instruction else None, ) if not supports_system_instruction: messages = [ - {"role": "user", "parts": prompt["parts"]}, + {"role": "user", "parts": prompt}, {"role": "model", "parts": "Ok"}, *messages, ] @@ -322,50 +390,40 @@ class GoogleGenerativeAIConversationEntity( content = " ".join( [part.text.strip() for part in chat_response.parts if part.text] ) - if content: - session.async_add_message( - conversation.Content( - role="assistant", - agent_id=user_input.agent_id, - content=content, - ) - ) - function_calls = [ - part.function_call for part in chat_response.parts if part.function_call - ] - - if not function_calls or not session.llm_api: - break - - tool_responses = [] - for function_call in function_calls: - tool_call = MessageToDict(function_call._pb) # noqa: SLF001 + tool_calls = [] + for part in chat_response.parts: + if not part.function_call: + continue + tool_call = MessageToDict(part.function_call._pb) # noqa: SLF001 tool_name = tool_call["name"] tool_args = _escape_decode(tool_call["args"]) - tool_input = llm.ToolInput(tool_name=tool_name, tool_args=tool_args) - function_response = await session.async_call_tool(tool_input) - tool_responses.append( - protos.Part( - function_response=protos.FunctionResponse( - name=tool_name, response=function_response + tool_calls.append( + llm.ToolInput(tool_name=tool_name, tool_args=tool_args) + ) + + chat_request = _create_google_tool_response_content( + [ + tool_response + async for tool_response in chat_log.async_add_assistant_content( + conversation.AssistantContent( + agent_id=user_input.agent_id, + content=content, + tool_calls=tool_calls or None, ) ) - ) - chat_request = protos.Content(parts=tool_responses) - session.async_add_message( - conversation.NativeContent( - agent_id=user_input.agent_id, - content=chat_request, - ) + ] ) + if not tool_calls: + break + response = intent.IntentResponse(language=user_input.language) response.async_set_speech( " ".join([part.text.strip() for part in chat_response.parts if part.text]) ) return conversation.ConversationResult( - response=response, conversation_id=session.conversation_id + response=response, conversation_id=chat_log.conversation_id ) async def _async_entry_update_listener( diff --git a/homeassistant/components/govee_ble/manifest.json b/homeassistant/components/govee_ble/manifest.json index 5a123de7066..4d871a991a6 100644 --- a/homeassistant/components/govee_ble/manifest.json +++ b/homeassistant/components/govee_ble/manifest.json @@ -131,5 +131,5 @@ "dependencies": ["bluetooth_adapters"], "documentation": "https://www.home-assistant.io/integrations/govee_ble", "iot_class": "local_push", - "requirements": ["govee-ble==0.42.0"] + "requirements": ["govee-ble==0.42.1"] } diff --git a/homeassistant/components/habitica/manifest.json b/homeassistant/components/habitica/manifest.json index 1c92c314e66..6ace6d45509 100644 --- a/homeassistant/components/habitica/manifest.json +++ b/homeassistant/components/habitica/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/habitica", "iot_class": "cloud_polling", "loggers": ["habiticalib"], - "requirements": ["habiticalib==0.3.3"] + "requirements": ["habiticalib==0.3.4"] } diff --git a/homeassistant/components/hassio/backup.py b/homeassistant/components/hassio/backup.py index 495e953df9d..142c5fc01ce 100644 --- a/homeassistant/components/hassio/backup.py +++ b/homeassistant/components/hassio/backup.py @@ -27,15 +27,19 @@ from homeassistant.components.backup import ( AgentBackup, BackupAgent, BackupManagerError, + BackupNotFound, BackupReaderWriter, BackupReaderWriterError, CreateBackupEvent, + CreateBackupStage, + CreateBackupState, Folder, IdleEvent, IncorrectPasswordError, ManagerBackup, NewBackup, RestoreBackupEvent, + RestoreBackupStage, RestoreBackupState, WrittenBackup, async_get_manager as async_get_backup_manager, @@ -47,6 +51,7 @@ from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.util import dt as dt_util +from homeassistant.util.enum import try_parse_enum from .const import DOMAIN, EVENT_SUPERVISOR_EVENT from .handler import get_supervisor_client @@ -158,10 +163,15 @@ class SupervisorBackupAgent(BackupAgent): **kwargs: Any, ) -> AsyncIterator[bytes]: """Download a backup file.""" - return await self._client.backups.download_backup( - backup_id, - options=supervisor_backups.DownloadBackupOptions(location=self.location), - ) + try: + return await self._client.backups.download_backup( + backup_id, + options=supervisor_backups.DownloadBackupOptions( + location=self.location + ), + ) + except SupervisorNotFoundError as err: + raise BackupNotFound from err async def async_upload_backup( self, @@ -336,6 +346,7 @@ class SupervisorBackupReaderWriter(BackupReaderWriter): self._async_wait_for_backup( backup, locations, + on_progress=on_progress, remove_after_upload=locations == [LOCATION_CLOUD_BACKUP], ), name="backup_manager_create_backup", @@ -349,18 +360,29 @@ class SupervisorBackupReaderWriter(BackupReaderWriter): backup: supervisor_backups.NewBackup, locations: list[str | None], *, + on_progress: Callable[[CreateBackupEvent], None], remove_after_upload: bool, ) -> WrittenBackup: """Wait for a backup to complete.""" backup_complete = asyncio.Event() backup_id: str | None = None + create_errors: list[dict[str, str]] = [] @callback def on_job_progress(data: Mapping[str, Any]) -> None: """Handle backup progress.""" nonlocal backup_id + if not (stage := try_parse_enum(CreateBackupStage, data.get("stage"))): + _LOGGER.debug("Unknown create stage: %s", data.get("stage")) + else: + on_progress( + CreateBackupEvent( + reason=None, stage=stage, state=CreateBackupState.IN_PROGRESS + ) + ) if data.get("done") is True: backup_id = data.get("reference") + create_errors.extend(data.get("errors", [])) backup_complete.set() unsub = self._async_listen_job_events(backup.job_id, on_job_progress) @@ -369,8 +391,11 @@ class SupervisorBackupReaderWriter(BackupReaderWriter): await backup_complete.wait() finally: unsub() - if not backup_id: - raise BackupReaderWriterError("Backup failed") + if not backup_id or create_errors: + # We should add more specific error handling here in the future + raise BackupReaderWriterError( + f"Backup failed: {create_errors or 'no backup_id'}" + ) async def open_backup() -> AsyncIterator[bytes]: try: @@ -509,6 +534,8 @@ class SupervisorBackupReaderWriter(BackupReaderWriter): location=restore_location, ), ) + except SupervisorNotFoundError as err: + raise BackupNotFound from err except SupervisorBadRequestError as err: # Supervisor currently does not transmit machine parsable error types message = err.args[0] @@ -517,17 +544,30 @@ class SupervisorBackupReaderWriter(BackupReaderWriter): raise HomeAssistantError(message) from err restore_complete = asyncio.Event() + restore_errors: list[dict[str, str]] = [] @callback def on_job_progress(data: Mapping[str, Any]) -> None: """Handle backup restore progress.""" + if not (stage := try_parse_enum(RestoreBackupStage, data.get("stage"))): + _LOGGER.debug("Unknown restore stage: %s", data.get("stage")) + else: + on_progress( + RestoreBackupEvent( + reason=None, stage=stage, state=RestoreBackupState.IN_PROGRESS + ) + ) if data.get("done") is True: restore_complete.set() + restore_errors.extend(data.get("errors", [])) unsub = self._async_listen_job_events(job.job_id, on_job_progress) try: await self._get_job_state(job.job_id, on_job_progress) await restore_complete.wait() + if restore_errors: + # We should add more specific error handling here in the future + raise BackupReaderWriterError(f"Restore failed: {restore_errors}") finally: unsub() @@ -543,22 +583,45 @@ class SupervisorBackupReaderWriter(BackupReaderWriter): _LOGGER.debug("Found restore job ID %s in environment", restore_job_id) + sent_event = False + @callback def on_job_progress(data: Mapping[str, Any]) -> None: """Handle backup restore progress.""" + nonlocal sent_event + + if not (stage := try_parse_enum(RestoreBackupStage, data.get("stage"))): + _LOGGER.debug("Unknown restore stage: %s", data.get("stage")) + if data.get("done") is not True: - on_progress( - RestoreBackupEvent( - reason="", stage=None, state=RestoreBackupState.IN_PROGRESS + if stage or not sent_event: + sent_event = True + on_progress( + RestoreBackupEvent( + reason=None, + stage=stage, + state=RestoreBackupState.IN_PROGRESS, + ) ) - ) return - on_progress( - RestoreBackupEvent( - reason="", stage=None, state=RestoreBackupState.COMPLETED + restore_errors = data.get("errors", []) + if restore_errors: + _LOGGER.warning("Restore backup failed: %s", restore_errors) + # We should add more specific error handling here in the future + on_progress( + RestoreBackupEvent( + reason="unknown_error", + stage=stage, + state=RestoreBackupState.FAILED, + ) + ) + else: + on_progress( + RestoreBackupEvent( + reason=None, stage=stage, state=RestoreBackupState.COMPLETED + ) ) - ) on_progress(IdleEvent()) unsub() diff --git a/homeassistant/components/heicko/__init__.py b/homeassistant/components/heicko/__init__.py new file mode 100644 index 00000000000..65c527f5252 --- /dev/null +++ b/homeassistant/components/heicko/__init__.py @@ -0,0 +1 @@ +"""Virtual integration: Heicko.""" diff --git a/homeassistant/components/heicko/manifest.json b/homeassistant/components/heicko/manifest.json new file mode 100644 index 00000000000..d8f939a5bed --- /dev/null +++ b/homeassistant/components/heicko/manifest.json @@ -0,0 +1,6 @@ +{ + "domain": "heicko", + "name": "Heicko", + "integration_type": "virtual", + "supported_by": "motion_blinds" +} diff --git a/homeassistant/components/home_connect/binary_sensor.py b/homeassistant/components/home_connect/binary_sensor.py index 90743c829e2..67e3d56e713 100644 --- a/homeassistant/components/home_connect/binary_sensor.py +++ b/homeassistant/components/home_connect/binary_sensor.py @@ -21,6 +21,7 @@ from homeassistant.helpers.issue_registry import ( async_delete_issue, ) +from .common import setup_home_connect_entry from .const import ( BSH_DOOR_STATE_CLOSED, BSH_DOOR_STATE_LOCKED, @@ -113,24 +114,33 @@ BINARY_SENSORS = ( ) +def _get_entities_for_appliance( + entry: HomeConnectConfigEntry, + appliance: HomeConnectApplianceData, +) -> list[HomeConnectEntity]: + """Get a list of entities.""" + entities: list[HomeConnectEntity] = [] + entities.extend( + HomeConnectBinarySensor(entry.runtime_data, appliance, description) + for description in BINARY_SENSORS + if description.key in appliance.status + ) + if StatusKey.BSH_COMMON_DOOR_STATE in appliance.status: + entities.append(HomeConnectDoorBinarySensor(entry.runtime_data, appliance)) + return entities + + async def async_setup_entry( hass: HomeAssistant, entry: HomeConnectConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: """Set up the Home Connect binary sensor.""" - - entities: list[BinarySensorEntity] = [] - for appliance in entry.runtime_data.data.values(): - entities.extend( - HomeConnectBinarySensor(entry.runtime_data, appliance, description) - for description in BINARY_SENSORS - if description.key in appliance.status - ) - if StatusKey.BSH_COMMON_DOOR_STATE in appliance.status: - entities.append(HomeConnectDoorBinarySensor(entry.runtime_data, appliance)) - - async_add_entities(entities) + setup_home_connect_entry( + entry, + _get_entities_for_appliance, + async_add_entities, + ) class HomeConnectBinarySensor(HomeConnectEntity, BinarySensorEntity): diff --git a/homeassistant/components/home_connect/common.py b/homeassistant/components/home_connect/common.py new file mode 100644 index 00000000000..6bd098a76fc --- /dev/null +++ b/homeassistant/components/home_connect/common.py @@ -0,0 +1,99 @@ +"""Common callbacks for all Home Connect platforms.""" + +from collections.abc import Callable +from functools import partial +from typing import cast + +from aiohomeconnect.model import EventKey + +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from .coordinator import HomeConnectApplianceData, HomeConnectConfigEntry +from .entity import HomeConnectEntity + + +def _handle_paired_or_connected_appliance( + entry: HomeConnectConfigEntry, + known_entity_unique_ids: dict[str, str], + get_entities_for_appliance: Callable[ + [HomeConnectConfigEntry, HomeConnectApplianceData], list[HomeConnectEntity] + ], + async_add_entities: AddEntitiesCallback, +) -> None: + """Handle a new paired appliance or an appliance that has been connected. + + This function is used to handle connected events also, because some appliances + don't report any data while they are off because they disconnect themselves + when they are turned off, so we need to check if the entities have been added + already or it is the first time we see them when the appliance is connected. + """ + entities: list[HomeConnectEntity] = [] + for appliance in entry.runtime_data.data.values(): + entities_to_add = [ + entity + for entity in get_entities_for_appliance(entry, appliance) + if entity.unique_id not in known_entity_unique_ids + ] + known_entity_unique_ids.update( + { + cast(str, entity.unique_id): appliance.info.ha_id + for entity in entities_to_add + } + ) + entities.extend(entities_to_add) + async_add_entities(entities) + + +def _handle_depaired_appliance( + entry: HomeConnectConfigEntry, + known_entity_unique_ids: dict[str, str], +) -> None: + """Handle a removed appliance.""" + for entity_unique_id, appliance_id in known_entity_unique_ids.copy().items(): + if appliance_id not in entry.runtime_data.data: + known_entity_unique_ids.pop(entity_unique_id, None) + + +def setup_home_connect_entry( + entry: HomeConnectConfigEntry, + get_entities_for_appliance: Callable[ + [HomeConnectConfigEntry, HomeConnectApplianceData], list[HomeConnectEntity] + ], + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up the callbacks for paired and depaired appliances.""" + known_entity_unique_ids: dict[str, str] = {} + + entities: list[HomeConnectEntity] = [] + for appliance in entry.runtime_data.data.values(): + entities_to_add = get_entities_for_appliance(entry, appliance) + known_entity_unique_ids.update( + { + cast(str, entity.unique_id): appliance.info.ha_id + for entity in entities_to_add + } + ) + entities.extend(entities_to_add) + async_add_entities(entities) + + entry.async_on_unload( + entry.runtime_data.async_add_special_listener( + partial( + _handle_paired_or_connected_appliance, + entry, + known_entity_unique_ids, + get_entities_for_appliance, + async_add_entities, + ), + ( + EventKey.BSH_COMMON_APPLIANCE_PAIRED, + EventKey.BSH_COMMON_APPLIANCE_CONNECTED, + ), + ) + ) + entry.async_on_unload( + entry.runtime_data.async_add_special_listener( + partial(_handle_depaired_appliance, entry, known_entity_unique_ids), + (EventKey.BSH_COMMON_APPLIANCE_DEPAIRED,), + ) + ) diff --git a/homeassistant/components/home_connect/coordinator.py b/homeassistant/components/home_connect/coordinator.py index 9e49b6e678e..68652936872 100644 --- a/homeassistant/components/home_connect/coordinator.py +++ b/homeassistant/components/home_connect/coordinator.py @@ -3,7 +3,7 @@ import asyncio from collections import defaultdict from collections.abc import Callable -from dataclasses import dataclass, field +from dataclasses import dataclass import logging from typing import Any @@ -30,6 +30,7 @@ from propcache.api import cached_property from homeassistant.config_entries import ConfigEntry from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback +from homeassistant.helpers import device_registry as dr from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from .const import APPLIANCES_WITH_PROGRAMS, DOMAIN @@ -46,9 +47,9 @@ EVENT_STREAM_RECONNECT_DELAY = 30 class HomeConnectApplianceData: """Class to hold Home Connect appliance data.""" - events: dict[EventKey, Event] = field(default_factory=dict) + events: dict[EventKey, Event] info: HomeAppliance - programs: list[EnumerateProgram] = field(default_factory=list) + programs: list[EnumerateProgram] settings: dict[SettingKey, GetSetting] status: dict[StatusKey, Status] @@ -83,6 +84,10 @@ class HomeConnectCoordinator( name=config_entry.entry_id, ) self.client = client + self._special_listeners: dict[ + CALLBACK_TYPE, tuple[CALLBACK_TYPE, tuple[EventKey, ...]] + ] = {} + self.device_registry = dr.async_get(self.hass) @cached_property def context_listeners(self) -> dict[tuple[str, EventKey], list[CALLBACK_TYPE]]: @@ -107,6 +112,28 @@ class HomeConnectCoordinator( return remove_listener_and_invalidate_context_listeners + @callback + def async_add_special_listener( + self, + update_callback: CALLBACK_TYPE, + context: tuple[EventKey, ...], + ) -> Callable[[], None]: + """Listen for special data updates. + + These listeners will not be called on refresh. + """ + + @callback + def remove_listener() -> None: + """Remove update listener.""" + self._special_listeners.pop(remove_listener) + if not self._special_listeners: + self._unschedule_refresh() + + self._special_listeners[remove_listener] = (update_callback, context) + + return remove_listener + @callback def start_event_listener(self) -> None: """Start event listener.""" @@ -161,18 +188,49 @@ class HomeConnectCoordinator( events[event.key] = event self._call_event_listener(event_message) - case EventType.CONNECTED: - self.data[event_message_ha_id].info.connected = True - self._call_all_event_listeners_for_appliance( + case EventType.CONNECTED | EventType.PAIRED: + appliance_info = await self.client.get_specific_appliance( event_message_ha_id ) + appliance_data = await self._get_appliance_data( + appliance_info, self.data.get(appliance_info.ha_id) + ) + if event_message_ha_id in self.data: + self.data[event_message_ha_id].update(appliance_data) + else: + self.data[event_message_ha_id] = appliance_data + for listener, context in list( + self._special_listeners.values() + ) + list(self._listeners.values()): + assert isinstance(context, tuple) + if ( + EventKey.BSH_COMMON_APPLIANCE_DEPAIRED + not in context + ): + listener() + case EventType.DISCONNECTED: self.data[event_message_ha_id].info.connected = False self._call_all_event_listeners_for_appliance( event_message_ha_id ) + case EventType.DEPAIRED: + device = self.device_registry.async_get_device( + identifiers={(DOMAIN, event_message_ha_id)} + ) + if device: + self.device_registry.async_update_device( + device_id=device.id, + remove_config_entry_id=self.config_entry.entry_id, + ) + self.data.pop(event_message_ha_id, None) + for listener, context in self._special_listeners.values(): + assert isinstance(context, tuple) + if EventKey.BSH_COMMON_APPLIANCE_DEPAIRED in context: + listener() + except (EventStreamInterruptedError, HomeConnectRequestError) as error: _LOGGER.debug( "Non-breaking error (%s) while listening for events," @@ -217,60 +275,101 @@ class HomeConnectCoordinator( translation_placeholders=get_dict_from_home_connect_error(error), ) from error - appliances_data = self.data or {} - for appliance in appliances.homeappliances: - try: - settings = { - setting.key: setting - for setting in ( - await self.client.get_settings(appliance.ha_id) - ).settings - } - except HomeConnectError as error: - _LOGGER.debug( - "Error fetching settings for %s: %s", - appliance.ha_id, - error - if isinstance(error, HomeConnectApiError) - else type(error).__name__, - ) - settings = {} - try: - status = { - status.key: status - for status in (await self.client.get_status(appliance.ha_id)).status - } - except HomeConnectError as error: - _LOGGER.debug( - "Error fetching status for %s: %s", - appliance.ha_id, - error - if isinstance(error, HomeConnectApiError) - else type(error).__name__, - ) - status = {} - appliance_data = HomeConnectApplianceData( - info=appliance, settings=settings, status=status + return { + appliance.ha_id: await self._get_appliance_data( + appliance, self.data.get(appliance.ha_id) if self.data else None ) - if appliance.ha_id in appliances_data: - appliances_data[appliance.ha_id].update(appliance_data) - appliance_data = appliances_data[appliance.ha_id] + for appliance in appliances.homeappliances + } + + async def _get_appliance_data( + self, + appliance: HomeAppliance, + appliance_data_to_update: HomeConnectApplianceData | None = None, + ) -> HomeConnectApplianceData: + """Get appliance data.""" + self.device_registry.async_get_or_create( + config_entry_id=self.config_entry.entry_id, + identifiers={(DOMAIN, appliance.ha_id)}, + manufacturer=appliance.brand, + name=appliance.name, + model=appliance.vib, + ) + try: + settings = { + setting.key: setting + for setting in ( + await self.client.get_settings(appliance.ha_id) + ).settings + } + except HomeConnectError as error: + _LOGGER.debug( + "Error fetching settings for %s: %s", + appliance.ha_id, + error + if isinstance(error, HomeConnectApiError) + else type(error).__name__, + ) + settings = {} + try: + status = { + status.key: status + for status in (await self.client.get_status(appliance.ha_id)).status + } + except HomeConnectError as error: + _LOGGER.debug( + "Error fetching status for %s: %s", + appliance.ha_id, + error + if isinstance(error, HomeConnectApiError) + else type(error).__name__, + ) + status = {} + + programs = [] + events = {} + if appliance.type in APPLIANCES_WITH_PROGRAMS: + try: + all_programs = await self.client.get_all_programs(appliance.ha_id) + except HomeConnectError as error: + _LOGGER.debug( + "Error fetching programs for %s: %s", + appliance.ha_id, + error + if isinstance(error, HomeConnectApiError) + else type(error).__name__, + ) else: - appliances_data[appliance.ha_id] = appliance_data - if ( - appliance.type in APPLIANCES_WITH_PROGRAMS - and not appliance_data.programs - ): - try: - appliance_data.programs.extend( - (await self.client.get_all_programs(appliance.ha_id)).programs - ) - except HomeConnectError as error: - _LOGGER.debug( - "Error fetching programs for %s: %s", - appliance.ha_id, - error - if isinstance(error, HomeConnectApiError) - else type(error).__name__, - ) - return appliances_data + programs.extend(all_programs.programs) + for program, event_key in ( + ( + all_programs.active, + EventKey.BSH_COMMON_ROOT_ACTIVE_PROGRAM, + ), + ( + all_programs.selected, + EventKey.BSH_COMMON_ROOT_SELECTED_PROGRAM, + ), + ): + if program and program.key: + events[event_key] = Event( + event_key, + event_key.value, + 0, + "", + "", + program.key, + ) + + appliance_data = HomeConnectApplianceData( + events=events, + info=appliance, + programs=programs, + settings=settings, + status=status, + ) + if appliance_data_to_update: + appliance_data_to_update.update(appliance_data) + appliance_data = appliance_data_to_update + + return appliance_data diff --git a/homeassistant/components/home_connect/entity.py b/homeassistant/components/home_connect/entity.py index c665ca7f947..8eb9d757f14 100644 --- a/homeassistant/components/home_connect/entity.py +++ b/homeassistant/components/home_connect/entity.py @@ -35,9 +35,6 @@ class HomeConnectEntity(CoordinatorEntity[HomeConnectCoordinator]): self._attr_unique_id = f"{appliance.info.ha_id}-{desc.key}" self._attr_device_info = DeviceInfo( identifiers={(DOMAIN, appliance.info.ha_id)}, - manufacturer=appliance.info.brand, - model=appliance.info.vib, - name=appliance.info.name, ) self.update_native_value() diff --git a/homeassistant/components/home_connect/light.py b/homeassistant/components/home_connect/light.py index 9d1c4d7a55b..05c154d9153 100644 --- a/homeassistant/components/home_connect/light.py +++ b/homeassistant/components/home_connect/light.py @@ -20,6 +20,7 @@ from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.util import color as color_util +from .common import setup_home_connect_entry from .const import ( BSH_AMBIENT_LIGHT_COLOR_CUSTOM_COLOR, DOMAIN, @@ -78,20 +79,28 @@ LIGHTS: tuple[HomeConnectLightEntityDescription, ...] = ( ) +def _get_entities_for_appliance( + entry: HomeConnectConfigEntry, + appliance: HomeConnectApplianceData, +) -> list[HomeConnectEntity]: + """Get a list of entities.""" + return [ + HomeConnectLight(entry.runtime_data, appliance, description) + for description in LIGHTS + if description.key in appliance.settings + ] + + async def async_setup_entry( hass: HomeAssistant, entry: HomeConnectConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: """Set up the Home Connect light.""" - - async_add_entities( - [ - HomeConnectLight(entry.runtime_data, appliance, description) - for description in LIGHTS - for appliance in entry.runtime_data.data.values() - if description.key in appliance.settings - ], + setup_home_connect_entry( + entry, + _get_entities_for_appliance, + async_add_entities, ) diff --git a/homeassistant/components/home_connect/manifest.json b/homeassistant/components/home_connect/manifest.json index 1d9f3f363aa..41d359446fa 100644 --- a/homeassistant/components/home_connect/manifest.json +++ b/homeassistant/components/home_connect/manifest.json @@ -6,6 +6,6 @@ "dependencies": ["application_credentials"], "documentation": "https://www.home-assistant.io/integrations/home_connect", "iot_class": "cloud_push", - "loggers": ["homeconnect"], - "requirements": ["aiohomeconnect==0.12.1"] + "loggers": ["aiohomeconnect"], + "requirements": ["aiohomeconnect==0.12.3"] } diff --git a/homeassistant/components/home_connect/number.py b/homeassistant/components/home_connect/number.py index 7c6101950bf..aa0c4e4ae3f 100644 --- a/homeassistant/components/home_connect/number.py +++ b/homeassistant/components/home_connect/number.py @@ -15,6 +15,7 @@ from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.entity_platform import AddEntitiesCallback +from .common import setup_home_connect_entry from .const import ( DOMAIN, SVE_TRANSLATION_KEY_SET_SETTING, @@ -22,7 +23,7 @@ from .const import ( SVE_TRANSLATION_PLACEHOLDER_KEY, SVE_TRANSLATION_PLACEHOLDER_VALUE, ) -from .coordinator import HomeConnectConfigEntry +from .coordinator import HomeConnectApplianceData, HomeConnectConfigEntry from .entity import HomeConnectEntity from .utils import get_dict_from_home_connect_error @@ -78,19 +79,28 @@ NUMBERS = ( ) +def _get_entities_for_appliance( + entry: HomeConnectConfigEntry, + appliance: HomeConnectApplianceData, +) -> list[HomeConnectEntity]: + """Get a list of entities.""" + return [ + HomeConnectNumberEntity(entry.runtime_data, appliance, description) + for description in NUMBERS + if description.key in appliance.settings + ] + + async def async_setup_entry( hass: HomeAssistant, entry: HomeConnectConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: """Set up the Home Connect number.""" - async_add_entities( - [ - HomeConnectNumberEntity(entry.runtime_data, appliance, description) - for description in NUMBERS - for appliance in entry.runtime_data.data.values() - if description.key in appliance.settings - ], + setup_home_connect_entry( + entry, + _get_entities_for_appliance, + async_add_entities, ) diff --git a/homeassistant/components/home_connect/select.py b/homeassistant/components/home_connect/select.py index 2df5718fa53..13518c5dea2 100644 --- a/homeassistant/components/home_connect/select.py +++ b/homeassistant/components/home_connect/select.py @@ -14,6 +14,7 @@ from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.entity_platform import AddEntitiesCallback +from .common import setup_home_connect_entry from .const import APPLIANCES_WITH_PROGRAMS, DOMAIN, SVE_TRANSLATION_PLACEHOLDER_PROGRAM from .coordinator import ( HomeConnectApplianceData, @@ -69,18 +70,31 @@ PROGRAM_SELECT_ENTITY_DESCRIPTIONS = ( ) +def _get_entities_for_appliance( + entry: HomeConnectConfigEntry, + appliance: HomeConnectApplianceData, +) -> list[HomeConnectEntity]: + """Get a list of entities.""" + return ( + [ + HomeConnectProgramSelectEntity(entry.runtime_data, appliance, desc) + for desc in PROGRAM_SELECT_ENTITY_DESCRIPTIONS + ] + if appliance.info.type in APPLIANCES_WITH_PROGRAMS + else [] + ) + + async def async_setup_entry( hass: HomeAssistant, entry: HomeConnectConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: """Set up the Home Connect select entities.""" - - async_add_entities( - HomeConnectProgramSelectEntity(entry.runtime_data, appliance, desc) - for appliance in entry.runtime_data.data.values() - for desc in PROGRAM_SELECT_ENTITY_DESCRIPTIONS - if appliance.info.type in APPLIANCES_WITH_PROGRAMS + setup_home_connect_entry( + entry, + _get_entities_for_appliance, + async_add_entities, ) @@ -110,7 +124,6 @@ class HomeConnectProgramSelectEntity(HomeConnectEntity, SelectEntity): or program.constraints.execution in desc.allowed_executions ) ] - self._attr_current_option = None def update_native_value(self) -> None: """Set the program value.""" diff --git a/homeassistant/components/home_connect/sensor.py b/homeassistant/components/home_connect/sensor.py index 5e7c417a172..545df1d68b6 100644 --- a/homeassistant/components/home_connect/sensor.py +++ b/homeassistant/components/home_connect/sensor.py @@ -17,13 +17,14 @@ from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.util import dt as dt_util, slugify +from .common import setup_home_connect_entry from .const import ( APPLIANCES_WITH_PROGRAMS, BSH_OPERATION_STATE_FINISHED, BSH_OPERATION_STATE_PAUSE, BSH_OPERATION_STATE_RUN, ) -from .coordinator import HomeConnectConfigEntry +from .coordinator import HomeConnectApplianceData, HomeConnectConfigEntry from .entity import HomeConnectEntity EVENT_OPTIONS = ["confirmed", "off", "present"] @@ -243,37 +244,42 @@ EVENT_SENSORS = ( ) +def _get_entities_for_appliance( + entry: HomeConnectConfigEntry, + appliance: HomeConnectApplianceData, +) -> list[HomeConnectEntity]: + """Get a list of entities.""" + return [ + *[ + HomeConnectEventSensor(entry.runtime_data, appliance, description) + for description in EVENT_SENSORS + if description.appliance_types + and appliance.info.type in description.appliance_types + ], + *[ + HomeConnectProgramSensor(entry.runtime_data, appliance, desc) + for desc in BSH_PROGRAM_SENSORS + if desc.appliance_types and appliance.info.type in desc.appliance_types + ], + *[ + HomeConnectSensor(entry.runtime_data, appliance, description) + for description in SENSORS + if description.key in appliance.status + ], + ] + + async def async_setup_entry( hass: HomeAssistant, entry: HomeConnectConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: """Set up the Home Connect sensor.""" - - entities: list[SensorEntity] = [] - for appliance in entry.runtime_data.data.values(): - entities.extend( - HomeConnectEventSensor( - entry.runtime_data, - appliance, - description, - ) - for description in EVENT_SENSORS - if description.appliance_types - and appliance.info.type in description.appliance_types - ) - entities.extend( - HomeConnectProgramSensor(entry.runtime_data, appliance, desc) - for desc in BSH_PROGRAM_SENSORS - if desc.appliance_types and appliance.info.type in desc.appliance_types - ) - entities.extend( - HomeConnectSensor(entry.runtime_data, appliance, description) - for description in SENSORS - if description.key in appliance.status - ) - - async_add_entities(entities) + setup_home_connect_entry( + entry, + _get_entities_for_appliance, + async_add_entities, + ) class HomeConnectSensor(HomeConnectEntity, SensorEntity): diff --git a/homeassistant/components/home_connect/switch.py b/homeassistant/components/home_connect/switch.py index 521252ccc2f..e7fcd29e191 100644 --- a/homeassistant/components/home_connect/switch.py +++ b/homeassistant/components/home_connect/switch.py @@ -21,6 +21,7 @@ from homeassistant.helpers.issue_registry import ( ) from homeassistant.helpers.typing import UNDEFINED, UndefinedType +from .common import setup_home_connect_entry from .const import ( BSH_POWER_OFF, BSH_POWER_ON, @@ -100,33 +101,43 @@ POWER_SWITCH_DESCRIPTION = SwitchEntityDescription( ) +def _get_entities_for_appliance( + entry: HomeConnectConfigEntry, + appliance: HomeConnectApplianceData, +) -> list[HomeConnectEntity]: + """Get a list of entities.""" + entities: list[HomeConnectEntity] = [] + entities.extend( + HomeConnectProgramSwitch(entry.runtime_data, appliance, program) + for program in appliance.programs + if program.key != ProgramKey.UNKNOWN + ) + if SettingKey.BSH_COMMON_POWER_STATE in appliance.settings: + entities.append( + HomeConnectPowerSwitch( + entry.runtime_data, appliance, POWER_SWITCH_DESCRIPTION + ) + ) + entities.extend( + HomeConnectSwitch(entry.runtime_data, appliance, description) + for description in SWITCHES + if description.key in appliance.settings + ) + + return entities + + async def async_setup_entry( hass: HomeAssistant, entry: HomeConnectConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: """Set up the Home Connect switch.""" - - entities: list[SwitchEntity] = [] - for appliance in entry.runtime_data.data.values(): - entities.extend( - HomeConnectProgramSwitch(entry.runtime_data, appliance, program) - for program in appliance.programs - if program.key != ProgramKey.UNKNOWN - ) - if SettingKey.BSH_COMMON_POWER_STATE in appliance.settings: - entities.append( - HomeConnectPowerSwitch( - entry.runtime_data, appliance, POWER_SWITCH_DESCRIPTION - ) - ) - entities.extend( - HomeConnectSwitch(entry.runtime_data, appliance, description) - for description in SWITCHES - if description.key in appliance.settings - ) - - async_add_entities(entities) + setup_home_connect_entry( + entry, + _get_entities_for_appliance, + async_add_entities, + ) class HomeConnectSwitch(HomeConnectEntity, SwitchEntity): @@ -192,6 +203,7 @@ class HomeConnectProgramSwitch(HomeConnectEntity, SwitchEntity): desc = " ".join( ["Program", program.key.split(".")[-3], program.key.split(".")[-1]] ) + self.program = program super().__init__( coordinator, appliance, @@ -200,7 +212,6 @@ class HomeConnectProgramSwitch(HomeConnectEntity, SwitchEntity): self._attr_name = f"{appliance.info.name} {desc}" self._attr_unique_id = f"{appliance.info.ha_id}-{desc}" self._attr_has_entity_name = False - self.program = program async def async_added_to_hass(self) -> None: """Call when entity is added to hass.""" diff --git a/homeassistant/components/home_connect/time.py b/homeassistant/components/home_connect/time.py index 5ed07424082..48f651857d2 100644 --- a/homeassistant/components/home_connect/time.py +++ b/homeassistant/components/home_connect/time.py @@ -11,6 +11,7 @@ from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.entity_platform import AddEntitiesCallback +from .common import setup_home_connect_entry from .const import ( DOMAIN, SVE_TRANSLATION_KEY_SET_SETTING, @@ -18,7 +19,7 @@ from .const import ( SVE_TRANSLATION_PLACEHOLDER_KEY, SVE_TRANSLATION_PLACEHOLDER_VALUE, ) -from .coordinator import HomeConnectConfigEntry +from .coordinator import HomeConnectApplianceData, HomeConnectConfigEntry from .entity import HomeConnectEntity from .utils import get_dict_from_home_connect_error @@ -30,20 +31,28 @@ TIME_ENTITIES = ( ) +def _get_entities_for_appliance( + entry: HomeConnectConfigEntry, + appliance: HomeConnectApplianceData, +) -> list[HomeConnectEntity]: + """Get a list of entities.""" + return [ + HomeConnectTimeEntity(entry.runtime_data, appliance, description) + for description in TIME_ENTITIES + if description.key in appliance.settings + ] + + async def async_setup_entry( hass: HomeAssistant, entry: HomeConnectConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: """Set up the Home Connect switch.""" - - async_add_entities( - [ - HomeConnectTimeEntity(entry.runtime_data, appliance, description) - for description in TIME_ENTITIES - for appliance in entry.runtime_data.data.values() - if description.key in appliance.settings - ], + setup_home_connect_entry( + entry, + _get_entities_for_appliance, + async_add_entities, ) diff --git a/homeassistant/components/homee/const.py b/homeassistant/components/homee/const.py index 298b5237a35..54d7773890f 100644 --- a/homeassistant/components/homee/const.py +++ b/homeassistant/components/homee/const.py @@ -3,6 +3,7 @@ from pyHomee.const import NodeProfile from homeassistant.const import ( + DEGREE, LIGHT_LUX, PERCENTAGE, REVOLUTIONS_PER_MINUTE, @@ -34,6 +35,7 @@ HOMEE_UNIT_TO_HA_UNIT = { "W": UnitOfPower.WATT, "m/s": UnitOfSpeed.METERS_PER_SECOND, "km/h": UnitOfSpeed.KILOMETERS_PER_HOUR, + "°": DEGREE, "°F": UnitOfTemperature.FAHRENHEIT, "°C": UnitOfTemperature.CELSIUS, "K": UnitOfTemperature.KELVIN, @@ -53,7 +55,7 @@ OPEN_CLOSE_MAP_REVERSED = { 0.0: "closed", 1.0: "open", 2.0: "partial", - 3.0: "cosing", + 3.0: "closing", 4.0: "opening", } WINDOW_MAP = { diff --git a/homeassistant/components/homee/cover.py b/homeassistant/components/homee/cover.py index b4a853f7c35..2e6f7babaff 100644 --- a/homeassistant/components/homee/cover.py +++ b/homeassistant/components/homee/cover.py @@ -68,6 +68,7 @@ def get_device_class(node: HomeeNode) -> CoverDeviceClass | None: """Determine the device class a homee node based on the node profile.""" COVER_DEVICE_PROFILES = { NodeProfile.GARAGE_DOOR_OPERATOR: CoverDeviceClass.GARAGE, + NodeProfile.ENTRANCE_GATE_OPERATOR: CoverDeviceClass.GATE, NodeProfile.SHUTTER_POSITION_SWITCH: CoverDeviceClass.SHUTTER, } @@ -93,6 +94,7 @@ def is_cover_node(node: HomeeNode) -> bool: return node.profile in [ NodeProfile.ELECTRIC_MOTOR_METERING_SWITCH, NodeProfile.ELECTRIC_MOTOR_METERING_SWITCH_WITHOUT_SLAT_POSITION, + NodeProfile.ENTRANCE_GATE_OPERATOR, NodeProfile.GARAGE_DOOR_OPERATOR, NodeProfile.SHUTTER_POSITION_SWITCH, ] diff --git a/homeassistant/components/homee/entity.py b/homeassistant/components/homee/entity.py index 5af5a059463..2d1dbca2cc2 100644 --- a/homeassistant/components/homee/entity.py +++ b/homeassistant/components/homee/entity.py @@ -2,7 +2,9 @@ from pyHomee.const import AttributeState, AttributeType, NodeProfile, NodeState from pyHomee.model import HomeeAttribute, HomeeNode +from websockets.exceptions import ConnectionClosed +from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity import Entity @@ -135,7 +137,13 @@ class HomeeNodeEntity(Entity): async def async_set_value(self, attribute: HomeeAttribute, value: float) -> None: """Set an attribute value on the homee node.""" homee = self._entry.runtime_data - await homee.set_value(attribute.node_id, attribute.id, value) + try: + await homee.set_value(attribute.node_id, attribute.id, value) + except ConnectionClosed as exception: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="connection_closed", + ) from exception def _on_node_updated(self, node: HomeeNode) -> None: self.schedule_update_ha_state() diff --git a/homeassistant/components/homee/sensor.py b/homeassistant/components/homee/sensor.py index 9b8fb0f6fe1..da01c2aa5b9 100644 --- a/homeassistant/components/homee/sensor.py +++ b/homeassistant/components/homee/sensor.py @@ -177,6 +177,7 @@ SENSOR_DESCRIPTIONS: dict[AttributeType, HomeeSensorEntityDescription] = { AttributeType.TOTAL_CURRENT: HomeeSensorEntityDescription( key="total_current", device_class=SensorDeviceClass.CURRENT, + state_class=SensorStateClass.MEASUREMENT, ), AttributeType.TOTAL_CURRENT_ENERGY_USE: HomeeSensorEntityDescription( key="total_power", @@ -252,7 +253,7 @@ NODE_SENSOR_DESCRIPTIONS: tuple[HomeeNodeSensorEntityDescription, ...] = ( ], entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, - translation_key="node_sensor_state", + translation_key="node_state", value_fn=lambda node: get_name_for_enum(NodeState, node.state), ), ) diff --git a/homeassistant/components/homee/strings.json b/homeassistant/components/homee/strings.json index 3423524d672..5f45c2e3228 100644 --- a/homeassistant/components/homee/strings.json +++ b/homeassistant/components/homee/strings.json @@ -67,7 +67,23 @@ "name": "Link quality" }, "node_state": { - "name": "Node state" + "name": "Node state", + "state": { + "available": "Available", + "unavailable": "Unavailable", + "update_in_progress": "Update in progress", + "waiting_for_attributes": "Waiting for attributes", + "initializing": "Initializing", + "user_interaction_required": "User interaction required", + "password_required": "Password required", + "host_unavailable": "Host unavailable", + "delete_in_progress": "Delete in progress", + "cosi_connected": "Cosi connected", + "blocked": "Blocked", + "waiting_for_wakeup": "Waiting for wakeup", + "remote_node_deleted": "Remote node deleted", + "firmware_update_in_progress": "Firmware update in progress" + } }, "operating_hours": { "name": "Operating hours" @@ -192,5 +208,10 @@ "name": "Watchdog" } } + }, + "exceptions": { + "connection_closed": { + "message": "Could not connect to Homee while setting attribute" + } } } diff --git a/homeassistant/components/homekit/util.py b/homeassistant/components/homekit/util.py index c36738b286d..1181ceaa953 100644 --- a/homeassistant/components/homekit/util.py +++ b/homeassistant/components/homekit/util.py @@ -78,6 +78,7 @@ from .const import ( CONF_VIDEO_CODEC, CONF_VIDEO_MAP, CONF_VIDEO_PACKET_SIZE, + CONF_VIDEO_PROFILE_NAMES, DEFAULT_AUDIO_CODEC, DEFAULT_AUDIO_MAP, DEFAULT_AUDIO_PACKET_SIZE, @@ -90,6 +91,7 @@ from .const import ( DEFAULT_VIDEO_CODEC, DEFAULT_VIDEO_MAP, DEFAULT_VIDEO_PACKET_SIZE, + DEFAULT_VIDEO_PROFILE_NAMES, DOMAIN, FEATURE_ON_OFF, FEATURE_PLAY_PAUSE, @@ -163,6 +165,9 @@ CAMERA_SCHEMA = BASIC_INFO_SCHEMA.extend( vol.Optional(CONF_VIDEO_CODEC, default=DEFAULT_VIDEO_CODEC): vol.In( VALID_VIDEO_CODECS ), + vol.Optional(CONF_VIDEO_PROFILE_NAMES, default=DEFAULT_VIDEO_PROFILE_NAMES): [ + cv.string + ], vol.Optional( CONF_AUDIO_PACKET_SIZE, default=DEFAULT_AUDIO_PACKET_SIZE ): cv.positive_int, diff --git a/homeassistant/components/homewizard/__init__.py b/homeassistant/components/homewizard/__init__.py index 1f29be8e6b6..36c9681dcd2 100644 --- a/homeassistant/components/homewizard/__init__.py +++ b/homeassistant/components/homewizard/__init__.py @@ -25,7 +25,9 @@ async def async_setup_entry(hass: HomeAssistant, entry: HomeWizardConfigEntry) - api: HomeWizardEnergy - if token := entry.data.get(CONF_TOKEN): + is_battery = entry.unique_id.startswith("HWE-BAT") if entry.unique_id else False + + if (token := entry.data.get(CONF_TOKEN)) and is_battery: api = HomeWizardEnergyV2( entry.data[CONF_IP_ADDRESS], token=token, @@ -37,7 +39,8 @@ async def async_setup_entry(hass: HomeAssistant, entry: HomeWizardConfigEntry) - clientsession=async_get_clientsession(hass), ) - await async_check_v2_support_and_create_issue(hass, entry) + if is_battery: + await async_check_v2_support_and_create_issue(hass, entry) coordinator = HWEnergyDeviceUpdateCoordinator(hass, api) try: diff --git a/homeassistant/components/homewizard/config_flow.py b/homeassistant/components/homewizard/config_flow.py index c94f590f000..6bcc51f939e 100644 --- a/homeassistant/components/homewizard/config_flow.py +++ b/homeassistant/components/homewizard/config_flow.py @@ -272,9 +272,14 @@ class HomeWizardConfigFlow(ConfigFlow, domain=DOMAIN): ) -> ConfigFlowResult: """Handle reconfiguration of the integration.""" errors: dict[str, str] = {} + reconfigure_entry = self._get_reconfigure_entry() + if user_input: try: - device_info = await async_try_connect(user_input[CONF_IP_ADDRESS]) + device_info = await async_try_connect( + user_input[CONF_IP_ADDRESS], + token=reconfigure_entry.data.get(CONF_TOKEN), + ) except RecoverableError as ex: LOGGER.error(ex) @@ -288,7 +293,6 @@ class HomeWizardConfigFlow(ConfigFlow, domain=DOMAIN): self._get_reconfigure_entry(), data_updates=user_input, ) - reconfigure_entry = self._get_reconfigure_entry() return self.async_show_form( step_id="reconfigure", data_schema=vol.Schema( @@ -306,7 +310,7 @@ class HomeWizardConfigFlow(ConfigFlow, domain=DOMAIN): ) -async def async_try_connect(ip_address: str) -> Device: +async def async_try_connect(ip_address: str, token: str | None = None) -> Device: """Try to connect. Make connection with device to test the connection @@ -317,7 +321,7 @@ async def async_try_connect(ip_address: str) -> Device: # Determine if device is v1 or v2 capable if await has_v2_api(ip_address): - energy_api = HomeWizardEnergyV2(ip_address) + energy_api = HomeWizardEnergyV2(ip_address, token=token) else: energy_api = HomeWizardEnergyV1(ip_address) diff --git a/homeassistant/components/hue/v1/light.py b/homeassistant/components/hue/v1/light.py index e9669d226f0..33b99a7895b 100644 --- a/homeassistant/components/hue/v1/light.py +++ b/homeassistant/components/hue/v1/light.py @@ -408,7 +408,7 @@ class HueLight(CoordinatorEntity, LightEntity): if self._fixed_color_mode: return self._fixed_color_mode - # The light supports both hs/xy and white with adjustabe color_temperature + # The light supports both hs/xy and white with adjustable color_temperature mode = self._color_mode if mode in ("xy", "hs"): return ColorMode.HS diff --git a/homeassistant/components/idasen_desk/config_flow.py b/homeassistant/components/idasen_desk/config_flow.py index 782d4988a3c..aa832fdfe48 100644 --- a/homeassistant/components/idasen_desk/config_flow.py +++ b/homeassistant/components/idasen_desk/config_flow.py @@ -87,7 +87,7 @@ class IdasenDeskConfigFlow(ConfigFlow, domain=DOMAIN): if discovery := self._discovery_info: self._discovered_devices[discovery.address] = discovery else: - current_addresses = self._async_current_ids() + current_addresses = self._async_current_ids(include_ignore=False) for discovery in async_discovered_service_info(self.hass): if ( discovery.address in current_addresses diff --git a/homeassistant/components/inkbird/config_flow.py b/homeassistant/components/inkbird/config_flow.py index 0d4e404c9b5..09dd31a9cf6 100644 --- a/homeassistant/components/inkbird/config_flow.py +++ b/homeassistant/components/inkbird/config_flow.py @@ -72,7 +72,7 @@ class INKBIRDConfigFlow(ConfigFlow, domain=DOMAIN): title=self._discovered_devices[address], data={} ) - current_addresses = self._async_current_ids() + current_addresses = self._async_current_ids(include_ignore=False) for discovery_info in async_discovered_service_info(self.hass, False): address = discovery_info.address if address in current_addresses or address in self._discovered_devices: diff --git a/homeassistant/components/iometer/__init__.py b/homeassistant/components/iometer/__init__.py new file mode 100644 index 00000000000..5106d449fed --- /dev/null +++ b/homeassistant/components/iometer/__init__.py @@ -0,0 +1,39 @@ +"""The IOmeter integration.""" + +from __future__ import annotations + +from iometer import IOmeterClient, IOmeterConnectionError + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_HOST, Platform +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryNotReady +from homeassistant.helpers.aiohttp_client import async_get_clientsession + +from .coordinator import IOmeterConfigEntry, IOMeterCoordinator + +PLATFORMS: list[Platform] = [Platform.SENSOR] + + +async def async_setup_entry(hass: HomeAssistant, entry: IOmeterConfigEntry) -> bool: + """Set up IOmeter from a config entry.""" + + host = entry.data[CONF_HOST] + session = async_get_clientsession(hass) + client = IOmeterClient(host=host, session=session) + try: + await client.get_current_status() + except IOmeterConnectionError as err: + raise ConfigEntryNotReady from err + + coordinator = IOMeterCoordinator(hass, client) + await coordinator.async_config_entry_first_refresh() + entry.runtime_data = coordinator + 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/iometer/config_flow.py b/homeassistant/components/iometer/config_flow.py new file mode 100644 index 00000000000..ee03d09abf7 --- /dev/null +++ b/homeassistant/components/iometer/config_flow.py @@ -0,0 +1,91 @@ +"""Config flow for the IOmeter integration.""" + +from typing import Any, Final + +from iometer import IOmeterClient, IOmeterConnectionError +import voluptuous as vol + +from homeassistant.config_entries import ConfigFlow, ConfigFlowResult +from homeassistant.const import CONF_HOST +from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo + +from .const import DOMAIN + +CONFIG_SCHEMA: Final = vol.Schema({vol.Required(CONF_HOST): str}) + + +class IOMeterConfigFlow(ConfigFlow, domain=DOMAIN): + """Handles the config flow for a IOmeter bridge and core.""" + + def __init__(self) -> None: + """Initialize the config flow.""" + self._host: str + self._meter_number: str + + async def async_step_zeroconf( + self, discovery_info: ZeroconfServiceInfo + ) -> ConfigFlowResult: + """Handle zeroconf discovery.""" + self._host = host = discovery_info.host + self._async_abort_entries_match({CONF_HOST: host}) + + session = async_get_clientsession(self.hass) + client = IOmeterClient(host=host, session=session) + try: + status = await client.get_current_status() + except IOmeterConnectionError: + return self.async_abort(reason="cannot_connect") + + self._meter_number = status.meter.number + + await self.async_set_unique_id(status.device.id) + self._abort_if_unique_id_configured() + + self.context["title_placeholders"] = {"name": f"IOmeter {self._meter_number}"} + return await self.async_step_zeroconf_confirm() + + async def async_step_zeroconf_confirm( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Confirm discovery.""" + if user_input is not None: + return await self._async_create_entry() + + self._set_confirm_only() + return self.async_show_form( + step_id="zeroconf_confirm", + description_placeholders={"meter_number": self._meter_number}, + ) + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle the initial configuration.""" + errors: dict[str, str] = {} + + if user_input is not None: + self._host = user_input[CONF_HOST] + session = async_get_clientsession(self.hass) + client = IOmeterClient(host=self._host, session=session) + try: + status = await client.get_current_status() + except IOmeterConnectionError: + errors["base"] = "cannot_connect" + else: + self._meter_number = status.meter.number + await self.async_set_unique_id(status.device.id) + self._abort_if_unique_id_configured() + return await self._async_create_entry() + return self.async_show_form( + step_id="user", + data_schema=CONFIG_SCHEMA, + errors=errors, + ) + + async def _async_create_entry(self) -> ConfigFlowResult: + """Create entry.""" + return self.async_create_entry( + title=f"IOmeter {self._meter_number}", + data={CONF_HOST: self._host}, + ) diff --git a/homeassistant/components/iometer/const.py b/homeassistant/components/iometer/const.py new file mode 100644 index 00000000000..797aefcd7f0 --- /dev/null +++ b/homeassistant/components/iometer/const.py @@ -0,0 +1,5 @@ +"""Constants for the IOmeter integration.""" + +from typing import Final + +DOMAIN: Final = "iometer" diff --git a/homeassistant/components/iometer/coordinator.py b/homeassistant/components/iometer/coordinator.py new file mode 100644 index 00000000000..3321b032e4b --- /dev/null +++ b/homeassistant/components/iometer/coordinator.py @@ -0,0 +1,55 @@ +"""DataUpdateCoordinator for IOmeter.""" + +from dataclasses import dataclass +from datetime import timedelta +import logging + +from iometer import IOmeterClient, IOmeterConnectionError, Reading, Status + +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed + +from .const import DOMAIN + +_LOGGER = logging.getLogger(__name__) +DEFAULT_SCAN_INTERVAL = timedelta(seconds=10) + +type IOmeterConfigEntry = ConfigEntry[IOMeterCoordinator] + + +@dataclass +class IOmeterData: + """Class for data update.""" + + reading: Reading + status: Status + + +class IOMeterCoordinator(DataUpdateCoordinator[IOmeterData]): + """Class to manage fetching IOmeter data.""" + + config_entry: IOmeterConfigEntry + client: IOmeterClient + + def __init__(self, hass: HomeAssistant, client: IOmeterClient) -> None: + """Initialize coordinator.""" + + super().__init__( + hass, + _LOGGER, + name=DOMAIN, + update_interval=DEFAULT_SCAN_INTERVAL, + ) + self.client = client + self.identifier = self.config_entry.entry_id + + async def _async_update_data(self) -> IOmeterData: + """Update data async.""" + try: + reading = await self.client.get_current_reading() + status = await self.client.get_current_status() + except IOmeterConnectionError as error: + raise UpdateFailed(f"Error communicating with IOmeter: {error}") from error + + return IOmeterData(reading=reading, status=status) diff --git a/homeassistant/components/iometer/entity.py b/homeassistant/components/iometer/entity.py new file mode 100644 index 00000000000..86494857e18 --- /dev/null +++ b/homeassistant/components/iometer/entity.py @@ -0,0 +1,24 @@ +"""Base class for IOmeter entities.""" + +from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from .const import DOMAIN +from .coordinator import IOMeterCoordinator + + +class IOmeterEntity(CoordinatorEntity[IOMeterCoordinator]): + """Defines a base IOmeter entity.""" + + _attr_has_entity_name = True + + def __init__(self, coordinator: IOMeterCoordinator) -> None: + """Initialize IOmeter entity.""" + super().__init__(coordinator) + status = coordinator.data.status + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, status.device.id)}, + manufacturer="IOmeter GmbH", + model="IOmeter", + sw_version=f"{status.device.core.version}/{status.device.bridge.version}", + ) diff --git a/homeassistant/components/iometer/icons.json b/homeassistant/components/iometer/icons.json new file mode 100644 index 00000000000..8c71684f859 --- /dev/null +++ b/homeassistant/components/iometer/icons.json @@ -0,0 +1,38 @@ +{ + "entity": { + "sensor": { + "attachment_status": { + "default": "mdi:eye", + "state": { + "attached": "mdi:check-bold", + "detached": "mdi:close", + "unknown": "mdi:help" + } + }, + "connection_status": { + "default": "mdi:eye", + "state": { + "connected": "mdi:check-bold", + "disconnected": "mdi:close", + "unknown": "mdi:help" + } + }, + "pin_status": { + "default": "mdi:eye", + "state": { + "entered": "mdi:lock-open", + "pending": "mdi:lock-clock", + "missing": "mdi:lock", + "unknown": "mdi:help" + } + }, + "power_status": { + "default": "mdi:eye", + "state": { + "battery": "mdi:battery", + "wired": "mdi:power-plug" + } + } + } + } +} diff --git a/homeassistant/components/iometer/manifest.json b/homeassistant/components/iometer/manifest.json new file mode 100644 index 00000000000..061a2318e04 --- /dev/null +++ b/homeassistant/components/iometer/manifest.json @@ -0,0 +1,12 @@ +{ + "domain": "iometer", + "name": "IOmeter", + "codeowners": ["@MaestroOnICe"], + "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"], + "zeroconf": ["_iometer._tcp.local."] +} diff --git a/homeassistant/components/iometer/quality_scale.yaml b/homeassistant/components/iometer/quality_scale.yaml new file mode 100644 index 00000000000..71496d8043c --- /dev/null +++ b/homeassistant/components/iometer/quality_scale.yaml @@ -0,0 +1,74 @@ +rules: + # Bronze + action-setup: + status: exempt + comment: This integration does not provide additional actions. + appropriate-polling: done + brands: done + common-modules: done + config-flow-test-coverage: done + config-flow: done + dependency-transparency: done + docs-actions: + status: exempt + comment: This integration does not provide additional actions. + docs-high-level-description: done + docs-installation-instructions: done + docs-removal-instructions: done + entity-event-setup: + status: exempt + comment: This integration does not register any events. + entity-unique-id: done + has-entity-name: done + runtime-data: done + test-before-configure: done + test-before-setup: done + unique-config-entry: done + + # Silver + action-exceptions: + status: exempt + comment: This integration does not provide additional actions. + config-entry-unloading: done + docs-configuration-parameters: + status: exempt + comment: This integration has not option flow. + docs-installation-parameters: done + entity-unavailable: done + integration-owner: done + log-when-unavailable: done + parallel-updates: + status: exempt + comment: This integration polls data using a coordinator, there is no need for parallel updates. + reauthentication-flow: + status: exempt + comment: This integration requires no authentication. + test-coverage: todo + + # Gold + devices: todo + diagnostics: todo + discovery-update-info: todo + discovery: done + docs-data-update: todo + docs-examples: todo + docs-known-limitations: todo + docs-supported-devices: todo + docs-supported-functions: todo + docs-troubleshooting: todo + docs-use-cases: todo + dynamic-devices: todo + entity-category: done + entity-device-class: done + entity-disabled-by-default: done + entity-translations: done + exception-translations: todo + icon-translations: todo + reconfiguration-flow: todo + repair-issues: todo + stale-devices: todo + + # Platinum + async-dependency: done + inject-websession: done + strict-typing: todo diff --git a/homeassistant/components/iometer/sensor.py b/homeassistant/components/iometer/sensor.py new file mode 100644 index 00000000000..7d4c1155e8b --- /dev/null +++ b/homeassistant/components/iometer/sensor.py @@ -0,0 +1,146 @@ +"""IOmeter sensors.""" + +from collections.abc import Callable +from dataclasses import dataclass + +from homeassistant.components.sensor import ( + SensorDeviceClass, + SensorEntity, + SensorEntityDescription, + SensorStateClass, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import ( + PERCENTAGE, + SIGNAL_STRENGTH_DECIBELS, + STATE_UNKNOWN, + EntityCategory, + UnitOfEnergy, + UnitOfPower, +) +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.typing import StateType + +from .coordinator import IOMeterCoordinator, IOmeterData +from .entity import IOmeterEntity + + +@dataclass(frozen=True, kw_only=True) +class IOmeterEntityDescription(SensorEntityDescription): + """Describes IOmeter sensor entity.""" + + value_fn: Callable[[IOmeterData], str | int | float] + + +SENSOR_TYPES: list[IOmeterEntityDescription] = [ + IOmeterEntityDescription( + key="meter_number", + translation_key="meter_number", + icon="mdi:meter-electric", + value_fn=lambda data: data.status.meter.number, + ), + IOmeterEntityDescription( + key="wifi_rssi", + translation_key="wifi_rssi", + native_unit_of_measurement=SIGNAL_STRENGTH_DECIBELS, + device_class=SensorDeviceClass.SIGNAL_STRENGTH, + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + value_fn=lambda data: data.status.device.bridge.rssi, + ), + IOmeterEntityDescription( + key="core_bridge_rssi", + translation_key="core_bridge_rssi", + native_unit_of_measurement=SIGNAL_STRENGTH_DECIBELS, + device_class=SensorDeviceClass.SIGNAL_STRENGTH, + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + value_fn=lambda data: data.status.device.core.rssi, + ), + IOmeterEntityDescription( + key="power_status", + translation_key="power_status", + device_class=SensorDeviceClass.ENUM, + options=["battery", "wired", "unknown"], + value_fn=lambda data: data.status.device.core.power_status or STATE_UNKNOWN, + ), + IOmeterEntityDescription( + key="battery_level", + translation_key="battery_level", + native_unit_of_measurement=PERCENTAGE, + device_class=SensorDeviceClass.BATTERY, + state_class=SensorStateClass.MEASUREMENT, + value_fn=lambda data: data.status.device.core.battery_level, + ), + IOmeterEntityDescription( + key="pin_status", + translation_key="pin_status", + device_class=SensorDeviceClass.ENUM, + options=["entered", "pending", "missing", "unknown"], + value_fn=lambda data: data.status.device.core.pin_status or STATE_UNKNOWN, + ), + IOmeterEntityDescription( + key="total_consumption", + translation_key="total_consumption", + native_unit_of_measurement=UnitOfEnergy.WATT_HOUR, + device_class=SensorDeviceClass.ENERGY, + state_class=SensorStateClass.TOTAL, + value_fn=lambda data: data.reading.get_total_consumption(), + ), + IOmeterEntityDescription( + key="total_production", + translation_key="total_production", + native_unit_of_measurement=UnitOfEnergy.WATT_HOUR, + device_class=SensorDeviceClass.ENERGY, + state_class=SensorStateClass.TOTAL, + value_fn=lambda data: data.reading.get_total_production(), + ), + IOmeterEntityDescription( + key="power", + native_unit_of_measurement=UnitOfPower.WATT, + device_class=SensorDeviceClass.POWER, + state_class=SensorStateClass.MEASUREMENT, + value_fn=lambda data: data.reading.get_current_power(), + ), +] + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up the Sensors.""" + coordinator: IOMeterCoordinator = config_entry.runtime_data + + async_add_entities( + IOmeterSensor( + coordinator=coordinator, + description=description, + ) + for description in SENSOR_TYPES + ) + + +class IOmeterSensor(IOmeterEntity, SensorEntity): + """Defines a IOmeter sensor.""" + + entity_description: IOmeterEntityDescription + + def __init__( + self, + coordinator: IOMeterCoordinator, + description: IOmeterEntityDescription, + ) -> None: + """Initialize the sensor.""" + super().__init__(coordinator) + self.entity_description = description + self._attr_unique_id = f"{coordinator.identifier}_{description.key}" + + @property + def native_value(self) -> StateType: + """Return the sensor value.""" + return self.entity_description.value_fn(self.coordinator.data) diff --git a/homeassistant/components/iometer/strings.json b/homeassistant/components/iometer/strings.json new file mode 100644 index 00000000000..31deb16aa9c --- /dev/null +++ b/homeassistant/components/iometer/strings.json @@ -0,0 +1,65 @@ +{ + "config": { + "step": { + "user": { + "description": "Setup your IOmeter device for local data", + "data": { + "host": "[%key:common::config_flow::data::host%]" + }, + "data_description": { + "host": "The hostname or IP address of the IOmeter device to connect to." + } + }, + "zeroconf_confirm": { + "title": "Discovered IOmeter", + "description": "Do you want to set up IOmeter on the meter with meter number: {meter_number}?" + } + }, + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", + "already_in_progress": "[%key:common::config_flow::abort::already_in_progress%]" + }, + "error": { + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "unknown": "Unexpected error" + } + }, + "entity": { + "sensor": { + "battery_level": { + "name": "Battery level" + }, + "meter_number": { + "name": "Meter number" + }, + "pin_status": { + "name": "PIN status", + "state": { + "entered": "Entered", + "pending": "Pending", + "missing": "Missing", + "unknown": "Unknown" + } + }, + "power_status": { + "name": "Power supply", + "state": { + "battery": "Battery", + "wired": "Wired" + } + }, + "total_consumption": { + "name": "Total consumption" + }, + "total_production": { + "name": "Total production" + }, + "core_bridge_rssi": { + "name": "Signal strength Core/Bridge" + }, + "wifi_rssi": { + "name": "Signal strength Wi-Fi" + } + } + } +} diff --git a/homeassistant/components/isy994/sensor.py b/homeassistant/components/isy994/sensor.py index 789075e5c57..58ba3171bc8 100644 --- a/homeassistant/components/isy994/sensor.py +++ b/homeassistant/components/isy994/sensor.py @@ -73,7 +73,7 @@ ISY_CONTROL_TO_DEVICE_CLASS = { "CV": SensorDeviceClass.VOLTAGE, "DEWPT": SensorDeviceClass.TEMPERATURE, "DISTANC": SensorDeviceClass.DISTANCE, - "ETO": SensorDeviceClass.PRECIPITATION_INTENSITY, + "ETO": SensorDeviceClass.PRECIPITATION_INTENSITY, # codespell:ignore eto "FATM": SensorDeviceClass.WEIGHT, "FREQ": SensorDeviceClass.FREQUENCY, "MUSCLEM": SensorDeviceClass.WEIGHT, diff --git a/homeassistant/components/jellyfin/manifest.json b/homeassistant/components/jellyfin/manifest.json index 810b9ea45a9..d6b2261acaa 100644 --- a/homeassistant/components/jellyfin/manifest.json +++ b/homeassistant/components/jellyfin/manifest.json @@ -1,7 +1,7 @@ { "domain": "jellyfin", "name": "Jellyfin", - "codeowners": ["@j-stienstra", "@ctalkington"], + "codeowners": ["@RunC0deRun", "@ctalkington"], "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/jellyfin", "integration_type": "service", diff --git a/homeassistant/components/lcn/manifest.json b/homeassistant/components/lcn/manifest.json index 2ac183dcc97..c1dd7751940 100644 --- a/homeassistant/components/lcn/manifest.json +++ b/homeassistant/components/lcn/manifest.json @@ -8,5 +8,5 @@ "documentation": "https://www.home-assistant.io/integrations/lcn", "iot_class": "local_push", "loggers": ["pypck"], - "requirements": ["pypck==0.8.3", "lcn-frontend==0.2.3"] + "requirements": ["pypck==0.8.5", "lcn-frontend==0.2.3"] } diff --git a/homeassistant/components/ld2410_ble/manifest.json b/homeassistant/components/ld2410_ble/manifest.json index 2e64a590eaf..36d0150642e 100644 --- a/homeassistant/components/ld2410_ble/manifest.json +++ b/homeassistant/components/ld2410_ble/manifest.json @@ -20,5 +20,5 @@ "documentation": "https://www.home-assistant.io/integrations/ld2410_ble", "integration_type": "device", "iot_class": "local_push", - "requirements": ["bluetooth-data-tools==1.22.0", "ld2410-ble==0.1.1"] + "requirements": ["bluetooth-data-tools==1.23.4", "ld2410-ble==0.1.1"] } diff --git a/homeassistant/components/led_ble/manifest.json b/homeassistant/components/led_ble/manifest.json index 7b07653e2db..309399e6958 100644 --- a/homeassistant/components/led_ble/manifest.json +++ b/homeassistant/components/led_ble/manifest.json @@ -35,5 +35,5 @@ "dependencies": ["bluetooth_adapters"], "documentation": "https://www.home-assistant.io/integrations/led_ble", "iot_class": "local_polling", - "requirements": ["bluetooth-data-tools==1.22.0", "led-ble==1.1.4"] + "requirements": ["bluetooth-data-tools==1.23.4", "led-ble==1.1.6"] } diff --git a/homeassistant/components/linx/__init__.py b/homeassistant/components/linx/__init__.py new file mode 100644 index 00000000000..3a0b14ce05f --- /dev/null +++ b/homeassistant/components/linx/__init__.py @@ -0,0 +1 @@ +"""Virtual integration: Linx.""" diff --git a/homeassistant/components/linx/manifest.json b/homeassistant/components/linx/manifest.json new file mode 100644 index 00000000000..fa96a2bbb55 --- /dev/null +++ b/homeassistant/components/linx/manifest.json @@ -0,0 +1,6 @@ +{ + "domain": "linx", + "name": "Linx", + "integration_type": "virtual", + "supported_by": "motion_blinds" +} diff --git a/homeassistant/components/mcp_server/__init__.py b/homeassistant/components/mcp_server/__init__.py index e523f46228f..941eccbe528 100644 --- a/homeassistant/components/mcp_server/__init__.py +++ b/homeassistant/components/mcp_server/__init__.py @@ -6,7 +6,7 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers import config_validation as cv from homeassistant.helpers.typing import ConfigType -from . import http +from . import http, llm_api from .const import DOMAIN from .session import SessionManager from .types import MCPServerConfigEntry @@ -25,6 +25,7 @@ CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN) async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up the Model Context Protocol component.""" http.async_register(hass) + llm_api.async_register_api(hass) return True diff --git a/homeassistant/components/mcp_server/config_flow.py b/homeassistant/components/mcp_server/config_flow.py index 8d68c6a868a..8d8d311b874 100644 --- a/homeassistant/components/mcp_server/config_flow.py +++ b/homeassistant/components/mcp_server/config_flow.py @@ -16,7 +16,7 @@ from homeassistant.helpers.selector import ( SelectSelectorConfig, ) -from .const import DOMAIN +from .const import DOMAIN, LLM_API, LLM_API_NAME _LOGGER = logging.getLogger(__name__) @@ -33,6 +33,12 @@ class ModelContextServerProtocolConfigFlow(ConfigFlow, domain=DOMAIN): ) -> ConfigFlowResult: """Handle the initial step.""" llm_apis = {api.id: api.name for api in llm.async_get_apis(self.hass)} + if LLM_API not in llm_apis: + # MCP server component is not loaded yet, so make the LLM API a choice. + llm_apis = { + LLM_API: LLM_API_NAME, + **llm_apis, + } if user_input is not None: return self.async_create_entry( diff --git a/homeassistant/components/mcp_server/const.py b/homeassistant/components/mcp_server/const.py index 1aa81f445a1..8958ac36616 100644 --- a/homeassistant/components/mcp_server/const.py +++ b/homeassistant/components/mcp_server/const.py @@ -2,3 +2,5 @@ DOMAIN = "mcp_server" TITLE = "Model Context Protocol Server" +LLM_API = "stateless_assist" +LLM_API_NAME = "Stateless Assist" diff --git a/homeassistant/components/mcp_server/llm_api.py b/homeassistant/components/mcp_server/llm_api.py new file mode 100644 index 00000000000..f4292744815 --- /dev/null +++ b/homeassistant/components/mcp_server/llm_api.py @@ -0,0 +1,48 @@ +"""LLM API for MCP Server.""" + +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers import llm +from homeassistant.util import yaml as yaml_util + +from .const import LLM_API, LLM_API_NAME + +EXPOSED_ENTITY_FIELDS = {"name", "domain", "description", "areas", "names"} + + +def async_register_api(hass: HomeAssistant) -> None: + """Register the LLM API.""" + llm.async_register_api(hass, StatelessAssistAPI(hass)) + + +class StatelessAssistAPI(llm.AssistAPI): + """LLM API for MCP Server that provides the Assist API without state information in the prompt. + + Syncing the state information is possible, but may put unnecessary load on + the system so we are instead providing the prompt without entity state. Since + actions don't care about the current state, there is little quality loss. + """ + + def __init__(self, hass: HomeAssistant) -> None: + """Initialize the StatelessAssistAPI.""" + super().__init__(hass) + self.id = LLM_API + self.name = LLM_API_NAME + + @callback + def _async_get_exposed_entities_prompt( + self, llm_context: llm.LLMContext, exposed_entities: dict | None + ) -> list[str]: + """Return the prompt for the exposed entities.""" + prompt = [] + + if exposed_entities: + prompt.append( + "An overview of the areas and the devices in this smart home:" + ) + entities = [ + {k: v for k, v in entity_info.items() if k in EXPOSED_ENTITY_FIELDS} + for entity_info in exposed_entities.values() + ] + prompt.append(yaml_util.dump(list(entities))) + + return prompt diff --git a/homeassistant/components/mill/manifest.json b/homeassistant/components/mill/manifest.json index 6316eb72096..44c1136b7d5 100644 --- a/homeassistant/components/mill/manifest.json +++ b/homeassistant/components/mill/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/mill", "iot_class": "local_polling", "loggers": ["mill", "mill_local"], - "requirements": ["millheater==0.12.2", "mill-local==0.3.0"] + "requirements": ["millheater==0.12.3", "mill-local==0.3.0"] } diff --git a/homeassistant/components/modbus/__init__.py b/homeassistant/components/modbus/__init__.py index 5b1b78a5aef..61df7206402 100644 --- a/homeassistant/components/modbus/__init__.py +++ b/homeassistant/components/modbus/__init__.py @@ -90,6 +90,7 @@ from .const import ( CONF_HVAC_MODE_VALUES, CONF_HVAC_OFF_VALUE, CONF_HVAC_ON_VALUE, + CONF_HVAC_ONOFF_COIL, CONF_HVAC_ONOFF_REGISTER, CONF_INPUT_TYPE, CONF_MAX_TEMP, @@ -258,7 +259,8 @@ CLIMATE_SCHEMA = vol.All( vol.Optional(CONF_MIN_TEMP, default=5): vol.Coerce(float), vol.Optional(CONF_STEP, default=0.5): vol.Coerce(float), vol.Optional(CONF_TEMPERATURE_UNIT, default=DEFAULT_TEMP_UNIT): cv.string, - vol.Optional(CONF_HVAC_ONOFF_REGISTER): cv.positive_int, + vol.Exclusive(CONF_HVAC_ONOFF_COIL, "hvac_onoff_type"): cv.positive_int, + vol.Exclusive(CONF_HVAC_ONOFF_REGISTER, "hvac_onoff_type"): cv.positive_int, vol.Optional( CONF_HVAC_ON_VALUE, default=DEFAULT_HVAC_ON_VALUE ): cv.positive_int, diff --git a/homeassistant/components/modbus/climate.py b/homeassistant/components/modbus/climate.py index e1a2688048d..fca1b94611a 100644 --- a/homeassistant/components/modbus/climate.py +++ b/homeassistant/components/modbus/climate.py @@ -43,7 +43,9 @@ from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from . import get_hub from .const import ( + CALL_TYPE_COIL, CALL_TYPE_REGISTER_HOLDING, + CALL_TYPE_WRITE_COIL, CALL_TYPE_WRITE_REGISTER, CALL_TYPE_WRITE_REGISTERS, CONF_CLIMATES, @@ -70,6 +72,7 @@ from .const import ( CONF_HVAC_MODE_VALUES, CONF_HVAC_OFF_VALUE, CONF_HVAC_ON_VALUE, + CONF_HVAC_ONOFF_COIL, CONF_HVAC_ONOFF_REGISTER, CONF_MAX_TEMP, CONF_MIN_TEMP, @@ -254,6 +257,13 @@ class ModbusThermostat(BaseStructPlatform, RestoreEntity, ClimateEntity): else: self._hvac_onoff_register = None + if CONF_HVAC_ONOFF_COIL in config: + self._hvac_onoff_coil = config[CONF_HVAC_ONOFF_COIL] + if HVACMode.OFF not in self._attr_hvac_modes: + self._attr_hvac_modes.append(HVACMode.OFF) + else: + self._hvac_onoff_coil = None + async def async_added_to_hass(self) -> None: """Handle entity which will be added.""" await self.async_base_added_to_hass() @@ -287,6 +297,15 @@ class ModbusThermostat(BaseStructPlatform, RestoreEntity, ClimateEntity): CALL_TYPE_WRITE_REGISTER, ) + if self._hvac_onoff_coil is not None: + # Turn HVAC Off by writing 0 to the On/Off coil, or 1 otherwise. + await self._hub.async_pb_call( + self._slave, + self._hvac_onoff_coil, + 0 if hvac_mode == HVACMode.OFF else 1, + CALL_TYPE_WRITE_COIL, + ) + if self._hvac_mode_register is not None: # Write a value to the mode register for the desired mode. for value, mode in self._hvac_mode_mapping: @@ -484,6 +503,11 @@ class ModbusThermostat(BaseStructPlatform, RestoreEntity, ClimateEntity): if onoff == self._hvac_off_value: self._attr_hvac_mode = HVACMode.OFF + if self._hvac_onoff_coil is not None: + onoff = await self._async_read_coil(self._hvac_onoff_coil) + if onoff == 0: + self._attr_hvac_mode = HVACMode.OFF + async def _async_read_register( self, register_type: str, register: int, raw: bool | None = False ) -> float | None: @@ -508,3 +532,11 @@ class ModbusThermostat(BaseStructPlatform, RestoreEntity, ClimateEntity): return None self._attr_available = True return float(self._value) + + async def _async_read_coil(self, address: int) -> int | None: + result = await self._hub.async_pb_call(self._slave, address, 1, CALL_TYPE_COIL) + if result is not None and result.bits is not None: + self._attr_available = True + return int(result.bits[0]) + self._attr_available = False + return None diff --git a/homeassistant/components/modbus/const.py b/homeassistant/components/modbus/const.py index e11e15fff20..5926569040d 100644 --- a/homeassistant/components/modbus/const.py +++ b/homeassistant/components/modbus/const.py @@ -62,6 +62,7 @@ CONF_HVAC_MODE_REGISTER = "hvac_mode_register" CONF_HVAC_ONOFF_REGISTER = "hvac_onoff_register" CONF_HVAC_ON_VALUE = "hvac_on_value" CONF_HVAC_OFF_VALUE = "hvac_off_value" +CONF_HVAC_ONOFF_COIL = "hvac_onoff_coil" CONF_HVAC_MODE_OFF = "state_off" CONF_HVAC_MODE_HEAT = "state_heat" CONF_HVAC_MODE_COOL = "state_cool" diff --git a/homeassistant/components/monarch_money/manifest.json b/homeassistant/components/monarch_money/manifest.json index ed28f825bcf..d45415bbcd7 100644 --- a/homeassistant/components/monarch_money/manifest.json +++ b/homeassistant/components/monarch_money/manifest.json @@ -5,5 +5,5 @@ "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/monarchmoney", "iot_class": "cloud_polling", - "requirements": ["typedmonarchmoney==0.3.1"] + "requirements": ["typedmonarchmoney==0.4.4"] } diff --git a/homeassistant/components/mopeka/config_flow.py b/homeassistant/components/mopeka/config_flow.py index 2e35ff4283f..e5b7d5d7dd2 100644 --- a/homeassistant/components/mopeka/config_flow.py +++ b/homeassistant/components/mopeka/config_flow.py @@ -111,7 +111,7 @@ class MopekaConfigFlow(ConfigFlow, domain=DOMAIN): data={CONF_MEDIUM_TYPE: user_input[CONF_MEDIUM_TYPE]}, ) - current_addresses = self._async_current_ids() + current_addresses = self._async_current_ids(include_ignore=False) for discovery_info in async_discovered_service_info(self.hass, False): address = discovery_info.address if address in current_addresses or address in self._discovered_devices: diff --git a/homeassistant/components/motionmount/entity.py b/homeassistant/components/motionmount/entity.py index 81d4d0119b5..bbb79729a9e 100644 --- a/homeassistant/components/motionmount/entity.py +++ b/homeassistant/components/motionmount/entity.py @@ -30,7 +30,7 @@ class MotionMountEntity(Entity): self.config_entry = config_entry # We store the pin, as we might need it during reconnect - self.pin = config_entry.data[CONF_PIN] + self.pin = config_entry.data.get(CONF_PIN) mac = format_mac(mm.mac.hex()) diff --git a/homeassistant/components/mqtt/async_client.py b/homeassistant/components/mqtt/async_client.py index 882e910d7e8..5f90136df44 100644 --- a/homeassistant/components/mqtt/async_client.py +++ b/homeassistant/components/mqtt/async_client.py @@ -51,10 +51,10 @@ class AsyncMQTTClient(MQTTClient): since the client is running in an async event loop and will never run in multiple threads. """ - self._in_callback_mutex = NullLock() - self._callback_mutex = NullLock() - self._msgtime_mutex = NullLock() - self._out_message_mutex = NullLock() - self._in_message_mutex = NullLock() - self._reconnect_delay_mutex = NullLock() - self._mid_generate_mutex = NullLock() + self._in_callback_mutex = NullLock() # type: ignore[assignment] + self._callback_mutex = NullLock() # type: ignore[assignment] + self._msgtime_mutex = NullLock() # type: ignore[assignment] + self._out_message_mutex = NullLock() # type: ignore[assignment] + self._in_message_mutex = NullLock() # type: ignore[assignment] + self._reconnect_delay_mutex = NullLock() # type: ignore[assignment] + self._mid_generate_mutex = NullLock() # type: ignore[assignment] diff --git a/homeassistant/components/mqtt/client.py b/homeassistant/components/mqtt/client.py index 16a02e4956e..3aca566dbfc 100644 --- a/homeassistant/components/mqtt/client.py +++ b/homeassistant/components/mqtt/client.py @@ -15,7 +15,6 @@ import socket import ssl import time from typing import TYPE_CHECKING, Any -import uuid import certifi @@ -117,7 +116,7 @@ MAX_UNSUBSCRIBES_PER_CALL = 500 MAX_PACKETS_TO_READ = 500 -type SocketType = socket.socket | ssl.SSLSocket | mqtt.WebsocketWrapper | Any +type SocketType = socket.socket | ssl.SSLSocket | mqtt._WebsocketWrapper | Any # noqa: SLF001 type SubscribePayloadType = str | bytes | bytearray # Only bytes if encoding is None @@ -309,12 +308,13 @@ class MqttClientSetup: if (client_id := config.get(CONF_CLIENT_ID)) is None: # PAHO MQTT relies on the MQTT server to generate random client IDs. # However, that feature is not mandatory so we generate our own. - client_id = mqtt.base62(uuid.uuid4().int, padding=22) + client_id = None transport: str = config.get(CONF_TRANSPORT, DEFAULT_TRANSPORT) self._client = AsyncMQTTClient( + mqtt.CallbackAPIVersion.VERSION1, client_id, protocol=proto, - transport=transport, + transport=transport, # type: ignore[arg-type] reconnect_on_failure=False, ) self._client.setup() @@ -533,7 +533,7 @@ class MQTT: try: # Some operating systems do not allow us to set the preferred # buffer size. In that case we try some other size options. - sock.setsockopt(socket.SOL_SOCKET, socket.SO_RCVBUF, new_buffer_size) + sock.setsockopt(socket.SOL_SOCKET, socket.SO_RCVBUF, new_buffer_size) # type: ignore[union-attr] except OSError as err: if new_buffer_size <= MIN_BUFFER_SIZE: _LOGGER.warning( @@ -1216,7 +1216,9 @@ class MQTT: if not future.done(): future.set_exception(asyncio.TimeoutError) - async def _async_wait_for_mid_or_raise(self, mid: int, result_code: int) -> None: + async def _async_wait_for_mid_or_raise( + self, mid: int | None, result_code: int + ) -> None: """Wait for ACK from broker or raise on error.""" if result_code != 0: # pylint: disable-next=import-outside-toplevel @@ -1232,6 +1234,8 @@ class MQTT: # Create the mid event if not created, either _mqtt_handle_mid or # _async_wait_for_mid_or_raise may be executed first. + if TYPE_CHECKING: + assert mid is not None future = self._async_get_mid_future(mid) loop = self.hass.loop timer_handle = loop.call_later(TIMEOUT_ACK, self._async_timeout_mid, future) @@ -1269,7 +1273,7 @@ def _matcher_for_topic(subscription: str) -> Callable[[str], bool]: # pylint: disable-next=import-outside-toplevel from paho.mqtt.matcher import MQTTMatcher - matcher = MQTTMatcher() + matcher = MQTTMatcher() # type: ignore[no-untyped-call] matcher[subscription] = True - return lambda topic: next(matcher.iter_match(topic), False) + return lambda topic: next(matcher.iter_match(topic), False) # type: ignore[no-untyped-call] diff --git a/homeassistant/components/mqtt/config_flow.py b/homeassistant/components/mqtt/config_flow.py index a4d400dfea2..a9d417fc783 100644 --- a/homeassistant/components/mqtt/config_flow.py +++ b/homeassistant/components/mqtt/config_flow.py @@ -485,7 +485,7 @@ class FlowHandler(ConfigFlow, domain=DOMAIN): errors, ): if is_reconfigure: - update_password_from_user_input( + validated_user_input = update_password_from_user_input( reconfigure_entry.data.get(CONF_PASSWORD), validated_user_input ) diff --git a/homeassistant/components/mqtt/light/schema_json.py b/homeassistant/components/mqtt/light/schema_json.py index 43b0cbf77b3..14e21e61d48 100644 --- a/homeassistant/components/mqtt/light/schema_json.py +++ b/homeassistant/components/mqtt/light/schema_json.py @@ -2,7 +2,6 @@ from __future__ import annotations -from collections.abc import Callable from contextlib import suppress import logging from typing import TYPE_CHECKING, Any, cast @@ -24,7 +23,6 @@ from homeassistant.components.light import ( ATTR_XY_COLOR, DEFAULT_MAX_KELVIN, DEFAULT_MIN_KELVIN, - DOMAIN as LIGHT_DOMAIN, ENTITY_ID_FORMAT, FLASH_LONG, FLASH_SHORT, @@ -34,7 +32,6 @@ from homeassistant.components.light import ( LightEntityFeature, brightness_supported, color_supported, - filter_supported_color_modes, valid_supported_color_modes, ) from homeassistant.const import ( @@ -48,15 +45,13 @@ from homeassistant.const import ( CONF_XY, STATE_ON, ) -from homeassistant.core import async_get_hass, callback +from homeassistant.core import callback from homeassistant.helpers import config_validation as cv -from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue from homeassistant.helpers.json import json_dumps from homeassistant.helpers.restore_state import RestoreEntity from homeassistant.helpers.typing import ConfigType, VolSchemaType from homeassistant.util import color as color_util from homeassistant.util.json import json_loads_object -from homeassistant.util.yaml import dump as yaml_dump from .. import subscription from ..config import DEFAULT_QOS, DEFAULT_RETAIN, MQTT_RW_SCHEMA @@ -68,7 +63,6 @@ from ..const import ( CONF_QOS, CONF_RETAIN, CONF_STATE_TOPIC, - DOMAIN as MQTT_DOMAIN, ) from ..entity import MqttEntity from ..models import ReceiveMessage @@ -86,15 +80,10 @@ _LOGGER = logging.getLogger(__name__) DOMAIN = "mqtt_json" DEFAULT_BRIGHTNESS = False -DEFAULT_COLOR_MODE = False -DEFAULT_COLOR_TEMP = False DEFAULT_EFFECT = False DEFAULT_FLASH_TIME_LONG = 10 DEFAULT_FLASH_TIME_SHORT = 2 DEFAULT_NAME = "MQTT JSON Light" -DEFAULT_RGB = False -DEFAULT_XY = False -DEFAULT_HS = False DEFAULT_BRIGHTNESS_SCALE = 255 DEFAULT_WHITE_SCALE = 255 @@ -110,89 +99,6 @@ CONF_MAX_MIREDS = "max_mireds" CONF_MIN_MIREDS = "min_mireds" -def valid_color_configuration( - setup_from_yaml: bool, -) -> Callable[[dict[str, Any]], dict[str, Any]]: - """Test color_mode is not combined with deprecated config.""" - - def _valid_color_configuration(config: ConfigType) -> ConfigType: - deprecated = {CONF_COLOR_TEMP, CONF_HS, CONF_RGB, CONF_XY} - deprecated_flags_used = any(config.get(key) for key in deprecated) - if config.get(CONF_SUPPORTED_COLOR_MODES): - if deprecated_flags_used: - raise vol.Invalid( - "supported_color_modes must not " - f"be combined with any of {deprecated}" - ) - elif deprecated_flags_used: - deprecated_flags = ", ".join(key for key in deprecated if key in config) - _LOGGER.warning( - "Deprecated flags [%s] used in MQTT JSON light config " - "for handling color mode, please use `supported_color_modes` instead. " - "Got: %s. This will stop working in Home Assistant Core 2025.3", - deprecated_flags, - config, - ) - if not setup_from_yaml: - return config - issue_id = hex(hash(frozenset(config))) - yaml_config_str = yaml_dump(config) - learn_more_url = ( - "https://www.home-assistant.io/integrations/" - f"{LIGHT_DOMAIN}.mqtt/#json-schema" - ) - hass = async_get_hass() - async_create_issue( - hass, - MQTT_DOMAIN, - issue_id, - issue_domain=LIGHT_DOMAIN, - is_fixable=False, - severity=IssueSeverity.WARNING, - learn_more_url=learn_more_url, - translation_placeholders={ - "deprecated_flags": deprecated_flags, - "config": yaml_config_str, - }, - translation_key="deprecated_color_handling", - ) - - if CONF_COLOR_MODE in config: - _LOGGER.warning( - "Deprecated flag `color_mode` used in MQTT JSON light config " - ", the `color_mode` flag is not used anymore and should be removed. " - "Got: %s. This will stop working in Home Assistant Core 2025.3", - config, - ) - if not setup_from_yaml: - return config - issue_id = hex(hash(frozenset(config))) - yaml_config_str = yaml_dump(config) - learn_more_url = ( - "https://www.home-assistant.io/integrations/" - f"{LIGHT_DOMAIN}.mqtt/#json-schema" - ) - hass = async_get_hass() - async_create_issue( - hass, - MQTT_DOMAIN, - issue_id, - breaks_in_ha_version="2025.3.0", - issue_domain=LIGHT_DOMAIN, - is_fixable=False, - severity=IssueSeverity.WARNING, - learn_more_url=learn_more_url, - translation_placeholders={ - "config": yaml_config_str, - }, - translation_key="deprecated_color_mode_flag", - ) - - return config - - return _valid_color_configuration - - _PLATFORM_SCHEMA_BASE = ( MQTT_RW_SCHEMA.extend( { @@ -200,12 +106,6 @@ _PLATFORM_SCHEMA_BASE = ( vol.Optional( CONF_BRIGHTNESS_SCALE, default=DEFAULT_BRIGHTNESS_SCALE ): vol.All(vol.Coerce(int), vol.Range(min=1)), - # CONF_COLOR_MODE was deprecated with HA Core 2024.4 and will be - # removed with HA Core 2025.3 - vol.Optional(CONF_COLOR_MODE): cv.boolean, - # CONF_COLOR_TEMP was deprecated with HA Core 2024.4 and will be - # removed with HA Core 2025.3 - vol.Optional(CONF_COLOR_TEMP, default=DEFAULT_COLOR_TEMP): cv.boolean, vol.Optional(CONF_COLOR_TEMP_KELVIN, default=False): cv.boolean, vol.Optional(CONF_EFFECT, default=DEFAULT_EFFECT): cv.boolean, vol.Optional(CONF_EFFECT_LIST): vol.All(cv.ensure_list, [cv.string]), @@ -215,9 +115,6 @@ _PLATFORM_SCHEMA_BASE = ( vol.Optional( CONF_FLASH_TIME_SHORT, default=DEFAULT_FLASH_TIME_SHORT ): cv.positive_int, - # CONF_HS was deprecated with HA Core 2024.4 and will be - # removed with HA Core 2025.3 - vol.Optional(CONF_HS, default=DEFAULT_HS): cv.boolean, vol.Optional(CONF_MAX_MIREDS): cv.positive_int, vol.Optional(CONF_MIN_MIREDS): cv.positive_int, vol.Optional(CONF_MAX_KELVIN): cv.positive_int, @@ -227,9 +124,6 @@ _PLATFORM_SCHEMA_BASE = ( vol.Coerce(int), vol.In([0, 1, 2]) ), vol.Optional(CONF_RETAIN, default=DEFAULT_RETAIN): cv.boolean, - # CONF_RGB was deprecated with HA Core 2024.4 and will be - # removed with HA Core 2025.3 - vol.Optional(CONF_RGB, default=DEFAULT_RGB): cv.boolean, vol.Optional(CONF_STATE_TOPIC): valid_subscribe_topic, vol.Optional(CONF_SUPPORTED_COLOR_MODES): vol.All( cv.ensure_list, @@ -240,22 +134,29 @@ _PLATFORM_SCHEMA_BASE = ( vol.Optional(CONF_WHITE_SCALE, default=DEFAULT_WHITE_SCALE): vol.All( vol.Coerce(int), vol.Range(min=1) ), - # CONF_XY was deprecated with HA Core 2024.4 and will be - # removed with HA Core 2025.3 - vol.Optional(CONF_XY, default=DEFAULT_XY): cv.boolean, }, ) .extend(MQTT_ENTITY_COMMON_SCHEMA.schema) .extend(MQTT_LIGHT_SCHEMA_SCHEMA.schema) ) +# Support for legacy color_mode handling was removed with HA Core 2025.3 +# The removed attributes can be removed from the schema's from HA Core 2026.3 DISCOVERY_SCHEMA_JSON = vol.All( - valid_color_configuration(False), + cv.removed(CONF_COLOR_MODE, raise_if_present=False), + cv.removed(CONF_COLOR_TEMP, raise_if_present=False), + cv.removed(CONF_HS, raise_if_present=False), + cv.removed(CONF_RGB, raise_if_present=False), + cv.removed(CONF_XY, raise_if_present=False), _PLATFORM_SCHEMA_BASE.extend({}, extra=vol.REMOVE_EXTRA), ) PLATFORM_SCHEMA_MODERN_JSON = vol.All( - valid_color_configuration(True), + cv.removed(CONF_COLOR_MODE), + cv.removed(CONF_COLOR_TEMP), + cv.removed(CONF_HS), + cv.removed(CONF_RGB), + cv.removed(CONF_XY), _PLATFORM_SCHEMA_BASE, ) @@ -272,8 +173,6 @@ class MqttLightJson(MqttEntity, LightEntity, RestoreEntity): _topic: dict[str, str | None] _optimistic: bool - _deprecated_color_handling: bool = False - @staticmethod def config_schema() -> VolSchemaType: """Return the config schema.""" @@ -318,122 +217,65 @@ class MqttLightJson(MqttEntity, LightEntity, RestoreEntity): self._attr_color_mode = next(iter(self.supported_color_modes)) else: self._attr_color_mode = ColorMode.UNKNOWN - else: - self._deprecated_color_handling = True - color_modes = {ColorMode.ONOFF} - if config[CONF_BRIGHTNESS]: - color_modes.add(ColorMode.BRIGHTNESS) - if config[CONF_COLOR_TEMP]: - color_modes.add(ColorMode.COLOR_TEMP) - if config[CONF_HS] or config[CONF_RGB] or config[CONF_XY]: - color_modes.add(ColorMode.HS) - self._attr_supported_color_modes = filter_supported_color_modes(color_modes) - if self.supported_color_modes and len(self.supported_color_modes) == 1: - self._fixed_color_mode = next(iter(self.supported_color_modes)) def _update_color(self, values: dict[str, Any]) -> None: - if self._deprecated_color_handling: - # Deprecated color handling - try: - red = int(values["color"]["r"]) - green = int(values["color"]["g"]) - blue = int(values["color"]["b"]) - self._attr_hs_color = color_util.color_RGB_to_hs(red, green, blue) - except KeyError: - pass - except ValueError: - _LOGGER.warning( - "Invalid RGB color value '%s' received for entity %s", - values, - self.entity_id, + color_mode: str = values["color_mode"] + if not self._supports_color_mode(color_mode): + _LOGGER.warning( + "Invalid color mode '%s' received for entity %s", + color_mode, + self.entity_id, + ) + return + try: + if color_mode == ColorMode.COLOR_TEMP: + self._attr_color_temp_kelvin = ( + values["color_temp"] + if self._color_temp_kelvin + else color_util.color_temperature_mired_to_kelvin( + values["color_temp"] + ) ) - return - - try: - x_color = float(values["color"]["x"]) - y_color = float(values["color"]["y"]) - self._attr_hs_color = color_util.color_xy_to_hs(x_color, y_color) - except KeyError: - pass - except ValueError: - _LOGGER.warning( - "Invalid XY color value '%s' received for entity %s", - values, - self.entity_id, - ) - return - - try: + self._attr_color_mode = ColorMode.COLOR_TEMP + elif color_mode == ColorMode.HS: hue = float(values["color"]["h"]) saturation = float(values["color"]["s"]) + self._attr_color_mode = ColorMode.HS self._attr_hs_color = (hue, saturation) - except KeyError: - pass - except ValueError: - _LOGGER.warning( - "Invalid HS color value '%s' received for entity %s", - values, - self.entity_id, - ) - return - else: - color_mode: str = values["color_mode"] - if not self._supports_color_mode(color_mode): - _LOGGER.warning( - "Invalid color mode '%s' received for entity %s", - color_mode, - self.entity_id, - ) - return - try: - if color_mode == ColorMode.COLOR_TEMP: - self._attr_color_temp_kelvin = ( - values["color_temp"] - if self._color_temp_kelvin - else color_util.color_temperature_mired_to_kelvin( - values["color_temp"] - ) - ) - self._attr_color_mode = ColorMode.COLOR_TEMP - elif color_mode == ColorMode.HS: - hue = float(values["color"]["h"]) - saturation = float(values["color"]["s"]) - self._attr_color_mode = ColorMode.HS - self._attr_hs_color = (hue, saturation) - elif color_mode == ColorMode.RGB: - r = int(values["color"]["r"]) - g = int(values["color"]["g"]) - b = int(values["color"]["b"]) - self._attr_color_mode = ColorMode.RGB - self._attr_rgb_color = (r, g, b) - elif color_mode == ColorMode.RGBW: - r = int(values["color"]["r"]) - g = int(values["color"]["g"]) - b = int(values["color"]["b"]) - w = int(values["color"]["w"]) - self._attr_color_mode = ColorMode.RGBW - self._attr_rgbw_color = (r, g, b, w) - elif color_mode == ColorMode.RGBWW: - r = int(values["color"]["r"]) - g = int(values["color"]["g"]) - b = int(values["color"]["b"]) - c = int(values["color"]["c"]) - w = int(values["color"]["w"]) - self._attr_color_mode = ColorMode.RGBWW - self._attr_rgbww_color = (r, g, b, c, w) - elif color_mode == ColorMode.WHITE: - self._attr_color_mode = ColorMode.WHITE - elif color_mode == ColorMode.XY: - x = float(values["color"]["x"]) - y = float(values["color"]["y"]) - self._attr_color_mode = ColorMode.XY - self._attr_xy_color = (x, y) - except (KeyError, ValueError): - _LOGGER.warning( - "Invalid or incomplete color value '%s' received for entity %s", - values, - self.entity_id, - ) + elif color_mode == ColorMode.RGB: + r = int(values["color"]["r"]) + g = int(values["color"]["g"]) + b = int(values["color"]["b"]) + self._attr_color_mode = ColorMode.RGB + self._attr_rgb_color = (r, g, b) + elif color_mode == ColorMode.RGBW: + r = int(values["color"]["r"]) + g = int(values["color"]["g"]) + b = int(values["color"]["b"]) + w = int(values["color"]["w"]) + self._attr_color_mode = ColorMode.RGBW + self._attr_rgbw_color = (r, g, b, w) + elif color_mode == ColorMode.RGBWW: + r = int(values["color"]["r"]) + g = int(values["color"]["g"]) + b = int(values["color"]["b"]) + c = int(values["color"]["c"]) + w = int(values["color"]["w"]) + self._attr_color_mode = ColorMode.RGBWW + self._attr_rgbww_color = (r, g, b, c, w) + elif color_mode == ColorMode.WHITE: + self._attr_color_mode = ColorMode.WHITE + elif color_mode == ColorMode.XY: + x = float(values["color"]["x"]) + y = float(values["color"]["y"]) + self._attr_color_mode = ColorMode.XY + self._attr_xy_color = (x, y) + except (KeyError, TypeError, ValueError): + _LOGGER.warning( + "Invalid or incomplete color value '%s' received for entity %s", + values, + self.entity_id, + ) @callback def _state_received(self, msg: ReceiveMessage) -> None: @@ -447,18 +289,7 @@ class MqttLightJson(MqttEntity, LightEntity, RestoreEntity): elif values["state"] is None: self._attr_is_on = None - if ( - self._deprecated_color_handling - and color_supported(self.supported_color_modes) - and "color" in values - ): - # Deprecated color handling - if values["color"] is None: - self._attr_hs_color = None - else: - self._update_color(values) - - if not self._deprecated_color_handling and "color_mode" in values: + if color_supported(self.supported_color_modes) and "color_mode" in values: self._update_color(values) if brightness_supported(self.supported_color_modes): @@ -484,35 +315,6 @@ class MqttLightJson(MqttEntity, LightEntity, RestoreEntity): self.entity_id, ) - if ( - self._deprecated_color_handling - and self.supported_color_modes - and ColorMode.COLOR_TEMP in self.supported_color_modes - ): - # Deprecated color handling - try: - if values["color_temp"] is None: - self._attr_color_temp_kelvin = None - else: - self._attr_color_temp_kelvin = ( - values["color_temp"] # type: ignore[assignment] - if self._color_temp_kelvin - else color_util.color_temperature_mired_to_kelvin( - values["color_temp"] # type: ignore[arg-type] - ) - ) - except KeyError: - pass - except (TypeError, ValueError): - _LOGGER.warning( - "Invalid color temp value '%s' received for entity %s", - values["color_temp"], - self.entity_id, - ) - # Allow to switch back to color_temp - if "color" not in values: - self._attr_hs_color = None - if self.supported_features and LightEntityFeature.EFFECT: with suppress(KeyError): self._attr_effect = cast(str, values["effect"]) @@ -565,19 +367,6 @@ class MqttLightJson(MqttEntity, LightEntity, RestoreEntity): ) self._attr_xy_color = last_attributes.get(ATTR_XY_COLOR, self.xy_color) - @property - def color_mode(self) -> ColorMode | str | None: - """Return current color mode.""" - if not self._deprecated_color_handling: - return self._attr_color_mode - if self._fixed_color_mode: - # Legacy light with support for a single color mode - return self._fixed_color_mode - # Legacy light with support for ct + hs, prioritize hs - if self.hs_color is not None: - return ColorMode.HS - return ColorMode.COLOR_TEMP - def _set_flash_and_transition(self, message: dict[str, Any], **kwargs: Any) -> None: if ATTR_TRANSITION in kwargs: message["transition"] = kwargs[ATTR_TRANSITION] @@ -604,17 +393,15 @@ class MqttLightJson(MqttEntity, LightEntity, RestoreEntity): def _supports_color_mode(self, color_mode: ColorMode | str) -> bool: """Return True if the light natively supports a color mode.""" return ( - not self._deprecated_color_handling - and self.supported_color_modes is not None + self.supported_color_modes is not None and color_mode in self.supported_color_modes ) - async def async_turn_on(self, **kwargs: Any) -> None: # noqa: C901 + async def async_turn_on(self, **kwargs: Any) -> None: """Turn the device on. This method is a coroutine. """ - brightness: int should_update = False hs_color: tuple[float, float] message: dict[str, Any] = {"state": "ON"} @@ -623,39 +410,6 @@ class MqttLightJson(MqttEntity, LightEntity, RestoreEntity): rgbcw: tuple[int, ...] xy_color: tuple[float, float] - if ATTR_HS_COLOR in kwargs and ( - self._config[CONF_HS] or self._config[CONF_RGB] or self._config[CONF_XY] - ): - # Legacy color handling - hs_color = kwargs[ATTR_HS_COLOR] - message["color"] = {} - if self._config[CONF_RGB]: - # If brightness is supported, we don't want to scale the - # RGB values given using the brightness. - if self._config[CONF_BRIGHTNESS]: - brightness = 255 - else: - # We pop the brightness, to omit it from the payload - brightness = kwargs.pop(ATTR_BRIGHTNESS, 255) - rgb = color_util.color_hsv_to_RGB( - hs_color[0], hs_color[1], brightness / 255 * 100 - ) - message["color"]["r"] = rgb[0] - message["color"]["g"] = rgb[1] - message["color"]["b"] = rgb[2] - if self._config[CONF_XY]: - xy_color = color_util.color_hs_to_xy(*kwargs[ATTR_HS_COLOR]) - message["color"]["x"] = xy_color[0] - message["color"]["y"] = xy_color[1] - if self._config[CONF_HS]: - message["color"]["h"] = hs_color[0] - message["color"]["s"] = hs_color[1] - - if self._optimistic: - self._attr_color_temp_kelvin = None - self._attr_hs_color = kwargs[ATTR_HS_COLOR] - should_update = True - if ATTR_HS_COLOR in kwargs and self._supports_color_mode(ColorMode.HS): hs_color = kwargs[ATTR_HS_COLOR] message["color"] = {"h": hs_color[0], "s": hs_color[1]} diff --git a/homeassistant/components/mqtt/manifest.json b/homeassistant/components/mqtt/manifest.json index 25e98c01aaf..1cd6ae3e47c 100644 --- a/homeassistant/components/mqtt/manifest.json +++ b/homeassistant/components/mqtt/manifest.json @@ -8,6 +8,6 @@ "documentation": "https://www.home-assistant.io/integrations/mqtt", "iot_class": "local_push", "quality_scale": "platinum", - "requirements": ["paho-mqtt==1.6.1"], + "requirements": ["paho-mqtt==2.1.0"], "single_config_entry": true } diff --git a/homeassistant/components/mqtt/strings.json b/homeassistant/components/mqtt/strings.json index 3815b6adbd5..bf0bd594ea4 100644 --- a/homeassistant/components/mqtt/strings.json +++ b/homeassistant/components/mqtt/strings.json @@ -1,13 +1,5 @@ { "issues": { - "deprecated_color_handling": { - "title": "Deprecated color handling used for MQTT light", - "description": "An MQTT light config (with `json` schema) found in `configuration.yaml` uses deprecated color handling flags.\n\nConfiguration found:\n```yaml\n{config}\n```\nDeprecated flags: **{deprecated_flags}**.\n\nUse the `supported_color_modes` option instead and [reload](/developer-tools/yaml) the manually configured MQTT items or restart Home Assistant to fix this issue." - }, - "deprecated_color_mode_flag": { - "title": "Deprecated color_mode option flag used for MQTT light", - "description": "An MQTT light config (with `json` schema) found in `configuration.yaml` uses a deprecated `color_mode` flag.\n\nConfiguration found:\n```yaml\n{config}\n```\n\nRemove the option from your config and [reload](/developer-tools/yaml) the manually configured MQTT items or restart Home Assistant to fix this issue." - }, "invalid_platform_config": { "title": "Invalid config found for mqtt {domain} item", "description": "Home Assistant detected an invalid config for a manually configured item.\n\nPlatform domain: **{domain}**\nConfiguration file: **{config_file}**\nNear line: **{line}**\nConfiguration found:\n```yaml\n{config}\n```\nError: **{error}**.\n\nMake sure the configuration is valid and [reload](/developer-tools/yaml) the manually configured MQTT items or restart Home Assistant to fix this issue." diff --git a/homeassistant/components/nexia/manifest.json b/homeassistant/components/nexia/manifest.json index 0013cd63de1..6a439f869c9 100644 --- a/homeassistant/components/nexia/manifest.json +++ b/homeassistant/components/nexia/manifest.json @@ -12,5 +12,5 @@ "documentation": "https://www.home-assistant.io/integrations/nexia", "iot_class": "cloud_polling", "loggers": ["nexia"], - "requirements": ["nexia==2.0.8"] + "requirements": ["nexia==2.0.9"] } diff --git a/homeassistant/components/niko_home_control/__init__.py b/homeassistant/components/niko_home_control/__init__.py index ae4e8986816..37396e69caa 100644 --- a/homeassistant/components/niko_home_control/__init__.py +++ b/homeassistant/components/niko_home_control/__init__.py @@ -2,7 +2,6 @@ from __future__ import annotations -from nclib.errors import NetcatError from nhc.controller import NHCController from homeassistant.config_entries import ConfigEntry @@ -25,12 +24,8 @@ async def async_setup_entry( controller = NHCController(entry.data[CONF_HOST]) try: await controller.connect() - except NetcatError as err: + except (TimeoutError, OSError) as err: raise ConfigEntryNotReady("cannot connect to controller.") from err - except OSError as err: - raise ConfigEntryNotReady( - "unknown error while connecting to controller." - ) from err entry.runtime_data = controller await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) diff --git a/homeassistant/components/niko_home_control/cover.py b/homeassistant/components/niko_home_control/cover.py index 51e2a8a702d..b3546b517d5 100644 --- a/homeassistant/components/niko_home_control/cover.py +++ b/homeassistant/components/niko_home_control/cover.py @@ -37,17 +37,17 @@ class NikoHomeControlCover(NikoHomeControlEntity, CoverEntity): ) _action: NHCCover - def open_cover(self, **kwargs: Any) -> None: + async def async_open_cover(self, **kwargs: Any) -> None: """Open the cover.""" - self._action.open() + await self._action.open() - def close_cover(self, **kwargs: Any) -> None: + async def async_close_cover(self, **kwargs: Any) -> None: """Close the cover.""" - self._action.close() + await self._action.close() - def stop_cover(self, **kwargs: Any) -> None: + async def async_stop_cover(self, **kwargs: Any) -> None: """Stop the cover.""" - self._action.stop() + await self._action.stop() def update_state(self): """Update HA state.""" diff --git a/homeassistant/components/niko_home_control/light.py b/homeassistant/components/niko_home_control/light.py index 5c2b372fd25..7c0d11b3388 100644 --- a/homeassistant/components/niko_home_control/light.py +++ b/homeassistant/components/niko_home_control/light.py @@ -109,13 +109,13 @@ class NikoHomeControlLight(NikoHomeControlEntity, LightEntity): self._attr_supported_color_modes = {ColorMode.BRIGHTNESS} self._attr_brightness = round(action.state * 2.55) - def turn_on(self, **kwargs: Any) -> None: + async def async_turn_on(self, **kwargs: Any) -> None: """Instruct the light to turn on.""" - self._action.turn_on(round(kwargs.get(ATTR_BRIGHTNESS, 255) / 2.55)) + await self._action.turn_on(round(kwargs.get(ATTR_BRIGHTNESS, 255) / 2.55)) - def turn_off(self, **kwargs: Any) -> None: + async def async_turn_off(self, **kwargs: Any) -> None: """Instruct the light to turn off.""" - self._action.turn_off() + await self._action.turn_off() def update_state(self) -> None: """Handle updates from the controller.""" diff --git a/homeassistant/components/niko_home_control/manifest.json b/homeassistant/components/niko_home_control/manifest.json index 57f83180eb0..b50410cd7de 100644 --- a/homeassistant/components/niko_home_control/manifest.json +++ b/homeassistant/components/niko_home_control/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/niko_home_control", "iot_class": "local_push", "loggers": ["nikohomecontrol"], - "requirements": ["nhc==0.3.9"] + "requirements": ["nhc==0.4.4"] } diff --git a/homeassistant/components/nut/manifest.json b/homeassistant/components/nut/manifest.json index 9e968b5a349..fb6c8561b25 100644 --- a/homeassistant/components/nut/manifest.json +++ b/homeassistant/components/nut/manifest.json @@ -7,6 +7,6 @@ "integration_type": "device", "iot_class": "local_polling", "loggers": ["aionut"], - "requirements": ["aionut==4.3.3"], + "requirements": ["aionut==4.3.4"], "zeroconf": ["_nut._tcp.local."] } diff --git a/homeassistant/components/onboarding/views.py b/homeassistant/components/onboarding/views.py index edf0b615779..1e29860e3c5 100644 --- a/homeassistant/components/onboarding/views.py +++ b/homeassistant/components/onboarding/views.py @@ -378,7 +378,7 @@ class BackupInfoView(BackupOnboardingView): backups, _ = await manager.async_get_backups() return self.json( { - "backups": [backup.as_frontend_json() for backup in backups.values()], + "backups": list(backups.values()), "state": manager.state, "last_non_idle_event": manager.last_non_idle_event, } diff --git a/homeassistant/components/oncue/manifest.json b/homeassistant/components/oncue/manifest.json index b4c425a1645..33d56f23669 100644 --- a/homeassistant/components/oncue/manifest.json +++ b/homeassistant/components/oncue/manifest.json @@ -12,5 +12,5 @@ "documentation": "https://www.home-assistant.io/integrations/oncue", "iot_class": "cloud_polling", "loggers": ["aiooncue"], - "requirements": ["aiooncue==0.3.7"] + "requirements": ["aiooncue==0.3.9"] } diff --git a/homeassistant/components/onedrive/__init__.py b/homeassistant/components/onedrive/__init__.py index 4ae5ac73560..ef7ddd04da6 100644 --- a/homeassistant/components/onedrive/__init__.py +++ b/homeassistant/components/onedrive/__init__.py @@ -5,34 +5,33 @@ from __future__ import annotations from dataclasses import dataclass import logging -from kiota_abstractions.api_error import APIError -from kiota_abstractions.authentication import BaseBearerTokenAuthenticationProvider -from msgraph import GraphRequestAdapter, GraphServiceClient -from msgraph.generated.drives.item.items.items_request_builder import ( - ItemsRequestBuilder, +from onedrive_personal_sdk import OneDriveClient +from onedrive_personal_sdk.exceptions import ( + AuthenticationError, + HttpRequestException, + OneDriveException, ) -from msgraph.generated.models.drive_item import DriveItem -from msgraph.generated.models.folder import Folder from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady +from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.config_entry_oauth2_flow import ( OAuth2Session, async_get_config_entry_implementation, ) -from homeassistant.helpers.httpx_client import create_async_httpx_client from homeassistant.helpers.instance_id import async_get as async_get_instance_id from .api import OneDriveConfigEntryAccessTokenProvider -from .const import DATA_BACKUP_AGENT_LISTENERS, DOMAIN, OAUTH_SCOPES +from .const import DATA_BACKUP_AGENT_LISTENERS, DOMAIN @dataclass class OneDriveRuntimeData: """Runtime data for the OneDrive integration.""" - items: ItemsRequestBuilder + client: OneDriveClient + token_provider: OneDriveConfigEntryAccessTokenProvider backup_folder_id: str @@ -47,29 +46,18 @@ async def async_setup_entry(hass: HomeAssistant, entry: OneDriveConfigEntry) -> session = OAuth2Session(hass, entry, implementation) - auth_provider = BaseBearerTokenAuthenticationProvider( - access_token_provider=OneDriveConfigEntryAccessTokenProvider(session) - ) - adapter = GraphRequestAdapter( - auth_provider=auth_provider, - client=create_async_httpx_client(hass, follow_redirects=True), - ) + token_provider = OneDriveConfigEntryAccessTokenProvider(session) - graph_client = GraphServiceClient( - request_adapter=adapter, - scopes=OAUTH_SCOPES, - ) - assert entry.unique_id - drive_item = graph_client.drives.by_drive_id(entry.unique_id) + client = OneDriveClient(token_provider, async_get_clientsession(hass)) # get approot, will be created automatically if it does not exist try: - approot = await drive_item.special.by_drive_item_id("approot").get() - except APIError as err: - if err.response_status_code == 403: - raise ConfigEntryAuthFailed( - translation_domain=DOMAIN, translation_key="authentication_failed" - ) from err + approot = await client.get_approot() + except AuthenticationError as err: + raise ConfigEntryAuthFailed( + translation_domain=DOMAIN, translation_key="authentication_failed" + ) from err + except (HttpRequestException, OneDriveException, TimeoutError) as err: _LOGGER.debug("Failed to get approot", exc_info=True) raise ConfigEntryNotReady( translation_domain=DOMAIN, @@ -77,24 +65,24 @@ async def async_setup_entry(hass: HomeAssistant, entry: OneDriveConfigEntry) -> translation_placeholders={"folder": "approot"}, ) from err - if approot is None or not approot.id: - _LOGGER.debug("Failed to get approot, was None") + instance_id = await async_get_instance_id(hass) + backup_folder_name = f"backups_{instance_id[:8]}" + try: + backup_folder = await client.create_folder( + parent_id=approot.id, name=backup_folder_name + ) + except (HttpRequestException, OneDriveException, TimeoutError) as err: + _LOGGER.debug("Failed to create backup folder", exc_info=True) raise ConfigEntryNotReady( translation_domain=DOMAIN, translation_key="failed_to_get_folder", - translation_placeholders={"folder": "approot"}, - ) - - instance_id = await async_get_instance_id(hass) - backup_folder_id = await _async_create_folder_if_not_exists( - items=drive_item.items, - base_folder_id=approot.id, - folder=f"backups_{instance_id[:8]}", - ) + translation_placeholders={"folder": backup_folder_name}, + ) from err entry.runtime_data = OneDriveRuntimeData( - items=drive_item.items, - backup_folder_id=backup_folder_id, + client=client, + token_provider=token_provider, + backup_folder_id=backup_folder.id, ) _async_notify_backup_listeners_soon(hass) @@ -116,54 +104,3 @@ def _async_notify_backup_listeners(hass: HomeAssistant) -> None: @callback def _async_notify_backup_listeners_soon(hass: HomeAssistant) -> None: hass.loop.call_soon(_async_notify_backup_listeners, hass) - - -async def _async_create_folder_if_not_exists( - items: ItemsRequestBuilder, - base_folder_id: str, - folder: str, -) -> str: - """Check if a folder exists and create it if it does not exist.""" - folder_item: DriveItem | None = None - - try: - folder_item = await items.by_drive_item_id(f"{base_folder_id}:/{folder}:").get() - except APIError as err: - if err.response_status_code != 404: - _LOGGER.debug("Failed to get folder %s", folder, exc_info=True) - raise ConfigEntryNotReady( - translation_domain=DOMAIN, - translation_key="failed_to_get_folder", - translation_placeholders={"folder": folder}, - ) from err - # is 404 not found, create folder - _LOGGER.debug("Creating folder %s", folder) - request_body = DriveItem( - name=folder, - folder=Folder(), - additional_data={ - "@microsoft_graph_conflict_behavior": "fail", - }, - ) - try: - folder_item = await items.by_drive_item_id(base_folder_id).children.post( - request_body - ) - except APIError as create_err: - _LOGGER.debug("Failed to create folder %s", folder, exc_info=True) - raise ConfigEntryNotReady( - translation_domain=DOMAIN, - translation_key="failed_to_create_folder", - translation_placeholders={"folder": folder}, - ) from create_err - _LOGGER.debug("Created folder %s", folder) - else: - _LOGGER.debug("Found folder %s", folder) - if folder_item is None or not folder_item.id: - _LOGGER.debug("Failed to get folder %s, was None", folder) - raise ConfigEntryNotReady( - translation_domain=DOMAIN, - translation_key="failed_to_get_folder", - translation_placeholders={"folder": folder}, - ) - return folder_item.id diff --git a/homeassistant/components/onedrive/api.py b/homeassistant/components/onedrive/api.py index 934a4f74ec9..d8f6ea188f3 100644 --- a/homeassistant/components/onedrive/api.py +++ b/homeassistant/components/onedrive/api.py @@ -1,28 +1,14 @@ """API for OneDrive bound to Home Assistant OAuth.""" -from typing import Any, cast +from typing import cast -from kiota_abstractions.authentication import AccessTokenProvider, AllowedHostsValidator +from onedrive_personal_sdk import TokenProvider from homeassistant.const import CONF_ACCESS_TOKEN from homeassistant.helpers import config_entry_oauth2_flow -class OneDriveAccessTokenProvider(AccessTokenProvider): - """Provide OneDrive authentication tied to an OAuth2 based config entry.""" - - def __init__(self) -> None: - """Initialize OneDrive auth.""" - super().__init__() - # currently allowing all hosts - self._allowed_hosts_validator = AllowedHostsValidator(allowed_hosts=[]) - - def get_allowed_hosts_validator(self) -> AllowedHostsValidator: - """Retrieve the allowed hosts validator.""" - return self._allowed_hosts_validator - - -class OneDriveConfigFlowAccessTokenProvider(OneDriveAccessTokenProvider): +class OneDriveConfigFlowAccessTokenProvider(TokenProvider): """Provide OneDrive authentication tied to an OAuth2 based config entry.""" def __init__(self, token: str) -> None: @@ -30,14 +16,12 @@ class OneDriveConfigFlowAccessTokenProvider(OneDriveAccessTokenProvider): super().__init__() self._token = token - async def get_authorization_token( # pylint: disable=dangerous-default-value - self, uri: str, additional_authentication_context: dict[str, Any] = {} - ) -> str: - """Return a valid authorization token.""" + def async_get_access_token(self) -> str: + """Return a valid access token.""" return self._token -class OneDriveConfigEntryAccessTokenProvider(OneDriveAccessTokenProvider): +class OneDriveConfigEntryAccessTokenProvider(TokenProvider): """Provide OneDrive authentication tied to an OAuth2 based config entry.""" def __init__(self, oauth_session: config_entry_oauth2_flow.OAuth2Session) -> None: @@ -45,9 +29,6 @@ class OneDriveConfigEntryAccessTokenProvider(OneDriveAccessTokenProvider): super().__init__() self._oauth_session = oauth_session - async def get_authorization_token( # pylint: disable=dangerous-default-value - self, uri: str, additional_authentication_context: dict[str, Any] = {} - ) -> str: - """Return a valid authorization token.""" - await self._oauth_session.async_ensure_token_valid() + def async_get_access_token(self) -> str: + """Return a valid access token.""" return cast(str, self._oauth_session.token[CONF_ACCESS_TOKEN]) diff --git a/homeassistant/components/onedrive/backup.py b/homeassistant/components/onedrive/backup.py index a7bac5d01fc..43eac020538 100644 --- a/homeassistant/components/onedrive/backup.py +++ b/homeassistant/components/onedrive/backup.py @@ -2,37 +2,22 @@ from __future__ import annotations -import asyncio from collections.abc import AsyncIterator, Callable, Coroutine from functools import wraps import html import json import logging -from typing import Any, Concatenate, cast +from typing import Any, Concatenate -from httpx import Response, TimeoutException -from kiota_abstractions.api_error import APIError -from kiota_abstractions.authentication import AnonymousAuthenticationProvider -from kiota_abstractions.headers_collection import HeadersCollection -from kiota_abstractions.method import Method -from kiota_abstractions.native_response_handler import NativeResponseHandler -from kiota_abstractions.request_information import RequestInformation -from kiota_http.middleware.options import ResponseHandlerOption -from msgraph import GraphRequestAdapter -from msgraph.generated.drives.item.items.item.content.content_request_builder import ( - ContentRequestBuilder, +from aiohttp import ClientTimeout +from onedrive_personal_sdk.clients.large_file_upload import LargeFileUploadClient +from onedrive_personal_sdk.exceptions import ( + AuthenticationError, + HashMismatchError, + OneDriveException, ) -from msgraph.generated.drives.item.items.item.create_upload_session.create_upload_session_post_request_body import ( - CreateUploadSessionPostRequestBody, -) -from msgraph.generated.drives.item.items.item.drive_item_item_request_builder import ( - DriveItemItemRequestBuilder, -) -from msgraph.generated.models.drive_item import DriveItem -from msgraph.generated.models.drive_item_uploadable_properties import ( - DriveItemUploadableProperties, -) -from msgraph_core.models import LargeFileUploadSession +from onedrive_personal_sdk.models.items import File, Folder, ItemUpdate +from onedrive_personal_sdk.models.upload import FileInfo from homeassistant.components.backup import ( AgentBackup, @@ -41,14 +26,14 @@ from homeassistant.components.backup import ( suggested_filename, ) from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers.httpx_client import get_async_client +from homeassistant.helpers.aiohttp_client import async_get_clientsession from . import OneDriveConfigEntry from .const import DATA_BACKUP_AGENT_LISTENERS, DOMAIN _LOGGER = logging.getLogger(__name__) UPLOAD_CHUNK_SIZE = 16 * 320 * 1024 # 5.2MB -MAX_RETRIES = 5 +TIMEOUT = ClientTimeout(connect=10, total=43200) # 12 hours async def async_get_backup_agents( @@ -92,18 +77,18 @@ def handle_backup_errors[_R, **P]( ) -> _R: try: return await func(self, *args, **kwargs) - except APIError as err: - if err.response_status_code == 403: - self._entry.async_start_reauth(self._hass) + except AuthenticationError as err: + self._entry.async_start_reauth(self._hass) + raise BackupAgentError("Authentication error") from err + except OneDriveException as err: _LOGGER.error( - "Error during backup in %s: Status %s, message %s", + "Error during backup in %s:, message %s", func.__name__, - err.response_status_code, - err.message, + err, ) _LOGGER.debug("Full error: %s", err, exc_info=True) raise BackupAgentError("Backup operation failed") from err - except TimeoutException as err: + except TimeoutError as err: _LOGGER.error( "Error during backup in %s: Timeout", func.__name__, @@ -123,7 +108,8 @@ class OneDriveBackupAgent(BackupAgent): super().__init__() self._hass = hass self._entry = entry - self._items = entry.runtime_data.items + self._client = entry.runtime_data.client + self._token_provider = entry.runtime_data.token_provider self._folder_id = entry.runtime_data.backup_folder_id self.name = entry.title assert entry.unique_id @@ -134,24 +120,12 @@ class OneDriveBackupAgent(BackupAgent): self, backup_id: str, **kwargs: Any ) -> AsyncIterator[bytes]: """Download a backup file.""" - # this forces the query to return a raw httpx response, but breaks typing - backup = await self._find_item_by_backup_id(backup_id) - if backup is None or backup.id is None: + item = await self._find_item_by_backup_id(backup_id) + if item is None: raise BackupAgentError("Backup not found") - request_config = ( - ContentRequestBuilder.ContentRequestBuilderGetRequestConfiguration( - options=[ResponseHandlerOption(NativeResponseHandler())], - ) - ) - response = cast( - Response, - await self._items.by_drive_item_id(backup.id).content.get( - request_configuration=request_config - ), - ) - - return response.aiter_bytes(chunk_size=1024) + stream = await self._client.download_drive_item(item.id, timeout=TIMEOUT) + return stream.iter_chunked(1024) @handle_backup_errors async def async_upload_backup( @@ -163,27 +137,20 @@ class OneDriveBackupAgent(BackupAgent): ) -> None: """Upload a backup.""" - # upload file in chunks to support large files - upload_session_request_body = CreateUploadSessionPostRequestBody( - item=DriveItemUploadableProperties( - additional_data={ - "@microsoft.graph.conflictBehavior": "fail", - }, + file = FileInfo( + suggested_filename(backup), + backup.size, + self._folder_id, + await open_stream(), + ) + try: + item = await LargeFileUploadClient.upload( + self._token_provider, file, session=async_get_clientsession(self._hass) ) - ) - file_item = self._get_backup_file_item(suggested_filename(backup)) - upload_session = await file_item.create_upload_session.post( - upload_session_request_body - ) - - if upload_session is None or upload_session.upload_url is None: + except HashMismatchError as err: raise BackupAgentError( - translation_domain=DOMAIN, translation_key="backup_no_upload_session" - ) - - await self._upload_file( - upload_session.upload_url, await open_stream(), backup.size - ) + "Hash validation failed, backup file might be corrupt" + ) from err # store metadata in description backup_dict = backup.as_dict() @@ -191,7 +158,10 @@ class OneDriveBackupAgent(BackupAgent): description = json.dumps(backup_dict) _LOGGER.debug("Creating metadata: %s", description) - await file_item.patch(DriveItem(description=description)) + await self._client.update_drive_item( + path_or_id=item.id, + data=ItemUpdate(description=description), + ) @handle_backup_errors async def async_delete_backup( @@ -200,35 +170,31 @@ class OneDriveBackupAgent(BackupAgent): **kwargs: Any, ) -> None: """Delete a backup file.""" - backup = await self._find_item_by_backup_id(backup_id) - if backup is None or backup.id is None: + item = await self._find_item_by_backup_id(backup_id) + if item is None: return - await self._items.by_drive_item_id(backup.id).delete() + await self._client.delete_drive_item(item.id) @handle_backup_errors async def async_list_backups(self, **kwargs: Any) -> list[AgentBackup]: """List backups.""" - backups: list[AgentBackup] = [] - items = await self._items.by_drive_item_id(f"{self._folder_id}").children.get() - if items and (values := items.value): - for item in values: - if (description := item.description) is None: - continue - if "homeassistant_version" in description: - backups.append(self._backup_from_description(description)) - return backups + return [ + self._backup_from_description(item.description) + for item in await self._client.list_drive_items(self._folder_id) + if item.description and "homeassistant_version" in item.description + ] @handle_backup_errors async def async_get_backup( self, backup_id: str, **kwargs: Any ) -> AgentBackup | None: """Return a backup.""" - backup = await self._find_item_by_backup_id(backup_id) - if backup is None: - return None - - assert backup.description # already checked in _find_item_by_backup_id - return self._backup_from_description(backup.description) + item = await self._find_item_by_backup_id(backup_id) + return ( + self._backup_from_description(item.description) + if item and item.description + else None + ) def _backup_from_description(self, description: str) -> AgentBackup: """Create a backup object from a description.""" @@ -237,91 +203,13 @@ class OneDriveBackupAgent(BackupAgent): ) # OneDrive encodes the description on save automatically return AgentBackup.from_dict(json.loads(description)) - async def _find_item_by_backup_id(self, backup_id: str) -> DriveItem | None: - """Find a backup item by its backup ID.""" - - items = await self._items.by_drive_item_id(f"{self._folder_id}").children.get() - if items and (values := items.value): - for item in values: - if (description := item.description) is None: - continue - if backup_id in description: - return item - return None - - def _get_backup_file_item(self, backup_id: str) -> DriveItemItemRequestBuilder: - return self._items.by_drive_item_id(f"{self._folder_id}:/{backup_id}:") - - async def _upload_file( - self, upload_url: str, stream: AsyncIterator[bytes], total_size: int - ) -> None: - """Use custom large file upload; SDK does not support stream.""" - - adapter = GraphRequestAdapter( - auth_provider=AnonymousAuthenticationProvider(), - client=get_async_client(self._hass), + async def _find_item_by_backup_id(self, backup_id: str) -> File | Folder | None: + """Find an item by backup ID.""" + return next( + ( + item + for item in await self._client.list_drive_items(self._folder_id) + if item.description and backup_id in item.description + ), + None, ) - - async def async_upload( - start: int, end: int, chunk_data: bytes - ) -> LargeFileUploadSession: - info = RequestInformation() - info.url = upload_url - info.http_method = Method.PUT - info.headers = HeadersCollection() - info.headers.try_add("Content-Range", f"bytes {start}-{end}/{total_size}") - info.headers.try_add("Content-Length", str(len(chunk_data))) - info.headers.try_add("Content-Type", "application/octet-stream") - _LOGGER.debug(info.headers.get_all()) - info.set_stream_content(chunk_data) - result = await adapter.send_async(info, LargeFileUploadSession, {}) - _LOGGER.debug("Next expected range: %s", result.next_expected_ranges) - return result - - start = 0 - buffer: list[bytes] = [] - buffer_size = 0 - retries = 0 - - async for chunk in stream: - buffer.append(chunk) - buffer_size += len(chunk) - if buffer_size >= UPLOAD_CHUNK_SIZE: - chunk_data = b"".join(buffer) - uploaded_chunks = 0 - while ( - buffer_size > UPLOAD_CHUNK_SIZE - ): # Loop in case the buffer is >= UPLOAD_CHUNK_SIZE * 2 - slice_start = uploaded_chunks * UPLOAD_CHUNK_SIZE - try: - await async_upload( - start, - start + UPLOAD_CHUNK_SIZE - 1, - chunk_data[slice_start : slice_start + UPLOAD_CHUNK_SIZE], - ) - except APIError as err: - if ( - err.response_status_code and err.response_status_code < 500 - ): # no retry on 4xx errors - raise - if retries < MAX_RETRIES: - await asyncio.sleep(2**retries) - retries += 1 - continue - raise - except TimeoutException: - if retries < MAX_RETRIES: - retries += 1 - continue - raise - retries = 0 - start += UPLOAD_CHUNK_SIZE - uploaded_chunks += 1 - buffer_size -= UPLOAD_CHUNK_SIZE - buffer = [chunk_data[UPLOAD_CHUNK_SIZE * uploaded_chunks :]] - - # upload the remaining bytes - if buffer: - _LOGGER.debug("Last chunk") - chunk_data = b"".join(buffer) - await async_upload(start, start + len(chunk_data) - 1, chunk_data) diff --git a/homeassistant/components/onedrive/config_flow.py b/homeassistant/components/onedrive/config_flow.py index 09c0d1b44cc..cbdf59648b9 100644 --- a/homeassistant/components/onedrive/config_flow.py +++ b/homeassistant/components/onedrive/config_flow.py @@ -4,16 +4,13 @@ from collections.abc import Mapping import logging from typing import Any, cast -from kiota_abstractions.api_error import APIError -from kiota_abstractions.authentication import BaseBearerTokenAuthenticationProvider -from kiota_abstractions.method import Method -from kiota_abstractions.request_information import RequestInformation -from msgraph import GraphRequestAdapter, GraphServiceClient +from onedrive_personal_sdk.clients.client import OneDriveClient +from onedrive_personal_sdk.exceptions import OneDriveException from homeassistant.config_entries import SOURCE_REAUTH, ConfigFlowResult from homeassistant.const import CONF_ACCESS_TOKEN, CONF_TOKEN +from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.config_entry_oauth2_flow import AbstractOAuth2FlowHandler -from homeassistant.helpers.httpx_client import get_async_client from .api import OneDriveConfigFlowAccessTokenProvider from .const import DOMAIN, OAUTH_SCOPES @@ -39,48 +36,24 @@ class OneDriveConfigFlow(AbstractOAuth2FlowHandler, domain=DOMAIN): data: dict[str, Any], ) -> ConfigFlowResult: """Handle the initial step.""" - auth_provider = BaseBearerTokenAuthenticationProvider( - access_token_provider=OneDriveConfigFlowAccessTokenProvider( - cast(str, data[CONF_TOKEN][CONF_ACCESS_TOKEN]) - ) - ) - adapter = GraphRequestAdapter( - auth_provider=auth_provider, - client=get_async_client(self.hass), + token_provider = OneDriveConfigFlowAccessTokenProvider( + cast(str, data[CONF_TOKEN][CONF_ACCESS_TOKEN]) ) - graph_client = GraphServiceClient( - request_adapter=adapter, - scopes=OAUTH_SCOPES, + graph_client = OneDriveClient( + token_provider, async_get_clientsession(self.hass) ) - # need to get adapter from client, as client changes it - request_adapter = cast(GraphRequestAdapter, graph_client.request_adapter) - - request_info = RequestInformation( - method=Method.GET, - url_template="{+baseurl}/me/drive/special/approot", - path_parameters={}, - ) - parent_span = request_adapter.start_tracing_span(request_info, "get_approot") - - # get the OneDrive id - # use low level methods, to avoid files.read permissions - # which would be required by drives.me.get() try: - response = await request_adapter.get_http_response_message( - request_info=request_info, parent_span=parent_span - ) - except APIError: + approot = await graph_client.get_approot() + except OneDriveException: self.logger.exception("Failed to connect to OneDrive") return self.async_abort(reason="connection_error") except Exception: self.logger.exception("Unknown error") return self.async_abort(reason="unknown") - drive: dict = response.json() - - await self.async_set_unique_id(drive["parentReference"]["driveId"]) + await self.async_set_unique_id(approot.parent_reference.drive_id) if self.source == SOURCE_REAUTH: reauth_entry = self._get_reauth_entry() @@ -94,10 +67,11 @@ class OneDriveConfigFlow(AbstractOAuth2FlowHandler, domain=DOMAIN): self._abort_if_unique_id_configured() - user = drive.get("createdBy", {}).get("user", {}).get("displayName") - - title = f"{user}'s OneDrive" if user else "OneDrive" - + title = ( + f"{approot.created_by.user.display_name}'s OneDrive" + if approot.created_by.user and approot.created_by.user.display_name + else "OneDrive" + ) return self.async_create_entry(title=title, data=data) async def async_step_reauth( diff --git a/homeassistant/components/onedrive/manifest.json b/homeassistant/components/onedrive/manifest.json index 056e31864a4..47eb48742be 100644 --- a/homeassistant/components/onedrive/manifest.json +++ b/homeassistant/components/onedrive/manifest.json @@ -7,7 +7,7 @@ "documentation": "https://www.home-assistant.io/integrations/onedrive", "integration_type": "service", "iot_class": "cloud_polling", - "loggers": ["msgraph", "msgraph-core", "kiota"], + "loggers": ["onedrive_personal_sdk"], "quality_scale": "bronze", - "requirements": ["msgraph-sdk==1.16.0"] + "requirements": ["onedrive-personal-sdk==0.0.4"] } diff --git a/homeassistant/components/onedrive/strings.json b/homeassistant/components/onedrive/strings.json index 9cbdb2bdeae..7686e83e2a5 100644 --- a/homeassistant/components/onedrive/strings.json +++ b/homeassistant/components/onedrive/strings.json @@ -23,31 +23,18 @@ "connection_error": "Failed to connect to OneDrive.", "wrong_drive": "New account does not contain previously configured OneDrive.", "unknown": "[%key:common::config_flow::error::unknown%]", - "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]", - "failed_to_create_folder": "Failed to create backup folder" + "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]" }, "create_entry": { "default": "[%key:common::config_flow::create_entry::authenticated%]" } }, "exceptions": { - "backup_not_found": { - "message": "Backup not found" - }, - "backup_no_content": { - "message": "Backup has no content" - }, - "backup_no_upload_session": { - "message": "Failed to start backup upload" - }, "authentication_failed": { "message": "Authentication failed" }, "failed_to_get_folder": { "message": "Failed to get {folder} folder" - }, - "failed_to_create_folder": { - "message": "Failed to create {folder} folder" } } } diff --git a/homeassistant/components/openai_conversation/config_flow.py b/homeassistant/components/openai_conversation/config_flow.py index 2a1764e6b5e..c631884ea0b 100644 --- a/homeassistant/components/openai_conversation/config_flow.py +++ b/homeassistant/components/openai_conversation/config_flow.py @@ -24,6 +24,7 @@ from homeassistant.helpers.selector import ( SelectOptionDict, SelectSelector, SelectSelectorConfig, + SelectSelectorMode, TemplateSelector, ) from homeassistant.helpers.typing import VolDictType @@ -32,14 +33,17 @@ from .const import ( CONF_CHAT_MODEL, CONF_MAX_TOKENS, CONF_PROMPT, + CONF_REASONING_EFFORT, CONF_RECOMMENDED, CONF_TEMPERATURE, CONF_TOP_P, DOMAIN, RECOMMENDED_CHAT_MODEL, RECOMMENDED_MAX_TOKENS, + RECOMMENDED_REASONING_EFFORT, RECOMMENDED_TEMPERATURE, RECOMMENDED_TOP_P, + UNSUPPORTED_MODELS, ) _LOGGER = logging.getLogger(__name__) @@ -124,26 +128,32 @@ class OpenAIOptionsFlow(OptionsFlow): ) -> ConfigFlowResult: """Manage the options.""" options: dict[str, Any] | MappingProxyType[str, Any] = self.config_entry.options + errors: dict[str, str] = {} if user_input is not None: if user_input[CONF_RECOMMENDED] == self.last_rendered_recommended: if user_input[CONF_LLM_HASS_API] == "none": user_input.pop(CONF_LLM_HASS_API) - return self.async_create_entry(title="", data=user_input) - # Re-render the options again, now with the recommended options shown/hidden - self.last_rendered_recommended = user_input[CONF_RECOMMENDED] + if user_input.get(CONF_CHAT_MODEL) in UNSUPPORTED_MODELS: + errors[CONF_CHAT_MODEL] = "model_not_supported" + else: + return self.async_create_entry(title="", data=user_input) + else: + # Re-render the options again, now with the recommended options shown/hidden + self.last_rendered_recommended = user_input[CONF_RECOMMENDED] - options = { - CONF_RECOMMENDED: user_input[CONF_RECOMMENDED], - CONF_PROMPT: user_input[CONF_PROMPT], - CONF_LLM_HASS_API: user_input[CONF_LLM_HASS_API], - } + options = { + CONF_RECOMMENDED: user_input[CONF_RECOMMENDED], + CONF_PROMPT: user_input[CONF_PROMPT], + CONF_LLM_HASS_API: user_input[CONF_LLM_HASS_API], + } schema = openai_config_option_schema(self.hass, options) return self.async_show_form( step_id="init", data_schema=vol.Schema(schema), + errors=errors, ) @@ -210,6 +220,17 @@ def openai_config_option_schema( description={"suggested_value": options.get(CONF_TEMPERATURE)}, default=RECOMMENDED_TEMPERATURE, ): NumberSelector(NumberSelectorConfig(min=0, max=2, step=0.05)), + vol.Optional( + CONF_REASONING_EFFORT, + description={"suggested_value": options.get(CONF_REASONING_EFFORT)}, + default=RECOMMENDED_REASONING_EFFORT, + ): SelectSelector( + SelectSelectorConfig( + options=["low", "medium", "high"], + translation_key="reasoning_effort", + mode=SelectSelectorMode.DROPDOWN, + ) + ), } ) return schema diff --git a/homeassistant/components/openai_conversation/const.py b/homeassistant/components/openai_conversation/const.py index e8ee003fcca..793e021e332 100644 --- a/homeassistant/components/openai_conversation/const.py +++ b/homeassistant/components/openai_conversation/const.py @@ -15,3 +15,17 @@ CONF_TOP_P = "top_p" RECOMMENDED_TOP_P = 1.0 CONF_TEMPERATURE = "temperature" RECOMMENDED_TEMPERATURE = 1.0 +CONF_REASONING_EFFORT = "reasoning_effort" +RECOMMENDED_REASONING_EFFORT = "low" + +UNSUPPORTED_MODELS = [ + "o1-mini", + "o1-mini-2024-09-12", + "o1-preview", + "o1-preview-2024-09-12", + "gpt-4o-realtime-preview", + "gpt-4o-realtime-preview-2024-12-17", + "gpt-4o-realtime-preview-2024-10-01", + "gpt-4o-mini-realtime-preview", + "gpt-4o-mini-realtime-preview-2024-12-17", +] diff --git a/homeassistant/components/openai_conversation/conversation.py b/homeassistant/components/openai_conversation/conversation.py index 2f35bea97e2..73dafa1c48d 100644 --- a/homeassistant/components/openai_conversation/conversation.py +++ b/homeassistant/components/openai_conversation/conversation.py @@ -23,7 +23,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_LLM_HASS_API, MATCH_ALL from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers import device_registry as dr, intent, llm +from homeassistant.helpers import chat_session, device_registry as dr, intent, llm from homeassistant.helpers.entity_platform import AddEntitiesCallback from . import OpenAIConfigEntry @@ -31,12 +31,14 @@ from .const import ( CONF_CHAT_MODEL, CONF_MAX_TOKENS, CONF_PROMPT, + CONF_REASONING_EFFORT, CONF_TEMPERATURE, CONF_TOP_P, DOMAIN, LOGGER, RECOMMENDED_CHAT_MODEL, RECOMMENDED_MAX_TOKENS, + RECOMMENDED_REASONING_EFFORT, RECOMMENDED_TEMPERATURE, RECOMMENDED_TOP_P, ) @@ -68,7 +70,9 @@ def _format_tool( return ChatCompletionToolParam(type="function", function=tool_spec) -def _message_convert(message: ChatCompletionMessage) -> ChatCompletionMessageParam: +def _convert_message_to_param( + message: ChatCompletionMessage, +) -> ChatCompletionMessageParam: """Convert from class to TypedDict.""" tool_calls: list[ChatCompletionMessageToolCallParam] = [] if message.tool_calls: @@ -92,17 +96,42 @@ def _message_convert(message: ChatCompletionMessage) -> ChatCompletionMessagePar return param -def _chat_message_convert( - message: conversation.Content - | conversation.NativeContent[ChatCompletionMessageParam], +def _convert_content_to_param( + content: conversation.Content, ) -> ChatCompletionMessageParam: """Convert any native chat message for this agent to the native format.""" - if message.role == "native": - # mypy doesn't understand that checking role ensures content type - return message.content # type: ignore[return-value] - return cast( - ChatCompletionMessageParam, - {"role": message.role, "content": message.content}, + if content.role == "tool_result": + assert type(content) is conversation.ToolResultContent + return ChatCompletionToolMessageParam( + role="tool", + tool_call_id=content.tool_call_id, + content=json.dumps(content.tool_result), + ) + if content.role != "assistant" or not content.tool_calls: # type: ignore[union-attr] + role = content.role + if role == "system": + role = "developer" + return cast( + ChatCompletionMessageParam, + {"role": content.role, "content": content.content}, # type: ignore[union-attr] + ) + + # Handle the Assistant content including tool calls. + assert type(content) is conversation.AssistantContent + return ChatCompletionAssistantMessageParam( + role="assistant", + content=content.content, + tool_calls=[ + ChatCompletionMessageToolCallParam( + id=tool_call.id, + function=Function( + arguments=json.dumps(tool_call.tool_args), + name=tool_call.tool_name, + ), + type="function", + ) + for tool_call in content.tool_calls + ], ) @@ -155,22 +184,25 @@ class OpenAIConversationEntity( self, user_input: conversation.ConversationInput ) -> conversation.ConversationResult: """Process a sentence.""" - async with conversation.async_get_chat_session( - self.hass, user_input - ) as session: - return await self._async_handle_message(user_input, session) + with ( + chat_session.async_get_chat_session( + self.hass, user_input.conversation_id + ) as session, + conversation.async_get_chat_log(self.hass, session, user_input) as chat_log, + ): + return await self._async_handle_message(user_input, chat_log) async def _async_handle_message( self, user_input: conversation.ConversationInput, - session: conversation.ChatSession[ChatCompletionMessageParam], + chat_log: conversation.ChatLog, ) -> conversation.ConversationResult: """Call the API.""" assert user_input.agent_id options = self.entry.options try: - await session.async_update_llm_data( + await chat_log.async_update_llm_data( DOMAIN, user_input, options.get(CONF_LLM_HASS_API), @@ -180,73 +212,77 @@ class OpenAIConversationEntity( return err.as_conversation_result() tools: list[ChatCompletionToolParam] | None = None - if session.llm_api: + if chat_log.llm_api: tools = [ - _format_tool(tool, session.llm_api.custom_serializer) - for tool in session.llm_api.tools + _format_tool(tool, chat_log.llm_api.custom_serializer) + for tool in chat_log.llm_api.tools ] - messages = [ - _chat_message_convert(message) for message in session.async_get_messages() - ] + model = options.get(CONF_CHAT_MODEL, RECOMMENDED_CHAT_MODEL) + messages = [_convert_content_to_param(content) for content in chat_log.content] client = self.entry.runtime_data # To prevent infinite loops, we limit the number of iterations for _iteration in range(MAX_TOOL_ITERATIONS): - try: - result = await client.chat.completions.create( - model=options.get(CONF_CHAT_MODEL, RECOMMENDED_CHAT_MODEL), - messages=messages, - tools=tools or NOT_GIVEN, - max_tokens=options.get(CONF_MAX_TOKENS, RECOMMENDED_MAX_TOKENS), - top_p=options.get(CONF_TOP_P, RECOMMENDED_TOP_P), - temperature=options.get(CONF_TEMPERATURE, RECOMMENDED_TEMPERATURE), - user=session.conversation_id, + model_args = { + "model": model, + "messages": messages, + "tools": tools or NOT_GIVEN, + "max_completion_tokens": options.get( + CONF_MAX_TOKENS, RECOMMENDED_MAX_TOKENS + ), + "top_p": options.get(CONF_TOP_P, RECOMMENDED_TOP_P), + "temperature": options.get(CONF_TEMPERATURE, RECOMMENDED_TEMPERATURE), + "user": chat_log.conversation_id, + } + + if model.startswith("o"): + model_args["reasoning_effort"] = options.get( + CONF_REASONING_EFFORT, RECOMMENDED_REASONING_EFFORT ) + + try: + result = await client.chat.completions.create(**model_args) except openai.OpenAIError as err: LOGGER.error("Error talking to OpenAI: %s", err) raise HomeAssistantError("Error talking to OpenAI") from err LOGGER.debug("Response %s", result) response = result.choices[0].message - messages.append(_message_convert(response)) + messages.append(_convert_message_to_param(response)) - session.async_add_message( - conversation.Content( - role=response.role, - agent_id=user_input.agent_id, - content=response.content or "", - ), + tool_calls: list[llm.ToolInput] | None = None + if response.tool_calls: + tool_calls = [ + llm.ToolInput( + id=tool_call.id, + tool_name=tool_call.function.name, + tool_args=json.loads(tool_call.function.arguments), + ) + for tool_call in response.tool_calls + ] + + messages.extend( + [ + _convert_content_to_param(tool_response) + async for tool_response in chat_log.async_add_assistant_content( + conversation.AssistantContent( + agent_id=user_input.agent_id, + content=response.content or "", + tool_calls=tool_calls, + ) + ) + ] ) - if not response.tool_calls or not session.llm_api: + if not tool_calls: break - for tool_call in response.tool_calls: - tool_input = llm.ToolInput( - tool_name=tool_call.function.name, - tool_args=json.loads(tool_call.function.arguments), - ) - tool_response = await session.async_call_tool(tool_input) - messages.append( - ChatCompletionToolMessageParam( - role="tool", - tool_call_id=tool_call.id, - content=json.dumps(tool_response), - ) - ) - session.async_add_message( - conversation.NativeContent( - agent_id=user_input.agent_id, - content=messages[-1], - ) - ) - intent_response = intent.IntentResponse(language=user_input.language) intent_response.async_set_speech(response.content or "") return conversation.ConversationResult( - response=intent_response, conversation_id=session.conversation_id + response=intent_response, conversation_id=chat_log.conversation_id ) async def _async_entry_update_listener( diff --git a/homeassistant/components/openai_conversation/manifest.json b/homeassistant/components/openai_conversation/manifest.json index 9b70246117c..a7aa7884dc4 100644 --- a/homeassistant/components/openai_conversation/manifest.json +++ b/homeassistant/components/openai_conversation/manifest.json @@ -8,5 +8,5 @@ "documentation": "https://www.home-assistant.io/integrations/openai_conversation", "integration_type": "service", "iot_class": "cloud_polling", - "requirements": ["openai==1.59.9"] + "requirements": ["openai==1.61.0"] } diff --git a/homeassistant/components/openai_conversation/strings.json b/homeassistant/components/openai_conversation/strings.json index 2477155e3cb..b8768f8abbe 100644 --- a/homeassistant/components/openai_conversation/strings.json +++ b/homeassistant/components/openai_conversation/strings.json @@ -23,12 +23,26 @@ "temperature": "Temperature", "top_p": "Top P", "llm_hass_api": "[%key:common::config_flow::data::llm_hass_api%]", - "recommended": "Recommended model settings" + "recommended": "Recommended model settings", + "reasoning_effort": "Reasoning effort" }, "data_description": { - "prompt": "Instruct how the LLM should respond. This can be a template." + "prompt": "Instruct how the LLM should respond. This can be a template.", + "reasoning_effort": "How many reasoning tokens the model should generate before creating a response to the prompt (for certain reasoning models)" } } + }, + "error": { + "model_not_supported": "This model is not supported, please select a different model" + } + }, + "selector": { + "reasoning_effort": { + "options": { + "low": "Low", + "medium": "Medium", + "high": "High" + } } }, "services": { diff --git a/homeassistant/components/oralb/config_flow.py b/homeassistant/components/oralb/config_flow.py index ab5d919194e..bac2d32bb2f 100644 --- a/homeassistant/components/oralb/config_flow.py +++ b/homeassistant/components/oralb/config_flow.py @@ -72,7 +72,7 @@ class OralBConfigFlow(ConfigFlow, domain=DOMAIN): title=self._discovered_devices[address], data={} ) - current_addresses = self._async_current_ids() + current_addresses = self._async_current_ids(include_ignore=False) for discovery_info in async_discovered_service_info(self.hass, False): address = discovery_info.address if address in current_addresses or address in self._discovered_devices: diff --git a/homeassistant/components/overkiz/manifest.json b/homeassistant/components/overkiz/manifest.json index eda39821d5c..c25accd87f3 100644 --- a/homeassistant/components/overkiz/manifest.json +++ b/homeassistant/components/overkiz/manifest.json @@ -13,7 +13,7 @@ "integration_type": "hub", "iot_class": "local_polling", "loggers": ["boto3", "botocore", "pyhumps", "pyoverkiz", "s3transfer"], - "requirements": ["pyoverkiz==1.15.5"], + "requirements": ["pyoverkiz==1.16.0"], "zeroconf": [ { "type": "_kizbox._tcp.local.", diff --git a/homeassistant/components/ovo_energy/strings.json b/homeassistant/components/ovo_energy/strings.json index 3dc11e3a601..9d8e449e1d1 100644 --- a/homeassistant/components/ovo_energy/strings.json +++ b/homeassistant/components/ovo_energy/strings.json @@ -16,10 +16,10 @@ "data": { "username": "[%key:common::config_flow::data::username%]", "password": "[%key:common::config_flow::data::password%]", - "account": "OVO account id (only add if you have multiple accounts)" + "account": "OVO account ID (only add if you have multiple accounts)" }, "description": "Set up an OVO Energy instance to access your energy usage.", - "title": "Add OVO Energy Account" + "title": "Add OVO Energy account" }, "reauth_confirm": { "data": { diff --git a/homeassistant/components/private_ble_device/manifest.json b/homeassistant/components/private_ble_device/manifest.json index 2ab736b02d3..445affbcd57 100644 --- a/homeassistant/components/private_ble_device/manifest.json +++ b/homeassistant/components/private_ble_device/manifest.json @@ -6,5 +6,5 @@ "dependencies": ["bluetooth_adapters"], "documentation": "https://www.home-assistant.io/integrations/private_ble_device", "iot_class": "local_push", - "requirements": ["bluetooth-data-tools==1.22.0"] + "requirements": ["bluetooth-data-tools==1.23.4"] } diff --git a/homeassistant/components/qingping/config_flow.py b/homeassistant/components/qingping/config_flow.py index c5efe03a878..990eb5116eb 100644 --- a/homeassistant/components/qingping/config_flow.py +++ b/homeassistant/components/qingping/config_flow.py @@ -98,7 +98,7 @@ class QingpingConfigFlow(ConfigFlow, domain=DOMAIN): title=self._discovered_devices[address], data={} ) - current_addresses = self._async_current_ids() + current_addresses = self._async_current_ids(include_ignore=False) for discovery_info in async_discovered_service_info(self.hass, False): address = discovery_info.address if address in current_addresses or address in self._discovered_devices: diff --git a/homeassistant/components/reolink/host.py b/homeassistant/components/reolink/host.py index a23f53ff9cd..2f646ba9090 100644 --- a/homeassistant/components/reolink/host.py +++ b/homeassistant/components/reolink/host.py @@ -158,10 +158,10 @@ class ReolinkHost: store: Store[str] | None = None if self._config_entry_id is not None: store = get_store(self._hass, self._config_entry_id) - if self._config.get(CONF_SUPPORTS_PRIVACY_MODE): - data = await store.async_load() - if data: - self._api.set_raw_host_data(data) + if self._config.get(CONF_SUPPORTS_PRIVACY_MODE) and ( + data := await store.async_load() + ): + self._api.set_raw_host_data(data) await self._api.get_host_data() diff --git a/homeassistant/components/rflink/light.py b/homeassistant/components/rflink/light.py index 2a5b1ccf8d7..af8d2c76844 100644 --- a/homeassistant/components/rflink/light.py +++ b/homeassistant/components/rflink/light.py @@ -101,7 +101,7 @@ def entity_class_for_type(entity_type): entity_device_mapping = { # sends only 'dim' commands not compatible with on/off switches TYPE_DIMMABLE: DimmableRflinkLight, - # sends only 'on/off' commands not advices with dimmers and signal + # sends only 'on/off' commands not advised with dimmers and signal # repetition TYPE_SWITCHABLE: RflinkLight, # sends 'dim' and 'on' command to support both dimmers and on/off diff --git a/homeassistant/components/ring/camera.py b/homeassistant/components/ring/camera.py index c1a4e67ffd4..e0ae2b52fa0 100644 --- a/homeassistant/components/ring/camera.py +++ b/homeassistant/components/ring/camera.py @@ -31,6 +31,7 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.util import dt as dt_util from . import RingConfigEntry +from .const import DOMAIN from .coordinator import RingDataCoordinator from .entity import RingDeviceT, RingEntity, exception_wrap @@ -218,8 +219,13 @@ class RingCam(RingEntity[RingDoorBell], Camera): ) -> None: """Handle a WebRTC candidate.""" if candidate.sdp_m_line_index is None: - msg = "The sdp_m_line_index is required for ring webrtc streaming" - raise HomeAssistantError(msg) + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="sdp_m_line_index_required", + translation_placeholders={ + "device": self._device.name, + }, + ) await self._device.on_webrtc_candidate( session_id, candidate.candidate, candidate.sdp_m_line_index ) diff --git a/homeassistant/components/ring/coordinator.py b/homeassistant/components/ring/coordinator.py index f35a6e10b9f..413c48c35eb 100644 --- a/homeassistant/components/ring/coordinator.py +++ b/homeassistant/components/ring/coordinator.py @@ -27,7 +27,7 @@ from homeassistant.helpers.update_coordinator import ( UpdateFailed, ) -from .const import SCAN_INTERVAL +from .const import DOMAIN, SCAN_INTERVAL _LOGGER = logging.getLogger(__name__) @@ -45,26 +45,6 @@ class RingData: type RingConfigEntry = ConfigEntry[RingData] -async def _call_api[*_Ts, _R]( - hass: HomeAssistant, - target: Callable[[*_Ts], Coroutine[Any, Any, _R]], - *args: *_Ts, - msg_suffix: str = "", -) -> _R: - try: - return await target(*args) - except AuthenticationError as err: - # Raising ConfigEntryAuthFailed will cancel future updates - # and start a config flow with SOURCE_REAUTH (async_step_reauth) - raise ConfigEntryAuthFailed from err - except RingTimeout as err: - raise UpdateFailed( - f"Timeout communicating with API{msg_suffix}: {err}" - ) from err - except RingError as err: - raise UpdateFailed(f"Error communicating with API{msg_suffix}: {err}") from err - - class RingDataCoordinator(DataUpdateCoordinator[RingDevices]): """Base class for device coordinators.""" @@ -87,12 +67,37 @@ class RingDataCoordinator(DataUpdateCoordinator[RingDevices]): self.ring_api: Ring = ring_api self.first_call: bool = True + async def _call_api[*_Ts, _R]( + self, + target: Callable[[*_Ts], Coroutine[Any, Any, _R]], + *args: *_Ts, + ) -> _R: + try: + return await target(*args) + except AuthenticationError as err: + # Raising ConfigEntryAuthFailed will cancel future updates + # and start a config flow with SOURCE_REAUTH (async_step_reauth) + raise ConfigEntryAuthFailed( + translation_domain=DOMAIN, + translation_key="api_authentication", + ) from err + except RingTimeout as err: + raise UpdateFailed( + translation_domain=DOMAIN, + translation_key="api_timeout", + ) from err + except RingError as err: + raise UpdateFailed( + translation_domain=DOMAIN, + translation_key="api_error", + ) from err + async def _async_update_data(self) -> RingDevices: """Fetch data from API endpoint.""" update_method: str = ( "async_update_data" if self.first_call else "async_update_devices" ) - await _call_api(self.hass, getattr(self.ring_api, update_method)) + await self._call_api(getattr(self.ring_api, update_method)) self.first_call = False devices: RingDevices = self.ring_api.devices() subscribed_device_ids = set(self.async_contexts()) @@ -104,18 +109,14 @@ class RingDataCoordinator(DataUpdateCoordinator[RingDevices]): async with TaskGroup() as tg: if device.has_capability("history"): tg.create_task( - _call_api( - self.hass, + self._call_api( lambda device: device.async_history(limit=10), device, - msg_suffix=f" for device {device.name}", # device_id is the mac ) ) tg.create_task( - _call_api( - self.hass, + self._call_api( device.async_update_health_data, - msg_suffix=f" for device {device.name}", ) ) except ExceptionGroup as eg: diff --git a/homeassistant/components/ring/entity.py b/homeassistant/components/ring/entity.py index d48cc35a4f5..5d77bf3a285 100644 --- a/homeassistant/components/ring/entity.py +++ b/homeassistant/components/ring/entity.py @@ -2,6 +2,7 @@ from collections.abc import Awaitable, Callable, Coroutine from dataclasses import dataclass +import logging from typing import Any, Concatenate, Generic, TypeVar, cast from ring_doorbell import ( @@ -36,6 +37,8 @@ _RingCoordinatorT = TypeVar( bound=(RingDataCoordinator | RingListenCoordinator), ) +_LOGGER = logging.getLogger(__name__) + @dataclass(slots=True) class DeprecatedInfo: @@ -62,14 +65,22 @@ def exception_wrap[_RingBaseEntityT: RingBaseEntity[Any, Any], **_P, _R]( return await async_func(self, *args, **kwargs) except AuthenticationError as err: self.coordinator.config_entry.async_start_reauth(self.hass) - raise HomeAssistantError(err) from err + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="api_authentication", + ) from err except RingTimeout as err: raise HomeAssistantError( - f"Timeout communicating with API {async_func}: {err}" + translation_domain=DOMAIN, + translation_key="api_timeout", ) from err except RingError as err: + _LOGGER.debug( + "Error calling %s in platform %s: ", async_func.__name__, self.platform + ) raise HomeAssistantError( - f"Error communicating with API{async_func}: {err}" + translation_domain=DOMAIN, + translation_key="api_error", ) from err return _wrap diff --git a/homeassistant/components/ring/strings.json b/homeassistant/components/ring/strings.json index 219463d92d9..2d7e0b17da1 100644 --- a/homeassistant/components/ring/strings.json +++ b/homeassistant/components/ring/strings.json @@ -141,6 +141,20 @@ } } }, + "exceptions": { + "api_authentication": { + "message": "Authentication error communicating with Ring API" + }, + "api_timeout": { + "message": "Timeout communicating with Ring API" + }, + "api_error": { + "message": "Error communicating with Ring API" + }, + "sdp_m_line_index_required": { + "message": "Error negotiating stream for {device}" + } + }, "issues": { "deprecated_entity": { "title": "Detected deprecated {platform} entity usage", diff --git a/homeassistant/components/roborock/manifest.json b/homeassistant/components/roborock/manifest.json index 76d7ab98a34..db2654d4baa 100644 --- a/homeassistant/components/roborock/manifest.json +++ b/homeassistant/components/roborock/manifest.json @@ -7,7 +7,7 @@ "iot_class": "local_polling", "loggers": ["roborock"], "requirements": [ - "python-roborock==2.9.7", + "python-roborock==2.11.1", "vacuum-map-parser-roborock==0.1.2" ] } diff --git a/homeassistant/components/roomba/manifest.json b/homeassistant/components/roomba/manifest.json index edb317f9752..dbfd803f89b 100644 --- a/homeassistant/components/roomba/manifest.json +++ b/homeassistant/components/roomba/manifest.json @@ -24,7 +24,7 @@ "documentation": "https://www.home-assistant.io/integrations/roomba", "iot_class": "local_push", "loggers": ["paho_mqtt", "roombapy"], - "requirements": ["roombapy==1.8.1"], + "requirements": ["roombapy==1.9.0"], "zeroconf": [ { "type": "_amzn-alexa._tcp.local.", diff --git a/homeassistant/components/schedule/__init__.py b/homeassistant/components/schedule/__init__.py index 20dc9c1256a..ea569f4e277 100644 --- a/homeassistant/components/schedule/__init__.py +++ b/homeassistant/components/schedule/__init__.py @@ -18,7 +18,13 @@ from homeassistant.const import ( STATE_OFF, STATE_ON, ) -from homeassistant.core import HomeAssistant, ServiceCall, callback +from homeassistant.core import ( + HomeAssistant, + ServiceCall, + ServiceResponse, + SupportsResponse, + callback, +) from homeassistant.helpers import config_validation as cv from homeassistant.helpers.collection import ( CollectionEntity, @@ -44,6 +50,7 @@ from .const import ( CONF_TO, DOMAIN, LOGGER, + SERVICE_GET, WEEKDAY_TO_CONF, ) @@ -205,6 +212,14 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: reload_service_handler, ) + component.async_register_entity_service( + SERVICE_GET, + {}, + async_get_schedule_service, + supports_response=SupportsResponse.ONLY, + ) + await component.async_setup(config) + return True @@ -296,6 +311,10 @@ class Schedule(CollectionEntity): self.async_on_remove(self._clean_up_listener) self._update() + def get_schedule(self) -> ConfigType: + """Return the schedule.""" + return {d: self._config[d] for d in WEEKDAY_TO_CONF.values()} + @callback def _update(self, _: datetime | None = None) -> None: """Update the states of the schedule.""" @@ -390,3 +409,10 @@ class Schedule(CollectionEntity): data_keys.update(time_range_custom_data.keys()) return frozenset(data_keys) + + +async def async_get_schedule_service( + schedule: Schedule, service_call: ServiceCall +) -> ServiceResponse: + """Return the schedule configuration.""" + return schedule.get_schedule() diff --git a/homeassistant/components/schedule/const.py b/homeassistant/components/schedule/const.py index 6687dafefdb..410cd00c3a0 100644 --- a/homeassistant/components/schedule/const.py +++ b/homeassistant/components/schedule/const.py @@ -37,3 +37,5 @@ WEEKDAY_TO_CONF: Final = { 5: CONF_SATURDAY, 6: CONF_SUNDAY, } + +SERVICE_GET: Final = "get_schedule" diff --git a/homeassistant/components/schedule/icons.json b/homeassistant/components/schedule/icons.json index a9829425570..7d631cfd42d 100644 --- a/homeassistant/components/schedule/icons.json +++ b/homeassistant/components/schedule/icons.json @@ -2,6 +2,9 @@ "services": { "reload": { "service": "mdi:reload" + }, + "get_schedule": { + "service": "mdi:calendar-export" } } } diff --git a/homeassistant/components/schedule/services.yaml b/homeassistant/components/schedule/services.yaml index c983a105c93..1cb3f0280af 100644 --- a/homeassistant/components/schedule/services.yaml +++ b/homeassistant/components/schedule/services.yaml @@ -1 +1,5 @@ reload: +get_schedule: + target: + entity: + domain: schedule diff --git a/homeassistant/components/schedule/strings.json b/homeassistant/components/schedule/strings.json index a40c5814d36..8638e4a8a84 100644 --- a/homeassistant/components/schedule/strings.json +++ b/homeassistant/components/schedule/strings.json @@ -25,6 +25,10 @@ "reload": { "name": "[%key:common::action::reload%]", "description": "Reloads schedules from the YAML-configuration." + }, + "get_schedule": { + "name": "Get schedule", + "description": "Retrieve one or multiple schedules." } } } diff --git a/homeassistant/components/screenlogic/config_flow.py b/homeassistant/components/screenlogic/config_flow.py index 0fdf5d96445..b4deb9b36aa 100644 --- a/homeassistant/components/screenlogic/config_flow.py +++ b/homeassistant/components/screenlogic/config_flow.py @@ -105,7 +105,7 @@ class ScreenlogicConfigFlow(ConfigFlow, domain=DOMAIN): async def async_step_gateway_select(self, user_input=None) -> ConfigFlowResult: """Handle the selection of a discovered ScreenLogic gateway.""" - existing = self._async_current_ids() + existing = self._async_current_ids(include_ignore=False) unconfigured_gateways = { mac: gateway[SL_GATEWAY_NAME] for mac, gateway in self.discovered_gateways.items() diff --git a/homeassistant/components/sensorpush/config_flow.py b/homeassistant/components/sensorpush/config_flow.py index d826029276b..d3233ac2d5f 100644 --- a/homeassistant/components/sensorpush/config_flow.py +++ b/homeassistant/components/sensorpush/config_flow.py @@ -72,7 +72,7 @@ class SensorPushConfigFlow(ConfigFlow, domain=DOMAIN): title=self._discovered_devices[address], data={} ) - current_addresses = self._async_current_ids() + current_addresses = self._async_current_ids(include_ignore=False) for discovery_info in async_discovered_service_info(self.hass, False): address = discovery_info.address if address in current_addresses or address in self._discovered_devices: diff --git a/homeassistant/components/sharkiq/strings.json b/homeassistant/components/sharkiq/strings.json index 40b569e13b7..3c4c98db38f 100644 --- a/homeassistant/components/sharkiq/strings.json +++ b/homeassistant/components/sharkiq/strings.json @@ -1,16 +1,16 @@ { "config": { - "flow_title": "Add Shark IQ Account", + "flow_title": "Add Shark IQ account", "step": { "user": { - "description": "Sign into your Shark Clean account to control your devices.", + "description": "Sign into your SharkClean account to control your devices.", "data": { "username": "[%key:common::config_flow::data::username%]", "password": "[%key:common::config_flow::data::password%]", "region": "Region" }, "data_description": { - "region": "Shark IQ uses different services in the EU. Select your region to connect to the correct service for your account." + "region": "Shark IQ uses different services in the EU. Select your region to connect to the correct service for your account." } }, "reauth_confirm": { @@ -37,18 +37,18 @@ "region": { "options": { "europe": "Europe", - "elsewhere": "Everywhere Else" + "elsewhere": "Everywhere else" } } }, "exceptions": { "invalid_room": { - "message": "The room {room} is unavailable to your vacuum. Make sure all rooms match the Shark App, including capitalization." + "message": "The room {room} is unavailable to your vacuum. Make sure all rooms match the SharkClean app, including capitalization." } }, "services": { "clean_room": { - "name": "Clean Room", + "name": "Clean room", "description": "Cleans a specific user-defined room or set of rooms.", "fields": { "rooms": { diff --git a/homeassistant/components/shelly/binary_sensor.py b/homeassistant/components/shelly/binary_sensor.py index 108a8236733..fb253c682d8 100644 --- a/homeassistant/components/shelly/binary_sensor.py +++ b/homeassistant/components/shelly/binary_sensor.py @@ -272,6 +272,18 @@ RPC_SENSORS: Final = { entity_category=EntityCategory.DIAGNOSTIC, entity_class=RpcBluTrvBinarySensor, ), + "flood": RpcBinarySensorDescription( + key="flood", + sub_key="alarm", + name="Flood", + device_class=BinarySensorDeviceClass.MOISTURE, + ), + "mute": RpcBinarySensorDescription( + key="flood", + sub_key="mute", + name="Mute", + entity_category=EntityCategory.DIAGNOSTIC, + ), } diff --git a/homeassistant/components/shelly/bluetooth/__init__.py b/homeassistant/components/shelly/bluetooth/__init__.py index 5200ec9b913..366d5c51d25 100644 --- a/homeassistant/components/shelly/bluetooth/__init__.py +++ b/homeassistant/components/shelly/bluetooth/__init__.py @@ -21,6 +21,7 @@ async def async_connect_scanner( hass: HomeAssistant, coordinator: ShellyRpcCoordinator, scanner_mode: BLEScannerMode, + device_id: str, ) -> CALLBACK_TYPE: """Connect scanner.""" device = coordinator.device @@ -34,6 +35,7 @@ async def async_connect_scanner( source_domain=entry.domain, source_model=coordinator.model, source_config_entry_id=entry.entry_id, + source_device_id=device_id, ), scanner.async_setup(), coordinator.async_subscribe_events(scanner.async_on_event), diff --git a/homeassistant/components/shelly/const.py b/homeassistant/components/shelly/const.py index e78a6f1a59d..c8fa72606d6 100644 --- a/homeassistant/components/shelly/const.py +++ b/homeassistant/components/shelly/const.py @@ -116,6 +116,10 @@ BATTERY_DEVICES_WITH_PERMANENT_CONNECTION: Final = [ # Button/Click events for Block & RPC devices EVENT_SHELLY_CLICK: Final = "shelly.click" +SHELLY_EMIT_EVENT_PATTERN: Final = re.compile( + r"(?:Shelly\s*\.\s*emitEvent\s*\(\s*[\"'`])(\w*)" +) + ATTR_CLICK_TYPE: Final = "click_type" ATTR_CHANNEL: Final = "channel" ATTR_DEVICE: Final = "device" diff --git a/homeassistant/components/shelly/coordinator.py b/homeassistant/components/shelly/coordinator.py index d5071c4e849..f2a01240f70 100644 --- a/homeassistant/components/shelly/coordinator.py +++ b/homeassistant/components/shelly/coordinator.py @@ -704,8 +704,11 @@ class ShellyRpcCoordinator(ShellyCoordinatorBase[RpcDevice]): # BLE enable required a reboot, don't bother connecting # the scanner since it will be disconnected anyway return + assert self.device_id is not None self._disconnected_callbacks.append( - await async_connect_scanner(self.hass, self, ble_scanner_mode) + await async_connect_scanner( + self.hass, self, ble_scanner_mode, self.device_id + ) ) @callback diff --git a/homeassistant/components/shelly/event.py b/homeassistant/components/shelly/event.py index 372d73dea3c..78093bec8aa 100644 --- a/homeassistant/components/shelly/event.py +++ b/homeassistant/components/shelly/event.py @@ -6,6 +6,7 @@ from collections.abc import Callable from dataclasses import dataclass from typing import TYPE_CHECKING, Any, Final +from aioshelly.ble.const import BLE_SCRIPT_NAME from aioshelly.block_device import Block from aioshelly.const import MODEL_I3, RPC_GENERATIONS @@ -28,10 +29,12 @@ from .const import ( from .coordinator import ShellyBlockCoordinator, ShellyConfigEntry, ShellyRpcCoordinator from .entity import ShellyBlockEntity from .utils import ( + async_remove_orphaned_entities, async_remove_shelly_entity, get_device_entry_gen, get_rpc_entity_name, get_rpc_key_instances, + get_rpc_script_event_types, is_block_momentary_input, is_rpc_momentary_input, ) @@ -68,6 +71,13 @@ RPC_EVENT: Final = ShellyRpcEventDescription( config, status, key ), ) +SCRIPT_EVENT: Final = ShellyRpcEventDescription( + key="script", + translation_key="script", + device_class=None, + entity_registry_enabled_default=False, + has_entity_name=True, +) async def async_setup_entry( @@ -95,6 +105,33 @@ async def async_setup_entry( async_remove_shelly_entity(hass, EVENT_DOMAIN, unique_id) else: entities.append(ShellyRpcEvent(coordinator, key, RPC_EVENT)) + + script_instances = get_rpc_key_instances( + coordinator.device.status, SCRIPT_EVENT.key + ) + for script in script_instances: + script_name = get_rpc_entity_name(coordinator.device, script) + if script_name == BLE_SCRIPT_NAME: + continue + + event_types = await get_rpc_script_event_types( + coordinator.device, int(script.split(":")[-1]) + ) + if not event_types: + continue + + entities.append(ShellyRpcScriptEvent(coordinator, script, event_types)) + + # If a script is removed, from the device configuration, we need to remove orphaned entities + async_remove_orphaned_entities( + hass, + config_entry.entry_id, + coordinator.mac, + EVENT_DOMAIN, + coordinator.device.status, + "script", + ) + else: coordinator = config_entry.runtime_data.block if TYPE_CHECKING: @@ -170,7 +207,7 @@ class ShellyRpcEvent(CoordinatorEntity[ShellyRpcCoordinator], EventEntity): ) -> None: """Initialize Shelly entity.""" super().__init__(coordinator) - self.input_index = int(key.split(":")[-1]) + self.event_id = int(key.split(":")[-1]) self._attr_device_info = DeviceInfo( connections={(CONNECTION_NETWORK_MAC, coordinator.mac)} ) @@ -181,6 +218,7 @@ class ShellyRpcEvent(CoordinatorEntity[ShellyRpcCoordinator], EventEntity): async def async_added_to_hass(self) -> None: """When entity is added to hass.""" await super().async_added_to_hass() + self.async_on_remove( self.coordinator.async_subscribe_input_events(self._async_handle_event) ) @@ -188,6 +226,42 @@ class ShellyRpcEvent(CoordinatorEntity[ShellyRpcCoordinator], EventEntity): @callback def _async_handle_event(self, event: dict[str, Any]) -> None: """Handle the demo button event.""" - if event["id"] == self.input_index: + if event["id"] == self.event_id: self._trigger_event(event["event"]) self.async_write_ha_state() + + +class ShellyRpcScriptEvent(ShellyRpcEvent): + """Represent RPC script event entity.""" + + def __init__( + self, + coordinator: ShellyRpcCoordinator, + key: str, + event_types: list[str], + ) -> None: + """Initialize Shelly script event entity.""" + super().__init__(coordinator, key, SCRIPT_EVENT) + + self.component = key + self._attr_event_types = event_types + + async def async_added_to_hass(self) -> None: + """When entity is added to hass.""" + await super(CoordinatorEntity, self).async_added_to_hass() + + self.async_on_remove( + self.coordinator.async_subscribe_events(self._async_handle_event) + ) + + @callback + def _async_handle_event(self, event: dict[str, Any]) -> None: + """Handle script event.""" + if event.get("component") == self.component: + event_type = event.get("event") + if event_type not in self.event_types: + # This can happen if we didn't find this event type in the script + return + + self._trigger_event(event_type, event.get("data")) + self.async_write_ha_state() diff --git a/homeassistant/components/shelly/utils.py b/homeassistant/components/shelly/utils.py index 81766c65388..fa310104424 100644 --- a/homeassistant/components/shelly/utils.py +++ b/homeassistant/components/shelly/utils.py @@ -56,6 +56,7 @@ from .const import ( RPC_INPUTS_EVENTS_TYPES, SHBTN_INPUTS_EVENTS_TYPES, SHBTN_MODELS, + SHELLY_EMIT_EVENT_PATTERN, SHIX3_1_INPUTS_EVENTS_TYPES, UPTIME_DEVIATION, VIRTUAL_COMPONENTS_MAP, @@ -598,3 +599,10 @@ def get_rpc_ws_url(hass: HomeAssistant) -> str | None: url = URL(raw_url) ws_url = url.with_scheme("wss" if url.scheme == "https" else "ws") return str(ws_url.joinpath(API_WS_URL.removeprefix("/"))) + + +async def get_rpc_script_event_types(device: RpcDevice, id: int) -> list[str]: + """Return a list of event types for a specific script.""" + code_response = await device.script_getcode(id) + matches = SHELLY_EMIT_EVENT_PATTERN.finditer(code_response["data"]) + return sorted([*{str(event_type.group(1)) for event_type in matches}]) diff --git a/homeassistant/components/smart_rollos/__init__.py b/homeassistant/components/smart_rollos/__init__.py new file mode 100644 index 00000000000..d4bb8c7fb1b --- /dev/null +++ b/homeassistant/components/smart_rollos/__init__.py @@ -0,0 +1 @@ +"""Virtual integration: Smart Rollos.""" diff --git a/homeassistant/components/smart_rollos/manifest.json b/homeassistant/components/smart_rollos/manifest.json new file mode 100644 index 00000000000..f093f740bd6 --- /dev/null +++ b/homeassistant/components/smart_rollos/manifest.json @@ -0,0 +1,6 @@ +{ + "domain": "smart_rollos", + "name": "Smart Rollos", + "integration_type": "virtual", + "supported_by": "motion_blinds" +} diff --git a/homeassistant/components/smlight/__init__.py b/homeassistant/components/smlight/__init__.py index cbfb8162d63..11c6ffb73fb 100644 --- a/homeassistant/components/smlight/__init__.py +++ b/homeassistant/components/smlight/__init__.py @@ -4,7 +4,7 @@ from __future__ import annotations from dataclasses import dataclass -from pysmlight import Api2 +from pysmlight import Api2, Info, Radio from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_HOST, Platform @@ -61,3 +61,9 @@ async def async_setup_entry(hass: HomeAssistant, entry: SmConfigEntry) -> bool: async def async_unload_entry(hass: HomeAssistant, entry: SmConfigEntry) -> bool: """Unload a config entry.""" return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) + + +def get_radio(info: Info, idx: int) -> Radio: + """Get the radio object from the info.""" + assert info.radios is not None + return info.radios[idx] diff --git a/homeassistant/components/smlight/config_flow.py b/homeassistant/components/smlight/config_flow.py index dee81264fa4..667e6e2884b 100644 --- a/homeassistant/components/smlight/config_flow.py +++ b/homeassistant/components/smlight/config_flow.py @@ -10,10 +10,11 @@ from pysmlight.const import Devices from pysmlight.exceptions import SmlightAuthError, SmlightConnectionError import voluptuous as vol -from homeassistant.config_entries import ConfigFlow, ConfigFlowResult +from homeassistant.config_entries import SOURCE_USER, ConfigFlow, ConfigFlowResult from homeassistant.const import CONF_HOST, CONF_NAME, CONF_PASSWORD, CONF_USERNAME from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.device_registry import format_mac +from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo from .const import DOMAIN @@ -35,11 +36,9 @@ STEP_AUTH_DATA_SCHEMA = vol.Schema( class SmlightConfigFlow(ConfigFlow, domain=DOMAIN): """Handle a config flow for SMLIGHT Zigbee.""" - host: str - - def __init__(self) -> None: - """Initialize the config flow.""" - self.client: Api2 + _host: str + _device_name: str + client: Api2 async def async_step_user( self, user_input: dict[str, Any] | None = None @@ -48,11 +47,13 @@ class SmlightConfigFlow(ConfigFlow, domain=DOMAIN): errors: dict[str, str] = {} if user_input is not None: - self.host = user_input[CONF_HOST] - self.client = Api2(self.host, session=async_get_clientsession(self.hass)) + self._host = user_input[CONF_HOST] + self.client = Api2(self._host, session=async_get_clientsession(self.hass)) try: info = await self.client.get_info() + self._host = str(info.device_ip) + self._device_name = str(info.hostname) if info.model not in Devices: return self.async_abort(reason="unsupported_device") @@ -96,15 +97,14 @@ class SmlightConfigFlow(ConfigFlow, domain=DOMAIN): self, discovery_info: ZeroconfServiceInfo ) -> ConfigFlowResult: """Handle a discovered Lan coordinator.""" - local_name = discovery_info.hostname[:-1] - node_name = local_name.removesuffix(".local") + mac: str | None = discovery_info.properties.get("mac") + self._device_name = discovery_info.hostname.removesuffix(".local.") + self._host = discovery_info.host - self.host = local_name - self.context["title_placeholders"] = {CONF_NAME: node_name} - self.client = Api2(self.host, session=async_get_clientsession(self.hass)) + self.context["title_placeholders"] = {CONF_NAME: self._device_name} + self.client = Api2(self._host, session=async_get_clientsession(self.hass)) - mac = discovery_info.properties.get("mac") - # fallback for legacy firmware + # fallback for legacy firmware older than v2.3.x if mac is None: try: info = await self.client.get_info() @@ -114,7 +114,7 @@ class SmlightConfigFlow(ConfigFlow, domain=DOMAIN): mac = info.MAC await self.async_set_unique_id(format_mac(mac)) - self._abort_if_unique_id_configured() + self._abort_if_unique_id_configured(updates={CONF_HOST: self._host}) return await self.async_step_confirm_discovery() @@ -125,7 +125,6 @@ class SmlightConfigFlow(ConfigFlow, domain=DOMAIN): errors: dict[str, str] = {} if user_input is not None: - user_input[CONF_HOST] = self.host try: info = await self.client.get_info() @@ -145,7 +144,7 @@ class SmlightConfigFlow(ConfigFlow, domain=DOMAIN): return self.async_show_form( step_id="confirm_discovery", - description_placeholders={"host": self.host}, + description_placeholders={"host": self._device_name}, errors=errors, ) @@ -154,8 +153,8 @@ class SmlightConfigFlow(ConfigFlow, domain=DOMAIN): ) -> ConfigFlowResult: """Handle reauth when API Authentication failed.""" - self.host = entry_data[CONF_HOST] - self.client = Api2(self.host, session=async_get_clientsession(self.hass)) + self._host = entry_data[CONF_HOST] + self.client = Api2(self._host, session=async_get_clientsession(self.hass)) return await self.async_step_reauth_confirm() @@ -185,6 +184,16 @@ class SmlightConfigFlow(ConfigFlow, domain=DOMAIN): errors=errors, ) + async def async_step_dhcp( + self, discovery_info: DhcpServiceInfo + ) -> ConfigFlowResult: + """Handle DHCP discovery.""" + await self.async_set_unique_id(format_mac(discovery_info.macaddress)) + self._abort_if_unique_id_configured(updates={CONF_HOST: discovery_info.ip}) + # This should never happen since we only listen to DHCP requests + # for configured devices. + return self.async_abort(reason="already_configured") + async def _async_check_auth_required(self, user_input: dict[str, Any]) -> bool: """Check if auth required and attempt to authenticate.""" if await self.client.check_auth_needed(): @@ -199,12 +208,14 @@ class SmlightConfigFlow(ConfigFlow, domain=DOMAIN): self, user_input: dict[str, Any] ) -> ConfigFlowResult: info = await self.client.get_info() - await self.async_set_unique_id(format_mac(info.MAC)) - self._abort_if_unique_id_configured() - if user_input.get(CONF_HOST) is None: - user_input[CONF_HOST] = self.host + await self.async_set_unique_id( + format_mac(info.MAC), raise_on_progress=self.source != SOURCE_USER + ) + self._abort_if_unique_id_configured(updates={CONF_HOST: self._host}) + + user_input[CONF_HOST] = self._host assert info.model is not None - title = self.context.get("title_placeholders", {}).get(CONF_NAME) or info.model + title = self._device_name or info.model return self.async_create_entry(title=title, data=user_input) diff --git a/homeassistant/components/smlight/coordinator.py b/homeassistant/components/smlight/coordinator.py index 6be36439e9f..341c627afe5 100644 --- a/homeassistant/components/smlight/coordinator.py +++ b/homeassistant/components/smlight/coordinator.py @@ -9,7 +9,7 @@ from typing import TYPE_CHECKING from pysmlight import Api2, Info, Sensors from pysmlight.const import Settings, SettingsProp from pysmlight.exceptions import SmlightAuthError, SmlightConnectionError -from pysmlight.web import Firmware +from pysmlight.models import FirmwareList from homeassistant.const import CONF_PASSWORD, CONF_USERNAME from homeassistant.core import HomeAssistant @@ -38,8 +38,8 @@ class SmFwData: """SMLIGHT firmware data stored in the FirmwareUpdateCoordinator.""" info: Info - esp_firmware: list[Firmware] | None - zb_firmware: list[Firmware] | None + esp_firmware: FirmwareList + zb_firmware: list[FirmwareList] class SmBaseDataUpdateCoordinator[_DataT](DataUpdateCoordinator[_DataT]): @@ -144,15 +144,30 @@ class SmFirmwareUpdateCoordinator(SmBaseDataUpdateCoordinator[SmFwData]): async def _internal_update_data(self) -> SmFwData: """Fetch data from the SMLIGHT device.""" info = await self.client.get_info() + assert info.radios is not None esp_firmware = None - zb_firmware = None + zb_firmware: list[FirmwareList] = [] try: esp_firmware = await self.client.get_firmware_version(info.fw_channel) - zb_firmware = await self.client.get_firmware_version( - info.fw_channel, device=info.model, mode="zigbee" + zb_firmware.extend( + [ + await self.client.get_firmware_version( + info.fw_channel, + device=info.model, + mode="zigbee", + zb_type=r.zb_type, + idx=idx, + ) + for idx, r in enumerate(info.radios) + ] ) + except SmlightConnectionError as err: self.async_set_update_error(err) - return SmFwData(info=info, esp_firmware=esp_firmware, zb_firmware=zb_firmware) + return SmFwData( + info=info, + esp_firmware=esp_firmware, + zb_firmware=zb_firmware, + ) diff --git a/homeassistant/components/smlight/manifest.json b/homeassistant/components/smlight/manifest.json index 3691c211838..cec5d6a6d8b 100644 --- a/homeassistant/components/smlight/manifest.json +++ b/homeassistant/components/smlight/manifest.json @@ -3,10 +3,15 @@ "name": "SMLIGHT SLZB", "codeowners": ["@tl-sl"], "config_flow": true, + "dhcp": [ + { + "registered_devices": true + } + ], "documentation": "https://www.home-assistant.io/integrations/smlight", "integration_type": "device", "iot_class": "local_push", - "requirements": ["pysmlight==0.2.1"], + "requirements": ["pysmlight==0.2.2"], "zeroconf": [ { "type": "_slzb-06._tcp.local." diff --git a/homeassistant/components/smlight/update.py b/homeassistant/components/smlight/update.py index 147b1d766ef..50a123345c6 100644 --- a/homeassistant/components/smlight/update.py +++ b/homeassistant/components/smlight/update.py @@ -5,7 +5,7 @@ from __future__ import annotations import asyncio from collections.abc import Callable from dataclasses import dataclass -from typing import Any, Final +from typing import Any from pysmlight.const import Events as SmEvents from pysmlight.models import Firmware, Info @@ -22,34 +22,43 @@ from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import SmConfigEntry +from . import SmConfigEntry, get_radio from .const import LOGGER from .coordinator import SmFirmwareUpdateCoordinator, SmFwData from .entity import SmEntity +def zigbee_latest_version(data: SmFwData, idx: int) -> Firmware | None: + """Get the latest Zigbee firmware version.""" + + if idx < len(data.zb_firmware): + firmware_list = data.zb_firmware[idx] + if firmware_list: + return firmware_list[0] + return None + + @dataclass(frozen=True, kw_only=True) class SmUpdateEntityDescription(UpdateEntityDescription): """Describes SMLIGHT SLZB-06 update entity.""" - installed_version: Callable[[Info], str | None] - fw_list: Callable[[SmFwData], list[Firmware] | None] + installed_version: Callable[[Info, int], str | None] + latest_version: Callable[[SmFwData, int], Firmware | None] -UPDATE_ENTITIES: Final = [ - SmUpdateEntityDescription( - key="core_update", - translation_key="core_update", - installed_version=lambda x: x.sw_version, - fw_list=lambda x: x.esp_firmware, - ), - SmUpdateEntityDescription( - key="zigbee_update", - translation_key="zigbee_update", - installed_version=lambda x: x.zb_version, - fw_list=lambda x: x.zb_firmware, - ), -] +CORE_UPDATE_ENTITY = SmUpdateEntityDescription( + key="core_update", + translation_key="core_update", + installed_version=lambda x, idx: x.sw_version, + latest_version=lambda x, idx: x.esp_firmware[0] if x.esp_firmware else None, +) + +ZB_UPDATE_ENTITY = SmUpdateEntityDescription( + key="zigbee_update", + translation_key="zigbee_update", + installed_version=lambda x, idx: get_radio(x, idx).zb_version, + latest_version=zigbee_latest_version, +) async def async_setup_entry( @@ -58,10 +67,21 @@ async def async_setup_entry( """Set up the SMLIGHT update entities.""" coordinator = entry.runtime_data.firmware - async_add_entities( - SmUpdateEntity(coordinator, description) for description in UPDATE_ENTITIES + # updates not available for legacy API, user will get repair to update externally + if coordinator.legacy_api == 2: + return + + entities = [SmUpdateEntity(coordinator, CORE_UPDATE_ENTITY)] + radios = coordinator.data.info.radios + assert radios is not None + + entities.extend( + SmUpdateEntity(coordinator, ZB_UPDATE_ENTITY, idx) + for idx, _ in enumerate(radios) ) + async_add_entities(entities) + class SmUpdateEntity(SmEntity, UpdateEntity): """Representation for SLZB-06 update entities.""" @@ -80,42 +100,46 @@ class SmUpdateEntity(SmEntity, UpdateEntity): self, coordinator: SmFirmwareUpdateCoordinator, description: SmUpdateEntityDescription, + idx: int = 0, ) -> None: """Initialize the entity.""" super().__init__(coordinator) self.entity_description = description - self._attr_unique_id = f"{coordinator.unique_id}-{description.key}" + device = description.key + (f"_{idx}" if idx else "") + self._attr_unique_id = f"{coordinator.unique_id}-{device}" self._finished_event = asyncio.Event() self._firmware: Firmware | None = None self._unload: list[Callable] = [] + self.idx = idx + + async def async_added_to_hass(self) -> None: + """When entity is added to hass.""" + await super().async_added_to_hass() + self._handle_coordinator_update() + + @callback + def _handle_coordinator_update(self) -> None: + """Handle coordinator update callbacks.""" + self._firmware = self.entity_description.latest_version( + self.coordinator.data, self.idx + ) + if self._firmware: + self.async_write_ha_state() @property def installed_version(self) -> str | None: """Version installed..""" data = self.coordinator.data - version = self.entity_description.installed_version(data.info) - return version if version != "-1" else None + return self.entity_description.installed_version(data.info, self.idx) @property def latest_version(self) -> str | None: """Latest version available for install.""" - data = self.coordinator.data - if self.coordinator.legacy_api == 2: - return None - fw = self.entity_description.fw_list(data) - - if fw and self.entity_description.key == "zigbee_update": - fw = [f for f in fw if f.type == data.info.zb_type] - - if fw: - self._firmware = fw[0] - return self._firmware.ver - - return None + return self._firmware.ver if self._firmware else None def register_callbacks(self) -> None: """Register callbacks for SSE update events.""" @@ -143,9 +167,14 @@ class SmUpdateEntity(SmEntity, UpdateEntity): def release_notes(self) -> str | None: """Return release notes for firmware.""" + if "zigbee" in self.entity_description.key: + notes = f"### {'ZNP' if self.idx else 'EZSP'} Firmware\n\n" + else: + notes = "### Core Firmware\n\n" if self._firmware and self._firmware.notes: - return self._firmware.notes + notes += self._firmware.notes + return notes return None @@ -192,7 +221,7 @@ class SmUpdateEntity(SmEntity, UpdateEntity): self._attr_update_percentage = None self.register_callbacks() - await self.coordinator.client.fw_update(self._firmware) + await self.coordinator.client.fw_update(self._firmware, self.idx) # block until update finished event received await self._finished_event.wait() diff --git a/homeassistant/components/sonos/strings.json b/homeassistant/components/sonos/strings.json index d3774e85213..07d2e2db4e0 100644 --- a/homeassistant/components/sonos/strings.json +++ b/homeassistant/components/sonos/strings.json @@ -87,7 +87,7 @@ "services": { "snapshot": { "name": "Snapshot", - "description": "Takes a snapshot of the media player.", + "description": "Takes a snapshot of a media player.", "fields": { "entity_id": { "name": "Entity", @@ -95,13 +95,13 @@ }, "with_group": { "name": "With group", - "description": "True or False. Also snapshot the group layout." + "description": "Whether the snapshot should include the group layout and the state of other speakers in the group." } } }, "restore": { "name": "Restore", - "description": "Restores a snapshot of the media player.", + "description": "Restores a snapshot of a media player.", "fields": { "entity_id": { "name": "Entity", @@ -109,7 +109,7 @@ }, "with_group": { "name": "[%key:component::sonos::services::snapshot::fields::with_group::name%]", - "description": "True or False. Also restore the group layout." + "description": "Whether the group layout and the state of other speakers in the group should also be restored." } } }, @@ -129,7 +129,7 @@ }, "play_queue": { "name": "Play queue", - "description": "Start playing the queue from the first item.", + "description": "Starts playing the queue from the first item.", "fields": { "queue_position": { "name": "Queue position", @@ -153,23 +153,23 @@ "fields": { "alarm_id": { "name": "Alarm ID", - "description": "ID for the alarm to be updated." + "description": "The ID of the alarm to be updated." }, "time": { "name": "Time", - "description": "Set time for the alarm." + "description": "The time for the alarm." }, "volume": { "name": "Volume", - "description": "Set alarm volume level." + "description": "The alarm volume level." }, "enabled": { "name": "Alarm enabled", - "description": "Enable or disable the alarm." + "description": "Whether or not to enable the alarm." }, "include_linked_zones": { "name": "Include linked zones", - "description": "Enable or disable including grouped rooms." + "description": "Whether the alarm also plays on grouped players." } } }, diff --git a/homeassistant/components/starlink/binary_sensor.py b/homeassistant/components/starlink/binary_sensor.py index e48d28dcc44..b03648e81c5 100644 --- a/homeassistant/components/starlink/binary_sensor.py +++ b/homeassistant/components/starlink/binary_sensor.py @@ -65,6 +65,7 @@ BINARY_SENSORS = [ key="currently_obstructed", translation_key="currently_obstructed", device_class=BinarySensorDeviceClass.PROBLEM, + entity_category=EntityCategory.DIAGNOSTIC, value_fn=lambda data: data.status["currently_obstructed"], ), StarlinkBinarySensorEntityDescription( @@ -114,4 +115,9 @@ BINARY_SENSORS = [ entity_category=EntityCategory.DIAGNOSTIC, value_fn=lambda data: data.alert["alert_unexpected_location"], ), + StarlinkBinarySensorEntityDescription( + key="connection", + device_class=BinarySensorDeviceClass.CONNECTIVITY, + value_fn=lambda data: data.status["state"] == "CONNECTED", + ), ] diff --git a/homeassistant/components/steamist/manifest.json b/homeassistant/components/steamist/manifest.json index b15d7f87312..f79c2aaa99c 100644 --- a/homeassistant/components/steamist/manifest.json +++ b/homeassistant/components/steamist/manifest.json @@ -16,5 +16,5 @@ "documentation": "https://www.home-assistant.io/integrations/steamist", "iot_class": "local_polling", "loggers": ["aiosteamist", "discovery30303"], - "requirements": ["aiosteamist==1.0.0", "discovery30303==0.3.2"] + "requirements": ["aiosteamist==1.0.1", "discovery30303==0.3.2"] } diff --git a/homeassistant/components/synology_dsm/backup.py b/homeassistant/components/synology_dsm/backup.py index 62a1b97b717..83c3455bdf1 100644 --- a/homeassistant/components/synology_dsm/backup.py +++ b/homeassistant/components/synology_dsm/backup.py @@ -10,7 +10,12 @@ from aiohttp import StreamReader from synology_dsm.api.file_station import SynoFileStation from synology_dsm.exceptions import SynologyDSMAPIErrorException -from homeassistant.components.backup import AgentBackup, BackupAgent, BackupAgentError +from homeassistant.components.backup import ( + AgentBackup, + BackupAgent, + BackupAgentError, + suggested_filename, +) from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.aiohttp_client import ChunkAsyncStreamIterator @@ -28,6 +33,15 @@ from .models import SynologyDSMData LOGGER = logging.getLogger(__name__) +def suggested_filenames(backup: AgentBackup) -> tuple[str, str]: + """Suggest filenames for the backup. + + returns a tuple of tar_filename and meta_filename + """ + base_name = suggested_filename(backup).rsplit(".", 1)[0] + return (f"{base_name}.tar", f"{base_name}_meta.json") + + async def async_get_backup_agents( hass: HomeAssistant, ) -> list[BackupAgent]: @@ -95,6 +109,19 @@ class SynologyDSMBackupAgent(BackupAgent): assert self.api.file_station return self.api.file_station + async def _async_suggested_filenames( + self, + backup_id: str, + ) -> tuple[str, str]: + """Suggest filenames for the backup. + + :param backup_id: The ID of the backup that was returned in async_list_backups. + :return: A tuple of tar_filename and meta_filename + """ + if (backup := await self.async_get_backup(backup_id)) is None: + raise BackupAgentError("Backup not found") + return suggested_filenames(backup) + async def async_download_backup( self, backup_id: str, @@ -105,10 +132,12 @@ class SynologyDSMBackupAgent(BackupAgent): :param backup_id: The ID of the backup that was returned in async_list_backups. :return: An async iterator that yields bytes. """ + (filename_tar, _) = await self._async_suggested_filenames(backup_id) + try: resp = await self._file_station.download_file( path=self.path, - filename=f"{backup_id}.tar", + filename=filename_tar, ) except SynologyDSMAPIErrorException as err: raise BackupAgentError("Failed to download backup") from err @@ -131,11 +160,13 @@ class SynologyDSMBackupAgent(BackupAgent): :param backup: Metadata about the backup that should be uploaded. """ + (filename_tar, filename_meta) = suggested_filenames(backup) + # upload backup.tar file first try: await self._file_station.upload_file( path=self.path, - filename=f"{backup.backup_id}.tar", + filename=filename_tar, source=await open_stream(), create_parents=True, ) @@ -146,7 +177,7 @@ class SynologyDSMBackupAgent(BackupAgent): try: await self._file_station.upload_file( path=self.path, - filename=f"{backup.backup_id}_meta.json", + filename=filename_meta, source=json_dumps(backup.as_dict()).encode(), ) except SynologyDSMAPIErrorException as err: @@ -162,14 +193,27 @@ class SynologyDSMBackupAgent(BackupAgent): :param backup_id: The ID of the backup that was returned in async_list_backups. """ try: - await self._file_station.delete_file( - path=self.path, filename=f"{backup_id}.tar" + (filename_tar, filename_meta) = await self._async_suggested_filenames( + backup_id ) - await self._file_station.delete_file( - path=self.path, filename=f"{backup_id}_meta.json" - ) - except SynologyDSMAPIErrorException as err: - raise BackupAgentError("Failed to delete the backup") from err + except BackupAgentError: + # backup meta data could not be found, so we can't delete the backup + return + + for filename in (filename_tar, filename_meta): + try: + await self._file_station.delete_file(path=self.path, filename=filename) + except SynologyDSMAPIErrorException as err: + err_args: dict = err.args[0] + if int(err_args.get("code", 0)) != 900 or ( + (err_details := err_args.get("details")) is not None + and isinstance(err_details, list) + and isinstance(err_details[0], dict) + and int(err_details[0].get("code", 0)) + != 408 # No such file or directory + ): + LOGGER.error("Failed to delete backup: %s", err) + raise BackupAgentError("Failed to delete backup") from err async def async_list_backups(self, **kwargs: Any) -> list[AgentBackup]: """List backups.""" diff --git a/homeassistant/components/system_health/__init__.py b/homeassistant/components/system_health/__init__.py index ce80f6303d9..7d2224fc6fc 100644 --- a/homeassistant/components/system_health/__init__.py +++ b/homeassistant/components/system_health/__init__.py @@ -3,7 +3,7 @@ from __future__ import annotations import asyncio -from collections.abc import Awaitable, Callable +from collections.abc import AsyncGenerator, Awaitable, Callable import dataclasses from datetime import datetime import logging @@ -101,6 +101,57 @@ async def get_integration_info( return result +async def _registered_domain_data( + hass: HomeAssistant, +) -> AsyncGenerator[tuple[str, dict[str, Any]]]: + registrations: dict[str, SystemHealthRegistration] = hass.data[DOMAIN] + for domain, domain_data in zip( + registrations, + await asyncio.gather( + *( + get_integration_info(hass, registration) + for registration in registrations.values() + ) + ), + strict=False, + ): + yield domain, domain_data + + +async def get_info(hass: HomeAssistant) -> dict[str, dict[str, str]]: + """Get the full set of system health information.""" + domains: dict[str, dict[str, Any]] = {} + + async def _get_info_value(value: Any) -> Any: + if not asyncio.iscoroutine(value): + return value + try: + return await value + except Exception as exception: + _LOGGER.exception("Error fetching system info for %s - %s", domain, key) + return f"Exception: {exception}" + + async for domain, domain_data in _registered_domain_data(hass): + domain_info: dict[str, Any] = {} + for key, value in domain_data["info"].items(): + info_value = await _get_info_value(value) + + if isinstance(info_value, datetime): + domain_info[key] = info_value.isoformat() + elif ( + isinstance(info_value, dict) + and "type" in info_value + and info_value["type"] == "failed" + ): + domain_info[key] = f"Failed: {info_value.get('error', 'unknown')}" + else: + domain_info[key] = info_value + + domains[domain] = domain_info + + return domains + + @callback def _format_value(val: Any) -> Any: """Format a system health value.""" @@ -115,20 +166,10 @@ async def handle_info( hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg: dict[str, Any] ) -> None: """Handle an info request via a subscription.""" - registrations: dict[str, SystemHealthRegistration] = hass.data[DOMAIN] data = {} pending_info: dict[tuple[str, str], asyncio.Task] = {} - for domain, domain_data in zip( - registrations, - await asyncio.gather( - *( - get_integration_info(hass, registration) - for registration in registrations.values() - ) - ), - strict=False, - ): + async for domain, domain_data in _registered_domain_data(hass): for key, value in domain_data["info"].items(): if asyncio.iscoroutine(value): value = asyncio.create_task(value) diff --git a/homeassistant/components/tado/climate.py b/homeassistant/components/tado/climate.py index c8eaec76255..db7b1823bd9 100644 --- a/homeassistant/components/tado/climate.py +++ b/homeassistant/components/tado/climate.py @@ -506,7 +506,7 @@ class TadoClimate(TadoZoneEntity, ClimateEntity): offset, ) - self._tado.set_temperature_offset(self._device_id, offset) + await self._tado.set_temperature_offset(self._device_id, offset) await self.coordinator.async_request_refresh() async def async_set_temperature(self, **kwargs: Any) -> None: diff --git a/homeassistant/components/tado/strings.json b/homeassistant/components/tado/strings.json index 735fe34bcf4..f1550517457 100644 --- a/homeassistant/components/tado/strings.json +++ b/homeassistant/components/tado/strings.json @@ -14,7 +14,7 @@ }, "reconfigure": { "title": "Reconfigure your Tado", - "description": "Reconfigure the entry, for your account: `{username}`.", + "description": "Reconfigure the entry for your account: `{username}`.", "data": { "password": "[%key:common::config_flow::data::password%]" }, @@ -25,7 +25,7 @@ }, "error": { "unknown": "[%key:common::config_flow::error::unknown%]", - "no_homes": "There are no homes linked to this tado account.", + "no_homes": "There are no homes linked to this Tado account.", "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]" } @@ -33,7 +33,7 @@ "options": { "step": { "init": { - "description": "Fallback mode lets you choose when to fallback to Smart Schedule from your manual zone overlay. (NEXT_TIME_BLOCK:= Change at next Smart Schedule change; MANUAL:= Dont change until you cancel; TADO_DEFAULT:= Change based on your setting in Tado App).", + "description": "Fallback mode lets you choose when to fallback to Smart Schedule from your manual zone overlay. (NEXT_TIME_BLOCK:= Change at next Smart Schedule change; MANUAL:= Don't change until you cancel; TADO_DEFAULT:= Change based on your setting in the Tado app).", "data": { "fallback": "Choose fallback mode." }, @@ -102,11 +102,11 @@ }, "time_period": { "name": "Time period", - "description": "Choose this or Overlay. Set the time period for the change if you want to be specific. Alternatively use Overlay." + "description": "Choose this or 'Overlay'. Set the time period for the change if you want to be specific." }, "requested_overlay": { "name": "Overlay", - "description": "Choose this or Time Period. Allows you to choose an overlay. MANUAL:=Overlay until user removes; NEXT_TIME_BLOCK:=Overlay until next timeblock; TADO_DEFAULT:=Overlay based on tado app setting." + "description": "Choose this or 'Time period'. Allows you to choose an overlay. MANUAL:=Overlay until user removes; NEXT_TIME_BLOCK:=Overlay until next timeblock; TADO_DEFAULT:=Overlay based on Tado app setting." } } }, @@ -151,8 +151,8 @@ }, "issues": { "water_heater_fallback": { - "title": "Tado Water Heater entities now support fallback options", - "description": "Due to added support for water heaters entities, these entities may use different overlay. Please configure integration entity and tado app water heater zone overlay options. Otherwise, please configure the integration entity and Tado app water heater zone overlay options (under Settings -> Rooms & Devices -> Hot Water)." + "title": "Tado water heater entities now support fallback options", + "description": "Due to added support for water heaters entities, these entities may use a different overlay. Please configure the integration entity and Tado app water heater zone overlay options (under Settings -> Rooms & Devices -> Hot Water)." } } } diff --git a/homeassistant/components/tesla_fleet/manifest.json b/homeassistant/components/tesla_fleet/manifest.json index aecc6a04af3..fa0f336eb18 100644 --- a/homeassistant/components/tesla_fleet/manifest.json +++ b/homeassistant/components/tesla_fleet/manifest.json @@ -7,5 +7,5 @@ "documentation": "https://www.home-assistant.io/integrations/tesla_fleet", "iot_class": "cloud_polling", "loggers": ["tesla-fleet-api"], - "requirements": ["tesla-fleet-api==0.9.2"] + "requirements": ["tesla-fleet-api==0.9.6"] } diff --git a/homeassistant/components/teslemetry/manifest.json b/homeassistant/components/teslemetry/manifest.json index 7a3d0905ea1..749bd7c4173 100644 --- a/homeassistant/components/teslemetry/manifest.json +++ b/homeassistant/components/teslemetry/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/teslemetry", "iot_class": "cloud_polling", "loggers": ["tesla-fleet-api"], - "requirements": ["tesla-fleet-api==0.9.2", "teslemetry-stream==0.6.6"] + "requirements": ["tesla-fleet-api==0.9.6", "teslemetry-stream==0.6.10"] } diff --git a/homeassistant/components/tessie/manifest.json b/homeassistant/components/tessie/manifest.json index 8f7c9890664..f6015b0ef4e 100644 --- a/homeassistant/components/tessie/manifest.json +++ b/homeassistant/components/tessie/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/tessie", "iot_class": "cloud_polling", "loggers": ["tessie", "tesla-fleet-api"], - "requirements": ["tessie-api==0.1.1", "tesla-fleet-api==0.9.2"] + "requirements": ["tessie-api==0.1.1", "tesla-fleet-api==0.9.6"] } diff --git a/homeassistant/components/thermopro/config_flow.py b/homeassistant/components/thermopro/config_flow.py index 4d080c6e074..4c6d59473c2 100644 --- a/homeassistant/components/thermopro/config_flow.py +++ b/homeassistant/components/thermopro/config_flow.py @@ -72,7 +72,7 @@ class ThermoProConfigFlow(ConfigFlow, domain=DOMAIN): title=self._discovered_devices[address], data={} ) - current_addresses = self._async_current_ids() + current_addresses = self._async_current_ids(include_ignore=False) for discovery_info in async_discovered_service_info(self.hass, False): address = discovery_info.address if address in current_addresses or address in self._discovered_devices: diff --git a/homeassistant/components/thermopro/manifest.json b/homeassistant/components/thermopro/manifest.json index 2c066d785ca..6027e4bc99c 100644 --- a/homeassistant/components/thermopro/manifest.json +++ b/homeassistant/components/thermopro/manifest.json @@ -24,5 +24,5 @@ "dependencies": ["bluetooth_adapters"], "documentation": "https://www.home-assistant.io/integrations/thermopro", "iot_class": "local_push", - "requirements": ["thermopro-ble==0.10.1"] + "requirements": ["thermopro-ble==0.11.0"] } diff --git a/homeassistant/components/tilt_ble/config_flow.py b/homeassistant/components/tilt_ble/config_flow.py index 5c1f9721aae..b4a3235c60f 100644 --- a/homeassistant/components/tilt_ble/config_flow.py +++ b/homeassistant/components/tilt_ble/config_flow.py @@ -72,7 +72,7 @@ class TiltConfigFlow(ConfigFlow, domain=DOMAIN): title=self._discovered_devices[address], data={} ) - current_addresses = self._async_current_ids() + current_addresses = self._async_current_ids(include_ignore=False) for discovery_info in async_discovered_service_info(self.hass, False): address = discovery_info.address if address in current_addresses or address in self._discovered_devices: diff --git a/homeassistant/components/todoist/calendar.py b/homeassistant/components/todoist/calendar.py index 94581439ae9..8c61394d300 100644 --- a/homeassistant/components/todoist/calendar.py +++ b/homeassistant/components/todoist/calendar.py @@ -541,9 +541,8 @@ class TodoistProjectData: return None # All task Labels (optional parameter). - task[LABELS] = [ - label.name for label in self._labels if label.name in data.labels - ] + labels = data.labels or [] + task[LABELS] = [label.name for label in self._labels if label.name in labels] if self._label_whitelist and ( not any(label in task[LABELS] for label in self._label_whitelist) ): diff --git a/homeassistant/components/todoist/manifest.json b/homeassistant/components/todoist/manifest.json index 72d76108353..791f5642aad 100644 --- a/homeassistant/components/todoist/manifest.json +++ b/homeassistant/components/todoist/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/todoist", "iot_class": "cloud_polling", "loggers": ["todoist"], - "requirements": ["todoist-api-python==2.1.2"] + "requirements": ["todoist-api-python==2.1.7"] } diff --git a/homeassistant/components/tolo/manifest.json b/homeassistant/components/tolo/manifest.json index 14125a857f6..613fc810683 100644 --- a/homeassistant/components/tolo/manifest.json +++ b/homeassistant/components/tolo/manifest.json @@ -11,5 +11,5 @@ "documentation": "https://www.home-assistant.io/integrations/tolo", "iot_class": "local_polling", "loggers": ["tololib"], - "requirements": ["tololib==1.1.0"] + "requirements": ["tololib==1.2.2"] } diff --git a/homeassistant/components/tplink/manifest.json b/homeassistant/components/tplink/manifest.json index 6f9eefbdabb..ff65211c9b3 100644 --- a/homeassistant/components/tplink/manifest.json +++ b/homeassistant/components/tplink/manifest.json @@ -301,5 +301,5 @@ "iot_class": "local_polling", "loggers": ["kasa"], "quality_scale": "platinum", - "requirements": ["python-kasa[speedups]==0.10.0"] + "requirements": ["python-kasa[speedups]==0.10.1"] } diff --git a/homeassistant/components/tplink/sensor.py b/homeassistant/components/tplink/sensor.py index 38aab26cf8b..9b21ba775a9 100644 --- a/homeassistant/components/tplink/sensor.py +++ b/homeassistant/components/tplink/sensor.py @@ -135,13 +135,17 @@ SENSOR_DESCRIPTIONS: tuple[TPLinkSensorEntityDescription, ...] = ( TPLinkSensorEntityDescription( key="clean_area", device_class=SensorDeviceClass.AREA, + state_class=SensorStateClass.MEASUREMENT, ), TPLinkSensorEntityDescription( + entity_registry_enabled_default=False, key="clean_progress", + state_class=SensorStateClass.MEASUREMENT, ), TPLinkSensorEntityDescription( key="last_clean_time", device_class=SensorDeviceClass.DURATION, + state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=UnitOfTime.SECONDS, suggested_unit_of_measurement=UnitOfTime.MINUTES, convert_fn=_TOTAL_SECONDS_METHOD_CALLER, @@ -155,20 +159,26 @@ SENSOR_DESCRIPTIONS: tuple[TPLinkSensorEntityDescription, ...] = ( device_class=SensorDeviceClass.TIMESTAMP, ), TPLinkSensorEntityDescription( + entity_registry_enabled_default=False, key="total_clean_time", device_class=SensorDeviceClass.DURATION, + state_class=SensorStateClass.TOTAL_INCREASING, native_unit_of_measurement=UnitOfTime.SECONDS, suggested_unit_of_measurement=UnitOfTime.MINUTES, convert_fn=_TOTAL_SECONDS_METHOD_CALLER, ), TPLinkSensorEntityDescription( + entity_registry_enabled_default=False, key="total_clean_area", device_class=SensorDeviceClass.AREA, + state_class=SensorStateClass.TOTAL_INCREASING, ), TPLinkSensorEntityDescription( key="total_clean_count", + state_class=SensorStateClass.TOTAL_INCREASING, ), TPLinkSensorEntityDescription( + entity_registry_enabled_default=False, key="main_brush_remaining", device_class=SensorDeviceClass.DURATION, native_unit_of_measurement=UnitOfTime.SECONDS, @@ -176,6 +186,7 @@ SENSOR_DESCRIPTIONS: tuple[TPLinkSensorEntityDescription, ...] = ( convert_fn=_TOTAL_SECONDS_METHOD_CALLER, ), TPLinkSensorEntityDescription( + entity_registry_enabled_default=False, key="main_brush_used", device_class=SensorDeviceClass.DURATION, native_unit_of_measurement=UnitOfTime.SECONDS, @@ -183,6 +194,7 @@ SENSOR_DESCRIPTIONS: tuple[TPLinkSensorEntityDescription, ...] = ( convert_fn=_TOTAL_SECONDS_METHOD_CALLER, ), TPLinkSensorEntityDescription( + entity_registry_enabled_default=False, key="side_brush_remaining", device_class=SensorDeviceClass.DURATION, native_unit_of_measurement=UnitOfTime.SECONDS, @@ -190,6 +202,7 @@ SENSOR_DESCRIPTIONS: tuple[TPLinkSensorEntityDescription, ...] = ( convert_fn=_TOTAL_SECONDS_METHOD_CALLER, ), TPLinkSensorEntityDescription( + entity_registry_enabled_default=False, key="side_brush_used", device_class=SensorDeviceClass.DURATION, native_unit_of_measurement=UnitOfTime.SECONDS, @@ -197,6 +210,7 @@ SENSOR_DESCRIPTIONS: tuple[TPLinkSensorEntityDescription, ...] = ( convert_fn=_TOTAL_SECONDS_METHOD_CALLER, ), TPLinkSensorEntityDescription( + entity_registry_enabled_default=False, key="filter_remaining", device_class=SensorDeviceClass.DURATION, native_unit_of_measurement=UnitOfTime.SECONDS, @@ -204,6 +218,7 @@ SENSOR_DESCRIPTIONS: tuple[TPLinkSensorEntityDescription, ...] = ( convert_fn=_TOTAL_SECONDS_METHOD_CALLER, ), TPLinkSensorEntityDescription( + entity_registry_enabled_default=False, key="filter_used", device_class=SensorDeviceClass.DURATION, native_unit_of_measurement=UnitOfTime.SECONDS, @@ -211,6 +226,7 @@ SENSOR_DESCRIPTIONS: tuple[TPLinkSensorEntityDescription, ...] = ( convert_fn=_TOTAL_SECONDS_METHOD_CALLER, ), TPLinkSensorEntityDescription( + entity_registry_enabled_default=False, key="sensor_remaining", device_class=SensorDeviceClass.DURATION, native_unit_of_measurement=UnitOfTime.SECONDS, @@ -218,6 +234,7 @@ SENSOR_DESCRIPTIONS: tuple[TPLinkSensorEntityDescription, ...] = ( convert_fn=_TOTAL_SECONDS_METHOD_CALLER, ), TPLinkSensorEntityDescription( + entity_registry_enabled_default=False, key="sensor_used", device_class=SensorDeviceClass.DURATION, native_unit_of_measurement=UnitOfTime.SECONDS, @@ -225,6 +242,7 @@ SENSOR_DESCRIPTIONS: tuple[TPLinkSensorEntityDescription, ...] = ( convert_fn=_TOTAL_SECONDS_METHOD_CALLER, ), TPLinkSensorEntityDescription( + entity_registry_enabled_default=False, key="charging_contacts_remaining", device_class=SensorDeviceClass.DURATION, native_unit_of_measurement=UnitOfTime.SECONDS, @@ -232,6 +250,7 @@ SENSOR_DESCRIPTIONS: tuple[TPLinkSensorEntityDescription, ...] = ( convert_fn=_TOTAL_SECONDS_METHOD_CALLER, ), TPLinkSensorEntityDescription( + entity_registry_enabled_default=False, key="charging_contacts_used", device_class=SensorDeviceClass.DURATION, native_unit_of_measurement=UnitOfTime.SECONDS, diff --git a/homeassistant/components/tuya/sensor.py b/homeassistant/components/tuya/sensor.py index f766c744998..756564c6a03 100644 --- a/homeassistant/components/tuya/sensor.py +++ b/homeassistant/components/tuya/sensor.py @@ -45,7 +45,7 @@ class TuyaSensorEntityDescription(SensorEntityDescription): subkey: str | None = None -# Commonly used battery sensors, that are re-used in the sensors down below. +# Commonly used battery sensors, that are reused in the sensors down below. BATTERY_SENSORS: tuple[TuyaSensorEntityDescription, ...] = ( TuyaSensorEntityDescription( key=DPCode.BATTERY_PERCENTAGE, diff --git a/homeassistant/components/ublockout/__init__.py b/homeassistant/components/ublockout/__init__.py new file mode 100644 index 00000000000..87127e331da --- /dev/null +++ b/homeassistant/components/ublockout/__init__.py @@ -0,0 +1 @@ +"""Virtual integration: Ublockout.""" diff --git a/homeassistant/components/ublockout/manifest.json b/homeassistant/components/ublockout/manifest.json new file mode 100644 index 00000000000..d5ef46b8ad2 --- /dev/null +++ b/homeassistant/components/ublockout/manifest.json @@ -0,0 +1,6 @@ +{ + "domain": "ublockout", + "name": "Ublockout", + "integration_type": "virtual", + "supported_by": "motion_blinds" +} diff --git a/homeassistant/components/unifiprotect/manifest.json b/homeassistant/components/unifiprotect/manifest.json index 69c7f8b205b..a4bb6d20841 100644 --- a/homeassistant/components/unifiprotect/manifest.json +++ b/homeassistant/components/unifiprotect/manifest.json @@ -40,7 +40,7 @@ "integration_type": "hub", "iot_class": "local_push", "loggers": ["uiprotect", "unifi_discovery"], - "requirements": ["uiprotect==7.5.0", "unifi-discovery==1.2.0"], + "requirements": ["uiprotect==7.5.1", "unifi-discovery==1.2.0"], "ssdp": [ { "manufacturer": "Ubiquiti Networks", diff --git a/homeassistant/components/upb/__init__.py b/homeassistant/components/upb/__init__.py index c9f3a2df105..ebfc8eaeece 100644 --- a/homeassistant/components/upb/__init__.py +++ b/homeassistant/components/upb/__init__.py @@ -27,6 +27,7 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> b file = config_entry.data[CONF_FILE_PATH] upb = upb_lib.UpbPim({"url": url, "UPStartExportFile": file}) + await upb.load_upstart_file() await upb.async_connect() hass.data.setdefault(DOMAIN, {}) hass.data[DOMAIN][config_entry.entry_id] = {"upb": upb} diff --git a/homeassistant/components/upb/config_flow.py b/homeassistant/components/upb/config_flow.py index 788a0336d73..af1ee7d5ab0 100644 --- a/homeassistant/components/upb/config_flow.py +++ b/homeassistant/components/upb/config_flow.py @@ -40,8 +40,9 @@ async def _validate_input(data): url = _make_url_from_data(data) upb = upb_lib.UpbPim({"url": url, "UPStartExportFile": file_path}) - - await upb.async_connect(_connected_callback) + upb.add_handler("connected", _connected_callback) + await upb.load_upstart_file() + await upb.async_connect() if not upb.config_ok: _LOGGER.error("Missing or invalid UPB file: %s", file_path) diff --git a/homeassistant/components/upb/manifest.json b/homeassistant/components/upb/manifest.json index 1e61747b3f1..e5da4c4d621 100644 --- a/homeassistant/components/upb/manifest.json +++ b/homeassistant/components/upb/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/upb", "iot_class": "local_push", "loggers": ["upb_lib"], - "requirements": ["upb-lib==0.5.9"] + "requirements": ["upb-lib==0.6.0"] } diff --git a/homeassistant/components/upb/services.yaml b/homeassistant/components/upb/services.yaml index cf415705d72..985ce11c436 100644 --- a/homeassistant/components/upb/services.yaml +++ b/homeassistant/components/upb/services.yaml @@ -49,7 +49,7 @@ link_deactivate: target: entity: integration: upb - domain: light + domain: scene link_goto: target: diff --git a/homeassistant/components/vesync/__init__.py b/homeassistant/components/vesync/__init__.py index 27e626faeac..1c55d932425 100644 --- a/homeassistant/components/vesync/__init__.py +++ b/homeassistant/components/vesync/__init__.py @@ -7,6 +7,7 @@ from pyvesync import VeSync from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_PASSWORD, CONF_USERNAME, Platform from homeassistant.core import HomeAssistant, ServiceCall +from homeassistant.helpers import entity_registry as er from homeassistant.helpers.dispatcher import async_dispatcher_send from .common import async_generate_device_list @@ -91,3 +92,37 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: hass.data.pop(DOMAIN) return unload_ok + + +async def async_migrate_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool: + """Migrate old entry.""" + _LOGGER.debug( + "Migrating VeSync config entry: %s minor version: %s", + config_entry.version, + config_entry.minor_version, + ) + if config_entry.minor_version == 1: + # Migrate switch/outlets entity to a new unique ID + _LOGGER.debug("Migrating VeSync config entry from version 1 to version 2") + entity_registry = er.async_get(hass) + registry_entries = er.async_entries_for_config_entry( + entity_registry, config_entry.entry_id + ) + for reg_entry in registry_entries: + if "-" not in reg_entry.unique_id and reg_entry.entity_id.startswith( + Platform.SWITCH + ): + _LOGGER.debug( + "Migrating switch/outlet entity from unique_id: %s to unique_id: %s", + reg_entry.unique_id, + reg_entry.unique_id + "-device_status", + ) + entity_registry.async_update_entity( + reg_entry.entity_id, + new_unique_id=reg_entry.unique_id + "-device_status", + ) + else: + _LOGGER.debug("Skipping entity with unique_id: %s", reg_entry.unique_id) + hass.config_entries.async_update_entry(config_entry, minor_version=2) + + return True diff --git a/homeassistant/components/vesync/config_flow.py b/homeassistant/components/vesync/config_flow.py index e19c46e5490..07543440e91 100644 --- a/homeassistant/components/vesync/config_flow.py +++ b/homeassistant/components/vesync/config_flow.py @@ -24,6 +24,7 @@ class VeSyncFlowHandler(ConfigFlow, domain=DOMAIN): """Handle a config flow.""" VERSION = 1 + MINOR_VERSION = 2 @callback def _show_form(self, errors: dict[str, str] | None = None) -> ConfigFlowResult: diff --git a/homeassistant/components/vesync/humidifier.py b/homeassistant/components/vesync/humidifier.py index 86e0d6b5d87..5afe7360673 100644 --- a/homeassistant/components/vesync/humidifier.py +++ b/homeassistant/components/vesync/humidifier.py @@ -121,6 +121,8 @@ class VeSyncHumidifierHA(VeSyncBaseEntity, HumidifierEntity): self._available_modes.append(ha_mode) self._ha_to_vs_mode_map[ha_mode] = vs_mode + self._available_modes.sort() + def _get_vs_mode(self, ha_mode: str) -> str | None: return self._ha_to_vs_mode_map.get(ha_mode) @@ -155,11 +157,15 @@ class VeSyncHumidifierHA(VeSyncBaseEntity, HumidifierEntity): """Set the mode of the device.""" if mode not in self.available_modes: raise HomeAssistantError( - "{mode} is not one of the valid available modes: {self.available_modes}" + f"{mode} is not one of the valid available modes: {self.available_modes}" ) if not self.device.set_humidity_mode(self._get_vs_mode(mode)): raise HomeAssistantError(f"An error occurred while setting mode {mode}.") + if mode == MODE_SLEEP: + # We successfully changed the mode. Consider it a success even if display operation fails. + self.device.set_display(False) + # Changing mode while humidifier is off actually turns it on, as per the app. But # the library does not seem to update the device_status. It is also possible that # other attributes get updated. Scheduling a forced refresh to get device status. diff --git a/homeassistant/components/vesync/manifest.json b/homeassistant/components/vesync/manifest.json index cdb5ed96652..b3697844f19 100644 --- a/homeassistant/components/vesync/manifest.json +++ b/homeassistant/components/vesync/manifest.json @@ -12,5 +12,5 @@ "documentation": "https://www.home-assistant.io/integrations/vesync", "iot_class": "cloud_polling", "loggers": ["pyvesync"], - "requirements": ["pyvesync==2.1.16"] + "requirements": ["pyvesync==2.1.17"] } diff --git a/homeassistant/components/vesync/switch.py b/homeassistant/components/vesync/switch.py index ef8e6c6051f..efae1192406 100644 --- a/homeassistant/components/vesync/switch.py +++ b/homeassistant/components/vesync/switch.py @@ -83,6 +83,7 @@ class VeSyncSwitchHA(VeSyncBaseSwitch, SwitchEntity): ) -> None: """Initialize the VeSync switch device.""" super().__init__(plug, coordinator) + self._attr_unique_id = f"{super().unique_id}-device_status" self.smartplug = plug @@ -94,4 +95,5 @@ class VeSyncLightSwitch(VeSyncBaseSwitch, SwitchEntity): ) -> None: """Initialize Light Switch device class.""" super().__init__(switch, coordinator) + self._attr_unique_id = f"{super().unique_id}-device_status" self.switch = switch diff --git a/homeassistant/components/vicare/binary_sensor.py b/homeassistant/components/vicare/binary_sensor.py index 61a5abce942..9d216404156 100644 --- a/homeassistant/components/vicare/binary_sensor.py +++ b/homeassistant/components/vicare/binary_sensor.py @@ -106,6 +106,12 @@ GLOBAL_SENSORS: tuple[ViCareBinarySensorEntityDescription, ...] = ( device_class=BinarySensorDeviceClass.RUNNING, value_getter=lambda api: api.getDomesticHotWaterPumpActive(), ), + ViCareBinarySensorEntityDescription( + key="one_time_charge", + translation_key="one_time_charge", + device_class=BinarySensorDeviceClass.RUNNING, + value_getter=lambda api: api.getOneTimeCharge(), + ), ) diff --git a/homeassistant/components/vicare/icons.json b/homeassistant/components/vicare/icons.json index 52148b1fa32..c54be7af0d5 100644 --- a/homeassistant/components/vicare/icons.json +++ b/homeassistant/components/vicare/icons.json @@ -18,6 +18,9 @@ }, "domestic_hot_water_pump": { "default": "mdi:pump" + }, + "one_time_charge": { + "default": "mdi:shower-head" } }, "button": { diff --git a/homeassistant/components/vicare/strings.json b/homeassistant/components/vicare/strings.json index 26ca0f5a264..50eeaf038e0 100644 --- a/homeassistant/components/vicare/strings.json +++ b/homeassistant/components/vicare/strings.json @@ -63,6 +63,9 @@ }, "domestic_hot_water_pump": { "name": "DHW pump" + }, + "one_time_charge": { + "name": "One-time charge" } }, "button": { diff --git a/homeassistant/components/vodafone_station/__init__.py b/homeassistant/components/vodafone_station/__init__.py index b4c44ea9130..871afe09a2e 100644 --- a/homeassistant/components/vodafone_station/__init__.py +++ b/homeassistant/components/vodafone_station/__init__.py @@ -17,7 +17,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: entry.data[CONF_HOST], entry.data[CONF_USERNAME], entry.data[CONF_PASSWORD], - entry.unique_id, + entry, ) await coordinator.async_config_entry_first_refresh() diff --git a/homeassistant/components/vodafone_station/coordinator.py b/homeassistant/components/vodafone_station/coordinator.py index de794488040..cd640d10cb6 100644 --- a/homeassistant/components/vodafone_station/coordinator.py +++ b/homeassistant/components/vodafone_station/coordinator.py @@ -8,13 +8,16 @@ from typing import Any from aiovodafone import VodafoneStationDevice, VodafoneStationSercommApi, exceptions from homeassistant.components.device_tracker import DEFAULT_CONSIDER_HOME +from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryAuthFailed +from homeassistant.helpers import device_registry as dr from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from homeassistant.util import dt as dt_util from .const import _LOGGER, DOMAIN, SCAN_INTERVAL +from .helpers import cleanup_device_tracker CONSIDER_HOME_SECONDS = DEFAULT_CONSIDER_HOME.total_seconds() @@ -39,13 +42,15 @@ class UpdateCoordinatorDataType: class VodafoneStationRouter(DataUpdateCoordinator[UpdateCoordinatorDataType]): """Queries router running Vodafone Station firmware.""" + config_entry: ConfigEntry + def __init__( self, hass: HomeAssistant, host: str, username: str, password: str, - config_entry_unique_id: str | None, + config_entry: ConfigEntry, ) -> None: """Initialize the scanner.""" @@ -53,14 +58,26 @@ class VodafoneStationRouter(DataUpdateCoordinator[UpdateCoordinatorDataType]): self.api = VodafoneStationSercommApi(host, username, password) # Last resort as no MAC or S/N can be retrieved via API - self._id = config_entry_unique_id + self._id = config_entry.unique_id super().__init__( hass=hass, logger=_LOGGER, name=f"{DOMAIN}-{host}-coordinator", update_interval=timedelta(seconds=SCAN_INTERVAL), + config_entry=config_entry, ) + device_reg = dr.async_get(self.hass) + device_list = dr.async_entries_for_config_entry( + device_reg, self.config_entry.entry_id + ) + + self.previous_devices = { + connection[1].upper() + for device in device_list + for connection in device.connections + if connection[0] == dr.CONNECTION_NETWORK_MAC + } def _calculate_update_time_and_consider_home( self, device: VodafoneStationDevice, utc_point_in_time: datetime @@ -125,6 +142,18 @@ class VodafoneStationRouter(DataUpdateCoordinator[UpdateCoordinatorDataType]): ) for dev_info in (raw_data_devices).values() } + current_devices = set(data_devices) + _LOGGER.debug( + "Loaded current %s devices: %s", len(current_devices), current_devices + ) + if stale_devices := self.previous_devices - current_devices: + _LOGGER.debug( + "Found %s stale devices: %s", len(stale_devices), stale_devices + ) + await cleanup_device_tracker(self.hass, self.config_entry, data_devices) + + self.previous_devices = current_devices + return UpdateCoordinatorDataType(data_devices, data_sensors) @property diff --git a/homeassistant/components/vodafone_station/device_tracker.py b/homeassistant/components/vodafone_station/device_tracker.py index 3e4d7763bff..4af0b85e003 100644 --- a/homeassistant/components/vodafone_station/device_tracker.py +++ b/homeassistant/components/vodafone_station/device_tracker.py @@ -61,6 +61,7 @@ class VodafoneStationTracker(CoordinatorEntity[VodafoneStationRouter], ScannerEn """Representation of a Vodafone Station device.""" _attr_translation_key = "device_tracker" + _attr_has_entity_name = True mac_address: str def __init__( @@ -72,7 +73,9 @@ class VodafoneStationTracker(CoordinatorEntity[VodafoneStationRouter], ScannerEn mac = device_info.device.mac self._attr_mac_address = mac self._attr_unique_id = mac - self._attr_hostname = device_info.device.name or mac.replace(":", "_") + self._attr_hostname = self._attr_name = device_info.device.name or mac.replace( + ":", "_" + ) @property def _device_info(self) -> VodafoneStationDeviceInfo: diff --git a/homeassistant/components/vodafone_station/helpers.py b/homeassistant/components/vodafone_station/helpers.py new file mode 100644 index 00000000000..aa0fda3f6be --- /dev/null +++ b/homeassistant/components/vodafone_station/helpers.py @@ -0,0 +1,72 @@ +"""Vodafone Station helpers.""" + +from typing import Any + +from homeassistant.components.device_tracker import DOMAIN as DEVICE_TRACKER_DOMAIN +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.helpers import device_registry as dr, entity_registry as er + +from .const import _LOGGER + + +async def cleanup_device_tracker( + hass: HomeAssistant, config_entry: ConfigEntry, devices: dict[str, Any] +) -> None: + """Cleanup stale device tracker.""" + entity_reg: er.EntityRegistry = er.async_get(hass) + + entities_removed: bool = False + + device_hosts_macs: set[str] = set() + device_hosts_names: set[str] = set() + for mac, device_info in devices.items(): + device_hosts_macs.add(mac) + device_hosts_names.add(device_info.device.name) + + for entry in er.async_entries_for_config_entry(entity_reg, config_entry.entry_id): + if entry.domain != DEVICE_TRACKER_DOMAIN: + continue + entry_name = entry.name or entry.original_name + entry_host = entry_name.partition(" ")[0] if entry_name else None + entry_mac = entry.unique_id.partition("_")[0] + + # Some devices, mainly routers, allow to change the hostname of the connected devices. + # This can lead to entities no longer aligned to the device UI + if ( + entry_host + and entry_host in device_hosts_names + and entry_mac in device_hosts_macs + ): + _LOGGER.debug( + "Skipping entity %s [mac=%s, host=%s]", + entry_name, + entry_mac, + entry_host, + ) + continue + # Entity is removed so that at the next coordinator update + # the correct one will be created + _LOGGER.info("Removing entity: %s", entry_name) + entity_reg.async_remove(entry.entity_id) + entities_removed = True + + if entities_removed: + _async_remove_empty_devices(hass, entity_reg, config_entry) + + +def _async_remove_empty_devices( + hass: HomeAssistant, entity_reg: er.EntityRegistry, config_entry: ConfigEntry +) -> None: + """Remove devices with no entities.""" + + device_reg = dr.async_get(hass) + device_list = dr.async_entries_for_config_entry(device_reg, config_entry.entry_id) + for device_entry in device_list: + if not er.async_entries_for_device( + entity_reg, + device_entry.id, + include_disabled_entities=True, + ): + _LOGGER.info("Removing device: %s", device_entry.name) + device_reg.async_remove_device(device_entry.id) diff --git a/homeassistant/components/xiaomi_ble/config_flow.py b/homeassistant/components/xiaomi_ble/config_flow.py index df2de381d39..c293d7832d0 100644 --- a/homeassistant/components/xiaomi_ble/config_flow.py +++ b/homeassistant/components/xiaomi_ble/config_flow.py @@ -306,7 +306,7 @@ class XiaomiConfigFlow(ConfigFlow, domain=DOMAIN): return self._async_get_or_create_entry() - current_addresses = self._async_current_ids() + current_addresses = self._async_current_ids(include_ignore=False) for discovery_info in async_discovered_service_info(self.hass, False): address = discovery_info.address if address in current_addresses or address in self._discovered_devices: diff --git a/homeassistant/components/yale/manifest.json b/homeassistant/components/yale/manifest.json index f1cde31d066..5c8e98b1e6e 100644 --- a/homeassistant/components/yale/manifest.json +++ b/homeassistant/components/yale/manifest.json @@ -13,5 +13,5 @@ "documentation": "https://www.home-assistant.io/integrations/yale", "iot_class": "cloud_push", "loggers": ["socketio", "engineio", "yalexs"], - "requirements": ["yalexs==8.10.0", "yalexs-ble==2.5.6"] + "requirements": ["yalexs==8.10.0", "yalexs-ble==2.5.7"] } diff --git a/homeassistant/components/yalexs_ble/config_flow.py b/homeassistant/components/yalexs_ble/config_flow.py index 6de74759686..0e1eabdf6b2 100644 --- a/homeassistant/components/yalexs_ble/config_flow.py +++ b/homeassistant/components/yalexs_ble/config_flow.py @@ -267,7 +267,7 @@ class YalexsConfigFlow(ConfigFlow, domain=DOMAIN): if discovery := self._discovery_info: self._discovered_devices[discovery.address] = discovery else: - current_addresses = self._async_current_ids() + current_addresses = self._async_current_ids(include_ignore=False) current_unique_names = { entry.data.get(CONF_LOCAL_NAME) for entry in self._async_current_entries() diff --git a/homeassistant/components/yalexs_ble/manifest.json b/homeassistant/components/yalexs_ble/manifest.json index 15b11719fdb..c44f0fdd1e9 100644 --- a/homeassistant/components/yalexs_ble/manifest.json +++ b/homeassistant/components/yalexs_ble/manifest.json @@ -12,5 +12,5 @@ "dependencies": ["bluetooth_adapters"], "documentation": "https://www.home-assistant.io/integrations/yalexs_ble", "iot_class": "local_push", - "requirements": ["yalexs-ble==2.5.6"] + "requirements": ["yalexs-ble==2.5.7"] } diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index 3c8a1d40dc2..d0a8e821f8d 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -289,6 +289,7 @@ FLOWS = { "inkbird", "insteon", "intellifire", + "iometer", "ios", "iotawatt", "iotty", diff --git a/homeassistant/generated/dhcp.py b/homeassistant/generated/dhcp.py index b9d51ac1006..3dba5a98f3c 100644 --- a/homeassistant/generated/dhcp.py +++ b/homeassistant/generated/dhcp.py @@ -616,6 +616,10 @@ DHCP: Final[list[dict[str, str | bool]]] = [ "hostname": "hub*", "macaddress": "286D97*", }, + { + "domain": "smlight", + "registered_devices": True, + }, { "domain": "solaredge", "hostname": "target", diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index cab624ecb5b..026eab30f8f 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -2521,6 +2521,11 @@ "config_flow": false, "iot_class": "local_polling" }, + "heicko": { + "name": "Heicko", + "integration_type": "virtual", + "supported_by": "motion_blinds" + }, "heiwa": { "name": "Heiwa", "integration_type": "virtual", @@ -2924,6 +2929,12 @@ "config_flow": false, "iot_class": "cloud_push" }, + "iometer": { + "name": "IOmeter", + "integration_type": "device", + "config_flow": true, + "iot_class": "local_polling" + }, "ios": { "name": "Home Assistant iOS", "integration_type": "hub", @@ -3410,6 +3421,11 @@ "config_flow": false, "iot_class": "local_polling" }, + "linx": { + "name": "Linx", + "integration_type": "virtual", + "supported_by": "motion_blinds" + }, "lirc": { "name": "LIRC", "integration_type": "hub", @@ -5801,6 +5817,11 @@ "config_flow": true, "iot_class": "cloud_polling" }, + "smart_rollos": { + "name": "Smart Rollos", + "integration_type": "virtual", + "supported_by": "motion_blinds" + }, "smarther": { "name": "Smarther", "integration_type": "virtual", @@ -6736,6 +6757,11 @@ "integration_type": "virtual", "supported_by": "overkiz" }, + "ublockout": { + "name": "Ublockout", + "integration_type": "virtual", + "supported_by": "motion_blinds" + }, "uk_transport": { "name": "UK Transport", "integration_type": "hub", diff --git a/homeassistant/generated/zeroconf.py b/homeassistant/generated/zeroconf.py index be15d88aec2..8244f19660f 100644 --- a/homeassistant/generated/zeroconf.py +++ b/homeassistant/generated/zeroconf.py @@ -614,6 +614,11 @@ ZEROCONF = { "domain": "homewizard", }, ], + "_iometer._tcp.local.": [ + { + "domain": "iometer", + }, + ], "_ipp._tcp.local.": [ { "domain": "ipp", diff --git a/homeassistant/helpers/chat_session.py b/homeassistant/helpers/chat_session.py new file mode 100644 index 00000000000..686272dd834 --- /dev/null +++ b/homeassistant/helpers/chat_session.py @@ -0,0 +1,161 @@ +"""Helper to organize chat sessions between integrations.""" + +from __future__ import annotations + +from collections.abc import Generator +from contextlib import contextmanager +from contextvars import ContextVar +from dataclasses import dataclass, field +from datetime import datetime, timedelta + +from homeassistant.const import EVENT_HOMEASSISTANT_STOP +from homeassistant.core import ( + CALLBACK_TYPE, + Event, + HassJob, + HassJobType, + HomeAssistant, + callback, +) +from homeassistant.util import dt as dt_util +from homeassistant.util.hass_dict import HassKey +from homeassistant.util.ulid import ulid_now, ulid_to_bytes + +from .event import async_call_later + +DATA_CHAT_SESSION: HassKey[dict[str, ChatSession]] = HassKey("chat_session") +DATA_CHAT_SESSION_CLEANUP: HassKey[SessionCleanup] = HassKey("chat_session_cleanup") + +CONVERSATION_TIMEOUT = timedelta(minutes=5) + +current_session: ContextVar[ChatSession | None] = ContextVar( + "current_session", default=None +) + + +@dataclass +class ChatSession: + """Represent a chat session.""" + + conversation_id: str + last_updated: datetime = field(default_factory=dt_util.utcnow) + _cleanup_callbacks: list[CALLBACK_TYPE] = field(default_factory=list) + + @callback + def async_updated(self) -> None: + """Update the last updated time.""" + self.last_updated = dt_util.utcnow() + + @callback + def async_on_cleanup(self, cb: CALLBACK_TYPE) -> None: + """Register a callback to clean up the session.""" + self._cleanup_callbacks.append(cb) + + @callback + def async_cleanup(self) -> None: + """Call all clean up callbacks.""" + for cb in self._cleanup_callbacks: + cb() + self._cleanup_callbacks.clear() + + +class SessionCleanup: + """Helper to clean up the stale sessions.""" + + unsub: CALLBACK_TYPE | None = None + + def __init__(self, hass: HomeAssistant) -> None: + """Initialize the session cleanup.""" + self.hass = hass + hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, self._on_hass_stop) + self.cleanup_job = HassJob( + self._cleanup, "chat_session_cleanup", job_type=HassJobType.Callback + ) + + @callback + def schedule(self) -> None: + """Schedule the cleanup.""" + if self.unsub: + return + self.unsub = async_call_later( + self.hass, + CONVERSATION_TIMEOUT.total_seconds() + 1, + self.cleanup_job, + ) + + @callback + def _on_hass_stop(self, event: Event) -> None: + """Cancel the cleanup on shutdown.""" + if self.unsub: + self.unsub() + self.unsub = None + + @callback + def _cleanup(self, now: datetime) -> None: + """Clean up the history and schedule follow-up if necessary.""" + self.unsub = None + all_sessions = self.hass.data[DATA_CHAT_SESSION] + + # We mutate original object because current commands could be + # yielding session based on it. + for conversation_id, session in list(all_sessions.items()): + if session.last_updated + CONVERSATION_TIMEOUT < now: + del all_sessions[conversation_id] + session.async_cleanup() + + # Still conversations left, check again in timeout time. + if all_sessions: + self.schedule() + + +@contextmanager +def async_get_chat_session( + hass: HomeAssistant, + conversation_id: str | None = None, +) -> Generator[ChatSession]: + """Return a chat session.""" + if session := current_session.get(): + # If a session is already active and it's the requested conversation ID, + # return that. We won't update the last updated time in this case. + if session.conversation_id == conversation_id: + yield session + return + + # If it's not the same conversation ID, we will create a new session + # because it might be a conversation agent calling a tool that is talking + # to another LLM. + session = None + + all_sessions = hass.data.get(DATA_CHAT_SESSION) + if all_sessions is None: + all_sessions = {} + hass.data[DATA_CHAT_SESSION] = all_sessions + hass.data[DATA_CHAT_SESSION_CLEANUP] = SessionCleanup(hass) + + if conversation_id is None: + conversation_id = ulid_now() + + elif conversation_id in all_sessions: + session = all_sessions[conversation_id] + + else: + # Conversation IDs are ULIDs. We generate a new one if not provided. + # If an old ULID is passed in, we will generate a new one to indicate + # a new conversation was started. If the user picks their own, they + # want to track a conversation and we respect it. + try: + ulid_to_bytes(conversation_id) + conversation_id = ulid_now() + except ValueError: + pass + + if session is None: + session = ChatSession(conversation_id) + + current_session.set(session) + yield session + current_session.set(None) + + session.last_updated = dt_util.utcnow() + all_sessions[conversation_id] = session + hass.data[DATA_CHAT_SESSION_CLEANUP].schedule() diff --git a/homeassistant/helpers/debounce.py b/homeassistant/helpers/debounce.py index 83555b56dcb..c46c6806d5d 100644 --- a/homeassistant/helpers/debounce.py +++ b/homeassistant/helpers/debounce.py @@ -146,6 +146,10 @@ class Debouncer[_R_co]: """Cancel any scheduled call, and prevent new runs.""" self._shutdown_requested = True self.async_cancel() + # Release hard references to parent function + # https://github.com/home-assistant/core/issues/137237 + self._function = None + self._job = None @callback def async_cancel(self) -> None: diff --git a/homeassistant/helpers/llm.py b/homeassistant/helpers/llm.py index cc397c5d428..b7c4951d8de 100644 --- a/homeassistant/helpers/llm.py +++ b/homeassistant/helpers/llm.py @@ -4,7 +4,7 @@ from __future__ import annotations from abc import ABC, abstractmethod from collections.abc import Callable -from dataclasses import dataclass +from dataclasses import dataclass, field as dc_field from datetime import timedelta from decimal import Decimal from enum import Enum @@ -36,6 +36,7 @@ from homeassistant.exceptions import HomeAssistantError from homeassistant.util import dt as dt_util, yaml as yaml_util from homeassistant.util.hass_dict import HassKey from homeassistant.util.json import JsonObjectType +from homeassistant.util.ulid import ulid_now from . import ( area_registry as ar, @@ -139,6 +140,8 @@ class ToolInput: tool_name: str tool_args: dict[str, Any] + # Using lambda for default to allow patching in tests + id: str = dc_field(default_factory=lambda: ulid_now()) # pylint: disable=unnecessary-lambda class Tool: @@ -326,12 +329,21 @@ class AssistAPI(API): def _async_get_api_prompt( self, llm_context: LLMContext, exposed_entities: dict | None ) -> str: - """Return the prompt for the API.""" if not exposed_entities: return ( "Only if the user wants to control a device, tell them to expose entities " "to their voice assistant in Home Assistant." ) + return "\n".join( + [ + *self._async_get_preable(llm_context), + *self._async_get_exposed_entities_prompt(llm_context, exposed_entities), + ] + ) + + @callback + def _async_get_preable(self, llm_context: LLMContext) -> list[str]: + """Return the prompt for the API.""" prompt = [ ( @@ -371,13 +383,22 @@ class AssistAPI(API): ): prompt.append("This device is not able to start timers.") + return prompt + + @callback + def _async_get_exposed_entities_prompt( + self, llm_context: LLMContext, exposed_entities: dict | None + ) -> list[str]: + """Return the prompt for the API for exposed entities.""" + prompt = [] + if exposed_entities: prompt.append( "An overview of the areas and the devices in this smart home:" ) prompt.append(yaml_util.dump(list(exposed_entities.values()))) - return "\n".join(prompt) + return prompt @callback def _async_get_tools( diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 3d3c43470f7..bf9b7262194 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -1,43 +1,43 @@ # Automatically generated by gen_requirements_all.py, do not edit -aiodhcpwatcher==1.0.2 -aiodiscover==2.1.0 +aiodhcpwatcher==1.1.0 +aiodiscover==2.2.2 aiodns==3.2.0 aiohasupervisor==0.2.2b6 aiohttp-asyncmdnsresolver==0.0.3 -aiohttp-fast-zlib==0.2.0 +aiohttp-fast-zlib==0.2.2 aiohttp==3.11.11 aiohttp_cors==0.7.0 aiousbwatcher==1.1.1 -aiozoneinfo==0.2.1 +aiozoneinfo==0.2.3 astral==2.2 -async-interrupt==1.2.0 +async-interrupt==1.2.1 async-upnp-client==0.43.0 atomicwrites-homeassistant==1.4.1 -attrs==24.2.0 +attrs==25.1.0 audioop-lts==0.2.1;python_version>='3.13' av==13.1.0 awesomeversion==24.6.0 bcrypt==4.2.0 -bleak-retry-connector==3.8.0 +bleak-retry-connector==3.8.1 bleak==0.22.3 -bluetooth-adapters==0.21.1 +bluetooth-adapters==0.21.4 bluetooth-auto-recovery==1.4.2 -bluetooth-data-tools==1.22.0 +bluetooth-data-tools==1.23.4 cached-ipaddress==0.8.0 certifi>=2021.5.30 ciso8601==2.3.2 cronsim==2.6 cryptography==44.0.0 -dbus-fast==2.30.2 +dbus-fast==2.32.0 fnv-hash-fast==1.2.2 go2rtc-client==0.1.2 ha-ffmpeg==3.2.2 -habluetooth==3.20.1 -hass-nabucasa==0.88.1 +habluetooth==3.21.1 +hass-nabucasa==0.89.0 hassil==2.2.0 -home-assistant-bluetooth==1.13.0 -home-assistant-frontend==20250131.0 +home-assistant-bluetooth==1.13.1 +home-assistant-frontend==20250204.0 home-assistant-intents==2025.1.28 httpx==0.28.1 ifaddr==0.2.0 @@ -46,14 +46,14 @@ lru-dict==1.3.0 mutagen==1.47.0 orjson==3.10.12 packaging>=23.1 -paho-mqtt==1.6.1 +paho-mqtt==2.1.0 Pillow==11.1.0 propcache==0.2.1 psutil-home-assistant==0.0.1 PyJWT==2.10.1 pymicro-vad==1.0.1 PyNaCl==1.5.0 -pyOpenSSL==24.3.0 +pyOpenSSL==25.0.0 pyserial==3.5 pyspeex-noise==1.0.2 python-slugify==8.0.4 @@ -67,7 +67,7 @@ standard-telnetlib==3.13.0;python_version>='3.13' typing-extensions>=4.12.2,<5.0 ulid-transform==1.2.0 urllib3>=1.26.5,<2 -uv==0.5.21 +uv==0.5.27 voluptuous-openapi==0.0.6 voluptuous-serialize==2.6.0 voluptuous==0.15.2 diff --git a/pylint/plugins/hass_enforce_class_module.py b/pylint/plugins/hass_enforce_class_module.py index 09fe61b68c6..cc7b33d9946 100644 --- a/pylint/plugins/hass_enforce_class_module.py +++ b/pylint/plugins/hass_enforce_class_module.py @@ -140,7 +140,7 @@ class HassEnforceClassModule(BaseChecker): for ancestor in top_level_ancestors: if ancestor.name in _BASE_ENTITY_MODULES and not any( - anc.name in _MODULE_CLASSES for anc in ancestors + parent.name in _MODULE_CLASSES for parent in ancestors ): self.add_message( "hass-enforce-class-module", diff --git a/pyproject.toml b/pyproject.toml index afed8fd7091..f1baf85cdf3 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -30,12 +30,12 @@ dependencies = [ "aiohasupervisor==0.2.2b6", "aiohttp==3.11.11", "aiohttp_cors==0.7.0", - "aiohttp-fast-zlib==0.2.0", + "aiohttp-fast-zlib==0.2.2", "aiohttp-asyncmdnsresolver==0.0.3", - "aiozoneinfo==0.2.1", + "aiozoneinfo==0.2.3", "astral==2.2", - "async-interrupt==1.2.0", - "attrs==24.2.0", + "async-interrupt==1.2.1", + "attrs==25.1.0", "atomicwrites-homeassistant==1.4.1", "audioop-lts==0.2.1;python_version>='3.13'", "awesomeversion==24.6.0", @@ -46,11 +46,11 @@ dependencies = [ "fnv-hash-fast==1.2.2", # hass-nabucasa is imported by helpers which don't depend on the cloud # integration - "hass-nabucasa==0.88.1", + "hass-nabucasa==0.89.0", # When bumping httpx, please check the version pins of # httpcore, anyio, and h11 in gen_requirements_all "httpx==0.28.1", - "home-assistant-bluetooth==1.13.0", + "home-assistant-bluetooth==1.13.1", "ifaddr==0.2.0", "Jinja2==3.1.5", "lru-dict==1.3.0", @@ -59,7 +59,7 @@ dependencies = [ "cryptography==44.0.0", "Pillow==11.1.0", "propcache==0.2.1", - "pyOpenSSL==24.3.0", + "pyOpenSSL==25.0.0", "orjson==3.10.12", "packaging>=23.1", "psutil-home-assistant==0.0.1", @@ -76,7 +76,7 @@ dependencies = [ # Temporary setting an upper bound, to prevent compat issues with urllib3>=2 # https://github.com/home-assistant/core/issues/97248 "urllib3>=1.26.5,<2", - "uv==0.5.21", + "uv==0.5.27", "voluptuous==0.15.2", "voluptuous-serialize==2.6.0", "voluptuous-openapi==0.0.6", diff --git a/requirements.txt b/requirements.txt index a58065a3a7a..1a80837e2cc 100644 --- a/requirements.txt +++ b/requirements.txt @@ -7,12 +7,12 @@ aiodns==3.2.0 aiohasupervisor==0.2.2b6 aiohttp==3.11.11 aiohttp_cors==0.7.0 -aiohttp-fast-zlib==0.2.0 +aiohttp-fast-zlib==0.2.2 aiohttp-asyncmdnsresolver==0.0.3 -aiozoneinfo==0.2.1 +aiozoneinfo==0.2.3 astral==2.2 -async-interrupt==1.2.0 -attrs==24.2.0 +async-interrupt==1.2.1 +attrs==25.1.0 atomicwrites-homeassistant==1.4.1 audioop-lts==0.2.1;python_version>='3.13' awesomeversion==24.6.0 @@ -21,9 +21,9 @@ certifi>=2021.5.30 ciso8601==2.3.2 cronsim==2.6 fnv-hash-fast==1.2.2 -hass-nabucasa==0.88.1 +hass-nabucasa==0.89.0 httpx==0.28.1 -home-assistant-bluetooth==1.13.0 +home-assistant-bluetooth==1.13.1 ifaddr==0.2.0 Jinja2==3.1.5 lru-dict==1.3.0 @@ -31,7 +31,7 @@ PyJWT==2.10.1 cryptography==44.0.0 Pillow==11.1.0 propcache==0.2.1 -pyOpenSSL==24.3.0 +pyOpenSSL==25.0.0 orjson==3.10.12 packaging>=23.1 psutil-home-assistant==0.0.1 @@ -45,7 +45,7 @@ standard-telnetlib==3.13.0;python_version>='3.13' typing-extensions>=4.12.2,<5.0 ulid-transform==1.2.0 urllib3>=1.26.5,<2 -uv==0.5.21 +uv==0.5.27 voluptuous==0.15.2 voluptuous-serialize==2.6.0 voluptuous-openapi==0.0.6 diff --git a/requirements_all.txt b/requirements_all.txt index 56870467284..b643ebc3a87 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -216,10 +216,10 @@ aiobotocore==2.13.1 aiocomelit==0.10.1 # homeassistant.components.dhcp -aiodhcpwatcher==1.0.2 +aiodhcpwatcher==1.1.0 # homeassistant.components.dhcp -aiodiscover==2.1.0 +aiodiscover==2.2.2 # homeassistant.components.dnsip aiodns==3.2.0 @@ -264,7 +264,7 @@ aioharmony==0.4.1 aiohasupervisor==0.2.2b6 # homeassistant.components.home_connect -aiohomeconnect==0.12.1 +aiohomeconnect==0.12.3 # homeassistant.components.homekit_controller aiohomekit==3.2.7 @@ -315,10 +315,10 @@ aionanoleaf==0.2.1 aionotion==2024.03.0 # homeassistant.components.nut -aionut==4.3.3 +aionut==4.3.4 # homeassistant.components.oncue -aiooncue==0.3.7 +aiooncue==0.3.9 # homeassistant.components.openexchangerates aioopenexchangerates==0.6.8 @@ -383,7 +383,7 @@ aioslimproto==3.0.0 aiosolaredge==0.2.0 # homeassistant.components.steamist -aiosteamist==1.0.0 +aiosteamist==1.0.1 # homeassistant.components.cambridge_audio aiostreammagic==2.10.0 @@ -494,7 +494,7 @@ apsystems-ez1==2.4.0 aqualogic==2.6 # homeassistant.components.aranet -aranet4==2.5.0 +aranet4==2.5.1 # homeassistant.components.arcam_fmj arcam-fmj==1.5.2 @@ -597,10 +597,10 @@ bizkaibus==0.1.1 # homeassistant.components.eq3btsmart # homeassistant.components.esphome -bleak-esphome==2.6.0 +bleak-esphome==2.7.1 # homeassistant.components.bluetooth -bleak-retry-connector==3.8.0 +bleak-retry-connector==3.8.1 # homeassistant.components.bluetooth bleak==0.22.3 @@ -625,7 +625,7 @@ bluemaestro-ble==0.2.3 # bluepy==1.3.0 # homeassistant.components.bluetooth -bluetooth-adapters==0.21.1 +bluetooth-adapters==0.21.4 # homeassistant.components.bluetooth bluetooth-auto-recovery==1.4.2 @@ -634,7 +634,7 @@ bluetooth-auto-recovery==1.4.2 # homeassistant.components.ld2410_ble # homeassistant.components.led_ble # homeassistant.components.private_ble_device -bluetooth-data-tools==1.22.0 +bluetooth-data-tools==1.23.4 # homeassistant.components.bond bond-async==0.2.1 @@ -668,7 +668,7 @@ brunt==1.2.0 bt-proximity==0.2.1 # homeassistant.components.bthome -bthome-ble==3.12.3 +bthome-ble==3.12.4 # homeassistant.components.bt_home_hub_5 bthomehub5-devicelist==0.1.1 @@ -738,7 +738,7 @@ datadog==0.15.0 datapoint==0.9.9 # homeassistant.components.bluetooth -dbus-fast==2.30.2 +dbus-fast==2.32.0 # homeassistant.components.debugpy debugpy==1.8.11 @@ -750,7 +750,7 @@ debugpy==1.8.11 # decora==0.6 # homeassistant.components.ecovacs -deebot-client==11.1.0b2 +deebot-client==12.0.0 # homeassistant.components.ihc # homeassistant.components.namecheapdns @@ -1052,7 +1052,7 @@ goslide-api==0.7.0 gotailwind==0.3.0 # homeassistant.components.govee_ble -govee-ble==0.42.0 +govee-ble==0.42.1 # homeassistant.components.govee_light_local govee-local-api==1.5.3 @@ -1100,13 +1100,13 @@ ha-iotawattpy==0.1.2 ha-philipsjs==3.2.2 # homeassistant.components.habitica -habiticalib==0.3.3 +habiticalib==0.3.4 # homeassistant.components.bluetooth -habluetooth==3.20.1 +habluetooth==3.21.1 # homeassistant.components.cloud -hass-nabucasa==0.88.1 +hass-nabucasa==0.89.0 # homeassistant.components.splunk hass-splunk==0.1.1 @@ -1146,7 +1146,7 @@ hole==0.8.0 holidays==0.65 # homeassistant.components.frontend -home-assistant-frontend==20250131.0 +home-assistant-frontend==20250204.0 # homeassistant.components.conversation home-assistant-intents==2025.1.28 @@ -1228,6 +1228,9 @@ insteon-frontend-home-assistant==0.5.0 # homeassistant.components.intellifire intellifire4py==4.1.9 +# homeassistant.components.iometer +iometer==0.1.0 + # homeassistant.components.iotty iottycloud==0.3.0 @@ -1299,7 +1302,7 @@ ld2410-ble==0.1.1 leaone-ble==0.1.0 # homeassistant.components.led_ble -led-ble==1.1.4 +led-ble==1.1.6 # homeassistant.components.lektrico lektricowifi==0.0.43 @@ -1405,7 +1408,7 @@ microBeesPy==0.3.5 mill-local==0.3.0 # homeassistant.components.mill -millheater==0.12.2 +millheater==0.12.3 # homeassistant.components.minio minio==7.1.12 @@ -1434,9 +1437,6 @@ motioneye-client==0.3.14 # homeassistant.components.bang_olufsen mozart-api==4.1.1.116.4 -# homeassistant.components.onedrive -msgraph-sdk==1.16.0 - # homeassistant.components.mullvad mullvad-api==1.0.0 @@ -1477,7 +1477,7 @@ nettigo-air-monitor==4.0.0 neurio==0.3.1 # homeassistant.components.nexia -nexia==2.0.8 +nexia==2.0.9 # homeassistant.components.nextcloud nextcloudmonitor==1.5.1 @@ -1489,7 +1489,7 @@ nextcord==2.6.0 nextdns==4.0.0 # homeassistant.components.niko_home_control -nhc==0.3.9 +nhc==0.4.4 # homeassistant.components.nibe_heatpump nibe==2.14.0 @@ -1558,6 +1558,9 @@ omnilogic==0.4.5 # homeassistant.components.ondilo_ico ondilo==0.5.0 +# homeassistant.components.onedrive +onedrive-personal-sdk==0.0.4 + # homeassistant.components.onvif onvif-zeep-async==3.2.5 @@ -1568,7 +1571,7 @@ open-garage==0.2.0 open-meteo==0.3.2 # homeassistant.components.openai_conversation -openai==1.59.9 +openai==1.61.0 # homeassistant.components.openerz openerz-api==0.3.0 @@ -1613,7 +1616,7 @@ ovoenergy==2.0.0 p1monitor==3.1.0 # homeassistant.components.mqtt -paho-mqtt==1.6.1 +paho-mqtt==2.1.0 # homeassistant.components.panasonic_bluray panacotta==0.2 @@ -1909,7 +1912,7 @@ pyebox==1.1.4 pyecoforest==0.4.0 # homeassistant.components.econet -pyeconet==0.1.23 +pyeconet==0.1.26 # homeassistant.components.ista_ecotrend pyecotrend-ista==3.3.1 @@ -2190,7 +2193,7 @@ pyotgw==2.2.2 pyotp==2.8.0 # homeassistant.components.overkiz -pyoverkiz==1.15.5 +pyoverkiz==1.16.0 # homeassistant.components.onewire pyownet==0.10.0.post1 @@ -2202,7 +2205,7 @@ pypalazzetti==0.1.19 pypca==0.0.7 # homeassistant.components.lcn -pypck==0.8.3 +pypck==0.8.5 # homeassistant.components.pjlink pypjlink2==1.2.1 @@ -2310,7 +2313,7 @@ pysmarty2==0.10.1 pysml==0.0.12 # homeassistant.components.smlight -pysmlight==0.2.1 +pysmlight==0.2.2 # homeassistant.components.snmp pysnmp==6.2.6 @@ -2388,7 +2391,7 @@ python-gitlab==1.6.0 python-google-drive-api==0.0.2 # homeassistant.components.analytics_insights -python-homeassistant-analytics==0.8.1 +python-homeassistant-analytics==0.9.0 # homeassistant.components.homewizard python-homewizard-energy==v8.3.2 @@ -2406,7 +2409,7 @@ python-join-api==0.0.9 python-juicenet==1.1.0 # homeassistant.components.tplink -python-kasa[speedups]==0.10.0 +python-kasa[speedups]==0.10.1 # homeassistant.components.linkplay python-linkplay==0.1.3 @@ -2449,7 +2452,7 @@ python-rabbitair==0.0.8 python-ripple-api==0.0.3 # homeassistant.components.roborock -python-roborock==2.9.7 +python-roborock==2.11.1 # homeassistant.components.smarttub python-smarttub==0.0.38 @@ -2513,7 +2516,7 @@ pyvera==0.3.15 pyversasense==0.0.6 # homeassistant.components.vesync -pyvesync==2.1.16 +pyvesync==2.1.17 # homeassistant.components.vizio pyvizio==0.1.61 @@ -2630,7 +2633,7 @@ rokuecp==0.19.3 romy==0.0.10 # homeassistant.components.roomba -roombapy==1.8.1 +roombapy==1.9.0 # homeassistant.components.roon roonapi==0.1.6 @@ -2854,7 +2857,7 @@ temperusb==1.6.1 # homeassistant.components.tesla_fleet # homeassistant.components.teslemetry # homeassistant.components.tessie -tesla-fleet-api==0.9.2 +tesla-fleet-api==0.9.6 # homeassistant.components.powerwall tesla-powerwall==0.5.2 @@ -2863,7 +2866,7 @@ tesla-powerwall==0.5.2 tesla-wall-connector==1.0.2 # homeassistant.components.teslemetry -teslemetry-stream==0.6.6 +teslemetry-stream==0.6.10 # homeassistant.components.tessie tessie-api==0.1.1 @@ -2875,7 +2878,7 @@ tessie-api==0.1.1 thermobeacon-ble==0.7.0 # homeassistant.components.thermopro -thermopro-ble==0.10.1 +thermopro-ble==0.11.0 # homeassistant.components.thingspeak thingspeak==1.0.0 @@ -2893,10 +2896,10 @@ tilt-ble==0.2.3 tmb==0.0.4 # homeassistant.components.todoist -todoist-api-python==2.1.2 +todoist-api-python==2.1.7 # homeassistant.components.tolo -tololib==1.1.0 +tololib==1.2.2 # homeassistant.components.toon toonapi==0.3.0 @@ -2935,13 +2938,13 @@ twilio==6.32.0 twitchAPI==4.2.1 # homeassistant.components.monarch_money -typedmonarchmoney==0.3.1 +typedmonarchmoney==0.4.4 # homeassistant.components.ukraine_alarm uasiren==0.0.1 # homeassistant.components.unifiprotect -uiprotect==7.5.0 +uiprotect==7.5.1 # homeassistant.components.landisgyr_heat_meter ultraheat-api==0.5.7 @@ -2959,7 +2962,7 @@ unifiled==0.11 universal-silabs-flasher==0.0.25 # homeassistant.components.upb -upb-lib==0.5.9 +upb-lib==0.6.0 # homeassistant.components.upcloud upcloud-api==2.6.0 @@ -3091,7 +3094,7 @@ yalesmartalarmclient==0.4.3 # homeassistant.components.august # homeassistant.components.yale # homeassistant.components.yalexs_ble -yalexs-ble==2.5.6 +yalexs-ble==2.5.7 # homeassistant.components.august # homeassistant.components.yale diff --git a/requirements_test.txt b/requirements_test.txt index cf0a1e5473f..16983de5706 100644 --- a/requirements_test.txt +++ b/requirements_test.txt @@ -8,48 +8,47 @@ -c homeassistant/package_constraints.txt -r requirements_test_pre_commit.txt astroid==3.3.8 -coverage==7.6.8 +coverage==7.6.10 freezegun==1.5.1 -license-expression==30.4.0 +license-expression==30.4.1 mock-open==1.4.0 mypy-dev==1.16.0a1 pre-commit==4.0.0 pydantic==2.10.6 -pylint==3.3.3 -pylint-per-file-ignores==1.3.2 -pipdeptree==2.23.4 -pytest-asyncio==0.24.0 -pytest-aiohttp==1.0.5 +pylint==3.3.4 +pylint-per-file-ignores==1.4.0 +pipdeptree==2.25.0 +pytest-asyncio==0.25.3 +pytest-aiohttp==1.1.0 pytest-cov==6.0.0 -pytest-freezer==0.4.8 -pytest-github-actions-annotate-failures==0.2.0 +pytest-freezer==0.4.9 +pytest-github-actions-annotate-failures==0.3.0 pytest-socket==0.7.0 pytest-sugar==1.0.0 pytest-timeout==2.3.1 pytest-unordered==0.6.1 -pytest-picked==0.5.0 +pytest-picked==0.5.1 pytest-xdist==3.6.1 pytest==8.3.4 requests-mock==1.12.1 respx==0.22.0 -syrupy==4.8.0 -tqdm==4.66.5 +syrupy==4.8.1 +tqdm==4.67.1 types-aiofiles==24.1.0.20241221 types-atomicwrites==1.4.5.1 types-croniter==5.0.1.20241205 -types-beautifulsoup4==4.12.0.20241020 +types-beautifulsoup4==4.12.0.20250204 types-caldav==1.3.0.20241107 types-chardet==0.1.5 -types-decorator==5.1.8.20240310 -types-paho-mqtt==1.6.0.20240321 +types-decorator==5.1.8.20250121 types-pexpect==4.9.0.20241208 types-pillow==10.2.0.20240822 types-protobuf==5.29.1.20241207 types-psutil==6.1.0.20241221 -types-pyserial==3.5.0.20241221 +types-pyserial==3.5.0.20250130 types-python-dateutil==2.9.0.20241206 types-python-slugify==8.0.2.20240310 -types-pytz==2024.2.0.20241221 +types-pytz==2025.1.0.20250204 types-PyYAML==6.0.12.20241230 types-requests==2.31.0.3 types-xmltodict==0.13.0.3 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index ab322561fef..522ceac309d 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -204,10 +204,10 @@ aiobotocore==2.13.1 aiocomelit==0.10.1 # homeassistant.components.dhcp -aiodhcpwatcher==1.0.2 +aiodhcpwatcher==1.1.0 # homeassistant.components.dhcp -aiodiscover==2.1.0 +aiodiscover==2.2.2 # homeassistant.components.dnsip aiodns==3.2.0 @@ -249,7 +249,7 @@ aioharmony==0.4.1 aiohasupervisor==0.2.2b6 # homeassistant.components.home_connect -aiohomeconnect==0.12.1 +aiohomeconnect==0.12.3 # homeassistant.components.homekit_controller aiohomekit==3.2.7 @@ -297,10 +297,10 @@ aionanoleaf==0.2.1 aionotion==2024.03.0 # homeassistant.components.nut -aionut==4.3.3 +aionut==4.3.4 # homeassistant.components.oncue -aiooncue==0.3.7 +aiooncue==0.3.9 # homeassistant.components.openexchangerates aioopenexchangerates==0.6.8 @@ -365,7 +365,7 @@ aioslimproto==3.0.0 aiosolaredge==0.2.0 # homeassistant.components.steamist -aiosteamist==1.0.0 +aiosteamist==1.0.1 # homeassistant.components.cambridge_audio aiostreammagic==2.10.0 @@ -464,7 +464,7 @@ aprslib==0.7.2 apsystems-ez1==2.4.0 # homeassistant.components.aranet -aranet4==2.5.0 +aranet4==2.5.1 # homeassistant.components.arcam_fmj arcam-fmj==1.5.2 @@ -528,10 +528,10 @@ bimmer-connected[china]==0.17.2 # homeassistant.components.eq3btsmart # homeassistant.components.esphome -bleak-esphome==2.6.0 +bleak-esphome==2.7.1 # homeassistant.components.bluetooth -bleak-retry-connector==3.8.0 +bleak-retry-connector==3.8.1 # homeassistant.components.bluetooth bleak==0.22.3 @@ -549,7 +549,7 @@ bluecurrent-api==1.2.3 bluemaestro-ble==0.2.3 # homeassistant.components.bluetooth -bluetooth-adapters==0.21.1 +bluetooth-adapters==0.21.4 # homeassistant.components.bluetooth bluetooth-auto-recovery==1.4.2 @@ -558,7 +558,7 @@ bluetooth-auto-recovery==1.4.2 # homeassistant.components.ld2410_ble # homeassistant.components.led_ble # homeassistant.components.private_ble_device -bluetooth-data-tools==1.22.0 +bluetooth-data-tools==1.23.4 # homeassistant.components.bond bond-async==0.2.1 @@ -585,7 +585,7 @@ brottsplatskartan==1.0.5 brunt==1.2.0 # homeassistant.components.bthome -bthome-ble==3.12.3 +bthome-ble==3.12.4 # homeassistant.components.buienradar buienradar==1.0.6 @@ -634,13 +634,13 @@ datadog==0.15.0 datapoint==0.9.9 # homeassistant.components.bluetooth -dbus-fast==2.30.2 +dbus-fast==2.32.0 # homeassistant.components.debugpy debugpy==1.8.11 # homeassistant.components.ecovacs -deebot-client==11.1.0b2 +deebot-client==12.0.0 # homeassistant.components.ihc # homeassistant.components.namecheapdns @@ -902,7 +902,7 @@ goslide-api==0.7.0 gotailwind==0.3.0 # homeassistant.components.govee_ble -govee-ble==0.42.0 +govee-ble==0.42.1 # homeassistant.components.govee_light_local govee-local-api==1.5.3 @@ -941,13 +941,13 @@ ha-iotawattpy==0.1.2 ha-philipsjs==3.2.2 # homeassistant.components.habitica -habiticalib==0.3.3 +habiticalib==0.3.4 # homeassistant.components.bluetooth -habluetooth==3.20.1 +habluetooth==3.21.1 # homeassistant.components.cloud -hass-nabucasa==0.88.1 +hass-nabucasa==0.89.0 # homeassistant.components.conversation hassil==2.2.0 @@ -975,7 +975,7 @@ hole==0.8.0 holidays==0.65 # homeassistant.components.frontend -home-assistant-frontend==20250131.0 +home-assistant-frontend==20250204.0 # homeassistant.components.conversation home-assistant-intents==2025.1.28 @@ -1042,6 +1042,9 @@ insteon-frontend-home-assistant==0.5.0 # homeassistant.components.intellifire intellifire4py==4.1.9 +# homeassistant.components.iometer +iometer==0.1.0 + # homeassistant.components.iotty iottycloud==0.3.0 @@ -1098,7 +1101,7 @@ ld2410-ble==0.1.1 leaone-ble==0.1.0 # homeassistant.components.led_ble -led-ble==1.1.4 +led-ble==1.1.6 # homeassistant.components.lektrico lektricowifi==0.0.43 @@ -1177,7 +1180,7 @@ microBeesPy==0.3.5 mill-local==0.3.0 # homeassistant.components.mill -millheater==0.12.2 +millheater==0.12.3 # homeassistant.components.minio minio==7.1.12 @@ -1206,9 +1209,6 @@ motioneye-client==0.3.14 # homeassistant.components.bang_olufsen mozart-api==4.1.1.116.4 -# homeassistant.components.onedrive -msgraph-sdk==1.16.0 - # homeassistant.components.mullvad mullvad-api==1.0.0 @@ -1240,7 +1240,7 @@ netmap==0.7.0.2 nettigo-air-monitor==4.0.0 # homeassistant.components.nexia -nexia==2.0.8 +nexia==2.0.9 # homeassistant.components.nextcloud nextcloudmonitor==1.5.1 @@ -1252,7 +1252,7 @@ nextcord==2.6.0 nextdns==4.0.0 # homeassistant.components.niko_home_control -nhc==0.3.9 +nhc==0.4.4 # homeassistant.components.nibe_heatpump nibe==2.14.0 @@ -1306,6 +1306,9 @@ omnilogic==0.4.5 # homeassistant.components.ondilo_ico ondilo==0.5.0 +# homeassistant.components.onedrive +onedrive-personal-sdk==0.0.4 + # homeassistant.components.onvif onvif-zeep-async==3.2.5 @@ -1316,7 +1319,7 @@ open-garage==0.2.0 open-meteo==0.3.2 # homeassistant.components.openai_conversation -openai==1.59.9 +openai==1.61.0 # homeassistant.components.openerz openerz-api==0.3.0 @@ -1343,7 +1346,7 @@ ovoenergy==2.0.0 p1monitor==3.1.0 # homeassistant.components.mqtt -paho-mqtt==1.6.1 +paho-mqtt==2.1.0 # homeassistant.components.panasonic_viera panasonic-viera==0.4.2 @@ -1556,7 +1559,7 @@ pydroid-ipcam==2.0.0 pyecoforest==0.4.0 # homeassistant.components.econet -pyeconet==0.1.23 +pyeconet==0.1.26 # homeassistant.components.ista_ecotrend pyecotrend-ista==3.3.1 @@ -1786,7 +1789,7 @@ pyotgw==2.2.2 pyotp==2.8.0 # homeassistant.components.overkiz -pyoverkiz==1.15.5 +pyoverkiz==1.16.0 # homeassistant.components.onewire pyownet==0.10.0.post1 @@ -1795,7 +1798,7 @@ pyownet==0.10.0.post1 pypalazzetti==0.1.19 # homeassistant.components.lcn -pypck==0.8.3 +pypck==0.8.5 # homeassistant.components.pjlink pypjlink2==1.2.1 @@ -1882,7 +1885,7 @@ pysmarty2==0.10.1 pysml==0.0.12 # homeassistant.components.smlight -pysmlight==0.2.1 +pysmlight==0.2.2 # homeassistant.components.snmp pysnmp==6.2.6 @@ -1933,7 +1936,7 @@ python-fullykiosk==0.0.14 python-google-drive-api==0.0.2 # homeassistant.components.analytics_insights -python-homeassistant-analytics==0.8.1 +python-homeassistant-analytics==0.9.0 # homeassistant.components.homewizard python-homewizard-energy==v8.3.2 @@ -1945,7 +1948,7 @@ python-izone==1.2.9 python-juicenet==1.1.0 # homeassistant.components.tplink -python-kasa[speedups]==0.10.0 +python-kasa[speedups]==0.10.1 # homeassistant.components.linkplay python-linkplay==0.1.3 @@ -1982,7 +1985,7 @@ python-picnic-api==1.1.0 python-rabbitair==0.0.8 # homeassistant.components.roborock -python-roborock==2.9.7 +python-roborock==2.11.1 # homeassistant.components.smarttub python-smarttub==0.0.38 @@ -2031,7 +2034,7 @@ pyuptimerobot==22.2.0 pyvera==0.3.15 # homeassistant.components.vesync -pyvesync==2.1.16 +pyvesync==2.1.17 # homeassistant.components.vizio pyvizio==0.1.61 @@ -2121,7 +2124,7 @@ rokuecp==0.19.3 romy==0.0.10 # homeassistant.components.roomba -roombapy==1.8.1 +roombapy==1.9.0 # homeassistant.components.roon roonapi==0.1.6 @@ -2294,7 +2297,7 @@ temperusb==1.6.1 # homeassistant.components.tesla_fleet # homeassistant.components.teslemetry # homeassistant.components.tessie -tesla-fleet-api==0.9.2 +tesla-fleet-api==0.9.6 # homeassistant.components.powerwall tesla-powerwall==0.5.2 @@ -2303,7 +2306,7 @@ tesla-powerwall==0.5.2 tesla-wall-connector==1.0.2 # homeassistant.components.teslemetry -teslemetry-stream==0.6.6 +teslemetry-stream==0.6.10 # homeassistant.components.tessie tessie-api==0.1.1 @@ -2312,7 +2315,7 @@ tessie-api==0.1.1 thermobeacon-ble==0.7.0 # homeassistant.components.thermopro -thermopro-ble==0.10.1 +thermopro-ble==0.11.0 # homeassistant.components.lg_thinq thinqconnect==1.0.2 @@ -2321,10 +2324,10 @@ thinqconnect==1.0.2 tilt-ble==0.2.3 # homeassistant.components.todoist -todoist-api-python==2.1.2 +todoist-api-python==2.1.7 # homeassistant.components.tolo -tololib==1.1.0 +tololib==1.2.2 # homeassistant.components.toon toonapi==0.3.0 @@ -2360,13 +2363,13 @@ twilio==6.32.0 twitchAPI==4.2.1 # homeassistant.components.monarch_money -typedmonarchmoney==0.3.1 +typedmonarchmoney==0.4.4 # homeassistant.components.ukraine_alarm uasiren==0.0.1 # homeassistant.components.unifiprotect -uiprotect==7.5.0 +uiprotect==7.5.1 # homeassistant.components.landisgyr_heat_meter ultraheat-api==0.5.7 @@ -2375,7 +2378,7 @@ ultraheat-api==0.5.7 unifi-discovery==1.2.0 # homeassistant.components.upb -upb-lib==0.5.9 +upb-lib==0.6.0 # homeassistant.components.upcloud upcloud-api==2.6.0 @@ -2489,7 +2492,7 @@ yalesmartalarmclient==0.4.3 # homeassistant.components.august # homeassistant.components.yale # homeassistant.components.yalexs_ble -yalexs-ble==2.5.6 +yalexs-ble==2.5.7 # homeassistant.components.august # homeassistant.components.yale diff --git a/requirements_test_pre_commit.txt b/requirements_test_pre_commit.txt index 4dd3bc46010..1cf3d91defa 100644 --- a/requirements_test_pre_commit.txt +++ b/requirements_test_pre_commit.txt @@ -1,5 +1,5 @@ # Automatically generated from .pre-commit-config.yaml by gen_requirements_all.py, do not edit -codespell==2.3.0 +codespell==2.4.1 ruff==0.9.1 yamllint==1.35.1 diff --git a/script/hassfest/docker/Dockerfile b/script/hassfest/docker/Dockerfile index 2c433ba362e..22eae847706 100644 --- a/script/hassfest/docker/Dockerfile +++ b/script/hassfest/docker/Dockerfile @@ -14,7 +14,7 @@ WORKDIR "/github/workspace" COPY . /usr/src/homeassistant # Uv is only needed during build -RUN --mount=from=ghcr.io/astral-sh/uv:0.5.21,source=/uv,target=/bin/uv \ +RUN --mount=from=ghcr.io/astral-sh/uv:0.5.27,source=/uv,target=/bin/uv \ # Uv creates a lock file in /tmp --mount=type=tmpfs,target=/tmp \ # Required for PyTurboJPEG @@ -24,7 +24,7 @@ RUN --mount=from=ghcr.io/astral-sh/uv:0.5.21,source=/uv,target=/bin/uv \ --no-cache \ -c /usr/src/homeassistant/homeassistant/package_constraints.txt \ -r /usr/src/homeassistant/requirements.txt \ - stdlib-list==0.10.0 pipdeptree==2.23.4 tqdm==4.66.5 ruff==0.9.1 \ + stdlib-list==0.10.0 pipdeptree==2.25.0 tqdm==4.67.1 ruff==0.9.1 \ PyTurboJPEG==1.7.5 go2rtc-client==0.1.2 ha-ffmpeg==3.2.2 hassil==2.2.0 home-assistant-intents==2025.1.28 mutagen==1.47.0 pymicro-vad==1.0.1 pyspeex-noise==1.0.2 LABEL "name"="hassfest" diff --git a/script/hassfest/quality_scale_validation/runtime_data.py b/script/hassfest/quality_scale_validation/runtime_data.py index cfc4c5224de..3562d897967 100644 --- a/script/hassfest/quality_scale_validation/runtime_data.py +++ b/script/hassfest/quality_scale_validation/runtime_data.py @@ -10,7 +10,7 @@ from homeassistant.const import Platform from script.hassfest import ast_parse_module from script.hassfest.model import Config, Integration -_ANNOTATION_MATCH = re.compile(r"^[A-Za-z]+ConfigEntry$") +_ANNOTATION_MATCH = re.compile(r"^[A-Za-z][A-Za-z0-9]+ConfigEntry$") _FUNCTIONS: dict[str, dict[str, int]] = { "__init__": { # based on ComponentProtocol "async_migrate_entry": 2, diff --git a/script/licenses.py b/script/licenses.py index 464a2fc456b..aa15a58f3bd 100644 --- a/script/licenses.py +++ b/script/licenses.py @@ -199,7 +199,6 @@ EXCEPTIONS = { "pigpio", # https://github.com/joan2937/pigpio/pull/608 "pymitv", # MIT "pybbox", # https://github.com/HydrelioxGitHub/pybbox/pull/5 - "pyeconet", # https://github.com/w1ll1am23/pyeconet/pull/41 "pysabnzbd", # https://github.com/jeradM/pysabnzbd/pull/6 "pyvera", # https://github.com/maximvelichko/pyvera/pull/164 "repoze.lru", diff --git a/tests/components/airthings_ble/test_config_flow.py b/tests/components/airthings_ble/test_config_flow.py index 79ae46500dd..314594c612f 100644 --- a/tests/components/airthings_ble/test_config_flow.py +++ b/tests/components/airthings_ble/test_config_flow.py @@ -7,7 +7,7 @@ from bleak import BleakError import pytest from homeassistant.components.airthings_ble.const import DOMAIN -from homeassistant.config_entries import SOURCE_BLUETOOTH, SOURCE_USER +from homeassistant.config_entries import SOURCE_BLUETOOTH, SOURCE_IGNORE, SOURCE_USER from homeassistant.const import CONF_ADDRESS from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType @@ -153,6 +153,57 @@ async def test_user_setup(hass: HomeAssistant) -> None: assert result["result"].unique_id == "cc:cc:cc:cc:cc:cc" +async def test_user_setup_replaces_ignored_device(hass: HomeAssistant) -> None: + """Test the user initiated form can replace an ignored device.""" + entry = MockConfigEntry( + domain=DOMAIN, + unique_id="cc:cc:cc:cc:cc:cc", + source=SOURCE_IGNORE, + data={CONF_ADDRESS: "cc:cc:cc:cc:cc:cc"}, + ) + entry.add_to_hass(hass) + with ( + patch( + "homeassistant.components.airthings_ble.config_flow.async_discovered_service_info", + return_value=[WAVE_SERVICE_INFO], + ), + patch_async_ble_device_from_address(WAVE_SERVICE_INFO), + patch_airthings_ble( + AirthingsDevice( + manufacturer="Airthings AS", + model=AirthingsDeviceType.WAVE_PLUS, + name="Airthings Wave Plus", + identifier="123456", + ) + ), + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + assert result["errors"] is None + assert result["data_schema"] is not None + schema = result["data_schema"].schema + + assert schema.get(CONF_ADDRESS).container == { + "cc:cc:cc:cc:cc:cc": "Airthings Wave Plus" + } + + with patch( + "homeassistant.components.airthings_ble.async_setup_entry", + return_value=True, + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={CONF_ADDRESS: "cc:cc:cc:cc:cc:cc"} + ) + + await hass.async_block_till_done() + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == "Airthings Wave Plus (123456)" + assert result["result"].unique_id == "cc:cc:cc:cc:cc:cc" + + async def test_user_setup_no_device(hass: HomeAssistant) -> None: """Test the user initiated form without any device detected.""" with patch( diff --git a/tests/components/anthropic/test_conversation.py b/tests/components/anthropic/test_conversation.py index fa5bcb8137a..bb77e2ff926 100644 --- a/tests/components/anthropic/test_conversation.py +++ b/tests/components/anthropic/test_conversation.py @@ -236,6 +236,7 @@ async def test_function_call( mock_tool.async_call.assert_awaited_once_with( hass, llm.ToolInput( + id="toolu_0123456789AbCdEfGhIjKlM", tool_name="test_tool", tool_args={"param1": "test_value"}, ), @@ -373,6 +374,7 @@ async def test_function_exception( mock_tool.async_call.assert_awaited_once_with( hass, llm.ToolInput( + id="toolu_0123456789AbCdEfGhIjKlM", tool_name="test_tool", tool_args={"param1": "test_value"}, ), diff --git a/tests/components/aranet/test_config_flow.py b/tests/components/aranet/test_config_flow.py index 9596507960b..c40725c397d 100644 --- a/tests/components/aranet/test_config_flow.py +++ b/tests/components/aranet/test_config_flow.py @@ -4,6 +4,7 @@ from unittest.mock import patch from homeassistant import config_entries from homeassistant.components.aranet.const import DOMAIN +from homeassistant.config_entries import SOURCE_IGNORE, SOURCE_USER from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType @@ -275,3 +276,31 @@ async def test_async_step_user_integrations_disabled(hass: HomeAssistant) -> Non ) assert result2["type"] is FlowResultType.ABORT assert result2["reason"] == "integrations_disabled" + + +async def test_user_setup_replaces_ignored_device(hass: HomeAssistant) -> None: + """Test the user initiated form can replace an ignored device.""" + entry = MockConfigEntry( + domain=DOMAIN, unique_id="aa:bb:cc:dd:ee:ff", source=SOURCE_IGNORE + ) + entry.add_to_hass(hass) + + with patch( + "homeassistant.components.aranet.config_flow.async_discovered_service_info", + return_value=[VALID_DATA_SERVICE_INFO], + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + + with patch("homeassistant.components.aranet.async_setup_entry", return_value=True): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={"address": "aa:bb:cc:dd:ee:ff"} + ) + await hass.async_block_till_done() + assert result2["type"] is FlowResultType.CREATE_ENTRY + assert result2["title"] == "Aranet4 12345" + assert result2["data"] == {} + assert result2["result"].unique_id == "aa:bb:cc:dd:ee:ff" diff --git a/tests/components/assist_pipeline/snapshots/test_init.ambr b/tests/components/assist_pipeline/snapshots/test_init.ambr index 526e1bff151..11e6bc2339a 100644 --- a/tests/components/assist_pipeline/snapshots/test_init.ambr +++ b/tests/components/assist_pipeline/snapshots/test_init.ambr @@ -3,6 +3,7 @@ list([ dict({ 'data': dict({ + 'conversation_id': 'mock-ulid', 'language': 'en', 'pipeline': , }), @@ -32,7 +33,7 @@ }), dict({ 'data': dict({ - 'conversation_id': None, + 'conversation_id': 'mock-ulid', 'device_id': None, 'engine': 'conversation.home_assistant', 'intent_input': 'test transcript', @@ -94,6 +95,7 @@ list([ dict({ 'data': dict({ + 'conversation_id': 'mock-ulid', 'language': 'en', 'pipeline': , }), @@ -123,7 +125,7 @@ }), dict({ 'data': dict({ - 'conversation_id': None, + 'conversation_id': 'mock-ulid', 'device_id': None, 'engine': 'conversation.home_assistant', 'intent_input': 'test transcript', @@ -185,6 +187,7 @@ list([ dict({ 'data': dict({ + 'conversation_id': 'mock-ulid', 'language': 'en', 'pipeline': , }), @@ -214,7 +217,7 @@ }), dict({ 'data': dict({ - 'conversation_id': None, + 'conversation_id': 'mock-ulid', 'device_id': None, 'engine': 'conversation.home_assistant', 'intent_input': 'test transcript', @@ -276,6 +279,7 @@ list([ dict({ 'data': dict({ + 'conversation_id': 'mock-ulid', 'language': 'en', 'pipeline': , }), @@ -329,7 +333,7 @@ }), dict({ 'data': dict({ - 'conversation_id': None, + 'conversation_id': 'mock-ulid', 'device_id': None, 'engine': 'conversation.home_assistant', 'intent_input': 'test transcript', @@ -391,6 +395,7 @@ list([ dict({ 'data': dict({ + 'conversation_id': 'mock-ulid', 'language': 'en', 'pipeline': , }), @@ -427,6 +432,7 @@ list([ dict({ 'data': dict({ + 'conversation_id': 'mock-conversation-id', 'language': 'en', 'pipeline': , }), @@ -434,7 +440,7 @@ }), dict({ 'data': dict({ - 'conversation_id': None, + 'conversation_id': 'mock-conversation-id', 'device_id': None, 'engine': 'conversation.home_assistant', 'intent_input': 'test input', @@ -478,6 +484,7 @@ list([ dict({ 'data': dict({ + 'conversation_id': 'mock-conversation-id', 'language': 'en', 'pipeline': , }), @@ -485,7 +492,7 @@ }), dict({ 'data': dict({ - 'conversation_id': None, + 'conversation_id': 'mock-conversation-id', 'device_id': None, 'engine': 'conversation.home_assistant', 'intent_input': 'test input', @@ -529,6 +536,7 @@ list([ dict({ 'data': dict({ + 'conversation_id': 'mock-conversation-id', 'language': 'en', 'pipeline': , }), @@ -536,7 +544,7 @@ }), dict({ 'data': dict({ - 'conversation_id': None, + 'conversation_id': 'mock-conversation-id', 'device_id': None, 'engine': 'conversation.home_assistant', 'intent_input': 'test input', @@ -580,6 +588,7 @@ list([ dict({ 'data': dict({ + 'conversation_id': 'mock-conversation-id', 'language': 'en', 'pipeline': , }), diff --git a/tests/components/assist_pipeline/snapshots/test_websocket.ambr b/tests/components/assist_pipeline/snapshots/test_websocket.ambr index 5f06172404b..f677fa6d8cf 100644 --- a/tests/components/assist_pipeline/snapshots/test_websocket.ambr +++ b/tests/components/assist_pipeline/snapshots/test_websocket.ambr @@ -1,6 +1,7 @@ # serializer version: 1 # name: test_audio_pipeline dict({ + 'conversation_id': 'mock-ulid', 'language': 'en', 'pipeline': , 'runner_data': dict({ @@ -31,7 +32,7 @@ # --- # name: test_audio_pipeline.3 dict({ - 'conversation_id': None, + 'conversation_id': 'mock-ulid', 'device_id': None, 'engine': 'conversation.home_assistant', 'intent_input': 'test transcript', @@ -84,6 +85,7 @@ # --- # name: test_audio_pipeline_debug dict({ + 'conversation_id': 'mock-ulid', 'language': 'en', 'pipeline': , 'runner_data': dict({ @@ -114,7 +116,7 @@ # --- # name: test_audio_pipeline_debug.3 dict({ - 'conversation_id': None, + 'conversation_id': 'mock-ulid', 'device_id': None, 'engine': 'conversation.home_assistant', 'intent_input': 'test transcript', @@ -179,6 +181,7 @@ # --- # name: test_audio_pipeline_with_enhancements dict({ + 'conversation_id': 'mock-ulid', 'language': 'en', 'pipeline': , 'runner_data': dict({ @@ -209,7 +212,7 @@ # --- # name: test_audio_pipeline_with_enhancements.3 dict({ - 'conversation_id': None, + 'conversation_id': 'mock-ulid', 'device_id': None, 'engine': 'conversation.home_assistant', 'intent_input': 'test transcript', @@ -262,6 +265,7 @@ # --- # name: test_audio_pipeline_with_wake_word_no_timeout dict({ + 'conversation_id': 'mock-ulid', 'language': 'en', 'pipeline': , 'runner_data': dict({ @@ -314,7 +318,7 @@ # --- # name: test_audio_pipeline_with_wake_word_no_timeout.5 dict({ - 'conversation_id': None, + 'conversation_id': 'mock-ulid', 'device_id': None, 'engine': 'conversation.home_assistant', 'intent_input': 'test transcript', @@ -367,6 +371,7 @@ # --- # name: test_audio_pipeline_with_wake_word_timeout dict({ + 'conversation_id': 'mock-ulid', 'language': 'en', 'pipeline': , 'runner_data': dict({ @@ -399,6 +404,7 @@ # --- # name: test_device_capture dict({ + 'conversation_id': 'mock-ulid', 'language': 'en', 'pipeline': , 'runner_data': dict({ @@ -425,6 +431,7 @@ # --- # name: test_device_capture_override dict({ + 'conversation_id': 'mock-ulid', 'language': 'en', 'pipeline': , 'runner_data': dict({ @@ -473,6 +480,7 @@ # --- # name: test_device_capture_queue_full dict({ + 'conversation_id': 'mock-ulid', 'language': 'en', 'pipeline': , 'runner_data': dict({ @@ -512,6 +520,7 @@ # --- # name: test_intent_failed dict({ + 'conversation_id': 'mock-ulid', 'language': 'en', 'pipeline': , 'runner_data': dict({ @@ -522,7 +531,7 @@ # --- # name: test_intent_failed.1 dict({ - 'conversation_id': None, + 'conversation_id': 'mock-ulid', 'device_id': None, 'engine': 'conversation.home_assistant', 'intent_input': 'Are the lights on?', @@ -535,6 +544,7 @@ # --- # name: test_intent_timeout dict({ + 'conversation_id': 'mock-ulid', 'language': 'en', 'pipeline': , 'runner_data': dict({ @@ -545,7 +555,7 @@ # --- # name: test_intent_timeout.1 dict({ - 'conversation_id': None, + 'conversation_id': 'mock-ulid', 'device_id': None, 'engine': 'conversation.home_assistant', 'intent_input': 'Are the lights on?', @@ -564,6 +574,7 @@ # --- # name: test_pipeline_empty_tts_output dict({ + 'conversation_id': 'mock-ulid', 'language': 'en', 'pipeline': , 'runner_data': dict({ @@ -574,7 +585,7 @@ # --- # name: test_pipeline_empty_tts_output.1 dict({ - 'conversation_id': None, + 'conversation_id': 'mock-ulid', 'device_id': None, 'engine': 'conversation.home_assistant', 'intent_input': 'never mind', @@ -611,6 +622,7 @@ # --- # name: test_stt_cooldown_different_ids dict({ + 'conversation_id': 'mock-ulid', 'language': 'en', 'pipeline': , 'runner_data': dict({ @@ -621,6 +633,7 @@ # --- # name: test_stt_cooldown_different_ids.1 dict({ + 'conversation_id': 'mock-ulid', 'language': 'en', 'pipeline': , 'runner_data': dict({ @@ -631,6 +644,7 @@ # --- # name: test_stt_cooldown_same_id dict({ + 'conversation_id': 'mock-ulid', 'language': 'en', 'pipeline': , 'runner_data': dict({ @@ -641,6 +655,7 @@ # --- # name: test_stt_cooldown_same_id.1 dict({ + 'conversation_id': 'mock-ulid', 'language': 'en', 'pipeline': , 'runner_data': dict({ @@ -651,6 +666,7 @@ # --- # name: test_stt_stream_failed dict({ + 'conversation_id': 'mock-ulid', 'language': 'en', 'pipeline': , 'runner_data': dict({ @@ -677,6 +693,7 @@ # --- # name: test_text_only_pipeline[extra_msg0] dict({ + 'conversation_id': 'mock-conversation-id', 'language': 'en', 'pipeline': , 'runner_data': dict({ @@ -723,6 +740,7 @@ # --- # name: test_text_only_pipeline[extra_msg1] dict({ + 'conversation_id': 'mock-conversation-id', 'language': 'en', 'pipeline': , 'runner_data': dict({ @@ -775,6 +793,7 @@ # --- # name: test_tts_failed dict({ + 'conversation_id': 'mock-ulid', 'language': 'en', 'pipeline': , 'runner_data': dict({ @@ -796,6 +815,7 @@ # --- # name: test_wake_word_cooldown_different_entities dict({ + 'conversation_id': 'mock-ulid', 'language': 'en', 'pipeline': , 'runner_data': dict({ @@ -806,6 +826,7 @@ # --- # name: test_wake_word_cooldown_different_entities.1 dict({ + 'conversation_id': 'mock-ulid', 'language': 'en', 'pipeline': , 'runner_data': dict({ @@ -857,6 +878,7 @@ # --- # name: test_wake_word_cooldown_different_ids dict({ + 'conversation_id': 'mock-ulid', 'language': 'en', 'pipeline': , 'runner_data': dict({ @@ -867,6 +889,7 @@ # --- # name: test_wake_word_cooldown_different_ids.1 dict({ + 'conversation_id': 'mock-ulid', 'language': 'en', 'pipeline': , 'runner_data': dict({ @@ -921,6 +944,7 @@ # --- # name: test_wake_word_cooldown_same_id dict({ + 'conversation_id': 'mock-ulid', 'language': 'en', 'pipeline': , 'runner_data': dict({ @@ -931,6 +955,7 @@ # --- # name: test_wake_word_cooldown_same_id.1 dict({ + 'conversation_id': 'mock-ulid', 'language': 'en', 'pipeline': , 'runner_data': dict({ diff --git a/tests/components/assist_pipeline/test_init.py b/tests/components/assist_pipeline/test_init.py index a2cb9ef382a..1651950c173 100644 --- a/tests/components/assist_pipeline/test_init.py +++ b/tests/components/assist_pipeline/test_init.py @@ -1,11 +1,12 @@ """Test Voice Assistant init.""" import asyncio +from collections.abc import Generator from dataclasses import asdict import itertools as it from pathlib import Path import tempfile -from unittest.mock import ANY, patch +from unittest.mock import ANY, Mock, patch import wave import hass_nabucasa @@ -41,6 +42,14 @@ from .conftest import ( from tests.typing import ClientSessionGenerator, WebSocketGenerator +@pytest.fixture(autouse=True) +def mock_ulid() -> Generator[Mock]: + """Mock the ulid of chat sessions.""" + with patch("homeassistant.helpers.chat_session.ulid_now") as mock_ulid_now: + mock_ulid_now.return_value = "mock-ulid" + yield mock_ulid_now + + def process_events(events: list[assist_pipeline.PipelineEvent]) -> list[dict]: """Process events to remove dynamic values.""" processed = [] @@ -684,7 +693,7 @@ async def test_wake_word_detection_aborted( pipeline = assist_pipeline.pipeline.async_get_pipeline(hass, pipeline_id) pipeline_input = assist_pipeline.pipeline.PipelineInput( - conversation_id=None, + conversation_id="mock-conversation-id", device_id=None, stt_metadata=stt.SpeechMetadata( language="", @@ -771,7 +780,7 @@ async def test_tts_audio_output( pipeline_input = assist_pipeline.pipeline.PipelineInput( tts_input="This is a test.", - conversation_id=None, + conversation_id="mock-conversation-id", device_id=None, run=assist_pipeline.pipeline.PipelineRun( hass, @@ -828,7 +837,7 @@ async def test_tts_wav_preferred_format( pipeline_input = assist_pipeline.pipeline.PipelineInput( tts_input="This is a test.", - conversation_id=None, + conversation_id="mock-conversation-id", device_id=None, run=assist_pipeline.pipeline.PipelineRun( hass, @@ -896,7 +905,7 @@ async def test_tts_dict_preferred_format( pipeline_input = assist_pipeline.pipeline.PipelineInput( tts_input="This is a test.", - conversation_id=None, + conversation_id="mock-conversation-id", device_id=None, run=assist_pipeline.pipeline.PipelineRun( hass, @@ -982,6 +991,7 @@ async def test_sentence_trigger_overrides_conversation_agent( pipeline_input = assist_pipeline.pipeline.PipelineInput( intent_input="test trigger sentence", + conversation_id="mock-conversation-id", run=assist_pipeline.pipeline.PipelineRun( hass, context=Context(), @@ -1059,6 +1069,7 @@ async def test_prefer_local_intents( pipeline_input = assist_pipeline.pipeline.PipelineInput( intent_input="I'd like to order a stout please", + conversation_id="mock-conversation-id", run=assist_pipeline.pipeline.PipelineRun( hass, context=Context(), @@ -1136,6 +1147,7 @@ async def test_stt_language_used_instead_of_conversation_language( pipeline_input = assist_pipeline.pipeline.PipelineInput( intent_input="test input", + conversation_id="mock-conversation-id", run=assist_pipeline.pipeline.PipelineRun( hass, context=Context(), @@ -1210,6 +1222,7 @@ async def test_tts_language_used_instead_of_conversation_language( pipeline_input = assist_pipeline.pipeline.PipelineInput( intent_input="test input", + conversation_id="mock-conversation-id", run=assist_pipeline.pipeline.PipelineRun( hass, context=Context(), @@ -1284,6 +1297,7 @@ async def test_pipeline_language_used_instead_of_conversation_language( pipeline_input = assist_pipeline.pipeline.PipelineInput( intent_input="test input", + conversation_id="mock-conversation-id", run=assist_pipeline.pipeline.PipelineRun( hass, context=Context(), diff --git a/tests/components/assist_pipeline/test_websocket.py b/tests/components/assist_pipeline/test_websocket.py index c1caf6f86a4..2cd56f094dd 100644 --- a/tests/components/assist_pipeline/test_websocket.py +++ b/tests/components/assist_pipeline/test_websocket.py @@ -2,8 +2,9 @@ import asyncio import base64 +from collections.abc import Generator from typing import Any -from unittest.mock import ANY, patch +from unittest.mock import ANY, Mock, patch import pytest from syrupy.assertion import SnapshotAssertion @@ -35,6 +36,14 @@ from tests.common import MockConfigEntry from tests.typing import WebSocketGenerator +@pytest.fixture(autouse=True) +def mock_ulid() -> Generator[Mock]: + """Mock the ulid of chat sessions.""" + with patch("homeassistant.helpers.chat_session.ulid_now") as mock_ulid_now: + mock_ulid_now.return_value = "mock-ulid" + yield mock_ulid_now + + @pytest.mark.parametrize( "extra_msg", [ diff --git a/tests/components/assist_satellite/conftest.py b/tests/components/assist_satellite/conftest.py index 0cc0e94e149..79e4061bacc 100644 --- a/tests/components/assist_satellite/conftest.py +++ b/tests/components/assist_satellite/conftest.py @@ -94,7 +94,9 @@ class MockAssistSatellite(AssistSatelliteEntity): self, start_announcement: AssistSatelliteConfiguration ) -> None: """Start a conversation from the satellite.""" - self.start_conversations.append((self._extra_system_prompt, start_announcement)) + self.start_conversations.append( + (self._conversation_id, self._extra_system_prompt, start_announcement) + ) @pytest.fixture diff --git a/tests/components/assist_satellite/test_entity.py b/tests/components/assist_satellite/test_entity.py index 46facb80844..b3437bf5c5d 100644 --- a/tests/components/assist_satellite/test_entity.py +++ b/tests/components/assist_satellite/test_entity.py @@ -1,7 +1,8 @@ """Test the Assist Satellite entity.""" import asyncio -from unittest.mock import patch +from collections.abc import Generator +from unittest.mock import Mock, patch import pytest @@ -31,6 +32,14 @@ from . import ENTITY_ID from .conftest import MockAssistSatellite +@pytest.fixture +def mock_chat_session_conversation_id() -> Generator[Mock]: + """Mock the ulid library.""" + with patch("homeassistant.helpers.chat_session.ulid_now") as mock_ulid_now: + mock_ulid_now.return_value = "mock-conversation-id" + yield mock_ulid_now + + @pytest.fixture(autouse=True) async def set_pipeline_tts(hass: HomeAssistant, init_components: ConfigEntry) -> None: """Set up a pipeline with a TTS engine.""" @@ -487,6 +496,7 @@ async def test_vad_sensitivity_entity_not_found( "extra_system_prompt": "Better system prompt", }, ( + "mock-conversation-id", "Better system prompt", AssistSatelliteAnnouncement( message="Hello", @@ -502,6 +512,7 @@ async def test_vad_sensitivity_entity_not_found( "start_media_id": "media-source://given", }, ( + "mock-conversation-id", "Hello", AssistSatelliteAnnouncement( message="Hello", @@ -514,6 +525,7 @@ async def test_vad_sensitivity_entity_not_found( ( {"start_media_id": "http://example.com/given.mp3"}, ( + "mock-conversation-id", None, AssistSatelliteAnnouncement( message="", @@ -525,6 +537,7 @@ async def test_vad_sensitivity_entity_not_found( ), ], ) +@pytest.mark.usefixtures("mock_chat_session_conversation_id") async def test_start_conversation( hass: HomeAssistant, init_components: ConfigEntry, diff --git a/tests/components/backup/common.py b/tests/components/backup/common.py index a7888dbd08c..1e7278134d4 100644 --- a/tests/components/backup/common.py +++ b/tests/components/backup/common.py @@ -5,7 +5,7 @@ from __future__ import annotations from collections.abc import AsyncIterator, Callable, Coroutine, Iterable from pathlib import Path from typing import Any -from unittest.mock import ANY, AsyncMock, Mock, patch +from unittest.mock import AsyncMock, Mock, patch from homeassistant.components.backup import ( DOMAIN, @@ -29,7 +29,7 @@ TEST_BACKUP_ABC123 = AgentBackup( backup_id="abc123", database_included=True, date="1970-01-01T00:00:00.000Z", - extra_metadata={"instance_id": ANY, "with_automatic_settings": True}, + extra_metadata={"instance_id": "our_uuid", "with_automatic_settings": True}, folders=[Folder.MEDIA, Folder.SHARE], homeassistant_included=True, homeassistant_version="2024.12.0", diff --git a/tests/components/backup/conftest.py b/tests/components/backup/conftest.py index d0d9ac7e0e1..eb38399eb79 100644 --- a/tests/components/backup/conftest.py +++ b/tests/components/backup/conftest.py @@ -18,6 +18,16 @@ from .common import TEST_BACKUP_PATH_ABC123, TEST_BACKUP_PATH_DEF456 from tests.common import get_fixture_path +@pytest.fixture(name="instance_id", autouse=True) +def instance_id_fixture(hass: HomeAssistant) -> Generator[None]: + """Mock instance ID.""" + with patch( + "homeassistant.components.backup.manager.instance_id.async_get", + return_value="our_uuid", + ): + yield + + @pytest.fixture(name="mocked_json_bytes") def mocked_json_bytes_fixture() -> Generator[Mock]: """Mock json_bytes.""" diff --git a/tests/components/backup/snapshots/test_backup.ambr b/tests/components/backup/snapshots/test_backup.ambr index 68b00632a6b..28ee9b834c1 100644 --- a/tests/components/backup/snapshots/test_backup.ambr +++ b/tests/components/backup/snapshots/test_backup.ambr @@ -71,6 +71,10 @@ 'backup_id': 'abc123', 'database_included': True, 'date': '1970-01-01T00:00:00.000Z', + 'extra_metadata': dict({ + 'instance_id': 'our_uuid', + 'with_automatic_settings': True, + }), 'failed_agent_ids': list([ ]), 'folders': list([ @@ -94,6 +98,10 @@ 'backup_id': 'def456', 'database_included': False, 'date': '1980-01-01T00:00:00.000Z', + 'extra_metadata': dict({ + 'instance_id': 'unknown_uuid', + 'with_automatic_settings': True, + }), 'failed_agent_ids': list([ ]), 'folders': list([ diff --git a/tests/components/backup/snapshots/test_websocket.ambr b/tests/components/backup/snapshots/test_websocket.ambr index 08c19906241..421432fb66e 100644 --- a/tests/components/backup/snapshots/test_websocket.ambr +++ b/tests/components/backup/snapshots/test_websocket.ambr @@ -229,6 +229,28 @@ 'type': 'result', }) # --- +# name: test_can_decrypt_on_download_with_agent_error[BackupAgentError] + dict({ + 'error': dict({ + 'code': 'home_assistant_error', + 'message': 'Unknown error', + }), + 'id': 1, + 'success': False, + 'type': 'result', + }) +# --- +# name: test_can_decrypt_on_download_with_agent_error[BackupNotFound] + dict({ + 'error': dict({ + 'code': 'backup_not_found', + 'message': 'Backup not found', + }), + 'id': 1, + 'success': False, + 'type': 'result', + }) +# --- # name: test_config_info[storage_data0] dict({ 'id': 1, @@ -3040,6 +3062,10 @@ 'backup_id': 'abc123', 'database_included': True, 'date': '1970-01-01T00:00:00.000Z', + 'extra_metadata': dict({ + 'instance_id': 'our_uuid', + 'with_automatic_settings': True, + }), 'failed_agent_ids': list([ ]), 'folders': list([ @@ -3117,6 +3143,10 @@ 'backup_id': 'abc123', 'database_included': True, 'date': '1970-01-01T00:00:00.000Z', + 'extra_metadata': dict({ + 'instance_id': 'our_uuid', + 'with_automatic_settings': True, + }), 'failed_agent_ids': list([ ]), 'folders': list([ @@ -3175,6 +3205,10 @@ 'backup_id': 'abc123', 'database_included': True, 'date': '1970-01-01T00:00:00.000Z', + 'extra_metadata': dict({ + 'instance_id': 'our_uuid', + 'with_automatic_settings': True, + }), 'failed_agent_ids': list([ ]), 'folders': list([ @@ -3217,6 +3251,10 @@ 'backup_id': 'def456', 'database_included': False, 'date': '1980-01-01T00:00:00.000Z', + 'extra_metadata': dict({ + 'instance_id': 'unknown_uuid', + 'with_automatic_settings': True, + }), 'failed_agent_ids': list([ ]), 'folders': list([ @@ -3270,6 +3308,10 @@ 'backup_id': 'def456', 'database_included': False, 'date': '1980-01-01T00:00:00.000Z', + 'extra_metadata': dict({ + 'instance_id': 'unknown_uuid', + 'with_automatic_settings': True, + }), 'failed_agent_ids': list([ ]), 'folders': list([ @@ -3321,6 +3363,10 @@ 'backup_id': 'abc123', 'database_included': True, 'date': '1970-01-01T00:00:00.000Z', + 'extra_metadata': dict({ + 'instance_id': 'our_uuid', + 'with_automatic_settings': True, + }), 'failed_agent_ids': list([ ]), 'folders': list([ @@ -3379,6 +3425,10 @@ 'backup_id': 'abc123', 'database_included': True, 'date': '1970-01-01T00:00:00.000Z', + 'extra_metadata': dict({ + 'instance_id': 'our_uuid', + 'with_automatic_settings': True, + }), 'failed_agent_ids': list([ ]), 'folders': list([ @@ -3438,6 +3488,8 @@ 'backup_id': 'abc123', 'database_included': True, 'date': '1970-01-01T00:00:00Z', + 'extra_metadata': dict({ + }), 'failed_agent_ids': list([ ]), 'folders': list([ @@ -3497,6 +3549,8 @@ 'backup_id': 'abc123', 'database_included': True, 'date': '1970-01-01T00:00:00Z', + 'extra_metadata': dict({ + }), 'failed_agent_ids': list([ 'test.remote', ]), @@ -3556,6 +3610,8 @@ 'backup_id': 'abc123', 'database_included': True, 'date': '1970-01-01T00:00:00Z', + 'extra_metadata': dict({ + }), 'failed_agent_ids': list([ ]), 'folders': list([ @@ -3614,6 +3670,8 @@ 'backup_id': 'abc123', 'database_included': True, 'date': '1970-01-01T00:00:00Z', + 'extra_metadata': dict({ + }), 'failed_agent_ids': list([ ]), 'folders': list([ @@ -3672,6 +3730,8 @@ 'backup_id': 'abc123', 'database_included': True, 'date': '1970-01-01T00:00:00Z', + 'extra_metadata': dict({ + }), 'failed_agent_ids': list([ ]), 'folders': list([ @@ -3730,6 +3790,8 @@ 'backup_id': 'abc123', 'database_included': True, 'date': '1970-01-01T00:00:00Z', + 'extra_metadata': dict({ + }), 'failed_agent_ids': list([ 'test.remote', ]), @@ -3789,6 +3851,10 @@ 'backup_id': 'abc123', 'database_included': True, 'date': '1970-01-01T00:00:00.000Z', + 'extra_metadata': dict({ + 'instance_id': 'our_uuid', + 'with_automatic_settings': True, + }), 'failed_agent_ids': list([ ]), 'folders': list([ @@ -3828,6 +3894,10 @@ 'backup_id': 'abc123', 'database_included': True, 'date': '1970-01-01T00:00:00.000Z', + 'extra_metadata': dict({ + 'instance_id': 'our_uuid', + 'with_automatic_settings': True, + }), 'failed_agent_ids': list([ ]), 'folders': list([ @@ -3883,6 +3953,10 @@ 'backup_id': 'abc123', 'database_included': True, 'date': '1970-01-01T00:00:00.000Z', + 'extra_metadata': dict({ + 'instance_id': 'our_uuid', + 'with_automatic_settings': True, + }), 'failed_agent_ids': list([ ]), 'folders': list([ @@ -3923,6 +3997,10 @@ 'backup_id': 'abc123', 'database_included': True, 'date': '1970-01-01T00:00:00.000Z', + 'extra_metadata': dict({ + 'instance_id': 'our_uuid', + 'with_automatic_settings': True, + }), 'failed_agent_ids': list([ ]), 'folders': list([ @@ -4199,6 +4277,10 @@ 'backup_id': 'abc123', 'database_included': True, 'date': '1970-01-01T00:00:00.000Z', + 'extra_metadata': dict({ + 'instance_id': 'our_uuid', + 'with_automatic_settings': True, + }), 'failed_agent_ids': list([ ]), 'folders': list([ @@ -4246,6 +4328,10 @@ 'backup_id': 'abc123', 'database_included': True, 'date': '1970-01-01T00:00:00.000Z', + 'extra_metadata': dict({ + 'instance_id': 'our_uuid', + 'with_automatic_settings': True, + }), 'failed_agent_ids': list([ ]), 'folders': list([ @@ -4297,6 +4383,10 @@ 'backup_id': 'abc123', 'database_included': True, 'date': '1970-01-01T00:00:00.000Z', + 'extra_metadata': dict({ + 'instance_id': 'our_uuid', + 'with_automatic_settings': True, + }), 'failed_agent_ids': list([ ]), 'folders': list([ @@ -4339,6 +4429,10 @@ 'backup_id': 'def456', 'database_included': False, 'date': '1980-01-01T00:00:00.000Z', + 'extra_metadata': dict({ + 'instance_id': 'unknown_uuid', + 'with_automatic_settings': True, + }), 'failed_agent_ids': list([ ]), 'folders': list([ @@ -4367,6 +4461,10 @@ 'backup_id': 'abc123', 'database_included': True, 'date': '1970-01-01T00:00:00.000Z', + 'extra_metadata': dict({ + 'instance_id': 'our_uuid', + 'with_automatic_settings': True, + }), 'failed_agent_ids': list([ ]), 'folders': list([ @@ -4415,6 +4513,10 @@ 'backup_id': 'abc123', 'database_included': True, 'date': '1970-01-01T00:00:00.000Z', + 'extra_metadata': dict({ + 'instance_id': 'our_uuid', + 'with_automatic_settings': True, + }), 'failed_agent_ids': list([ ]), 'folders': list([ diff --git a/tests/components/backup/test_http.py b/tests/components/backup/test_http.py index aac39c04d31..24fd15fc4fe 100644 --- a/tests/components/backup/test_http.py +++ b/tests/components/backup/test_http.py @@ -11,7 +11,13 @@ from unittest.mock import patch from aiohttp import web import pytest -from homeassistant.components.backup import AddonInfo, AgentBackup, Folder +from homeassistant.components.backup import ( + AddonInfo, + AgentBackup, + BackupAgentError, + BackupNotFound, + Folder, +) from homeassistant.components.backup.const import DATA_MANAGER, DOMAIN from homeassistant.core import HomeAssistant @@ -141,6 +147,50 @@ async def test_downloading_remote_encrypted_backup( await _test_downloading_encrypted_backup(hass_client, "domain.test") +@pytest.mark.parametrize( + ("error", "status"), + [ + (BackupAgentError, 500), + (BackupNotFound, 404), + ], +) +@patch.object(BackupAgentTest, "async_download_backup") +async def test_downloading_remote_encrypted_backup_with_error( + download_mock, + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + error: Exception, + status: int, +) -> None: + """Test downloading a local backup file.""" + await setup_backup_integration(hass) + hass.data[DATA_MANAGER].backup_agents["domain.test"] = BackupAgentTest( + "test", + [ + AgentBackup( + addons=[AddonInfo(name="Test", slug="test", version="1.0.0")], + backup_id="abc123", + database_included=True, + date="1970-01-01T00:00:00Z", + extra_metadata={}, + folders=[Folder.MEDIA, Folder.SHARE], + homeassistant_included=True, + homeassistant_version="2024.12.0", + name="Test", + protected=True, + size=13, + ) + ], + ) + + download_mock.side_effect = error + client = await hass_client() + resp = await client.get( + "/api/backup/download/abc123?agent_id=domain.test&password=blah" + ) + assert resp.status == status + + async def _test_downloading_encrypted_backup( hass_client: ClientSessionGenerator, agent_id: str, diff --git a/tests/components/backup/test_manager.py b/tests/components/backup/test_manager.py index b98cec47e8d..57f11ed4708 100644 --- a/tests/components/backup/test_manager.py +++ b/tests/components/backup/test_manager.py @@ -136,7 +136,7 @@ async def test_create_backup_service( agent_ids=["backup.local"], backup_name="Custom backup 2025.1.0", extra_metadata={ - "instance_id": hass.data["core.uuid"], + "instance_id": "our_uuid", "with_automatic_settings": False, }, include_addons=None, @@ -595,7 +595,7 @@ async def test_initiate_backup( "compressed": True, "date": ANY, "extra": { - "instance_id": hass.data["core.uuid"], + "instance_id": "our_uuid", "with_automatic_settings": False, }, "homeassistant": { @@ -625,6 +625,7 @@ async def test_initiate_backup( "backup_id": backup_id, "database_included": include_database, "date": ANY, + "extra_metadata": {"instance_id": "our_uuid", "with_automatic_settings": False}, "failed_agent_ids": [], "folders": [], "homeassistant_included": True, @@ -675,6 +676,10 @@ async def test_initiate_backup_with_agent_error( "backup_id": "backup1", "database_included": True, "date": "1970-01-01T00:00:00.000Z", + "extra_metadata": { + "instance_id": "our_uuid", + "with_automatic_settings": True, + }, "failed_agent_ids": [], "folders": [ "media", @@ -691,6 +696,10 @@ async def test_initiate_backup_with_agent_error( "backup_id": "backup2", "database_included": False, "date": "1980-01-01T00:00:00.000Z", + "extra_metadata": { + "instance_id": "unknown_uuid", + "with_automatic_settings": True, + }, "failed_agent_ids": [], "folders": [ "media", @@ -713,6 +722,10 @@ async def test_initiate_backup_with_agent_error( "backup_id": "backup3", "database_included": True, "date": "1970-01-01T00:00:00.000Z", + "extra_metadata": { + "instance_id": "our_uuid", + "with_automatic_settings": True, + }, "failed_agent_ids": [], "folders": [ "media", @@ -836,6 +849,7 @@ async def test_initiate_backup_with_agent_error( "backup_id": "abc123", "database_included": True, "date": ANY, + "extra_metadata": {"instance_id": "our_uuid", "with_automatic_settings": False}, "failed_agent_ids": ["test.remote"], "folders": [], "homeassistant_included": True, @@ -1770,6 +1784,10 @@ async def test_receive_backup_agent_error( "backup_id": "backup1", "database_included": True, "date": "1970-01-01T00:00:00.000Z", + "extra_metadata": { + "instance_id": "our_uuid", + "with_automatic_settings": True, + }, "failed_agent_ids": [], "folders": [ "media", @@ -1786,6 +1804,10 @@ async def test_receive_backup_agent_error( "backup_id": "backup2", "database_included": False, "date": "1980-01-01T00:00:00.000Z", + "extra_metadata": { + "instance_id": "unknown_uuid", + "with_automatic_settings": True, + }, "failed_agent_ids": [], "folders": [ "media", @@ -1808,6 +1830,10 @@ async def test_receive_backup_agent_error( "backup_id": "backup3", "database_included": True, "date": "1970-01-01T00:00:00.000Z", + "extra_metadata": { + "instance_id": "our_uuid", + "with_automatic_settings": True, + }, "failed_agent_ids": [], "folders": [ "media", @@ -3325,6 +3351,7 @@ async def test_initiate_backup_per_agent_encryption( "backup_id": backup_id, "database_included": True, "date": ANY, + "extra_metadata": {"instance_id": "our_uuid", "with_automatic_settings": False}, "failed_agent_ids": [], "folders": [], "homeassistant_included": True, diff --git a/tests/components/backup/test_util.py b/tests/components/backup/test_util.py index 3bcb53f7c86..3b188ff8226 100644 --- a/tests/components/backup/test_util.py +++ b/tests/components/backup/test_util.py @@ -2,6 +2,7 @@ from __future__ import annotations +import asyncio from collections.abc import AsyncIterator import dataclasses import tarfile @@ -189,6 +190,73 @@ async def test_decrypted_backup_streamer(hass: HomeAssistant) -> None: assert decrypted_output == decrypted_backup_data + expected_padding +async def test_decrypted_backup_streamer_interrupt_stuck_reader( + hass: HomeAssistant, +) -> None: + """Test the decrypted backup streamer.""" + encrypted_backup_path = get_fixture_path("test_backups/c0cb53bd.tar", DOMAIN) + backup = AgentBackup( + addons=["addon_1", "addon_2"], + backup_id="1234", + date="2024-12-02T07:23:58.261875-05:00", + database_included=False, + extra_metadata={}, + folders=[], + homeassistant_included=True, + homeassistant_version="2024.12.0.dev0", + name="test", + protected=True, + size=encrypted_backup_path.stat().st_size, + ) + + stuck = asyncio.Event() + + async def send_backup() -> AsyncIterator[bytes]: + f = encrypted_backup_path.open("rb") + while chunk := f.read(1024): + await stuck.wait() + yield chunk + + async def open_backup() -> AsyncIterator[bytes]: + return send_backup() + + decryptor = DecryptedBackupStreamer(hass, backup, open_backup, "hunter2") + await decryptor.open_stream() + await decryptor.wait() + + +async def test_decrypted_backup_streamer_interrupt_stuck_writer( + hass: HomeAssistant, +) -> None: + """Test the decrypted backup streamer.""" + encrypted_backup_path = get_fixture_path("test_backups/c0cb53bd.tar", DOMAIN) + backup = AgentBackup( + addons=["addon_1", "addon_2"], + backup_id="1234", + date="2024-12-02T07:23:58.261875-05:00", + database_included=False, + extra_metadata={}, + folders=[], + homeassistant_included=True, + homeassistant_version="2024.12.0.dev0", + name="test", + protected=True, + size=encrypted_backup_path.stat().st_size, + ) + + async def send_backup() -> AsyncIterator[bytes]: + f = encrypted_backup_path.open("rb") + while chunk := f.read(1024): + yield chunk + + async def open_backup() -> AsyncIterator[bytes]: + return send_backup() + + decryptor = DecryptedBackupStreamer(hass, backup, open_backup, "hunter2") + await decryptor.open_stream() + await decryptor.wait() + + async def test_decrypted_backup_streamer_wrong_password(hass: HomeAssistant) -> None: """Test the decrypted backup streamer with wrong password.""" encrypted_backup_path = get_fixture_path("test_backups/c0cb53bd.tar", DOMAIN) @@ -279,6 +347,77 @@ async def test_encrypted_backup_streamer(hass: HomeAssistant) -> None: assert encrypted_output == encrypted_backup_data + expected_padding +async def test_encrypted_backup_streamer_interrupt_stuck_reader( + hass: HomeAssistant, +) -> None: + """Test the encrypted backup streamer.""" + decrypted_backup_path = get_fixture_path( + "test_backups/c0cb53bd.tar.decrypted", DOMAIN + ) + backup = AgentBackup( + addons=["addon_1", "addon_2"], + backup_id="1234", + date="2024-12-02T07:23:58.261875-05:00", + database_included=False, + extra_metadata={}, + folders=[], + homeassistant_included=True, + homeassistant_version="2024.12.0.dev0", + name="test", + protected=False, + size=decrypted_backup_path.stat().st_size, + ) + + stuck = asyncio.Event() + + async def send_backup() -> AsyncIterator[bytes]: + f = decrypted_backup_path.open("rb") + while chunk := f.read(1024): + await stuck.wait() + yield chunk + + async def open_backup() -> AsyncIterator[bytes]: + return send_backup() + + decryptor = EncryptedBackupStreamer(hass, backup, open_backup, "hunter2") + await decryptor.open_stream() + await decryptor.wait() + + +async def test_encrypted_backup_streamer_interrupt_stuck_writer( + hass: HomeAssistant, +) -> None: + """Test the encrypted backup streamer.""" + decrypted_backup_path = get_fixture_path( + "test_backups/c0cb53bd.tar.decrypted", DOMAIN + ) + backup = AgentBackup( + addons=["addon_1", "addon_2"], + backup_id="1234", + date="2024-12-02T07:23:58.261875-05:00", + database_included=False, + extra_metadata={}, + folders=[], + homeassistant_included=True, + homeassistant_version="2024.12.0.dev0", + name="test", + protected=True, + size=decrypted_backup_path.stat().st_size, + ) + + async def send_backup() -> AsyncIterator[bytes]: + f = decrypted_backup_path.open("rb") + while chunk := f.read(1024): + yield chunk + + async def open_backup() -> AsyncIterator[bytes]: + return send_backup() + + decryptor = EncryptedBackupStreamer(hass, backup, open_backup, "hunter2") + await decryptor.open_stream() + await decryptor.wait() + + async def test_encrypted_backup_streamer_random_nonce(hass: HomeAssistant) -> None: """Test the encrypted backup streamer.""" decrypted_backup_path = get_fixture_path( diff --git a/tests/components/backup/test_websocket.py b/tests/components/backup/test_websocket.py index 613c0b69b6b..5af6d595938 100644 --- a/tests/components/backup/test_websocket.py +++ b/tests/components/backup/test_websocket.py @@ -12,6 +12,7 @@ from homeassistant.components.backup import ( AgentBackup, BackupAgentError, BackupAgentPlatformProtocol, + BackupNotFound, BackupReaderWriterError, Folder, store, @@ -2967,3 +2968,39 @@ async def test_can_decrypt_on_download( } ) assert await client.receive_json() == snapshot + + +@pytest.mark.parametrize( + "error", + [ + BackupAgentError, + BackupNotFound, + ], +) +@pytest.mark.usefixtures("mock_backups") +async def test_can_decrypt_on_download_with_agent_error( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + snapshot: SnapshotAssertion, + error: Exception, +) -> None: + """Test can decrypt on download.""" + + await setup_backup_integration( + hass, + with_hassio=False, + backups={"test.remote": [TEST_BACKUP_ABC123]}, + remote_agents=["remote"], + ) + client = await hass_ws_client(hass) + + with patch.object(BackupAgentTest, "async_download_backup", side_effect=error): + await client.send_json_auto_id( + { + "type": "backup/can_decrypt_on_download", + "backup_id": TEST_BACKUP_ABC123.backup_id, + "agent_id": "test.remote", + "password": "hunter2", + } + ) + assert await client.receive_json() == snapshot diff --git a/tests/components/bluetooth/test_config_flow.py b/tests/components/bluetooth/test_config_flow.py index 0070bebe4b6..f0136396c22 100644 --- a/tests/components/bluetooth/test_config_flow.py +++ b/tests/components/bluetooth/test_config_flow.py @@ -13,12 +13,14 @@ from homeassistant.components.bluetooth.const import ( CONF_PASSIVE, CONF_SOURCE, CONF_SOURCE_CONFIG_ENTRY_ID, + CONF_SOURCE_DEVICE_ID, CONF_SOURCE_DOMAIN, CONF_SOURCE_MODEL, DOMAIN, ) from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType +from homeassistant.helpers import area_registry as ar, device_registry as dr from homeassistant.setup import async_setup_component from . import FakeRemoteScanner, MockBleakClient, _get_manager @@ -515,8 +517,10 @@ async def test_options_flow_local_no_passive_support(hass: HomeAssistant) -> Non @pytest.mark.usefixtures("one_adapter") -async def test_async_step_user_linux_adapter_is_ignored(hass: HomeAssistant) -> None: - """Test we give a hint that the adapter is ignored.""" +async def test_async_step_user_linux_adapter_replace_ignored( + hass: HomeAssistant, +) -> None: + """Test we can replace an ignored adapter from user flow.""" entry = MockConfigEntry( domain=DOMAIN, unique_id="00:00:00:00:00:01", @@ -528,41 +532,54 @@ async def test_async_step_user_linux_adapter_is_ignored(hass: HomeAssistant) -> context={"source": config_entries.SOURCE_USER}, data={}, ) - assert result["type"] is FlowResultType.ABORT - assert result["reason"] == "no_adapters" - assert result["description_placeholders"] == {"ignored_adapters": "1"} - - -@pytest.mark.usefixtures("enable_bluetooth") -async def test_async_step_integration_discovery_remote_adapter( - hass: HomeAssistant, -) -> None: - """Test remote adapter configuration via integration discovery.""" - entry = MockConfigEntry(domain="test") - connector = ( - HaBluetoothConnector(MockBleakClient, "mock_bleak_client", lambda: False), - ) - scanner = FakeRemoteScanner("esp32", "esp32", connector, True) - manager = _get_manager() - cancel_scanner = manager.async_register_scanner(scanner) - - entry.add_to_hass(hass) with ( patch("homeassistant.components.bluetooth.async_setup", return_value=True), patch( "homeassistant.components.bluetooth.async_setup_entry", return_value=True ) as mock_setup_entry, ): - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": config_entries.SOURCE_INTEGRATION_DISCOVERY}, - data={ - CONF_SOURCE: scanner.source, - CONF_SOURCE_DOMAIN: "test", - CONF_SOURCE_MODEL: "test", - CONF_SOURCE_CONFIG_ENTRY_ID: entry.entry_id, - }, + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={} ) + assert result2["type"] is FlowResultType.CREATE_ENTRY + assert result2["title"] == "ACME Bluetooth Adapter 5.0 (00:00:00:00:00:01)" + assert result2["data"] == {} + assert len(mock_setup_entry.mock_calls) == 1 + + +@pytest.mark.usefixtures("enable_bluetooth") +async def test_async_step_integration_discovery_remote_adapter( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + area_registry: ar.AreaRegistry, +) -> None: + """Test remote adapter configuration via integration discovery.""" + entry = MockConfigEntry(domain="test") + entry.add_to_hass(hass) + connector = ( + HaBluetoothConnector(MockBleakClient, "mock_bleak_client", lambda: False), + ) + scanner = FakeRemoteScanner("esp32", "esp32", connector, True) + manager = _get_manager() + area_entry = area_registry.async_get_or_create("test") + cancel_scanner = manager.async_register_scanner(scanner) + device_entry = device_registry.async_get_or_create( + config_entry_id=entry.entry_id, + identifiers={("test", "BB:BB:BB:BB:BB:BB")}, + suggested_area=area_entry.id, + ) + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_INTEGRATION_DISCOVERY}, + data={ + CONF_SOURCE: scanner.source, + CONF_SOURCE_DOMAIN: "test", + CONF_SOURCE_MODEL: "test", + CONF_SOURCE_CONFIG_ENTRY_ID: entry.entry_id, + CONF_SOURCE_DEVICE_ID: device_entry.id, + }, + ) assert result["type"] is FlowResultType.CREATE_ENTRY assert result["title"] == "esp32" assert result["data"] == { @@ -570,9 +587,23 @@ async def test_async_step_integration_discovery_remote_adapter( CONF_SOURCE_DOMAIN: "test", CONF_SOURCE_MODEL: "test", CONF_SOURCE_CONFIG_ENTRY_ID: entry.entry_id, + CONF_SOURCE_DEVICE_ID: device_entry.id, } - assert len(mock_setup_entry.mock_calls) == 1 await hass.async_block_till_done() + + new_entry_id: str = result["result"].entry_id + new_entry = hass.config_entries.async_get_entry(new_entry_id) + assert new_entry is not None + assert new_entry.state is config_entries.ConfigEntryState.LOADED + + ble_device_entry = device_registry.async_get_device( + connections={(dr.CONNECTION_BLUETOOTH, scanner.source)} + ) + assert ble_device_entry is not None + assert ble_device_entry.via_device_id == device_entry.id + assert ble_device_entry.area_id == area_entry.id + + await hass.config_entries.async_unload(new_entry.entry_id) await hass.config_entries.async_unload(entry.entry_id) await hass.async_block_till_done() cancel_scanner() diff --git a/tests/components/bluetooth_le_tracker/test_device_tracker.py b/tests/components/bluetooth_le_tracker/test_device_tracker.py index da90980640b..738cae90c22 100644 --- a/tests/components/bluetooth_le_tracker/test_device_tracker.py +++ b/tests/components/bluetooth_le_tracker/test_device_tracker.py @@ -215,7 +215,7 @@ async def test_see_device_if_time_updated(hass: HomeAssistant) -> None: @pytest.mark.usefixtures("mock_bluetooth", "mock_device_tracker_conf") async def test_preserve_new_tracked_device_name(hass: HomeAssistant) -> None: - """Test preserving tracked device name across new seens.""" + """Test preserving tracked device name across new seens.""" # codespell:ignore seens address = "DE:AD:BE:EF:13:37" name = "Mock device name" diff --git a/tests/components/bthome/test_config_flow.py b/tests/components/bthome/test_config_flow.py index faf2f1c9ef5..5aea3a3cc9b 100644 --- a/tests/components/bthome/test_config_flow.py +++ b/tests/components/bthome/test_config_flow.py @@ -213,6 +213,36 @@ async def test_async_step_user_with_found_devices(hass: HomeAssistant) -> None: assert result2["result"].unique_id == "54:48:E6:8F:80:A5" +async def test_async_step_user_replaces_ignored(hass: HomeAssistant) -> None: + """Test setup from service info cache replaces an ignored entry.""" + entry = MockConfigEntry( + domain=DOMAIN, + unique_id="54:48:E6:8F:80:A5", + data={}, + source=config_entries.SOURCE_IGNORE, + ) + entry.add_to_hass(hass) + with patch( + "homeassistant.components.bthome.config_flow.async_discovered_service_info", + return_value=[PRST_SERVICE_INFO], + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_USER}, + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + with patch("homeassistant.components.bthome.async_setup_entry", return_value=True): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={"address": "54:48:E6:8F:80:A5"}, + ) + assert result2["type"] is FlowResultType.CREATE_ENTRY + assert result2["title"] == "b-parasite 80A5" + assert result2["data"] == {} + assert result2["result"].unique_id == "54:48:E6:8F:80:A5" + + async def test_async_step_user_with_found_devices_encryption( hass: HomeAssistant, ) -> None: diff --git a/tests/components/cloud/snapshots/test_http_api.ambr b/tests/components/cloud/snapshots/test_http_api.ambr new file mode 100644 index 00000000000..9b2f2e0eb33 --- /dev/null +++ b/tests/components/cloud/snapshots/test_http_api.ambr @@ -0,0 +1,49 @@ +# serializer version: 1 +# name: test_download_support_package + ''' + ## System Information + + version | core-2025.2.0 + --- | --- + installation_type | Home Assistant Core + dev | False + hassio | False + docker | False + user | hass + virtualenv | False + python_version | 3.13.1 + os_name | Linux + os_version | 6.12.9 + arch | x86_64 + timezone | US/Pacific + config_dir | config + +
mock_no_info_integration + + No information available +
+ +
cloud + + logged_in | True + --- | --- + subscription_expiration | 2025-01-17T11:19:31+00:00 + relayer_connected | True + relayer_region | xx-earth-616 + remote_enabled | True + remote_connected | False + alexa_enabled | True + google_enabled | False + cloud_ice_servers_enabled | True + remote_server | us-west-1 + certificate_status | CertificateStatus.READY + instance_id | 12345678901234567890 + can_reach_cert_server | Exception: Unexpected exception + can_reach_cloud_auth | Failed: unreachable + can_reach_cloud | ok + +
+ + + ''' +# --- diff --git a/tests/components/cloud/test_backup.py b/tests/components/cloud/test_backup.py index c2513168ab9..5b2b8751311 100644 --- a/tests/components/cloud/test_backup.py +++ b/tests/components/cloud/test_backup.py @@ -174,6 +174,7 @@ async def test_agents_list_backups( "backup_id": "23e64aec", "date": "2024-11-22T11:48:48.727189+01:00", "database_included": True, + "extra_metadata": {}, "folders": [], "homeassistant_included": True, "homeassistant_version": "2024.12.0.dev0", @@ -223,6 +224,7 @@ async def test_agents_list_backups_fail_cloud( "backup_id": "23e64aec", "date": "2024-11-22T11:48:48.727189+01:00", "database_included": True, + "extra_metadata": {}, "folders": [], "homeassistant_included": True, "homeassistant_version": "2024.12.0.dev0", diff --git a/tests/components/cloud/test_http_api.py b/tests/components/cloud/test_http_api.py index 910fa03d46c..e4a526ceadd 100644 --- a/tests/components/cloud/test_http_api.py +++ b/tests/components/cloud/test_http_api.py @@ -1,10 +1,11 @@ """Tests for the HTTP API for the cloud component.""" +from collections.abc import Callable, Coroutine from copy import deepcopy from http import HTTPStatus import json from typing import Any -from unittest.mock import AsyncMock, MagicMock, Mock, patch +from unittest.mock import AsyncMock, MagicMock, Mock, PropertyMock, patch import aiohttp from hass_nabucasa import thingtalk @@ -15,9 +16,12 @@ from hass_nabucasa.auth import ( UnknownError, ) from hass_nabucasa.const import STATE_CONNECTED +from hass_nabucasa.remote import CertificateStatus from hass_nabucasa.voice import TTS_VOICES import pytest +from syrupy.assertion import SnapshotAssertion +from homeassistant.components import system_health from homeassistant.components.alexa import errors as alexa_errors # pylint: disable-next=hass-component-root-import @@ -30,8 +34,10 @@ from homeassistant.components.websocket_api import ERR_INVALID_FORMAT from homeassistant.core import HomeAssistant, State from homeassistant.helpers import entity_registry as er from homeassistant.setup import async_setup_component +from homeassistant.util import dt as dt_util from homeassistant.util.location import LocationInfo +from tests.common import mock_platform from tests.components.google_assistant import MockConfig from tests.test_util.aiohttp import AiohttpClientMocker from tests.typing import ClientSessionGenerator, WebSocketGenerator @@ -113,6 +119,7 @@ async def setup_cloud_fixture(hass: HomeAssistant, cloud: MagicMock) -> None: "user_pool_id": "user_pool_id", "region": "region", "relayer_server": "relayer", + "acme_server": "cert-server", "accounts_server": "api-test.hass.io", "google_actions": {"filter": {"include_domains": "light"}}, "alexa": { @@ -1860,3 +1867,96 @@ async def test_logout_view_dispatch_event( assert async_dispatcher_send_mock.call_count == 1 assert async_dispatcher_send_mock.mock_calls[0][1][1] == "cloud_event" assert async_dispatcher_send_mock.mock_calls[0][1][2] == {"type": "logout"} + + +async def test_download_support_package( + hass: HomeAssistant, + cloud: MagicMock, + set_cloud_prefs: Callable[[dict[str, Any]], Coroutine[Any, Any, None]], + hass_client: ClientSessionGenerator, + aioclient_mock: AiohttpClientMocker, + snapshot: SnapshotAssertion, +) -> None: + """Test downloading a support package file.""" + aioclient_mock.get("https://cloud.bla.com/status", text="") + aioclient_mock.get( + "https://cert-server/directory", exc=Exception("Unexpected exception") + ) + aioclient_mock.get( + "https://cognito-idp.us-east-1.amazonaws.com/AAAA/.well-known/jwks.json", + exc=aiohttp.ClientError, + ) + + def async_register_mock_platform( + hass: HomeAssistant, register: system_health.SystemHealthRegistration + ) -> None: + async def mock_empty_info(hass: HomeAssistant) -> dict[str, Any]: + return {} + + register.async_register_info(mock_empty_info, "/config/mock_integration") + + mock_platform( + hass, + "mock_no_info_integration.system_health", + MagicMock(async_register=async_register_mock_platform), + ) + hass.config.components.add("mock_no_info_integration") + + assert await async_setup_component(hass, "system_health", {}) + + with patch("uuid.UUID.hex", new_callable=PropertyMock) as hexmock: + hexmock.return_value = "12345678901234567890" + assert await async_setup_component( + hass, + DOMAIN, + { + DOMAIN: { + "user_pool_id": "AAAA", + "region": "us-east-1", + "acme_server": "cert-server", + "relayer_server": "cloud.bla.com", + }, + }, + ) + await hass.async_block_till_done() + + await cloud.login("test-user", "test-pass") + + cloud.remote.snitun_server = "us-west-1" + cloud.remote.certificate_status = CertificateStatus.READY + cloud.expiration_date = dt_util.parse_datetime("2025-01-17T11:19:31.0+00:00") + + await cloud.client.async_system_message({"region": "xx-earth-616"}) + await set_cloud_prefs( + { + "alexa_enabled": True, + "google_enabled": False, + "remote_enabled": True, + "cloud_ice_servers_enabled": True, + } + ) + + cloud_client = await hass_client() + with ( + patch.object(hass.config, "config_dir", new="config"), + patch( + "homeassistant.components.homeassistant.system_health.system_info.async_get_system_info", + return_value={ + "installation_type": "Home Assistant Core", + "version": "2025.2.0", + "dev": False, + "hassio": False, + "virtualenv": False, + "python_version": "3.13.1", + "docker": False, + "arch": "x86_64", + "timezone": "US/Pacific", + "os_name": "Linux", + "os_version": "6.12.9", + "user": "hass", + }, + ), + ): + req = await cloud_client.get("/api/cloud/support_package") + assert req.status == HTTPStatus.OK + assert await req.text() == snapshot diff --git a/tests/components/cloud/test_tts.py b/tests/components/cloud/test_tts.py index bf9fd7302ae..81b10866dff 100644 --- a/tests/components/cloud/test_tts.py +++ b/tests/components/cloud/test_tts.py @@ -12,7 +12,12 @@ import voluptuous as vol from homeassistant.components.assist_pipeline.pipeline import STORAGE_KEY from homeassistant.components.cloud.const import DEFAULT_TTS_DEFAULT_VOICE, DOMAIN -from homeassistant.components.cloud.tts import PLATFORM_SCHEMA, SUPPORT_LANGUAGES, Voice +from homeassistant.components.cloud.tts import ( + DEFAULT_VOICES, + PLATFORM_SCHEMA, + SUPPORT_LANGUAGES, + Voice, +) from homeassistant.components.media_player import ( ATTR_MEDIA_CONTENT_ID, DOMAIN as DOMAIN_MP, @@ -61,6 +66,19 @@ def test_default_exists() -> None: assert DEFAULT_TTS_DEFAULT_VOICE[1] in TTS_VOICES[DEFAULT_TTS_DEFAULT_VOICE[0]] +def test_all_languages_have_default() -> None: + """Test all languages have a default voice.""" + assert set(SUPPORT_LANGUAGES).difference(DEFAULT_VOICES) == set() + assert set(DEFAULT_VOICES).difference(SUPPORT_LANGUAGES) == set() + + +@pytest.mark.parametrize(("language", "voice"), DEFAULT_VOICES.items()) +def test_default_voice_is_valid(language: str, voice: str) -> None: + """Test that the default voice is valid.""" + assert language in TTS_VOICES + assert voice in TTS_VOICES[language] + + def test_schema() -> None: """Test schema.""" assert "nl-NL" in SUPPORT_LANGUAGES diff --git a/tests/components/config/test_config_entries.py b/tests/components/config/test_config_entries.py index ee000c5ada2..f5241f65200 100644 --- a/tests/components/config/test_config_entries.py +++ b/tests/components/config/test_config_entries.py @@ -3,6 +3,7 @@ from collections import OrderedDict from collections.abc import Generator from http import HTTPStatus +from typing import Any from unittest.mock import ANY, AsyncMock, patch from aiohttp.test_utils import TestClient @@ -12,12 +13,13 @@ import voluptuous as vol from homeassistant import config_entries as core_ce, data_entry_flow, loader from homeassistant.components.config import config_entries -from homeassistant.config_entries import HANDLERS, ConfigFlow +from homeassistant.config_entries import HANDLERS, ConfigFlow, ConfigFlowResult from homeassistant.const import CONF_LATITUDE, CONF_LONGITUDE, CONF_RADIUS from homeassistant.core import HomeAssistant, callback from homeassistant.data_entry_flow import FlowResultType from homeassistant.helpers import config_entry_flow, config_validation as cv from homeassistant.helpers.discovery_flow import DiscoveryKey +from homeassistant.helpers.service_info.hassio import HassioServiceInfo from homeassistant.loader import IntegrationNotFound from homeassistant.setup import async_setup_component from homeassistant.util.dt import utcnow @@ -729,27 +731,62 @@ async def test_get_progress_index( mock_platform(hass, "test.config_flow", None) ws_client = await hass_ws_client(hass) + mock_integration( + hass, MockModule("test", async_setup_entry=AsyncMock(return_value=True)) + ) + + entry = MockConfigEntry(domain="test", title="Test", entry_id="1234") + entry.add_to_hass(hass) + class TestFlow(core_ce.ConfigFlow): VERSION = 5 - async def async_step_hassio(self, discovery_info): + async def async_step_hassio( + self, discovery_info: HassioServiceInfo + ) -> ConfigFlowResult: + """Handle a Hass.io discovery.""" return await self.async_step_account() - async def async_step_account(self, user_input=None): + async def async_step_account(self, user_input: dict[str, Any] | None = None): + """Show a form to the user.""" return self.async_show_form(step_id="account") + async def async_step_user(self, user_input: dict[str, Any] | None = None): + """Handle a config flow initialized by the user.""" + return await self.async_step_account() + + async def async_step_reconfigure( + self, user_input: dict[str, Any] | None = None + ): + """Handle a reconfiguration flow initialized by the user.""" + nonlocal entry + assert self._get_reconfigure_entry() is entry + return await self.async_step_account() + with patch.dict(HANDLERS, {"test": TestFlow}): - form = await hass.config_entries.flow.async_init( + form_hassio = await hass.config_entries.flow.async_init( "test", context={"source": core_ce.SOURCE_HASSIO} ) + form_user = await hass.config_entries.flow.async_init( + "test", context={"source": core_ce.SOURCE_USER} + ) + form_reconfigure = await hass.config_entries.flow.async_init( + "test", context={"source": core_ce.SOURCE_RECONFIGURE, "entry_id": "1234"} + ) + + for form in (form_hassio, form_user, form_reconfigure): + assert form["type"] == data_entry_flow.FlowResultType.FORM + assert form["step_id"] == "account" await ws_client.send_json({"id": 5, "type": "config_entries/flow/progress"}) response = await ws_client.receive_json() assert response["success"] + + # Active flows with SOURCE_USER and SOURCE_RECONFIGURE should be filtered out assert response["result"] == [ { - "flow_id": form["flow_id"], + "flow_id": form_hassio["flow_id"], "handler": "test", "step_id": "account", "context": {"source": core_ce.SOURCE_HASSIO}, diff --git a/tests/components/conversation/snapshots/test_session.ambr b/tests/components/conversation/snapshots/test_chat_log.ambr similarity index 100% rename from tests/components/conversation/snapshots/test_session.ambr rename to tests/components/conversation/snapshots/test_chat_log.ambr diff --git a/tests/components/conversation/test_chat_log.py b/tests/components/conversation/test_chat_log.py new file mode 100644 index 00000000000..c22a90e6928 --- /dev/null +++ b/tests/components/conversation/test_chat_log.py @@ -0,0 +1,392 @@ +"""Test the conversation session.""" + +from collections.abc import Generator +from datetime import timedelta +from unittest.mock import AsyncMock, Mock, patch + +import pytest +from syrupy.assertion import SnapshotAssertion +import voluptuous as vol + +from homeassistant.components.conversation import ( + AssistantContent, + ConversationInput, + ConverseError, + ToolResultContent, + async_get_chat_log, +) +from homeassistant.components.conversation.chat_log import DATA_CHAT_HISTORY +from homeassistant.core import Context, HomeAssistant +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers import chat_session, llm +from homeassistant.util import dt as dt_util + +from tests.common import async_fire_time_changed + + +@pytest.fixture +def mock_conversation_input(hass: HomeAssistant) -> ConversationInput: + """Return a conversation input instance.""" + return ConversationInput( + text="Hello", + context=Context(), + conversation_id=None, + agent_id="mock-agent-id", + device_id=None, + language="en", + ) + + +@pytest.fixture +def mock_ulid() -> Generator[Mock]: + """Mock the ulid library.""" + with patch("homeassistant.helpers.chat_session.ulid_now") as mock_ulid_now: + mock_ulid_now.return_value = "mock-ulid" + yield mock_ulid_now + + +async def test_cleanup( + hass: HomeAssistant, + mock_conversation_input: ConversationInput, +) -> None: + """Test cleanup of the chat log.""" + with ( + chat_session.async_get_chat_session(hass) as session, + async_get_chat_log(hass, session, mock_conversation_input) as chat_log, + ): + conversation_id = session.conversation_id + # Add message so it persists + chat_log.async_add_assistant_content_without_tools( + AssistantContent( + agent_id="mock-agent-id", + content="Hey!", + ) + ) + + assert conversation_id in hass.data[DATA_CHAT_HISTORY] + + # Set the last updated to be older than the timeout + hass.data[chat_session.DATA_CHAT_SESSION][conversation_id].last_updated = ( + dt_util.utcnow() + chat_session.CONVERSATION_TIMEOUT + ) + + async_fire_time_changed( + hass, + dt_util.utcnow() + chat_session.CONVERSATION_TIMEOUT * 2 + timedelta(seconds=1), + ) + + assert conversation_id not in hass.data[DATA_CHAT_HISTORY] + + +async def test_default_content( + hass: HomeAssistant, mock_conversation_input: ConversationInput +) -> None: + """Test filtering of messages.""" + with ( + chat_session.async_get_chat_session(hass) as session, + async_get_chat_log(hass, session, mock_conversation_input) as chat_log, + ): + assert len(chat_log.content) == 2 + assert chat_log.content[0].role == "system" + assert chat_log.content[0].content == "" + assert chat_log.content[1].role == "user" + assert chat_log.content[1].content == mock_conversation_input.text + + +async def test_llm_api( + hass: HomeAssistant, + mock_conversation_input: ConversationInput, +) -> None: + """Test when we reference an LLM API.""" + with ( + chat_session.async_get_chat_session(hass) as session, + async_get_chat_log(hass, session, mock_conversation_input) as chat_log, + ): + await chat_log.async_update_llm_data( + conversing_domain="test", + user_input=mock_conversation_input, + user_llm_hass_api="assist", + user_llm_prompt=None, + ) + + assert isinstance(chat_log.llm_api, llm.APIInstance) + assert chat_log.llm_api.api.id == "assist" + + +async def test_unknown_llm_api( + hass: HomeAssistant, + mock_conversation_input: ConversationInput, + snapshot: SnapshotAssertion, +) -> None: + """Test when we reference an LLM API that does not exists.""" + with ( + chat_session.async_get_chat_session(hass) as session, + async_get_chat_log(hass, session, mock_conversation_input) as chat_log, + pytest.raises(ConverseError) as exc_info, + ): + await chat_log.async_update_llm_data( + conversing_domain="test", + user_input=mock_conversation_input, + user_llm_hass_api="unknown-api", + user_llm_prompt=None, + ) + + assert str(exc_info.value) == "Error getting LLM API unknown-api" + assert exc_info.value.as_conversation_result().as_dict() == snapshot + + +async def test_template_error( + hass: HomeAssistant, + mock_conversation_input: ConversationInput, + snapshot: SnapshotAssertion, +) -> None: + """Test that template error handling works.""" + with ( + chat_session.async_get_chat_session(hass) as session, + async_get_chat_log(hass, session, mock_conversation_input) as chat_log, + pytest.raises(ConverseError) as exc_info, + ): + await chat_log.async_update_llm_data( + conversing_domain="test", + user_input=mock_conversation_input, + user_llm_hass_api=None, + user_llm_prompt="{{ invalid_syntax", + ) + + assert str(exc_info.value) == "Error rendering prompt" + assert exc_info.value.as_conversation_result().as_dict() == snapshot + + +async def test_template_variables( + hass: HomeAssistant, mock_conversation_input: ConversationInput +) -> None: + """Test that template variables work.""" + mock_user = Mock() + mock_user.id = "12345" + mock_user.name = "Test User" + mock_conversation_input.context = Context(user_id=mock_user.id) + + with ( + chat_session.async_get_chat_session(hass) as session, + async_get_chat_log(hass, session, mock_conversation_input) as chat_log, + patch("homeassistant.auth.AuthManager.async_get_user", return_value=mock_user), + ): + await chat_log.async_update_llm_data( + conversing_domain="test", + user_input=mock_conversation_input, + user_llm_hass_api=None, + user_llm_prompt=( + "The instance name is {{ ha_name }}. " + "The user name is {{ user_name }}. " + "The user id is {{ llm_context.context.user_id }}." + "The calling platform is {{ llm_context.platform }}." + ), + ) + + assert "The instance name is test home." in chat_log.content[0].content + assert "The user name is Test User." in chat_log.content[0].content + assert "The user id is 12345." in chat_log.content[0].content + assert "The calling platform is test." in chat_log.content[0].content + + +async def test_extra_systen_prompt( + hass: HomeAssistant, mock_conversation_input: ConversationInput +) -> None: + """Test that extra system prompt works.""" + extra_system_prompt = "Garage door cover.garage_door has been left open for 30 minutes. We asked the user if they want to close it." + extra_system_prompt2 = ( + "User person.paulus came home. Asked him what he wants to do." + ) + mock_conversation_input.extra_system_prompt = extra_system_prompt + + with ( + chat_session.async_get_chat_session(hass) as session, + async_get_chat_log(hass, session, mock_conversation_input) as chat_log, + ): + await chat_log.async_update_llm_data( + conversing_domain="test", + user_input=mock_conversation_input, + user_llm_hass_api=None, + user_llm_prompt=None, + ) + chat_log.async_add_assistant_content_without_tools( + AssistantContent( + agent_id="mock-agent-id", + content="Hey!", + ) + ) + + assert chat_log.extra_system_prompt == extra_system_prompt + assert chat_log.content[0].content.endswith(extra_system_prompt) + + # Verify that follow-up conversations with no system prompt take previous one + conversation_id = chat_log.conversation_id + mock_conversation_input.extra_system_prompt = None + + with ( + chat_session.async_get_chat_session(hass, conversation_id) as session, + async_get_chat_log(hass, session, mock_conversation_input) as chat_log, + ): + await chat_log.async_update_llm_data( + conversing_domain="test", + user_input=mock_conversation_input, + user_llm_hass_api=None, + user_llm_prompt=None, + ) + + assert chat_log.extra_system_prompt == extra_system_prompt + assert chat_log.content[0].content.endswith(extra_system_prompt) + + # Verify that we take new system prompts + mock_conversation_input.extra_system_prompt = extra_system_prompt2 + + with ( + chat_session.async_get_chat_session(hass, conversation_id) as session, + async_get_chat_log(hass, session, mock_conversation_input) as chat_log, + ): + await chat_log.async_update_llm_data( + conversing_domain="test", + user_input=mock_conversation_input, + user_llm_hass_api=None, + user_llm_prompt=None, + ) + chat_log.async_add_assistant_content_without_tools( + AssistantContent( + agent_id="mock-agent-id", + content="Hey!", + ) + ) + + assert chat_log.extra_system_prompt == extra_system_prompt2 + assert chat_log.content[0].content.endswith(extra_system_prompt2) + assert extra_system_prompt not in chat_log.content[0].content + + # Verify that follow-up conversations with no system prompt take previous one + mock_conversation_input.extra_system_prompt = None + + with ( + chat_session.async_get_chat_session(hass, conversation_id) as session, + async_get_chat_log(hass, session, mock_conversation_input) as chat_log, + ): + await chat_log.async_update_llm_data( + conversing_domain="test", + user_input=mock_conversation_input, + user_llm_hass_api=None, + user_llm_prompt=None, + ) + + assert chat_log.extra_system_prompt == extra_system_prompt2 + assert chat_log.content[0].content.endswith(extra_system_prompt2) + + +async def test_tool_call( + hass: HomeAssistant, + mock_conversation_input: ConversationInput, +) -> None: + """Test using the session tool calling API.""" + + mock_tool = AsyncMock() + mock_tool.name = "test_tool" + mock_tool.description = "Test function" + mock_tool.parameters = vol.Schema( + {vol.Optional("param1", description="Test parameters"): str} + ) + mock_tool.async_call.return_value = "Test response" + + with patch( + "homeassistant.helpers.llm.AssistAPI._async_get_tools", return_value=[] + ) as mock_get_tools: + mock_get_tools.return_value = [mock_tool] + + with ( + chat_session.async_get_chat_session(hass) as session, + async_get_chat_log(hass, session, mock_conversation_input) as chat_log, + ): + await chat_log.async_update_llm_data( + conversing_domain="test", + user_input=mock_conversation_input, + user_llm_hass_api="assist", + user_llm_prompt=None, + ) + content = AssistantContent( + agent_id=mock_conversation_input.agent_id, + content="", + tool_calls=[ + llm.ToolInput( + id="mock-tool-call-id", + tool_name="test_tool", + tool_args={"param1": "Test Param"}, + ) + ], + ) + + with pytest.raises(ValueError): + chat_log.async_add_assistant_content_without_tools(content) + + result = None + async for tool_result_content in chat_log.async_add_assistant_content( + content + ): + assert result is None + result = tool_result_content + + assert result == ToolResultContent( + agent_id=mock_conversation_input.agent_id, + tool_call_id="mock-tool-call-id", + tool_result="Test response", + tool_name="test_tool", + ) + + +async def test_tool_call_exception( + hass: HomeAssistant, + mock_conversation_input: ConversationInput, +) -> None: + """Test using the session tool calling API.""" + + mock_tool = AsyncMock() + mock_tool.name = "test_tool" + mock_tool.description = "Test function" + mock_tool.parameters = vol.Schema( + {vol.Optional("param1", description="Test parameters"): str} + ) + mock_tool.async_call.side_effect = HomeAssistantError("Test error") + + with patch( + "homeassistant.helpers.llm.AssistAPI._async_get_tools", return_value=[] + ) as mock_get_tools: + mock_get_tools.return_value = [mock_tool] + + with ( + chat_session.async_get_chat_session(hass) as session, + async_get_chat_log(hass, session, mock_conversation_input) as chat_log, + ): + await chat_log.async_update_llm_data( + conversing_domain="test", + user_input=mock_conversation_input, + user_llm_hass_api="assist", + user_llm_prompt=None, + ) + result = None + async for tool_result_content in chat_log.async_add_assistant_content( + AssistantContent( + agent_id=mock_conversation_input.agent_id, + content="", + tool_calls=[ + llm.ToolInput( + id="mock-tool-call-id", + tool_name="test_tool", + tool_args={"param1": "Test Param"}, + ) + ], + ) + ): + assert result is None + result = tool_result_content + + assert result == ToolResultContent( + agent_id=mock_conversation_input.agent_id, + tool_call_id="mock-tool-call-id", + tool_result={"error": "HomeAssistantError", "error_text": "Test error"}, + tool_name="test_tool", + ) diff --git a/tests/components/conversation/test_session.py b/tests/components/conversation/test_session.py deleted file mode 100644 index 60c7f2957b8..00000000000 --- a/tests/components/conversation/test_session.py +++ /dev/null @@ -1,492 +0,0 @@ -"""Test the conversation session.""" - -from collections.abc import Generator -from datetime import timedelta -from unittest.mock import AsyncMock, Mock, patch - -import pytest -from syrupy.assertion import SnapshotAssertion -import voluptuous as vol - -from homeassistant.components.conversation import ConversationInput, session -from homeassistant.core import Context, HomeAssistant -from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers import llm -from homeassistant.util import dt as dt_util - -from tests.common import async_fire_time_changed - - -@pytest.fixture -def mock_conversation_input(hass: HomeAssistant) -> ConversationInput: - """Return a conversation input instance.""" - return ConversationInput( - text="Hello", - context=Context(), - conversation_id=None, - agent_id="mock-agent-id", - device_id=None, - language="en", - ) - - -@pytest.fixture -def mock_ulid() -> Generator[Mock]: - """Mock the ulid library.""" - with patch("homeassistant.util.ulid.ulid_now") as mock_ulid_now: - mock_ulid_now.return_value = "mock-ulid" - yield mock_ulid_now - - -@pytest.mark.parametrize( - ("start_id", "given_id"), - [ - (None, "mock-ulid"), - # This ULID is not known as a session - ("01JHXE0952TSJCFJZ869AW6HMD", "mock-ulid"), - ("not-a-ulid", "not-a-ulid"), - ], -) -async def test_conversation_id( - hass: HomeAssistant, - mock_conversation_input: ConversationInput, - mock_ulid: Mock, - start_id: str | None, - given_id: str, -) -> None: - """Test conversation ID generation.""" - mock_conversation_input.conversation_id = start_id - async with session.async_get_chat_session( - hass, mock_conversation_input - ) as chat_session: - assert chat_session.conversation_id == given_id - - -async def test_cleanup( - hass: HomeAssistant, - mock_conversation_input: ConversationInput, -) -> None: - """Mock cleanup of the conversation session.""" - async with session.async_get_chat_session( - hass, mock_conversation_input - ) as chat_session: - assert len(chat_session.messages) == 2 - conversation_id = chat_session.conversation_id - - # Generate session entry. - async with session.async_get_chat_session( - hass, mock_conversation_input - ) as chat_session: - # Because we didn't add a message to the session in the last block, - # the conversation was not be persisted and we get a new ID - assert chat_session.conversation_id != conversation_id - conversation_id = chat_session.conversation_id - chat_session.async_add_message( - session.Content( - role="assistant", - agent_id="mock-agent-id", - content="Hey!", - ) - ) - assert len(chat_session.messages) == 3 - - # Reuse conversation ID to ensure we can chat with same session - mock_conversation_input.conversation_id = conversation_id - async with session.async_get_chat_session( - hass, mock_conversation_input - ) as chat_session: - assert len(chat_session.messages) == 4 - assert chat_session.conversation_id == conversation_id - - # Set the last updated to be older than the timeout - hass.data[session.DATA_CHAT_HISTORY][conversation_id].last_updated = ( - dt_util.utcnow() + session.CONVERSATION_TIMEOUT - ) - - async_fire_time_changed( - hass, dt_util.utcnow() + session.CONVERSATION_TIMEOUT + timedelta(seconds=1) - ) - - # Should not be cleaned up, but it should have scheduled another cleanup - mock_conversation_input.conversation_id = conversation_id - async with session.async_get_chat_session( - hass, mock_conversation_input - ) as chat_session: - assert len(chat_session.messages) == 4 - assert chat_session.conversation_id == conversation_id - - async_fire_time_changed( - hass, dt_util.utcnow() + session.CONVERSATION_TIMEOUT * 2 + timedelta(seconds=1) - ) - - # It should be cleaned up now and we start a new conversation - async with session.async_get_chat_session( - hass, mock_conversation_input - ) as chat_session: - assert chat_session.conversation_id != conversation_id - assert len(chat_session.messages) == 2 - - -async def test_add_message( - hass: HomeAssistant, mock_conversation_input: ConversationInput -) -> None: - """Test filtering of messages.""" - async with session.async_get_chat_session( - hass, mock_conversation_input - ) as chat_session: - assert len(chat_session.messages) == 2 - - with pytest.raises(ValueError): - chat_session.async_add_message( - session.Content(role="system", agent_id=None, content="") - ) - - # No 2 user messages in a row - assert chat_session.messages[1].role == "user" - - with pytest.raises(ValueError): - chat_session.async_add_message( - session.Content(role="user", agent_id=None, content="") - ) - - # No 2 assistant messages in a row - chat_session.async_add_message( - session.Content(role="assistant", agent_id=None, content="") - ) - assert len(chat_session.messages) == 3 - assert chat_session.messages[-1].role == "assistant" - - with pytest.raises(ValueError): - chat_session.async_add_message( - session.Content(role="assistant", agent_id=None, content="") - ) - - -async def test_message_filtering( - hass: HomeAssistant, mock_conversation_input: ConversationInput -) -> None: - """Test filtering of messages.""" - async with session.async_get_chat_session( - hass, mock_conversation_input - ) as chat_session: - messages = chat_session.async_get_messages(agent_id=None) - assert len(messages) == 2 - assert messages[0] == session.Content( - role="system", - agent_id=None, - content="", - ) - assert messages[1] == session.Content( - role="user", - agent_id="mock-agent-id", - content=mock_conversation_input.text, - ) - # Cannot add a second user message in a row - with pytest.raises(ValueError): - chat_session.async_add_message( - session.Content( - role="user", - agent_id="mock-agent-id", - content="Hey!", - ) - ) - - chat_session.async_add_message( - session.Content( - role="assistant", - agent_id="mock-agent-id", - content="Hey!", - ) - ) - # Different agent, native messages will be filtered out. - chat_session.async_add_message( - session.NativeContent(agent_id="another-mock-agent-id", content=1) - ) - chat_session.async_add_message( - session.NativeContent(agent_id="mock-agent-id", content=1) - ) - # A non-native message from another agent is not filtered out. - chat_session.async_add_message( - session.Content( - role="assistant", - agent_id="another-mock-agent-id", - content="Hi!", - ) - ) - - assert len(chat_session.messages) == 6 - - messages = chat_session.async_get_messages(agent_id="mock-agent-id") - assert len(messages) == 5 - - assert messages[2] == session.Content( - role="assistant", - agent_id="mock-agent-id", - content="Hey!", - ) - assert messages[3] == session.NativeContent(agent_id="mock-agent-id", content=1) - assert messages[4] == session.Content( - role="assistant", agent_id="another-mock-agent-id", content="Hi!" - ) - - -async def test_llm_api( - hass: HomeAssistant, - mock_conversation_input: ConversationInput, -) -> None: - """Test when we reference an LLM API.""" - async with session.async_get_chat_session( - hass, mock_conversation_input - ) as chat_session: - await chat_session.async_update_llm_data( - conversing_domain="test", - user_input=mock_conversation_input, - user_llm_hass_api="assist", - user_llm_prompt=None, - ) - - assert isinstance(chat_session.llm_api, llm.APIInstance) - assert chat_session.llm_api.api.id == "assist" - - -async def test_unknown_llm_api( - hass: HomeAssistant, - mock_conversation_input: ConversationInput, - snapshot: SnapshotAssertion, -) -> None: - """Test when we reference an LLM API that does not exists.""" - async with session.async_get_chat_session( - hass, mock_conversation_input - ) as chat_session: - with pytest.raises(session.ConverseError) as exc_info: - await chat_session.async_update_llm_data( - conversing_domain="test", - user_input=mock_conversation_input, - user_llm_hass_api="unknown-api", - user_llm_prompt=None, - ) - - assert str(exc_info.value) == "Error getting LLM API unknown-api" - assert exc_info.value.as_conversation_result().as_dict() == snapshot - - -async def test_template_error( - hass: HomeAssistant, - mock_conversation_input: ConversationInput, - snapshot: SnapshotAssertion, -) -> None: - """Test that template error handling works.""" - async with session.async_get_chat_session( - hass, mock_conversation_input - ) as chat_session: - with pytest.raises(session.ConverseError) as exc_info: - await chat_session.async_update_llm_data( - conversing_domain="test", - user_input=mock_conversation_input, - user_llm_hass_api=None, - user_llm_prompt="{{ invalid_syntax", - ) - - assert str(exc_info.value) == "Error rendering prompt" - assert exc_info.value.as_conversation_result().as_dict() == snapshot - - -async def test_template_variables( - hass: HomeAssistant, mock_conversation_input: ConversationInput -) -> None: - """Test that template variables work.""" - mock_user = Mock() - mock_user.id = "12345" - mock_user.name = "Test User" - mock_conversation_input.context = Context(user_id=mock_user.id) - - async with session.async_get_chat_session( - hass, mock_conversation_input - ) as chat_session: - with patch( - "homeassistant.auth.AuthManager.async_get_user", return_value=mock_user - ): - await chat_session.async_update_llm_data( - conversing_domain="test", - user_input=mock_conversation_input, - user_llm_hass_api=None, - user_llm_prompt=( - "The instance name is {{ ha_name }}. " - "The user name is {{ user_name }}. " - "The user id is {{ llm_context.context.user_id }}." - "The calling platform is {{ llm_context.platform }}." - ), - ) - - assert chat_session.user_name == "Test User" - - assert "The instance name is test home." in chat_session.messages[0].content - assert "The user name is Test User." in chat_session.messages[0].content - assert "The user id is 12345." in chat_session.messages[0].content - assert "The calling platform is test." in chat_session.messages[0].content - - -async def test_extra_systen_prompt( - hass: HomeAssistant, mock_conversation_input: ConversationInput -) -> None: - """Test that extra system prompt works.""" - extra_system_prompt = "Garage door cover.garage_door has been left open for 30 minutes. We asked the user if they want to close it." - extra_system_prompt2 = ( - "User person.paulus came home. Asked him what he wants to do." - ) - mock_conversation_input.extra_system_prompt = extra_system_prompt - - async with session.async_get_chat_session( - hass, mock_conversation_input - ) as chat_session: - await chat_session.async_update_llm_data( - conversing_domain="test", - user_input=mock_conversation_input, - user_llm_hass_api=None, - user_llm_prompt=None, - ) - chat_session.async_add_message( - session.Content( - role="assistant", - agent_id="mock-agent-id", - content="Hey!", - ) - ) - - assert chat_session.extra_system_prompt == extra_system_prompt - assert chat_session.messages[0].content.endswith(extra_system_prompt) - - # Verify that follow-up conversations with no system prompt take previous one - mock_conversation_input.conversation_id = chat_session.conversation_id - mock_conversation_input.extra_system_prompt = None - - async with session.async_get_chat_session( - hass, mock_conversation_input - ) as chat_session: - await chat_session.async_update_llm_data( - conversing_domain="test", - user_input=mock_conversation_input, - user_llm_hass_api=None, - user_llm_prompt=None, - ) - - assert chat_session.extra_system_prompt == extra_system_prompt - assert chat_session.messages[0].content.endswith(extra_system_prompt) - - # Verify that we take new system prompts - mock_conversation_input.extra_system_prompt = extra_system_prompt2 - - async with session.async_get_chat_session( - hass, mock_conversation_input - ) as chat_session: - await chat_session.async_update_llm_data( - conversing_domain="test", - user_input=mock_conversation_input, - user_llm_hass_api=None, - user_llm_prompt=None, - ) - chat_session.async_add_message( - session.Content( - role="assistant", - agent_id="mock-agent-id", - content="Hey!", - ) - ) - - assert chat_session.extra_system_prompt == extra_system_prompt2 - assert chat_session.messages[0].content.endswith(extra_system_prompt2) - assert extra_system_prompt not in chat_session.messages[0].content - - # Verify that follow-up conversations with no system prompt take previous one - mock_conversation_input.extra_system_prompt = None - - async with session.async_get_chat_session( - hass, mock_conversation_input - ) as chat_session: - await chat_session.async_update_llm_data( - conversing_domain="test", - user_input=mock_conversation_input, - user_llm_hass_api=None, - user_llm_prompt=None, - ) - - assert chat_session.extra_system_prompt == extra_system_prompt2 - assert chat_session.messages[0].content.endswith(extra_system_prompt2) - - -async def test_tool_call( - hass: HomeAssistant, - mock_conversation_input: ConversationInput, -) -> None: - """Test using the session tool calling API.""" - - mock_tool = AsyncMock() - mock_tool.name = "test_tool" - mock_tool.description = "Test function" - mock_tool.parameters = vol.Schema( - {vol.Optional("param1", description="Test parameters"): str} - ) - mock_tool.async_call.return_value = "Test response" - - with patch( - "homeassistant.components.conversation.session.llm.AssistAPI._async_get_tools", - return_value=[], - ) as mock_get_tools: - mock_get_tools.return_value = [mock_tool] - - async with session.async_get_chat_session( - hass, mock_conversation_input - ) as chat_session: - await chat_session.async_update_llm_data( - conversing_domain="test", - user_input=mock_conversation_input, - user_llm_hass_api="assist", - user_llm_prompt=None, - ) - result = await chat_session.async_call_tool( - llm.ToolInput( - tool_name="test_tool", - tool_args={"param1": "Test Param"}, - ) - ) - - assert result == "Test response" - - -async def test_tool_call_exception( - hass: HomeAssistant, - mock_conversation_input: ConversationInput, -) -> None: - """Test using the session tool calling API.""" - - mock_tool = AsyncMock() - mock_tool.name = "test_tool" - mock_tool.description = "Test function" - mock_tool.parameters = vol.Schema( - {vol.Optional("param1", description="Test parameters"): str} - ) - mock_tool.async_call.side_effect = HomeAssistantError("Test error") - - with patch( - "homeassistant.components.conversation.session.llm.AssistAPI._async_get_tools", - return_value=[], - ) as mock_get_tools: - mock_get_tools.return_value = [mock_tool] - - async with session.async_get_chat_session( - hass, mock_conversation_input - ) as chat_session: - await chat_session.async_update_llm_data( - conversing_domain="test", - user_input=mock_conversation_input, - user_llm_hass_api="assist", - user_llm_prompt=None, - ) - result = await chat_session.async_call_tool( - llm.ToolInput( - tool_name="test_tool", - tool_args={"param1": "Test Param"}, - ) - ) - - assert result == {"error": "HomeAssistantError", "error_text": "Test error"} diff --git a/tests/components/eheimdigital/conftest.py b/tests/components/eheimdigital/conftest.py index ef52eade9ae..afb97b97569 100644 --- a/tests/components/eheimdigital/conftest.py +++ b/tests/components/eheimdigital/conftest.py @@ -34,6 +34,7 @@ def classic_led_ctrl_mock(): ) classic_led_ctrl_mock.name = "Mock classicLEDcontrol+e" classic_led_ctrl_mock.aquarium_name = "Mock Aquarium" + classic_led_ctrl_mock.sw_version = "1.0.0_1.0.0" classic_led_ctrl_mock.light_mode = LightMode.DAYCL_MODE classic_led_ctrl_mock.light_level = (10, 39) return classic_led_ctrl_mock @@ -47,6 +48,7 @@ def heater_mock(): heater_mock.device_type = EheimDeviceType.VERSION_EHEIM_EXT_HEATER heater_mock.name = "Mock Heater" heater_mock.aquarium_name = "Mock Aquarium" + heater_mock.sw_version = "1.0.0_1.0.0" heater_mock.temperature_unit = HeaterUnit.CELSIUS heater_mock.current_temperature = 24.2 heater_mock.target_temperature = 25.5 diff --git a/tests/components/enphase_envoy/test_config_flow.py b/tests/components/enphase_envoy/test_config_flow.py index a3da14b3835..efbe6da9b13 100644 --- a/tests/components/enphase_envoy/test_config_flow.py +++ b/tests/components/enphase_envoy/test_config_flow.py @@ -439,7 +439,7 @@ async def test_zero_conf_old_blank_entry( mock_setup_entry: AsyncMock, mock_envoy: AsyncMock, ) -> None: - """Test re-using old blank entry.""" + """Test reusing old blank entry.""" entry = MockConfigEntry( domain=DOMAIN, data={ @@ -478,7 +478,7 @@ async def test_zero_conf_old_blank_entry_standard_title( mock_setup_entry: AsyncMock, mock_envoy: AsyncMock, ) -> None: - """Test re-using old blank entry was Envoy as title.""" + """Test reusing old blank entry was Envoy as title.""" entry = MockConfigEntry( domain=DOMAIN, data={ @@ -519,7 +519,7 @@ async def test_zero_conf_old_blank_entry_user_title( mock_setup_entry: AsyncMock, mock_envoy: AsyncMock, ) -> None: - """Test re-using old blank entry with user title.""" + """Test reusing old blank entry with user title.""" entry = MockConfigEntry( domain=DOMAIN, data={ diff --git a/tests/components/esphome/test_bluetooth.py b/tests/components/esphome/test_bluetooth.py index 31d9fcd34f9..19bc5a2e7c7 100644 --- a/tests/components/esphome/test_bluetooth.py +++ b/tests/components/esphome/test_bluetooth.py @@ -4,6 +4,7 @@ from unittest.mock import patch from homeassistant.components import bluetooth from homeassistant.core import HomeAssistant +from homeassistant.helpers import device_registry as dr from .conftest import MockESPHomeDevice @@ -48,6 +49,34 @@ async def test_bluetooth_connect_with_legacy_adv( assert scanner.scanning is True +async def test_bluetooth_device_linked_via_device( + hass: HomeAssistant, + mock_bluetooth_entry_with_raw_adv: MockESPHomeDevice, + device_registry: dr.DeviceRegistry, +) -> None: + """Test the Bluetooth device is linked to the ESPHome device.""" + scanner = bluetooth.async_scanner_by_source(hass, "11:22:33:44:55:AA") + assert scanner.connectable is True + entry = hass.config_entries.async_entry_for_domain_unique_id( + "bluetooth", "11:22:33:44:55:AA" + ) + assert entry is not None + esp_device = device_registry.async_get_device( + connections={ + ( + dr.CONNECTION_NETWORK_MAC, + mock_bluetooth_entry_with_raw_adv.device_info.mac_address, + ) + } + ) + assert esp_device is not None + device = device_registry.async_get_device( + connections={(dr.CONNECTION_BLUETOOTH, "11:22:33:44:55:AA")} + ) + assert device is not None + assert device.via_device_id == esp_device.id + + async def test_bluetooth_cleanup_on_remove_entry( hass: HomeAssistant, mock_bluetooth_entry_with_raw_adv: MockESPHomeDevice ) -> None: diff --git a/tests/components/esphome/test_manager.py b/tests/components/esphome/test_manager.py index 6fbd3726f64..7db1427d975 100644 --- a/tests/components/esphome/test_manager.py +++ b/tests/components/esphome/test_manager.py @@ -1100,6 +1100,42 @@ async def test_esphome_device_with_web_server( assert dev.configuration_url == "http://test.local:80" +async def test_esphome_device_with_ipv6_web_server( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + mock_client: APIClient, + mock_esphome_device: Callable[ + [APIClient, list[EntityInfo], list[UserService], list[EntityState]], + Awaitable[MockESPHomeDevice], + ], +) -> None: + """Test a device with a web server.""" + entry = MockConfigEntry( + domain=DOMAIN, + data={ + CONF_HOST: "fe80::1", + CONF_PORT: 6053, + CONF_PASSWORD: "", + }, + options={}, + ) + entry.add_to_hass(hass) + device = await mock_esphome_device( + mock_client=mock_client, + entry=entry, + entity_info=[], + user_service=[], + device_info={"webserver_port": 80}, + states=[], + ) + await hass.async_block_till_done() + entry = device.entry + dev = device_registry.async_get_device( + connections={(dr.CONNECTION_NETWORK_MAC, entry.unique_id)} + ) + assert dev.configuration_url == "http://[fe80::1]:80" + + async def test_esphome_device_with_compilation_time( hass: HomeAssistant, device_registry: dr.DeviceRegistry, diff --git a/tests/components/google_drive/test_backup.py b/tests/components/google_drive/test_backup.py index 7e455ebb535..115a30a3eb6 100644 --- a/tests/components/google_drive/test_backup.py +++ b/tests/components/google_drive/test_backup.py @@ -47,6 +47,7 @@ TEST_AGENT_BACKUP_RESULT = { "backup_id": "test-backup", "database_included": True, "date": "2025-01-01T01:23:45.678Z", + "extra_metadata": {"with_automatic_settings": False}, "folders": [], "homeassistant_included": True, "homeassistant_version": "2024.12.0", diff --git a/tests/components/google_generative_ai_conversation/test_conversation.py b/tests/components/google_generative_ai_conversation/test_conversation.py index a87056275dc..72a5390f4b1 100644 --- a/tests/components/google_generative_ai_conversation/test_conversation.py +++ b/tests/components/google_generative_ai_conversation/test_conversation.py @@ -36,6 +36,13 @@ def freeze_the_time(): yield +@pytest.fixture(autouse=True) +def mock_ulid_tools(): + """Mock generated ULIDs for tool calls.""" + with patch("homeassistant.helpers.llm.ulid_now", return_value="mock-tool-call"): + yield + + @pytest.mark.parametrize( "agent_id", [None, "conversation.google_generative_ai_conversation"] ) @@ -177,6 +184,7 @@ async def test_chat_history( "homeassistant.components.google_generative_ai_conversation.conversation.llm.AssistAPI._async_get_tools" ) @pytest.mark.usefixtures("mock_init_component") +@pytest.mark.usefixtures("mock_ulid_tools") async def test_function_call( mock_get_tools, hass: HomeAssistant, @@ -256,6 +264,7 @@ async def test_function_call( mock_tool.async_call.assert_awaited_once_with( hass, llm.ToolInput( + id="mock-tool-call", tool_name="test_tool", tool_args={ "param1": ["test_value", "param1's value"], @@ -287,9 +296,7 @@ async def test_function_call( detail_event = trace_events[1] assert "Answer in plain text" in detail_event["data"]["messages"][0]["content"] assert [ - p.function_response.name - for p in detail_event["data"]["messages"][2]["content"].parts - if p.function_response + p["tool_name"] for p in detail_event["data"]["messages"][2]["tool_calls"] ] == ["test_tool"] @@ -362,6 +369,7 @@ async def test_function_call_without_parameters( mock_tool.async_call.assert_awaited_once_with( hass, llm.ToolInput( + id="mock-tool-call", tool_name="test_tool", tool_args={}, ), @@ -451,6 +459,7 @@ async def test_function_exception( mock_tool.async_call.assert_awaited_once_with( hass, llm.ToolInput( + id="mock-tool-call", tool_name="test_tool", tool_args={"param1": 1}, ), @@ -605,6 +614,7 @@ async def test_template_variables( mock_chat.send_message_async.return_value = chat_response mock_part = MagicMock() mock_part.text = "Model response" + mock_part.function_call = None chat_response.parts = [mock_part] result = await conversation.async_converse( hass, "hello", None, context, agent_id=mock_config_entry.entry_id diff --git a/tests/components/habitica/conftest.py b/tests/components/habitica/conftest.py index daf1c669463..e04fc58ad15 100644 --- a/tests/components/habitica/conftest.py +++ b/tests/components/habitica/conftest.py @@ -17,7 +17,7 @@ from habiticalib import ( HabiticaTaskOrderResponse, HabiticaTaskResponse, HabiticaTasksResponse, - HabiticaUserAnonymizedrResponse, + HabiticaUserAnonymizedResponse, HabiticaUserResponse, NotAuthorizedError, NotFoundError, @@ -140,7 +140,7 @@ async def mock_habiticalib() -> Generator[AsyncMock]: {"data": [], "success": True} ) client.get_user_anonymized.return_value = ( - HabiticaUserAnonymizedrResponse.from_json( + HabiticaUserAnonymizedResponse.from_json( load_fixture("anonymized.json", DOMAIN) ) ) diff --git a/tests/components/habitica/snapshots/test_diagnostics.ambr b/tests/components/habitica/snapshots/test_diagnostics.ambr index b4304e33ec8..1f3a14fade1 100644 --- a/tests/components/habitica/snapshots/test_diagnostics.ambr +++ b/tests/components/habitica/snapshots/test_diagnostics.ambr @@ -119,7 +119,8 @@ 'managerNotes': None, 'taskId': None, }), - 'history': None, + 'history': list([ + ]), 'id': 'e6e06dc6-c887-4b86-b175-b99cc2e20fdf', 'isDue': None, 'nextDue': list([ @@ -190,7 +191,8 @@ 'managerNotes': None, 'taskId': None, }), - 'history': None, + 'history': list([ + ]), 'id': '2fbf11a5-ab1e-4fb7-97f0-dfb5c45c96a9', 'isDue': None, 'nextDue': list([ diff --git a/tests/components/habitica/snapshots/test_services.ambr b/tests/components/habitica/snapshots/test_services.ambr index d0062212775..f40d50ded98 100644 --- a/tests/components/habitica/snapshots/test_services.ambr +++ b/tests/components/habitica/snapshots/test_services.ambr @@ -764,7 +764,8 @@ 'managerNotes': None, 'taskId': None, }), - 'history': None, + 'history': list([ + ]), 'id': UUID('88de7cd9-af2b-49ce-9afd-bf941d87336b'), 'isDue': None, 'nextDue': list([ @@ -837,7 +838,8 @@ 'managerNotes': None, 'taskId': None, }), - 'history': None, + 'history': list([ + ]), 'id': UUID('2f6fcabc-f670-4ec3-ba65-817e8deea490'), 'isDue': None, 'nextDue': list([ @@ -913,7 +915,8 @@ 'managerNotes': None, 'taskId': None, }), - 'history': None, + 'history': list([ + ]), 'id': UUID('1aa3137e-ef72-4d1f-91ee-41933602f438'), 'isDue': None, 'nextDue': list([ @@ -984,7 +987,8 @@ 'managerNotes': None, 'taskId': None, }), - 'history': None, + 'history': list([ + ]), 'id': UUID('86ea2475-d1b5-4020-bdcc-c188c7996afa'), 'isDue': None, 'nextDue': list([ @@ -1056,7 +1060,8 @@ 'managerNotes': None, 'taskId': None, }), - 'history': None, + 'history': list([ + ]), 'id': UUID('5e2ea1df-f6e6-4ba3-bccb-97c5ec63e99b'), 'isDue': None, 'nextDue': list([ @@ -1284,7 +1289,8 @@ 'managerNotes': None, 'taskId': None, }), - 'history': None, + 'history': list([ + ]), 'id': UUID('162f0bbe-a097-4a06-b4f4-8fbeed85d2ba'), 'isDue': None, 'nextDue': list([ @@ -1356,7 +1362,8 @@ 'managerNotes': None, 'taskId': None, }), - 'history': None, + 'history': list([ + ]), 'id': UUID('3fa06743-aa0f-472b-af1a-f27c755e329c'), 'isDue': None, 'nextDue': list([ @@ -1853,7 +1860,8 @@ 'managerNotes': None, 'taskId': None, }), - 'history': None, + 'history': list([ + ]), 'id': UUID('88de7cd9-af2b-49ce-9afd-bf941d87336b'), 'isDue': None, 'nextDue': list([ @@ -2996,7 +3004,8 @@ 'managerNotes': None, 'taskId': None, }), - 'history': None, + 'history': list([ + ]), 'id': UUID('88de7cd9-af2b-49ce-9afd-bf941d87336b'), 'isDue': None, 'nextDue': list([ @@ -3069,7 +3078,8 @@ 'managerNotes': None, 'taskId': None, }), - 'history': None, + 'history': list([ + ]), 'id': UUID('2f6fcabc-f670-4ec3-ba65-817e8deea490'), 'isDue': None, 'nextDue': list([ @@ -3145,7 +3155,8 @@ 'managerNotes': None, 'taskId': None, }), - 'history': None, + 'history': list([ + ]), 'id': UUID('1aa3137e-ef72-4d1f-91ee-41933602f438'), 'isDue': None, 'nextDue': list([ @@ -3216,7 +3227,8 @@ 'managerNotes': None, 'taskId': None, }), - 'history': None, + 'history': list([ + ]), 'id': UUID('86ea2475-d1b5-4020-bdcc-c188c7996afa'), 'isDue': None, 'nextDue': list([ @@ -3288,7 +3300,8 @@ 'managerNotes': None, 'taskId': None, }), - 'history': None, + 'history': list([ + ]), 'id': UUID('5e2ea1df-f6e6-4ba3-bccb-97c5ec63e99b'), 'isDue': None, 'nextDue': list([ @@ -3612,7 +3625,8 @@ 'managerNotes': None, 'taskId': None, }), - 'history': None, + 'history': list([ + ]), 'id': UUID('3fa06743-aa0f-472b-af1a-f27c755e329c'), 'isDue': None, 'nextDue': list([ @@ -3690,7 +3704,8 @@ 'managerNotes': None, 'taskId': None, }), - 'history': None, + 'history': list([ + ]), 'id': UUID('162f0bbe-a097-4a06-b4f4-8fbeed85d2ba'), 'isDue': None, 'nextDue': list([ @@ -5544,7 +5559,8 @@ 'managerNotes': None, 'taskId': None, }), - 'history': None, + 'history': list([ + ]), 'id': UUID('5e2ea1df-f6e6-4ba3-bccb-97c5ec63e99b'), 'isDue': None, 'nextDue': list([ @@ -5621,7 +5637,8 @@ 'managerNotes': None, 'taskId': None, }), - 'history': None, + 'history': list([ + ]), 'id': UUID('88de7cd9-af2b-49ce-9afd-bf941d87336b'), 'isDue': None, 'nextDue': list([ @@ -5694,7 +5711,8 @@ 'managerNotes': None, 'taskId': None, }), - 'history': None, + 'history': list([ + ]), 'id': UUID('2f6fcabc-f670-4ec3-ba65-817e8deea490'), 'isDue': None, 'nextDue': list([ @@ -5770,7 +5788,8 @@ 'managerNotes': None, 'taskId': None, }), - 'history': None, + 'history': list([ + ]), 'id': UUID('1aa3137e-ef72-4d1f-91ee-41933602f438'), 'isDue': None, 'nextDue': list([ @@ -5841,7 +5860,8 @@ 'managerNotes': None, 'taskId': None, }), - 'history': None, + 'history': list([ + ]), 'id': UUID('86ea2475-d1b5-4020-bdcc-c188c7996afa'), 'isDue': None, 'nextDue': list([ @@ -5913,7 +5933,8 @@ 'managerNotes': None, 'taskId': None, }), - 'history': None, + 'history': list([ + ]), 'id': UUID('5e2ea1df-f6e6-4ba3-bccb-97c5ec63e99b'), 'isDue': None, 'nextDue': list([ @@ -5984,7 +6005,8 @@ 'managerNotes': None, 'taskId': None, }), - 'history': None, + 'history': list([ + ]), 'id': UUID('162f0bbe-a097-4a06-b4f4-8fbeed85d2ba'), 'isDue': None, 'nextDue': list([ @@ -6056,7 +6078,8 @@ 'managerNotes': None, 'taskId': None, }), - 'history': None, + 'history': list([ + ]), 'id': UUID('3fa06743-aa0f-472b-af1a-f27c755e329c'), 'isDue': None, 'nextDue': list([ @@ -6134,7 +6157,8 @@ 'managerNotes': None, 'taskId': None, }), - 'history': None, + 'history': list([ + ]), 'id': UUID('88de7cd9-af2b-49ce-9afd-bf941d87336b'), 'isDue': None, 'nextDue': list([ @@ -6207,7 +6231,8 @@ 'managerNotes': None, 'taskId': None, }), - 'history': None, + 'history': list([ + ]), 'id': UUID('2f6fcabc-f670-4ec3-ba65-817e8deea490'), 'isDue': None, 'nextDue': list([ @@ -6283,7 +6308,8 @@ 'managerNotes': None, 'taskId': None, }), - 'history': None, + 'history': list([ + ]), 'id': UUID('1aa3137e-ef72-4d1f-91ee-41933602f438'), 'isDue': None, 'nextDue': list([ @@ -6354,7 +6380,8 @@ 'managerNotes': None, 'taskId': None, }), - 'history': None, + 'history': list([ + ]), 'id': UUID('86ea2475-d1b5-4020-bdcc-c188c7996afa'), 'isDue': None, 'nextDue': list([ @@ -6426,7 +6453,8 @@ 'managerNotes': None, 'taskId': None, }), - 'history': None, + 'history': list([ + ]), 'id': UUID('162f0bbe-a097-4a06-b4f4-8fbeed85d2ba'), 'isDue': None, 'nextDue': list([ @@ -6498,7 +6526,8 @@ 'managerNotes': None, 'taskId': None, }), - 'history': None, + 'history': list([ + ]), 'id': UUID('3fa06743-aa0f-472b-af1a-f27c755e329c'), 'isDue': None, 'nextDue': list([ diff --git a/tests/components/hassio/test_backup.py b/tests/components/hassio/test_backup.py index d001a358640..496dc93df32 100644 --- a/tests/components/hassio/test_backup.py +++ b/tests/components/hassio/test_backup.py @@ -106,7 +106,7 @@ TEST_BACKUP_2 = supervisor_backups.Backup( compressed=False, content=supervisor_backups.BackupContent( addons=["ssl"], - folders=["share"], + folders=[supervisor_backups.Folder.SHARE], homeassistant=False, ), date=datetime.fromisoformat("1970-01-01T00:00:00Z"), @@ -136,7 +136,7 @@ TEST_BACKUP_DETAILS_2 = supervisor_backups.BackupComplete( compressed=TEST_BACKUP_2.compressed, date=TEST_BACKUP_2.date, extra=None, - folders=["share"], + folders=[supervisor_backups.Folder.SHARE], homeassistant_exclude_database=False, homeassistant=None, location=TEST_BACKUP_2.location, @@ -156,7 +156,7 @@ TEST_BACKUP_3 = supervisor_backups.Backup( compressed=False, content=supervisor_backups.BackupContent( addons=["ssl"], - folders=["share"], + folders=[supervisor_backups.Folder.SHARE], homeassistant=True, ), date=datetime.fromisoformat("1970-01-01T00:00:00Z"), @@ -186,7 +186,7 @@ TEST_BACKUP_DETAILS_3 = supervisor_backups.BackupComplete( compressed=TEST_BACKUP_3.compressed, date=TEST_BACKUP_3.date, extra=None, - folders=["share"], + folders=[supervisor_backups.Folder.SHARE], homeassistant_exclude_database=False, homeassistant=None, location=TEST_BACKUP_3.location, @@ -207,7 +207,7 @@ TEST_BACKUP_4 = supervisor_backups.Backup( compressed=False, content=supervisor_backups.BackupContent( addons=["ssl"], - folders=["share"], + folders=[supervisor_backups.Folder.SHARE], homeassistant=True, ), date=datetime.fromisoformat("1970-01-01T00:00:00Z"), @@ -234,23 +234,23 @@ TEST_BACKUP_DETAILS_4 = supervisor_backups.BackupComplete( version="9.14.0", ) ], - compressed=TEST_BACKUP.compressed, - date=TEST_BACKUP.date, + compressed=TEST_BACKUP_4.compressed, + date=TEST_BACKUP_4.date, extra=None, - folders=["share"], + folders=[supervisor_backups.Folder.SHARE], homeassistant_exclude_database=True, homeassistant="2024.12.0", - location=TEST_BACKUP.location, - location_attributes=TEST_BACKUP.location_attributes, - locations=TEST_BACKUP.locations, - name=TEST_BACKUP.name, - protected=TEST_BACKUP.protected, + location=TEST_BACKUP_4.location, + location_attributes=TEST_BACKUP_4.location_attributes, + locations=TEST_BACKUP_4.locations, + name=TEST_BACKUP_4.name, + protected=TEST_BACKUP_4.protected, repositories=[], - size=TEST_BACKUP.size, - size_bytes=TEST_BACKUP.size_bytes, - slug=TEST_BACKUP.slug, + size=TEST_BACKUP_4.size, + size_bytes=TEST_BACKUP_4.size_bytes, + slug=TEST_BACKUP_4.slug, supervisor_version="2024.11.2", - type=TEST_BACKUP.type, + type=TEST_BACKUP_4.type, ) TEST_BACKUP_5 = supervisor_backups.Backup( @@ -324,6 +324,24 @@ TEST_JOB_DONE = supervisor_jobs.Job( errors=[], child_jobs=[], ) +TEST_RESTORE_JOB_DONE_WITH_ERROR = supervisor_jobs.Job( + name="backup_manager_partial_restore", + reference="1ef41507", + uuid=UUID(TEST_JOB_ID), + progress=0.0, + stage="copy_additional_locations", + done=True, + errors=[ + supervisor_jobs.JobError( + type="BackupInvalidError", + message=( + "Backup was made on supervisor version 2025.02.2.dev3105, " + "can't restore on 2025.01.2.dev3105" + ), + ) + ], + child_jobs=[], +) @pytest.fixture(autouse=True) @@ -346,7 +364,7 @@ async def hassio_enabled( @pytest.fixture -async def setup_integration( +async def setup_backup_integration( hass: HomeAssistant, hassio_enabled: None, supervisor_client: AsyncMock ) -> None: """Set up Backup integration.""" @@ -476,7 +494,7 @@ async def test_agent_info( } -@pytest.mark.usefixtures("hassio_client", "setup_integration") +@pytest.mark.usefixtures("hassio_client", "setup_backup_integration") @pytest.mark.parametrize( ("backup", "backup_details", "expected_response"), [ @@ -491,6 +509,7 @@ async def test_agent_info( "backup_id": "abc123", "database_included": True, "date": "1970-01-01T00:00:00+00:00", + "extra_metadata": {}, "failed_agent_ids": [], "folders": ["share"], "homeassistant_included": True, @@ -510,6 +529,7 @@ async def test_agent_info( "backup_id": "abc123", "database_included": False, "date": "1970-01-01T00:00:00+00:00", + "extra_metadata": {}, "failed_agent_ids": [], "folders": ["share"], "homeassistant_included": False, @@ -540,7 +560,7 @@ async def test_agent_list_backups( assert response["result"]["backups"] == [expected_response] -@pytest.mark.usefixtures("hassio_client", "setup_integration") +@pytest.mark.usefixtures("hassio_client", "setup_backup_integration") async def test_agent_download( hass: HomeAssistant, hass_client: ClientSessionGenerator, @@ -564,26 +584,33 @@ async def test_agent_download( ) -@pytest.mark.usefixtures("hassio_client", "setup_integration") +@pytest.mark.parametrize( + ("backup_info", "backup_id", "agent_id"), + [ + (TEST_BACKUP_DETAILS_3, "unknown", "hassio.local"), + (TEST_BACKUP_DETAILS_3, TEST_BACKUP_DETAILS_3.slug, "hassio.local"), + (TEST_BACKUP_DETAILS, TEST_BACKUP_DETAILS_3.slug, "hassio.local"), + ], +) +@pytest.mark.usefixtures("hassio_client", "setup_backup_integration") async def test_agent_download_unavailable_backup( hass: HomeAssistant, hass_client: ClientSessionGenerator, supervisor_client: AsyncMock, + agent_id: str, + backup_id: str, + backup_info: supervisor_backups.BackupComplete, ) -> None: """Test agent download backup which does not exist.""" client = await hass_client() - backup_id = "abc123" - supervisor_client.backups.list.return_value = [TEST_BACKUP_3] - supervisor_client.backups.backup_info.return_value = TEST_BACKUP_DETAILS_3 - supervisor_client.backups.download_backup.return_value.__aiter__.return_value = ( - iter((b"backup data",)) - ) + supervisor_client.backups.backup_info.return_value = backup_info + supervisor_client.backups.download_backup.side_effect = SupervisorNotFoundError - resp = await client.get(f"/api/backup/download/{backup_id}?agent_id=hassio.local") + resp = await client.get(f"/api/backup/download/{backup_id}?agent_id={agent_id}") assert resp.status == 404 -@pytest.mark.usefixtures("hassio_client", "setup_integration") +@pytest.mark.usefixtures("hassio_client", "setup_backup_integration") async def test_agent_upload( hass: HomeAssistant, hass_client: ClientSessionGenerator, @@ -632,7 +659,7 @@ async def test_agent_upload( supervisor_client.backups.remove_backup.assert_not_called() -@pytest.mark.usefixtures("hassio_client", "setup_integration") +@pytest.mark.usefixtures("hassio_client", "setup_backup_integration") async def test_agent_get_backup( hass: HomeAssistant, hass_ws_client: WebSocketGenerator, @@ -662,6 +689,7 @@ async def test_agent_get_backup( "backup_id": "abc123", "database_included": True, "date": "1970-01-01T00:00:00+00:00", + "extra_metadata": {}, "failed_agent_ids": [], "folders": ["share"], "homeassistant_included": True, @@ -673,7 +701,7 @@ async def test_agent_get_backup( supervisor_client.backups.backup_info.assert_called_once_with(backup_id) -@pytest.mark.usefixtures("hassio_client", "setup_integration") +@pytest.mark.usefixtures("hassio_client", "setup_backup_integration") @pytest.mark.parametrize( ("backup_info_side_effect", "expected_response"), [ @@ -717,7 +745,7 @@ async def test_agent_get_backup_with_error( supervisor_client.backups.backup_info.assert_called_once_with(backup_id) -@pytest.mark.usefixtures("hassio_client", "setup_integration") +@pytest.mark.usefixtures("hassio_client", "setup_backup_integration") async def test_agent_delete_backup( hass: HomeAssistant, hass_ws_client: WebSocketGenerator, @@ -742,7 +770,7 @@ async def test_agent_delete_backup( ) -@pytest.mark.usefixtures("hassio_client", "setup_integration") +@pytest.mark.usefixtures("hassio_client", "setup_backup_integration") @pytest.mark.parametrize( ("remove_side_effect", "expected_response"), [ @@ -788,7 +816,7 @@ async def test_agent_delete_with_error( ) -@pytest.mark.usefixtures("hassio_client", "setup_integration") +@pytest.mark.usefixtures("hassio_client", "setup_backup_integration") @pytest.mark.parametrize( ("event_data", "mount_info_calls"), [ @@ -869,7 +897,7 @@ DEFAULT_BACKUP_OPTIONS = supervisor_backups.PartialBackupOptions( ) -@pytest.mark.usefixtures("hassio_client", "setup_integration") +@pytest.mark.usefixtures("hassio_client", "setup_backup_integration") @pytest.mark.parametrize( ("extra_generate_options", "expected_supervisor_options"), [ @@ -984,7 +1012,114 @@ async def test_reader_writer_create( assert response["event"] == {"manager_state": "idle"} -@pytest.mark.usefixtures("hassio_client", "setup_integration") +@pytest.mark.usefixtures("hassio_client", "setup_backup_integration") +async def test_reader_writer_create_report_progress( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + freezer: FrozenDateTimeFactory, + supervisor_client: AsyncMock, +) -> None: + """Test generating a backup.""" + client = await hass_ws_client(hass) + freezer.move_to("2025-01-30 13:42:12.345678") + supervisor_client.backups.partial_backup.return_value.job_id = TEST_JOB_ID + supervisor_client.backups.backup_info.return_value = TEST_BACKUP_DETAILS + supervisor_client.jobs.get_job.return_value = TEST_JOB_NOT_DONE + + await client.send_json_auto_id({"type": "backup/subscribe_events"}) + response = await client.receive_json() + assert response["event"] == {"manager_state": "idle"} + response = await client.receive_json() + assert response["success"] + + await client.send_json_auto_id( + {"type": "backup/generate", "agent_ids": ["hassio.local"], "name": "Test"} + ) + response = await client.receive_json() + assert response["event"] == { + "manager_state": "create_backup", + "reason": None, + "stage": None, + "state": "in_progress", + } + + response = await client.receive_json() + assert response["success"] + assert response["result"] == {"backup_job_id": TEST_JOB_ID} + + supervisor_client.backups.partial_backup.assert_called_once_with( + DEFAULT_BACKUP_OPTIONS + ) + + supervisor_event_base = {"uuid": TEST_JOB_ID, "reference": "test_slug"} + supervisor_events = [ + supervisor_event_base | {"done": False, "stage": "addon_repositories"}, + supervisor_event_base | {"done": False, "stage": None}, # Will be skipped + supervisor_event_base | {"done": False, "stage": "unknown"}, # Will be skipped + supervisor_event_base | {"done": False, "stage": "home_assistant"}, + supervisor_event_base | {"done": False, "stage": "addons"}, + supervisor_event_base | {"done": True, "stage": "finishing_file"}, + ] + expected_manager_events = [ + "addon_repositories", + "home_assistant", + "addons", + "finishing_file", + ] + + for supervisor_event in supervisor_events: + await client.send_json_auto_id( + { + "type": "supervisor/event", + "data": {"event": "job", "data": supervisor_event}, + } + ) + + acks = 0 + events = [] + for _ in range(len(supervisor_events) + len(expected_manager_events)): + response = await client.receive_json() + if "event" in response: + events.append(response) + continue + assert response["success"] + acks += 1 + + assert acks == len(supervisor_events) + assert len(events) == len(expected_manager_events) + + for i, event in enumerate(events): + assert event["event"] == { + "manager_state": "create_backup", + "reason": None, + "stage": expected_manager_events[i], + "state": "in_progress", + } + + response = await client.receive_json() + assert response["event"] == { + "manager_state": "create_backup", + "reason": None, + "stage": "upload_to_agents", + "state": "in_progress", + } + + response = await client.receive_json() + assert response["event"] == { + "manager_state": "create_backup", + "reason": None, + "stage": None, + "state": "completed", + } + + supervisor_client.backups.download_backup.assert_not_called() + supervisor_client.backups.remove_backup.assert_not_called() + + response = await client.receive_json() + assert response["event"] == {"manager_state": "idle"} + + +@pytest.mark.usefixtures("hassio_client", "setup_backup_integration") async def test_reader_writer_create_job_done( hass: HomeAssistant, hass_ws_client: WebSocketGenerator, @@ -1275,7 +1410,7 @@ async def test_reader_writer_create_per_agent_encryption( assert response["event"] == {"manager_state": "idle"} -@pytest.mark.usefixtures("hassio_client", "setup_integration") +@pytest.mark.usefixtures("hassio_client", "setup_backup_integration") @pytest.mark.parametrize( ("side_effect", "error_code", "error_message", "expected_reason"), [ @@ -1342,11 +1477,40 @@ async def test_reader_writer_create_partial_backup_error( assert supervisor_client.backups.partial_backup.call_count == 1 -@pytest.mark.usefixtures("hassio_client", "setup_integration") +@pytest.mark.parametrize( + "supervisor_event", + [ + # Missing backup reference + { + "event": "job", + "data": { + "done": True, + "uuid": TEST_JOB_ID, + }, + }, + # Errors + { + "event": "job", + "data": { + "done": True, + "errors": [ + { + "type": "BackupMountDownError", + "message": "test_mount is down, cannot back-up to it", + } + ], + "uuid": TEST_JOB_ID, + "reference": "test_slug", + }, + }, + ], +) +@pytest.mark.usefixtures("hassio_client", "setup_backup_integration") async def test_reader_writer_create_missing_reference_error( hass: HomeAssistant, hass_ws_client: WebSocketGenerator, supervisor_client: AsyncMock, + supervisor_event: dict[str, Any], ) -> None: """Test missing reference error when generating a backup.""" client = await hass_ws_client(hass) @@ -1377,13 +1541,7 @@ async def test_reader_writer_create_missing_reference_error( assert supervisor_client.backups.partial_backup.call_count == 1 await client.send_json_auto_id( - { - "type": "supervisor/event", - "data": { - "event": "job", - "data": {"done": True, "uuid": TEST_JOB_ID}, - }, - } + {"type": "supervisor/event", "data": supervisor_event} ) response = await client.receive_json() assert response["success"] @@ -1406,7 +1564,7 @@ async def test_reader_writer_create_missing_reference_error( assert response["event"] == {"manager_state": "idle"} -@pytest.mark.usefixtures("hassio_client", "setup_integration") +@pytest.mark.usefixtures("hassio_client", "setup_backup_integration") @pytest.mark.parametrize("exception", [SupervisorError("Boom!"), Exception("Boom!")]) @pytest.mark.parametrize( ("method", "download_call_count", "remove_call_count"), @@ -1500,7 +1658,7 @@ async def test_reader_writer_create_download_remove_error( assert response["event"] == {"manager_state": "idle"} -@pytest.mark.usefixtures("hassio_client", "setup_integration") +@pytest.mark.usefixtures("hassio_client", "setup_backup_integration") @pytest.mark.parametrize("exception", [SupervisorError("Boom!"), Exception("Boom!")]) async def test_reader_writer_create_info_error( hass: HomeAssistant, @@ -1577,7 +1735,7 @@ async def test_reader_writer_create_info_error( assert response["event"] == {"manager_state": "idle"} -@pytest.mark.usefixtures("hassio_client", "setup_integration") +@pytest.mark.usefixtures("hassio_client", "setup_backup_integration") async def test_reader_writer_create_remote_backup( hass: HomeAssistant, hass_ws_client: WebSocketGenerator, @@ -1661,7 +1819,7 @@ async def test_reader_writer_create_remote_backup( ) -@pytest.mark.usefixtures("hassio_client", "setup_integration") +@pytest.mark.usefixtures("hassio_client", "setup_backup_integration") @pytest.mark.parametrize( ("extra_generate_options", "expected_error"), [ @@ -1731,7 +1889,7 @@ async def test_reader_writer_create_wrong_parameters( supervisor_client.backups.partial_backup.assert_not_called() -@pytest.mark.usefixtures("hassio_client", "setup_integration") +@pytest.mark.usefixtures("hassio_client", "setup_backup_integration") async def test_agent_receive_remote_backup( hass: HomeAssistant, hass_client: ClientSessionGenerator, @@ -1807,7 +1965,7 @@ async def test_agent_receive_remote_backup( ), ], ) -@pytest.mark.usefixtures("hassio_client", "setup_integration") +@pytest.mark.usefixtures("hassio_client", "setup_backup_integration") async def test_reader_writer_restore( hass: HomeAssistant, hass_ws_client: WebSocketGenerator, @@ -1874,31 +2032,140 @@ async def test_reader_writer_restore( assert response["result"] is None +@pytest.mark.usefixtures("hassio_client", "setup_backup_integration") +async def test_reader_writer_restore_report_progress( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + supervisor_client: AsyncMock, +) -> None: + """Test restoring a backup.""" + client = await hass_ws_client(hass) + supervisor_client.backups.partial_restore.return_value.job_id = TEST_JOB_ID + supervisor_client.backups.list.return_value = [TEST_BACKUP] + supervisor_client.backups.backup_info.return_value = TEST_BACKUP_DETAILS + supervisor_client.jobs.get_job.return_value = TEST_JOB_NOT_DONE + + await client.send_json_auto_id({"type": "backup/subscribe_events"}) + response = await client.receive_json() + assert response["event"] == { + "manager_state": "idle", + } + response = await client.receive_json() + assert response["success"] + + await client.send_json_auto_id( + {"type": "backup/restore", "agent_id": "hassio.local", "backup_id": "abc123"} + ) + response = await client.receive_json() + assert response["event"] == { + "manager_state": "restore_backup", + "reason": None, + "stage": None, + "state": "in_progress", + } + + supervisor_client.backups.partial_restore.assert_called_once_with( + "abc123", + supervisor_backups.PartialRestoreOptions( + addons=None, + background=True, + folders=None, + homeassistant=True, + location=None, + password=None, + ), + ) + + supervisor_event_base = {"uuid": TEST_JOB_ID, "reference": "test_slug"} + supervisor_events = [ + supervisor_event_base | {"done": False, "stage": "addon_repositories"}, + supervisor_event_base | {"done": False, "stage": None}, # Will be skipped + supervisor_event_base | {"done": False, "stage": "unknown"}, # Will be skipped + supervisor_event_base | {"done": False, "stage": "home_assistant"}, + supervisor_event_base | {"done": True, "stage": "addons"}, + ] + expected_manager_events = [ + "addon_repositories", + "home_assistant", + "addons", + ] + + for supervisor_event in supervisor_events: + await client.send_json_auto_id( + { + "type": "supervisor/event", + "data": {"event": "job", "data": supervisor_event}, + } + ) + + acks = 0 + events = [] + for _ in range(len(supervisor_events) + len(expected_manager_events)): + response = await client.receive_json() + if "event" in response: + events.append(response) + continue + assert response["success"] + acks += 1 + + assert acks == len(supervisor_events) + assert len(events) == len(expected_manager_events) + + for i, event in enumerate(events): + assert event["event"] == { + "manager_state": "restore_backup", + "reason": None, + "stage": expected_manager_events[i], + "state": "in_progress", + } + + response = await client.receive_json() + assert response["event"] == { + "manager_state": "restore_backup", + "reason": None, + "stage": None, + "state": "completed", + } + + response = await client.receive_json() + assert response["event"] == {"manager_state": "idle"} + + response = await client.receive_json() + assert response["success"] + assert response["result"] is None + + @pytest.mark.parametrize( - ("supervisor_error_string", "expected_error_code", "expected_reason"), + ("supervisor_error", "expected_error_code", "expected_reason"), [ - ("Invalid password for backup", "password_incorrect", "password_incorrect"), ( - "Backup was made on supervisor version 2025.12.0, can't restore on 2024.12.0. Must update supervisor first.", + SupervisorBadRequestError("Invalid password for backup"), + "password_incorrect", + "password_incorrect", + ), + ( + SupervisorBadRequestError( + "Backup was made on supervisor version 2025.12.0, can't " + "restore on 2024.12.0. Must update supervisor first." + ), "home_assistant_error", "unknown_error", ), + (SupervisorNotFoundError(), "backup_not_found", "backup_not_found"), ], ) -@pytest.mark.usefixtures("hassio_client", "setup_integration") +@pytest.mark.usefixtures("hassio_client", "setup_backup_integration") async def test_reader_writer_restore_error( hass: HomeAssistant, hass_ws_client: WebSocketGenerator, supervisor_client: AsyncMock, - supervisor_error_string: str, + supervisor_error: Exception, expected_error_code: str, expected_reason: str, ) -> None: """Test restoring a backup.""" client = await hass_ws_client(hass) - supervisor_client.backups.partial_restore.side_effect = SupervisorBadRequestError( - supervisor_error_string - ) + supervisor_client.backups.partial_restore.side_effect = supervisor_error supervisor_client.backups.list.return_value = [TEST_BACKUP] supervisor_client.backups.backup_info.return_value = TEST_BACKUP_DETAILS @@ -1946,6 +2213,97 @@ async def test_reader_writer_restore_error( assert response["error"]["code"] == expected_error_code +@pytest.mark.usefixtures("hassio_client", "setup_backup_integration") +async def test_reader_writer_restore_late_error( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + supervisor_client: AsyncMock, +) -> None: + """Test restoring a backup with error.""" + client = await hass_ws_client(hass) + supervisor_client.backups.partial_restore.return_value.job_id = TEST_JOB_ID + supervisor_client.backups.list.return_value = [TEST_BACKUP] + supervisor_client.backups.backup_info.return_value = TEST_BACKUP_DETAILS + supervisor_client.jobs.get_job.return_value = TEST_JOB_NOT_DONE + + await client.send_json_auto_id({"type": "backup/subscribe_events"}) + response = await client.receive_json() + assert response["event"] == {"manager_state": "idle"} + response = await client.receive_json() + assert response["success"] + + await client.send_json_auto_id( + {"type": "backup/restore", "agent_id": "hassio.local", "backup_id": "abc123"} + ) + response = await client.receive_json() + assert response["event"] == { + "manager_state": "restore_backup", + "reason": None, + "stage": None, + "state": "in_progress", + } + + supervisor_client.backups.partial_restore.assert_called_once_with( + "abc123", + supervisor_backups.PartialRestoreOptions( + addons=None, + background=True, + folders=None, + homeassistant=True, + location=None, + password=None, + ), + ) + + event = { + "event": "job", + "data": { + "name": "backup_manager_partial_restore", + "reference": "7c54aeed", + "uuid": TEST_JOB_ID, + "progress": 0, + "stage": None, + "done": True, + "parent_id": None, + "errors": [ + { + "type": "BackupInvalidError", + "message": ( + "Backup was made on supervisor version 2025.02.2.dev3105, can't" + " restore on 2025.01.2.dev3105. Must update supervisor first." + ), + } + ], + "created": "2025-02-03T08:27:49.297997+00:00", + }, + } + await client.send_json_auto_id({"type": "supervisor/event", "data": event}) + response = await client.receive_json() + assert response["success"] + + response = await client.receive_json() + assert response["event"] == { + "manager_state": "restore_backup", + "reason": "backup_reader_writer_error", + "stage": None, + "state": "failed", + } + + response = await client.receive_json() + assert response["event"] == {"manager_state": "idle"} + + response = await client.receive_json() + assert not response["success"] + assert response["error"] == { + "code": "home_assistant_error", + "message": ( + "Restore failed: [{'type': 'BackupInvalidError', 'message': \"Backup " + "was made on supervisor version 2025.02.2.dev3105, can't restore on " + '2025.01.2.dev3105. Must update supervisor first."}]' + ), + } + + @pytest.mark.parametrize( ("backup", "backup_details", "parameters", "expected_error"), [ @@ -1969,7 +2327,7 @@ async def test_reader_writer_restore_error( ), ], ) -@pytest.mark.usefixtures("hassio_client", "setup_integration") +@pytest.mark.usefixtures("hassio_client", "setup_backup_integration") async def test_reader_writer_restore_wrong_parameters( hass: HomeAssistant, hass_ws_client: WebSocketGenerator, @@ -1999,15 +2357,40 @@ async def test_reader_writer_restore_wrong_parameters( } +@pytest.mark.parametrize( + ("get_job_result", "last_non_idle_event"), + [ + ( + TEST_JOB_DONE, + { + "manager_state": "restore_backup", + "reason": None, + "stage": None, + "state": "completed", + }, + ), + ( + TEST_RESTORE_JOB_DONE_WITH_ERROR, + { + "manager_state": "restore_backup", + "reason": "unknown_error", + "stage": None, + "state": "failed", + }, + ), + ], +) @pytest.mark.usefixtures("hassio_client") async def test_restore_progress_after_restart( hass: HomeAssistant, hass_ws_client: WebSocketGenerator, supervisor_client: AsyncMock, + get_job_result: supervisor_jobs.Job, + last_non_idle_event: dict[str, Any], ) -> None: """Test restore backup progress after restart.""" - supervisor_client.jobs.get_job.return_value = TEST_JOB_DONE + supervisor_client.jobs.get_job.return_value = get_job_result with patch.dict(os.environ, MOCK_ENVIRON | {RESTORE_JOB_ID_ENV: TEST_JOB_ID}): assert await async_setup_component(hass, BACKUP_DOMAIN, {BACKUP_DOMAIN: {}}) @@ -2017,11 +2400,88 @@ async def test_restore_progress_after_restart( await client.send_json_auto_id({"type": "backup/info"}) response = await client.receive_json() + assert response["success"] + assert response["result"]["last_non_idle_event"] == last_non_idle_event + assert response["result"]["state"] == "idle" + + +@pytest.mark.usefixtures("hassio_client") +async def test_restore_progress_after_restart_report_progress( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + supervisor_client: AsyncMock, +) -> None: + """Test restore backup progress after restart.""" + + supervisor_client.jobs.get_job.return_value = TEST_JOB_NOT_DONE + + with patch.dict(os.environ, MOCK_ENVIRON | {RESTORE_JOB_ID_ENV: TEST_JOB_ID}): + assert await async_setup_component(hass, BACKUP_DOMAIN, {BACKUP_DOMAIN: {}}) + + client = await hass_ws_client(hass) + + await client.send_json_auto_id({"type": "backup/subscribe_events"}) + response = await client.receive_json() + assert response["event"] == { + "manager_state": "restore_backup", + "reason": None, + "stage": None, + "state": "in_progress", + } + response = await client.receive_json() + assert response["success"] + + supervisor_event_base = {"uuid": TEST_JOB_ID, "reference": "test_slug"} + supervisor_events = [ + supervisor_event_base | {"done": False, "stage": "addon_repositories"}, + supervisor_event_base | {"done": False, "stage": None}, # Will be skipped + supervisor_event_base | {"done": False, "stage": "unknown"}, # Will be skipped + supervisor_event_base | {"done": False, "stage": "home_assistant"}, + supervisor_event_base | {"done": True, "stage": "addons"}, + ] + expected_manager_events = ["addon_repositories", "home_assistant", "addons"] + expected_manager_states = ["in_progress", "in_progress", "completed"] + + for supervisor_event in supervisor_events: + await client.send_json_auto_id( + { + "type": "supervisor/event", + "data": {"event": "job", "data": supervisor_event}, + } + ) + + acks = 0 + events = [] + for _ in range(len(supervisor_events) + len(expected_manager_events)): + response = await client.receive_json() + if "event" in response: + events.append(response) + continue + assert response["success"] + acks += 1 + + assert acks == len(supervisor_events) + assert len(events) == len(expected_manager_events) + + for i, event in enumerate(events): + assert event["event"] == { + "manager_state": "restore_backup", + "reason": None, + "stage": expected_manager_events[i], + "state": expected_manager_states[i], + } + + response = await client.receive_json() + assert response["event"] == {"manager_state": "idle"} + + await client.send_json_auto_id({"type": "backup/info"}) + response = await client.receive_json() + assert response["success"] assert response["result"]["last_non_idle_event"] == { "manager_state": "restore_backup", - "reason": "", - "stage": None, + "reason": None, + "stage": "addons", "state": "completed", } assert response["result"]["state"] == "idle" diff --git a/tests/components/home_connect/conftest.py b/tests/components/home_connect/conftest.py index ae98c69d242..4061d5ed863 100644 --- a/tests/components/home_connect/conftest.py +++ b/tests/components/home_connect/conftest.py @@ -18,9 +18,14 @@ from aiohomeconnect.model import ( EventKey, EventMessage, EventType, + GetSetting, + HomeAppliance, Option, + Program, + SettingKey, ) from aiohomeconnect.model.error import HomeConnectApiError, HomeConnectError +from aiohomeconnect.model.program import EnumerateProgram import pytest from homeassistant.components.application_credentials import ( @@ -143,6 +148,14 @@ async def mock_integration_setup( return run +def _get_specific_appliance_side_effect(ha_id: str) -> HomeAppliance: + """Get specific appliance side effect.""" + for appliance in copy.deepcopy(MOCK_APPLIANCES).homeappliances: + if appliance.ha_id == ha_id: + return appliance + raise HomeConnectApiError("error.key", "error description") + + def _get_set_program_side_effect( event_queue: asyncio.Queue[list[EventMessage]], event_key: EventKey ): @@ -227,7 +240,14 @@ async def _get_all_programs_side_effect(ha_id: str) -> ArrayOfPrograms: if appliance_type not in MOCK_PROGRAMS: raise HomeConnectApiError("error.key", "error description") - return ArrayOfPrograms.from_dict(MOCK_PROGRAMS[appliance_type]["data"]) + return ArrayOfPrograms( + [ + EnumerateProgram.from_dict(program) + for program in MOCK_PROGRAMS[appliance_type]["data"]["programs"] + ], + Program.from_dict(MOCK_PROGRAMS[appliance_type]["data"]["programs"][0]), + Program.from_dict(MOCK_PROGRAMS[appliance_type]["data"]["programs"][0]), + ) async def _get_settings_side_effect(ha_id: str) -> ArrayOfSettings: @@ -244,6 +264,24 @@ async def _get_settings_side_effect(ha_id: str) -> ArrayOfSettings: ) +async def _get_setting_side_effect(ha_id: str, setting_key: SettingKey): + """Get setting.""" + for appliance in MOCK_APPLIANCES.homeappliances: + if appliance.ha_id == ha_id: + settings = MOCK_SETTINGS.get( + next( + appliance + for appliance in MOCK_APPLIANCES.homeappliances + if appliance.ha_id == ha_id + ).type, + {}, + ).get("data", {"settings": []}) + for setting_dict in cast(list[dict], settings["settings"]): + if setting_dict["key"] == setting_key: + return GetSetting.from_dict(setting_dict) + raise HomeConnectApiError("error.key", "error description") + + @pytest.fixture(name="client") def mock_client(request: pytest.FixtureRequest) -> MagicMock: """Fixture to mock Client from HomeConnect.""" @@ -265,7 +303,10 @@ def mock_client(request: pytest.FixtureRequest) -> MagicMock: for event in await event_queue.get(): yield event - mock.get_home_appliances = AsyncMock(return_value=MOCK_APPLIANCES) + mock.get_home_appliances = AsyncMock(return_value=copy.deepcopy(MOCK_APPLIANCES)) + mock.get_specific_appliance = AsyncMock( + side_effect=_get_specific_appliance_side_effect + ) mock.stream_all_events = stream_all_events mock.start_program = AsyncMock( side_effect=_get_set_program_side_effect( @@ -287,6 +328,7 @@ def mock_client(request: pytest.FixtureRequest) -> MagicMock: side_effect=_get_set_key_value_side_effect(event_queue, "setting_key"), ) mock.get_settings = AsyncMock(side_effect=_get_settings_side_effect) + mock.get_setting = AsyncMock(side_effect=_get_setting_side_effect) mock.get_status = AsyncMock(return_value=copy.deepcopy(MOCK_STATUS)) mock.get_all_programs = AsyncMock(side_effect=_get_all_programs_side_effect) mock.put_command = AsyncMock() @@ -314,7 +356,7 @@ def mock_client_with_exception(request: pytest.FixtureRequest) -> MagicMock: for event in await event_queue.get(): yield event - mock.get_home_appliances = AsyncMock(return_value=MOCK_APPLIANCES) + mock.get_home_appliances = AsyncMock(return_value=copy.deepcopy(MOCK_APPLIANCES)) mock.stream_all_events = stream_all_events mock.start_program = AsyncMock(side_effect=exception) diff --git a/tests/components/home_connect/test_binary_sensor.py b/tests/components/home_connect/test_binary_sensor.py index 25e0e8084e2..211192f592b 100644 --- a/tests/components/home_connect/test_binary_sensor.py +++ b/tests/components/home_connect/test_binary_sensor.py @@ -1,9 +1,10 @@ """Tests for home_connect binary_sensor entities.""" from collections.abc import Awaitable, Callable -from unittest.mock import MagicMock +from unittest.mock import AsyncMock, MagicMock from aiohomeconnect.model import ArrayOfEvents, Event, EventKey, EventMessage, EventType +from aiohomeconnect.model.error import HomeConnectApiError import pytest from homeassistant.components import automation, script @@ -26,6 +27,7 @@ from homeassistant.const import ( Platform, ) from homeassistant.core import HomeAssistant +from homeassistant.helpers import device_registry as dr, entity_registry as er import homeassistant.helpers.issue_registry as ir from homeassistant.setup import async_setup_component @@ -50,6 +52,110 @@ async def test_binary_sensors( assert config_entry.state == ConfigEntryState.LOADED +async def test_paired_depaired_devices_flow( + appliance_ha_id: str, + hass: HomeAssistant, + config_entry: MockConfigEntry, + integration_setup: Callable[[MagicMock], Awaitable[bool]], + setup_credentials: None, + client: MagicMock, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, +) -> None: + """Test that removed devices are correctly removed from and added to hass on API events.""" + assert config_entry.state == ConfigEntryState.NOT_LOADED + assert await integration_setup(client) + assert config_entry.state == ConfigEntryState.LOADED + + device = device_registry.async_get_device(identifiers={(DOMAIN, appliance_ha_id)}) + assert device + entity_entries = entity_registry.entities.get_entries_for_device_id(device.id) + assert entity_entries + + await client.add_events( + [ + EventMessage( + appliance_ha_id, + EventType.DEPAIRED, + data=ArrayOfEvents([]), + ) + ] + ) + await hass.async_block_till_done() + + device = device_registry.async_get_device(identifiers={(DOMAIN, appliance_ha_id)}) + assert not device + for entity_entry in entity_entries: + assert not entity_registry.async_get(entity_entry.entity_id) + + # Now that all everything related to the device is removed, pair it again + await client.add_events( + [ + EventMessage( + appliance_ha_id, + EventType.PAIRED, + data=ArrayOfEvents([]), + ) + ] + ) + await hass.async_block_till_done() + + assert device_registry.async_get_device(identifiers={(DOMAIN, appliance_ha_id)}) + for entity_entry in entity_entries: + assert entity_registry.async_get(entity_entry.entity_id) + + +async def test_connected_devices( + appliance_ha_id: str, + hass: HomeAssistant, + config_entry: MockConfigEntry, + integration_setup: Callable[[MagicMock], Awaitable[bool]], + setup_credentials: None, + client: MagicMock, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, +) -> None: + """Test that devices reconnected. + + Specifically those devices whose settings, status, etc. could + not be obtained while disconnected and once connected, the entities are added. + """ + get_status_original_mock = client.get_status + + def get_status_side_effect(ha_id: str): + if ha_id == appliance_ha_id: + raise HomeConnectApiError( + "SDK.Error.HomeAppliance.Connection.Initialization.Failed" + ) + return get_status_original_mock.return_value + + client.get_status = AsyncMock(side_effect=get_status_side_effect) + assert config_entry.state == ConfigEntryState.NOT_LOADED + assert await integration_setup(client) + assert config_entry.state == ConfigEntryState.LOADED + client.get_status = get_status_original_mock + + device = device_registry.async_get_device(identifiers={(DOMAIN, appliance_ha_id)}) + assert device + entity_entries = entity_registry.entities.get_entries_for_device_id(device.id) + + await client.add_events( + [ + EventMessage( + appliance_ha_id, + EventType.CONNECTED, + data=ArrayOfEvents([]), + ) + ] + ) + await hass.async_block_till_done() + + device = device_registry.async_get_device(identifiers={(DOMAIN, appliance_ha_id)}) + assert device + new_entity_entries = entity_registry.entities.get_entries_for_device_id(device.id) + assert len(new_entity_entries) > len(entity_entries) + + async def test_binary_sensors_entity_availabilty( hass: HomeAssistant, config_entry: MockConfigEntry, diff --git a/tests/components/home_connect/test_light.py b/tests/components/home_connect/test_light.py index f0998523c8c..6021c99bb5e 100644 --- a/tests/components/home_connect/test_light.py +++ b/tests/components/home_connect/test_light.py @@ -2,7 +2,7 @@ from collections.abc import Awaitable, Callable from typing import Any -from unittest.mock import MagicMock, call +from unittest.mock import AsyncMock, MagicMock, call from aiohomeconnect.model import ( ArrayOfEvents, @@ -14,11 +14,12 @@ from aiohomeconnect.model import ( GetSetting, SettingKey, ) -from aiohomeconnect.model.error import HomeConnectError +from aiohomeconnect.model.error import HomeConnectApiError, HomeConnectError import pytest from homeassistant.components.home_connect.const import ( BSH_AMBIENT_LIGHT_COLOR_CUSTOM_COLOR, + DOMAIN, ) from homeassistant.components.light import DOMAIN as LIGHT_DOMAIN from homeassistant.config_entries import ConfigEntryState @@ -32,6 +33,7 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers import device_registry as dr, entity_registry as er from tests.common import MockConfigEntry @@ -56,6 +58,124 @@ async def test_light( assert config_entry.state == ConfigEntryState.LOADED +@pytest.mark.parametrize("appliance_ha_id", ["Hood"], indirect=True) +async def test_paired_depaired_devices_flow( + appliance_ha_id: str, + hass: HomeAssistant, + config_entry: MockConfigEntry, + integration_setup: Callable[[MagicMock], Awaitable[bool]], + setup_credentials: None, + client: MagicMock, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, +) -> None: + """Test that removed devices are correctly removed from and added to hass on API events.""" + assert config_entry.state == ConfigEntryState.NOT_LOADED + assert await integration_setup(client) + assert config_entry.state == ConfigEntryState.LOADED + + device = device_registry.async_get_device(identifiers={(DOMAIN, appliance_ha_id)}) + assert device + entity_entries = entity_registry.entities.get_entries_for_device_id(device.id) + assert entity_entries + + await client.add_events( + [ + EventMessage( + appliance_ha_id, + EventType.DEPAIRED, + data=ArrayOfEvents([]), + ) + ] + ) + await hass.async_block_till_done() + + device = device_registry.async_get_device(identifiers={(DOMAIN, appliance_ha_id)}) + assert not device + for entity_entry in entity_entries: + assert not entity_registry.async_get(entity_entry.entity_id) + + # Now that all everything related to the device is removed, pair it again + await client.add_events( + [ + EventMessage( + appliance_ha_id, + EventType.PAIRED, + data=ArrayOfEvents([]), + ) + ] + ) + await hass.async_block_till_done() + + assert device_registry.async_get_device(identifiers={(DOMAIN, appliance_ha_id)}) + for entity_entry in entity_entries: + assert entity_registry.async_get(entity_entry.entity_id) + + +@pytest.mark.parametrize("appliance_ha_id", ["Hood"], indirect=True) +async def test_connected_devices( + appliance_ha_id: str, + hass: HomeAssistant, + config_entry: MockConfigEntry, + integration_setup: Callable[[MagicMock], Awaitable[bool]], + setup_credentials: None, + client: MagicMock, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, +) -> None: + """Test that devices reconnected. + + Specifically those devices whose settings, status, etc. could + not be obtained while disconnected and once connected, the entities are added. + """ + get_settings_original_mock = client.get_settings + get_available_programs_mock = client.get_available_programs + + async def get_settings_side_effect(ha_id: str): + if ha_id == appliance_ha_id: + raise HomeConnectApiError( + "SDK.Error.HomeAppliance.Connection.Initialization.Failed" + ) + return await get_settings_original_mock.side_effect(ha_id) + + async def get_available_programs_side_effect(ha_id: str): + if ha_id == appliance_ha_id: + raise HomeConnectApiError( + "SDK.Error.HomeAppliance.Connection.Initialization.Failed" + ) + return await get_available_programs_mock.side_effect(ha_id) + + client.get_settings = AsyncMock(side_effect=get_settings_side_effect) + client.get_available_programs = AsyncMock( + side_effect=get_available_programs_side_effect + ) + assert config_entry.state == ConfigEntryState.NOT_LOADED + assert await integration_setup(client) + assert config_entry.state == ConfigEntryState.LOADED + client.get_settings = get_settings_original_mock + client.get_available_programs = get_available_programs_mock + + device = device_registry.async_get_device(identifiers={(DOMAIN, appliance_ha_id)}) + assert device + entity_entries = entity_registry.entities.get_entries_for_device_id(device.id) + + await client.add_events( + [ + EventMessage( + appliance_ha_id, + EventType.CONNECTED, + data=ArrayOfEvents([]), + ) + ] + ) + await hass.async_block_till_done() + + device = device_registry.async_get_device(identifiers={(DOMAIN, appliance_ha_id)}) + assert device + new_entity_entries = entity_registry.entities.get_entries_for_device_id(device.id) + assert len(new_entity_entries) > len(entity_entries) + + @pytest.mark.parametrize("appliance_ha_id", ["Hood"], indirect=True) async def test_light_availabilty( hass: HomeAssistant, diff --git a/tests/components/home_connect/test_number.py b/tests/components/home_connect/test_number.py index 7df21e45da9..edab86cf819 100644 --- a/tests/components/home_connect/test_number.py +++ b/tests/components/home_connect/test_number.py @@ -12,10 +12,11 @@ from aiohomeconnect.model import ( GetSetting, SettingKey, ) -from aiohomeconnect.model.error import HomeConnectError +from aiohomeconnect.model.error import HomeConnectApiError, HomeConnectError from aiohomeconnect.model.setting import SettingConstraints import pytest +from homeassistant.components.home_connect.const import DOMAIN from homeassistant.components.number import ( ATTR_VALUE as SERVICE_ATTR_VALUE, DEFAULT_MAX_VALUE, @@ -27,6 +28,7 @@ from homeassistant.config_entries import ConfigEntryState from homeassistant.const import ATTR_ENTITY_ID, STATE_UNAVAILABLE, Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers import device_registry as dr, entity_registry as er from tests.common import MockConfigEntry @@ -49,6 +51,112 @@ async def test_number( assert config_entry.state is ConfigEntryState.LOADED +@pytest.mark.parametrize("appliance_ha_id", ["FridgeFreezer"], indirect=True) +async def test_paired_depaired_devices_flow( + appliance_ha_id: str, + hass: HomeAssistant, + config_entry: MockConfigEntry, + integration_setup: Callable[[MagicMock], Awaitable[bool]], + setup_credentials: None, + client: MagicMock, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, +) -> None: + """Test that removed devices are correctly removed from and added to hass on API events.""" + assert config_entry.state == ConfigEntryState.NOT_LOADED + assert await integration_setup(client) + assert config_entry.state == ConfigEntryState.LOADED + + device = device_registry.async_get_device(identifiers={(DOMAIN, appliance_ha_id)}) + assert device + entity_entries = entity_registry.entities.get_entries_for_device_id(device.id) + assert entity_entries + + await client.add_events( + [ + EventMessage( + appliance_ha_id, + EventType.DEPAIRED, + data=ArrayOfEvents([]), + ) + ] + ) + await hass.async_block_till_done() + + device = device_registry.async_get_device(identifiers={(DOMAIN, appliance_ha_id)}) + assert not device + for entity_entry in entity_entries: + assert not entity_registry.async_get(entity_entry.entity_id) + + # Now that all everything related to the device is removed, pair it again + await client.add_events( + [ + EventMessage( + appliance_ha_id, + EventType.PAIRED, + data=ArrayOfEvents([]), + ) + ] + ) + await hass.async_block_till_done() + + assert device_registry.async_get_device(identifiers={(DOMAIN, appliance_ha_id)}) + for entity_entry in entity_entries: + assert entity_registry.async_get(entity_entry.entity_id) + + +@pytest.mark.parametrize("appliance_ha_id", ["FridgeFreezer"], indirect=True) +async def test_connected_devices( + appliance_ha_id: str, + hass: HomeAssistant, + config_entry: MockConfigEntry, + integration_setup: Callable[[MagicMock], Awaitable[bool]], + setup_credentials: None, + client: MagicMock, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, +) -> None: + """Test that devices reconnected. + + Specifically those devices whose settings, status, etc. could + not be obtained while disconnected and once connected, the entities are added. + """ + get_settings_original_mock = client.get_settings + + def get_settings_side_effect(ha_id: str): + if ha_id == appliance_ha_id: + raise HomeConnectApiError( + "SDK.Error.HomeAppliance.Connection.Initialization.Failed" + ) + return get_settings_original_mock.return_value + + client.get_settings = AsyncMock(side_effect=get_settings_side_effect) + assert config_entry.state == ConfigEntryState.NOT_LOADED + assert await integration_setup(client) + assert config_entry.state == ConfigEntryState.LOADED + client.get_settings = get_settings_original_mock + + device = device_registry.async_get_device(identifiers={(DOMAIN, appliance_ha_id)}) + assert device + entity_entries = entity_registry.entities.get_entries_for_device_id(device.id) + + await client.add_events( + [ + EventMessage( + appliance_ha_id, + EventType.CONNECTED, + data=ArrayOfEvents([]), + ) + ] + ) + await hass.async_block_till_done() + + device = device_registry.async_get_device(identifiers={(DOMAIN, appliance_ha_id)}) + assert device + new_entity_entries = entity_registry.entities.get_entries_for_device_id(device.id) + assert len(new_entity_entries) > len(entity_entries) + + @pytest.mark.parametrize("appliance_ha_id", ["FridgeFreezer"], indirect=True) async def test_number_entity_availabilty( hass: HomeAssistant, diff --git a/tests/components/home_connect/test_select.py b/tests/components/home_connect/test_select.py index 6dad99c90cb..a1e6fafd768 100644 --- a/tests/components/home_connect/test_select.py +++ b/tests/components/home_connect/test_select.py @@ -20,6 +20,7 @@ from aiohomeconnect.model.program import ( ) import pytest +from homeassistant.components.home_connect.const import DOMAIN from homeassistant.components.select import ( ATTR_OPTION, ATTR_OPTIONS, @@ -35,7 +36,7 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers import entity_registry as er +from homeassistant.helpers import device_registry as dr, entity_registry as er from tests.common import MockConfigEntry @@ -58,6 +59,99 @@ async def test_select( assert config_entry.state is ConfigEntryState.LOADED +async def test_paired_depaired_devices_flow( + appliance_ha_id: str, + hass: HomeAssistant, + config_entry: MockConfigEntry, + integration_setup: Callable[[MagicMock], Awaitable[bool]], + setup_credentials: None, + client: MagicMock, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, +) -> None: + """Test that removed devices are correctly removed from and added to hass on API events.""" + assert config_entry.state == ConfigEntryState.NOT_LOADED + assert await integration_setup(client) + assert config_entry.state == ConfigEntryState.LOADED + + device = device_registry.async_get_device(identifiers={(DOMAIN, appliance_ha_id)}) + assert device + entity_entries = entity_registry.entities.get_entries_for_device_id(device.id) + assert entity_entries + + await client.add_events( + [ + EventMessage( + appliance_ha_id, + EventType.DEPAIRED, + data=ArrayOfEvents([]), + ) + ] + ) + await hass.async_block_till_done() + + device = device_registry.async_get_device(identifiers={(DOMAIN, appliance_ha_id)}) + assert not device + for entity_entry in entity_entries: + assert not entity_registry.async_get(entity_entry.entity_id) + + # Now that all everything related to the device is removed, pair it again + await client.add_events( + [ + EventMessage( + appliance_ha_id, + EventType.PAIRED, + data=ArrayOfEvents([]), + ) + ] + ) + await hass.async_block_till_done() + + assert device_registry.async_get_device(identifiers={(DOMAIN, appliance_ha_id)}) + for entity_entry in entity_entries: + assert entity_registry.async_get(entity_entry.entity_id) + + +async def test_connected_devices( + appliance_ha_id: str, + hass: HomeAssistant, + config_entry: MockConfigEntry, + integration_setup: Callable[[MagicMock], Awaitable[bool]], + setup_credentials: None, + client: MagicMock, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, +) -> None: + """Test that devices reconnected. + + Specifically those devices whose settings, status, etc. could + not be obtained while disconnected and once connected, the entities are added. + """ + + assert config_entry.state == ConfigEntryState.NOT_LOADED + assert await integration_setup(client) + assert config_entry.state == ConfigEntryState.LOADED + + device = device_registry.async_get_device(identifiers={(DOMAIN, appliance_ha_id)}) + assert device + + await client.add_events( + [ + EventMessage( + appliance_ha_id, + EventType.CONNECTED, + data=ArrayOfEvents([]), + ) + ] + ) + await hass.async_block_till_done() + + device = device_registry.async_get_device(identifiers={(DOMAIN, appliance_ha_id)}) + assert device + entity_entries = entity_registry.entities.get_entries_for_device_id(device.id) + assert entity_entries + + async def test_select_entity_availabilty( hass: HomeAssistant, config_entry: MockConfigEntry, @@ -174,6 +268,7 @@ async def test_filter_programs( ( "appliance_ha_id", "entity_id", + "expected_initial_state", "mock_method", "program_key", "program_to_set", @@ -183,6 +278,7 @@ async def test_filter_programs( ( "Dishwasher", "select.dishwasher_selected_program", + "dishcare_dishwasher_program_auto_1", "set_selected_program", ProgramKey.DISHCARE_DISHWASHER_ECO_50, "dishcare_dishwasher_program_eco_50", @@ -191,6 +287,7 @@ async def test_filter_programs( ( "Dishwasher", "select.dishwasher_active_program", + "dishcare_dishwasher_program_auto_1", "start_program", ProgramKey.DISHCARE_DISHWASHER_ECO_50, "dishcare_dishwasher_program_eco_50", @@ -202,6 +299,7 @@ async def test_filter_programs( async def test_select_program_functionality( appliance_ha_id: str, entity_id: str, + expected_initial_state: str, mock_method: str, program_key: ProgramKey, program_to_set: str, @@ -217,7 +315,7 @@ async def test_select_program_functionality( assert await integration_setup(client) assert config_entry.state is ConfigEntryState.LOADED - assert hass.states.is_state(entity_id, "unknown") + assert hass.states.is_state(entity_id, expected_initial_state) await hass.services.async_call( SELECT_DOMAIN, SERVICE_SELECT_OPTION, @@ -301,6 +399,8 @@ async def test_select_exception_handling( assert await integration_setup(client_with_exception) assert config_entry.state is ConfigEntryState.LOADED + assert hass.states.is_state(entity_id, STATE_UNKNOWN) + # Assert that an exception is called. with pytest.raises(HomeConnectError): await getattr(client_with_exception, mock_attr)() diff --git a/tests/components/home_connect/test_sensor.py b/tests/components/home_connect/test_sensor.py index 398210d586a..1ec137b95be 100644 --- a/tests/components/home_connect/test_sensor.py +++ b/tests/components/home_connect/test_sensor.py @@ -1,7 +1,7 @@ """Tests for home_connect sensor entities.""" from collections.abc import Awaitable, Callable -from unittest.mock import MagicMock +from unittest.mock import AsyncMock, MagicMock from aiohomeconnect.model import ( ArrayOfEvents, @@ -12,6 +12,7 @@ from aiohomeconnect.model import ( Status, StatusKey, ) +from aiohomeconnect.model.error import HomeConnectApiError from freezegun.api import FrozenDateTimeFactory import pytest @@ -22,10 +23,12 @@ from homeassistant.components.home_connect.const import ( BSH_EVENT_PRESENT_STATE_CONFIRMED, BSH_EVENT_PRESENT_STATE_OFF, BSH_EVENT_PRESENT_STATE_PRESENT, + DOMAIN, ) from homeassistant.config_entries import ConfigEntryState from homeassistant.const import STATE_UNAVAILABLE, Platform from homeassistant.core import HomeAssistant +from homeassistant.helpers import device_registry as dr, entity_registry as er from tests.common import MockConfigEntry @@ -94,6 +97,110 @@ async def test_sensors( assert config_entry.state == ConfigEntryState.LOADED +async def test_paired_depaired_devices_flow( + appliance_ha_id: str, + hass: HomeAssistant, + config_entry: MockConfigEntry, + integration_setup: Callable[[MagicMock], Awaitable[bool]], + setup_credentials: None, + client: MagicMock, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, +) -> None: + """Test that removed devices are correctly removed from and added to hass on API events.""" + assert config_entry.state == ConfigEntryState.NOT_LOADED + assert await integration_setup(client) + assert config_entry.state == ConfigEntryState.LOADED + + device = device_registry.async_get_device(identifiers={(DOMAIN, appliance_ha_id)}) + assert device + entity_entries = entity_registry.entities.get_entries_for_device_id(device.id) + assert entity_entries + + await client.add_events( + [ + EventMessage( + appliance_ha_id, + EventType.DEPAIRED, + data=ArrayOfEvents([]), + ) + ] + ) + await hass.async_block_till_done() + + device = device_registry.async_get_device(identifiers={(DOMAIN, appliance_ha_id)}) + assert not device + for entity_entry in entity_entries: + assert not entity_registry.async_get(entity_entry.entity_id) + + # Now that all everything related to the device is removed, pair it again + await client.add_events( + [ + EventMessage( + appliance_ha_id, + EventType.PAIRED, + data=ArrayOfEvents([]), + ) + ] + ) + await hass.async_block_till_done() + + assert device_registry.async_get_device(identifiers={(DOMAIN, appliance_ha_id)}) + for entity_entry in entity_entries: + assert entity_registry.async_get(entity_entry.entity_id) + + +async def test_connected_devices( + appliance_ha_id: str, + hass: HomeAssistant, + config_entry: MockConfigEntry, + integration_setup: Callable[[MagicMock], Awaitable[bool]], + setup_credentials: None, + client: MagicMock, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, +) -> None: + """Test that devices reconnected. + + Specifically those devices whose settings, status, etc. could + not be obtained while disconnected and once connected, the entities are added. + """ + get_status_original_mock = client.get_status + + def get_status_side_effect(ha_id: str): + if ha_id == appliance_ha_id: + raise HomeConnectApiError( + "SDK.Error.HomeAppliance.Connection.Initialization.Failed" + ) + return get_status_original_mock.return_value + + client.get_status = AsyncMock(side_effect=get_status_side_effect) + assert config_entry.state == ConfigEntryState.NOT_LOADED + assert await integration_setup(client) + assert config_entry.state == ConfigEntryState.LOADED + client.get_status = get_status_original_mock + + device = device_registry.async_get_device(identifiers={(DOMAIN, appliance_ha_id)}) + assert device + entity_entries = entity_registry.entities.get_entries_for_device_id(device.id) + + await client.add_events( + [ + EventMessage( + appliance_ha_id, + EventType.CONNECTED, + data=ArrayOfEvents([]), + ) + ] + ) + await hass.async_block_till_done() + + device = device_registry.async_get_device(identifiers={(DOMAIN, appliance_ha_id)}) + assert device + new_entity_entries = entity_registry.entities.get_entries_for_device_id(device.id) + assert len(new_entity_entries) > len(entity_entries) + + @pytest.mark.parametrize("appliance_ha_id", [TEST_HC_APP], indirect=True) async def test_sensor_entity_availabilty( hass: HomeAssistant, diff --git a/tests/components/home_connect/test_switch.py b/tests/components/home_connect/test_switch.py index eb58f832c9d..d4e0f999197 100644 --- a/tests/components/home_connect/test_switch.py +++ b/tests/components/home_connect/test_switch.py @@ -13,7 +13,7 @@ from aiohomeconnect.model import ( ProgramKey, SettingKey, ) -from aiohomeconnect.model.error import HomeConnectError +from aiohomeconnect.model.error import HomeConnectApiError, HomeConnectError from aiohomeconnect.model.event import ArrayOfEvents, EventType from aiohomeconnect.model.program import ArrayOfPrograms, EnumerateProgram from aiohomeconnect.model.setting import SettingConstraints @@ -41,7 +41,11 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers import issue_registry as ir +from homeassistant.helpers import ( + device_registry as dr, + entity_registry as er, + issue_registry as ir, +) from homeassistant.setup import async_setup_component from tests.common import MockConfigEntry @@ -66,6 +70,122 @@ async def test_switches( assert config_entry.state == ConfigEntryState.LOADED +async def test_paired_depaired_devices_flow( + appliance_ha_id: str, + hass: HomeAssistant, + config_entry: MockConfigEntry, + integration_setup: Callable[[MagicMock], Awaitable[bool]], + setup_credentials: None, + client: MagicMock, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, +) -> None: + """Test that removed devices are correctly removed from and added to hass on API events.""" + assert config_entry.state == ConfigEntryState.NOT_LOADED + assert await integration_setup(client) + assert config_entry.state == ConfigEntryState.LOADED + + device = device_registry.async_get_device(identifiers={(DOMAIN, appliance_ha_id)}) + assert device + entity_entries = entity_registry.entities.get_entries_for_device_id(device.id) + assert entity_entries + + await client.add_events( + [ + EventMessage( + appliance_ha_id, + EventType.DEPAIRED, + data=ArrayOfEvents([]), + ) + ] + ) + await hass.async_block_till_done() + + device = device_registry.async_get_device(identifiers={(DOMAIN, appliance_ha_id)}) + assert not device + for entity_entry in entity_entries: + assert not entity_registry.async_get(entity_entry.entity_id) + + # Now that all everything related to the device is removed, pair it again + await client.add_events( + [ + EventMessage( + appliance_ha_id, + EventType.PAIRED, + data=ArrayOfEvents([]), + ) + ] + ) + await hass.async_block_till_done() + + assert device_registry.async_get_device(identifiers={(DOMAIN, appliance_ha_id)}) + for entity_entry in entity_entries: + assert entity_registry.async_get(entity_entry.entity_id) + + +async def test_connected_devices( + appliance_ha_id: str, + hass: HomeAssistant, + config_entry: MockConfigEntry, + integration_setup: Callable[[MagicMock], Awaitable[bool]], + setup_credentials: None, + client: MagicMock, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, +) -> None: + """Test that devices reconnected. + + Specifically those devices whose settings, status, etc. could + not be obtained while disconnected and once connected, the entities are added. + """ + get_settings_original_mock = client.get_settings + get_available_programs_mock = client.get_available_programs + + async def get_settings_side_effect(ha_id: str): + if ha_id == appliance_ha_id: + raise HomeConnectApiError( + "SDK.Error.HomeAppliance.Connection.Initialization.Failed" + ) + return await get_settings_original_mock.side_effect(ha_id) + + async def get_available_programs_side_effect(ha_id: str): + if ha_id == appliance_ha_id: + raise HomeConnectApiError( + "SDK.Error.HomeAppliance.Connection.Initialization.Failed" + ) + return await get_available_programs_mock.side_effect(ha_id) + + client.get_settings = AsyncMock(side_effect=get_settings_side_effect) + client.get_available_programs = AsyncMock( + side_effect=get_available_programs_side_effect + ) + assert config_entry.state == ConfigEntryState.NOT_LOADED + assert await integration_setup(client) + assert config_entry.state == ConfigEntryState.LOADED + client.get_settings = get_settings_original_mock + client.get_available_programs = get_available_programs_mock + + device = device_registry.async_get_device(identifiers={(DOMAIN, appliance_ha_id)}) + assert device + entity_entries = entity_registry.entities.get_entries_for_device_id(device.id) + + await client.add_events( + [ + EventMessage( + appliance_ha_id, + EventType.CONNECTED, + data=ArrayOfEvents([]), + ) + ] + ) + await hass.async_block_till_done() + + device = device_registry.async_get_device(identifiers={(DOMAIN, appliance_ha_id)}) + assert device + new_entity_entries = entity_registry.entities.get_entries_for_device_id(device.id) + assert len(new_entity_entries) > len(entity_entries) + + @pytest.mark.parametrize("appliance_ha_id", ["Dishwasher"], indirect=True) async def test_switch_entity_availabilty( hass: HomeAssistant, @@ -178,11 +298,18 @@ async def test_switch_functionality( @pytest.mark.parametrize( - ("entity_id", "program_key", "appliance_ha_id"), + ("entity_id", "program_key", "initial_state", "appliance_ha_id"), [ ( "switch.dryer_program_mix", ProgramKey.LAUNDRY_CARE_DRYER_MIX, + STATE_OFF, + "Dryer", + ), + ( + "switch.dryer_program_cotton", + ProgramKey.LAUNDRY_CARE_DRYER_COTTON, + STATE_ON, "Dryer", ), ], @@ -191,6 +318,7 @@ async def test_switch_functionality( async def test_program_switch_functionality( entity_id: str, program_key: ProgramKey, + initial_state: str, hass: HomeAssistant, config_entry: MockConfigEntry, integration_setup: Callable[[MagicMock], Awaitable[bool]], @@ -227,6 +355,7 @@ async def test_program_switch_functionality( assert config_entry.state == ConfigEntryState.NOT_LOADED assert await integration_setup(client) assert config_entry.state == ConfigEntryState.LOADED + assert hass.states.is_state(entity_id, initial_state) await hass.services.async_call( SWITCH_DOMAIN, SERVICE_TURN_ON, {ATTR_ENTITY_ID: entity_id} diff --git a/tests/components/home_connect/test_time.py b/tests/components/home_connect/test_time.py index 58ffd17c41a..affb5ecfedf 100644 --- a/tests/components/home_connect/test_time.py +++ b/tests/components/home_connect/test_time.py @@ -2,18 +2,26 @@ from collections.abc import Awaitable, Callable from datetime import time -from unittest.mock import MagicMock +from unittest.mock import AsyncMock, MagicMock -from aiohomeconnect.model import ArrayOfSettings, EventMessage, GetSetting, SettingKey -from aiohomeconnect.model.error import HomeConnectError -from aiohomeconnect.model.event import ArrayOfEvents, EventType +from aiohomeconnect.model import ( + ArrayOfEvents, + ArrayOfSettings, + EventMessage, + EventType, + GetSetting, + SettingKey, +) +from aiohomeconnect.model.error import HomeConnectApiError, HomeConnectError import pytest +from homeassistant.components.home_connect.const import DOMAIN from homeassistant.components.time import DOMAIN as TIME_DOMAIN, SERVICE_SET_VALUE from homeassistant.config_entries import ConfigEntryState from homeassistant.const import ATTR_ENTITY_ID, ATTR_TIME, STATE_UNAVAILABLE, Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers import device_registry as dr, entity_registry as er from tests.common import MockConfigEntry @@ -36,6 +44,112 @@ async def test_time( assert config_entry.state is ConfigEntryState.LOADED +@pytest.mark.parametrize("appliance_ha_id", ["Oven"], indirect=True) +async def test_paired_depaired_devices_flow( + appliance_ha_id: str, + hass: HomeAssistant, + config_entry: MockConfigEntry, + integration_setup: Callable[[MagicMock], Awaitable[bool]], + setup_credentials: None, + client: MagicMock, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, +) -> None: + """Test that removed devices are correctly removed from and added to hass on API events.""" + assert config_entry.state == ConfigEntryState.NOT_LOADED + assert await integration_setup(client) + assert config_entry.state == ConfigEntryState.LOADED + + device = device_registry.async_get_device(identifiers={(DOMAIN, appliance_ha_id)}) + assert device + entity_entries = entity_registry.entities.get_entries_for_device_id(device.id) + assert entity_entries + + await client.add_events( + [ + EventMessage( + appliance_ha_id, + EventType.DEPAIRED, + data=ArrayOfEvents([]), + ) + ] + ) + await hass.async_block_till_done() + + device = device_registry.async_get_device(identifiers={(DOMAIN, appliance_ha_id)}) + assert not device + for entity_entry in entity_entries: + assert not entity_registry.async_get(entity_entry.entity_id) + + # Now that all everything related to the device is removed, pair it again + await client.add_events( + [ + EventMessage( + appliance_ha_id, + EventType.PAIRED, + data=ArrayOfEvents([]), + ) + ] + ) + await hass.async_block_till_done() + + assert device_registry.async_get_device(identifiers={(DOMAIN, appliance_ha_id)}) + for entity_entry in entity_entries: + assert entity_registry.async_get(entity_entry.entity_id) + + +@pytest.mark.parametrize("appliance_ha_id", ["Oven"], indirect=True) +async def test_connected_devices( + appliance_ha_id: str, + hass: HomeAssistant, + config_entry: MockConfigEntry, + integration_setup: Callable[[MagicMock], Awaitable[bool]], + setup_credentials: None, + client: MagicMock, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, +) -> None: + """Test that devices reconnected. + + Specifically those devices whose settings, status, etc. could + not be obtained while disconnected and once connected, the entities are added. + """ + get_settings_original_mock = client.get_settings + + async def get_settings_side_effect(ha_id: str): + if ha_id == appliance_ha_id: + raise HomeConnectApiError( + "SDK.Error.HomeAppliance.Connection.Initialization.Failed" + ) + return await get_settings_original_mock.side_effect(ha_id) + + client.get_settings = AsyncMock(side_effect=get_settings_side_effect) + assert config_entry.state == ConfigEntryState.NOT_LOADED + assert await integration_setup(client) + assert config_entry.state == ConfigEntryState.LOADED + client.get_settings = get_settings_original_mock + + device = device_registry.async_get_device(identifiers={(DOMAIN, appliance_ha_id)}) + assert device + entity_entries = entity_registry.entities.get_entries_for_device_id(device.id) + + await client.add_events( + [ + EventMessage( + appliance_ha_id, + EventType.CONNECTED, + data=ArrayOfEvents([]), + ) + ] + ) + await hass.async_block_till_done() + + device = device_registry.async_get_device(identifiers={(DOMAIN, appliance_ha_id)}) + assert device + new_entity_entries = entity_registry.entities.get_entries_for_device_id(device.id) + assert len(new_entity_entries) > len(entity_entries) + + @pytest.mark.parametrize("appliance_ha_id", ["Oven"], indirect=True) async def test_time_entity_availabilty( hass: HomeAssistant, diff --git a/tests/components/homee/test_cover.py b/tests/components/homee/test_cover.py index d52f3fa3164..4f85b2dd7cc 100644 --- a/tests/components/homee/test_cover.py +++ b/tests/components/homee/test_cover.py @@ -2,6 +2,10 @@ from unittest.mock import MagicMock +import pytest +from websockets import frames +from websockets.exceptions import ConnectionClosed + from homeassistant.components.cover import ( ATTR_POSITION, ATTR_TILT_POSITION, @@ -9,6 +13,7 @@ from homeassistant.components.cover import ( CoverEntityFeature, CoverState, ) +from homeassistant.components.homee.const import DOMAIN from homeassistant.const import ( ATTR_ENTITY_ID, SERVICE_CLOSE_COVER, @@ -20,6 +25,7 @@ from homeassistant.const import ( SERVICE_STOP_COVER, ) from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError from . import build_mock_node, setup_integration @@ -253,3 +259,28 @@ async def test_reversed_cover( await hass.async_block_till_done() assert hass.states.get("cover.test_cover").state == CoverState.CLOSED + + +async def test_send_error( + hass: HomeAssistant, + mock_homee: MagicMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test failed set_value command.""" + mock_homee.nodes = [build_mock_node("cover_without_position.json")] + + await setup_integration(hass, mock_config_entry) + + mock_homee.set_value.side_effect = ConnectionClosed( + rcvd=frames.Close(1002, "Protocol Error"), sent=None + ) + with pytest.raises(HomeAssistantError) as exc_info: + await hass.services.async_call( + COVER_DOMAIN, + SERVICE_OPEN_COVER, + {ATTR_ENTITY_ID: "cover.test_cover"}, + blocking=True, + ) + + assert exc_info.value.translation_domain == DOMAIN + assert exc_info.value.translation_key == "connection_closed" diff --git a/tests/components/homekit/test_util.py b/tests/components/homekit/test_util.py index 853db54b992..1da12402a56 100644 --- a/tests/components/homekit/test_util.py +++ b/tests/components/homekit/test_util.py @@ -26,6 +26,7 @@ from homeassistant.components.homekit.const import ( CONF_VIDEO_CODEC, CONF_VIDEO_MAP, CONF_VIDEO_PACKET_SIZE, + CONF_VIDEO_PROFILE_NAMES, DEFAULT_AUDIO_CODEC, DEFAULT_AUDIO_MAP, DEFAULT_AUDIO_PACKET_SIZE, @@ -39,6 +40,7 @@ from homeassistant.components.homekit.const import ( DEFAULT_VIDEO_CODEC, DEFAULT_VIDEO_MAP, DEFAULT_VIDEO_PACKET_SIZE, + DEFAULT_VIDEO_PROFILE_NAMES, DOMAIN, FEATURE_ON_OFF, FEATURE_PLAY_PAUSE, @@ -235,6 +237,7 @@ def test_validate_entity_config() -> None: CONF_VIDEO_MAP: DEFAULT_VIDEO_MAP, CONF_STREAM_COUNT: DEFAULT_STREAM_COUNT, CONF_VIDEO_CODEC: DEFAULT_VIDEO_CODEC, + CONF_VIDEO_PROFILE_NAMES: DEFAULT_VIDEO_PROFILE_NAMES, CONF_AUDIO_PACKET_SIZE: DEFAULT_AUDIO_PACKET_SIZE, CONF_VIDEO_PACKET_SIZE: DEFAULT_VIDEO_PACKET_SIZE, CONF_LOW_BATTERY_THRESHOLD: DEFAULT_LOW_BATTERY_THRESHOLD, diff --git a/tests/components/homewizard/conftest.py b/tests/components/homewizard/conftest.py index f9c5e617904..b8367f87e57 100644 --- a/tests/components/homewizard/conftest.py +++ b/tests/components/homewizard/conftest.py @@ -160,7 +160,7 @@ def mock_config_entry_v2() -> MockConfigEntry: CONF_IP_ADDRESS: "127.0.0.1", CONF_TOKEN: "00112233445566778899ABCDEFABCDEF", }, - unique_id="HWE-P1_5c2fafabcdef", + unique_id="HWE-BAT_5c2fafabcdef", ) diff --git a/tests/components/homewizard/test_init.py b/tests/components/homewizard/test_init.py index 77366da84c5..412ddb13eda 100644 --- a/tests/components/homewizard/test_init.py +++ b/tests/components/homewizard/test_init.py @@ -9,6 +9,7 @@ import pytest from homeassistant.components.homewizard.const import DOMAIN from homeassistant.config_entries import SOURCE_REAUTH, ConfigEntryState +from homeassistant.const import CONF_IP_ADDRESS, CONF_TOKEN from homeassistant.core import HomeAssistant from tests.common import MockConfigEntry, async_fire_time_changed @@ -52,6 +53,36 @@ async def test_load_unload_v2( assert mock_config_entry_v2.state is ConfigEntryState.NOT_LOADED +async def test_load_unload_v2_as_v1( + hass: HomeAssistant, + mock_homewizardenergy: MagicMock, +) -> None: + """Test loading and unloading of integration with v2 config, but without using it.""" + + # Simulate v2 config but as a P1 Meter + mock_config_entry = MockConfigEntry( + title="Device", + domain=DOMAIN, + data={ + CONF_IP_ADDRESS: "127.0.0.1", + CONF_TOKEN: "00112233445566778899ABCDEFABCDEF", + }, + unique_id="HWE-P1_5c2fafabcdef", + ) + + mock_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + assert mock_config_entry.state is ConfigEntryState.LOADED + assert len(mock_homewizardenergy.combined.mock_calls) == 1 + + await hass.config_entries.async_unload(mock_config_entry.entry_id) + await hass.async_block_till_done() + + assert mock_config_entry.state is ConfigEntryState.NOT_LOADED + + async def test_load_failed_host_unavailable( hass: HomeAssistant, mock_config_entry: MockConfigEntry, diff --git a/tests/components/homewizard/test_repair.py b/tests/components/homewizard/test_repair.py index a59d6f415dd..763af48b1a2 100644 --- a/tests/components/homewizard/test_repair.py +++ b/tests/components/homewizard/test_repair.py @@ -36,6 +36,10 @@ async def test_repair_acquires_token( client = await hass_client() mock_config_entry.add_to_hass(hass) + hass.config_entries.async_update_entry( + mock_config_entry, unique_id="HWE-BAT_5c2fafabcdef" + ) + await hass.async_block_till_done() with patch("homeassistant.components.homewizard.has_v2_api", return_value=True): await hass.config_entries.async_setup(mock_config_entry.entry_id) diff --git a/tests/components/hue/test_light_v2.py b/tests/components/hue/test_light_v2.py index 2b978ffc33f..c831d40d261 100644 --- a/tests/components/hue/test_light_v2.py +++ b/tests/components/hue/test_light_v2.py @@ -392,7 +392,7 @@ async def test_light_availability( assert test_light is not None assert test_light.state == "on" - # Change availability by modififying the zigbee_connectivity status + # Change availability by modifying the zigbee_connectivity status for status in ("connectivity_issue", "disconnected", "connected"): mock_bridge_v2.api.emit_event( "update", diff --git a/tests/components/idasen_desk/test_config_flow.py b/tests/components/idasen_desk/test_config_flow.py index baeed6be1ab..15baac1b055 100644 --- a/tests/components/idasen_desk/test_config_flow.py +++ b/tests/components/idasen_desk/test_config_flow.py @@ -50,6 +50,49 @@ async def test_user_step_success(hass: HomeAssistant, mock_desk_api: MagicMock) assert len(mock_setup_entry.mock_calls) == 1 +async def test_user_step_replaces_ignored_device( + hass: HomeAssistant, mock_desk_api: MagicMock +) -> None: + """Test user step replaces ignored devices.""" + entry = MockConfigEntry( + domain=DOMAIN, + unique_id=IDASEN_DISCOVERY_INFO.address, + source=config_entries.SOURCE_IGNORE, + data={CONF_ADDRESS: IDASEN_DISCOVERY_INFO.address}, + ) + entry.add_to_hass(hass) + + with patch( + "homeassistant.components.idasen_desk.config_flow.async_discovered_service_info", + return_value=[NOT_IDASEN_DISCOVERY_INFO, IDASEN_DISCOVERY_INFO], + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + assert result["errors"] == {} + + with patch( + "homeassistant.components.idasen_desk.async_setup_entry", return_value=True + ) as mock_setup_entry: + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_ADDRESS: IDASEN_DISCOVERY_INFO.address, + }, + ) + await hass.async_block_till_done() + + assert result2["type"] is FlowResultType.CREATE_ENTRY + assert result2["title"] == IDASEN_DISCOVERY_INFO.name + assert result2["data"] == { + CONF_ADDRESS: IDASEN_DISCOVERY_INFO.address, + } + assert result2["result"].unique_id == IDASEN_DISCOVERY_INFO.address + assert len(mock_setup_entry.mock_calls) == 1 + + async def test_user_step_no_devices_found(hass: HomeAssistant) -> None: """Test user step with no devices found.""" with patch( diff --git a/tests/components/inkbird/test_config_flow.py b/tests/components/inkbird/test_config_flow.py index 154132c34fc..796f57da55b 100644 --- a/tests/components/inkbird/test_config_flow.py +++ b/tests/components/inkbird/test_config_flow.py @@ -75,6 +75,36 @@ async def test_async_step_user_with_found_devices(hass: HomeAssistant) -> None: assert result2["result"].unique_id == "61DE521B-F0BF-9F44-64D4-75BBE1738105" +async def test_async_step_user_replace_ignored(hass: HomeAssistant) -> None: + """Test setup from service info can replace an ignored entry.""" + entry = MockConfigEntry( + domain=DOMAIN, + unique_id=SPS_SERVICE_INFO.address, + data={}, + source=config_entries.SOURCE_IGNORE, + ) + entry.add_to_hass(hass) + with patch( + "homeassistant.components.inkbird.config_flow.async_discovered_service_info", + return_value=[SPS_SERVICE_INFO], + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_USER}, + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + with patch("homeassistant.components.inkbird.async_setup_entry", return_value=True): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={"address": "61DE521B-F0BF-9F44-64D4-75BBE1738105"}, + ) + assert result2["type"] is FlowResultType.CREATE_ENTRY + assert result2["title"] == "IBS-TH 8105" + assert result2["data"] == {} + assert result2["result"].unique_id == "61DE521B-F0BF-9F44-64D4-75BBE1738105" + + async def test_async_step_user_device_added_between_steps(hass: HomeAssistant) -> None: """Test the device gets added via another flow between steps.""" with patch( diff --git a/tests/components/iometer/__init__.py b/tests/components/iometer/__init__.py new file mode 100644 index 00000000000..5c08438925e --- /dev/null +++ b/tests/components/iometer/__init__.py @@ -0,0 +1 @@ +"""Tests for the IOmeter integration.""" diff --git a/tests/components/iometer/conftest.py b/tests/components/iometer/conftest.py new file mode 100644 index 00000000000..ee45021952e --- /dev/null +++ b/tests/components/iometer/conftest.py @@ -0,0 +1,57 @@ +"""Common fixtures for the IOmeter tests.""" + +from collections.abc import Generator +from unittest.mock import AsyncMock, patch + +from iometer import Reading, Status +import pytest + +from homeassistant.components.iometer.const import DOMAIN +from homeassistant.const import CONF_HOST + +from tests.common import MockConfigEntry, load_fixture + + +@pytest.fixture +def mock_setup_entry() -> Generator[AsyncMock]: + """Override async_setup_entry.""" + with patch( + "homeassistant.components.iometer.async_setup_entry", + return_value=True, + ) as mock_setup_entry: + yield mock_setup_entry + + +@pytest.fixture +def mock_iometer_client() -> Generator[AsyncMock]: + """Mock a new IOmeter client.""" + with ( + patch( + "homeassistant.components.iometer.IOmeterClient", + autospec=True, + ) as mock_client, + patch( + "homeassistant.components.iometer.config_flow.IOmeterClient", + new=mock_client, + ), + ): + client = mock_client.return_value + client.host = "10.0.0.2" + client.get_current_reading.return_value = Reading.from_json( + load_fixture("reading.json", DOMAIN) + ) + client.get_current_status.return_value = Status.from_json( + load_fixture("status.json", DOMAIN) + ) + yield client + + +@pytest.fixture +def mock_config_entry() -> MockConfigEntry: + """Mock a IOmeter config entry.""" + return MockConfigEntry( + domain=DOMAIN, + title="IOmeter-1ISK0000000000", + data={CONF_HOST: "10.0.0.2"}, + unique_id="658c2b34-2017-45f2-a12b-731235f8bb97", + ) diff --git a/tests/components/iometer/fixtures/reading.json b/tests/components/iometer/fixtures/reading.json new file mode 100644 index 00000000000..82190c88883 --- /dev/null +++ b/tests/components/iometer/fixtures/reading.json @@ -0,0 +1,14 @@ +{ + "__typename": "iometer.reading.v1", + "meter": { + "number": "1ISK0000000000", + "reading": { + "time": "2024-11-11T11:11:11Z", + "registers": [ + { "obis": "01-00:01.08.00*ff", "value": 1234.5, "unit": "Wh" }, + { "obis": "01-00:02.08.00*ff", "value": 5432.1, "unit": "Wh" }, + { "obis": "01-00:10.07.00*ff", "value": 100, "unit": "W" } + ] + } + } +} diff --git a/tests/components/iometer/fixtures/status.json b/tests/components/iometer/fixtures/status.json new file mode 100644 index 00000000000..4d3001d8454 --- /dev/null +++ b/tests/components/iometer/fixtures/status.json @@ -0,0 +1,19 @@ +{ + "__typename": "iometer.status.v1", + "meter": { + "number": "1ISK0000000000" + }, + "device": { + "bridge": { "rssi": -30, "version": "build-65" }, + "id": "658c2b34-2017-45f2-a12b-731235f8bb97", + "core": { + "connectionStatus": "connected", + "rssi": -30, + "version": "build-58", + "powerStatus": "battery", + "batteryLevel": 100, + "attachmentStatus": "attached", + "pinStatus": "entered" + } + } +} diff --git a/tests/components/iometer/test_config_flow.py b/tests/components/iometer/test_config_flow.py new file mode 100644 index 00000000000..49fce459282 --- /dev/null +++ b/tests/components/iometer/test_config_flow.py @@ -0,0 +1,171 @@ +"""Test the IOmeter config flow.""" + +from ipaddress import ip_address +from unittest.mock import AsyncMock + +from iometer import IOmeterConnectionError + +from homeassistant.components import zeroconf +from homeassistant.components.iometer.const import DOMAIN +from homeassistant.config_entries import SOURCE_USER, SOURCE_ZEROCONF +from homeassistant.const import CONF_HOST +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType + +from tests.common import MockConfigEntry + +IP_ADDRESS = "10.0.0.2" +IOMETER_DEVICE_ID = "658c2b34-2017-45f2-a12b-731235f8bb97" + +ZEROCONF_DISCOVERY = zeroconf.ZeroconfServiceInfo( + ip_address=ip_address(IP_ADDRESS), + ip_addresses=[ip_address(IP_ADDRESS)], + hostname="IOmeter-EC63E8.local.", + name="IOmeter-EC63E8", + port=80, + type="_iometer._tcp.", + properties={}, +) + + +async def test_user_flow( + hass: HomeAssistant, + mock_iometer_client: AsyncMock, +) -> None: + """Test full user configuration flow.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_USER}, + ) + await hass.async_block_till_done() + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={CONF_HOST: IP_ADDRESS}, + ) + + await hass.async_block_till_done() + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == "IOmeter 1ISK0000000000" + assert result["data"] == {CONF_HOST: IP_ADDRESS} + assert result["result"].unique_id == IOMETER_DEVICE_ID + + +async def test_zeroconf_flow( + hass: HomeAssistant, + mock_iometer_client: AsyncMock, +) -> None: + """Test zeroconf flow.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_ZEROCONF}, + data=ZEROCONF_DISCOVERY, + ) + await hass.async_block_till_done() + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "zeroconf_confirm" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {}, + ) + await hass.async_block_till_done() + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == "IOmeter 1ISK0000000000" + assert result["data"] == {CONF_HOST: IP_ADDRESS} + assert result["result"].unique_id == IOMETER_DEVICE_ID + + +async def test_zeroconf_flow_abort_duplicate( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, +) -> None: + """Test zeroconf flow aborts with duplicate.""" + mock_config_entry.add_to_hass(hass) + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_ZEROCONF}, + data=ZEROCONF_DISCOVERY, + ) + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" + + +async def test_zeroconf_flow_connection_error( + hass: HomeAssistant, + mock_iometer_client: AsyncMock, +) -> None: + """Test zeroconf flow.""" + mock_iometer_client.get_current_status.side_effect = IOmeterConnectionError() + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_ZEROCONF}, + data=ZEROCONF_DISCOVERY, + ) + await hass.async_block_till_done() + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "cannot_connect" + + +async def test_user_flow_connection_error( + hass: HomeAssistant, + mock_iometer_client: AsyncMock, + mock_setup_entry: AsyncMock, +) -> None: + """Test flow error.""" + mock_iometer_client.get_current_status.side_effect = IOmeterConnectionError() + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_USER}, + ) + await hass.async_block_till_done() + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_HOST: IP_ADDRESS}, + ) + await hass.async_block_till_done() + + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {"base": "cannot_connect"} + + mock_iometer_client.get_current_status.side_effect = None + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_HOST: IP_ADDRESS}, + ) + await hass.async_block_till_done() + assert result["type"] is FlowResultType.CREATE_ENTRY + + +async def test_flow_abort_duplicate( + hass: HomeAssistant, + mock_iometer_client: AsyncMock, + mock_setup_entry: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test duplicate flow.""" + mock_config_entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_USER}, + ) + await hass.async_block_till_done() + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_HOST: IP_ADDRESS}, + ) + await hass.async_block_till_done() + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" diff --git a/tests/components/kitchen_sink/test_backup.py b/tests/components/kitchen_sink/test_backup.py index a664b91393d..7c693abcda8 100644 --- a/tests/components/kitchen_sink/test_backup.py +++ b/tests/components/kitchen_sink/test_backup.py @@ -2,7 +2,7 @@ from collections.abc import AsyncGenerator from io import StringIO -from unittest.mock import patch +from unittest.mock import ANY, patch import pytest @@ -106,6 +106,7 @@ async def test_agents_list_backups( "backup_id": "abc123", "database_included": False, "date": "1970-01-01T00:00:00Z", + "extra_metadata": {}, "failed_agent_ids": [], "folders": ["media", "share"], "homeassistant_included": True, @@ -187,6 +188,7 @@ async def test_agents_upload( "backup_id": "test-backup", "database_included": True, "date": "1970-01-01T00:00:00.000Z", + "extra_metadata": {"instance_id": ANY, "with_automatic_settings": False}, "failed_agent_ids": [], "folders": ["media", "share"], "homeassistant_included": True, diff --git a/tests/components/mcp_server/conftest.py b/tests/components/mcp_server/conftest.py index 149073f3645..5ec67fb6ce3 100644 --- a/tests/components/mcp_server/conftest.py +++ b/tests/components/mcp_server/conftest.py @@ -5,10 +5,9 @@ from unittest.mock import AsyncMock, patch import pytest -from homeassistant.components.mcp_server.const import DOMAIN +from homeassistant.components.mcp_server.const import DOMAIN, LLM_API from homeassistant.const import CONF_LLM_HASS_API from homeassistant.core import HomeAssistant -from homeassistant.helpers import llm from tests.common import MockConfigEntry @@ -28,7 +27,7 @@ def mock_config_entry(hass: HomeAssistant) -> MockConfigEntry: config_entry = MockConfigEntry( domain=DOMAIN, data={ - CONF_LLM_HASS_API: llm.LLM_API_ASSIST, + CONF_LLM_HASS_API: LLM_API, }, ) config_entry.add_to_hass(hass) diff --git a/tests/components/mcp_server/test_http.py b/tests/components/mcp_server/test_http.py index a71bf42acc8..905bfaa11d7 100644 --- a/tests/components/mcp_server/test_http.py +++ b/tests/components/mcp_server/test_http.py @@ -20,7 +20,11 @@ from homeassistant.components.mcp_server.http import MESSAGES_API, SSE_API from homeassistant.config_entries import ConfigEntryState from homeassistant.const import CONF_LLM_HASS_API, STATE_OFF, STATE_ON from homeassistant.core import HomeAssistant -from homeassistant.helpers import device_registry as dr, entity_registry as er +from homeassistant.helpers import ( + area_registry as ar, + device_registry as dr, + entity_registry as er, +) from homeassistant.setup import async_setup_component from tests.common import MockConfigEntry, setup_test_component_platform @@ -45,6 +49,11 @@ INITIALIZE_MESSAGE = { } EVENT_PREFIX = "event: " DATA_PREFIX = "data: " +EXPECTED_PROMPT_SUFFIX = """ +- names: Kitchen Light + domain: light + areas: Kitchen +""" @pytest.fixture @@ -59,11 +68,13 @@ async def mock_entities( hass: HomeAssistant, device_registry: dr.DeviceRegistry, entity_registry: er.EntityRegistry, + area_registry: ar.AreaRegistry, setup_integration: None, ) -> None: """Fixture to expose entities to the conversation agent.""" - entity = MockLight("kitchen", STATE_OFF) + entity = MockLight("Kitchen Light", STATE_OFF) entity.entity_id = TEST_ENTITY + entity.unique_id = "test-light-unique-id" setup_test_component_platform(hass, LIGHT_DOMAIN, [entity]) assert await async_setup_component( @@ -71,6 +82,9 @@ async def mock_entities( LIGHT_DOMAIN, {LIGHT_DOMAIN: [{"platform": "test"}]}, ) + await hass.async_block_till_done() + kitchen = area_registry.async_get_or_create("Kitchen") + entity_registry.async_update_entity(TEST_ENTITY, area_id=kitchen.id) async_expose_entity(hass, CONVERSATION_DOMAIN, TEST_ENTITY, True) @@ -320,7 +334,7 @@ async def test_mcp_tool_call( async with mcp_session(mcp_sse_url, hass_supervisor_access_token) as session: result = await session.call_tool( name="HassTurnOn", - arguments={"name": "kitchen"}, + arguments={"name": "kitchen light"}, ) assert not result.isError @@ -370,8 +384,11 @@ async def test_prompt_list( assert len(result.prompts) == 1 prompt = result.prompts[0] - assert prompt.name == "Assist" - assert prompt.description == "Default prompt for the Home Assistant LLM API Assist" + assert prompt.name == "Stateless Assist" + assert ( + prompt.description + == "Default prompt for the Home Assistant LLM API Stateless Assist" + ) async def test_prompt_get( @@ -383,13 +400,17 @@ async def test_prompt_get( """Test the get prompt endpoint.""" async with mcp_session(mcp_sse_url, hass_supervisor_access_token) as session: - result = await session.get_prompt(name="Assist") + result = await session.get_prompt(name="Stateless Assist") - assert result.description == "Default prompt for the Home Assistant LLM API Assist" + assert ( + result.description + == "Default prompt for the Home Assistant LLM API Stateless Assist" + ) assert len(result.messages) == 1 assert result.messages[0].role == "assistant" assert result.messages[0].content.type == "text" assert "When controlling Home Assistant" in result.messages[0].content.text + assert result.messages[0].content.text.endswith(EXPECTED_PROMPT_SUFFIX) async def test_get_unknwon_prompt( diff --git a/tests/components/meteo_france/conftest.py b/tests/components/meteo_france/conftest.py index 123fc00e42a..eb28ec0a838 100644 --- a/tests/components/meteo_france/conftest.py +++ b/tests/components/meteo_france/conftest.py @@ -2,13 +2,48 @@ from unittest.mock import patch +from meteofrance_api.model import CurrentPhenomenons, Forecast, Rain import pytest +from homeassistant.components.meteo_france.const import CONF_CITY, DOMAIN +from homeassistant.config_entries import SOURCE_USER +from homeassistant.const import CONF_LATITUDE, CONF_LONGITUDE +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry, load_json_object_fixture + @pytest.fixture(autouse=True) def patch_requests(): """Stub out services that makes requests.""" - patch_client = patch("homeassistant.components.meteo_france.MeteoFranceClient") + with patch("homeassistant.components.meteo_france.MeteoFranceClient") as mock_data: + mock_data = mock_data.return_value + mock_data.get_forecast.return_value = Forecast( + load_json_object_fixture("raw_forecast.json", DOMAIN) + ) + mock_data.get_rain.return_value = Rain( + load_json_object_fixture("raw_rain.json", DOMAIN) + ) + mock_data.get_warning_current_phenomenoms.return_value = CurrentPhenomenons( + load_json_object_fixture("raw_warning_current_phenomenoms.json", DOMAIN) + ) + yield mock_data - with patch_client: - yield + +@pytest.fixture(name="config_entry") +def get_config_entry(hass: HomeAssistant) -> MockConfigEntry: + """Create and register mock config entry.""" + entry_data = { + CONF_CITY: "La Clusaz", + CONF_LATITUDE: 45.90417, + CONF_LONGITUDE: 6.42306, + } + config_entry = MockConfigEntry( + domain=DOMAIN, + source=SOURCE_USER, + unique_id=f"{entry_data[CONF_LATITUDE], entry_data[CONF_LONGITUDE]}", + title=entry_data[CONF_CITY], + data=entry_data, + ) + config_entry.add_to_hass(hass) + return config_entry diff --git a/tests/components/meteo_france/fixtures/raw_forecast.json b/tests/components/meteo_france/fixtures/raw_forecast.json new file mode 100644 index 00000000000..3c0552136d2 --- /dev/null +++ b/tests/components/meteo_france/fixtures/raw_forecast.json @@ -0,0 +1,53 @@ +{ + "updated_on": 1737995400, + "position": { + "country": "FR - France", + "dept": "74", + "insee": "74080", + "lat": 45.90417, + "lon": 6.42306, + "name": "La Clusaz", + "rain_product_available": 1, + "timezone": "Europe/Paris" + }, + "daily_forecast": [ + { + "T": { "max": 10.4, "min": 6.9, "sea": null }, + "dt": 1737936000, + "humidity": { "max": 90, "min": 65 }, + "precipitation": { "24h": 1.3 }, + "sun": { "rise": 1737963392, "set": 1737996163 }, + "uv": 1, + "weather12H": { "desc": "Eclaircies", "icon": "p2j" } + } + ], + "forecast": [ + { + "T": { "value": 9.1, "windchill": 5.4 }, + "clouds": 70, + "dt": 1737990000, + "humidity": 75, + "iso0": 1250, + "rain": { "1h": 0 }, + "rain snow limit": "Non pertinent", + "sea_level": 988.7, + "snow": { "1h": 0 }, + "uv": 1, + "weather": { "desc": "Eclaircies", "icon": "p2j" }, + "wind": { + "direction": 200, + "gust": 18, + "icon": "SSO", + "speed": 8 + } + } + ], + "probability_forecast": [ + { + "dt": 1737990000, + "freezing": 0, + "rain": { "3h": null, "6h": null }, + "snow": { "3h": null, "6h": null } + } + ] +} diff --git a/tests/components/meteo_france/fixtures/raw_rain.json b/tests/components/meteo_france/fixtures/raw_rain.json new file mode 100644 index 00000000000..a9f17b8a98e --- /dev/null +++ b/tests/components/meteo_france/fixtures/raw_rain.json @@ -0,0 +1,24 @@ +{ + "position": { + "lat": 48.807166, + "lon": 2.239895, + "alti": 76, + "name": "Meudon", + "country": "FR - France", + "dept": "92", + "timezone": "Europe/Paris" + }, + "updated_on": 1589995200, + "quality": 0, + "forecast": [ + { "dt": 1589996100, "rain": 1, "desc": "Temps sec" }, + { "dt": 1589996400, "rain": 1, "desc": "Temps sec" }, + { "dt": 1589996700, "rain": 1, "desc": "Temps sec" }, + { "dt": 1589997000, "rain": 2, "desc": "Pluie faible" }, + { "dt": 1589997300, "rain": 3, "desc": "Pluie modérée" }, + { "dt": 1589997600, "rain": 2, "desc": "Pluie faible" }, + { "dt": 1589998200, "rain": 1, "desc": "Temps sec" }, + { "dt": 1589998800, "rain": 1, "desc": "Temps sec" }, + { "dt": 1589999400, "rain": 1, "desc": "Temps sec" } + ] +} diff --git a/tests/components/meteo_france/fixtures/raw_warning_current_phenomenoms.json b/tests/components/meteo_france/fixtures/raw_warning_current_phenomenoms.json new file mode 100644 index 00000000000..8d84e512fb6 --- /dev/null +++ b/tests/components/meteo_france/fixtures/raw_warning_current_phenomenoms.json @@ -0,0 +1,13 @@ +{ + "update_time": 1591279200, + "end_validity_time": 1591365600, + "domain_id": "32", + "phenomenons_max_colors": [ + { "phenomenon_id": "6", "phenomenon_max_color_id": 1 }, + { "phenomenon_id": "4", "phenomenon_max_color_id": 1 }, + { "phenomenon_id": "5", "phenomenon_max_color_id": 3 }, + { "phenomenon_id": "2", "phenomenon_max_color_id": 1 }, + { "phenomenon_id": "1", "phenomenon_max_color_id": 1 }, + { "phenomenon_id": "3", "phenomenon_max_color_id": 2 } + ] +} diff --git a/tests/components/meteo_france/snapshots/test_sensor.ambr b/tests/components/meteo_france/snapshots/test_sensor.ambr new file mode 100644 index 00000000000..85fdec0fcea --- /dev/null +++ b/tests/components/meteo_france/snapshots/test_sensor.ambr @@ -0,0 +1,764 @@ +# serializer version: 1 +# name: test_sensor[sensor.32_weather_alert-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.32_weather_alert', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:weather-cloudy-alert', + 'original_name': '32 Weather alert', + 'platform': 'meteo_france', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '32 Weather alert', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor[sensor.32_weather_alert-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'Canicule': 'Vert', + 'Inondation': 'Vert', + 'Neige-verglas': 'Orange', + 'Orages': 'Jaune', + 'Pluie-inondation': 'Vert', + 'Vent violent': 'Vert', + 'attribution': 'Data provided by Météo-France', + 'friendly_name': '32 Weather alert', + 'icon': 'mdi:weather-cloudy-alert', + }), + 'context': , + 'entity_id': 'sensor.32_weather_alert', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'Orange', + }) +# --- +# name: test_sensor[sensor.la_clusaz_cloud_cover-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.la_clusaz_cloud_cover', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:weather-partly-cloudy', + 'original_name': 'La Clusaz Cloud cover', + 'platform': 'meteo_france', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '45.90417,6.42306_cloud', + 'unit_of_measurement': '%', + }) +# --- +# name: test_sensor[sensor.la_clusaz_cloud_cover-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by Météo-France', + 'friendly_name': 'La Clusaz Cloud cover', + 'icon': 'mdi:weather-partly-cloudy', + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.la_clusaz_cloud_cover', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '70', + }) +# --- +# name: test_sensor[sensor.la_clusaz_daily_original_condition-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.la_clusaz_daily_original_condition', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'La Clusaz Daily original condition', + 'platform': 'meteo_france', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '45.90417,6.42306_daily_original_condition', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor[sensor.la_clusaz_daily_original_condition-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by Météo-France', + 'friendly_name': 'La Clusaz Daily original condition', + }), + 'context': , + 'entity_id': 'sensor.la_clusaz_daily_original_condition', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'Eclaircies', + }) +# --- +# name: test_sensor[sensor.la_clusaz_daily_precipitation-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.la_clusaz_daily_precipitation', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'La Clusaz Daily precipitation', + 'platform': 'meteo_france', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '45.90417,6.42306_precipitation', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[sensor.la_clusaz_daily_precipitation-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by Météo-France', + 'device_class': 'precipitation', + 'friendly_name': 'La Clusaz Daily precipitation', + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.la_clusaz_daily_precipitation', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '1.3', + }) +# --- +# name: test_sensor[sensor.la_clusaz_freeze_chance-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.la_clusaz_freeze_chance', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:snowflake', + 'original_name': 'La Clusaz Freeze chance', + 'platform': 'meteo_france', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '45.90417,6.42306_freeze_chance', + 'unit_of_measurement': '%', + }) +# --- +# name: test_sensor[sensor.la_clusaz_freeze_chance-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by Météo-France', + 'friendly_name': 'La Clusaz Freeze chance', + 'icon': 'mdi:snowflake', + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.la_clusaz_freeze_chance', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0', + }) +# --- +# name: test_sensor[sensor.la_clusaz_humidity-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.la_clusaz_humidity', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'La Clusaz Humidity', + 'platform': 'meteo_france', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '45.90417,6.42306_humidity', + 'unit_of_measurement': '%', + }) +# --- +# name: test_sensor[sensor.la_clusaz_humidity-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by Météo-France', + 'device_class': 'humidity', + 'friendly_name': 'La Clusaz Humidity', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.la_clusaz_humidity', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '75', + }) +# --- +# name: test_sensor[sensor.la_clusaz_original_condition-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.la_clusaz_original_condition', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'La Clusaz Original condition', + 'platform': 'meteo_france', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '45.90417,6.42306_original_condition', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor[sensor.la_clusaz_original_condition-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by Météo-France', + 'friendly_name': 'La Clusaz Original condition', + }), + 'context': , + 'entity_id': 'sensor.la_clusaz_original_condition', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'Eclaircies', + }) +# --- +# name: test_sensor[sensor.la_clusaz_pressure-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.la_clusaz_pressure', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'La Clusaz Pressure', + 'platform': 'meteo_france', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '45.90417,6.42306_pressure', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[sensor.la_clusaz_pressure-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by Météo-France', + 'device_class': 'pressure', + 'friendly_name': 'La Clusaz Pressure', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.la_clusaz_pressure', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '988.7', + }) +# --- +# name: test_sensor[sensor.la_clusaz_rain_chance-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.la_clusaz_rain_chance', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:weather-rainy', + 'original_name': 'La Clusaz Rain chance', + 'platform': 'meteo_france', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '45.90417,6.42306_rain_chance', + 'unit_of_measurement': '%', + }) +# --- +# name: test_sensor[sensor.la_clusaz_rain_chance-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by Météo-France', + 'friendly_name': 'La Clusaz Rain chance', + 'icon': 'mdi:weather-rainy', + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.la_clusaz_rain_chance', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_sensor[sensor.la_clusaz_snow_chance-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.la_clusaz_snow_chance', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:weather-snowy', + 'original_name': 'La Clusaz Snow chance', + 'platform': 'meteo_france', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '45.90417,6.42306_snow_chance', + 'unit_of_measurement': '%', + }) +# --- +# name: test_sensor[sensor.la_clusaz_snow_chance-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by Météo-France', + 'friendly_name': 'La Clusaz Snow chance', + 'icon': 'mdi:weather-snowy', + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.la_clusaz_snow_chance', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_sensor[sensor.la_clusaz_temperature-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.la_clusaz_temperature', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'La Clusaz Temperature', + 'platform': 'meteo_france', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '45.90417,6.42306_temperature', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[sensor.la_clusaz_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by Météo-France', + 'device_class': 'temperature', + 'friendly_name': 'La Clusaz Temperature', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.la_clusaz_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '9.1', + }) +# --- +# name: test_sensor[sensor.la_clusaz_uv-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.la_clusaz_uv', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:sunglasses', + 'original_name': 'La Clusaz UV', + 'platform': 'meteo_france', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '45.90417,6.42306_uv', + 'unit_of_measurement': 'UV index', + }) +# --- +# name: test_sensor[sensor.la_clusaz_uv-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by Météo-France', + 'friendly_name': 'La Clusaz UV', + 'icon': 'mdi:sunglasses', + 'unit_of_measurement': 'UV index', + }), + 'context': , + 'entity_id': 'sensor.la_clusaz_uv', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '1', + }) +# --- +# name: test_sensor[sensor.la_clusaz_wind_gust-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.la_clusaz_wind_gust', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': 'mdi:weather-windy-variant', + 'original_name': 'La Clusaz Wind gust', + 'platform': 'meteo_france', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '45.90417,6.42306_wind_gust', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[sensor.la_clusaz_wind_gust-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by Météo-France', + 'device_class': 'wind_speed', + 'friendly_name': 'La Clusaz Wind gust', + 'icon': 'mdi:weather-windy-variant', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.la_clusaz_wind_gust', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '65', + }) +# --- +# name: test_sensor[sensor.la_clusaz_wind_speed-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.la_clusaz_wind_speed', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'La Clusaz Wind speed', + 'platform': 'meteo_france', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '45.90417,6.42306_wind_speed', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[sensor.la_clusaz_wind_speed-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by Météo-France', + 'device_class': 'wind_speed', + 'friendly_name': 'La Clusaz Wind speed', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.la_clusaz_wind_speed', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '29', + }) +# --- +# name: test_sensor[sensor.meudon_next_rain-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.meudon_next_rain', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Meudon Next rain', + 'platform': 'meteo_france', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '48.807166,2.239895_next_rain', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor[sensor.meudon_next_rain-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + '1_hour_forecast': dict({ + '0 min': 'Temps sec', + '10 min': 'Temps sec', + '15 min': 'Pluie faible', + '20 min': 'Pluie modérée', + '25 min': 'Pluie faible', + '35 min': 'Temps sec', + '45 min': 'Temps sec', + '5 min': 'Temps sec', + '55 min': 'Temps sec', + }), + 'attribution': 'Data provided by Météo-France', + 'device_class': 'timestamp', + 'forecast_time_ref': '2020-05-20T17:35:00+00:00', + 'friendly_name': 'Meudon Next rain', + }), + 'context': , + 'entity_id': 'sensor.meudon_next_rain', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '2020-05-20T17:50:00+00:00', + }) +# --- diff --git a/tests/components/meteo_france/snapshots/test_weather.ambr b/tests/components/meteo_france/snapshots/test_weather.ambr new file mode 100644 index 00000000000..9e7d7631479 --- /dev/null +++ b/tests/components/meteo_france/snapshots/test_weather.ambr @@ -0,0 +1,59 @@ +# serializer version: 1 +# name: test_weather[weather.la_clusaz-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'weather', + 'entity_category': None, + 'entity_id': 'weather.la_clusaz', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'La Clusaz', + 'platform': 'meteo_france', + 'previous_unique_id': None, + 'supported_features': , + 'translation_key': None, + 'unique_id': '45.90417,6.42306', + 'unit_of_measurement': None, + }) +# --- +# name: test_weather[weather.la_clusaz-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by Météo-France', + 'friendly_name': 'La Clusaz', + 'humidity': 75, + 'precipitation_unit': , + 'pressure': 988.7, + 'pressure_unit': , + 'supported_features': , + 'temperature': 9.1, + 'temperature_unit': , + 'visibility_unit': , + 'wind_bearing': 200, + 'wind_speed': 28.8, + 'wind_speed_unit': , + }), + 'context': , + 'entity_id': 'weather.la_clusaz', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'partlycloudy', + }) +# --- diff --git a/tests/components/meteo_france/test_sensor.py b/tests/components/meteo_france/test_sensor.py new file mode 100644 index 00000000000..be77de0008b --- /dev/null +++ b/tests/components/meteo_france/test_sensor.py @@ -0,0 +1,32 @@ +"""Test Météo France weather entity.""" + +from collections.abc import Generator +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.fixture(autouse=True) +def override_platforms() -> Generator[None]: + """Override PLATFORMS.""" + with patch("homeassistant.components.meteo_france.PLATFORMS", [Platform.SENSOR]): + yield + + +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +async def test_sensor( + hass: HomeAssistant, + config_entry: MockConfigEntry, + entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, +) -> None: + """Test the sensor entity.""" + await hass.config_entries.async_setup(config_entry.entry_id) + await snapshot_platform(hass, entity_registry, snapshot, config_entry.entry_id) diff --git a/tests/components/meteo_france/test_weather.py b/tests/components/meteo_france/test_weather.py new file mode 100644 index 00000000000..cd55ac31b27 --- /dev/null +++ b/tests/components/meteo_france/test_weather.py @@ -0,0 +1,31 @@ +"""Test Météo France weather entity.""" + +from collections.abc import Generator +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.fixture(autouse=True) +def override_platforms() -> Generator[None]: + """Override PLATFORMS.""" + with patch("homeassistant.components.meteo_france.PLATFORMS", [Platform.WEATHER]): + yield + + +async def test_weather( + hass: HomeAssistant, + config_entry: MockConfigEntry, + entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, +) -> None: + """Test the weather entity.""" + await hass.config_entries.async_setup(config_entry.entry_id) + await snapshot_platform(hass, entity_registry, snapshot, config_entry.entry_id) diff --git a/tests/components/modbus/test_climate.py b/tests/components/modbus/test_climate.py index b5bc9b02808..3c30efe9dce 100644 --- a/tests/components/modbus/test_climate.py +++ b/tests/components/modbus/test_climate.py @@ -58,6 +58,7 @@ from homeassistant.components.modbus.const import ( CONF_HVAC_MODE_VALUES, CONF_HVAC_OFF_VALUE, CONF_HVAC_ON_VALUE, + CONF_HVAC_ONOFF_COIL, CONF_HVAC_ONOFF_REGISTER, CONF_MAX_TEMP, CONF_MIN_TEMP, @@ -366,6 +367,29 @@ async def test_config_hvac_onoff_register(hass: HomeAssistant, mock_modbus) -> N assert HVACMode.AUTO in state.attributes[ATTR_HVAC_MODES] +@pytest.mark.parametrize( + "do_config", + [ + { + CONF_CLIMATES: [ + { + CONF_NAME: TEST_ENTITY_NAME, + CONF_TARGET_TEMP: 117, + CONF_ADDRESS: 117, + CONF_SLAVE: 10, + CONF_HVAC_ONOFF_REGISTER: 11, + } + ], + }, + ], +) +async def test_config_hvac_onoff_coil(hass: HomeAssistant, mock_modbus) -> None: + """Run configuration test for On/Off coil.""" + state = hass.states.get(ENTITY_ID) + assert HVACMode.OFF in state.attributes[ATTR_HVAC_MODES] + assert HVACMode.AUTO in state.attributes[ATTR_HVAC_MODES] + + @pytest.mark.parametrize( "do_config", [ @@ -407,6 +431,45 @@ async def test_hvac_onoff_values(hass: HomeAssistant, mock_modbus) -> None: mock_modbus.write_register.assert_called_with(11, value=0xFF, slave=10) +@pytest.mark.parametrize( + "do_config", + [ + { + CONF_CLIMATES: [ + { + CONF_NAME: TEST_ENTITY_NAME, + CONF_TARGET_TEMP: 117, + CONF_ADDRESS: 117, + CONF_SLAVE: 10, + CONF_HVAC_ONOFF_COIL: 11, + } + ], + }, + ], +) +async def test_hvac_onoff_coil(hass: HomeAssistant, mock_modbus) -> None: + """Run configuration test for On/Off coil values.""" + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: ENTITY_ID}, + blocking=True, + ) + await hass.async_block_till_done() + + mock_modbus.write_coil.assert_called_with(11, value=1, slave=10) + + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_TURN_OFF, + {ATTR_ENTITY_ID: ENTITY_ID}, + blocking=True, + ) + await hass.async_block_till_done() + + mock_modbus.write_coil.assert_called_with(11, value=0, slave=10) + + @pytest.mark.parametrize( "do_config", [ @@ -562,6 +625,126 @@ async def test_service_climate_update( assert hass.states.get(ENTITY_ID).state == result +@pytest.mark.parametrize( + ("do_config", "result", "register_words", "coil_value"), + [ + ( + { + CONF_CLIMATES: [ + { + CONF_NAME: TEST_ENTITY_NAME, + CONF_TARGET_TEMP: [130, 131, 132, 133, 134, 135, 136], + CONF_ADDRESS: 117, + CONF_SLAVE: 10, + CONF_SCAN_INTERVAL: 0, + CONF_DATA_TYPE: DataType.INT32, + CONF_HVAC_MODE_REGISTER: { + CONF_ADDRESS: 118, + CONF_HVAC_MODE_VALUES: { + CONF_HVAC_MODE_COOL: 0, + CONF_HVAC_MODE_HEAT: 1, + CONF_HVAC_MODE_DRY: 2, + }, + }, + CONF_HVAC_ONOFF_COIL: 11, + }, + ] + }, + HVACMode.COOL, + [0x00], + [0x01], + ), + ( + { + CONF_CLIMATES: [ + { + CONF_NAME: TEST_ENTITY_NAME, + CONF_TARGET_TEMP: 119, + CONF_ADDRESS: 117, + CONF_SLAVE: 10, + CONF_SCAN_INTERVAL: 0, + CONF_DATA_TYPE: DataType.INT32, + CONF_HVAC_MODE_REGISTER: { + CONF_ADDRESS: 118, + CONF_HVAC_MODE_VALUES: { + CONF_HVAC_MODE_COOL: 0, + CONF_HVAC_MODE_HEAT: 1, + CONF_HVAC_MODE_DRY: 2, + }, + }, + CONF_HVAC_ONOFF_COIL: 11, + }, + ] + }, + HVACMode.HEAT, + [0x01], + [0x01], + ), + ( + { + CONF_CLIMATES: [ + { + CONF_NAME: TEST_ENTITY_NAME, + CONF_TARGET_TEMP: 120, + CONF_ADDRESS: 117, + CONF_SLAVE: 10, + CONF_SCAN_INTERVAL: 0, + CONF_DATA_TYPE: DataType.INT32, + CONF_HVAC_MODE_REGISTER: { + CONF_ADDRESS: 118, + CONF_HVAC_MODE_VALUES: { + CONF_HVAC_MODE_COOL: 0, + CONF_HVAC_MODE_HEAT: 2, + CONF_HVAC_MODE_DRY: 3, + }, + }, + CONF_HVAC_ONOFF_COIL: 11, + }, + ] + }, + HVACMode.OFF, + [0x00], + [0x00], + ), + ( + { + CONF_CLIMATES: [ + { + CONF_NAME: TEST_ENTITY_NAME, + CONF_TARGET_TEMP: 120, + CONF_ADDRESS: 117, + CONF_SLAVE: 10, + CONF_SCAN_INTERVAL: 0, + CONF_DATA_TYPE: DataType.INT32, + CONF_HVAC_ONOFF_COIL: 11, + }, + ] + }, + "unavailable", + [0x00], + None, + ), + ], +) +async def test_hvac_onoff_coil_update( + hass: HomeAssistant, mock_modbus_ha, result, register_words, coil_value +) -> None: + """Test climate update based on On/Off coil values.""" + mock_modbus_ha.read_holding_registers.return_value = ReadResult(register_words) + mock_modbus_ha.read_coils.return_value = ReadResult(coil_value) + + await hass.services.async_call( + HOMEASSISTANT_DOMAIN, + SERVICE_UPDATE_ENTITY, + {ATTR_ENTITY_ID: ENTITY_ID}, + blocking=True, + ) + await hass.async_block_till_done() + + state = hass.states.get(ENTITY_ID) + assert state.state == result + + @pytest.mark.parametrize( ("do_config", "result", "register_words"), [ diff --git a/tests/components/mopeka/test_config_flow.py b/tests/components/mopeka/test_config_flow.py index 7a341052f22..d2887451629 100644 --- a/tests/components/mopeka/test_config_flow.py +++ b/tests/components/mopeka/test_config_flow.py @@ -81,6 +81,39 @@ async def test_async_step_user_with_found_devices(hass: HomeAssistant) -> None: assert result2["result"].unique_id == "aa:bb:cc:dd:ee:ff" +async def test_async_step_user_replace_ignored(hass: HomeAssistant) -> None: + """Test setup from service info can replace an ignored entry.""" + entry = MockConfigEntry( + domain=DOMAIN, + unique_id=PRO_SERVICE_INFO.address, + data={}, + source=config_entries.SOURCE_IGNORE, + ) + entry.add_to_hass(hass) + with patch( + "homeassistant.components.mopeka.config_flow.async_discovered_service_info", + return_value=[PRO_SERVICE_INFO], + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_USER}, + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + with patch("homeassistant.components.mopeka.async_setup_entry", return_value=True): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={"address": "aa:bb:cc:dd:ee:ff"}, + ) + assert result2["type"] is FlowResultType.CREATE_ENTRY + assert result2["title"] == "Pro Plus EEFF" + assert CONF_MEDIUM_TYPE in result2["data"] + assert result2["data"][CONF_MEDIUM_TYPE] in [ + medium_type.value for medium_type in MediumType + ] + assert result2["result"].unique_id == "aa:bb:cc:dd:ee:ff" + + async def test_async_step_user_device_added_between_steps(hass: HomeAssistant) -> None: """Test the device gets added via another flow between steps.""" with patch( diff --git a/tests/components/mqtt/conftest.py b/tests/components/mqtt/conftest.py index 2a1e4012f51..87bbcecebe5 100644 --- a/tests/components/mqtt/conftest.py +++ b/tests/components/mqtt/conftest.py @@ -38,7 +38,7 @@ def temp_dir_prefix() -> str: return "test" -@pytest.fixture +@pytest.fixture(autouse=True) def mock_temp_dir(temp_dir_prefix: str) -> Generator[str]: """Mock the certificate temp directory.""" with patch( diff --git a/tests/components/mqtt/test_client.py b/tests/components/mqtt/test_client.py index ad64b39a480..2faa9310548 100644 --- a/tests/components/mqtt/test_client.py +++ b/tests/components/mqtt/test_client.py @@ -2082,7 +2082,7 @@ async def test_server_sock_buffer_size_with_websocket( client.setblocking(False) server.setblocking(False) - class FakeWebsocket(paho_mqtt.WebsocketWrapper): + class FakeWebsocket(paho_mqtt._WebsocketWrapper): def _do_handshake(self, *args, **kwargs): pass diff --git a/tests/components/mqtt/test_config_flow.py b/tests/components/mqtt/test_config_flow.py index 072998f9b8d..1a4ca4bcf19 100644 --- a/tests/components/mqtt/test_config_flow.py +++ b/tests/components/mqtt/test_config_flow.py @@ -2193,6 +2193,61 @@ async def test_reconfigure_flow_form( await hass.async_block_till_done(wait_background_tasks=True) +@pytest.mark.usefixtures("mock_ssl_context", "mock_process_uploaded_file") +@pytest.mark.parametrize( + "mqtt_config_entry_data", + [ + { + mqtt.CONF_BROKER: "test-broker", + CONF_USERNAME: "mqtt-user", + CONF_PASSWORD: "mqtt-password", + CONF_PORT: 1234, + mqtt.CONF_TRANSPORT: "websockets", + mqtt.CONF_WS_HEADERS: {"header_1": "custom_header1"}, + mqtt.CONF_WS_PATH: "/some_path", + } + ], +) +async def test_reconfigure_no_changed_password( + hass: HomeAssistant, + mock_try_connection: MagicMock, + mqtt_mock_entry: MqttMockHAClientGenerator, +) -> None: + """Test reconfigure flow.""" + await mqtt_mock_entry() + entry: MockConfigEntry = hass.config_entries.async_entries(mqtt.DOMAIN)[0] + result = await entry.start_reconfigure_flow(hass, show_advanced_options=True) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "broker" + assert result["errors"] == {} + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ + mqtt.CONF_BROKER: "10.10.10,10", + CONF_USERNAME: "mqtt-user", + CONF_PASSWORD: PWD_NOT_CHANGED, + CONF_PORT: 1234, + mqtt.CONF_TRANSPORT: "websockets", + mqtt.CONF_WS_HEADERS: '{"header_1": "custom_header1"}', + mqtt.CONF_WS_PATH: "/some_new_path", + }, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reconfigure_successful" + assert entry.data == { + mqtt.CONF_BROKER: "10.10.10,10", + CONF_USERNAME: "mqtt-user", + CONF_PASSWORD: "mqtt-password", + CONF_PORT: 1234, + mqtt.CONF_TRANSPORT: "websockets", + mqtt.CONF_WS_HEADERS: {"header_1": "custom_header1"}, + mqtt.CONF_WS_PATH: "/some_new_path", + } + await hass.async_block_till_done(wait_background_tasks=True) + + @pytest.mark.parametrize( ( "version", diff --git a/tests/components/mqtt/test_light_json.py b/tests/components/mqtt/test_light_json.py index 512e4091438..7ddd04a09a6 100644 --- a/tests/components/mqtt/test_light_json.py +++ b/tests/components/mqtt/test_light_json.py @@ -100,7 +100,6 @@ from homeassistant.const import ( STATE_UNKNOWN, ) from homeassistant.core import HomeAssistant, State -from homeassistant.helpers.json import json_dumps from homeassistant.util.json import json_loads from .test_common import ( @@ -195,172 +194,6 @@ async def test_fail_setup_if_no_command_topic( assert "required key not provided" in caplog.text -@pytest.mark.parametrize( - "hass_config", - [ - help_custom_config(light.DOMAIN, COLOR_MODES_CONFIG, ({"color_temp": True},)), - help_custom_config(light.DOMAIN, COLOR_MODES_CONFIG, ({"hs": True},)), - help_custom_config(light.DOMAIN, COLOR_MODES_CONFIG, ({"rgb": True},)), - help_custom_config(light.DOMAIN, COLOR_MODES_CONFIG, ({"xy": True},)), - ], -) -async def test_fail_setup_if_color_mode_deprecated( - mqtt_mock_entry: MqttMockHAClientGenerator, - caplog: pytest.LogCaptureFixture, -) -> None: - """Test if setup fails if color mode is combined with deprecated config keys.""" - assert await mqtt_mock_entry() - assert "supported_color_modes must not be combined with any of" in caplog.text - - -@pytest.mark.parametrize( - ("hass_config", "color_modes"), - [ - ( - help_custom_config(light.DOMAIN, DEFAULT_CONFIG, ({"color_temp": True},)), - ("color_temp",), - ), - (help_custom_config(light.DOMAIN, DEFAULT_CONFIG, ({"hs": True},)), ("hs",)), - (help_custom_config(light.DOMAIN, DEFAULT_CONFIG, ({"rgb": True},)), ("rgb",)), - (help_custom_config(light.DOMAIN, DEFAULT_CONFIG, ({"xy": True},)), ("xy",)), - ( - help_custom_config( - light.DOMAIN, DEFAULT_CONFIG, ({"color_temp": True, "rgb": True},) - ), - ("color_temp, rgb", "rgb, color_temp"), - ), - ], - ids=["color_temp", "hs", "rgb", "xy", "color_temp, rgb"], -) -async def test_warning_if_color_mode_flags_are_used( - mqtt_mock_entry: MqttMockHAClientGenerator, - caplog: pytest.LogCaptureFixture, - color_modes: tuple[str, ...], -) -> None: - """Test warnings deprecated config keys without supported color modes defined.""" - with patch( - "homeassistant.components.mqtt.light.schema_json.async_create_issue" - ) as mock_async_create_issue: - assert await mqtt_mock_entry() - assert any( - ( - f"Deprecated flags [{color_modes_case}] used in MQTT JSON light config " - "for handling color mode, please use `supported_color_modes` instead." - in caplog.text - ) - for color_modes_case in color_modes - ) - mock_async_create_issue.assert_called_once() - - -@pytest.mark.parametrize( - ("config", "color_modes"), - [ - ( - help_custom_config(light.DOMAIN, DEFAULT_CONFIG, ({"color_temp": True},)), - ("color_temp",), - ), - (help_custom_config(light.DOMAIN, DEFAULT_CONFIG, ({"hs": True},)), ("hs",)), - (help_custom_config(light.DOMAIN, DEFAULT_CONFIG, ({"rgb": True},)), ("rgb",)), - (help_custom_config(light.DOMAIN, DEFAULT_CONFIG, ({"xy": True},)), ("xy",)), - ( - help_custom_config( - light.DOMAIN, DEFAULT_CONFIG, ({"color_temp": True, "rgb": True},) - ), - ("color_temp, rgb", "rgb, color_temp"), - ), - ], - ids=["color_temp", "hs", "rgb", "xy", "color_temp, rgb"], -) -async def test_warning_on_discovery_if_color_mode_flags_are_used( - hass: HomeAssistant, - mqtt_mock_entry: MqttMockHAClientGenerator, - caplog: pytest.LogCaptureFixture, - config: dict[str, Any], - color_modes: tuple[str, ...], -) -> None: - """Test warnings deprecated config keys with discovery.""" - with patch( - "homeassistant.components.mqtt.light.schema_json.async_create_issue" - ) as mock_async_create_issue: - assert await mqtt_mock_entry() - - config_payload = json_dumps(config[mqtt.DOMAIN][light.DOMAIN][0]) - async_fire_mqtt_message( - hass, - "homeassistant/light/bla/config", - config_payload, - ) - await hass.async_block_till_done() - assert any( - ( - f"Deprecated flags [{color_modes_case}] used in MQTT JSON light config " - "for handling color mode, please " - "use `supported_color_modes` instead" in caplog.text - ) - for color_modes_case in color_modes - ) - mock_async_create_issue.assert_not_called() - - -@pytest.mark.parametrize( - "hass_config", - [ - help_custom_config( - light.DOMAIN, - DEFAULT_CONFIG, - ({"color_mode": True, "supported_color_modes": ["color_temp"]},), - ), - ], - ids=["color_temp"], -) -async def test_warning_if_color_mode_option_flag_is_used( - mqtt_mock_entry: MqttMockHAClientGenerator, - caplog: pytest.LogCaptureFixture, -) -> None: - """Test warning deprecated color_mode option flag is used.""" - with patch( - "homeassistant.components.mqtt.light.schema_json.async_create_issue" - ) as mock_async_create_issue: - assert await mqtt_mock_entry() - assert "Deprecated flag `color_mode` used in MQTT JSON light config" in caplog.text - mock_async_create_issue.assert_called_once() - - -@pytest.mark.parametrize( - "config", - [ - help_custom_config( - light.DOMAIN, - DEFAULT_CONFIG, - ({"color_mode": True, "supported_color_modes": ["color_temp"]},), - ), - ], - ids=["color_temp"], -) -async def test_warning_on_discovery_if_color_mode_option_flag_is_used( - hass: HomeAssistant, - mqtt_mock_entry: MqttMockHAClientGenerator, - caplog: pytest.LogCaptureFixture, - config: dict[str, Any], -) -> None: - """Test warning deprecated color_mode option flag is used.""" - with patch( - "homeassistant.components.mqtt.light.schema_json.async_create_issue" - ) as mock_async_create_issue: - assert await mqtt_mock_entry() - - config_payload = json_dumps(config[mqtt.DOMAIN][light.DOMAIN][0]) - async_fire_mqtt_message( - hass, - "homeassistant/light/bla/config", - config_payload, - ) - await hass.async_block_till_done() - assert "Deprecated flag `color_mode` used in MQTT JSON light config" in caplog.text - mock_async_create_issue.assert_not_called() - - @pytest.mark.parametrize( ("hass_config", "error"), [ @@ -400,82 +233,6 @@ async def test_fail_setup_if_color_modes_invalid( assert error in caplog.text -@pytest.mark.parametrize( - ("hass_config", "kelvin", "color_temp_payload_value"), - [ - ( - { - mqtt.DOMAIN: { - light.DOMAIN: { - "schema": "json", - "name": "test", - "command_topic": "test_light/set", - "state_topic": "test_light", - "color_mode": True, - "color_temp_kelvin": False, - "supported_color_modes": "color_temp", - } - } - }, - 5208, - 192, - ), - ( - { - mqtt.DOMAIN: { - light.DOMAIN: { - "schema": "json", - "name": "test", - "command_topic": "test_light/set", - "state_topic": "test_light", - "color_mode": True, - "color_temp_kelvin": True, - "supported_color_modes": "color_temp", - } - } - }, - 5208, - 5208, - ), - ], - ids=["mireds", "kelvin"], -) -async def test_single_color_mode( - hass: HomeAssistant, - mqtt_mock_entry: MqttMockHAClientGenerator, - kelvin: int, - color_temp_payload_value: int, -) -> None: - """Test setup with single color_mode.""" - await mqtt_mock_entry() - state = hass.states.get("light.test") - assert state.state == STATE_UNKNOWN - - await common.async_turn_on( - hass, "light.test", brightness=50, color_temp_kelvin=kelvin - ) - - payload = { - "state": "ON", - "brightness": 50, - "color_mode": "color_temp", - "color_temp": color_temp_payload_value, - } - async_fire_mqtt_message( - hass, - "test_light", - json_dumps(payload), - ) - color_modes = [light.ColorMode.COLOR_TEMP] - state = hass.states.get("light.test") - assert state.state == STATE_ON - - assert state.attributes.get(light.ATTR_SUPPORTED_COLOR_MODES) == color_modes - assert state.attributes.get(light.ATTR_COLOR_TEMP_KELVIN) == 5208 - assert state.attributes.get(light.ATTR_BRIGHTNESS) == 50 - assert state.attributes.get(light.ATTR_COLOR_MODE) == color_modes[0] - - @pytest.mark.parametrize("hass_config", [COLOR_MODES_CONFIG]) async def test_turn_on_with_unknown_color_mode_optimistic( hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator @@ -550,34 +307,6 @@ async def test_controlling_state_with_unknown_color_mode( assert state.attributes.get(light.ATTR_COLOR_MODE) == light.ColorMode.COLOR_TEMP -@pytest.mark.parametrize( - "hass_config", - [ - { - mqtt.DOMAIN: { - light.DOMAIN: { - "schema": "json", - "name": "test", - "command_topic": "test_light_rgb/set", - "rgb": True, - } - } - } - ], -) -async def test_legacy_rgb_light( - hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator -) -> None: - """Test legacy RGB light flags expected features and color modes.""" - await mqtt_mock_entry() - - state = hass.states.get("light.test") - color_modes = [light.ColorMode.HS] - assert state.attributes.get(light.ATTR_SUPPORTED_COLOR_MODES) == color_modes - expected_features = light.SUPPORT_FLASH | light.SUPPORT_TRANSITION - assert state.attributes.get(ATTR_SUPPORTED_FEATURES) == expected_features - - @pytest.mark.parametrize( "hass_config", [ @@ -642,203 +371,9 @@ async def test_no_color_brightness_color_temp_if_no_topics( "name": "test", "state_topic": "test_light_rgb", "command_topic": "test_light_rgb/set", - "brightness": True, - "color_temp": True, - "effect": True, - "rgb": True, - "xy": True, - "hs": True, - "qos": "0", - } - } - } - ], -) -async def test_controlling_state_via_topic( - hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator -) -> None: - """Test the controlling of the state via topic.""" - await mqtt_mock_entry() - - state = hass.states.get("light.test") - assert state.state == STATE_UNKNOWN - color_modes = [light.ColorMode.COLOR_TEMP, light.ColorMode.HS] - assert state.attributes.get(light.ATTR_SUPPORTED_COLOR_MODES) == color_modes - expected_features = ( - light.SUPPORT_EFFECT | light.SUPPORT_FLASH | light.SUPPORT_TRANSITION - ) - assert state.attributes.get(ATTR_SUPPORTED_FEATURES) == expected_features - assert state.attributes.get("rgb_color") is None - assert state.attributes.get("brightness") is None - assert state.attributes.get("color_temp_kelvin") is None - assert state.attributes.get("effect") is None - assert state.attributes.get("xy_color") is None - assert state.attributes.get("hs_color") is None - assert not state.attributes.get(ATTR_ASSUMED_STATE) - - # Turn on the light - async_fire_mqtt_message( - hass, - "test_light_rgb", - '{"state":"ON",' - '"color":{"r":255,"g":255,"b":255},' - '"brightness":255,' - '"color_temp":155,' - '"effect":"colorloop"}', - ) - - state = hass.states.get("light.test") - assert state.state == STATE_ON - assert state.attributes.get("rgb_color") == (255, 255, 255) - assert state.attributes.get("brightness") == 255 - assert state.attributes.get("color_temp_kelvin") is None # rgb color has priority - assert state.attributes.get("effect") == "colorloop" - assert state.attributes.get("xy_color") == (0.323, 0.329) - assert state.attributes.get("hs_color") == (0.0, 0.0) - - # Turn on the light - async_fire_mqtt_message( - hass, - "test_light_rgb", - '{"state":"ON",' - '"brightness":255,' - '"color":null,' - '"color_temp":155,' - '"effect":"colorloop"}', - ) - - state = hass.states.get("light.test") - assert state.state == STATE_ON - assert state.attributes.get("rgb_color") == ( - 255, - 253, - 249, - ) # temp converted to color - assert state.attributes.get("brightness") == 255 - assert state.attributes.get("color_temp_kelvin") == 6451 - assert state.attributes.get("effect") == "colorloop" - assert state.attributes.get("xy_color") == (0.328, 0.333) # temp converted to color - assert state.attributes.get("hs_color") == (44.098, 2.43) # temp converted to color - - # Turn the light off - async_fire_mqtt_message(hass, "test_light_rgb", '{"state":"OFF"}') - - state = hass.states.get("light.test") - assert state.state == STATE_OFF - - async_fire_mqtt_message(hass, "test_light_rgb", '{"state":"ON", "brightness":100}') - - light_state = hass.states.get("light.test") - - assert light_state.attributes["brightness"] == 100 - - async_fire_mqtt_message( - hass, "test_light_rgb", '{"state":"ON", "color":{"r":125,"g":125,"b":125}}' - ) - - light_state = hass.states.get("light.test") - assert light_state.attributes.get("rgb_color") == (255, 255, 255) - - async_fire_mqtt_message( - hass, "test_light_rgb", '{"state":"ON", "color":{"x":0.135,"y":0.135}}' - ) - - light_state = hass.states.get("light.test") - assert light_state.attributes.get("xy_color") == (0.141, 0.141) - - async_fire_mqtt_message( - hass, "test_light_rgb", '{"state":"ON", "color":{"h":180,"s":50}}' - ) - - light_state = hass.states.get("light.test") - assert light_state.attributes.get("hs_color") == (180.0, 50.0) - - async_fire_mqtt_message(hass, "test_light_rgb", '{"state":"ON", "color":null}') - - light_state = hass.states.get("light.test") - assert "hs_color" in light_state.attributes # Color temp approximation - - async_fire_mqtt_message(hass, "test_light_rgb", '{"state":"ON", "color_temp":155}') - - light_state = hass.states.get("light.test") - assert light_state.attributes.get("color_temp_kelvin") == 6451 # 155 mired - - async_fire_mqtt_message(hass, "test_light_rgb", '{"state":"ON", "color_temp":null}') - - light_state = hass.states.get("light.test") - assert light_state.attributes.get("color_temp_kelvin") is None - - async_fire_mqtt_message( - hass, "test_light_rgb", '{"state":"ON", "effect":"colorloop"}' - ) - - light_state = hass.states.get("light.test") - assert light_state.attributes.get("effect") == "colorloop" - - async_fire_mqtt_message( - hass, - "test_light_rgb", - '{"state":"ON",' - '"color":{"r":255,"g":255,"b":255},' - '"brightness":128,' - '"color_temp":155,' - '"effect":"colorloop"}', - ) - light_state = hass.states.get("light.test") - assert light_state.state == STATE_ON - assert light_state.attributes.get("brightness") == 128 - - async_fire_mqtt_message( - hass, - "test_light_rgb", - '{"state":"OFF","brightness":0}', - ) - light_state = hass.states.get("light.test") - assert light_state.state == STATE_OFF - assert light_state.attributes.get("brightness") is None - - # Simulate the lights color temp has been changed - # while it was switched off - async_fire_mqtt_message( - hass, - "test_light_rgb", - '{"state":"OFF","color_temp":201}', - ) - light_state = hass.states.get("light.test") - assert light_state.state == STATE_OFF - # Color temp attribute is not exposed while the lamp is off - assert light_state.attributes.get("color_temp_kelvin") is None - - # test previous zero brightness received was ignored and brightness is restored - # see if the latest color_temp value received is restored - async_fire_mqtt_message(hass, "test_light_rgb", '{"state":"ON"}') - light_state = hass.states.get("light.test") - assert light_state.attributes.get("brightness") == 128 - assert light_state.attributes.get("color_temp_kelvin") == 4975 # 201 mired - - # A `0` brightness value is ignored when a light is turned on - async_fire_mqtt_message(hass, "test_light_rgb", '{"state":"ON","brightness":0}') - light_state = hass.states.get("light.test") - assert light_state.attributes.get("brightness") == 128 - - -@pytest.mark.parametrize( - "hass_config", - [ - { - mqtt.DOMAIN: { - light.DOMAIN: { - "schema": "json", - "name": "test", - "state_topic": "test_light_rgb", - "command_topic": "test_light_rgb/set", - "brightness": True, - "color_temp": True, "color_temp_kelvin": True, "effect": True, - "rgb": True, - "xy": True, - "hs": True, + "supported_color_modes": ["color_temp", "hs"], "qos": "0", } } @@ -856,9 +391,11 @@ async def test_controlling_state_color_temp_kelvin( color_modes = [light.ColorMode.COLOR_TEMP, light.ColorMode.HS] assert state.attributes.get(light.ATTR_SUPPORTED_COLOR_MODES) == color_modes expected_features = ( - light.SUPPORT_EFFECT | light.SUPPORT_FLASH | light.SUPPORT_TRANSITION + light.LightEntityFeature.EFFECT + | light.LightEntityFeature.FLASH + | light.LightEntityFeature.TRANSITION ) - assert state.attributes.get(ATTR_SUPPORTED_FEATURES) == expected_features + assert state.attributes.get(ATTR_SUPPORTED_FEATURES) is expected_features assert state.attributes.get("rgb_color") is None assert state.attributes.get("brightness") is None assert state.attributes.get("color_temp_kelvin") is None @@ -872,7 +409,8 @@ async def test_controlling_state_color_temp_kelvin( hass, "test_light_rgb", '{"state":"ON",' - '"color":{"r":255,"g":255,"b":255},' + '"color":{"h": 44.098, "s": 2.43},' + '"color_mode": "hs",' '"brightness":255,' '"color_temp":155,' '"effect":"colorloop"}', @@ -880,12 +418,12 @@ async def test_controlling_state_color_temp_kelvin( state = hass.states.get("light.test") assert state.state == STATE_ON - assert state.attributes.get("rgb_color") == (255, 255, 255) + assert state.attributes.get("rgb_color") == (255, 253, 249) assert state.attributes.get("brightness") == 255 assert state.attributes.get("color_temp_kelvin") is None # rgb color has priority assert state.attributes.get("effect") == "colorloop" - assert state.attributes.get("xy_color") == (0.323, 0.329) - assert state.attributes.get("hs_color") == (0.0, 0.0) + assert state.attributes.get("xy_color") == (0.328, 0.333) + assert state.attributes.get("hs_color") == (44.098, 2.43) # Turn on the light async_fire_mqtt_message( @@ -894,6 +432,7 @@ async def test_controlling_state_color_temp_kelvin( '{"state":"ON",' '"brightness":255,' '"color":null,' + '"color_mode":"color_temp",' '"color_temp":6451,' # Kelvin '"effect":"colorloop"}', ) @@ -920,7 +459,7 @@ async def test_controlling_state_color_temp_kelvin( ) ], ) -async def test_controlling_state_via_topic2( +async def test_controlling_state_via_topic( hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator, caplog: pytest.LogCaptureFixture, @@ -981,6 +520,11 @@ async def test_controlling_state_via_topic2( state = hass.states.get("light.test") assert state.attributes["brightness"] == 100 + # Zero brightness value is ignored + async_fire_mqtt_message(hass, "test_light_rgb", '{"state":"ON", "brightness":0}') + state = hass.states.get("light.test") + assert state.attributes["brightness"] == 100 + # RGB color async_fire_mqtt_message( hass, @@ -1083,242 +627,6 @@ async def test_controlling_state_via_topic2( { mqtt.DOMAIN: { light.DOMAIN: { - "schema": "json", - "name": "test", - "command_topic": "test_light_rgb/set", - "state_topic": "test_light_rgb/set", - "rgb": True, - "color_temp": True, - "brightness": True, - } - } - } - ], -) -async def test_controlling_the_state_with_legacy_color_handling( - hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator -) -> None: - """Test state updates for lights with a legacy color handling.""" - supported_color_modes = ["color_temp", "hs"] - await mqtt_mock_entry() - - state = hass.states.get("light.test") - assert state.state == STATE_UNKNOWN - expected_features = light.SUPPORT_FLASH | light.SUPPORT_TRANSITION - assert state.attributes.get(ATTR_SUPPORTED_FEATURES) == expected_features - assert state.attributes.get("brightness") is None - assert state.attributes.get("color_mode") is None - assert state.attributes.get("color_temp_kelvin") is None - assert state.attributes.get("effect") is None - assert state.attributes.get("hs_color") is None - assert state.attributes.get("rgb_color") is None - assert state.attributes.get("rgbw_color") is None - assert state.attributes.get("rgbww_color") is None - assert state.attributes.get("supported_color_modes") == supported_color_modes - assert state.attributes.get("xy_color") is None - assert not state.attributes.get(ATTR_ASSUMED_STATE) - - for _ in range(2): - # Returned state after the light was turned on - # Receiving legacy color mode: rgb. - async_fire_mqtt_message( - hass, - "test_light_rgb/set", - '{ "state": "ON", "brightness": 255, "level": 100, "hue": 16,' - '"saturation": 100, "color": { "r": 255, "g": 67, "b": 0 }, ' - '"bulb_mode": "color", "color_mode": "rgb" }', - ) - - state = hass.states.get("light.test") - assert state.state == STATE_ON - assert state.attributes.get("brightness") == 255 - assert state.attributes.get("color_mode") == "hs" - assert state.attributes.get("color_temp_kelvin") is None - assert state.attributes.get("effect") is None - assert state.attributes.get("hs_color") == (15.765, 100.0) - assert state.attributes.get("rgb_color") == (255, 67, 0) - assert state.attributes.get("rgbw_color") is None - assert state.attributes.get("rgbww_color") is None - assert state.attributes.get("xy_color") == (0.674, 0.322) - - # Returned state after the lights color mode was changed - # Receiving legacy color mode: color_temp - async_fire_mqtt_message( - hass, - "test_light_rgb/set", - '{ "state": "ON", "brightness": 255, "level": 100, ' - '"kelvin": 92, "color_temp": 353, "bulb_mode": "white", ' - '"color_mode": "color_temp" }', - ) - - state = hass.states.get("light.test") - assert state.state == STATE_ON - assert state.attributes.get("brightness") == 255 - assert state.attributes.get("color_mode") == "color_temp" - assert state.attributes.get("color_temp_kelvin") == 2832 - assert state.attributes.get("effect") is None - assert state.attributes.get("hs_color") == (28.125, 61.661) - assert state.attributes.get("rgb_color") == (255, 171, 98) - assert state.attributes.get("rgbw_color") is None - assert state.attributes.get("rgbww_color") is None - assert state.attributes.get("xy_color") == (0.512, 0.385) - - -@pytest.mark.parametrize( - "hass_config", - [ - { - mqtt.DOMAIN: { - light.DOMAIN: { - "schema": "json", - "name": "test", - "command_topic": "test_light_rgb/set", - "brightness": True, - "color_temp": True, - "effect": True, - "hs": True, - "rgb": True, - "xy": True, - "qos": 2, - } - } - } - ], -) -async def test_sending_mqtt_commands_and_optimistic( - hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator -) -> None: - """Test the sending of command in optimistic mode.""" - fake_state = State( - "light.test", - "on", - { - "brightness": 95, - "hs_color": [100, 100], - "effect": "random", - "color_temp_kelvin": 10000, - }, - ) - mock_restore_cache(hass, (fake_state,)) - - mqtt_mock = await mqtt_mock_entry() - - state = hass.states.get("light.test") - assert state.state == STATE_ON - assert state.attributes.get("brightness") == 95 - assert state.attributes.get("hs_color") == (100, 100) - assert state.attributes.get("effect") == "random" - assert state.attributes.get("color_temp_kelvin") is None # hs_color has priority - color_modes = [light.ColorMode.COLOR_TEMP, light.ColorMode.HS] - assert state.attributes.get(light.ATTR_SUPPORTED_COLOR_MODES) == color_modes - expected_features = ( - light.SUPPORT_EFFECT | light.SUPPORT_FLASH | light.SUPPORT_TRANSITION - ) - assert state.attributes.get(ATTR_SUPPORTED_FEATURES) == expected_features - assert state.attributes.get(ATTR_ASSUMED_STATE) - - await common.async_turn_on(hass, "light.test") - - mqtt_mock.async_publish.assert_called_once_with( - "test_light_rgb/set", '{"state":"ON"}', 2, False - ) - mqtt_mock.async_publish.reset_mock() - state = hass.states.get("light.test") - assert state.state == STATE_ON - - await common.async_turn_on(hass, "light.test", color_temp_kelvin=11111) - - mqtt_mock.async_publish.assert_called_once_with( - "test_light_rgb/set", - JsonValidator('{"state": "ON", "color_temp": 90}'), - 2, - False, - ) - mqtt_mock.async_publish.reset_mock() - state = hass.states.get("light.test") - assert state.state == STATE_ON - assert state.attributes.get("color_mode") == light.ColorMode.COLOR_TEMP - assert state.attributes.get("color_temp_kelvin") == 11111 - - await common.async_turn_off(hass, "light.test") - - mqtt_mock.async_publish.assert_called_once_with( - "test_light_rgb/set", '{"state":"OFF"}', 2, False - ) - mqtt_mock.async_publish.reset_mock() - state = hass.states.get("light.test") - assert state.state == STATE_OFF - - mqtt_mock.reset_mock() - await common.async_turn_on( - hass, "light.test", brightness=50, xy_color=(0.123, 0.123) - ) - mqtt_mock.async_publish.assert_called_once_with( - "test_light_rgb/set", - JsonValidator( - '{"state": "ON", "color": {"r": 0, "g": 124, "b": 255,' - ' "x": 0.14, "y": 0.133, "h": 210.824, "s": 100.0},' - ' "brightness": 50}' - ), - 2, - False, - ) - mqtt_mock.async_publish.reset_mock() - state = hass.states.get("light.test") - assert state.attributes.get("color_mode") == light.ColorMode.HS - assert state.attributes["brightness"] == 50 - assert state.attributes["hs_color"] == (210.824, 100.0) - assert state.attributes["rgb_color"] == (0, 124, 255) - assert state.attributes["xy_color"] == (0.14, 0.133) - - await common.async_turn_on(hass, "light.test", brightness=50, hs_color=(359, 78)) - mqtt_mock.async_publish.assert_called_once_with( - "test_light_rgb/set", - JsonValidator( - '{"state": "ON", "color": {"r": 255, "g": 56, "b": 59,' - ' "x": 0.654, "y": 0.301, "h": 359.0, "s": 78.0},' - ' "brightness": 50}' - ), - 2, - False, - ) - mqtt_mock.async_publish.reset_mock() - state = hass.states.get("light.test") - assert state.state == STATE_ON - assert state.attributes.get("color_mode") == light.ColorMode.HS - assert state.attributes["brightness"] == 50 - assert state.attributes["hs_color"] == (359.0, 78.0) - assert state.attributes["rgb_color"] == (255, 56, 59) - assert state.attributes["xy_color"] == (0.654, 0.301) - - await common.async_turn_on(hass, "light.test", rgb_color=(255, 128, 0)) - mqtt_mock.async_publish.assert_called_once_with( - "test_light_rgb/set", - JsonValidator( - '{"state": "ON", "color": {"r": 255, "g": 128, "b": 0,' - ' "x": 0.611, "y": 0.375, "h": 30.118, "s": 100.0}}' - ), - 2, - False, - ) - mqtt_mock.async_publish.reset_mock() - state = hass.states.get("light.test") - assert state.state == STATE_ON - assert state.attributes.get("color_mode") == light.ColorMode.HS - assert state.attributes["brightness"] == 50 - assert state.attributes["hs_color"] == (30.118, 100) - assert state.attributes["rgb_color"] == (255, 128, 0) - assert state.attributes["xy_color"] == (0.611, 0.375) - - -@pytest.mark.parametrize( - "hass_config", - [ - { - mqtt.DOMAIN: { - light.DOMAIN: { - "brightness": True, - "color_mode": True, "command_topic": "test_light_rgb/set", "effect": True, "name": "test", @@ -1338,7 +646,7 @@ async def test_sending_mqtt_commands_and_optimistic( } ], ) -async def test_sending_mqtt_commands_and_optimistic2( +async def test_sending_mqtt_commands_and_optimistic( hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator ) -> None: """Test the sending of command in optimistic mode for a light supporting color mode.""" @@ -1560,8 +868,7 @@ async def test_sending_mqtt_commands_and_optimistic2( "schema": "json", "name": "test", "command_topic": "test_light_rgb/set", - "brightness": True, - "hs": True, + "supported_color_modes": ["hs"], } } } @@ -1623,7 +930,7 @@ async def test_sending_hs_color( "schema": "json", "name": "test", "command_topic": "test_light_rgb/set", - "rgb": True, + "supported_color_modes": ["rgb"], } } } @@ -1678,7 +985,6 @@ async def test_sending_rgb_color_no_brightness( { mqtt.DOMAIN: { light.DOMAIN: { - "color_mode": True, "command_topic": "test_light_rgb/set", "name": "test", "schema": "json", @@ -1761,8 +1067,8 @@ async def test_sending_rgb_color_no_brightness2( "schema": "json", "name": "test", "command_topic": "test_light_rgb/set", + "supported_color_modes": ["rgb"], "brightness": True, - "rgb": True, } } } @@ -1829,9 +1135,9 @@ async def test_sending_rgb_color_with_brightness( "schema": "json", "name": "test", "command_topic": "test_light_rgb/set", + "supported_color_modes": ["rgb"], "brightness": True, "brightness_scale": 100, - "rgb": True, } } } @@ -1899,9 +1205,7 @@ async def test_sending_rgb_color_with_scaled_brightness( "schema": "json", "name": "test", "command_topic": "test_light_rgb/set", - "brightness": True, "brightness_scale": 100, - "color_mode": True, "supported_color_modes": ["hs", "white"], "white_scale": 50, } @@ -1946,8 +1250,7 @@ async def test_sending_scaled_white( "schema": "json", "name": "test", "command_topic": "test_light_rgb/set", - "brightness": True, - "xy": True, + "supported_color_modes": ["xy"], } } } @@ -1973,7 +1276,7 @@ async def test_sending_xy_color( call( "test_light_rgb/set", JsonValidator( - '{"state": "ON", "color": {"x": 0.14, "y": 0.133},' + '{"state": "ON", "color": {"x": 0.123, "y": 0.123},' ' "brightness": 50}' ), 0, @@ -2190,7 +1493,7 @@ async def test_transition( "name": "test", "state_topic": "test_light_bright_scale", "command_topic": "test_light_bright_scale/set", - "brightness": True, + "supported_color_modes": ["brightness"], "brightness_scale": 99, } } @@ -2255,7 +1558,6 @@ async def test_brightness_scale( "command_topic": "test_light_bright_scale/set", "brightness": True, "brightness_scale": 99, - "color_mode": True, "supported_color_modes": ["hs", "white"], "white_scale": 50, } @@ -2315,8 +1617,7 @@ async def test_white_scale( "state_topic": "test_light_rgb", "command_topic": "test_light_rgb/set", "brightness": True, - "color_temp": True, - "rgb": True, + "supported_color_modes": ["hs", "color_temp"], "qos": "0", } } @@ -2349,62 +1650,64 @@ async def test_invalid_values( '{"state":"ON",' '"color":{"r":255,"g":255,"b":255},' '"brightness": 255,' + '"color_mode": "color_temp",' '"color_temp": 100,' '"effect": "rainbow"}', ) state = hass.states.get("light.test") assert state.state == STATE_ON - assert state.attributes.get("rgb_color") == (255, 255, 255) + # Color converttrd from color_temp to rgb + assert state.attributes.get("rgb_color") == (202, 218, 255) assert state.attributes.get("brightness") == 255 - assert state.attributes.get("color_temp_kelvin") is None + assert state.attributes.get("color_temp_kelvin") == 10000 # Empty color value async_fire_mqtt_message( hass, "test_light_rgb", - '{"state":"ON", "color":{}}', + '{"state":"ON", "color":{}, "color_mode": "rgb"}', ) # Color should not have changed state = hass.states.get("light.test") assert state.state == STATE_ON - assert state.attributes.get("rgb_color") == (255, 255, 255) + assert state.attributes.get("rgb_color") == (202, 218, 255) # Bad HS color values async_fire_mqtt_message( hass, "test_light_rgb", - '{"state":"ON", "color":{"h":"bad","s":"val"}}', + '{"state":"ON", "color":{"h":"bad","s":"val"}, "color_mode": "hs"}', ) # Color should not have changed state = hass.states.get("light.test") assert state.state == STATE_ON - assert state.attributes.get("rgb_color") == (255, 255, 255) + assert state.attributes.get("rgb_color") == (202, 218, 255) # Bad RGB color values async_fire_mqtt_message( hass, "test_light_rgb", - '{"state":"ON", "color":{"r":"bad","g":"val","b":"test"}}', + '{"state":"ON", "color":{"r":"bad","g":"val","b":"test"}, "color_mode": "rgb"}', ) # Color should not have changed state = hass.states.get("light.test") assert state.state == STATE_ON - assert state.attributes.get("rgb_color") == (255, 255, 255) + assert state.attributes.get("rgb_color") == (202, 218, 255) # Bad XY color values async_fire_mqtt_message( hass, "test_light_rgb", - '{"state":"ON", "color":{"x":"bad","y":"val"}}', + '{"state":"ON", "color":{"x":"bad","y":"val"}, "color_mode": "xy"}', ) # Color should not have changed state = hass.states.get("light.test") assert state.state == STATE_ON - assert state.attributes.get("rgb_color") == (255, 255, 255) + assert state.attributes.get("rgb_color") == (202, 218, 255) # Bad brightness values async_fire_mqtt_message( @@ -2418,7 +1721,9 @@ async def test_invalid_values( # Unset color and set a valid color temperature async_fire_mqtt_message( - hass, "test_light_rgb", '{"state":"ON", "color": null, "color_temp": 100}' + hass, + "test_light_rgb", + '{"state":"ON", "color": null, "color_temp": 100, "color_mode": "color_temp"}', ) state = hass.states.get("light.test") assert state.state == STATE_ON @@ -2426,11 +1731,14 @@ async def test_invalid_values( # Bad color temperature async_fire_mqtt_message( - hass, "test_light_rgb", '{"state":"ON", "color_temp": "badValue"}' + hass, + "test_light_rgb", + '{"state":"ON", "color_temp": "badValue", "color_mode": "color_temp"}', ) assert ( - "Invalid color temp value 'badValue' received for entity light.test" - in caplog.text + "Invalid or incomplete color value '{'state': 'ON', 'color_temp': " + "'badValue', 'color_mode': 'color_temp'}' " + "received for entity light.test" in caplog.text ) # Color temperature should not have changed @@ -2927,7 +2235,6 @@ async def test_setup_manual_entity_from_yaml( DEFAULT_CONFIG, ( { - "color_mode": True, "effect": True, "supported_color_modes": [ "color_temp", diff --git a/tests/components/mqtt/test_util.py b/tests/components/mqtt/test_util.py index dd72902056d..f751096bca2 100644 --- a/tests/components/mqtt/test_util.py +++ b/tests/components/mqtt/test_util.py @@ -4,7 +4,6 @@ import asyncio from collections.abc import Callable from datetime import timedelta from pathlib import Path -from random import getrandbits import shutil import tempfile from unittest.mock import MagicMock, patch @@ -53,7 +52,7 @@ async def test_canceling_debouncer_on_shutdown( assert not mock_debouncer.is_set() mqtt_client_mock.subscribe.assert_not_called() - # Note thet the broker connection will not be disconnected gracefully + # Note that the broker connection will not be disconnected gracefully await hass.async_block_till_done() async_fire_time_changed(hass, utcnow() + timedelta(seconds=5)) await asyncio.sleep(0) @@ -199,7 +198,6 @@ async def test_reading_non_exitisting_certificate_file() -> None: ) -@pytest.mark.parametrize("temp_dir_prefix", "unknown") async def test_return_default_get_file_path( hass: HomeAssistant, mock_temp_dir: str ) -> None: @@ -211,12 +209,8 @@ async def test_return_default_get_file_path( and mqtt.util.get_file_path("some_option", "mydefault") == "mydefault" ) - with patch( - "homeassistant.components.mqtt.util.TEMP_DIR_NAME", - f"home-assistant-mqtt-other-{getrandbits(10):03x}", - ) as temp_dir_name: - tempdir = Path(tempfile.gettempdir()) / temp_dir_name - assert await hass.async_add_executor_job(_get_file_path, tempdir) + temp_dir = Path(tempfile.gettempdir()) / mock_temp_dir + assert await hass.async_add_executor_job(_get_file_path, temp_dir) async def test_waiting_for_client_not_loaded( diff --git a/tests/components/ollama/test_conversation.py b/tests/components/ollama/test_conversation.py index 202f7385697..b8e299f5e77 100644 --- a/tests/components/ollama/test_conversation.py +++ b/tests/components/ollama/test_conversation.py @@ -18,6 +18,13 @@ from homeassistant.helpers import intent, llm from tests.common import MockConfigEntry +@pytest.fixture(autouse=True) +def mock_ulid_tools(): + """Mock generated ULIDs for tool calls.""" + with patch("homeassistant.helpers.llm.ulid_now", return_value="mock-tool-call"): + yield + + @pytest.mark.parametrize("agent_id", [None, "conversation.mock_title"]) async def test_chat( hass: HomeAssistant, @@ -205,6 +212,7 @@ async def test_function_call( mock_tool.async_call.assert_awaited_once_with( hass, llm.ToolInput( + id="mock-tool-call", tool_name="test_tool", tool_args=expected_tool_args, ), @@ -285,6 +293,7 @@ async def test_function_exception( mock_tool.async_call.assert_awaited_once_with( hass, llm.ToolInput( + id="mock-tool-call", tool_name="test_tool", tool_args={"param1": "test_value"}, ), diff --git a/tests/components/onboarding/snapshots/test_views.ambr b/tests/components/onboarding/snapshots/test_views.ambr index b57c6cf96dd..2d084bd9ade 100644 --- a/tests/components/onboarding/snapshots/test_views.ambr +++ b/tests/components/onboarding/snapshots/test_views.ambr @@ -19,6 +19,10 @@ 'backup_id': 'abc123', 'database_included': True, 'date': '1970-01-01T00:00:00.000Z', + 'extra_metadata': dict({ + 'instance_id': 'abc123', + 'with_automatic_settings': True, + }), 'failed_agent_ids': list([ ]), 'folders': list([ @@ -42,6 +46,10 @@ 'backup_id': 'def456', 'database_included': False, 'date': '1980-01-01T00:00:00.000Z', + 'extra_metadata': dict({ + 'instance_id': 'unknown_uuid', + 'with_automatic_settings': True, + }), 'failed_agent_ids': list([ ]), 'folders': list([ diff --git a/tests/components/onedrive/conftest.py b/tests/components/onedrive/conftest.py index 205f5837ee7..e76ce1d01c8 100644 --- a/tests/components/onedrive/conftest.py +++ b/tests/components/onedrive/conftest.py @@ -1,18 +1,9 @@ """Fixtures for OneDrive tests.""" from collections.abc import AsyncIterator, Generator -from html import escape -from json import dumps import time from unittest.mock import AsyncMock, MagicMock, patch -from httpx import Response -from msgraph.generated.models.drive_item import DriveItem -from msgraph.generated.models.drive_item_collection_response import ( - DriveItemCollectionResponse, -) -from msgraph.generated.models.upload_session import UploadSession -from msgraph_core.models import LargeFileUploadSession import pytest from homeassistant.components.application_credentials import ( @@ -23,7 +14,13 @@ from homeassistant.components.onedrive.const import DOMAIN, OAUTH_SCOPES from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component -from .const import BACKUP_METADATA, CLIENT_ID, CLIENT_SECRET +from .const import ( + CLIENT_ID, + CLIENT_SECRET, + MOCK_APPROOT, + MOCK_BACKUP_FILE, + MOCK_BACKUP_FOLDER, +) from tests.common import MockConfigEntry @@ -70,96 +67,41 @@ def mock_config_entry(expires_at: int, scopes: list[str]) -> MockConfigEntry: ) -@pytest.fixture -def mock_adapter() -> Generator[MagicMock]: - """Return a mocked GraphAdapter.""" - with ( - patch( - "homeassistant.components.onedrive.config_flow.GraphRequestAdapter", - autospec=True, - ) as mock_adapter, - patch( - "homeassistant.components.onedrive.backup.GraphRequestAdapter", - new=mock_adapter, - ), - ): - adapter = mock_adapter.return_value - adapter.get_http_response_message.return_value = Response( - status_code=200, - json={ - "parentReference": {"driveId": "mock_drive_id"}, - "createdBy": {"user": {"displayName": "John Doe"}}, - }, - ) - yield adapter - adapter.send_async.return_value = LargeFileUploadSession( - next_expected_ranges=["2-"] - ) - - @pytest.fixture(autouse=True) -def mock_graph_client(mock_adapter: MagicMock) -> Generator[MagicMock]: +def mock_onedrive_client() -> Generator[MagicMock]: """Return a mocked GraphServiceClient.""" with ( patch( - "homeassistant.components.onedrive.config_flow.GraphServiceClient", + "homeassistant.components.onedrive.config_flow.OneDriveClient", autospec=True, - ) as graph_client, + ) as onedrive_client, patch( - "homeassistant.components.onedrive.GraphServiceClient", - new=graph_client, + "homeassistant.components.onedrive.OneDriveClient", + new=onedrive_client, ), ): - client = graph_client.return_value + client = onedrive_client.return_value + client.get_approot.return_value = MOCK_APPROOT + client.create_folder.return_value = MOCK_BACKUP_FOLDER + client.list_drive_items.return_value = [MOCK_BACKUP_FILE] + client.get_drive_item.return_value = MOCK_BACKUP_FILE - client.request_adapter = mock_adapter + class MockStreamReader: + async def iter_chunked(self, chunk_size: int) -> AsyncIterator[bytes]: + yield b"backup data" - drives = client.drives.by_drive_id.return_value - drives.special.by_drive_item_id.return_value.get = AsyncMock( - return_value=DriveItem(id="approot") - ) - - drive_items = drives.items.by_drive_item_id.return_value - drive_items.get = AsyncMock(return_value=DriveItem(id="folder_id")) - drive_items.children.post = AsyncMock(return_value=DriveItem(id="folder_id")) - drive_items.children.get = AsyncMock( - return_value=DriveItemCollectionResponse( - value=[ - DriveItem( - id=BACKUP_METADATA["backup_id"], - description=escape(dumps(BACKUP_METADATA)), - ), - DriveItem(), - ] - ) - ) - drive_items.delete = AsyncMock(return_value=None) - drive_items.create_upload_session.post = AsyncMock( - return_value=UploadSession(upload_url="https://test.tld") - ) - drive_items.patch = AsyncMock(return_value=None) - - async def generate_bytes() -> AsyncIterator[bytes]: - """Asynchronous generator that yields bytes.""" - yield b"backup data" - - drive_items.content.get = AsyncMock( - return_value=Response(status_code=200, content=generate_bytes()) - ) + client.download_drive_item.return_value = MockStreamReader() yield client @pytest.fixture -def mock_drive_items(mock_graph_client: MagicMock) -> MagicMock: - """Return a mocked DriveItems.""" - return mock_graph_client.drives.by_drive_id.return_value.items.by_drive_item_id.return_value - - -@pytest.fixture -def mock_get_special_folder(mock_graph_client: MagicMock) -> MagicMock: - """Mock the get special folder method.""" - return mock_graph_client.drives.by_drive_id.return_value.special.by_drive_item_id.return_value.get +def mock_large_file_upload_client() -> Generator[AsyncMock]: + """Return a mocked LargeFileUploadClient upload.""" + with patch( + "homeassistant.components.onedrive.backup.LargeFileUploadClient.upload" + ) as mock_upload: + yield mock_upload @pytest.fixture @@ -179,10 +121,3 @@ def mock_instance_id() -> Generator[AsyncMock]: return_value="9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0", ): yield - - -@pytest.fixture(autouse=True) -def mock_asyncio_sleep() -> Generator[AsyncMock]: - """Mock asyncio.sleep.""" - with patch("homeassistant.components.onedrive.backup.asyncio.sleep", AsyncMock()): - yield diff --git a/tests/components/onedrive/const.py b/tests/components/onedrive/const.py index c187feef30a..ee3a5ce3dc4 100644 --- a/tests/components/onedrive/const.py +++ b/tests/components/onedrive/const.py @@ -1,5 +1,18 @@ """Consts for OneDrive tests.""" +from html import escape +from json import dumps + +from onedrive_personal_sdk.models.items import ( + AppRoot, + Contributor, + File, + Folder, + Hashes, + ItemParentReference, + User, +) + CLIENT_ID = "1234" CLIENT_SECRET = "5678" @@ -17,3 +30,48 @@ BACKUP_METADATA = { "protected": False, "size": 34519040, } + +CONTRIBUTOR = Contributor( + user=User( + display_name="John Doe", + id="id", + email="john@doe.com", + ) +) + +MOCK_APPROOT = AppRoot( + id="id", + child_count=0, + size=0, + name="name", + parent_reference=ItemParentReference( + drive_id="mock_drive_id", id="id", path="path" + ), + created_by=CONTRIBUTOR, +) + +MOCK_BACKUP_FOLDER = Folder( + id="id", + name="name", + size=0, + child_count=0, + parent_reference=ItemParentReference( + drive_id="mock_drive_id", id="id", path="path" + ), + created_by=CONTRIBUTOR, +) + +MOCK_BACKUP_FILE = File( + id="id", + name="23e64aec.tar", + size=34519040, + parent_reference=ItemParentReference( + drive_id="mock_drive_id", id="id", path="path" + ), + hashes=Hashes( + quick_xor_hash="hash", + ), + mime_type="application/x-tar", + description=escape(dumps(BACKUP_METADATA)), + created_by=CONTRIBUTOR, +) diff --git a/tests/components/onedrive/test_backup.py b/tests/components/onedrive/test_backup.py index 0114d924e1a..0277c3da02e 100644 --- a/tests/components/onedrive/test_backup.py +++ b/tests/components/onedrive/test_backup.py @@ -3,15 +3,14 @@ from __future__ import annotations from collections.abc import AsyncGenerator -from html import escape from io import StringIO -from json import dumps from unittest.mock import Mock, patch -from httpx import TimeoutException -from kiota_abstractions.api_error import APIError -from msgraph.generated.models.drive_item import DriveItem -from msgraph_core.models import LargeFileUploadSession +from onedrive_personal_sdk.exceptions import ( + AuthenticationError, + HashMismatchError, + OneDriveException, +) import pytest from homeassistant.components.backup import DOMAIN as BACKUP_DOMAIN, AgentBackup @@ -89,6 +88,7 @@ async def test_agents_list_backups( "backup_id": "23e64aec", "date": "2024-11-22T11:48:48.727189+01:00", "database_included": True, + "extra_metadata": {}, "folders": [], "homeassistant_included": True, "homeassistant_version": "2024.12.0.dev0", @@ -102,14 +102,10 @@ async def test_agents_list_backups( async def test_agents_get_backup( hass: HomeAssistant, hass_ws_client: WebSocketGenerator, - mock_drive_items: MagicMock, mock_config_entry: MockConfigEntry, ) -> None: """Test agent get backup.""" - mock_drive_items.get = AsyncMock( - return_value=DriveItem(description=escape(dumps(BACKUP_METADATA))) - ) backup_id = BACKUP_METADATA["backup_id"] client = await hass_ws_client(hass) await client.send_json_auto_id({"type": "backup/details", "backup_id": backup_id}) @@ -128,6 +124,7 @@ async def test_agents_get_backup( "backup_id": "23e64aec", "date": "2024-11-22T11:48:48.727189+01:00", "database_included": True, + "extra_metadata": {}, "folders": [], "homeassistant_included": True, "homeassistant_version": "2024.12.0.dev0", @@ -140,7 +137,7 @@ async def test_agents_get_backup( async def test_agents_delete( hass: HomeAssistant, hass_ws_client: WebSocketGenerator, - mock_drive_items: MagicMock, + mock_onedrive_client: MagicMock, ) -> None: """Test agent delete backup.""" client = await hass_ws_client(hass) @@ -155,37 +152,15 @@ async def test_agents_delete( assert response["success"] assert response["result"] == {"agent_errors": {}} - mock_drive_items.delete.assert_called_once() - - -async def test_agents_delete_not_found_does_not_throw( - hass: HomeAssistant, - hass_ws_client: WebSocketGenerator, - mock_drive_items: MagicMock, -) -> None: - """Test agent delete backup.""" - mock_drive_items.children.get = AsyncMock(return_value=[]) - client = await hass_ws_client(hass) - - await client.send_json_auto_id( - { - "type": "backup/delete", - "backup_id": BACKUP_METADATA["backup_id"], - } - ) - response = await client.receive_json() - - assert response["success"] - assert response["result"] == {"agent_errors": {}} - assert mock_drive_items.delete.call_count == 0 + mock_onedrive_client.delete_drive_item.assert_called_once() async def test_agents_upload( hass_client: ClientSessionGenerator, caplog: pytest.LogCaptureFixture, - mock_drive_items: MagicMock, + mock_onedrive_client: MagicMock, + mock_large_file_upload_client: AsyncMock, mock_config_entry: MockConfigEntry, - mock_adapter: MagicMock, ) -> None: """Test agent upload backup.""" client = await hass_client() @@ -200,7 +175,6 @@ async def test_agents_upload( return_value=test_backup, ), patch("pathlib.Path.open") as mocked_open, - patch("homeassistant.components.onedrive.backup.UPLOAD_CHUNK_SIZE", 3), ): mocked_open.return_value.read = Mock(side_effect=[b"test", b""]) fetch_backup.return_value = test_backup @@ -211,31 +185,22 @@ async def test_agents_upload( assert resp.status == 201 assert f"Uploading backup {test_backup.backup_id}" in caplog.text - mock_drive_items.create_upload_session.post.assert_called_once() - mock_drive_items.patch.assert_called_once() - assert mock_adapter.send_async.call_count == 2 - assert mock_adapter.method_calls[0].args[0].content == b"tes" - assert mock_adapter.method_calls[0].args[0].headers.get("Content-Range") == { - "bytes 0-2/34519040" - } - assert mock_adapter.method_calls[1].args[0].content == b"t" - assert mock_adapter.method_calls[1].args[0].headers.get("Content-Range") == { - "bytes 3-3/34519040" - } + mock_large_file_upload_client.assert_called_once() + mock_onedrive_client.update_drive_item.assert_called_once() -async def test_broken_upload_session( +async def test_agents_upload_corrupt_upload( hass_client: ClientSessionGenerator, caplog: pytest.LogCaptureFixture, - mock_drive_items: MagicMock, + mock_onedrive_client: MagicMock, + mock_large_file_upload_client: AsyncMock, mock_config_entry: MockConfigEntry, ) -> None: - """Test broken upload session.""" + """Test hash validation fails.""" + mock_large_file_upload_client.side_effect = HashMismatchError("test") client = await hass_client() test_backup = AgentBackup.from_dict(BACKUP_METADATA) - mock_drive_items.create_upload_session.post = AsyncMock(return_value=None) - with ( patch( "homeassistant.components.backup.manager.BackupManager.async_get_backup", @@ -254,152 +219,18 @@ async def test_broken_upload_session( ) assert resp.status == 201 - assert "Failed to start backup upload" in caplog.text - - -@pytest.mark.parametrize( - "side_effect", - [ - APIError(response_status_code=500), - TimeoutException("Timeout"), - ], -) -async def test_agents_upload_errors_retried( - hass_client: ClientSessionGenerator, - caplog: pytest.LogCaptureFixture, - mock_drive_items: MagicMock, - mock_config_entry: MockConfigEntry, - mock_adapter: MagicMock, - side_effect: Exception, -) -> None: - """Test agent upload backup.""" - client = await hass_client() - test_backup = AgentBackup.from_dict(BACKUP_METADATA) - - mock_adapter.send_async.side_effect = [ - side_effect, - LargeFileUploadSession(next_expected_ranges=["2-"]), - LargeFileUploadSession(next_expected_ranges=["2-"]), - ] - - with ( - patch( - "homeassistant.components.backup.manager.BackupManager.async_get_backup", - ) as fetch_backup, - patch( - "homeassistant.components.backup.manager.read_backup", - return_value=test_backup, - ), - patch("pathlib.Path.open") as mocked_open, - patch("homeassistant.components.onedrive.backup.UPLOAD_CHUNK_SIZE", 3), - ): - mocked_open.return_value.read = Mock(side_effect=[b"test", b""]) - fetch_backup.return_value = test_backup - resp = await client.post( - f"/api/backup/upload?agent_id={DOMAIN}.{mock_config_entry.unique_id}", - data={"file": StringIO("test")}, - ) - - assert resp.status == 201 - assert mock_adapter.send_async.call_count == 3 assert f"Uploading backup {test_backup.backup_id}" in caplog.text - mock_drive_items.patch.assert_called_once() - - -async def test_agents_upload_4xx_errors_not_retried( - hass_client: ClientSessionGenerator, - caplog: pytest.LogCaptureFixture, - mock_drive_items: MagicMock, - mock_config_entry: MockConfigEntry, - mock_adapter: MagicMock, -) -> None: - """Test agent upload backup.""" - client = await hass_client() - test_backup = AgentBackup.from_dict(BACKUP_METADATA) - - mock_adapter.send_async.side_effect = APIError(response_status_code=404) - - with ( - patch( - "homeassistant.components.backup.manager.BackupManager.async_get_backup", - ) as fetch_backup, - patch( - "homeassistant.components.backup.manager.read_backup", - return_value=test_backup, - ), - patch("pathlib.Path.open") as mocked_open, - patch("homeassistant.components.onedrive.backup.UPLOAD_CHUNK_SIZE", 3), - ): - mocked_open.return_value.read = Mock(side_effect=[b"test", b""]) - fetch_backup.return_value = test_backup - resp = await client.post( - f"/api/backup/upload?agent_id={DOMAIN}.{mock_config_entry.unique_id}", - data={"file": StringIO("test")}, - ) - - assert resp.status == 201 - assert mock_adapter.send_async.call_count == 1 - assert f"Uploading backup {test_backup.backup_id}" in caplog.text - assert mock_drive_items.patch.call_count == 0 - assert "Backup operation failed" in caplog.text - - -@pytest.mark.parametrize( - ("side_effect", "error"), - [ - (APIError(response_status_code=500), "Backup operation failed"), - (TimeoutException("Timeout"), "Backup operation timed out"), - ], -) -async def test_agents_upload_fails_after_max_retries( - hass_client: ClientSessionGenerator, - caplog: pytest.LogCaptureFixture, - mock_drive_items: MagicMock, - mock_config_entry: MockConfigEntry, - mock_adapter: MagicMock, - side_effect: Exception, - error: str, -) -> None: - """Test agent upload backup.""" - client = await hass_client() - test_backup = AgentBackup.from_dict(BACKUP_METADATA) - - mock_adapter.send_async.side_effect = side_effect - - with ( - patch( - "homeassistant.components.backup.manager.BackupManager.async_get_backup", - ) as fetch_backup, - patch( - "homeassistant.components.backup.manager.read_backup", - return_value=test_backup, - ), - patch("pathlib.Path.open") as mocked_open, - patch("homeassistant.components.onedrive.backup.UPLOAD_CHUNK_SIZE", 3), - ): - mocked_open.return_value.read = Mock(side_effect=[b"test", b""]) - fetch_backup.return_value = test_backup - resp = await client.post( - f"/api/backup/upload?agent_id={DOMAIN}.{mock_config_entry.unique_id}", - data={"file": StringIO("test")}, - ) - - assert resp.status == 201 - assert mock_adapter.send_async.call_count == 6 - assert f"Uploading backup {test_backup.backup_id}" in caplog.text - assert mock_drive_items.patch.call_count == 0 - assert error in caplog.text + mock_large_file_upload_client.assert_called_once() + assert mock_onedrive_client.update_drive_item.call_count == 0 + assert "Hash validation failed, backup file might be corrupt" in caplog.text async def test_agents_download( hass_client: ClientSessionGenerator, - mock_drive_items: MagicMock, + mock_onedrive_client: MagicMock, mock_config_entry: MockConfigEntry, ) -> None: """Test agent download backup.""" - mock_drive_items.get = AsyncMock( - return_value=DriveItem(description=escape(dumps(BACKUP_METADATA))) - ) client = await hass_client() backup_id = BACKUP_METADATA["backup_id"] @@ -408,29 +239,30 @@ async def test_agents_download( ) assert resp.status == 200 assert await resp.content.read() == b"backup data" - mock_drive_items.content.get.assert_called_once() @pytest.mark.parametrize( ("side_effect", "error"), [ ( - APIError(response_status_code=500), + OneDriveException(), "Backup operation failed", ), - (TimeoutException("Timeout"), "Backup operation timed out"), + (TimeoutError(), "Backup operation timed out"), ], ) async def test_delete_error( hass: HomeAssistant, hass_ws_client: WebSocketGenerator, - mock_drive_items: MagicMock, + mock_onedrive_client: MagicMock, mock_config_entry: MockConfigEntry, side_effect: Exception, error: str, ) -> None: """Test error during delete.""" - mock_drive_items.delete = AsyncMock(side_effect=side_effect) + mock_onedrive_client.delete_drive_item.side_effect = AsyncMock( + side_effect=side_effect + ) client = await hass_ws_client(hass) @@ -448,14 +280,35 @@ async def test_delete_error( } +async def test_agents_delete_not_found_does_not_throw( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + mock_onedrive_client: MagicMock, +) -> None: + """Test agent delete backup.""" + mock_onedrive_client.list_drive_items.return_value = [] + client = await hass_ws_client(hass) + + await client.send_json_auto_id( + { + "type": "backup/delete", + "backup_id": BACKUP_METADATA["backup_id"], + } + ) + response = await client.receive_json() + + assert response["success"] + assert response["result"] == {"agent_errors": {}} + + async def test_agents_backup_not_found( hass: HomeAssistant, hass_ws_client: WebSocketGenerator, - mock_drive_items: MagicMock, + mock_onedrive_client: MagicMock, ) -> None: """Test backup not found.""" - mock_drive_items.children.get = AsyncMock(return_value=[]) + mock_onedrive_client.list_drive_items.return_value = [] backup_id = BACKUP_METADATA["backup_id"] client = await hass_ws_client(hass) await client.send_json_auto_id({"type": "backup/details", "backup_id": backup_id}) @@ -468,13 +321,13 @@ async def test_agents_backup_not_found( async def test_reauth_on_403( hass: HomeAssistant, hass_ws_client: WebSocketGenerator, - mock_drive_items: MagicMock, + mock_onedrive_client: MagicMock, mock_config_entry: MockConfigEntry, ) -> None: """Test we re-authenticate on 403.""" - mock_drive_items.children.get = AsyncMock( - side_effect=APIError(response_status_code=403) + mock_onedrive_client.list_drive_items.side_effect = AuthenticationError( + 403, "Auth failed" ) backup_id = BACKUP_METADATA["backup_id"] client = await hass_ws_client(hass) @@ -483,7 +336,7 @@ async def test_reauth_on_403( assert response["success"] assert response["result"]["agent_errors"] == { - f"{DOMAIN}.{mock_config_entry.unique_id}": "Backup operation failed" + f"{DOMAIN}.{mock_config_entry.unique_id}": "Authentication error" } await hass.async_block_till_done() diff --git a/tests/components/onedrive/test_config_flow.py b/tests/components/onedrive/test_config_flow.py index 8be6aadfd0f..9acfd8ada3c 100644 --- a/tests/components/onedrive/test_config_flow.py +++ b/tests/components/onedrive/test_config_flow.py @@ -3,8 +3,7 @@ from http import HTTPStatus from unittest.mock import AsyncMock, MagicMock -from httpx import Response -from kiota_abstractions.api_error import APIError +from onedrive_personal_sdk.exceptions import OneDriveException import pytest from homeassistant import config_entries @@ -20,7 +19,7 @@ from homeassistant.data_entry_flow import FlowResultType from homeassistant.helpers import config_entry_oauth2_flow from . import setup_integration -from .const import CLIENT_ID +from .const import CLIENT_ID, MOCK_APPROOT from tests.common import MockConfigEntry from tests.test_util.aiohttp import AiohttpClientMocker @@ -89,25 +88,52 @@ async def test_full_flow( assert result["data"][CONF_TOKEN]["refresh_token"] == "mock-refresh-token" +@pytest.mark.usefixtures("current_request_with_host") +async def test_full_flow_with_owner_not_found( + hass: HomeAssistant, + hass_client_no_auth: ClientSessionGenerator, + aioclient_mock: AiohttpClientMocker, + mock_setup_entry: AsyncMock, + mock_onedrive_client: MagicMock, +) -> None: + """Ensure we get a default title if the drive's owner can't be read.""" + + mock_onedrive_client.get_approot.return_value.created_by.user = None + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + await _do_get_token(hass, result, hass_client_no_auth, aioclient_mock) + result = await hass.config_entries.flow.async_configure(result["flow_id"]) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert len(hass.config_entries.async_entries(DOMAIN)) == 1 + assert len(mock_setup_entry.mock_calls) == 1 + assert result["title"] == "OneDrive" + assert result["result"].unique_id == "mock_drive_id" + assert result["data"][CONF_TOKEN][CONF_ACCESS_TOKEN] == "mock-access-token" + assert result["data"][CONF_TOKEN]["refresh_token"] == "mock-refresh-token" + + @pytest.mark.usefixtures("current_request_with_host") @pytest.mark.parametrize( ("exception", "error"), [ (Exception, "unknown"), - (APIError, "connection_error"), + (OneDriveException, "connection_error"), ], ) async def test_flow_errors( hass: HomeAssistant, hass_client_no_auth: ClientSessionGenerator, aioclient_mock: AiohttpClientMocker, - mock_adapter: MagicMock, + mock_onedrive_client: MagicMock, exception: Exception, error: str, ) -> None: """Test errors during flow.""" - mock_adapter.get_http_response_message.side_effect = exception + mock_onedrive_client.get_approot.side_effect = exception result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} @@ -172,15 +198,12 @@ async def test_reauth_flow_id_changed( aioclient_mock: AiohttpClientMocker, mock_setup_entry: AsyncMock, mock_config_entry: MockConfigEntry, - mock_adapter: MagicMock, + mock_onedrive_client: MagicMock, ) -> None: """Test that the reauth flow fails on a different drive id.""" - mock_adapter.get_http_response_message.return_value = Response( - status_code=200, - json={ - "parentReference": {"driveId": "other_drive_id"}, - }, - ) + app_root = MOCK_APPROOT + app_root.parent_reference.drive_id = "other_drive_id" + mock_onedrive_client.get_approot.return_value = app_root await setup_integration(hass, mock_config_entry) diff --git a/tests/components/onedrive/test_init.py b/tests/components/onedrive/test_init.py index bc5c22c3ce6..674bc2d38d9 100644 --- a/tests/components/onedrive/test_init.py +++ b/tests/components/onedrive/test_init.py @@ -2,7 +2,7 @@ from unittest.mock import MagicMock -from kiota_abstractions.api_error import APIError +from onedrive_personal_sdk.exceptions import AuthenticationError, OneDriveException import pytest from homeassistant.config_entries import ConfigEntryState @@ -31,82 +31,31 @@ async def test_load_unload_config_entry( @pytest.mark.parametrize( ("side_effect", "state"), [ - (APIError(response_status_code=403), ConfigEntryState.SETUP_ERROR), - (APIError(response_status_code=500), ConfigEntryState.SETUP_RETRY), + (AuthenticationError(403, "Auth failed"), ConfigEntryState.SETUP_ERROR), + (OneDriveException(), ConfigEntryState.SETUP_RETRY), ], ) async def test_approot_errors( hass: HomeAssistant, mock_config_entry: MockConfigEntry, - mock_get_special_folder: MagicMock, + mock_onedrive_client: MagicMock, side_effect: Exception, state: ConfigEntryState, ) -> None: """Test errors during approot retrieval.""" - mock_get_special_folder.side_effect = side_effect + mock_onedrive_client.get_approot.side_effect = side_effect await setup_integration(hass, mock_config_entry) assert mock_config_entry.state is state -async def test_faulty_approot( +async def test_get_integration_folder_error( hass: HomeAssistant, mock_config_entry: MockConfigEntry, - mock_get_special_folder: MagicMock, + mock_onedrive_client: MagicMock, caplog: pytest.LogCaptureFixture, ) -> None: """Test faulty approot retrieval.""" - mock_get_special_folder.return_value = None - await setup_integration(hass, mock_config_entry) - assert mock_config_entry.state is ConfigEntryState.SETUP_RETRY - assert "Failed to get approot folder" in caplog.text - - -async def test_faulty_integration_folder( - hass: HomeAssistant, - mock_config_entry: MockConfigEntry, - mock_drive_items: MagicMock, - caplog: pytest.LogCaptureFixture, -) -> None: - """Test faulty approot retrieval.""" - mock_drive_items.get.return_value = None + mock_onedrive_client.create_folder.side_effect = OneDriveException() await setup_integration(hass, mock_config_entry) assert mock_config_entry.state is ConfigEntryState.SETUP_RETRY assert "Failed to get backups_9f86d081 folder" in caplog.text - - -async def test_500_error_during_backup_folder_get( - hass: HomeAssistant, - mock_config_entry: MockConfigEntry, - mock_drive_items: MagicMock, - caplog: pytest.LogCaptureFixture, -) -> None: - """Test error during backup folder creation.""" - mock_drive_items.get.side_effect = APIError(response_status_code=500) - await setup_integration(hass, mock_config_entry) - assert mock_config_entry.state is ConfigEntryState.SETUP_RETRY - assert "Failed to get backups_9f86d081 folder" in caplog.text - - -async def test_error_during_backup_folder_creation( - hass: HomeAssistant, - mock_config_entry: MockConfigEntry, - mock_drive_items: MagicMock, - caplog: pytest.LogCaptureFixture, -) -> None: - """Test error during backup folder creation.""" - mock_drive_items.get.side_effect = APIError(response_status_code=404) - mock_drive_items.children.post.side_effect = APIError() - await setup_integration(hass, mock_config_entry) - assert mock_config_entry.state is ConfigEntryState.SETUP_RETRY - assert "Failed to create backups_9f86d081 folder" in caplog.text - - -async def test_successful_backup_folder_creation( - hass: HomeAssistant, - mock_config_entry: MockConfigEntry, - mock_drive_items: MagicMock, -) -> None: - """Test successful backup folder creation.""" - mock_drive_items.get.side_effect = APIError(response_status_code=404) - await setup_integration(hass, mock_config_entry) - assert mock_config_entry.state is ConfigEntryState.LOADED diff --git a/tests/components/openai_conversation/test_config_flow.py b/tests/components/openai_conversation/test_config_flow.py index f5017c124b1..90a08471f39 100644 --- a/tests/components/openai_conversation/test_config_flow.py +++ b/tests/components/openai_conversation/test_config_flow.py @@ -12,12 +12,14 @@ from homeassistant.components.openai_conversation.const import ( CONF_CHAT_MODEL, CONF_MAX_TOKENS, CONF_PROMPT, + CONF_REASONING_EFFORT, CONF_RECOMMENDED, CONF_TEMPERATURE, CONF_TOP_P, DOMAIN, RECOMMENDED_CHAT_MODEL, RECOMMENDED_MAX_TOKENS, + RECOMMENDED_REASONING_EFFORT, RECOMMENDED_TOP_P, ) from homeassistant.const import CONF_LLM_HASS_API @@ -88,6 +90,27 @@ async def test_options( assert options["data"][CONF_CHAT_MODEL] == RECOMMENDED_CHAT_MODEL +async def test_options_unsupported_model( + hass: HomeAssistant, mock_config_entry, mock_init_component +) -> None: + """Test the options form giving error about models not supported.""" + options_flow = await hass.config_entries.options.async_init( + mock_config_entry.entry_id + ) + result = await hass.config_entries.options.async_configure( + options_flow["flow_id"], + { + CONF_RECOMMENDED: False, + CONF_PROMPT: "Speak like a pirate", + CONF_CHAT_MODEL: "o1-mini", + CONF_LLM_HASS_API: "assist", + }, + ) + await hass.async_block_till_done() + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {"chat_model": "model_not_supported"} + + @pytest.mark.parametrize( ("side_effect", "error"), [ @@ -148,6 +171,7 @@ async def test_form_invalid_auth(hass: HomeAssistant, side_effect, error) -> Non CONF_CHAT_MODEL: RECOMMENDED_CHAT_MODEL, CONF_TOP_P: RECOMMENDED_TOP_P, CONF_MAX_TOKENS: RECOMMENDED_MAX_TOKENS, + CONF_REASONING_EFFORT: RECOMMENDED_REASONING_EFFORT, }, ), ( @@ -158,6 +182,7 @@ async def test_form_invalid_auth(hass: HomeAssistant, side_effect, error) -> Non CONF_CHAT_MODEL: RECOMMENDED_CHAT_MODEL, CONF_TOP_P: RECOMMENDED_TOP_P, CONF_MAX_TOKENS: RECOMMENDED_MAX_TOKENS, + CONF_REASONING_EFFORT: RECOMMENDED_REASONING_EFFORT, }, { CONF_RECOMMENDED: True, diff --git a/tests/components/openai_conversation/test_conversation.py b/tests/components/openai_conversation/test_conversation.py index 9ee19cd330c..39ca1b53e28 100644 --- a/tests/components/openai_conversation/test_conversation.py +++ b/tests/components/openai_conversation/test_conversation.py @@ -195,6 +195,7 @@ async def test_function_call( mock_tool.async_call.assert_awaited_once_with( hass, llm.ToolInput( + id="call_AbCdEfGhIjKlMnOpQrStUvWx", tool_name="test_tool", tool_args={"param1": "test_value"}, ), @@ -359,6 +360,7 @@ async def test_function_exception( mock_tool.async_call.assert_awaited_once_with( hass, llm.ToolInput( + id="call_AbCdEfGhIjKlMnOpQrStUvWx", tool_name="test_tool", tool_args={"param1": "test_value"}, ), diff --git a/tests/components/oralb/test_config_flow.py b/tests/components/oralb/test_config_flow.py index dee16cd0632..c4cc830b89c 100644 --- a/tests/components/oralb/test_config_flow.py +++ b/tests/components/oralb/test_config_flow.py @@ -96,6 +96,36 @@ async def test_async_step_user_with_found_devices(hass: HomeAssistant) -> None: assert result2["result"].unique_id == "78:DB:2F:C2:48:BE" +async def test_async_step_user_replace_ignored(hass: HomeAssistant) -> None: + """Test setup from service info can replace an ignored entry.""" + entry = MockConfigEntry( + domain=DOMAIN, + unique_id=ORALB_SERVICE_INFO.address, + source=config_entries.SOURCE_IGNORE, + data={}, + ) + entry.add_to_hass(hass) + with patch( + "homeassistant.components.oralb.config_flow.async_discovered_service_info", + return_value=[ORALB_SERVICE_INFO], + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_USER}, + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + with patch("homeassistant.components.oralb.async_setup_entry", return_value=True): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={"address": "78:DB:2F:C2:48:BE"}, + ) + assert result2["type"] is FlowResultType.CREATE_ENTRY + assert result2["title"] == "Smart Series 7000 48BE" + assert result2["data"] == {} + assert result2["result"].unique_id == "78:DB:2F:C2:48:BE" + + async def test_async_step_user_device_added_between_steps(hass: HomeAssistant) -> None: """Test the device gets added via another flow between steps.""" with patch( diff --git a/tests/components/qingping/test_config_flow.py b/tests/components/qingping/test_config_flow.py index 7bcd9c09e68..9d3d2a49e26 100644 --- a/tests/components/qingping/test_config_flow.py +++ b/tests/components/qingping/test_config_flow.py @@ -114,6 +114,38 @@ async def test_async_step_user_with_found_devices(hass: HomeAssistant) -> None: assert result2["result"].unique_id == "aa:bb:cc:dd:ee:ff" +async def test_async_step_user_replace_ignored(hass: HomeAssistant) -> None: + """Test setup from service info can replace an ignored entry.""" + entry = MockConfigEntry( + domain=DOMAIN, + unique_id=LIGHT_AND_SIGNAL_SERVICE_INFO.address, + source=config_entries.SOURCE_IGNORE, + data={}, + ) + entry.add_to_hass(hass) + with patch( + "homeassistant.components.qingping.config_flow.async_discovered_service_info", + return_value=[LIGHT_AND_SIGNAL_SERVICE_INFO], + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_USER}, + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + with patch( + "homeassistant.components.qingping.async_setup_entry", return_value=True + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={"address": "aa:bb:cc:dd:ee:ff"}, + ) + assert result2["type"] is FlowResultType.CREATE_ENTRY + assert result2["title"] == "Motion & Light EEFF" + assert result2["data"] == {} + assert result2["result"].unique_id == "aa:bb:cc:dd:ee:ff" + + async def test_async_step_user_device_added_between_steps(hass: HomeAssistant) -> None: """Test the device gets added via another flow between steps.""" with patch( diff --git a/tests/components/recorder/db_schema_9.py b/tests/components/recorder/db_schema_9.py index 784e326e1c3..6cf7085e279 100644 --- a/tests/components/recorder/db_schema_9.py +++ b/tests/components/recorder/db_schema_9.py @@ -19,8 +19,7 @@ from sqlalchemy import ( Text, distinct, ) -from sqlalchemy.ext.declarative import declarative_base -from sqlalchemy.orm import relationship +from sqlalchemy.orm import declarative_base, relationship from sqlalchemy.orm.session import Session from homeassistant.core import Context, Event, EventOrigin, State, split_entity_id diff --git a/tests/components/reolink/test_init.py b/tests/components/reolink/test_init.py index 25029375eb6..28d8c542f4f 100644 --- a/tests/components/reolink/test_init.py +++ b/tests/components/reolink/test_init.py @@ -860,6 +860,21 @@ async def test_privacy_mode_change_callback( assert reolink_connect.get_states.call_count >= 1 assert hass.states.get(entity_id).state == STATE_ON + # test cleanup during unloading, first reset to privacy mode ON + reolink_connect.baichuan.privacy_mode.return_value = True + callback_mock.callback_func() + freezer.tick(5) + async_fire_time_changed(hass) + await hass.async_block_till_done() + # now fire the callback again, but unload before refresh took place + reolink_connect.baichuan.privacy_mode.return_value = False + callback_mock.callback_func() + await hass.async_block_till_done() + + await hass.config_entries.async_unload(config_entry.entry_id) + await hass.async_block_till_done() + assert config_entry.state is ConfigEntryState.NOT_LOADED + async def test_remove( hass: HomeAssistant, diff --git a/tests/components/reolink/test_switch.py b/tests/components/reolink/test_switch.py index 2e7c1556201..2b2c33f0e8f 100644 --- a/tests/components/reolink/test_switch.py +++ b/tests/components/reolink/test_switch.py @@ -29,6 +29,211 @@ from .conftest import TEST_CAM_NAME, TEST_NVR_NAME, TEST_UID from tests.common import MockConfigEntry, async_fire_time_changed +async def test_switch( + hass: HomeAssistant, + config_entry: MockConfigEntry, + freezer: FrozenDateTimeFactory, + reolink_connect: MagicMock, +) -> None: + """Test switch entity.""" + reolink_connect.camera_name.return_value = TEST_CAM_NAME + reolink_connect.audio_record.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}_record_audio" + assert hass.states.get(entity_id).state == STATE_ON + + reolink_connect.audio_record.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 + await hass.services.async_call( + SWITCH_DOMAIN, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: entity_id}, + blocking=True, + ) + reolink_connect.set_audio.assert_called_with(0, True) + + reolink_connect.set_audio.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_connect.set_audio.reset_mock(side_effect=True) + await hass.services.async_call( + SWITCH_DOMAIN, + SERVICE_TURN_OFF, + {ATTR_ENTITY_ID: entity_id}, + blocking=True, + ) + reolink_connect.set_audio.assert_called_with(0, False) + + reolink_connect.set_audio.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, + ) + + reolink_connect.set_audio.reset_mock(side_effect=True) + + reolink_connect.camera_online.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_UNAVAILABLE + + reolink_connect.camera_online.return_value = True + + +async def test_host_switch( + hass: HomeAssistant, + config_entry: MockConfigEntry, + freezer: FrozenDateTimeFactory, + reolink_connect: MagicMock, +) -> None: + """Test host switch entity.""" + reolink_connect.camera_name.return_value = TEST_CAM_NAME + reolink_connect.email_enabled.return_value = True + reolink_connect.is_hub = False + reolink_connect.supported.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_NVR_NAME}_email_on_event" + assert hass.states.get(entity_id).state == STATE_ON + + reolink_connect.email_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 + await hass.services.async_call( + SWITCH_DOMAIN, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: entity_id}, + blocking=True, + ) + reolink_connect.set_email.assert_called_with(None, True) + + reolink_connect.set_email.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_connect.set_email.reset_mock(side_effect=True) + await hass.services.async_call( + SWITCH_DOMAIN, + SERVICE_TURN_OFF, + {ATTR_ENTITY_ID: entity_id}, + blocking=True, + ) + reolink_connect.set_email.assert_called_with(None, False) + + reolink_connect.set_email.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, + ) + + reolink_connect.set_email.reset_mock(side_effect=True) + + +async def test_chime_switch( + hass: HomeAssistant, + config_entry: MockConfigEntry, + freezer: FrozenDateTimeFactory, + reolink_connect: MagicMock, + test_chime: Chime, +) -> None: + """Test host switch entity.""" + 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_chime_led" + assert hass.states.get(entity_id).state == STATE_ON + + test_chime.led_state = 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 + test_chime.set_option = AsyncMock() + await hass.services.async_call( + SWITCH_DOMAIN, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: entity_id}, + blocking=True, + ) + test_chime.set_option.assert_called_with(led=True) + + test_chime.set_option.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 + test_chime.set_option.reset_mock(side_effect=True) + await hass.services.async_call( + SWITCH_DOMAIN, + SERVICE_TURN_OFF, + {ATTR_ENTITY_ID: entity_id}, + blocking=True, + ) + test_chime.set_option.assert_called_with(led=False) + + test_chime.set_option.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, + ) + + test_chime.set_option.reset_mock(side_effect=True) + + @pytest.mark.parametrize( ( "original_id", @@ -171,206 +376,3 @@ async def test_hub_switches_repair_issue( reolink_connect.is_hub = False reolink_connect.supported.return_value = True - - -async def test_switch( - hass: HomeAssistant, - config_entry: MockConfigEntry, - freezer: FrozenDateTimeFactory, - reolink_connect: MagicMock, -) -> None: - """Test switch entity.""" - reolink_connect.camera_name.return_value = TEST_CAM_NAME - reolink_connect.audio_record.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}_record_audio" - assert hass.states.get(entity_id).state == STATE_ON - - reolink_connect.audio_record.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 - await hass.services.async_call( - SWITCH_DOMAIN, - SERVICE_TURN_ON, - {ATTR_ENTITY_ID: entity_id}, - blocking=True, - ) - reolink_connect.set_audio.assert_called_with(0, True) - - reolink_connect.set_audio.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_connect.set_audio.reset_mock(side_effect=True) - await hass.services.async_call( - SWITCH_DOMAIN, - SERVICE_TURN_OFF, - {ATTR_ENTITY_ID: entity_id}, - blocking=True, - ) - reolink_connect.set_audio.assert_called_with(0, False) - - reolink_connect.set_audio.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, - ) - - reolink_connect.set_audio.reset_mock(side_effect=True) - - reolink_connect.camera_online.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_UNAVAILABLE - - reolink_connect.camera_online.return_value = True - - -async def test_host_switch( - hass: HomeAssistant, - config_entry: MockConfigEntry, - freezer: FrozenDateTimeFactory, - reolink_connect: MagicMock, -) -> None: - """Test host switch entity.""" - reolink_connect.camera_name.return_value = TEST_CAM_NAME - reolink_connect.email_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_NVR_NAME}_email_on_event" - assert hass.states.get(entity_id).state == STATE_ON - - reolink_connect.email_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 - await hass.services.async_call( - SWITCH_DOMAIN, - SERVICE_TURN_ON, - {ATTR_ENTITY_ID: entity_id}, - blocking=True, - ) - reolink_connect.set_email.assert_called_with(None, True) - - reolink_connect.set_email.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_connect.set_email.reset_mock(side_effect=True) - await hass.services.async_call( - SWITCH_DOMAIN, - SERVICE_TURN_OFF, - {ATTR_ENTITY_ID: entity_id}, - blocking=True, - ) - reolink_connect.set_email.assert_called_with(None, False) - - reolink_connect.set_email.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, - ) - - reolink_connect.set_email.reset_mock(side_effect=True) - - -async def test_chime_switch( - hass: HomeAssistant, - config_entry: MockConfigEntry, - freezer: FrozenDateTimeFactory, - reolink_connect: MagicMock, - test_chime: Chime, -) -> None: - """Test host switch entity.""" - 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_chime_led" - assert hass.states.get(entity_id).state == STATE_ON - - test_chime.led_state = 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 - test_chime.set_option = AsyncMock() - await hass.services.async_call( - SWITCH_DOMAIN, - SERVICE_TURN_ON, - {ATTR_ENTITY_ID: entity_id}, - blocking=True, - ) - test_chime.set_option.assert_called_with(led=True) - - test_chime.set_option.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 - test_chime.set_option.reset_mock(side_effect=True) - await hass.services.async_call( - SWITCH_DOMAIN, - SERVICE_TURN_OFF, - {ATTR_ENTITY_ID: entity_id}, - blocking=True, - ) - test_chime.set_option.assert_called_with(led=False) - - test_chime.set_option.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, - ) - - test_chime.set_option.reset_mock(side_effect=True) diff --git a/tests/components/ring/test_camera.py b/tests/components/ring/test_camera.py index 4b4f019fdf7..54638df9a46 100644 --- a/tests/components/ring/test_camera.py +++ b/tests/components/ring/test_camera.py @@ -436,9 +436,9 @@ async def test_camera_webrtc( assert response assert response.get("success") is False assert response["error"]["code"] == "home_assistant_error" - msg = "The sdp_m_line_index is required for ring webrtc streaming" - assert msg in response["error"].get("message") - assert msg in caplog.text + error_msg = f"Error negotiating stream for {front_camera_mock.name}" + assert error_msg in response["error"].get("message") + assert error_msg in caplog.text front_camera_mock.on_webrtc_candidate.assert_called_once() # Answer message diff --git a/tests/components/ring/test_init.py b/tests/components/ring/test_init.py index 7c3b93e5114..66decb5ce15 100644 --- a/tests/components/ring/test_init.py +++ b/tests/components/ring/test_init.py @@ -16,7 +16,7 @@ from homeassistant.components.ring.const import ( CONF_LISTEN_CREDENTIALS, SCAN_INTERVAL, ) -from homeassistant.components.ring.coordinator import RingEventListener +from homeassistant.components.ring.coordinator import RingConfigEntry, RingEventListener from homeassistant.config_entries import SOURCE_REAUTH, ConfigEntryState from homeassistant.const import CONF_DEVICE_ID, CONF_TOKEN, CONF_USERNAME from homeassistant.core import HomeAssistant @@ -80,12 +80,12 @@ async def test_auth_failed_on_setup( ("error_type", "log_msg"), [ ( - RingTimeout, - "Timeout communicating with API: ", + RingTimeout("Some internal error info"), + "Timeout communicating with Ring API", ), ( - RingError, - "Error communicating with API: ", + RingError("Some internal error info"), + "Error communicating with Ring API", ), ], ids=["timeout-error", "other-error"], @@ -95,6 +95,7 @@ async def test_error_on_setup( mock_ring_client, mock_config_entry: MockConfigEntry, caplog: pytest.LogCaptureFixture, + freezer: FrozenDateTimeFactory, error_type, log_msg, ) -> None: @@ -166,11 +167,11 @@ async def test_auth_failure_on_device_update( [ ( RingTimeout, - "Error fetching devices data: Timeout communicating with API: ", + "Error fetching devices data: Timeout communicating with Ring API", ), ( RingError, - "Error fetching devices data: Error communicating with API: ", + "Error fetching devices data: Error communicating with Ring API", ), ], ids=["timeout-error", "other-error"], @@ -178,7 +179,7 @@ async def test_auth_failure_on_device_update( async def test_error_on_global_update( hass: HomeAssistant, mock_ring_client, - mock_config_entry: MockConfigEntry, + mock_config_entry: RingConfigEntry, freezer: FrozenDateTimeFactory, caplog: pytest.LogCaptureFixture, error_type, @@ -189,15 +190,35 @@ async def test_error_on_global_update( await hass.config_entries.async_setup(mock_config_entry.entry_id) await hass.async_block_till_done() - mock_ring_client.async_update_devices.side_effect = error_type + coordinator = mock_config_entry.runtime_data.devices_coordinator + assert coordinator - freezer.tick(SCAN_INTERVAL) - async_fire_time_changed(hass) - await hass.async_block_till_done(wait_background_tasks=True) + with patch.object( + coordinator, "_async_update_data", wraps=coordinator._async_update_data + ) as refresh_spy: + error = error_type("Some internal error info 1") + mock_ring_client.async_update_devices.side_effect = error - assert log_msg in caplog.text + freezer.tick(SCAN_INTERVAL * 2) + async_fire_time_changed(hass) + await hass.async_block_till_done(wait_background_tasks=True) - assert hass.config_entries.async_get_entry(mock_config_entry.entry_id) + refresh_spy.assert_called() + assert coordinator.last_exception.__cause__ == error + assert log_msg in caplog.text + + # Check log is not being spammed. + refresh_spy.reset_mock() + error2 = error_type("Some internal error info 2") + caplog.clear() + mock_ring_client.async_update_devices.side_effect = error2 + freezer.tick(SCAN_INTERVAL * 2) + async_fire_time_changed(hass) + await hass.async_block_till_done(wait_background_tasks=True) + + refresh_spy.assert_called() + assert coordinator.last_exception.__cause__ == error2 + assert log_msg not in caplog.text @pytest.mark.parametrize( @@ -205,11 +226,11 @@ async def test_error_on_global_update( [ ( RingTimeout, - "Error fetching devices data: Timeout communicating with API for device Front: ", + "Error fetching devices data: Timeout communicating with Ring API", ), ( RingError, - "Error fetching devices data: Error communicating with API for device Front: ", + "Error fetching devices data: Error communicating with Ring API", ), ], ids=["timeout-error", "other-error"], @@ -218,7 +239,7 @@ async def test_error_on_device_update( hass: HomeAssistant, mock_ring_client, mock_ring_devices, - mock_config_entry: MockConfigEntry, + mock_config_entry: RingConfigEntry, freezer: FrozenDateTimeFactory, caplog: pytest.LogCaptureFixture, error_type, @@ -229,15 +250,36 @@ async def test_error_on_device_update( await hass.config_entries.async_setup(mock_config_entry.entry_id) await hass.async_block_till_done() - front_door_doorbell = mock_ring_devices.get_device(765432) - front_door_doorbell.async_history.side_effect = error_type + coordinator = mock_config_entry.runtime_data.devices_coordinator + assert coordinator - freezer.tick(SCAN_INTERVAL) - async_fire_time_changed(hass) - await hass.async_block_till_done(wait_background_tasks=True) + with patch.object( + coordinator, "_async_update_data", wraps=coordinator._async_update_data + ) as refresh_spy: + error = error_type("Some internal error info 1") + front_door_doorbell = mock_ring_devices.get_device(765432) + front_door_doorbell.async_history.side_effect = error - assert log_msg in caplog.text - assert hass.config_entries.async_get_entry(mock_config_entry.entry_id) + freezer.tick(SCAN_INTERVAL * 2) + async_fire_time_changed(hass) + await hass.async_block_till_done(wait_background_tasks=True) + + refresh_spy.assert_called() + assert coordinator.last_exception.__cause__ == error + assert log_msg in caplog.text + + # Check log is not being spammed. + error2 = error_type("Some internal error info 2") + front_door_doorbell.async_history.side_effect = error2 + refresh_spy.reset_mock() + caplog.clear() + freezer.tick(SCAN_INTERVAL * 2) + async_fire_time_changed(hass) + await hass.async_block_till_done(wait_background_tasks=True) + + refresh_spy.assert_called() + assert coordinator.last_exception.__cause__ == error2 + assert log_msg not in caplog.text @pytest.mark.parametrize( diff --git a/tests/components/schedule/snapshots/test_init.ambr b/tests/components/schedule/snapshots/test_init.ambr new file mode 100644 index 00000000000..93cde4f5733 --- /dev/null +++ b/tests/components/schedule/snapshots/test_init.ambr @@ -0,0 +1,59 @@ +# serializer version: 1 +# name: test_service_get[schedule.from_storage-get-after-update] + dict({ + 'friday': list([ + ]), + 'monday': list([ + ]), + 'saturday': list([ + ]), + 'sunday': list([ + ]), + 'thursday': list([ + ]), + 'tuesday': list([ + ]), + 'wednesday': list([ + dict({ + 'from': datetime.time(17, 0), + 'to': datetime.time(19, 0), + }), + ]), + }) +# --- +# name: test_service_get[schedule.from_storage-get] + dict({ + 'friday': list([ + dict({ + 'data': dict({ + 'party_level': 'epic', + }), + 'from': datetime.time(17, 0), + 'to': datetime.time(23, 59, 59), + }), + ]), + 'monday': list([ + ]), + 'saturday': list([ + dict({ + 'from': datetime.time(0, 0), + 'to': datetime.time(23, 59, 59), + }), + ]), + 'sunday': list([ + dict({ + 'data': dict({ + 'entry': 'VIPs only', + }), + 'from': datetime.time(0, 0), + 'to': datetime.time(23, 59, 59, 999999), + }), + ]), + 'thursday': list([ + ]), + 'tuesday': list([ + ]), + 'wednesday': list([ + ]), + }) +# --- diff --git a/tests/components/schedule/test_init.py b/tests/components/schedule/test_init.py index 18346122bfd..fef2ff745cd 100644 --- a/tests/components/schedule/test_init.py +++ b/tests/components/schedule/test_init.py @@ -8,10 +8,12 @@ from unittest.mock import patch from freezegun.api import FrozenDateTimeFactory import pytest +from syrupy.assertion import SnapshotAssertion from homeassistant.components.schedule import STORAGE_VERSION, STORAGE_VERSION_MINOR from homeassistant.components.schedule.const import ( ATTR_NEXT_EVENT, + CONF_ALL_DAYS, CONF_DATA, CONF_FRIDAY, CONF_FROM, @@ -23,12 +25,14 @@ from homeassistant.components.schedule.const import ( CONF_TUESDAY, CONF_WEDNESDAY, DOMAIN, + SERVICE_GET, ) from homeassistant.const import ( ATTR_EDITABLE, ATTR_FRIENDLY_NAME, ATTR_ICON, ATTR_NAME, + CONF_ENTITY_ID, CONF_ICON, CONF_ID, CONF_NAME, @@ -754,3 +758,66 @@ async def test_ws_create( assert result["party_mode"][CONF_MONDAY] == [ {CONF_FROM: "12:00:00", CONF_TO: saved_to} ] + + +async def test_service_get( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + snapshot: SnapshotAssertion, + schedule_setup: Callable[..., Coroutine[Any, Any, bool]], +) -> None: + """Test getting a single schedule via service.""" + assert await schedule_setup() + + entity_id = "schedule.from_storage" + + # Test retrieving a single schedule via service call + service_result = await hass.services.async_call( + DOMAIN, + SERVICE_GET, + { + CONF_ENTITY_ID: entity_id, + }, + blocking=True, + return_response=True, + ) + result = service_result.get(entity_id) + + assert set(result) == CONF_ALL_DAYS + assert result == snapshot(name=f"{entity_id}-get") + + # Now we update the schedule via WS + client = await hass_ws_client(hass) + await client.send_json( + { + "id": 1, + "type": f"{DOMAIN}/update", + f"{DOMAIN}_id": entity_id.rsplit(".", maxsplit=1)[-1], + CONF_NAME: "Party pooper", + CONF_ICON: "mdi:party-pooper", + CONF_MONDAY: [], + CONF_TUESDAY: [], + CONF_WEDNESDAY: [{CONF_FROM: "17:00:00", CONF_TO: "19:00:00"}], + CONF_THURSDAY: [], + CONF_FRIDAY: [], + CONF_SATURDAY: [], + CONF_SUNDAY: [], + } + ) + resp = await client.receive_json() + assert resp["success"] + + # Test retrieving the schedule via service call after WS update + service_result = await hass.services.async_call( + DOMAIN, + SERVICE_GET, + { + CONF_ENTITY_ID: entity_id, + }, + blocking=True, + return_response=True, + ) + result = service_result.get(entity_id) + + assert set(result) == CONF_ALL_DAYS + assert result == snapshot(name=f"{entity_id}-get-after-update") diff --git a/tests/components/screenlogic/test_config_flow.py b/tests/components/screenlogic/test_config_flow.py index 5ce777a47fa..ad8ef125dac 100644 --- a/tests/components/screenlogic/test_config_flow.py +++ b/tests/components/screenlogic/test_config_flow.py @@ -86,6 +86,53 @@ async def test_flow_discover_none(hass: HomeAssistant) -> None: assert result["step_id"] == "gateway_entry" +async def test_flow_replace_ignored(hass: HomeAssistant) -> None: + """Test we can replace ignored entries.""" + entry = MockConfigEntry( + domain=DOMAIN, + unique_id="00:c0:33:01:01:01", + source=config_entries.SOURCE_IGNORE, + ) + entry.add_to_hass(hass) + + with patch( + "homeassistant.components.screenlogic.config_flow.discovery.async_discover", + return_value=[ + { + SL_GATEWAY_IP: "1.1.1.1", + SL_GATEWAY_PORT: 80, + SL_GATEWAY_TYPE: 12, + SL_GATEWAY_SUBTYPE: 2, + SL_GATEWAY_NAME: "Pentair: 01-01-01", + }, + ], + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {} + assert result["step_id"] == "gateway_select" + + with patch( + "homeassistant.components.screenlogic.async_setup_entry", + return_value=True, + ) as mock_setup_entry: + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={GATEWAY_SELECT_KEY: "00:c0:33:01:01:01"} + ) + await hass.async_block_till_done() + + assert result2["type"] is FlowResultType.CREATE_ENTRY + assert result2["title"] == "Pentair: 01-01-01" + assert result2["data"] == { + CONF_IP_ADDRESS: "1.1.1.1", + CONF_PORT: 80, + } + assert len(mock_setup_entry.mock_calls) == 1 + + async def test_flow_discover_error(hass: HomeAssistant) -> None: """Test when discovery errors.""" diff --git a/tests/components/sensorpush/test_config_flow.py b/tests/components/sensorpush/test_config_flow.py index 7e87dd1c6b8..194f4fc4a78 100644 --- a/tests/components/sensorpush/test_config_flow.py +++ b/tests/components/sensorpush/test_config_flow.py @@ -79,6 +79,38 @@ async def test_async_step_user_with_found_devices(hass: HomeAssistant) -> None: assert result2["result"].unique_id == "61DE521B-F0BF-9F44-64D4-75BBE1738105" +async def test_async_step_user_replace_ignored(hass: HomeAssistant) -> None: + """Test setup from service info can replace an ignored entry.""" + entry = MockConfigEntry( + domain=DOMAIN, + unique_id=HTW_SERVICE_INFO.address, + source=config_entries.SOURCE_IGNORE, + data={}, + ) + entry.add_to_hass(hass) + with patch( + "homeassistant.components.sensorpush.config_flow.async_discovered_service_info", + return_value=[HTW_SERVICE_INFO], + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_USER}, + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + with patch( + "homeassistant.components.sensorpush.async_setup_entry", return_value=True + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={"address": "61DE521B-F0BF-9F44-64D4-75BBE1738105"}, + ) + assert result2["type"] is FlowResultType.CREATE_ENTRY + assert result2["title"] == "HT.w 0CA1" + assert result2["data"] == {} + assert result2["result"].unique_id == "61DE521B-F0BF-9F44-64D4-75BBE1738105" + + async def test_async_step_user_device_added_between_steps(hass: HomeAssistant) -> None: """Test the device gets added via another flow between steps.""" with patch( diff --git a/tests/components/shelly/conftest.py b/tests/components/shelly/conftest.py index 85cd558e918..b3074742949 100644 --- a/tests/components/shelly/conftest.py +++ b/tests/components/shelly/conftest.py @@ -2,6 +2,15 @@ from unittest.mock import AsyncMock, Mock, PropertyMock, patch +from aioshelly.ble.const import ( + BLE_CODE, + BLE_SCAN_RESULT_EVENT, + BLE_SCAN_RESULT_VERSION, + BLE_SCRIPT_NAME, + VAR_ACTIVE, + VAR_EVENT_TYPE, + VAR_VERSION, +) from aioshelly.block_device import BlockDevice, BlockUpdateType from aioshelly.const import MODEL_1, MODEL_25, MODEL_PLUS_2PM from aioshelly.rpc_device import RpcDevice, RpcUpdateType @@ -180,6 +189,7 @@ MOCK_CONFIG = { "xcounts": {"expr": None, "unit": None}, "xfreq": {"expr": None, "unit": None}, }, + "flood:0": {"id": 0, "name": "Test name"}, "light:0": {"name": "test light_0"}, "light:1": {"name": "test light_1"}, "light:2": {"name": "test light_2"}, @@ -200,6 +210,9 @@ MOCK_CONFIG = { "wifi": {"sta": {"enable": True}, "sta1": {"enable": False}}, "ws": {"enable": False, "server": None}, "voltmeter:100": {"xvoltage": {"unit": "ppm"}}, + "script:1": {"id": 1, "name": "test_script.js", "enable": True}, + "script:2": {"id": 2, "name": "test_script_2.js", "enable": False}, + "script:3": {"id": 3, "name": BLE_SCRIPT_NAME, "enable": False}, } @@ -326,6 +339,7 @@ MOCK_STATUS_RPC = { "em1:1": {"act_power": 123.3}, "em1data:0": {"total_act_energy": 123456.4}, "em1data:1": {"total_act_energy": 987654.3}, + "flood:0": {"id": 0, "alarm": False, "mute": False}, "thermostat:0": { "id": 0, "enable": True, @@ -333,6 +347,15 @@ MOCK_STATUS_RPC = { "current_C": 12.3, "output": True, }, + "script:1": { + "id": 1, + "running": True, + "mem_used": 826, + "mem_peak": 1666, + "mem_free": 24360, + }, + "script:2": {"id": 2, "running": False}, + "script:3": {"id": 3, "running": False}, "humidity:0": {"rh": 44.4}, "sys": { "available_updates": { @@ -345,6 +368,28 @@ MOCK_STATUS_RPC = { "wifi": {"rssi": -63}, } +MOCK_SCRIPTS = [ + """" +function eventHandler(event, userdata) { + if (typeof event.component !== "string") + return; + + let component = event.component.substring(0, 5); + if (component === "input") { + let id = Number(event.component.substring(6)); + Shelly.emitEvent("input_event", { id: id }); + } +} + +Shelly.addEventHandler(eventHandler); +Shelly.emitEvent("script_start"); +""", + 'console.log("Hello World!")', + BLE_CODE.replace(VAR_ACTIVE, "true") + .replace(VAR_EVENT_TYPE, BLE_SCAN_RESULT_EVENT) + .replace(VAR_VERSION, str(BLE_SCAN_RESULT_VERSION)), +] + @pytest.fixture(autouse=True) def mock_coap(): @@ -428,6 +473,9 @@ def _mock_rpc_device(version: str | None = None): firmware_version="some fw string", initialized=True, connected=True, + script_getcode=AsyncMock( + side_effect=lambda script_id: {"data": MOCK_SCRIPTS[script_id - 1]} + ), ) type(device).name = PropertyMock(return_value="Test name") return device diff --git a/tests/components/shelly/snapshots/test_binary_sensor.ambr b/tests/components/shelly/snapshots/test_binary_sensor.ambr index 8dcb7b00a42..942bcaad8ab 100644 --- a/tests/components/shelly/snapshots/test_binary_sensor.ambr +++ b/tests/components/shelly/snapshots/test_binary_sensor.ambr @@ -46,3 +46,96 @@ 'state': 'off', }) # --- +# name: test_rpc_flood_entities[binary_sensor.test_name_flood-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.test_name_flood', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Test name flood', + 'platform': 'shelly', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '123456789ABC-flood:0-flood', + 'unit_of_measurement': None, + }) +# --- +# name: test_rpc_flood_entities[binary_sensor.test_name_flood-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'moisture', + 'friendly_name': 'Test name flood', + }), + 'context': , + 'entity_id': 'binary_sensor.test_name_flood', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_rpc_flood_entities[binary_sensor.test_name_mute-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': , + 'entity_id': 'binary_sensor.test_name_mute', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Test name mute', + 'platform': 'shelly', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '123456789ABC-flood:0-mute', + 'unit_of_measurement': None, + }) +# --- +# name: test_rpc_flood_entities[binary_sensor.test_name_mute-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Test name mute', + }), + 'context': , + 'entity_id': 'binary_sensor.test_name_mute', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- diff --git a/tests/components/shelly/snapshots/test_event.ambr b/tests/components/shelly/snapshots/test_event.ambr new file mode 100644 index 00000000000..51129b7e249 --- /dev/null +++ b/tests/components/shelly/snapshots/test_event.ambr @@ -0,0 +1,69 @@ +# serializer version: 1 +# name: test_rpc_script_1_event[event.test_name_test_script_js-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'event_types': list([ + 'input_event', + 'script_start', + ]), + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'event', + 'entity_category': None, + 'entity_id': 'event.test_name_test_script_js', + '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': 'test_script.js', + 'platform': 'shelly', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'script', + 'unique_id': '123456789ABC-script:1', + 'unit_of_measurement': None, + }) +# --- +# name: test_rpc_script_1_event[event.test_name_test_script_js-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'event_type': None, + 'event_types': list([ + 'input_event', + 'script_start', + ]), + 'friendly_name': 'Test name test_script.js', + }), + 'context': , + 'entity_id': 'event.test_name_test_script_js', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_rpc_script_2_event[event.test_name_test_script_2_js-entry] + None +# --- +# name: test_rpc_script_2_event[event.test_name_test_script_2_js-state] + None +# --- +# name: test_rpc_script_ble_event[event.test_name_aioshelly_ble_integration-entry] + None +# --- +# name: test_rpc_script_ble_event[event.test_name_aioshelly_ble_integration-state] + None +# --- diff --git a/tests/components/shelly/test_binary_sensor.py b/tests/components/shelly/test_binary_sensor.py index bff6d199d0e..7f2d07b1ccc 100644 --- a/tests/components/shelly/test_binary_sensor.py +++ b/tests/components/shelly/test_binary_sensor.py @@ -496,3 +496,22 @@ async def test_blu_trv_binary_sensor_entity( entry = entity_registry.async_get(entity_id) assert entry == snapshot(name=f"{entity_id}-entry") + + +async def test_rpc_flood_entities( + hass: HomeAssistant, + mock_rpc_device: Mock, + entity_registry: EntityRegistry, + snapshot: SnapshotAssertion, +) -> None: + """Test RPC flood sensor entities.""" + await init_integration(hass, 4) + + for entity in ("flood", "mute"): + entity_id = f"{BINARY_SENSOR_DOMAIN}.test_name_{entity}" + + state = hass.states.get(entity_id) + assert state == snapshot(name=f"{entity_id}-state") + + entry = entity_registry.async_get(entity_id) + assert entry == snapshot(name=f"{entity_id}-entry") diff --git a/tests/components/shelly/test_event.py b/tests/components/shelly/test_event.py index 2465b016808..e184c154697 100644 --- a/tests/components/shelly/test_event.py +++ b/tests/components/shelly/test_event.py @@ -2,9 +2,11 @@ from unittest.mock import Mock +from aioshelly.ble.const import BLE_SCRIPT_NAME from aioshelly.const import MODEL_I3 import pytest from pytest_unordered import unordered +from syrupy import SnapshotAssertion from homeassistant.components.event import ( ATTR_EVENT_TYPE, @@ -64,6 +66,99 @@ async def test_rpc_button( assert state.attributes.get(ATTR_EVENT_TYPE) == "single_push" +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +async def test_rpc_script_1_event( + hass: HomeAssistant, + mock_rpc_device: Mock, + entity_registry: EntityRegistry, + monkeypatch: pytest.MonkeyPatch, + snapshot: SnapshotAssertion, +) -> None: + """Test script event.""" + await init_integration(hass, 2) + entity_id = "event.test_name_test_script_js" + + state = hass.states.get(entity_id) + assert state == snapshot(name=f"{entity_id}-state") + + entry = entity_registry.async_get(entity_id) + assert entry == snapshot(name=f"{entity_id}-entry") + + inject_rpc_device_event( + monkeypatch, + mock_rpc_device, + { + "events": [ + { + "component": "script:1", + "id": 1, + "event": "script_start", + "ts": 1668522399.2, + } + ], + "ts": 1668522399.2, + }, + ) + await hass.async_block_till_done() + + state = hass.states.get(entity_id) + assert state.attributes.get(ATTR_EVENT_TYPE) == "script_start" + + inject_rpc_device_event( + monkeypatch, + mock_rpc_device, + { + "events": [ + { + "component": "script:1", + "id": 1, + "event": "unknown_event", + "ts": 1668522399.2, + } + ], + "ts": 1668522399.2, + }, + ) + await hass.async_block_till_done() + + state = hass.states.get(entity_id) + assert state.attributes.get(ATTR_EVENT_TYPE) != "unknown_event" + + +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +async def test_rpc_script_2_event( + hass: HomeAssistant, + entity_registry: EntityRegistry, + snapshot: SnapshotAssertion, +) -> None: + """Test that scripts without any emitEvent will not get an event entity.""" + await init_integration(hass, 2) + entity_id = "event.test_name_test_script_2_js" + + state = hass.states.get(entity_id) + assert state == snapshot(name=f"{entity_id}-state") + + entry = entity_registry.async_get(entity_id) + assert entry == snapshot(name=f"{entity_id}-entry") + + +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +async def test_rpc_script_ble_event( + hass: HomeAssistant, + entity_registry: EntityRegistry, + snapshot: SnapshotAssertion, +) -> None: + """Test that the ble script will not get an event entity.""" + await init_integration(hass, 2) + entity_id = f"event.test_name_{BLE_SCRIPT_NAME}" + + state = hass.states.get(entity_id) + assert state == snapshot(name=f"{entity_id}-state") + + entry = entity_registry.async_get(entity_id) + assert entry == snapshot(name=f"{entity_id}-entry") + + async def test_rpc_event_removal( hass: HomeAssistant, mock_rpc_device: Mock, diff --git a/tests/components/smlight/conftest.py b/tests/components/smlight/conftest.py index 665a55ba880..0b1bf24c19a 100644 --- a/tests/components/smlight/conftest.py +++ b/tests/components/smlight/conftest.py @@ -18,7 +18,8 @@ from tests.common import ( load_json_object_fixture, ) -MOCK_HOST = "slzb-06.local" +MOCK_DEVICE_NAME = "slzb-06" +MOCK_HOST = "192.168.1.161" MOCK_USERNAME = "test-user" MOCK_PASSWORD = "test-pass" @@ -91,7 +92,10 @@ def mock_smlight_client(request: pytest.FixtureRequest) -> Generator[MagicMock]: """Return the firmware version.""" fw_list = [] if kwargs.get("mode") == "zigbee": - fw_list = load_json_array_fixture("zb_firmware.json", DOMAIN) + if kwargs.get("zb_type") == 0: + fw_list = load_json_array_fixture("zb_firmware.json", DOMAIN) + else: + fw_list = load_json_array_fixture("zb_firmware_router.json", DOMAIN) else: fw_list = load_json_array_fixture("esp_firmware.json", DOMAIN) diff --git a/tests/components/smlight/fixtures/esp_firmware.json b/tests/components/smlight/fixtures/esp_firmware.json index 6ea0e1a8b44..f0ee9eb989a 100644 --- a/tests/components/smlight/fixtures/esp_firmware.json +++ b/tests/components/smlight/fixtures/esp_firmware.json @@ -2,10 +2,10 @@ { "mode": "ESP", "type": null, - "notes": "CHANGELOG (Current 2.5.2 vs. Previous 2.3.6):\\r\\nFixed incorrect device type detection for some devices\\r\\nFixed web interface not working on some devices\\r\\nFixed disabled SSID/pass fields\\r\\n", + "notes": "CHANGELOG (Current 2.7.5 vs. Previous 2.3.6):\\r\\nFixed incorrect device type detection for some devices\\r\\nFixed web interface not working on some devices\\r\\nFixed disabled SSID/pass fields\\r\\n", "rev": "20240830", "link": "https://smlight.tech/flasher/firmware/bin/slzb06x/core/slzb-06-v2.5.2-ota.bin", - "ver": "v2.5.2", + "ver": "v2.7.5", "dev": false, "prod": true, "baud": null diff --git a/tests/components/smlight/fixtures/info-2.3.6.json b/tests/components/smlight/fixtures/info-2.3.6.json new file mode 100644 index 00000000000..e3defb4410e --- /dev/null +++ b/tests/components/smlight/fixtures/info-2.3.6.json @@ -0,0 +1,19 @@ +{ + "coord_mode": 0, + "device_ip": "192.168.1.161", + "fs_total": 3456, + "fw_channel": "dev", + "legacy_api": 0, + "hostname": "SLZB-06p7", + "MAC": "AA:BB:CC:DD:EE:FF", + "model": "SLZB-06p7", + "ram_total": 296, + "sw_version": "v2.3.6", + "wifi_mode": 0, + "zb_flash_size": 704, + "zb_channel": 0, + "zb_hw": "CC2652P7", + "zb_ram_size": 152, + "zb_version": "20240314", + "zb_type": 0 +} diff --git a/tests/components/smlight/fixtures/info-MR1.json b/tests/components/smlight/fixtures/info-MR1.json new file mode 100644 index 00000000000..df1c0b0f789 --- /dev/null +++ b/tests/components/smlight/fixtures/info-MR1.json @@ -0,0 +1,41 @@ +{ + "coord_mode": 0, + "device_ip": "192.168.1.161", + "fs_total": 3456, + "fw_channel": "dev", + "legacy_api": 0, + "hostname": "SLZB-MR1", + "MAC": "AA:BB:CC:DD:EE:FF", + "model": "SLZB-MR1", + "ram_total": 296, + "sw_version": "v2.7.3", + "wifi_mode": 0, + "zb_flash_size": 704, + "zb_channel": 0, + "zb_hw": "CC2652P7", + "zb_ram_size": 152, + "zb_version": "20240314", + "zb_type": 0, + "radios": [ + { + "chip_index": 0, + "zb_hw": "EFR32MG21", + "zb_version": 20241127, + "zb_type": 0, + "zb_channel": 0, + "zb_ram_size": 152, + "zb_flash_size": 704, + "radioModes": [true, true, true, false, false] + }, + { + "chip_index": 1, + "zb_hw": "CC2652P7", + "zb_version": 20240314, + "zb_type": 1, + "zb_channel": 0, + "zb_ram_size": 152, + "zb_flash_size": 704, + "radioModes": [true, true, true, false, false] + } + ] +} diff --git a/tests/components/smlight/fixtures/zb_firmware.json b/tests/components/smlight/fixtures/zb_firmware.json index ca9d10f87ac..b35bb20d64e 100644 --- a/tests/components/smlight/fixtures/zb_firmware.json +++ b/tests/components/smlight/fixtures/zb_firmware.json @@ -3,24 +3,13 @@ "mode": "ZB", "type": 0, "notes": "SMLIGHT latest Coordinator release for CC2674P10 chips [16-Jul-2024]:
- +20dB TRANSMIT POWER SUPPORT;
- SDK 7.41 based (latest);
", - "rev": "20240716", + "rev": "20250201", "link": "https://smlight.tech/flasher/firmware/bin/slzb06x/zigbee/slzb06p10/znp-SLZB-06P10-20240716.bin", - "ver": "20240716", + "ver": "20250201", "dev": false, "prod": true, "baud": 115200 }, - { - "mode": "ZB", - "type": 1, - "notes": "SMLIGHT latest ROUTER release for CC2674P10 chips [16-Jul-2024]:
- SDK 7.41 based (latest);
Terms of use", - "rev": "20240716", - "link": "https://smlight.tech/flasher/firmware/bin/slzb06x/zigbee/slzb06p10/zr-ZR_SLZB-06P10-20240716.bin", - "ver": "20240716", - "dev": false, - "prod": true, - "baud": 0 - }, { "mode": "ZB", "type": 0, diff --git a/tests/components/smlight/fixtures/zb_firmware_router.json b/tests/components/smlight/fixtures/zb_firmware_router.json new file mode 100644 index 00000000000..320fef89347 --- /dev/null +++ b/tests/components/smlight/fixtures/zb_firmware_router.json @@ -0,0 +1,13 @@ +[ + { + "mode": "ZB", + "type": 1, + "notes": "SMLIGHT latest ROUTER release for CC2652P7 chips [16-Jul-2024]:
- SDK 7.41 based (latest);
Terms of use - by downloading and installing this firmware, you agree to the aforementioned terms.", + "rev": "20240716", + "link": "https://smlight.tech/flasher/firmware/bin/slzb06x/zigbee/slzb06p10/znp-SLZB-06P10-20240716.bin", + "ver": "20240716", + "dev": false, + "prod": true, + "baud": 115200 + } +] diff --git a/tests/components/smlight/snapshots/test_init.ambr b/tests/components/smlight/snapshots/test_init.ambr index 598166e537b..457a529065c 100644 --- a/tests/components/smlight/snapshots/test_init.ambr +++ b/tests/components/smlight/snapshots/test_init.ambr @@ -3,7 +3,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , - 'configuration_url': 'http://slzb-06.local', + 'configuration_url': 'http://192.168.1.161', 'connections': set({ tuple( 'mac', diff --git a/tests/components/smlight/snapshots/test_update.ambr b/tests/components/smlight/snapshots/test_update.ambr index ed0085dcdc8..8c6757d5b91 100644 --- a/tests/components/smlight/snapshots/test_update.ambr +++ b/tests/components/smlight/snapshots/test_update.ambr @@ -42,7 +42,7 @@ 'friendly_name': 'Mock Title Core firmware', 'in_progress': False, 'installed_version': 'v2.3.6', - 'latest_version': 'v2.5.2', + 'latest_version': 'v2.7.5', 'release_summary': None, 'release_url': None, 'skipped_version': None, @@ -101,7 +101,7 @@ 'friendly_name': 'Mock Title Zigbee firmware', 'in_progress': False, 'installed_version': '20240314', - 'latest_version': '20240716', + 'latest_version': '20250201', 'release_summary': None, 'release_url': None, 'skipped_version': None, diff --git a/tests/components/smlight/test_config_flow.py b/tests/components/smlight/test_config_flow.py index c4aea195aa7..a1c9c9d6945 100644 --- a/tests/components/smlight/test_config_flow.py +++ b/tests/components/smlight/test_config_flow.py @@ -8,19 +8,20 @@ from pysmlight.exceptions import SmlightAuthError, SmlightConnectionError import pytest from homeassistant.components.smlight.const import DOMAIN -from homeassistant.config_entries import SOURCE_USER, SOURCE_ZEROCONF +from homeassistant.config_entries import SOURCE_DHCP, SOURCE_USER, SOURCE_ZEROCONF from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType +from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo -from .conftest import MOCK_HOST, MOCK_PASSWORD, MOCK_USERNAME +from .conftest import MOCK_DEVICE_NAME, MOCK_HOST, MOCK_PASSWORD, MOCK_USERNAME from tests.common import MockConfigEntry DISCOVERY_INFO = ZeroconfServiceInfo( - ip_address=ip_address("127.0.0.1"), - ip_addresses=[ip_address("127.0.0.1")], + ip_address=ip_address("192.168.1.161"), + ip_addresses=[ip_address("192.168.1.161")], hostname="slzb-06.local.", name="mock_name", port=6638, @@ -29,8 +30,8 @@ DISCOVERY_INFO = ZeroconfServiceInfo( ) DISCOVERY_INFO_LEGACY = ZeroconfServiceInfo( - ip_address=ip_address("127.0.0.1"), - ip_addresses=[ip_address("127.0.0.1")], + ip_address=ip_address("192.168.1.161"), + ip_addresses=[ip_address("192.168.1.161")], hostname="slzb-06.local.", name="mock_name", port=6638, @@ -52,7 +53,7 @@ async def test_user_flow(hass: HomeAssistant, mock_setup_entry: AsyncMock) -> No result2 = await hass.config_entries.flow.async_configure( result["flow_id"], { - CONF_HOST: MOCK_HOST, + CONF_HOST: "slzb-06p7.local", }, ) @@ -76,7 +77,7 @@ async def test_zeroconf_flow( DOMAIN, context={"source": SOURCE_ZEROCONF}, data=DISCOVERY_INFO ) - assert result["description_placeholders"] == {"host": MOCK_HOST} + assert result["description_placeholders"] == {"host": MOCK_DEVICE_NAME} assert result["type"] is FlowResultType.FORM assert result["step_id"] == "confirm_discovery" @@ -113,7 +114,7 @@ async def test_zeroconf_flow_auth( DOMAIN, context={"source": SOURCE_ZEROCONF}, data=DISCOVERY_INFO ) - assert result["description_placeholders"] == {"host": MOCK_HOST} + assert result["description_placeholders"] == {"host": MOCK_DEVICE_NAME} assert result["type"] is FlowResultType.FORM assert result["step_id"] == "confirm_discovery" @@ -167,7 +168,7 @@ async def test_zeroconf_unsupported_abort( DOMAIN, context={"source": SOURCE_ZEROCONF}, data=DISCOVERY_INFO ) - assert result["description_placeholders"] == {"host": MOCK_HOST} + assert result["description_placeholders"] == {"host": MOCK_DEVICE_NAME} assert result["type"] is FlowResultType.FORM assert result["step_id"] == "confirm_discovery" @@ -261,6 +262,44 @@ async def test_user_device_exists_abort( assert result["reason"] == "already_configured" +@pytest.mark.usefixtures("mock_smlight_client") +async def test_user_flow_can_override_discovery( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_setup_entry: AsyncMock, +) -> None: + """Test manual user flow can override discovery in progress.""" + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_ZEROCONF}, data=DISCOVERY_INFO + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "confirm_discovery" + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == SOURCE_USER + assert result["errors"] == {} + + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_HOST: MOCK_HOST, + }, + ) + + assert result2["type"] is FlowResultType.CREATE_ENTRY + assert result2["context"]["source"] == SOURCE_USER + assert result2["data"] == { + CONF_HOST: MOCK_HOST, + } + assert result2["context"]["unique_id"] == "aa:bb:cc:dd:ee:ff" + assert len(mock_setup_entry.mock_calls) == 1 + + @pytest.mark.usefixtures("mock_smlight_client") async def test_zeroconf_device_exists_abort( hass: HomeAssistant, mock_config_entry: MockConfigEntry @@ -451,7 +490,7 @@ async def test_zeroconf_legacy_mac( data=DISCOVERY_INFO_LEGACY, ) - assert result["description_placeholders"] == {"host": MOCK_HOST} + assert result["description_placeholders"] == {"host": MOCK_DEVICE_NAME} result2 = await hass.config_entries.flow.async_configure( result["flow_id"], user_input={} @@ -469,6 +508,76 @@ async def test_zeroconf_legacy_mac( assert len(mock_smlight_client.get_info.mock_calls) == 3 +@pytest.mark.usefixtures("mock_smlight_client") +async def test_zeroconf_updates_host( + hass: HomeAssistant, + mock_setup_entry: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test zeroconf discovery updates host ip.""" + mock_config_entry.add_to_hass(hass) + + service_info = DISCOVERY_INFO + service_info.ip_address = ip_address("192.168.1.164") + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_ZEROCONF}, data=service_info + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" + + assert mock_config_entry.data[CONF_HOST] == "192.168.1.164" + + +@pytest.mark.usefixtures("mock_smlight_client") +async def test_dhcp_discovery_updates_host( + hass: HomeAssistant, + mock_setup_entry: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test dhcp discovery updates host ip.""" + mock_config_entry.add_to_hass(hass) + + service_info = DhcpServiceInfo( + ip="192.168.1.164", + hostname="slzb-06", + macaddress="aabbccddeeff", + ) + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_DHCP}, data=service_info + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" + + assert mock_config_entry.data[CONF_HOST] == "192.168.1.164" + + +@pytest.mark.usefixtures("mock_smlight_client") +async def test_dhcp_discovery_aborts( + hass: HomeAssistant, + mock_setup_entry: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test dhcp discovery updates host ip.""" + mock_config_entry.add_to_hass(hass) + + service_info = DhcpServiceInfo( + ip="192.168.1.161", + hostname="slzb-06", + macaddress="000000000000", + ) + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_DHCP}, data=service_info + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" + + assert mock_config_entry.data[CONF_HOST] == "192.168.1.161" + + async def test_reauth_flow( hass: HomeAssistant, mock_smlight_client: MagicMock, diff --git a/tests/components/smlight/test_init.py b/tests/components/smlight/test_init.py index d0c5e494ae8..0acbab9f3a4 100644 --- a/tests/components/smlight/test_init.py +++ b/tests/components/smlight/test_init.py @@ -85,6 +85,7 @@ async def test_async_setup_no_internet( freezer: FrozenDateTimeFactory, ) -> None: """Test we still load integration when no internet is available.""" + side_effect = mock_smlight_client.get_firmware_version.side_effect mock_smlight_client.get_firmware_version.side_effect = SmlightConnectionError await setup_integration(hass, mock_config_entry_host) @@ -101,7 +102,7 @@ async def test_async_setup_no_internet( assert entity is not None assert entity.state == STATE_UNKNOWN - mock_smlight_client.get_firmware_version.side_effect = None + mock_smlight_client.get_firmware_version.side_effect = side_effect freezer.tick(SCAN_FIRMWARE_INTERVAL) async_fire_time_changed(hass) diff --git a/tests/components/smlight/test_update.py b/tests/components/smlight/test_update.py index 4fca7369116..632f1b5f26b 100644 --- a/tests/components/smlight/test_update.py +++ b/tests/components/smlight/test_update.py @@ -4,13 +4,13 @@ from datetime import timedelta from unittest.mock import MagicMock, patch from freezegun.api import FrozenDateTimeFactory -from pysmlight import Firmware, Info +from pysmlight import Firmware, Info, Radio from pysmlight.const import Events as SmEvents from pysmlight.sse import MessageEvent import pytest from syrupy.assertion import SnapshotAssertion -from homeassistant.components.smlight.const import SCAN_FIRMWARE_INTERVAL +from homeassistant.components.smlight.const import DOMAIN, SCAN_FIRMWARE_INTERVAL from homeassistant.components.update import ( ATTR_IN_PROGRESS, ATTR_INSTALLED_VERSION, @@ -27,7 +27,12 @@ from homeassistant.helpers import entity_registry as er from . import get_mock_event_function from .conftest import setup_integration -from tests.common import MockConfigEntry, async_fire_time_changed, snapshot_platform +from tests.common import ( + MockConfigEntry, + async_fire_time_changed, + load_json_object_fixture, + snapshot_platform, +) from tests.typing import WebSocketGenerator pytestmark = [ @@ -62,12 +67,14 @@ MOCK_FIRMWARE_FAIL = MessageEvent( MOCK_FIRMWARE_NOTES = [ Firmware( - ver="v2.3.6", + ver="v2.7.2", mode="ESP", notes=None, ) ] +MOCK_RADIO = Radio(chip_index=1, zb_channel=0, zb_type=0, zb_version="20240716") + @pytest.fixture def platforms() -> list[Platform]: @@ -103,7 +110,7 @@ async def test_update_firmware( state = hass.states.get(entity_id) assert state.state == STATE_ON assert state.attributes[ATTR_INSTALLED_VERSION] == "v2.3.6" - assert state.attributes[ATTR_LATEST_VERSION] == "v2.5.2" + assert state.attributes[ATTR_LATEST_VERSION] == "v2.7.5" await hass.services.async_call( PLATFORM, @@ -126,7 +133,7 @@ async def test_update_firmware( event_function(MOCK_FIRMWARE_DONE) mock_smlight_client.get_info.return_value = Info( - sw_version="v2.5.2", + sw_version="v2.7.5", ) freezer.tick(timedelta(seconds=5)) @@ -135,8 +142,50 @@ async def test_update_firmware( state = hass.states.get(entity_id) assert state.state == STATE_OFF - assert state.attributes[ATTR_INSTALLED_VERSION] == "v2.5.2" - assert state.attributes[ATTR_LATEST_VERSION] == "v2.5.2" + assert state.attributes[ATTR_INSTALLED_VERSION] == "v2.7.5" + assert state.attributes[ATTR_LATEST_VERSION] == "v2.7.5" + + +async def test_update_zigbee2_firmware( + hass: HomeAssistant, + freezer: FrozenDateTimeFactory, + mock_config_entry: MockConfigEntry, + mock_smlight_client: MagicMock, +) -> None: + """Test update of zigbee2 firmware where available.""" + mock_smlight_client.get_info.return_value = Info.from_dict( + load_json_object_fixture("info-MR1.json", DOMAIN) + ) + await setup_integration(hass, mock_config_entry) + entity_id = "update.mock_title_zigbee_firmware_2" + state = hass.states.get(entity_id) + assert state.state == STATE_ON + assert state.attributes[ATTR_INSTALLED_VERSION] == "20240314" + assert state.attributes[ATTR_LATEST_VERSION] == "20240716" + + await hass.services.async_call( + PLATFORM, + SERVICE_INSTALL, + {ATTR_ENTITY_ID: entity_id}, + blocking=False, + ) + + assert len(mock_smlight_client.fw_update.mock_calls) == 1 + + event_function = get_mock_event_function(mock_smlight_client, SmEvents.FW_UPD_done) + + event_function(MOCK_FIRMWARE_DONE) + with patch( + "homeassistant.components.smlight.update.get_radio", return_value=MOCK_RADIO + ): + freezer.tick(timedelta(seconds=5)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + state = hass.states.get(entity_id) + assert state.state == STATE_OFF + assert state.attributes[ATTR_INSTALLED_VERSION] == "20240716" + assert state.attributes[ATTR_LATEST_VERSION] == "20240716" async def test_update_legacy_firmware_v2( @@ -156,7 +205,7 @@ async def test_update_legacy_firmware_v2( state = hass.states.get(entity_id) assert state.state == STATE_ON assert state.attributes[ATTR_INSTALLED_VERSION] == "v2.0.18" - assert state.attributes[ATTR_LATEST_VERSION] == "v2.5.2" + assert state.attributes[ATTR_LATEST_VERSION] == "v2.7.5" await hass.services.async_call( PLATFORM, @@ -172,7 +221,7 @@ async def test_update_legacy_firmware_v2( event_function(MOCK_FIRMWARE_DONE) mock_smlight_client.get_info.return_value = Info( - sw_version="v2.5.2", + sw_version="v2.7.5", ) freezer.tick(SCAN_FIRMWARE_INTERVAL) @@ -181,8 +230,8 @@ async def test_update_legacy_firmware_v2( state = hass.states.get(entity_id) assert state.state == STATE_OFF - assert state.attributes[ATTR_INSTALLED_VERSION] == "v2.5.2" - assert state.attributes[ATTR_LATEST_VERSION] == "v2.5.2" + assert state.attributes[ATTR_INSTALLED_VERSION] == "v2.7.5" + assert state.attributes[ATTR_LATEST_VERSION] == "v2.7.5" async def test_update_firmware_failed( @@ -196,7 +245,7 @@ async def test_update_firmware_failed( state = hass.states.get(entity_id) assert state.state == STATE_ON assert state.attributes[ATTR_INSTALLED_VERSION] == "v2.3.6" - assert state.attributes[ATTR_LATEST_VERSION] == "v2.5.2" + assert state.attributes[ATTR_LATEST_VERSION] == "v2.7.5" await hass.services.async_call( PLATFORM, @@ -233,7 +282,7 @@ async def test_update_reboot_timeout( state = hass.states.get(entity_id) assert state.state == STATE_ON assert state.attributes[ATTR_INSTALLED_VERSION] == "v2.3.6" - assert state.attributes[ATTR_LATEST_VERSION] == "v2.5.2" + assert state.attributes[ATTR_LATEST_VERSION] == "v2.7.5" with ( patch( @@ -267,18 +316,29 @@ async def test_update_reboot_timeout( mock_warning.assert_called_once() +@pytest.mark.parametrize( + "entity_id", + [ + "update.mock_title_core_firmware", + "update.mock_title_zigbee_firmware", + "update.mock_title_zigbee_firmware_2", + ], +) async def test_update_release_notes( hass: HomeAssistant, + entity_id: str, freezer: FrozenDateTimeFactory, mock_config_entry: MockConfigEntry, mock_smlight_client: MagicMock, hass_ws_client: WebSocketGenerator, ) -> None: """Test firmware release notes.""" + mock_smlight_client.get_info.return_value = Info.from_dict( + load_json_object_fixture("info-MR1.json", DOMAIN) + ) await setup_integration(hass, mock_config_entry) ws_client = await hass_ws_client(hass) await hass.async_block_till_done() - entity_id = "update.mock_title_core_firmware" state = hass.states.get(entity_id) assert state @@ -294,16 +354,30 @@ async def test_update_release_notes( result = await ws_client.receive_json() assert result["result"] is not None + +async def test_update_blank_release_notes( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_smlight_client: MagicMock, + hass_ws_client: WebSocketGenerator, +) -> None: + """Test firmware missing release notes.""" + + entity_id = "update.mock_title_core_firmware" mock_smlight_client.get_firmware_version.side_effect = None mock_smlight_client.get_firmware_version.return_value = MOCK_FIRMWARE_NOTES - freezer.tick(SCAN_FIRMWARE_INTERVAL) - async_fire_time_changed(hass) + await setup_integration(hass, mock_config_entry) + ws_client = await hass_ws_client(hass) await hass.async_block_till_done() + state = hass.states.get(entity_id) + assert state + assert state.state == STATE_ON + await ws_client.send_json( { - "id": 2, + "id": 1, "type": "update/release_notes", "entity_id": entity_id, } diff --git a/tests/components/synology_dsm/test_backup.py b/tests/components/synology_dsm/test_backup.py index cdbc5934c5f..26e09d407ff 100644 --- a/tests/components/synology_dsm/test_backup.py +++ b/tests/components/synology_dsm/test_backup.py @@ -2,7 +2,7 @@ from io import StringIO from typing import Any -from unittest.mock import AsyncMock, MagicMock, Mock, patch +from unittest.mock import ANY, AsyncMock, MagicMock, Mock, patch import pytest from synology_dsm.api.file_station.models import SynoFileFile, SynoFileSharedFolder @@ -36,6 +36,8 @@ from .consts import HOST, MACS, PASSWORD, PORT, SERIAL, USE_SSL, USERNAME from tests.common import MockConfigEntry from tests.typing import ClientSessionGenerator, WebSocketGenerator +BASE_FILENAME = "Automatic_backup_2025.2.0.dev0_-_2025-01-09_20.14_35457323" + class MockStreamReaderChunked(MockStreamReader): """Mock a stream reader with simulated chunked data.""" @@ -46,14 +48,14 @@ class MockStreamReaderChunked(MockStreamReader): async def _mock_download_file(path: str, filename: str) -> MockStreamReader: - if filename == "abcd12ef_meta.json": + if filename == f"{BASE_FILENAME}_meta.json": return MockStreamReader( b'{"addons":[],"backup_id":"abcd12ef","date":"2025-01-09T20:14:35.457323+01:00",' b'"database_included":true,"extra_metadata":{"instance_id":"36b3b7e984da43fc89f7bafb2645fa36",' b'"with_automatic_settings":true},"folders":[],"homeassistant_included":true,' b'"homeassistant_version":"2025.2.0.dev0","name":"Automatic backup 2025.2.0.dev0","protected":true,"size":13916160}' ) - if filename == "abcd12ef.tar": + if filename == f"{BASE_FILENAME}.tar": return MockStreamReaderChunked(b"backup data") raise MockStreamReaderChunked(b"") @@ -61,22 +63,22 @@ async def _mock_download_file(path: str, filename: str) -> MockStreamReader: async def _mock_download_file_meta_ok_tar_missing( path: str, filename: str ) -> MockStreamReader: - if filename == "abcd12ef_meta.json": + if filename == f"{BASE_FILENAME}_meta.json": return MockStreamReader( b'{"addons":[],"backup_id":"abcd12ef","date":"2025-01-09T20:14:35.457323+01:00",' b'"database_included":true,"extra_metadata":{"instance_id":"36b3b7e984da43fc89f7bafb2645fa36",' b'"with_automatic_settings":true},"folders":[],"homeassistant_included":true,' b'"homeassistant_version":"2025.2.0.dev0","name":"Automatic backup 2025.2.0.dev0","protected":true,"size":13916160}' ) - if filename == "abcd12ef.tar": - raise SynologyDSMAPIErrorException("api", "404", "not found") + if filename == f"{BASE_FILENAME}.tar": + raise SynologyDSMAPIErrorException("api", "900", [{"code": 408}]) raise MockStreamReaderChunked(b"") async def _mock_download_file_meta_defect(path: str, filename: str) -> MockStreamReader: - if filename == "abcd12ef_meta.json": + if filename == f"{BASE_FILENAME}_meta.json": return MockStreamReader(b"im not a json") - if filename == "abcd12ef.tar": + if filename == f"{BASE_FILENAME}.tar": return MockStreamReaderChunked(b"backup data") raise MockStreamReaderChunked(b"") @@ -84,7 +86,6 @@ async def _mock_download_file_meta_defect(path: str, filename: str) -> MockStrea @pytest.fixture def mock_dsm_with_filestation(): """Mock a successful service with filestation support.""" - with patch("homeassistant.components.synology_dsm.common.SynologyDSM") as dsm: dsm.login = AsyncMock(return_value=True) dsm.update = AsyncMock(return_value=True) @@ -115,14 +116,14 @@ def mock_dsm_with_filestation(): SynoFileFile( additional=None, is_dir=False, - name="abcd12ef_meta.json", - path="/ha_backup/my_backup_path/abcd12ef_meta.json", + name=f"{BASE_FILENAME}_meta.json", + path=f"/ha_backup/my_backup_path/{BASE_FILENAME}_meta.json", ), SynoFileFile( additional=None, is_dir=False, - name="abcd12ef.tar", - path="/ha_backup/my_backup_path/abcd12ef.tar", + name=f"{BASE_FILENAME}.tar", + path=f"/ha_backup/my_backup_path/{BASE_FILENAME}.tar", ), ] ), @@ -299,6 +300,7 @@ async def test_agents_list_backups( "backup_id": "abcd12ef", "date": "2025-01-09T20:14:35.457323+01:00", "database_included": True, + "extra_metadata": {"instance_id": ANY, "with_automatic_settings": True}, "folders": [], "homeassistant_included": True, "homeassistant_version": "2025.2.0.dev0", @@ -369,6 +371,7 @@ async def test_agents_list_backups_disabled_filestation( "backup_id": "abcd12ef", "date": "2025-01-09T20:14:35.457323+01:00", "database_included": True, + "extra_metadata": {"instance_id": ANY, "with_automatic_settings": True}, "folders": [], "homeassistant_included": True, "homeassistant_version": "2025.2.0.dev0", @@ -522,6 +525,7 @@ async def test_agents_upload( protected=True, size=0, ) + base_filename = "Test_-_1970-01-01_00.00_00000000" with ( patch( @@ -544,9 +548,9 @@ async def test_agents_upload( assert f"Uploading backup {backup_id}" in caplog.text mock: AsyncMock = setup_dsm_with_filestation.file.upload_file assert len(mock.mock_calls) == 2 - assert mock.call_args_list[0].kwargs["filename"] == "test-backup.tar" + assert mock.call_args_list[0].kwargs["filename"] == f"{base_filename}.tar" assert mock.call_args_list[0].kwargs["path"] == "/ha_backup/my_backup_path" - assert mock.call_args_list[1].kwargs["filename"] == "test-backup_meta.json" + assert mock.call_args_list[1].kwargs["filename"] == f"{base_filename}_meta.json" assert mock.call_args_list[1].kwargs["path"] == "/ha_backup/my_backup_path" @@ -572,6 +576,7 @@ async def test_agents_upload_error( protected=True, size=0, ) + base_filename = "Test_-_1970-01-01_00.00_00000000" # fail to upload the tar file with ( @@ -599,7 +604,7 @@ async def test_agents_upload_error( assert "Failed to upload backup" in caplog.text mock: AsyncMock = setup_dsm_with_filestation.file.upload_file assert len(mock.mock_calls) == 1 - assert mock.call_args_list[0].kwargs["filename"] == "test-backup.tar" + assert mock.call_args_list[0].kwargs["filename"] == f"{base_filename}.tar" assert mock.call_args_list[0].kwargs["path"] == "/ha_backup/my_backup_path" # fail to upload the meta json file @@ -630,9 +635,9 @@ async def test_agents_upload_error( assert "Failed to upload backup" in caplog.text mock: AsyncMock = setup_dsm_with_filestation.file.upload_file assert len(mock.mock_calls) == 3 - assert mock.call_args_list[1].kwargs["filename"] == "test-backup.tar" + assert mock.call_args_list[1].kwargs["filename"] == f"{base_filename}.tar" assert mock.call_args_list[1].kwargs["path"] == "/ha_backup/my_backup_path" - assert mock.call_args_list[2].kwargs["filename"] == "test-backup_meta.json" + assert mock.call_args_list[2].kwargs["filename"] == f"{base_filename}_meta.json" assert mock.call_args_list[2].kwargs["path"] == "/ha_backup/my_backup_path" @@ -657,9 +662,9 @@ async def test_agents_delete( assert response["result"] == {"agent_errors": {}} mock: AsyncMock = setup_dsm_with_filestation.file.delete_file assert len(mock.mock_calls) == 2 - assert mock.call_args_list[0].kwargs["filename"] == "abcd12ef.tar" + assert mock.call_args_list[0].kwargs["filename"] == f"{BASE_FILENAME}.tar" assert mock.call_args_list[0].kwargs["path"] == "/ha_backup/my_backup_path" - assert mock.call_args_list[1].kwargs["filename"] == "abcd12ef_meta.json" + assert mock.call_args_list[1].kwargs["filename"] == f"{BASE_FILENAME}_meta.json" assert mock.call_args_list[1].kwargs["path"] == "/ha_backup/my_backup_path" @@ -672,8 +677,15 @@ async def test_agents_delete_not_existing( client = await hass_ws_client(hass) backup_id = "ef34ab12" + setup_dsm_with_filestation.file.download_file = ( + _mock_download_file_meta_ok_tar_missing + ) setup_dsm_with_filestation.file.delete_file = AsyncMock( - side_effect=SynologyDSMAPIErrorException("api", "404", "not found") + side_effect=SynologyDSMAPIErrorException( + "api", + "900", + [{"code": 408, "path": f"/ha_backup/my_backup_path/{backup_id}.tar"}], + ) ) await client.send_json_auto_id( @@ -685,26 +697,40 @@ async def test_agents_delete_not_existing( response = await client.receive_json() assert response["success"] - assert response["result"] == { - "agent_errors": { - "synology_dsm.mocked_syno_dsm_entry": "Failed to delete the backup" - } - } + assert response["result"] == {"agent_errors": {}} +@pytest.mark.parametrize( + ("error", "expected_log"), + [ + ( + SynologyDSMAPIErrorException("api", "100", "Unknown error"), + "{'api': 'api', 'code': '100', 'reason': 'Unknown', 'details': 'Unknown error'}", + ), + ( + SynologyDSMAPIErrorException("api", "900", [{"code": 407}]), + "{'api': 'api', 'code': '900', 'reason': 'Unknown', 'details': [{'code': 407}]", + ), + ( + SynologyDSMAPIErrorException("api", "900", [{"code": 417}]), + "{'api': 'api', 'code': '900', 'reason': 'Unknown', 'details': [{'code': 417}]", + ), + ], +) async def test_agents_delete_error( hass: HomeAssistant, hass_ws_client: WebSocketGenerator, + caplog: pytest.LogCaptureFixture, setup_dsm_with_filestation: MagicMock, + error: SynologyDSMAPIErrorException, + expected_log: str, ) -> None: """Test error while delete backup.""" client = await hass_ws_client(hass) # error while delete backup_id = "abcd12ef" - setup_dsm_with_filestation.file.delete_file.side_effect = ( - SynologyDSMAPIErrorException("api", "404", "not found") - ) + setup_dsm_with_filestation.file.delete_file.side_effect = error await client.send_json_auto_id( { "type": "backup/delete", @@ -716,10 +742,11 @@ async def test_agents_delete_error( assert response["success"] assert response["result"] == { "agent_errors": { - "synology_dsm.mocked_syno_dsm_entry": "Failed to delete the backup" + "synology_dsm.mocked_syno_dsm_entry": "Failed to delete backup" } } + assert f"Failed to delete backup: {expected_log}" in caplog.text mock: AsyncMock = setup_dsm_with_filestation.file.delete_file assert len(mock.mock_calls) == 1 - assert mock.call_args_list[0].kwargs["filename"] == "abcd12ef.tar" + assert mock.call_args_list[0].kwargs["filename"] == f"{BASE_FILENAME}.tar" assert mock.call_args_list[0].kwargs["path"] == "/ha_backup/my_backup_path" diff --git a/tests/components/thermopro/test_config_flow.py b/tests/components/thermopro/test_config_flow.py index 9b9fdd67334..3cf68fb612c 100644 --- a/tests/components/thermopro/test_config_flow.py +++ b/tests/components/thermopro/test_config_flow.py @@ -79,6 +79,38 @@ async def test_async_step_user_with_found_devices(hass: HomeAssistant) -> None: assert result2["result"].unique_id == "4125DDBA-2774-4851-9889-6AADDD4CAC3D" +async def test_async_step_user_replace_ignored(hass: HomeAssistant) -> None: + """Test setup from service info and replace an ignored entry.""" + entry = MockConfigEntry( + domain=DOMAIN, + unique_id=TP357_SERVICE_INFO.address, + data={}, + source=config_entries.SOURCE_IGNORE, + ) + entry.add_to_hass(hass) + with patch( + "homeassistant.components.thermopro.config_flow.async_discovered_service_info", + return_value=[TP357_SERVICE_INFO], + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_USER}, + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + with patch( + "homeassistant.components.thermopro.async_setup_entry", return_value=True + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={"address": "4125DDBA-2774-4851-9889-6AADDD4CAC3D"}, + ) + assert result2["type"] is FlowResultType.CREATE_ENTRY + assert result2["title"] == "TP357 (2142) AC3D" + assert result2["data"] == {} + assert result2["result"].unique_id == "4125DDBA-2774-4851-9889-6AADDD4CAC3D" + + async def test_async_step_user_device_added_between_steps(hass: HomeAssistant) -> None: """Test the device gets added via another flow between steps.""" with patch( diff --git a/tests/components/tilt_ble/test_config_flow.py b/tests/components/tilt_ble/test_config_flow.py index fd996228034..9c9450f3996 100644 --- a/tests/components/tilt_ble/test_config_flow.py +++ b/tests/components/tilt_ble/test_config_flow.py @@ -79,6 +79,37 @@ async def test_async_step_user_with_found_devices(hass: HomeAssistant) -> None: assert result2["result"].unique_id == "F6:0F:28:F2:1F:CB" +async def test_async_step_user_replaces_ignored(hass: HomeAssistant) -> None: + """Test setup from service info can replace an ignored entry.""" + entry = MockConfigEntry( + domain=DOMAIN, + unique_id=TILT_GREEN_SERVICE_INFO.address, + source=config_entries.SOURCE_IGNORE, + ) + entry.add_to_hass(hass) + with patch( + "homeassistant.components.tilt_ble.config_flow.async_discovered_service_info", + return_value=[TILT_GREEN_SERVICE_INFO], + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_USER}, + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + with patch( + "homeassistant.components.tilt_ble.async_setup_entry", return_value=True + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={"address": "F6:0F:28:F2:1F:CB"}, + ) + assert result2["type"] is FlowResultType.CREATE_ENTRY + assert result2["title"] == "Tilt Green" + assert result2["data"] == {} + assert result2["result"].unique_id == "F6:0F:28:F2:1F:CB" + + async def test_async_step_user_device_added_between_steps(hass: HomeAssistant) -> None: """Test the device gets added via another flow between steps.""" with patch( diff --git a/tests/components/todoist/conftest.py b/tests/components/todoist/conftest.py index 4b2bfea2e30..84f0fa740e9 100644 --- a/tests/components/todoist/conftest.py +++ b/tests/components/todoist/conftest.py @@ -70,6 +70,7 @@ def make_api_task( section_id=None, url="https://todoist.com", sync_id=None, + duration=None, ) @@ -94,6 +95,7 @@ def mock_api(tasks: list[Task]) -> AsyncMock: url="", is_inbox_project=False, is_team_inbox=False, + can_assign_tasks=False, order=1, parent_id=None, view_style="list", diff --git a/tests/components/tplink/snapshots/test_sensor.ambr b/tests/components/tplink/snapshots/test_sensor.ambr index 0d1cc9a03e4..093b92ef315 100644 --- a/tests/components/tplink/snapshots/test_sensor.ambr +++ b/tests/components/tplink/snapshots/test_sensor.ambr @@ -243,7 +243,9 @@ 'aliases': set({ }), 'area_id': None, - 'capabilities': None, + 'capabilities': dict({ + 'state_class': , + }), 'config_entry_id': , 'device_class': None, 'device_id': , @@ -279,6 +281,7 @@ 'attributes': ReadOnlyDict({ 'device_class': 'area', 'friendly_name': 'my_device Cleaning area', + 'state_class': , 'unit_of_measurement': , }), 'context': , @@ -294,11 +297,13 @@ 'aliases': set({ }), 'area_id': None, - 'capabilities': None, + 'capabilities': dict({ + 'state_class': , + }), 'config_entry_id': , 'device_class': None, 'device_id': , - 'disabled_by': None, + 'disabled_by': , 'domain': 'sensor', 'entity_category': , 'entity_id': 'sensor.my_device_cleaning_progress', @@ -322,20 +327,6 @@ 'unit_of_measurement': '%', }) # --- -# name: test_states[sensor.my_device_cleaning_progress-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'my_device Cleaning progress', - 'unit_of_measurement': '%', - }), - 'context': , - 'entity_id': 'sensor.my_device_cleaning_progress', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '30', - }) -# --- # name: test_states[sensor.my_device_cleaning_time-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -801,7 +792,9 @@ 'aliases': set({ }), 'area_id': None, - 'capabilities': None, + 'capabilities': dict({ + 'state_class': , + }), 'config_entry_id': , 'device_class': None, 'device_id': , @@ -1426,7 +1419,9 @@ 'aliases': set({ }), 'area_id': None, - 'capabilities': None, + 'capabilities': dict({ + 'state_class': , + }), 'config_entry_id': , 'device_class': None, 'device_id': , @@ -1462,7 +1457,9 @@ 'aliases': set({ }), 'area_id': None, - 'capabilities': None, + 'capabilities': dict({ + 'state_class': , + }), 'config_entry_id': , 'device_class': None, 'device_id': , @@ -1495,7 +1492,9 @@ 'aliases': set({ }), 'area_id': None, - 'capabilities': None, + 'capabilities': dict({ + 'state_class': , + }), 'config_entry_id': , 'device_class': None, 'device_id': , diff --git a/tests/components/unifiprotect/test_camera.py b/tests/components/unifiprotect/test_camera.py index 12b92beedd0..975e93edf09 100644 --- a/tests/components/unifiprotect/test_camera.py +++ b/tests/components/unifiprotect/test_camera.py @@ -174,7 +174,7 @@ def validate_common_camera_state( entity_id: str, features: int = CameraEntityFeature.STREAM, ): - """Validate state that is common to all camera entity, regradless of type.""" + """Validate state that is common to all camera entity, regardless of type.""" entity_state = hass.states.get(entity_id) assert entity_state assert entity_state.attributes[ATTR_ATTRIBUTION] == DEFAULT_ATTRIBUTION diff --git a/tests/components/upb/test_config_flow.py b/tests/components/upb/test_config_flow.py index 59a4e97d22b..3909c7e5dc4 100644 --- a/tests/components/upb/test_config_flow.py +++ b/tests/components/upb/test_config_flow.py @@ -13,15 +13,20 @@ from homeassistant.data_entry_flow import FlowResultType def mocked_upb(sync_complete=True, config_ok=True): """Mock UPB lib.""" - def _upb_lib_connect(callback): + def _add_handler(_, callback): callback() + def _dummy_add_handler(_, _callback): + pass + upb_mock = AsyncMock() type(upb_mock).network_id = PropertyMock(return_value="42") type(upb_mock).config_ok = PropertyMock(return_value=config_ok) type(upb_mock).disconnect = MagicMock() - if sync_complete: - upb_mock.async_connect.side_effect = _upb_lib_connect + type(upb_mock).add_handler = MagicMock() + upb_mock.add_handler.side_effect = ( + _add_handler if sync_complete else _dummy_add_handler + ) return patch( "homeassistant.components.upb.config_flow.upb_lib.UpbPim", return_value=upb_mock ) diff --git a/tests/components/vesync/conftest.py b/tests/components/vesync/conftest.py index a80c2631088..9ec7bd23fa5 100644 --- a/tests/components/vesync/conftest.py +++ b/tests/components/vesync/conftest.py @@ -153,3 +153,25 @@ async def humidifier_config_entry( await hass.async_block_till_done() return entry + + +@pytest.fixture(name="switch_old_id_config_entry") +async def switch_old_id_config_entry( + hass: HomeAssistant, requests_mock: requests_mock.Mocker, config +) -> MockConfigEntry: + """Create a mock VeSync config entry for `switch` with the old unique ID approach.""" + entry = MockConfigEntry( + title="VeSync", + domain=DOMAIN, + data=config[DOMAIN], + version=1, + minor_version=1, + ) + entry.add_to_hass(hass) + + wall_switch = "Wall Switch" + humidifer = "Humidifier 200s" + + mock_multiple_device_responses(requests_mock, [wall_switch, humidifer]) + + return entry diff --git a/tests/components/vesync/snapshots/test_diagnostics.ambr b/tests/components/vesync/snapshots/test_diagnostics.ambr index 54ed8acf2d7..1c409dbab00 100644 --- a/tests/components/vesync/snapshots/test_diagnostics.ambr +++ b/tests/components/vesync/snapshots/test_diagnostics.ambr @@ -128,7 +128,7 @@ 'sleep', 'manual', ]), - 'mode': None, + 'mode': 'humidity', 'night_light': True, 'pid': None, 'speed': None, @@ -160,6 +160,30 @@ # --- # name: test_async_get_device_diagnostics__single_fan dict({ + '_config_dict': dict({ + 'features': list([ + 'air_quality', + ]), + 'levels': list([ + 1, + 2, + ]), + 'models': list([ + 'LV-PUR131S', + 'LV-RH131S', + ]), + 'modes': list([ + 'manual', + 'auto', + 'sleep', + 'off', + ]), + 'module': 'VeSyncAir131', + }), + '_features': list([ + 'air_quality', + ]), + 'air_quality_feature': True, 'cid': 'abcdefghabcdefghabcdefghabcdefgh', 'config': dict({ }), @@ -180,6 +204,7 @@ 'device_region': 'US', 'device_status': 'unknown', 'device_type': 'LV-PUR131S', + 'enabled': True, 'extension': None, 'home_assistant': dict({ 'disabled': False, @@ -271,6 +296,12 @@ 'mac_id': '**REDACTED**', 'manager': '**REDACTED**', 'mode': None, + 'modes': list([ + 'manual', + 'auto', + 'sleep', + 'off', + ]), 'pid': None, 'speed': None, 'sub_device_no': None, diff --git a/tests/components/vesync/snapshots/test_switch.ambr b/tests/components/vesync/snapshots/test_switch.ambr index ec9cbc4398c..da652b30ac5 100644 --- a/tests/components/vesync/snapshots/test_switch.ambr +++ b/tests/components/vesync/snapshots/test_switch.ambr @@ -367,7 +367,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, - 'unique_id': 'outlet', + 'unique_id': 'outlet-device_status', 'unit_of_measurement': None, }), ]) @@ -525,7 +525,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, - 'unique_id': 'switch', + 'unique_id': 'switch-device_status', 'unit_of_measurement': None, }), ]) diff --git a/tests/components/vesync/test_humidifier.py b/tests/components/vesync/test_humidifier.py index b93c97baab6..d5057c44951 100644 --- a/tests/components/vesync/test_humidifier.py +++ b/tests/components/vesync/test_humidifier.py @@ -10,9 +10,16 @@ from homeassistant.components.humidifier import ( ATTR_HUMIDITY, ATTR_MODE, DOMAIN as HUMIDIFIER_DOMAIN, + MODE_AUTO, + MODE_SLEEP, SERVICE_SET_HUMIDITY, SERVICE_SET_MODE, ) +from homeassistant.components.vesync.const import ( + VS_HUMIDIFIER_MODE_AUTO, + VS_HUMIDIFIER_MODE_MANUAL, + VS_HUMIDIFIER_MODE_SLEEP, +) from homeassistant.config_entries import ConfigEntry, ConfigEntryState from homeassistant.const import ( ATTR_ENTITY_ID, @@ -222,7 +229,7 @@ async def test_set_mode( await hass.services.async_call( HUMIDIFIER_DOMAIN, SERVICE_SET_MODE, - {ATTR_ENTITY_ID: ENTITY_HUMIDIFIER, ATTR_MODE: "auto"}, + {ATTR_ENTITY_ID: ENTITY_HUMIDIFIER, ATTR_MODE: MODE_AUTO}, blocking=True, ) await hass.async_block_till_done() @@ -285,3 +292,38 @@ async def test_valid_mist_modes( await hass.async_block_till_done() assert "Unknown mode 'auto'" not in caplog.text assert "Unknown mode 'manual'" not in caplog.text + + +async def test_set_mode_sleep_turns_display_off( + hass: HomeAssistant, + config_entry: ConfigEntry, + humidifier, + manager, +) -> None: + """Test update of display for sleep mode.""" + + # First define valid mist modes + humidifier.mist_modes = [ + VS_HUMIDIFIER_MODE_AUTO, + VS_HUMIDIFIER_MODE_MANUAL, + VS_HUMIDIFIER_MODE_SLEEP, + ] + + with patch( + "homeassistant.components.vesync.async_generate_device_list", + return_value=[humidifier], + ): + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + with ( + patch.object(humidifier, "set_humidity_mode", return_value=True), + patch.object(humidifier, "set_display") as display_mock, + ): + await hass.services.async_call( + HUMIDIFIER_DOMAIN, + SERVICE_SET_MODE, + {ATTR_ENTITY_ID: ENTITY_HUMIDIFIER, ATTR_MODE: MODE_SLEEP}, + blocking=True, + ) + display_mock.assert_called_once_with(False) diff --git a/tests/components/vesync/test_init.py b/tests/components/vesync/test_init.py index 883e850fc62..f1fb3931bf9 100644 --- a/tests/components/vesync/test_init.py +++ b/tests/components/vesync/test_init.py @@ -10,6 +10,9 @@ from homeassistant.components.vesync.const import DOMAIN, VS_DEVICES, VS_MANAGER from homeassistant.config_entries import ConfigEntry, ConfigEntryState from homeassistant.const import Platform from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from tests.common import MockConfigEntry async def test_async_setup_entry__not_login( @@ -125,3 +128,55 @@ async def test_async_new_device_discovery( assert manager.login.call_count == 1 assert hass.data[DOMAIN][VS_MANAGER] == manager assert hass.data[DOMAIN][VS_DEVICES] == [fan, humidifier] + + +async def test_migrate_config_entry( + hass: HomeAssistant, + switch_old_id_config_entry: MockConfigEntry, + entity_registry: er.EntityRegistry, +) -> None: + """Test migration of config entry. Only migrates switches to a new unique_id.""" + switch: er.RegistryEntry = entity_registry.async_get_or_create( + domain="switch", + platform="vesync", + unique_id="switch", + config_entry=switch_old_id_config_entry, + suggested_object_id="switch", + ) + + humidifer: er.RegistryEntry = entity_registry.async_get_or_create( + domain="humidifer", + platform="vesync", + unique_id="humidifer", + config_entry=switch_old_id_config_entry, + suggested_object_id="humidifer", + ) + + assert switch.unique_id == "switch" + assert switch_old_id_config_entry.minor_version == 1 + assert humidifer.unique_id == "humidifer" + + await hass.config_entries.async_setup(switch_old_id_config_entry.entry_id) + await hass.async_block_till_done() + + assert switch_old_id_config_entry.minor_version == 2 + + migrated_switch = entity_registry.async_get(switch.entity_id) + assert migrated_switch is not None + assert migrated_switch.entity_id.startswith("switch") + assert migrated_switch.unique_id == "switch-device_status" + # Confirm humidifer was not impacted + migrated_humidifer = entity_registry.async_get(humidifer.entity_id) + assert migrated_humidifer is not None + assert migrated_humidifer.unique_id == "humidifer" + + # Assert that only one entity exists in the switch domain + switch_entities = [ + e for e in entity_registry.entities.values() if e.domain == "switch" + ] + assert len(switch_entities) == 1 + + humidifer_entities = [ + e for e in entity_registry.entities.values() if e.domain == "humidifer" + ] + assert len(humidifer_entities) == 1 diff --git a/tests/components/vicare/snapshots/test_binary_sensor.ambr b/tests/components/vicare/snapshots/test_binary_sensor.ambr index f3e4d4e1c84..ec2451cd466 100644 --- a/tests/components/vicare/snapshots/test_binary_sensor.ambr +++ b/tests/components/vicare/snapshots/test_binary_sensor.ambr @@ -373,6 +373,53 @@ 'state': 'unavailable', }) # --- +# name: test_all_entities[binary_sensor.model0_one_time_charge-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.model0_one_time_charge', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'One-time charge', + 'platform': 'vicare', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'one_time_charge', + 'unique_id': 'gateway0_deviceSerialVitodens300W-one_time_charge', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[binary_sensor.model0_one_time_charge-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'running', + 'friendly_name': 'model0 One-time charge', + }), + 'context': , + 'entity_id': 'binary_sensor.model0_one_time_charge', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unavailable', + }) +# --- # name: test_binary_sensors[burner] StateSnapshot({ 'attributes': ReadOnlyDict({ diff --git a/tests/components/vodafone_station/conftest.py b/tests/components/vodafone_station/conftest.py index 7763db5044a..a065a1e8065 100644 --- a/tests/components/vodafone_station/conftest.py +++ b/tests/components/vodafone_station/conftest.py @@ -8,7 +8,7 @@ import pytest from homeassistant.components.vodafone_station import DOMAIN from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME -from .const import DEVICE_1_MAC +from .const import DEVICE_1_HOST, DEVICE_1_MAC, DEVICE_2_MAC from tests.common import ( AsyncMock, @@ -48,11 +48,20 @@ def mock_vodafone_station_router() -> Generator[AsyncMock]: connected=True, connection_type="wifi", ip_address="192.168.1.10", - name="WifiDevice0", + name=DEVICE_1_HOST, mac=DEVICE_1_MAC, type="laptop", wifi="2.4G", ), + DEVICE_2_MAC: VodafoneStationDevice( + connected=False, + connection_type="lan", + ip_address="192.168.1.11", + name="LanDevice1", + mac=DEVICE_2_MAC, + type="desktop", + wifi="", + ), } router.get_sensor_data.return_value = load_json_object_fixture( "get_sensor_data.json", DOMAIN diff --git a/tests/components/vodafone_station/const.py b/tests/components/vodafone_station/const.py index 0f1ed2ba7da..cf6c274e5d5 100644 --- a/tests/components/vodafone_station/const.py +++ b/tests/components/vodafone_station/const.py @@ -1,3 +1,6 @@ """Common stuff for Vodafone Station tests.""" +DEVICE_1_HOST = "WifiDevice0" DEVICE_1_MAC = "xx:xx:xx:xx:xx:xx" +DEVICE_2_HOST = "LanDevice1" +DEVICE_2_MAC = "yy:yy:yy:yy:yy:yy" diff --git a/tests/components/vodafone_station/snapshots/test_device_tracker.ambr b/tests/components/vodafone_station/snapshots/test_device_tracker.ambr index 834c8b14459..e019ea73ab9 100644 --- a/tests/components/vodafone_station/snapshots/test_device_tracker.ambr +++ b/tests/components/vodafone_station/snapshots/test_device_tracker.ambr @@ -1,5 +1,5 @@ # serializer version: 1 -# name: test_all_entities[device_tracker.vodafone_station_xx_xx_xx_xx_xx_xx-entry] +# name: test_all_entities[device_tracker.landevice1-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -11,8 +11,8 @@ 'disabled_by': None, 'domain': 'device_tracker', 'entity_category': , - 'entity_id': 'device_tracker.vodafone_station_xx_xx_xx_xx_xx_xx', - 'has_entity_name': False, + 'entity_id': 'device_tracker.landevice1', + 'has_entity_name': True, 'hidden_by': None, 'icon': None, 'id': , @@ -23,7 +23,57 @@ }), 'original_device_class': None, 'original_icon': None, - 'original_name': None, + 'original_name': 'LanDevice1', + 'platform': 'vodafone_station', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'device_tracker', + 'unique_id': 'yy:yy:yy:yy:yy:yy', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[device_tracker.landevice1-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'LanDevice1', + 'host_name': 'LanDevice1', + 'ip': '192.168.1.11', + 'mac': 'yy:yy:yy:yy:yy:yy', + 'source_type': , + }), + 'context': , + 'entity_id': 'device_tracker.landevice1', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'not_home', + }) +# --- +# name: test_all_entities[device_tracker.wifidevice0-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'device_tracker', + 'entity_category': , + 'entity_id': 'device_tracker.wifidevice0', + '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': 'WifiDevice0', 'platform': 'vodafone_station', 'previous_unique_id': None, 'supported_features': 0, @@ -32,16 +82,17 @@ 'unit_of_measurement': None, }) # --- -# name: test_all_entities[device_tracker.vodafone_station_xx_xx_xx_xx_xx_xx-state] +# name: test_all_entities[device_tracker.wifidevice0-state] StateSnapshot({ 'attributes': ReadOnlyDict({ + 'friendly_name': 'WifiDevice0', 'host_name': 'WifiDevice0', 'ip': '192.168.1.10', 'mac': 'xx:xx:xx:xx:xx:xx', 'source_type': , }), 'context': , - 'entity_id': 'device_tracker.vodafone_station_xx_xx_xx_xx_xx_xx', + 'entity_id': 'device_tracker.wifidevice0', 'last_changed': , 'last_reported': , 'last_updated': , diff --git a/tests/components/vodafone_station/snapshots/test_diagnostics.ambr b/tests/components/vodafone_station/snapshots/test_diagnostics.ambr index c258b14dc2d..478080700cd 100644 --- a/tests/components/vodafone_station/snapshots/test_diagnostics.ambr +++ b/tests/components/vodafone_station/snapshots/test_diagnostics.ambr @@ -9,6 +9,12 @@ 'hostname': 'WifiDevice0', 'type': 'laptop', }), + dict({ + 'connected': False, + 'connection_type': 'lan', + 'hostname': 'LanDevice1', + 'type': 'desktop', + }), ]), 'last_exception': None, 'last_update success': True, diff --git a/tests/components/vodafone_station/test_coordinator.py b/tests/components/vodafone_station/test_coordinator.py new file mode 100644 index 00000000000..1a9470245c7 --- /dev/null +++ b/tests/components/vodafone_station/test_coordinator.py @@ -0,0 +1,68 @@ +"""Define tests for the Vodafone Station coordinator.""" + +import logging +from unittest.mock import AsyncMock + +from aiovodafone import VodafoneStationDevice +from freezegun.api import FrozenDateTimeFactory +import pytest + +from homeassistant.components.vodafone_station.const import DOMAIN, SCAN_INTERVAL +from homeassistant.core import HomeAssistant +from homeassistant.helpers import device_registry as dr + +from . import setup_integration +from .const import DEVICE_1_HOST, DEVICE_1_MAC, DEVICE_2_HOST, DEVICE_2_MAC + +from tests.common import MockConfigEntry, async_fire_time_changed + + +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +async def test_coordinator_device_cleanup( + hass: HomeAssistant, + freezer: FrozenDateTimeFactory, + mock_vodafone_station_router: AsyncMock, + mock_config_entry: MockConfigEntry, + caplog: pytest.LogCaptureFixture, + device_registry: dr.DeviceRegistry, +) -> None: + """Test Device cleanup on coordinator update.""" + + caplog.set_level(logging.DEBUG) + await setup_integration(hass, mock_config_entry) + + device = device_registry.async_get_or_create( + config_entry_id=mock_config_entry.entry_id, + identifiers={(DOMAIN, DEVICE_1_MAC)}, + name=DEVICE_1_HOST, + ) + assert device is not None + + device_tracker = f"device_tracker.{DEVICE_1_HOST}" + + state = hass.states.get(device_tracker) + assert state is not None + + mock_vodafone_station_router.get_devices_data.return_value = { + DEVICE_2_MAC: VodafoneStationDevice( + connected=True, + connection_type="lan", + ip_address="192.168.1.11", + name=DEVICE_2_HOST, + mac=DEVICE_2_MAC, + type="desktop", + wifi="", + ), + } + + freezer.tick(SCAN_INTERVAL) + async_fire_time_changed(hass) + await hass.async_block_till_done(wait_background_tasks=True) + + state = hass.states.get(device_tracker) + assert state is None + assert f"Skipping entity {DEVICE_2_HOST}" in caplog.text + + device = device_registry.async_get_device(identifiers={(DOMAIN, DEVICE_1_MAC)}) + assert device is None + assert f"Removing device: {DEVICE_1_HOST}" in caplog.text diff --git a/tests/components/vodafone_station/test_device_tracker.py b/tests/components/vodafone_station/test_device_tracker.py index 5133d0da980..e172fa76de5 100644 --- a/tests/components/vodafone_station/test_device_tracker.py +++ b/tests/components/vodafone_station/test_device_tracker.py @@ -13,7 +13,7 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er from . import setup_integration -from .const import DEVICE_1_MAC +from .const import DEVICE_1_HOST, DEVICE_1_MAC from tests.common import MockConfigEntry, async_fire_time_changed, snapshot_platform @@ -45,7 +45,7 @@ async def test_consider_home( """Test if device is considered not_home when disconnected.""" await setup_integration(hass, mock_config_entry) - device_tracker = "device_tracker.vodafone_station_xx_xx_xx_xx_xx_xx" + device_tracker = f"device_tracker.{DEVICE_1_HOST}" state = hass.states.get(device_tracker) assert state diff --git a/tests/components/voip/test_voip.py b/tests/components/voip/test_voip.py index 442f4a62392..3e3e5337417 100644 --- a/tests/components/voip/test_voip.py +++ b/tests/components/voip/test_voip.py @@ -1084,3 +1084,90 @@ async def test_start_conversation( # Wait for TTS await tts_sent.wait() await conversation_task + + +@pytest.mark.usefixtures("socket_enabled") +async def test_start_conversation_user_doesnt_pick_up( + hass: HomeAssistant, + voip_devices: VoIPDevices, + voip_device: VoIPDevice, +) -> None: + """Test start conversation when the user doesn't pick up.""" + assert await async_setup_component(hass, "voip", {}) + + pipeline = assist_pipeline.Pipeline( + conversation_engine="test engine", + conversation_language="en", + language="en", + name="test pipeline", + stt_engine="test stt", + stt_language="en", + tts_engine="test tts", + tts_language="en", + tts_voice=None, + wake_word_entity=None, + wake_word_id=None, + ) + + satellite = async_get_satellite_entity(hass, voip.DOMAIN, voip_device.voip_id) + assert isinstance(satellite, VoipAssistSatellite) + assert ( + satellite.supported_features + & assist_satellite.AssistSatelliteEntityFeature.START_CONVERSATION + ) + + # Protocol has already been mocked, but "outgoing_call" is not async + mock_protocol: AsyncMock = hass.data[DOMAIN].protocol + mock_protocol.outgoing_call = Mock() + + pipeline_started = asyncio.Event() + + async def async_pipeline_from_audio_stream( + hass: HomeAssistant, + context: Context, + *args, + conversation_extra_system_prompt: str | None = None, + **kwargs, + ): + # System prompt should be not be set due to timeout (user not picking up) + assert conversation_extra_system_prompt is None + + pipeline_started.set() + + with ( + patch( + "homeassistant.components.assist_satellite.entity.async_get_pipeline", + return_value=pipeline, + ), + patch( + "homeassistant.components.voip.assist_satellite.VoipAssistSatellite.async_start_conversation", + side_effect=TimeoutError, + ), + patch( + "homeassistant.components.assist_satellite.entity.async_pipeline_from_audio_stream", + new=async_pipeline_from_audio_stream, + ), + patch( + "homeassistant.components.assist_satellite.entity.tts_generate_media_source_id", + return_value="test media id", + ), + ): + satellite.transport = Mock() + + # Error should clear system prompt + with pytest.raises(TimeoutError): + await hass.services.async_call( + assist_satellite.DOMAIN, + "start_conversation", + { + "entity_id": satellite.entity_id, + "start_message": "test announcement", + "extra_system_prompt": "test prompt", + }, + blocking=True, + ) + + # Trigger a pipeline so we can check if the system prompt was cleared + satellite.on_chunk(bytes(_ONE_SECOND)) + async with asyncio.timeout(1): + await pipeline_started.wait() diff --git a/tests/components/xiaomi_ble/test_config_flow.py b/tests/components/xiaomi_ble/test_config_flow.py index e25ac939a53..3d8a4dab244 100644 --- a/tests/components/xiaomi_ble/test_config_flow.py +++ b/tests/components/xiaomi_ble/test_config_flow.py @@ -634,6 +634,38 @@ async def test_async_step_user_with_found_devices(hass: HomeAssistant) -> None: assert result2["result"].unique_id == "58:2D:34:35:93:21" +async def test_async_step_user_replace_ignored_entry(hass: HomeAssistant) -> None: + """Test setup from service info can replace an ignored entry.""" + entry = MockConfigEntry( + domain=DOMAIN, + unique_id=LYWSDCGQ_SERVICE_INFO.address, + data={}, + source=config_entries.SOURCE_IGNORE, + ) + entry.add_to_hass(hass) + with patch( + "homeassistant.components.xiaomi_ble.config_flow.async_discovered_service_info", + return_value=[LYWSDCGQ_SERVICE_INFO], + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_USER}, + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + with patch( + "homeassistant.components.xiaomi_ble.async_setup_entry", return_value=True + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={"address": "58:2D:34:35:93:21"}, + ) + assert result2["type"] is FlowResultType.CREATE_ENTRY + assert result2["title"] == "Temperature/Humidity Sensor 9321 (LYWSDCGQ)" + assert result2["data"] == {} + assert result2["result"].unique_id == "58:2D:34:35:93:21" + + async def test_async_step_user_short_payload(hass: HomeAssistant) -> None: """Test setup from service info cache with devices found but short payloads.""" with patch( diff --git a/tests/components/yalexs_ble/test_config_flow.py b/tests/components/yalexs_ble/test_config_flow.py index c546e754239..1b0df05db2c 100644 --- a/tests/components/yalexs_ble/test_config_flow.py +++ b/tests/components/yalexs_ble/test_config_flow.py @@ -92,6 +92,58 @@ async def test_user_step_success(hass: HomeAssistant, slot: int) -> None: assert len(mock_setup_entry.mock_calls) == 1 +@pytest.mark.parametrize("slot", [0, 1, 66]) +async def test_user_step_from_ignored(hass: HomeAssistant, slot: int) -> None: + """Test user step replaces an ignored entry.""" + entry = MockConfigEntry( + domain=DOMAIN, + data={}, + unique_id=YALE_ACCESS_LOCK_DISCOVERY_INFO.address, + source=config_entries.SOURCE_IGNORE, + ) + entry.add_to_hass(hass) + with patch( + "homeassistant.components.yalexs_ble.config_flow.async_discovered_service_info", + return_value=[NOT_YALE_DISCOVERY_INFO, YALE_ACCESS_LOCK_DISCOVERY_INFO], + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + assert result["errors"] == {} + + with ( + patch( + "homeassistant.components.yalexs_ble.config_flow.PushLock.validate", + ), + patch( + "homeassistant.components.yalexs_ble.async_setup_entry", + return_value=True, + ) as mock_setup_entry, + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_ADDRESS: YALE_ACCESS_LOCK_DISCOVERY_INFO.address, + CONF_KEY: "2fd51b8621c6a139eaffbedcb846b60f", + CONF_SLOT: slot, + }, + ) + await hass.async_block_till_done() + + assert result2["type"] is FlowResultType.CREATE_ENTRY + assert result2["title"] == YALE_ACCESS_LOCK_DISCOVERY_INFO.name + assert result2["data"] == { + CONF_LOCAL_NAME: YALE_ACCESS_LOCK_DISCOVERY_INFO.name, + CONF_ADDRESS: YALE_ACCESS_LOCK_DISCOVERY_INFO.address, + CONF_KEY: "2fd51b8621c6a139eaffbedcb846b60f", + CONF_SLOT: slot, + } + assert result2["result"].unique_id == YALE_ACCESS_LOCK_DISCOVERY_INFO.address + assert len(mock_setup_entry.mock_calls) == 1 + + async def test_user_step_no_devices_found(hass: HomeAssistant) -> None: """Test user step with no devices found.""" with patch( diff --git a/tests/helpers/test_chat_session.py b/tests/helpers/test_chat_session.py new file mode 100644 index 00000000000..f6c2fe5057d --- /dev/null +++ b/tests/helpers/test_chat_session.py @@ -0,0 +1,96 @@ +"""Test the chat session helper.""" + +from collections.abc import Generator +from datetime import timedelta +from unittest.mock import Mock, patch + +import pytest + +from homeassistant.core import HomeAssistant +from homeassistant.helpers import chat_session +from homeassistant.util import dt as dt_util, ulid as ulid_util + +from tests.common import async_fire_time_changed + + +@pytest.fixture +def mock_ulid() -> Generator[Mock]: + """Mock the ulid library.""" + with patch("homeassistant.helpers.chat_session.ulid_now") as mock_ulid_now: + mock_ulid_now.return_value = "mock-ulid" + yield mock_ulid_now + + +@pytest.mark.parametrize( + ("start_id", "given_id"), + [ + (None, "mock-ulid"), + # This ULID is not known as a session + ("01JHXE0952TSJCFJZ869AW6HMD", "mock-ulid"), + ("not-a-ulid", "not-a-ulid"), + ], +) +async def test_conversation_id( + hass: HomeAssistant, + start_id: str | None, + given_id: str, + mock_ulid: Mock, +) -> None: + """Test conversation ID generation.""" + with chat_session.async_get_chat_session(hass, start_id) as session: + assert session.conversation_id == given_id + + +async def test_context_var(hass: HomeAssistant) -> None: + """Test context var.""" + with chat_session.async_get_chat_session(hass) as session: + with chat_session.async_get_chat_session( + hass, session.conversation_id + ) as session2: + assert session is session2 + + with chat_session.async_get_chat_session(hass, None) as session2: + assert session.conversation_id != session2.conversation_id + + with chat_session.async_get_chat_session(hass, "something else") as session2: + assert session.conversation_id != session2.conversation_id + + with chat_session.async_get_chat_session( + hass, ulid_util.ulid_now() + ) as session2: + assert session.conversation_id != session2.conversation_id + + +async def test_cleanup( + hass: HomeAssistant, +) -> None: + """Test cleanup of the chat session.""" + with chat_session.async_get_chat_session(hass) as session: + conversation_id = session.conversation_id + + # Reuse conversation ID to ensure we can chat with same session + with chat_session.async_get_chat_session(hass, conversation_id) as session: + assert session.conversation_id == conversation_id + + # Set the last updated to be older than the timeout + hass.data[chat_session.DATA_CHAT_SESSION][conversation_id].last_updated = ( + dt_util.utcnow() + chat_session.CONVERSATION_TIMEOUT + ) + + async_fire_time_changed( + hass, + dt_util.utcnow() + chat_session.CONVERSATION_TIMEOUT + timedelta(seconds=1), + ) + + # Should not be cleaned up, but it should have scheduled another cleanup + with chat_session.async_get_chat_session(hass, conversation_id) as session: + assert session.conversation_id == conversation_id + + async_fire_time_changed( + hass, + dt_util.utcnow() + chat_session.CONVERSATION_TIMEOUT * 2 + timedelta(seconds=1), + ) + + # It should be cleaned up now and we start a new conversation + with chat_session.async_get_chat_session(hass, conversation_id) as session: + assert session.conversation_id != conversation_id diff --git a/tests/helpers/test_debounce.py b/tests/helpers/test_debounce.py index 6fa758aec6e..b2dd8943e78 100644 --- a/tests/helpers/test_debounce.py +++ b/tests/helpers/test_debounce.py @@ -4,6 +4,7 @@ import asyncio from datetime import timedelta import logging from unittest.mock import AsyncMock, Mock +import weakref import pytest @@ -529,3 +530,37 @@ async def test_background( async_fire_time_changed(hass, utcnow() + timedelta(seconds=1)) await hass.async_block_till_done(wait_background_tasks=False) assert len(calls) == 2 + + +async def test_shutdown_releases_parent_class(hass: HomeAssistant) -> None: + """Test shutdown releases parent class. + + See https://github.com/home-assistant/core/issues/137237 + """ + calls = [] + + class SomeClass: + def run_func(self) -> None: + calls.append(None) + + my_class = SomeClass() + my_class_weak_ref = weakref.ref(my_class) + + debouncer = debounce.Debouncer( + hass, + _LOGGER, + cooldown=0.01, + immediate=True, + function=my_class.run_func, + ) + + # Debouncer keeps a reference to the function, prevening GC + del my_class + await debouncer.async_call() + await hass.async_block_till_done() + assert len(calls) == 1 + assert my_class_weak_ref() is not None + + # Debouncer shutdown releases the class + debouncer.async_shutdown() + assert my_class_weak_ref() is None diff --git a/tests/util/test_async.py b/tests/util/test_async.py index cfa78228f0c..e2310e6acd5 100644 --- a/tests/util/test_async.py +++ b/tests/util/test_async.py @@ -134,8 +134,10 @@ async def test_create_eager_task_312(hass: HomeAssistant) -> None: async def test_create_eager_task_from_thread(hass: HomeAssistant) -> None: """Test we report trying to create an eager task from a thread.""" + coro = asyncio.sleep(0) + def create_task(): - hasync.create_eager_task(asyncio.sleep(0)) + hasync.create_eager_task(coro) with pytest.raises( RuntimeError, @@ -145,14 +147,19 @@ async def test_create_eager_task_from_thread(hass: HomeAssistant) -> None: ): await hass.async_add_executor_job(create_task) + # Avoid `RuntimeWarning: coroutine 'sleep' was never awaited` + await coro + async def test_create_eager_task_from_thread_in_integration( hass: HomeAssistant, caplog: pytest.LogCaptureFixture ) -> None: """Test we report trying to create an eager task from a thread.""" + coro = asyncio.sleep(0) + def create_task(): - hasync.create_eager_task(asyncio.sleep(0)) + hasync.create_eager_task(coro) frames = extract_stack_to_frame( [ @@ -200,6 +207,9 @@ async def test_create_eager_task_from_thread_in_integration( "self.light.is_on" ) in caplog.text + # Avoid `RuntimeWarning: coroutine 'sleep' was never awaited` + await coro + async def test_get_scheduled_timer_handles(hass: HomeAssistant) -> None: """Test get_scheduled_timer_handles returns all scheduled timer handles."""