diff --git a/CODEOWNERS b/CODEOWNERS index 2406606bb28791..419347d08a76fa 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -331,8 +331,8 @@ build.json @home-assistant/supervisor /tests/components/demo/ @home-assistant/core /homeassistant/components/denonavr/ @ol-iver @starkillerOG /tests/components/denonavr/ @ol-iver @starkillerOG -/homeassistant/components/derivative/ @afaucogney -/tests/components/derivative/ @afaucogney +/homeassistant/components/derivative/ @afaucogney @karwosts +/tests/components/derivative/ @afaucogney @karwosts /homeassistant/components/devialet/ @fwestenberg /tests/components/devialet/ @fwestenberg /homeassistant/components/device_automation/ @home-assistant/core diff --git a/homeassistant/components/aemet/sensor.py b/homeassistant/components/aemet/sensor.py index 9077b2bc44d0aa..a3aeab9deb913d 100644 --- a/homeassistant/components/aemet/sensor.py +++ b/homeassistant/components/aemet/sensor.py @@ -185,6 +185,7 @@ class AemetSensorEntityDescription(SensorEntityDescription): keys=[AOD_TOWN, AOD_FORECAST_DAILY, AOD_FORECAST_CURRENT, AOD_WIND_DIRECTION], name="Daily forecast wind bearing", native_unit_of_measurement=DEGREE, + device_class=SensorDeviceClass.WIND_DIRECTION, ), AemetSensorEntityDescription( entity_registry_enabled_default=False, @@ -192,6 +193,7 @@ class AemetSensorEntityDescription(SensorEntityDescription): keys=[AOD_TOWN, AOD_FORECAST_HOURLY, AOD_FORECAST_CURRENT, AOD_WIND_DIRECTION], name="Hourly forecast wind bearing", native_unit_of_measurement=DEGREE, + device_class=SensorDeviceClass.WIND_DIRECTION, ), AemetSensorEntityDescription( entity_registry_enabled_default=False, @@ -335,6 +337,7 @@ class AemetSensorEntityDescription(SensorEntityDescription): name="Wind bearing", native_unit_of_measurement=DEGREE, state_class=SensorStateClass.MEASUREMENT, + device_class=SensorDeviceClass.WIND_DIRECTION, ), AemetSensorEntityDescription( key=ATTR_API_WIND_MAX_SPEED, diff --git a/homeassistant/components/ai_task/__init__.py b/homeassistant/components/ai_task/__init__.py index 7fec89f384ea50..692e5d410ae879 100644 --- a/homeassistant/components/ai_task/__init__.py +++ b/homeassistant/components/ai_task/__init__.py @@ -24,20 +24,20 @@ DATA_COMPONENT, DATA_PREFERENCES, DOMAIN, - SERVICE_GENERATE_TEXT, + SERVICE_GENERATE_DATA, AITaskEntityFeature, ) from .entity import AITaskEntity from .http import async_setup as async_setup_http -from .task import GenTextTask, GenTextTaskResult, async_generate_text +from .task import GenDataTask, GenDataTaskResult, async_generate_data __all__ = [ "DOMAIN", "AITaskEntity", "AITaskEntityFeature", - "GenTextTask", - "GenTextTaskResult", - "async_generate_text", + "GenDataTask", + "GenDataTaskResult", + "async_generate_data", "async_setup", "async_setup_entry", "async_unload_entry", @@ -57,8 +57,8 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: async_setup_http(hass) hass.services.async_register( DOMAIN, - SERVICE_GENERATE_TEXT, - async_service_generate_text, + SERVICE_GENERATE_DATA, + async_service_generate_data, schema=vol.Schema( { vol.Required(ATTR_TASK_NAME): cv.string, @@ -82,18 +82,18 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: return await hass.data[DATA_COMPONENT].async_unload_entry(entry) -async def async_service_generate_text(call: ServiceCall) -> ServiceResponse: +async def async_service_generate_data(call: ServiceCall) -> ServiceResponse: """Run the run task service.""" - result = await async_generate_text(hass=call.hass, **call.data) - return result.as_dict() # type: ignore[return-value] + result = await async_generate_data(hass=call.hass, **call.data) + return result.as_dict() class AITaskPreferences: """AI Task preferences.""" - KEYS = ("gen_text_entity_id",) + KEYS = ("gen_data_entity_id",) - gen_text_entity_id: str | None = None + gen_data_entity_id: str | None = None def __init__(self, hass: HomeAssistant) -> None: """Initialize the preferences.""" @@ -113,11 +113,11 @@ async def async_load(self) -> None: def async_set_preferences( self, *, - gen_text_entity_id: str | None | UndefinedType = UNDEFINED, + gen_data_entity_id: str | None | UndefinedType = UNDEFINED, ) -> None: """Set the preferences.""" changed = False - for key, value in (("gen_text_entity_id", gen_text_entity_id),): + for key, value in (("gen_data_entity_id", gen_data_entity_id),): if value is not UNDEFINED: if getattr(self, key) != value: setattr(self, key, value) diff --git a/homeassistant/components/ai_task/const.py b/homeassistant/components/ai_task/const.py index b6058c11b45bcc..8b612e90560d43 100644 --- a/homeassistant/components/ai_task/const.py +++ b/homeassistant/components/ai_task/const.py @@ -17,7 +17,7 @@ DATA_COMPONENT: HassKey[EntityComponent[AITaskEntity]] = HassKey(DOMAIN) DATA_PREFERENCES: HassKey[AITaskPreferences] = HassKey(f"{DOMAIN}_preferences") -SERVICE_GENERATE_TEXT = "generate_text" +SERVICE_GENERATE_DATA = "generate_data" ATTR_INSTRUCTIONS: Final = "instructions" ATTR_TASK_NAME: Final = "task_name" @@ -30,5 +30,5 @@ class AITaskEntityFeature(IntFlag): """Supported features of the AI task entity.""" - GENERATE_TEXT = 1 - """Generate text based on instructions.""" + GENERATE_DATA = 1 + """Generate data based on instructions.""" diff --git a/homeassistant/components/ai_task/entity.py b/homeassistant/components/ai_task/entity.py index 88ce8144fb7288..cb6094cba4e97f 100644 --- a/homeassistant/components/ai_task/entity.py +++ b/homeassistant/components/ai_task/entity.py @@ -18,7 +18,7 @@ from homeassistant.util import dt as dt_util from .const import DEFAULT_SYSTEM_PROMPT, DOMAIN, AITaskEntityFeature -from .task import GenTextTask, GenTextTaskResult +from .task import GenDataTask, GenDataTaskResult class AITaskEntity(RestoreEntity): @@ -56,7 +56,7 @@ async def async_internal_added_to_hass(self) -> None: @contextlib.asynccontextmanager async def _async_get_ai_task_chat_log( self, - task: GenTextTask, + task: GenDataTask, ) -> AsyncGenerator[ChatLog]: """Context manager used to manage the ChatLog used during an AI Task.""" # pylint: disable-next=contextmanager-generator-missing-cleanup @@ -84,20 +84,20 @@ async def _async_get_ai_task_chat_log( yield chat_log @final - async def internal_async_generate_text( + async def internal_async_generate_data( self, - task: GenTextTask, - ) -> GenTextTaskResult: - """Run a gen text task.""" + task: GenDataTask, + ) -> GenDataTaskResult: + """Run a gen data task.""" self.__last_activity = dt_util.utcnow().isoformat() self.async_write_ha_state() async with self._async_get_ai_task_chat_log(task) as chat_log: - return await self._async_generate_text(task, chat_log) + return await self._async_generate_data(task, chat_log) - async def _async_generate_text( + async def _async_generate_data( self, - task: GenTextTask, + task: GenDataTask, chat_log: ChatLog, - ) -> GenTextTaskResult: - """Handle a gen text task.""" + ) -> GenDataTaskResult: + """Handle a gen data task.""" raise NotImplementedError diff --git a/homeassistant/components/ai_task/http.py b/homeassistant/components/ai_task/http.py index 6d44a4e8d3cdbc..5deffa84008a1f 100644 --- a/homeassistant/components/ai_task/http.py +++ b/homeassistant/components/ai_task/http.py @@ -36,7 +36,7 @@ def websocket_get_preferences( @websocket_api.websocket_command( { vol.Required("type"): "ai_task/preferences/set", - vol.Optional("gen_text_entity_id"): vol.Any(str, None), + vol.Optional("gen_data_entity_id"): vol.Any(str, None), } ) @websocket_api.require_admin diff --git a/homeassistant/components/ai_task/icons.json b/homeassistant/components/ai_task/icons.json index cb09e5c8f5d3c8..4a875e9fb11f06 100644 --- a/homeassistant/components/ai_task/icons.json +++ b/homeassistant/components/ai_task/icons.json @@ -1,6 +1,6 @@ { "services": { - "generate_text": { + "generate_data": { "service": "mdi:file-star-four-points-outline" } } diff --git a/homeassistant/components/ai_task/services.yaml b/homeassistant/components/ai_task/services.yaml index 12e3975fca6657..a531ca599b16b7 100644 --- a/homeassistant/components/ai_task/services.yaml +++ b/homeassistant/components/ai_task/services.yaml @@ -1,4 +1,4 @@ -generate_text: +generate_data: fields: task_name: example: "home summary" @@ -16,4 +16,4 @@ generate_text: entity: domain: ai_task supported_features: - - ai_task.AITaskEntityFeature.GENERATE_TEXT + - ai_task.AITaskEntityFeature.GENERATE_DATA diff --git a/homeassistant/components/ai_task/strings.json b/homeassistant/components/ai_task/strings.json index f994aaebe8e58e..877174de681fc6 100644 --- a/homeassistant/components/ai_task/strings.json +++ b/homeassistant/components/ai_task/strings.json @@ -1,8 +1,8 @@ { "services": { - "generate_text": { - "name": "Generate text", - "description": "Use AI to run a task that generates text.", + "generate_data": { + "name": "Generate data", + "description": "Uses AI to run a task that generates data.", "fields": { "task_name": { "name": "Task name", diff --git a/homeassistant/components/ai_task/task.py b/homeassistant/components/ai_task/task.py index 1ba5838d18b13d..2e546897602356 100644 --- a/homeassistant/components/ai_task/task.py +++ b/homeassistant/components/ai_task/task.py @@ -3,6 +3,7 @@ from __future__ import annotations from dataclasses import dataclass +from typing import Any from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError @@ -10,16 +11,16 @@ from .const import DATA_COMPONENT, DATA_PREFERENCES, AITaskEntityFeature -async def async_generate_text( +async def async_generate_data( hass: HomeAssistant, *, task_name: str, entity_id: str | None = None, instructions: str, -) -> GenTextTaskResult: +) -> GenDataTaskResult: """Run a task in the AI Task integration.""" if entity_id is None: - entity_id = hass.data[DATA_PREFERENCES].gen_text_entity_id + entity_id = hass.data[DATA_PREFERENCES].gen_data_entity_id if entity_id is None: raise HomeAssistantError("No entity_id provided and no preferred entity set") @@ -28,13 +29,13 @@ async def async_generate_text( if entity is None: raise HomeAssistantError(f"AI Task entity {entity_id} not found") - if AITaskEntityFeature.GENERATE_TEXT not in entity.supported_features: + if AITaskEntityFeature.GENERATE_DATA not in entity.supported_features: raise HomeAssistantError( - f"AI Task entity {entity_id} does not support generating text" + f"AI Task entity {entity_id} does not support generating data" ) - return await entity.internal_async_generate_text( - GenTextTask( + return await entity.internal_async_generate_data( + GenDataTask( name=task_name, instructions=instructions, ) @@ -42,8 +43,8 @@ async def async_generate_text( @dataclass(slots=True) -class GenTextTask: - """Gen text task to be processed.""" +class GenDataTask: + """Gen data task to be processed.""" name: str """Name of the task.""" @@ -53,22 +54,22 @@ class GenTextTask: def __str__(self) -> str: """Return task as a string.""" - return f"" + return f"" @dataclass(slots=True) -class GenTextTaskResult: - """Result of gen text task.""" +class GenDataTaskResult: + """Result of gen data task.""" conversation_id: str """Unique identifier for the conversation.""" - text: str - """Generated text.""" + data: Any + """Data generated by the task.""" - def as_dict(self) -> dict[str, str]: + def as_dict(self) -> dict[str, Any]: """Return result as a dict.""" return { "conversation_id": self.conversation_id, - "text": self.text, + "data": self.data, } diff --git a/homeassistant/components/android_ip_webcam/camera.py b/homeassistant/components/android_ip_webcam/camera.py index 833b9a0d29676b..e4b0f5536a7dc8 100644 --- a/homeassistant/components/android_ip_webcam/camera.py +++ b/homeassistant/components/android_ip_webcam/camera.py @@ -2,6 +2,7 @@ from __future__ import annotations +from homeassistant.components.camera import CameraEntityFeature from homeassistant.components.mjpeg import MjpegCamera, filter_urllib3_logging from homeassistant.const import ( CONF_HOST, @@ -31,6 +32,7 @@ class IPWebcamCamera(MjpegCamera): """Representation of a IP Webcam camera.""" _attr_has_entity_name = True + _attr_supported_features = CameraEntityFeature.STREAM def __init__(self, coordinator: AndroidIPCamDataUpdateCoordinator) -> None: """Initialize the camera.""" @@ -46,3 +48,17 @@ def __init__(self, coordinator: AndroidIPCamDataUpdateCoordinator) -> None: identifiers={(DOMAIN, coordinator.config_entry.entry_id)}, name=coordinator.config_entry.data[CONF_HOST], ) + self._coordinator = coordinator + + async def stream_source(self) -> str: + """Get the stream source for the Android IP camera.""" + return self._coordinator.cam.get_rtsp_url( + video_codec="h264", # most compatible & recommended + # while "opus" is compatible with more devices, + # HA's stream integration requires AAC or MP3, + # and IP webcam doesn't provide MP3 audio. + # aac is supported on select devices >= android 4.1. + # The stream will be quiet on devices that don't support aac, + # but it won't fail. + audio_codec="aac", + ) diff --git a/homeassistant/components/anthropic/__init__.py b/homeassistant/components/anthropic/__init__.py index a9745d1a6a5df7..c13c82f00200ed 100644 --- a/homeassistant/components/anthropic/__init__.py +++ b/homeassistant/components/anthropic/__init__.py @@ -6,11 +6,16 @@ import anthropic -from homeassistant.config_entries import ConfigEntry +from homeassistant.config_entries import ConfigEntry, ConfigSubentry from homeassistant.const import CONF_API_KEY, Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady -from homeassistant.helpers import config_validation as cv +from homeassistant.helpers import ( + config_validation as cv, + device_registry as dr, + entity_registry as er, +) +from homeassistant.helpers.typing import ConfigType from .const import CONF_CHAT_MODEL, DOMAIN, LOGGER, RECOMMENDED_CHAT_MODEL @@ -20,13 +25,24 @@ type AnthropicConfigEntry = ConfigEntry[anthropic.AsyncClient] +async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: + """Set up Anthropic.""" + await async_migrate_integration(hass) + return True + + async def async_setup_entry(hass: HomeAssistant, entry: AnthropicConfigEntry) -> bool: """Set up Anthropic from a config entry.""" client = await hass.async_add_executor_job( partial(anthropic.AsyncAnthropic, api_key=entry.data[CONF_API_KEY]) ) try: - model_id = entry.options.get(CONF_CHAT_MODEL, RECOMMENDED_CHAT_MODEL) + # Use model from first conversation subentry for validation + subentries = list(entry.subentries.values()) + if subentries: + model_id = subentries[0].data.get(CONF_CHAT_MODEL, RECOMMENDED_CHAT_MODEL) + else: + model_id = RECOMMENDED_CHAT_MODEL model = await client.models.retrieve(model_id=model_id, timeout=10.0) LOGGER.debug("Anthropic model: %s", model.display_name) except anthropic.AuthenticationError as err: @@ -45,3 +61,68 @@ async def async_setup_entry(hass: HomeAssistant, entry: AnthropicConfigEntry) -> async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Unload Anthropic.""" return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) + + +async def async_migrate_integration(hass: HomeAssistant) -> None: + """Migrate integration entry structure.""" + + entries = hass.config_entries.async_entries(DOMAIN) + if not any(entry.version == 1 for entry in entries): + return + + api_keys_entries: dict[str, ConfigEntry] = {} + entity_registry = er.async_get(hass) + device_registry = dr.async_get(hass) + + for entry in entries: + use_existing = False + subentry = ConfigSubentry( + data=entry.options, + subentry_type="conversation", + title=entry.title, + unique_id=None, + ) + if entry.data[CONF_API_KEY] not in api_keys_entries: + use_existing = True + api_keys_entries[entry.data[CONF_API_KEY]] = entry + + parent_entry = api_keys_entries[entry.data[CONF_API_KEY]] + + hass.config_entries.async_add_subentry(parent_entry, subentry) + conversation_entity = entity_registry.async_get_entity_id( + "conversation", + DOMAIN, + entry.entry_id, + ) + if conversation_entity is not None: + entity_registry.async_update_entity( + conversation_entity, + config_entry_id=parent_entry.entry_id, + config_subentry_id=subentry.subentry_id, + new_unique_id=subentry.subentry_id, + ) + + device = device_registry.async_get_device( + identifiers={(DOMAIN, entry.entry_id)} + ) + if device is not None: + device_registry.async_update_device( + device.id, + new_identifiers={(DOMAIN, subentry.subentry_id)}, + add_config_subentry_id=subentry.subentry_id, + add_config_entry_id=parent_entry.entry_id, + ) + if parent_entry.entry_id != entry.entry_id: + device_registry.async_update_device( + device.id, + remove_config_entry_id=entry.entry_id, + ) + + if not use_existing: + await hass.config_entries.async_remove(entry.entry_id) + else: + hass.config_entries.async_update_entry( + entry, + options={}, + version=2, + ) diff --git a/homeassistant/components/anthropic/config_flow.py b/homeassistant/components/anthropic/config_flow.py index ebad206af6174b..6a18cb693cd9b4 100644 --- a/homeassistant/components/anthropic/config_flow.py +++ b/homeassistant/components/anthropic/config_flow.py @@ -5,20 +5,21 @@ from collections.abc import Mapping from functools import partial import logging -from types import MappingProxyType -from typing import Any +from typing import Any, cast import anthropic import voluptuous as vol from homeassistant.config_entries import ( ConfigEntry, + ConfigEntryState, ConfigFlow, ConfigFlowResult, - OptionsFlow, + ConfigSubentryFlow, + SubentryFlowResult, ) -from homeassistant.const import CONF_API_KEY, CONF_LLM_HASS_API -from homeassistant.core import HomeAssistant +from homeassistant.const import CONF_API_KEY, CONF_LLM_HASS_API, CONF_NAME +from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import llm from homeassistant.helpers.selector import ( NumberSelector, @@ -36,6 +37,7 @@ CONF_RECOMMENDED, CONF_TEMPERATURE, CONF_THINKING_BUDGET, + DEFAULT_CONVERSATION_NAME, DOMAIN, RECOMMENDED_CHAT_MODEL, RECOMMENDED_MAX_TOKENS, @@ -72,7 +74,7 @@ async def validate_input(hass: HomeAssistant, data: dict[str, Any]) -> None: class AnthropicConfigFlow(ConfigFlow, domain=DOMAIN): """Handle a config flow for Anthropic.""" - VERSION = 1 + VERSION = 2 async def async_step_user( self, user_input: dict[str, Any] | None = None @@ -81,6 +83,7 @@ async def async_step_user( errors = {} if user_input is not None: + self._async_abort_entries_match(user_input) try: await validate_input(self.hass, user_input) except anthropic.APITimeoutError: @@ -102,57 +105,93 @@ async def async_step_user( return self.async_create_entry( title="Claude", data=user_input, - options=RECOMMENDED_OPTIONS, + subentries=[ + { + "subentry_type": "conversation", + "data": RECOMMENDED_OPTIONS, + "title": DEFAULT_CONVERSATION_NAME, + "unique_id": None, + } + ], ) return self.async_show_form( step_id="user", data_schema=STEP_USER_DATA_SCHEMA, errors=errors or None ) - @staticmethod - def async_get_options_flow( - config_entry: ConfigEntry, - ) -> OptionsFlow: - """Create the options flow.""" - return AnthropicOptionsFlow(config_entry) + @classmethod + @callback + def async_get_supported_subentry_types( + cls, config_entry: ConfigEntry + ) -> dict[str, type[ConfigSubentryFlow]]: + """Return subentries supported by this integration.""" + return {"conversation": ConversationSubentryFlowHandler} -class AnthropicOptionsFlow(OptionsFlow): - """Anthropic config flow options handler.""" +class ConversationSubentryFlowHandler(ConfigSubentryFlow): + """Flow for managing conversation subentries.""" - def __init__(self, config_entry: ConfigEntry) -> None: - """Initialize options flow.""" - self.last_rendered_recommended = config_entry.options.get( - CONF_RECOMMENDED, False - ) + last_rendered_recommended = False + + @property + def _is_new(self) -> bool: + """Return if this is a new subentry.""" + return self.source == "user" - async def async_step_init( + async def async_step_set_options( self, user_input: dict[str, Any] | None = None - ) -> ConfigFlowResult: - """Manage the options.""" - options: dict[str, Any] | MappingProxyType[str, Any] = self.config_entry.options + ) -> SubentryFlowResult: + """Set conversation options.""" + # abort if entry is not loaded + if self._get_entry().state != ConfigEntryState.LOADED: + return self.async_abort(reason="entry_not_loaded") + errors: dict[str, str] = {} - if user_input is not None: - if user_input[CONF_RECOMMENDED] == self.last_rendered_recommended: - if not user_input.get(CONF_LLM_HASS_API): - user_input.pop(CONF_LLM_HASS_API, None) - if user_input.get( - CONF_THINKING_BUDGET, RECOMMENDED_THINKING_BUDGET - ) >= user_input.get(CONF_MAX_TOKENS, RECOMMENDED_MAX_TOKENS): - errors[CONF_THINKING_BUDGET] = "thinking_budget_too_large" - - if not errors: - return self.async_create_entry(title="", data=user_input) + if user_input is None: + if self._is_new: + options = RECOMMENDED_OPTIONS.copy() else: - # Re-render the options again, now with the recommended options shown/hidden - self.last_rendered_recommended = user_input[CONF_RECOMMENDED] + # If this is a reconfiguration, we need to copy the existing options + # so that we can show the current values in the form. + options = self._get_reconfigure_subentry().data.copy() + + self.last_rendered_recommended = cast( + bool, options.get(CONF_RECOMMENDED, False) + ) + + elif user_input[CONF_RECOMMENDED] == self.last_rendered_recommended: + if not user_input.get(CONF_LLM_HASS_API): + user_input.pop(CONF_LLM_HASS_API, None) + if user_input.get( + CONF_THINKING_BUDGET, RECOMMENDED_THINKING_BUDGET + ) >= user_input.get(CONF_MAX_TOKENS, RECOMMENDED_MAX_TOKENS): + errors[CONF_THINKING_BUDGET] = "thinking_budget_too_large" + + if not errors: + if self._is_new: + return self.async_create_entry( + title=user_input.pop(CONF_NAME), + data=user_input, + ) + + return self.async_update_and_abort( + self._get_entry(), + self._get_reconfigure_subentry(), + data=user_input, + ) - options = { - CONF_RECOMMENDED: user_input[CONF_RECOMMENDED], - CONF_PROMPT: user_input[CONF_PROMPT], - CONF_LLM_HASS_API: user_input.get(CONF_LLM_HASS_API), - } + options = user_input + self.last_rendered_recommended = user_input[CONF_RECOMMENDED] + 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.get(CONF_LLM_HASS_API), + } suggested_values = options.copy() if not suggested_values.get(CONF_PROMPT): @@ -163,19 +202,25 @@ async def async_step_init( suggested_values[CONF_LLM_HASS_API] = [suggested_llm_apis] schema = self.add_suggested_values_to_schema( - vol.Schema(anthropic_config_option_schema(self.hass, options)), + vol.Schema( + anthropic_config_option_schema(self.hass, self._is_new, options) + ), suggested_values, ) return self.async_show_form( - step_id="init", + step_id="set_options", data_schema=schema, errors=errors or None, ) + async_step_user = async_step_set_options + async_step_reconfigure = async_step_set_options + def anthropic_config_option_schema( hass: HomeAssistant, + is_new: bool, options: Mapping[str, Any], ) -> dict: """Return a schema for Anthropic completion options.""" @@ -187,15 +232,24 @@ def anthropic_config_option_schema( for api in llm.async_get_apis(hass) ] - schema = { - vol.Optional(CONF_PROMPT): TemplateSelector(), - vol.Optional( - CONF_LLM_HASS_API, - ): SelectSelector(SelectSelectorConfig(options=hass_apis, multiple=True)), - vol.Required( - CONF_RECOMMENDED, default=options.get(CONF_RECOMMENDED, False) - ): bool, - } + if is_new: + schema: dict[vol.Required | vol.Optional, Any] = { + vol.Required(CONF_NAME, default=DEFAULT_CONVERSATION_NAME): str, + } + else: + schema = {} + + schema.update( + { + vol.Optional(CONF_PROMPT): TemplateSelector(), + vol.Optional( + CONF_LLM_HASS_API, + ): SelectSelector(SelectSelectorConfig(options=hass_apis, multiple=True)), + vol.Required( + CONF_RECOMMENDED, default=options.get(CONF_RECOMMENDED, False) + ): bool, + } + ) if options.get(CONF_RECOMMENDED): return schema diff --git a/homeassistant/components/anthropic/const.py b/homeassistant/components/anthropic/const.py index 69789b9a64ae69..d7e10dd7af206a 100644 --- a/homeassistant/components/anthropic/const.py +++ b/homeassistant/components/anthropic/const.py @@ -5,6 +5,8 @@ DOMAIN = "anthropic" LOGGER = logging.getLogger(__package__) +DEFAULT_CONVERSATION_NAME = "Claude conversation" + CONF_RECOMMENDED = "recommended" CONF_PROMPT = "prompt" CONF_CHAT_MODEL = "chat_model" diff --git a/homeassistant/components/anthropic/conversation.py b/homeassistant/components/anthropic/conversation.py index f17294fe0e7e01..f34d9ed97b67e2 100644 --- a/homeassistant/components/anthropic/conversation.py +++ b/homeassistant/components/anthropic/conversation.py @@ -38,7 +38,7 @@ from voluptuous_openapi import convert from homeassistant.components import conversation -from homeassistant.config_entries import ConfigEntry +from homeassistant.config_entries import ConfigEntry, ConfigSubentry from homeassistant.const import CONF_LLM_HASS_API, MATCH_ALL from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError @@ -72,8 +72,14 @@ async def async_setup_entry( async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up conversation entities.""" - agent = AnthropicConversationEntity(config_entry) - async_add_entities([agent]) + for subentry in config_entry.subentries.values(): + if subentry.subentry_type != "conversation": + continue + + async_add_entities( + [AnthropicConversationEntity(config_entry, subentry)], + config_subentry_id=subentry.subentry_id, + ) def _format_tool( @@ -326,21 +332,22 @@ class AnthropicConversationEntity( ): """Anthropic conversation agent.""" - _attr_has_entity_name = True - _attr_name = None _attr_supports_streaming = True - def __init__(self, entry: AnthropicConfigEntry) -> None: + def __init__(self, entry: AnthropicConfigEntry, subentry: ConfigSubentry) -> None: """Initialize the agent.""" self.entry = entry - self._attr_unique_id = entry.entry_id + self.subentry = subentry + self._attr_name = subentry.title + self._attr_unique_id = subentry.subentry_id self._attr_device_info = dr.DeviceInfo( - identifiers={(DOMAIN, entry.entry_id)}, + identifiers={(DOMAIN, subentry.subentry_id)}, + name=subentry.title, manufacturer="Anthropic", model="Claude", entry_type=dr.DeviceEntryType.SERVICE, ) - if self.entry.options.get(CONF_LLM_HASS_API): + if self.subentry.data.get(CONF_LLM_HASS_API): self._attr_supported_features = ( conversation.ConversationEntityFeature.CONTROL ) @@ -363,7 +370,7 @@ async def _async_handle_message( chat_log: conversation.ChatLog, ) -> conversation.ConversationResult: """Call the API.""" - options = self.entry.options + options = self.subentry.data try: await chat_log.async_provide_llm_data( @@ -393,7 +400,7 @@ async def _async_handle_chat_log( chat_log: conversation.ChatLog, ) -> None: """Generate an answer for the chat log.""" - options = self.entry.options + options = self.subentry.data tools: list[ToolParam] | None = None if chat_log.llm_api: diff --git a/homeassistant/components/anthropic/strings.json b/homeassistant/components/anthropic/strings.json index c2caf3a6666bbf..098b4d5fa746b7 100644 --- a/homeassistant/components/anthropic/strings.json +++ b/homeassistant/components/anthropic/strings.json @@ -12,28 +12,44 @@ "timeout_connect": "[%key:common::config_flow::error::timeout_connect%]", "authentication_error": "[%key:common::config_flow::error::invalid_auth%]", "unknown": "[%key:common::config_flow::error::unknown%]" + }, + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_service%]" } }, - "options": { - "step": { - "init": { - "data": { - "prompt": "Instructions", - "chat_model": "[%key:common::generic::model%]", - "max_tokens": "Maximum tokens to return in response", - "temperature": "Temperature", - "llm_hass_api": "[%key:common::config_flow::data::llm_hass_api%]", - "recommended": "Recommended model settings", - "thinking_budget_tokens": "Thinking budget" - }, - "data_description": { - "prompt": "Instruct how the LLM should respond. This can be a template.", - "thinking_budget_tokens": "The number of tokens the model can use to think about the response out of the total maximum number of tokens. Set to 1024 or greater to enable extended thinking." + "config_subentries": { + "conversation": { + "initiate_flow": { + "user": "Add conversation agent", + "reconfigure": "Reconfigure conversation agent" + }, + "entry_type": "Conversation agent", + + "step": { + "set_options": { + "data": { + "name": "[%key:common::config_flow::data::name%]", + "prompt": "Instructions", + "chat_model": "[%key:common::generic::model%]", + "max_tokens": "Maximum tokens to return in response", + "temperature": "Temperature", + "llm_hass_api": "[%key:common::config_flow::data::llm_hass_api%]", + "recommended": "Recommended model settings", + "thinking_budget_tokens": "Thinking budget" + }, + "data_description": { + "prompt": "Instruct how the LLM should respond. This can be a template.", + "thinking_budget_tokens": "The number of tokens the model can use to think about the response out of the total maximum number of tokens. Set to 1024 or greater to enable extended thinking." + } } + }, + "abort": { + "reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]", + "entry_not_loaded": "Cannot add things while the configuration is disabled." + }, + "error": { + "thinking_budget_too_large": "Maximum tokens must be greater than the thinking budget." } - }, - "error": { - "thinking_budget_too_large": "Maximum tokens must be greater than the thinking budget." } } } diff --git a/homeassistant/components/derivative/config_flow.py b/homeassistant/components/derivative/config_flow.py index 2ef2018eda80a0..37d54e04f7f2fb 100644 --- a/homeassistant/components/derivative/config_flow.py +++ b/homeassistant/components/derivative/config_flow.py @@ -26,6 +26,7 @@ ) from .const import ( + CONF_MAX_SUB_INTERVAL, CONF_ROUND_DIGITS, CONF_TIME_WINDOW, CONF_UNIT_PREFIX, @@ -104,6 +105,9 @@ async def _get_options_dict(handler: SchemaCommonFlowHandler | None) -> dict: options=TIME_UNITS, translation_key="time_unit" ), ), + vol.Optional(CONF_MAX_SUB_INTERVAL): selector.DurationSelector( + selector.DurationSelectorConfig(allow_negative=False) + ), } diff --git a/homeassistant/components/derivative/const.py b/homeassistant/components/derivative/const.py index 32f2777dc803b8..9166a5059157df 100644 --- a/homeassistant/components/derivative/const.py +++ b/homeassistant/components/derivative/const.py @@ -7,3 +7,4 @@ CONF_UNIT = "unit" CONF_UNIT_PREFIX = "unit_prefix" CONF_UNIT_TIME = "unit_time" +CONF_MAX_SUB_INTERVAL = "max_sub_interval" diff --git a/homeassistant/components/derivative/manifest.json b/homeassistant/components/derivative/manifest.json index e1d8986c2dd86f..4c5684bae75dcd 100644 --- a/homeassistant/components/derivative/manifest.json +++ b/homeassistant/components/derivative/manifest.json @@ -2,7 +2,7 @@ "domain": "derivative", "name": "Derivative", "after_dependencies": ["counter"], - "codeowners": ["@afaucogney"], + "codeowners": ["@afaucogney", "@karwosts"], "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/derivative", "integration_type": "helper", diff --git a/homeassistant/components/derivative/sensor.py b/homeassistant/components/derivative/sensor.py index f6c2b45ef9cf72..60f4611c5eb56d 100644 --- a/homeassistant/components/derivative/sensor.py +++ b/homeassistant/components/derivative/sensor.py @@ -3,7 +3,7 @@ from __future__ import annotations from datetime import datetime, timedelta -from decimal import Decimal, DecimalException +from decimal import Decimal, DecimalException, InvalidOperation import logging import voluptuous as vol @@ -25,6 +25,7 @@ UnitOfTime, ) from homeassistant.core import ( + CALLBACK_TYPE, Event, EventStateChangedData, EventStateReportedData, @@ -40,12 +41,14 @@ AddEntitiesCallback, ) from homeassistant.helpers.event import ( + async_call_later, async_track_state_change_event, async_track_state_report_event, ) from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from .const import ( + CONF_MAX_SUB_INTERVAL, CONF_ROUND_DIGITS, CONF_TIME_WINDOW, CONF_UNIT, @@ -89,10 +92,20 @@ vol.Optional(CONF_UNIT_TIME, default=UnitOfTime.HOURS): vol.In(UNIT_TIME), vol.Optional(CONF_UNIT): cv.string, vol.Optional(CONF_TIME_WINDOW, default=DEFAULT_TIME_WINDOW): cv.time_period, + vol.Optional(CONF_MAX_SUB_INTERVAL): cv.positive_time_period, } ) +def _is_decimal_state(state: str) -> bool: + try: + Decimal(state) + except (InvalidOperation, TypeError): + return False + else: + return True + + async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, @@ -114,6 +127,11 @@ async def async_setup_entry( # Before we had support for optional selectors, "none" was used for selecting nothing unit_prefix = None + if max_sub_interval_dict := config_entry.options.get(CONF_MAX_SUB_INTERVAL, None): + max_sub_interval = cv.time_period(max_sub_interval_dict) + else: + max_sub_interval = None + derivative_sensor = DerivativeSensor( name=config_entry.title, round_digits=int(config_entry.options[CONF_ROUND_DIGITS]), @@ -124,6 +142,7 @@ async def async_setup_entry( unit_prefix=unit_prefix, unit_time=config_entry.options[CONF_UNIT_TIME], device_info=device_info, + max_sub_interval=max_sub_interval, ) async_add_entities([derivative_sensor]) @@ -145,6 +164,7 @@ async def async_setup_platform( unit_prefix=config[CONF_UNIT_PREFIX], unit_time=config[CONF_UNIT_TIME], unique_id=None, + max_sub_interval=config.get(CONF_MAX_SUB_INTERVAL), ) async_add_entities([derivative]) @@ -166,6 +186,7 @@ def __init__( unit_of_measurement: str | None, unit_prefix: str | None, unit_time: UnitOfTime, + max_sub_interval: timedelta | None, unique_id: str | None, device_info: DeviceInfo | None = None, ) -> None: @@ -192,6 +213,34 @@ def __init__( self._unit_prefix = UNIT_PREFIXES[unit_prefix] self._unit_time = UNIT_TIME[unit_time] self._time_window = time_window.total_seconds() + self._max_sub_interval: timedelta | None = ( + None # disable time based derivative + if max_sub_interval is None or max_sub_interval.total_seconds() == 0 + else max_sub_interval + ) + self._cancel_max_sub_interval_exceeded_callback: CALLBACK_TYPE = ( + lambda *args: None + ) + + def _calc_derivative_from_state_list(self, current_time: datetime) -> Decimal: + def calculate_weight(start: datetime, end: datetime, now: datetime) -> float: + window_start = now - timedelta(seconds=self._time_window) + return (end - max(start, window_start)).total_seconds() / self._time_window + + derivative = Decimal("0.00") + for start, end, value in self._state_list: + weight = calculate_weight(start, end, current_time) + derivative = derivative + (value * Decimal(weight)) + + return derivative + + def _prune_state_list(self, current_time: datetime) -> None: + # filter out all derivatives older than `time_window` from our window list + self._state_list = [ + (time_start, time_end, state) + for time_start, time_end, state in self._state_list + if (current_time - time_end).total_seconds() < self._time_window + ] async def async_added_to_hass(self) -> None: """Handle entity which will be added.""" @@ -209,13 +258,52 @@ async def async_added_to_hass(self) -> None: except SyntaxError as err: _LOGGER.warning("Could not restore last state: %s", err) + def schedule_max_sub_interval_exceeded(source_state: State | None) -> None: + """Schedule calculation using the source state and max_sub_interval. + + The callback reference is stored for possible cancellation if the source state + reports a change before max_sub_interval has passed. + If the callback is executed, meaning there was no state change reported, the + source_state is assumed constant and calculation is done using its value. + """ + if ( + self._max_sub_interval is not None + and source_state is not None + and (_is_decimal_state(source_state.state)) + ): + + @callback + def _calc_derivative_on_max_sub_interval_exceeded_callback( + now: datetime, + ) -> None: + """Calculate derivative based on time and reschedule.""" + + self._prune_state_list(now) + derivative = self._calc_derivative_from_state_list(now) + self._attr_native_value = round(derivative, self._round_digits) + + self.async_write_ha_state() + + # If derivative is now zero, don't schedule another timeout callback, as it will have no effect + if derivative != 0: + schedule_max_sub_interval_exceeded(source_state) + + self._cancel_max_sub_interval_exceeded_callback = async_call_later( + self.hass, + self._max_sub_interval, + _calc_derivative_on_max_sub_interval_exceeded_callback, + ) + @callback def on_state_reported(event: Event[EventStateReportedData]) -> None: """Handle constant sensor state.""" + self._cancel_max_sub_interval_exceeded_callback() + new_state = event.data["new_state"] if self._attr_native_value == Decimal(0): # If the derivative is zero, and the source sensor hasn't # changed state, then we know it will still be zero. return + schedule_max_sub_interval_exceeded(new_state) new_state = event.data["new_state"] if new_state is not None: calc_derivative( @@ -225,7 +313,9 @@ def on_state_reported(event: Event[EventStateReportedData]) -> None: @callback def on_state_changed(event: Event[EventStateChangedData]) -> None: """Handle changed sensor state.""" + self._cancel_max_sub_interval_exceeded_callback() new_state = event.data["new_state"] + schedule_max_sub_interval_exceeded(new_state) old_state = event.data["old_state"] if new_state is not None and old_state is not None: calc_derivative(new_state, old_state.state, old_state.last_reported) @@ -312,6 +402,16 @@ def calculate_weight( self._attr_native_value = round(derivative, self._round_digits) self.async_write_ha_state() + if self._max_sub_interval is not None: + source_state = self.hass.states.get(self._sensor_source_id) + schedule_max_sub_interval_exceeded(source_state) + + @callback + def on_removed() -> None: + self._cancel_max_sub_interval_exceeded_callback() + + self.async_on_remove(on_removed) + self.async_on_remove( async_track_state_change_event( self.hass, self._sensor_source_id, on_state_changed diff --git a/homeassistant/components/derivative/strings.json b/homeassistant/components/derivative/strings.json index f1b7375ae07d87..5081e7f3b356d3 100644 --- a/homeassistant/components/derivative/strings.json +++ b/homeassistant/components/derivative/strings.json @@ -6,6 +6,7 @@ "title": "Create Derivative sensor", "description": "Create a sensor that estimates the derivative of a sensor.", "data": { + "max_sub_interval": "Max sub-interval", "name": "[%key:common::config_flow::data::name%]", "round": "Precision", "source": "Input sensor", @@ -14,6 +15,7 @@ "unit_time": "Time unit" }, "data_description": { + "max_sub_interval": "If defined, derivative automatically recalculates if the source has not updated for this duration.", "round": "Controls the number of decimal digits in the output.", "time_window": "If set, the sensor's value is a time-weighted moving average of derivatives within this window.", "unit_prefix": "The output will be scaled according to the selected metric prefix and time unit of the derivative." @@ -25,6 +27,7 @@ "step": { "init": { "data": { + "max_sub_interval": "[%key:component::derivative::config::step::user::data::max_sub_interval%]", "name": "[%key:common::config_flow::data::name%]", "round": "[%key:component::derivative::config::step::user::data::round%]", "source": "[%key:component::derivative::config::step::user::data::source%]", @@ -33,6 +36,7 @@ "unit_time": "[%key:component::derivative::config::step::user::data::unit_time%]" }, "data_description": { + "max_sub_interval": "[%key:component::derivative::config::step::user::data_description::max_sub_interval%]", "round": "[%key:component::derivative::config::step::user::data_description::round%]", "time_window": "[%key:component::derivative::config::step::user::data_description::time_window%]", "unit_prefix": "[%key:component::derivative::config::step::user::data_description::unit_prefix%]" diff --git a/homeassistant/components/lcn/__init__.py b/homeassistant/components/lcn/__init__.py index efc981b754cd21..43438fa64ddabd 100644 --- a/homeassistant/components/lcn/__init__.py +++ b/homeassistant/components/lcn/__init__.py @@ -107,7 +107,7 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: LcnConfigEntry) - f"Unable to connect to {config_entry.title}: {ex}" ) from ex - _LOGGER.debug('LCN connected to "%s"', config_entry.title) + _LOGGER.info('LCN connected to "%s"', config_entry.title) config_entry.runtime_data = LcnRuntimeData( connection=lcn_connection, device_connections={}, diff --git a/homeassistant/components/playstation_network/config_flow.py b/homeassistant/components/playstation_network/config_flow.py index e2b402a212e8e2..29ba8d4de904f8 100644 --- a/homeassistant/components/playstation_network/config_flow.py +++ b/homeassistant/components/playstation_network/config_flow.py @@ -1,5 +1,6 @@ """Config flow for the PlayStation Network integration.""" +from collections.abc import Mapping import logging from typing import Any @@ -14,8 +15,9 @@ import voluptuous as vol from homeassistant.config_entries import ConfigFlow, ConfigFlowResult +from homeassistant.const import CONF_NAME -from .const import CONF_NPSSO, DOMAIN +from .const import CONF_NPSSO, DOMAIN, NPSSO_LINK, PSN_LINK from .helpers import PlaystationNetwork _LOGGER = logging.getLogger(__name__) @@ -63,7 +65,61 @@ async def async_step_user( data_schema=STEP_USER_DATA_SCHEMA, errors=errors, description_placeholders={ - "npsso_link": "https://ca.account.sony.com/api/v1/ssocookie", - "psn_link": "https://playstation.com", + "npsso_link": NPSSO_LINK, + "psn_link": PSN_LINK, + }, + ) + + async def async_step_reauth( + self, entry_data: Mapping[str, Any] + ) -> ConfigFlowResult: + """Perform reauth upon an API authentication error.""" + return await self.async_step_reauth_confirm() + + async def async_step_reauth_confirm( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Confirm reauthentication dialog.""" + errors: dict[str, str] = {} + + entry = self._get_reauth_entry() + + if user_input is not None: + try: + npsso = parse_npsso_token(user_input[CONF_NPSSO]) + psn = PlaystationNetwork(self.hass, npsso) + user: User = await psn.get_user() + except PSNAWPAuthenticationError: + errors["base"] = "invalid_auth" + except (PSNAWPNotFoundError, PSNAWPInvalidTokenError): + errors["base"] = "invalid_account" + except PSNAWPError: + errors["base"] = "cannot_connect" + except Exception: + _LOGGER.exception("Unexpected exception") + errors["base"] = "unknown" + else: + await self.async_set_unique_id(user.account_id) + self._abort_if_unique_id_mismatch( + description_placeholders={ + "wrong_account": user.online_id, + CONF_NAME: entry.title, + } + ) + + return self.async_update_reload_and_abort( + entry, + data_updates={CONF_NPSSO: npsso}, + ) + + return self.async_show_form( + step_id="reauth_confirm", + data_schema=self.add_suggested_values_to_schema( + data_schema=STEP_USER_DATA_SCHEMA, suggested_values=user_input + ), + errors=errors, + description_placeholders={ + "npsso_link": NPSSO_LINK, + "psn_link": PSN_LINK, }, ) diff --git a/homeassistant/components/playstation_network/const.py b/homeassistant/components/playstation_network/const.py index 2db43f433e6e49..77b43af3b7361f 100644 --- a/homeassistant/components/playstation_network/const.py +++ b/homeassistant/components/playstation_network/const.py @@ -13,3 +13,6 @@ PlatformType.PS3, PlatformType.PSPC, } + +NPSSO_LINK: Final = "https://ca.account.sony.com/api/v1/ssocookie" +PSN_LINK: Final = "https://playstation.com" diff --git a/homeassistant/components/playstation_network/coordinator.py b/homeassistant/components/playstation_network/coordinator.py index f6fd53ccb248db..2581a016feb4c6 100644 --- a/homeassistant/components/playstation_network/coordinator.py +++ b/homeassistant/components/playstation_network/coordinator.py @@ -13,7 +13,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant -from homeassistant.exceptions import ConfigEntryNotReady +from homeassistant.exceptions import ConfigEntryAuthFailed from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from .const import DOMAIN @@ -53,7 +53,7 @@ async def _async_setup(self) -> None: try: self.user = await self.psn.get_user() except PSNAWPAuthenticationError as error: - raise ConfigEntryNotReady( + raise ConfigEntryAuthFailed( translation_domain=DOMAIN, translation_key="not_ready", ) from error @@ -62,7 +62,12 @@ async def _async_update_data(self) -> PlaystationNetworkData: """Get the latest data from the PSN.""" try: return await self.psn.get_data() - except (PSNAWPAuthenticationError, PSNAWPServerError) as error: + except PSNAWPAuthenticationError as error: + raise ConfigEntryAuthFailed( + translation_domain=DOMAIN, + translation_key="not_ready", + ) from error + except PSNAWPServerError as error: raise UpdateFailed( translation_domain=DOMAIN, translation_key="update_failed", diff --git a/homeassistant/components/playstation_network/manifest.json b/homeassistant/components/playstation_network/manifest.json index f929e569b66742..bdcb77f92c36be 100644 --- a/homeassistant/components/playstation_network/manifest.json +++ b/homeassistant/components/playstation_network/manifest.json @@ -3,6 +3,65 @@ "name": "PlayStation Network", "codeowners": ["@jackjpowell"], "config_flow": true, + "dhcp": [ + { + "macaddress": "AC8995*" + }, + { + "macaddress": "1C98C1*" + }, + { + "macaddress": "5C843C*" + }, + { + "macaddress": "605BB4*" + }, + { + "macaddress": "8060B7*" + }, + { + "macaddress": "78C881*" + }, + { + "macaddress": "00D9D1*" + }, + { + "macaddress": "00E421*" + }, + { + "macaddress": "0CFE45*" + }, + { + "macaddress": "2CCC44*" + }, + { + "macaddress": "BC60A7*" + }, + { + "macaddress": "C863F1*" + }, + { + "macaddress": "F8461C*" + }, + { + "macaddress": "70662A*" + }, + { + "macaddress": "09E29*" + }, + { + "macaddress": "B40AD8*" + }, + { + "macaddress": "A8474A*" + }, + { + "macaddress": "280DFC*" + }, + { + "macaddress": "D44B5E*" + } + ], "documentation": "https://www.home-assistant.io/integrations/playstation_network", "integration_type": "service", "iot_class": "cloud_polling", diff --git a/homeassistant/components/playstation_network/quality_scale.yaml b/homeassistant/components/playstation_network/quality_scale.yaml index 36c28f19145a8c..e173c4a710c203 100644 --- a/homeassistant/components/playstation_network/quality_scale.yaml +++ b/homeassistant/components/playstation_network/quality_scale.yaml @@ -39,7 +39,7 @@ rules: integration-owner: done log-when-unavailable: done parallel-updates: done - reauthentication-flow: todo + reauthentication-flow: done test-coverage: todo # Gold @@ -48,7 +48,7 @@ rules: discovery-update-info: status: exempt comment: Discovery flow is not applicable for this integration - discovery: todo + discovery: done docs-data-update: done docs-examples: todo docs-known-limitations: done diff --git a/homeassistant/components/playstation_network/strings.json b/homeassistant/components/playstation_network/strings.json index e4581322edb6f1..19d61859f9760d 100644 --- a/homeassistant/components/playstation_network/strings.json +++ b/homeassistant/components/playstation_network/strings.json @@ -9,6 +9,16 @@ "npsso": "The NPSSO token is generated upon successful login of your PlayStation Network account and is used to authenticate your requests within Home Assistant." }, "description": "To obtain your NPSSO token, log in to your [PlayStation account]({psn_link}) first. Then [click here]({npsso_link}) to retrieve the token." + }, + "reauth_confirm": { + "title": "Re-authenticate {name} with PlayStation Network", + "description": "The NPSSO token for **{name}** has expired. To obtain a new one, log in to your [PlayStation account]({psn_link}) first. Then [click here]({npsso_link}) to retrieve the token.", + "data": { + "npsso": "[%key:component::playstation_network::config::step::user::data::npsso%]" + }, + "data_description": { + "npsso": "[%key:component::playstation_network::config::step::user::data_description::npsso%]" + } } }, "error": { @@ -18,7 +28,9 @@ "unknown": "[%key:common::config_flow::error::unknown%]" }, "abort": { - "already_configured": "[%key:common::config_flow::abort::already_configured_account%]" + "already_configured": "[%key:common::config_flow::abort::already_configured_account%]", + "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]", + "unique_id_mismatch": "The provided NPSSO token corresponds to the account {wrong_account}. Please re-authenticate with the account **{name}**" } }, "exceptions": { diff --git a/homeassistant/components/sensor/icons.json b/homeassistant/components/sensor/icons.json index f412b5de253348..05311868fc66d8 100644 --- a/homeassistant/components/sensor/icons.json +++ b/homeassistant/components/sensor/icons.json @@ -176,7 +176,18 @@ "default": "mdi:weight" }, "wind_direction": { - "default": "mdi:compass-rose" + "default": "mdi:compass-rose", + "range": { + "0": "mdi:arrow-down", + "22.5": "mdi:arrow-bottom-left", + "67.5": "mdi:arrow-left", + "112.5": "mdi:arrow-top-left", + "157.5": "mdi:arrow-up", + "202.5": "mdi:arrow-top-right", + "247.5": "mdi:arrow-right", + "292.5": "mdi:arrow-bottom-right", + "337.5": "mdi:arrow-down" + } }, "wind_speed": { "default": "mdi:weather-windy" diff --git a/homeassistant/components/tedee/binary_sensor.py b/homeassistant/components/tedee/binary_sensor.py index 6570d9c5428dd2..e67db7b2a9bcde 100644 --- a/homeassistant/components/tedee/binary_sensor.py +++ b/homeassistant/components/tedee/binary_sensor.py @@ -4,7 +4,7 @@ from dataclasses import dataclass from aiotedee import TedeeLock -from aiotedee.lock import TedeeLockState +from aiotedee.lock import TedeeDoorState, TedeeLockState from homeassistant.components.binary_sensor import ( BinarySensorDeviceClass, @@ -29,6 +29,8 @@ class TedeeBinarySensorEntityDescription( """Describes Tedee binary sensor entity.""" is_on_fn: Callable[[TedeeLock], bool | None] + supported_fn: Callable[[TedeeLock], bool] = lambda _: True + available_fn: Callable[[TedeeLock], bool] = lambda _: True ENTITIES: tuple[TedeeBinarySensorEntityDescription, ...] = ( @@ -61,6 +63,14 @@ class TedeeBinarySensorEntityDescription( entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, ), + TedeeBinarySensorEntityDescription( + key="door_state", + is_on_fn=lambda lock: lock.door_state is TedeeDoorState.OPENED, + device_class=BinarySensorDeviceClass.DOOR, + supported_fn=lambda lock: lock.door_state is not TedeeDoorState.NOT_PAIRED, + available_fn=lambda lock: lock.door_state + not in [TedeeDoorState.UNCALIBRATED, TedeeDoorState.DISCONNECTED], + ), ) @@ -77,6 +87,7 @@ def _async_add_new_lock(locks: list[TedeeLock]) -> None: TedeeBinarySensorEntity(lock, coordinator, entity_description) for entity_description in ENTITIES for lock in locks + if entity_description.supported_fn(lock) ) coordinator.new_lock_callbacks.append(_async_add_new_lock) @@ -92,3 +103,8 @@ class TedeeBinarySensorEntity(TedeeDescriptionEntity, BinarySensorEntity): def is_on(self) -> bool | None: """Return true if the binary sensor is on.""" return self.entity_description.is_on_fn(self._lock) + + @property + def available(self) -> bool: + """Return true if the binary sensor is available.""" + return self.entity_description.available_fn(self._lock) and super().available diff --git a/homeassistant/components/telegram_bot/__init__.py b/homeassistant/components/telegram_bot/__init__.py index 554ddd8fc4e26e..5bdc670d69ca9a 100644 --- a/homeassistant/components/telegram_bot/__init__.py +++ b/homeassistant/components/telegram_bot/__init__.py @@ -72,7 +72,6 @@ CONF_ALLOWED_CHAT_IDS, CONF_BOT_COUNT, CONF_CONFIG_ENTRY_ID, - CONF_PROXY_PARAMS, CONF_PROXY_URL, CONF_TRUSTED_NETWORKS, DEFAULT_TRUSTED_NETWORKS, @@ -117,7 +116,6 @@ ), vol.Optional(ATTR_PARSER, default=PARSER_MD): cv.string, vol.Optional(CONF_PROXY_URL): cv.string, - vol.Optional(CONF_PROXY_PARAMS): dict, # webhooks vol.Optional(CONF_URL): cv.url, vol.Optional( diff --git a/homeassistant/components/telegram_bot/bot.py b/homeassistant/components/telegram_bot/bot.py index 9debc7bbbf1074..4a00aff8d3f9be 100644 --- a/homeassistant/components/telegram_bot/bot.py +++ b/homeassistant/components/telegram_bot/bot.py @@ -37,7 +37,6 @@ ) from homeassistant.core import Context, HomeAssistant from homeassistant.exceptions import HomeAssistantError, ServiceValidationError -from homeassistant.helpers import issue_registry as ir from homeassistant.util.ssl import get_default_context, get_default_no_verify_context from .const import ( @@ -77,7 +76,6 @@ ATTR_USERNAME, ATTR_VERIFY_SSL, CONF_CHAT_ID, - CONF_PROXY_PARAMS, CONF_PROXY_URL, DOMAIN, EVENT_TELEGRAM_CALLBACK, @@ -877,52 +875,9 @@ def initialize_bot(hass: HomeAssistant, p_config: MappingProxyType[str, Any]) -> """Initialize telegram bot with proxy support.""" api_key: str = p_config[CONF_API_KEY] proxy_url: str | None = p_config.get(CONF_PROXY_URL) - proxy_params: dict | None = p_config.get(CONF_PROXY_PARAMS) if proxy_url is not None: - auth = None - if proxy_params is None: - # CONF_PROXY_PARAMS has been kept for backwards compatibility. - proxy_params = {} - elif "username" in proxy_params and "password" in proxy_params: - # Auth can actually be stuffed into the URL, but the docs have previously - # indicated to put them here. - auth = proxy_params.pop("username"), proxy_params.pop("password") - ir.create_issue( - hass, - DOMAIN, - "proxy_params_auth_deprecation", - breaks_in_ha_version="2024.10.0", - is_persistent=False, - is_fixable=False, - severity=ir.IssueSeverity.WARNING, - translation_placeholders={ - "proxy_params": CONF_PROXY_PARAMS, - "proxy_url": CONF_PROXY_URL, - "telegram_bot": "Telegram bot", - }, - translation_key="proxy_params_auth_deprecation", - learn_more_url="https://github.com/home-assistant/core/pull/112778", - ) - else: - ir.create_issue( - hass, - DOMAIN, - "proxy_params_deprecation", - breaks_in_ha_version="2024.10.0", - is_persistent=False, - is_fixable=False, - severity=ir.IssueSeverity.WARNING, - translation_placeholders={ - "proxy_params": CONF_PROXY_PARAMS, - "proxy_url": CONF_PROXY_URL, - "httpx": "httpx", - "telegram_bot": "Telegram bot", - }, - translation_key="proxy_params_deprecation", - learn_more_url="https://github.com/home-assistant/core/pull/112778", - ) - proxy = httpx.Proxy(proxy_url, auth=auth, **proxy_params) + proxy = httpx.Proxy(proxy_url) request = HTTPXRequest(connection_pool_size=8, proxy=proxy) else: request = HTTPXRequest(connection_pool_size=8) diff --git a/homeassistant/components/telegram_bot/const.py b/homeassistant/components/telegram_bot/const.py index 4abdbaf97386c2..d6da96d9a2838d 100644 --- a/homeassistant/components/telegram_bot/const.py +++ b/homeassistant/components/telegram_bot/const.py @@ -13,8 +13,6 @@ CONF_BOT_COUNT = "bot_count" CONF_ALLOWED_CHAT_IDS = "allowed_chat_ids" CONF_CONFIG_ENTRY_ID = "config_entry_id" -CONF_PROXY_PARAMS = "proxy_params" - CONF_PROXY_URL = "proxy_url" CONF_TRUSTED_NETWORKS = "trusted_networks" diff --git a/homeassistant/components/telegram_bot/strings.json b/homeassistant/components/telegram_bot/strings.json index 9fcc0740970d0f..e932d010894a8e 100644 --- a/homeassistant/components/telegram_bot/strings.json +++ b/homeassistant/components/telegram_bot/strings.json @@ -924,10 +924,6 @@ "proxy_params_auth_deprecation": { "title": "{telegram_bot}: Proxy authentication should be moved to the URL", "description": "Authentication details for the the proxy configured in the {telegram_bot} integration should be moved into the {proxy_url} instead. Please update your configuration and restart Home Assistant to fix this issue.\n\nThe {proxy_params} config key will be removed in a future release." - }, - "proxy_params_deprecation": { - "title": "{telegram_bot}: Proxy params option will be removed", - "description": "The {proxy_params} config key for the {telegram_bot} integration will be removed in a future release.\n\nAuthentication can now be provided through the {proxy_url} key.\n\nThe underlying library has changed to {httpx} which is incompatible with previous parameters. If you still need this functionality for other options, please leave a comment on the learn more link.\n\nPlease update your configuration to remove the {proxy_params} key and restart Home Assistant to fix this issue." } } } diff --git a/homeassistant/components/wyoming/assist_satellite.py b/homeassistant/components/wyoming/assist_satellite.py index 75c227f8537b38..1a1a67bf1de169 100644 --- a/homeassistant/components/wyoming/assist_satellite.py +++ b/homeassistant/components/wyoming/assist_satellite.py @@ -739,7 +739,7 @@ async def _stream_tts(self, tts_result: tts.ResultStream) -> None: timestamp=timestamp, ) await self._client.write_event(chunk.event()) - timestamp += chunk.seconds + timestamp += chunk.milliseconds total_seconds += chunk.seconds await self._client.write_event(AudioStop(timestamp=timestamp).event()) diff --git a/homeassistant/components/wyoming/conversation.py b/homeassistant/components/wyoming/conversation.py index 5760d04bfc291f..988cf3c9045762 100644 --- a/homeassistant/components/wyoming/conversation.py +++ b/homeassistant/components/wyoming/conversation.py @@ -149,21 +149,21 @@ async def async_process( not_recognized = NotRecognized.from_event(event) intent_response.async_set_error( intent.IntentResponseErrorCode.NO_INTENT_MATCH, - not_recognized.text, + not_recognized.text or "", ) break if Handled.is_type(event.type): # Success handled = Handled.from_event(event) - intent_response.async_set_speech(handled.text) + intent_response.async_set_speech(handled.text or "") break if NotHandled.is_type(event.type): not_handled = NotHandled.from_event(event) intent_response.async_set_error( intent.IntentResponseErrorCode.FAILED_TO_HANDLE, - not_handled.text, + not_handled.text or "", ) break diff --git a/homeassistant/components/wyoming/manifest.json b/homeassistant/components/wyoming/manifest.json index d75b70dffa8678..31adb17d7f5bbe 100644 --- a/homeassistant/components/wyoming/manifest.json +++ b/homeassistant/components/wyoming/manifest.json @@ -13,6 +13,6 @@ "documentation": "https://www.home-assistant.io/integrations/wyoming", "integration_type": "service", "iot_class": "local_push", - "requirements": ["wyoming==1.5.4"], + "requirements": ["wyoming==1.7.1"], "zeroconf": ["_wyoming._tcp.local."] } diff --git a/homeassistant/components/wyoming/wake_word.py b/homeassistant/components/wyoming/wake_word.py index 2a21b7303e56e7..091b400a6c7ebc 100644 --- a/homeassistant/components/wyoming/wake_word.py +++ b/homeassistant/components/wyoming/wake_word.py @@ -147,8 +147,10 @@ async def next_chunk(): queued_audio = [audio_task.result()] return wake_word.DetectionResult( - wake_word_id=detection.name, - wake_word_phrase=self._get_phrase(detection.name), + wake_word_id=detection.name or "", + wake_word_phrase=self._get_phrase( + detection.name or "" + ), timestamp=detection.timestamp, queued_audio=queued_audio, ) diff --git a/homeassistant/components/zwave_js/strings.json b/homeassistant/components/zwave_js/strings.json index bceed10274b7d1..d9a3b82a47ca62 100644 --- a/homeassistant/components/zwave_js/strings.json +++ b/homeassistant/components/zwave_js/strings.json @@ -31,10 +31,10 @@ }, "flow_title": "{name}", "progress": { - "install_addon": "Installation can take several minutes.", - "start_addon": "Starting add-on.", - "backup_nvm": "Please wait while the network backup completes.", - "restore_nvm": "Please wait while the network restore completes." + "install_addon": "Installation can take several minutes", + "start_addon": "Starting add-on", + "backup_nvm": "Please wait while the network backup completes", + "restore_nvm": "Please wait while the network restore completes" }, "step": { "configure_addon_user": { @@ -47,7 +47,7 @@ "s2_unauthenticated_key": "S2 Unauthenticated Key", "usb_path": "[%key:common::config_flow::data::usb_path%]" }, - "description": "The add-on will generate security keys if those fields are left empty.", + "description": "Select your Z-Wave adapter", "title": "Enter the Z-Wave add-on configuration" }, "configure_addon_reconfigure": { @@ -129,7 +129,7 @@ }, "installation_type": { "title": "Set up Z-Wave", - "description": "In a few steps, we’re going to set up your Home Assistant Connect ZWA-2. Home Assistant can automatically install and configure the recommended Z-Wave setup, or you can customize it.", + "description": "In a few steps, we're going to set up your adapter. Home Assistant can automatically install and configure the recommended Z-Wave setup, or you can customize it.", "menu_options": { "intent_recommended": "Recommended installation", "intent_custom": "Custom installation" diff --git a/homeassistant/generated/dhcp.py b/homeassistant/generated/dhcp.py index 6213af63229ef4..b253c5a553d216 100644 --- a/homeassistant/generated/dhcp.py +++ b/homeassistant/generated/dhcp.py @@ -463,6 +463,82 @@ "domain": "palazzetti", "registered_devices": True, }, + { + "domain": "playstation_network", + "macaddress": "AC8995*", + }, + { + "domain": "playstation_network", + "macaddress": "1C98C1*", + }, + { + "domain": "playstation_network", + "macaddress": "5C843C*", + }, + { + "domain": "playstation_network", + "macaddress": "605BB4*", + }, + { + "domain": "playstation_network", + "macaddress": "8060B7*", + }, + { + "domain": "playstation_network", + "macaddress": "78C881*", + }, + { + "domain": "playstation_network", + "macaddress": "00D9D1*", + }, + { + "domain": "playstation_network", + "macaddress": "00E421*", + }, + { + "domain": "playstation_network", + "macaddress": "0CFE45*", + }, + { + "domain": "playstation_network", + "macaddress": "2CCC44*", + }, + { + "domain": "playstation_network", + "macaddress": "BC60A7*", + }, + { + "domain": "playstation_network", + "macaddress": "C863F1*", + }, + { + "domain": "playstation_network", + "macaddress": "F8461C*", + }, + { + "domain": "playstation_network", + "macaddress": "70662A*", + }, + { + "domain": "playstation_network", + "macaddress": "09E29*", + }, + { + "domain": "playstation_network", + "macaddress": "B40AD8*", + }, + { + "domain": "playstation_network", + "macaddress": "A8474A*", + }, + { + "domain": "playstation_network", + "macaddress": "280DFC*", + }, + { + "domain": "playstation_network", + "macaddress": "D44B5E*", + }, { "domain": "powerwall", "hostname": "1118431-*", diff --git a/homeassistant/helpers/selector.py b/homeassistant/helpers/selector.py index 322cfe340426b2..6f8df828c37d7b 100644 --- a/homeassistant/helpers/selector.py +++ b/homeassistant/helpers/selector.py @@ -1020,11 +1020,15 @@ class MediaSelector(Selector[MediaSelectorConfig]): selector_type = "media" - CONFIG_SCHEMA = BASE_SELECTOR_CONFIG_SCHEMA + CONFIG_SCHEMA = BASE_SELECTOR_CONFIG_SCHEMA.extend( + { + vol.Optional("accept"): [str], + } + ) DATA_SCHEMA = vol.Schema( { - # Although marked as optional in frontend, this field is required - vol.Required("entity_id"): cv.entity_id_or_uuid, + # If accept is set, the entity_id field will not be present + vol.Optional("entity_id"): cv.entity_id_or_uuid, # Although marked as optional in frontend, this field is required vol.Required("media_content_id"): str, # Although marked as optional in frontend, this field is required @@ -1113,9 +1117,23 @@ def __call__(self, data: Any) -> float: return value +class ObjectSelectorField(TypedDict): + """Class to represent an object selector fields dict.""" + + label: str + required: bool + selector: dict[str, Any] + + class ObjectSelectorConfig(BaseSelectorConfig): """Class to represent an object selector config.""" + fields: dict[str, ObjectSelectorField] + multiple: bool + label_field: str + description_field: bool + translation_key: str + @SELECTORS.register("object") class ObjectSelector(Selector[ObjectSelectorConfig]): @@ -1123,7 +1141,21 @@ class ObjectSelector(Selector[ObjectSelectorConfig]): selector_type = "object" - CONFIG_SCHEMA = BASE_SELECTOR_CONFIG_SCHEMA + CONFIG_SCHEMA = BASE_SELECTOR_CONFIG_SCHEMA.extend( + { + vol.Optional("fields"): { + str: { + vol.Required("selector"): dict, + vol.Optional("required"): bool, + vol.Optional("label"): str, + } + }, + vol.Optional("multiple", default=False): bool, + vol.Optional("label_field"): str, + vol.Optional("description_field"): str, + vol.Optional("translation_key"): str, + } + ) def __init__(self, config: ObjectSelectorConfig | None = None) -> None: """Instantiate a selector.""" diff --git a/homeassistant/helpers/template.py b/homeassistant/helpers/template.py index 34b19c07f83d5d..85ee1e283093b5 100644 --- a/homeassistant/helpers/template.py +++ b/homeassistant/helpers/template.py @@ -2634,9 +2634,14 @@ def ordinal(value): ) -def from_json(value): +def from_json(value, default=_SENTINEL): """Convert a JSON string to an object.""" - return json_loads(value) + try: + return json_loads(value) + except JSON_DECODE_EXCEPTIONS: + if default is _SENTINEL: + raise_no_default("from_json", value) + return default def _to_json_default(obj: Any) -> None: diff --git a/requirements_all.txt b/requirements_all.txt index 710785eed57afa..80f543f790f1d4 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -3123,7 +3123,7 @@ wolf-comm==0.0.23 wsdot==0.0.1 # homeassistant.components.wyoming -wyoming==1.5.4 +wyoming==1.7.1 # homeassistant.components.xbox xbox-webapi==2.1.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 815ab30d4c3beb..e1a546dfe2fe85 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2573,7 +2573,7 @@ wolf-comm==0.0.23 wsdot==0.0.1 # homeassistant.components.wyoming -wyoming==1.5.4 +wyoming==1.7.1 # homeassistant.components.xbox xbox-webapi==2.1.0 diff --git a/tests/components/ai_task/conftest.py b/tests/components/ai_task/conftest.py index 2060c51bfa4d42..7efbd1ffcdb628 100644 --- a/tests/components/ai_task/conftest.py +++ b/tests/components/ai_task/conftest.py @@ -6,8 +6,8 @@ DOMAIN, AITaskEntity, AITaskEntityFeature, - GenTextTask, - GenTextTaskResult, + GenDataTask, + GenDataTaskResult, ) from homeassistant.components.conversation import AssistantContent, ChatLog from homeassistant.config_entries import ConfigEntry, ConfigFlow @@ -33,24 +33,24 @@ class MockAITaskEntity(AITaskEntity): """Mock AI Task entity for testing.""" _attr_name = "Test Task Entity" - _attr_supported_features = AITaskEntityFeature.GENERATE_TEXT + _attr_supported_features = AITaskEntityFeature.GENERATE_DATA def __init__(self) -> None: """Initialize the mock entity.""" super().__init__() - self.mock_generate_text_tasks = [] + self.mock_generate_data_tasks = [] - async def _async_generate_text( - self, task: GenTextTask, chat_log: ChatLog - ) -> GenTextTaskResult: - """Mock handling of generate text task.""" - self.mock_generate_text_tasks.append(task) + async def _async_generate_data( + self, task: GenDataTask, chat_log: ChatLog + ) -> GenDataTaskResult: + """Mock handling of generate data task.""" + self.mock_generate_data_tasks.append(task) chat_log.async_add_assistant_content_without_tools( AssistantContent(self.entity_id, "Mock result") ) - return GenTextTaskResult( + return GenDataTaskResult( conversation_id=chat_log.conversation_id, - text="Mock result", + data="Mock result", ) diff --git a/tests/components/ai_task/snapshots/test_task.ambr b/tests/components/ai_task/snapshots/test_task.ambr index 6d155c82a68979..3b40b0632a62a7 100644 --- a/tests/components/ai_task/snapshots/test_task.ambr +++ b/tests/components/ai_task/snapshots/test_task.ambr @@ -1,5 +1,5 @@ # serializer version: 1 -# name: test_run_text_task_updates_chat_log +# name: test_run_data_task_updates_chat_log list([ dict({ 'content': ''' diff --git a/tests/components/ai_task/test_entity.py b/tests/components/ai_task/test_entity.py index aa9afbf65601f9..3ed1c3935883f5 100644 --- a/tests/components/ai_task/test_entity.py +++ b/tests/components/ai_task/test_entity.py @@ -2,7 +2,7 @@ from freezegun import freeze_time -from homeassistant.components.ai_task import async_generate_text +from homeassistant.components.ai_task import async_generate_data from homeassistant.const import STATE_UNKNOWN from homeassistant.core import HomeAssistant @@ -12,28 +12,28 @@ @freeze_time("2025-06-08 16:28:13") -async def test_state_generate_text( +async def test_state_generate_data( hass: HomeAssistant, init_components: None, mock_config_entry: MockConfigEntry, mock_ai_task_entity: MockAITaskEntity, ) -> None: - """Test the state of the AI Task entity is updated when generating text.""" + """Test the state of the AI Task entity is updated when generating data.""" entity = hass.states.get(TEST_ENTITY_ID) assert entity is not None assert entity.state == STATE_UNKNOWN - result = await async_generate_text( + result = await async_generate_data( hass, task_name="Test task", entity_id=TEST_ENTITY_ID, instructions="Test prompt", ) - assert result.text == "Mock result" + assert result.data == "Mock result" entity = hass.states.get(TEST_ENTITY_ID) assert entity.state == "2025-06-08T16:28:13+00:00" - assert mock_ai_task_entity.mock_generate_text_tasks - task = mock_ai_task_entity.mock_generate_text_tasks[0] + assert mock_ai_task_entity.mock_generate_data_tasks + task = mock_ai_task_entity.mock_generate_data_tasks[0] assert task.instructions == "Test prompt" diff --git a/tests/components/ai_task/test_http.py b/tests/components/ai_task/test_http.py index 4436e1d45d591d..a2eecfddf74337 100644 --- a/tests/components/ai_task/test_http.py +++ b/tests/components/ai_task/test_http.py @@ -18,20 +18,20 @@ async def test_ws_preferences( msg = await client.receive_json() assert msg["success"] assert msg["result"] == { - "gen_text_entity_id": None, + "gen_data_entity_id": None, } # Set preferences await client.send_json_auto_id( { "type": "ai_task/preferences/set", - "gen_text_entity_id": "ai_task.summary_1", + "gen_data_entity_id": "ai_task.summary_1", } ) msg = await client.receive_json() assert msg["success"] assert msg["result"] == { - "gen_text_entity_id": "ai_task.summary_1", + "gen_data_entity_id": "ai_task.summary_1", } # Get updated preferences @@ -39,20 +39,20 @@ async def test_ws_preferences( msg = await client.receive_json() assert msg["success"] assert msg["result"] == { - "gen_text_entity_id": "ai_task.summary_1", + "gen_data_entity_id": "ai_task.summary_1", } # Update an existing preference await client.send_json_auto_id( { "type": "ai_task/preferences/set", - "gen_text_entity_id": "ai_task.summary_2", + "gen_data_entity_id": "ai_task.summary_2", } ) msg = await client.receive_json() assert msg["success"] assert msg["result"] == { - "gen_text_entity_id": "ai_task.summary_2", + "gen_data_entity_id": "ai_task.summary_2", } # Get updated preferences @@ -60,7 +60,7 @@ async def test_ws_preferences( msg = await client.receive_json() assert msg["success"] assert msg["result"] == { - "gen_text_entity_id": "ai_task.summary_2", + "gen_data_entity_id": "ai_task.summary_2", } # No preferences set will preserve existing preferences @@ -72,7 +72,7 @@ async def test_ws_preferences( msg = await client.receive_json() assert msg["success"] assert msg["result"] == { - "gen_text_entity_id": "ai_task.summary_2", + "gen_data_entity_id": "ai_task.summary_2", } # Get updated preferences @@ -80,5 +80,5 @@ async def test_ws_preferences( msg = await client.receive_json() assert msg["success"] assert msg["result"] == { - "gen_text_entity_id": "ai_task.summary_2", + "gen_data_entity_id": "ai_task.summary_2", } diff --git a/tests/components/ai_task/test_init.py b/tests/components/ai_task/test_init.py index 2f45d812b1fbaf..fdfaaccd0a4f47 100644 --- a/tests/components/ai_task/test_init.py +++ b/tests/components/ai_task/test_init.py @@ -49,7 +49,7 @@ async def test_preferences_storage_load( ("set_preferences", "msg_extra"), [ ( - {"gen_text_entity_id": TEST_ENTITY_ID}, + {"gen_data_entity_id": TEST_ENTITY_ID}, {}, ), ( @@ -58,20 +58,20 @@ async def test_preferences_storage_load( ), ], ) -async def test_generate_text_service( +async def test_generate_data_service( hass: HomeAssistant, init_components: None, freezer: FrozenDateTimeFactory, set_preferences: dict[str, str | None], msg_extra: dict[str, str], ) -> None: - """Test the generate text service.""" + """Test the generate data service.""" preferences = hass.data[DATA_PREFERENCES] preferences.async_set_preferences(**set_preferences) result = await hass.services.async_call( "ai_task", - "generate_text", + "generate_data", { "task_name": "Test Name", "instructions": "Test prompt", @@ -81,4 +81,4 @@ async def test_generate_text_service( return_response=True, ) - assert result["text"] == "Mock result" + assert result["data"] == "Mock result" diff --git a/tests/components/ai_task/test_task.py b/tests/components/ai_task/test_task.py index d6e266aa02e40b..bed760c8a1d5af 100644 --- a/tests/components/ai_task/test_task.py +++ b/tests/components/ai_task/test_task.py @@ -4,7 +4,7 @@ import pytest from syrupy.assertion import SnapshotAssertion -from homeassistant.components.ai_task import AITaskEntityFeature, async_generate_text +from homeassistant.components.ai_task import AITaskEntityFeature, async_generate_data from homeassistant.components.conversation import async_get_chat_log from homeassistant.const import STATE_UNKNOWN from homeassistant.core import HomeAssistant @@ -28,7 +28,7 @@ async def test_run_task_preferred_entity( with pytest.raises( HomeAssistantError, match="No entity_id provided and no preferred entity set" ): - await async_generate_text( + await async_generate_data( hass, task_name="Test Task", instructions="Test prompt", @@ -37,7 +37,7 @@ async def test_run_task_preferred_entity( await client.send_json_auto_id( { "type": "ai_task/preferences/set", - "gen_text_entity_id": "ai_task.unknown", + "gen_data_entity_id": "ai_task.unknown", } ) msg = await client.receive_json() @@ -46,7 +46,7 @@ async def test_run_task_preferred_entity( with pytest.raises( HomeAssistantError, match="AI Task entity ai_task.unknown not found" ): - await async_generate_text( + await async_generate_data( hass, task_name="Test Task", instructions="Test prompt", @@ -55,7 +55,7 @@ async def test_run_task_preferred_entity( await client.send_json_auto_id( { "type": "ai_task/preferences/set", - "gen_text_entity_id": TEST_ENTITY_ID, + "gen_data_entity_id": TEST_ENTITY_ID, } ) msg = await client.receive_json() @@ -65,12 +65,15 @@ async def test_run_task_preferred_entity( assert state is not None assert state.state == STATE_UNKNOWN - result = await async_generate_text( + result = await async_generate_data( hass, task_name="Test Task", instructions="Test prompt", ) - assert result.text == "Mock result" + assert result.data == "Mock result" + as_dict = result.as_dict() + assert as_dict["conversation_id"] == result.conversation_id + assert as_dict["data"] == "Mock result" state = hass.states.get(TEST_ENTITY_ID) assert state is not None assert state.state != STATE_UNKNOWN @@ -78,25 +81,25 @@ async def test_run_task_preferred_entity( mock_ai_task_entity.supported_features = AITaskEntityFeature(0) with pytest.raises( HomeAssistantError, - match="AI Task entity ai_task.test_task_entity does not support generating text", + match="AI Task entity ai_task.test_task_entity does not support generating data", ): - await async_generate_text( + await async_generate_data( hass, task_name="Test Task", instructions="Test prompt", ) -async def test_run_text_task_unknown_entity( +async def test_run_data_task_unknown_entity( hass: HomeAssistant, init_components: None, ) -> None: - """Test running a text task with an unknown entity.""" + """Test running a data task with an unknown entity.""" with pytest.raises( HomeAssistantError, match="AI Task entity ai_task.unknown_entity not found" ): - await async_generate_text( + await async_generate_data( hass, task_name="Test Task", entity_id="ai_task.unknown_entity", @@ -105,19 +108,19 @@ async def test_run_text_task_unknown_entity( @freeze_time("2025-06-14 22:59:00") -async def test_run_text_task_updates_chat_log( +async def test_run_data_task_updates_chat_log( hass: HomeAssistant, init_components: None, snapshot: SnapshotAssertion, ) -> None: - """Test that running a text task updates the chat log.""" - result = await async_generate_text( + """Test that running a data task updates the chat log.""" + result = await async_generate_data( hass, task_name="Test Task", entity_id=TEST_ENTITY_ID, instructions="Test prompt", ) - assert result.text == "Mock result" + assert result.data == "Mock result" with ( chat_session.async_get_chat_session(hass, result.conversation_id) as session, diff --git a/tests/components/android_ip_webcam/test_camera.py b/tests/components/android_ip_webcam/test_camera.py new file mode 100644 index 00000000000000..0ecdb93bcbd89e --- /dev/null +++ b/tests/components/android_ip_webcam/test_camera.py @@ -0,0 +1,54 @@ +"""Test the Android IP Webcam camera.""" + +from typing import Any + +import pytest + +from homeassistant.components.android_ip_webcam.const import DOMAIN +from homeassistant.components.camera import async_get_stream_source +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry + + +@pytest.mark.usefixtures("aioclient_mock_fixture") +@pytest.mark.parametrize( + ("config", "expected_stream_source"), + [ + ( + { + "host": "1.1.1.1", + "port": 8080, + "username": "user", + "password": "pass", + }, + "rtsp://user:pass@1.1.1.1:8080/h264_aac.sdp", + ), + ( + { + "host": "1.1.1.1", + "port": 8080, + }, + "rtsp://1.1.1.1:8080/h264_aac.sdp", + ), + ], +) +async def test_camera_stream_source( + hass: HomeAssistant, + config: dict[str, Any], + expected_stream_source: str, +) -> None: + """Test camera stream source.""" + entity_id = "camera.1_1_1_1" + entry = MockConfigEntry(domain=DOMAIN, data=config) + entry.add_to_hass(hass) + + assert await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + state = hass.states.get(entity_id) + assert state is not None + + stream_source = await async_get_stream_source(hass, entity_id) + + assert stream_source == expected_stream_source diff --git a/tests/components/anthropic/conftest.py b/tests/components/anthropic/conftest.py index 7419ea6c28f3d4..53e00447a2ea7a 100644 --- a/tests/components/anthropic/conftest.py +++ b/tests/components/anthropic/conftest.py @@ -6,6 +6,7 @@ import pytest from homeassistant.components.anthropic import CONF_CHAT_MODEL +from homeassistant.components.anthropic.const import DEFAULT_CONVERSATION_NAME from homeassistant.const import CONF_LLM_HASS_API from homeassistant.core import HomeAssistant from homeassistant.helpers import llm @@ -23,6 +24,15 @@ def mock_config_entry(hass: HomeAssistant) -> MockConfigEntry: data={ "api_key": "bla", }, + version=2, + subentries_data=[ + { + "data": {}, + "subentry_type": "conversation", + "title": DEFAULT_CONVERSATION_NAME, + "unique_id": None, + } + ], ) entry.add_to_hass(hass) return entry @@ -33,8 +43,10 @@ def mock_config_entry_with_assist( hass: HomeAssistant, mock_config_entry: MockConfigEntry ) -> MockConfigEntry: """Mock a config entry with assist.""" - hass.config_entries.async_update_entry( - mock_config_entry, options={CONF_LLM_HASS_API: llm.LLM_API_ASSIST} + hass.config_entries.async_update_subentry( + mock_config_entry, + next(iter(mock_config_entry.subentries.values())), + data={CONF_LLM_HASS_API: llm.LLM_API_ASSIST}, ) return mock_config_entry @@ -44,9 +56,10 @@ def mock_config_entry_with_extended_thinking( hass: HomeAssistant, mock_config_entry: MockConfigEntry ) -> MockConfigEntry: """Mock a config entry with assist.""" - hass.config_entries.async_update_entry( + hass.config_entries.async_update_subentry( mock_config_entry, - options={ + next(iter(mock_config_entry.subentries.values())), + data={ CONF_LLM_HASS_API: llm.LLM_API_ASSIST, CONF_CHAT_MODEL: "claude-3-7-sonnet-latest", }, diff --git a/tests/components/anthropic/snapshots/test_conversation.ambr b/tests/components/anthropic/snapshots/test_conversation.ambr index ea4ce5a980de98..09618b135db6f7 100644 --- a/tests/components/anthropic/snapshots/test_conversation.ambr +++ b/tests/components/anthropic/snapshots/test_conversation.ambr @@ -16,7 +16,7 @@ 'role': 'user', }), dict({ - 'agent_id': 'conversation.claude', + 'agent_id': 'conversation.claude_conversation', 'content': 'Certainly, calling it now!', 'role': 'assistant', 'tool_calls': list([ @@ -30,14 +30,14 @@ ]), }), dict({ - 'agent_id': 'conversation.claude', + 'agent_id': 'conversation.claude_conversation', 'role': 'tool_result', 'tool_call_id': 'toolu_0123456789AbCdEfGhIjKlM', 'tool_name': 'test_tool', 'tool_result': 'Test response', }), dict({ - 'agent_id': 'conversation.claude', + 'agent_id': 'conversation.claude_conversation', 'content': 'I have successfully called the function', 'role': 'assistant', 'tool_calls': None, diff --git a/tests/components/anthropic/test_config_flow.py b/tests/components/anthropic/test_config_flow.py index 1f41b7df2c7fac..2eac125f5c3356 100644 --- a/tests/components/anthropic/test_config_flow.py +++ b/tests/components/anthropic/test_config_flow.py @@ -1,6 +1,6 @@ """Test the Anthropic config flow.""" -from unittest.mock import AsyncMock, patch +from unittest.mock import AsyncMock, Mock, patch from anthropic import ( APIConnectionError, @@ -22,12 +22,13 @@ CONF_RECOMMENDED, CONF_TEMPERATURE, CONF_THINKING_BUDGET, + DEFAULT_CONVERSATION_NAME, DOMAIN, RECOMMENDED_CHAT_MODEL, RECOMMENDED_MAX_TOKENS, RECOMMENDED_THINKING_BUDGET, ) -from homeassistant.const import CONF_LLM_HASS_API +from homeassistant.const import CONF_API_KEY, CONF_LLM_HASS_API, CONF_NAME from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType @@ -71,39 +72,103 @@ async def test_form(hass: HomeAssistant) -> None: assert result2["data"] == { "api_key": "bla", } - assert result2["options"] == RECOMMENDED_OPTIONS + assert result2["options"] == {} + assert result2["subentries"] == [ + { + "subentry_type": "conversation", + "data": RECOMMENDED_OPTIONS, + "title": DEFAULT_CONVERSATION_NAME, + "unique_id": None, + } + ] assert len(mock_setup_entry.mock_calls) == 1 -async def test_options( +async def test_duplicate_entry(hass: HomeAssistant) -> None: + """Test we abort on duplicate config entry.""" + MockConfigEntry( + domain=DOMAIN, + data={CONF_API_KEY: "bla"}, + ).add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] is FlowResultType.FORM + assert not result["errors"] + + with patch( + "anthropic.resources.models.AsyncModels.retrieve", + return_value=Mock(display_name="Claude 3.5 Sonnet"), + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_API_KEY: "bla", + }, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" + + +async def test_creating_conversation_subentry( hass: HomeAssistant, mock_config_entry, mock_init_component ) -> None: - """Test the options form.""" - options_flow = await hass.config_entries.options.async_init( - mock_config_entry.entry_id + """Test creating a conversation subentry.""" + result = await hass.config_entries.subentries.async_init( + (mock_config_entry.entry_id, "conversation"), + context={"source": config_entries.SOURCE_USER}, ) - options = await hass.config_entries.options.async_configure( - options_flow["flow_id"], - { - "prompt": "Speak like a pirate", - "max_tokens": 200, - }, + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "set_options" + assert not result["errors"] + + result2 = await hass.config_entries.subentries.async_configure( + result["flow_id"], + {CONF_NAME: "Mock name", **RECOMMENDED_OPTIONS}, ) await hass.async_block_till_done() - assert options["type"] is FlowResultType.CREATE_ENTRY - assert options["data"]["prompt"] == "Speak like a pirate" - assert options["data"]["max_tokens"] == 200 - assert options["data"][CONF_CHAT_MODEL] == RECOMMENDED_CHAT_MODEL + assert result2["type"] is FlowResultType.CREATE_ENTRY + assert result2["title"] == "Mock name" -async def test_options_thinking_budget_more_than_max( + processed_options = RECOMMENDED_OPTIONS.copy() + processed_options[CONF_PROMPT] = processed_options[CONF_PROMPT].strip() + + assert result2["data"] == processed_options + + +async def test_creating_conversation_subentry_not_loaded( + hass: HomeAssistant, + mock_init_component, + mock_config_entry: MockConfigEntry, +) -> None: + """Test creating a conversation subentry when entry is not loaded.""" + await hass.config_entries.async_unload(mock_config_entry.entry_id) + with patch( + "anthropic.resources.models.AsyncModels.list", + return_value=[], + ): + result = await hass.config_entries.subentries.async_init( + (mock_config_entry.entry_id, "conversation"), + context={"source": config_entries.SOURCE_USER}, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "entry_not_loaded" + + +async def test_subentry_options_thinking_budget_more_than_max( hass: HomeAssistant, mock_config_entry, mock_init_component ) -> None: """Test error about thinking budget being more than max tokens.""" - options_flow = await hass.config_entries.options.async_init( - mock_config_entry.entry_id + subentry = next(iter(mock_config_entry.subentries.values())) + options_flow = await mock_config_entry.start_subentry_reconfigure_flow( + hass, subentry.subentry_id ) - options = await hass.config_entries.options.async_configure( + options = await hass.config_entries.subentries.async_configure( options_flow["flow_id"], { "prompt": "Speak like a pirate", @@ -111,6 +176,7 @@ async def test_options_thinking_budget_more_than_max( "chat_model": "claude-3-7-sonnet-latest", "temperature": 1, "thinking_budget": 16384, + "recommended": False, }, ) await hass.async_block_till_done() @@ -252,7 +318,7 @@ async def test_form_invalid_auth(hass: HomeAssistant, side_effect, error) -> Non ), ], ) -async def test_options_switching( +async def test_subentry_options_switching( hass: HomeAssistant, mock_config_entry, mock_init_component, @@ -260,23 +326,29 @@ async def test_options_switching( new_options, expected_options, ) -> None: - """Test the options form.""" - hass.config_entries.async_update_entry(mock_config_entry, options=current_options) - options_flow = await hass.config_entries.options.async_init( - mock_config_entry.entry_id + """Test the subentry options form.""" + subentry = next(iter(mock_config_entry.subentries.values())) + hass.config_entries.async_update_subentry( + mock_config_entry, subentry, data=current_options + ) + await hass.async_block_till_done() + + options_flow = await mock_config_entry.start_subentry_reconfigure_flow( + hass, subentry.subentry_id ) if current_options.get(CONF_RECOMMENDED) != new_options.get(CONF_RECOMMENDED): - options_flow = await hass.config_entries.options.async_configure( + options_flow = await hass.config_entries.subentries.async_configure( options_flow["flow_id"], { **current_options, CONF_RECOMMENDED: new_options[CONF_RECOMMENDED], }, ) - options = await hass.config_entries.options.async_configure( + options = await hass.config_entries.subentries.async_configure( options_flow["flow_id"], new_options, ) await hass.async_block_till_done() - assert options["type"] is FlowResultType.CREATE_ENTRY - assert options["data"] == expected_options + assert options["type"] is FlowResultType.ABORT + assert options["reason"] == "reconfigure_successful" + assert subentry.data == expected_options diff --git a/tests/components/anthropic/test_conversation.py b/tests/components/anthropic/test_conversation.py index 6aadcf3eeb4d33..3ae44e552cca0a 100644 --- a/tests/components/anthropic/test_conversation.py +++ b/tests/components/anthropic/test_conversation.py @@ -180,21 +180,23 @@ async def test_entity( mock_init_component, ) -> None: """Test entity properties.""" - state = hass.states.get("conversation.claude") + state = hass.states.get("conversation.claude_conversation") assert state assert state.attributes["supported_features"] == 0 - hass.config_entries.async_update_entry( + subentry = next(iter(mock_config_entry.subentries.values())) + hass.config_entries.async_update_subentry( mock_config_entry, - options={ - **mock_config_entry.options, + subentry, + data={ + **subentry.data, CONF_LLM_HASS_API: "assist", }, ) with patch("anthropic.resources.models.AsyncModels.retrieve"): await hass.config_entries.async_reload(mock_config_entry.entry_id) - state = hass.states.get("conversation.claude") + state = hass.states.get("conversation.claude_conversation") assert state assert ( state.attributes["supported_features"] @@ -218,7 +220,7 @@ async def test_error_handling( ), ): result = await conversation.async_converse( - hass, "hello", None, Context(), agent_id="conversation.claude" + hass, "hello", None, Context(), agent_id="conversation.claude_conversation" ) assert result.response.response_type == intent.IntentResponseType.ERROR @@ -229,9 +231,11 @@ async def test_template_error( hass: HomeAssistant, mock_config_entry: MockConfigEntry ) -> None: """Test that template error handling works.""" - hass.config_entries.async_update_entry( + subentry = next(iter(mock_config_entry.subentries.values())) + hass.config_entries.async_update_subentry( mock_config_entry, - options={ + subentry, + data={ "prompt": "talk like a {% if True %}smarthome{% else %}pirate please.", }, ) @@ -244,7 +248,7 @@ async def test_template_error( await hass.config_entries.async_setup(mock_config_entry.entry_id) await hass.async_block_till_done() result = await conversation.async_converse( - hass, "hello", None, Context(), agent_id="conversation.claude" + hass, "hello", None, Context(), agent_id="conversation.claude_conversation" ) assert result.response.response_type == intent.IntentResponseType.ERROR @@ -260,9 +264,11 @@ async def test_template_variables( mock_user.id = "12345" mock_user.name = "Test User" - hass.config_entries.async_update_entry( + subentry = next(iter(mock_config_entry.subentries.values())) + hass.config_entries.async_update_subentry( mock_config_entry, - options={ + subentry, + data={ "prompt": ( "The user name is {{ user_name }}. " "The user id is {{ llm_context.context.user_id }}." @@ -286,7 +292,7 @@ async def test_template_variables( await hass.config_entries.async_setup(mock_config_entry.entry_id) await hass.async_block_till_done() result = await conversation.async_converse( - hass, "hello", None, context, agent_id="conversation.claude" + hass, "hello", None, context, agent_id="conversation.claude_conversation" ) assert result.response.response_type == intent.IntentResponseType.ACTION_DONE @@ -304,7 +310,9 @@ async def test_conversation_agent( mock_init_component, ) -> None: """Test Anthropic Agent.""" - agent = conversation.agent_manager.async_get_agent(hass, "conversation.claude") + agent = conversation.agent_manager.async_get_agent( + hass, "conversation.claude_conversation" + ) assert agent.supported_languages == "*" @@ -332,7 +340,7 @@ async def test_function_call( expected_call_tool_args: dict[str, Any], ) -> None: """Test function call from the assistant.""" - agent_id = "conversation.claude" + agent_id = "conversation.claude_conversation" context = Context() mock_tool = AsyncMock() @@ -430,7 +438,7 @@ async def test_function_exception( mock_init_component, ) -> None: """Test function call with exception.""" - agent_id = "conversation.claude" + agent_id = "conversation.claude_conversation" context = Context() mock_tool = AsyncMock() @@ -536,7 +544,7 @@ async def test_assist_api_tools_conversion( ): assert await async_setup_component(hass, component, {}) - agent_id = "conversation.claude" + agent_id = "conversation.claude_conversation" with patch( "anthropic.resources.messages.AsyncMessages.create", new_callable=AsyncMock, @@ -561,17 +569,19 @@ async def test_unknown_hass_api( mock_init_component, ) -> None: """Test when we reference an API that no longer exists.""" - hass.config_entries.async_update_entry( + subentry = next(iter(mock_config_entry.subentries.values())) + hass.config_entries.async_update_subentry( mock_config_entry, - options={ - **mock_config_entry.options, + subentry, + data={ + **subentry.data, CONF_LLM_HASS_API: "non-existing", }, ) await hass.async_block_till_done() result = await conversation.async_converse( - hass, "hello", "1234", Context(), agent_id="conversation.claude" + hass, "hello", "1234", Context(), agent_id="conversation.claude_conversation" ) assert result == snapshot @@ -597,17 +607,25 @@ def create_stream_generator(*args, **kwargs) -> Any: side_effect=create_stream_generator, ): result = await conversation.async_converse( - hass, "hello", "1234", Context(), agent_id="conversation.claude" + hass, + "hello", + "1234", + Context(), + agent_id="conversation.claude_conversation", ) result = await conversation.async_converse( - hass, "hello", None, None, agent_id="conversation.claude" + hass, "hello", None, None, agent_id="conversation.claude_conversation" ) conversation_id = result.conversation_id result = await conversation.async_converse( - hass, "hello", conversation_id, None, agent_id="conversation.claude" + hass, + "hello", + conversation_id, + None, + agent_id="conversation.claude_conversation", ) assert result.conversation_id == conversation_id @@ -615,13 +633,13 @@ def create_stream_generator(*args, **kwargs) -> Any: unknown_id = ulid_util.ulid() result = await conversation.async_converse( - hass, "hello", unknown_id, None, agent_id="conversation.claude" + hass, "hello", unknown_id, None, agent_id="conversation.claude_conversation" ) assert result.conversation_id != unknown_id result = await conversation.async_converse( - hass, "hello", "koala", None, agent_id="conversation.claude" + hass, "hello", "koala", None, agent_id="conversation.claude_conversation" ) assert result.conversation_id == "koala" @@ -654,7 +672,7 @@ async def test_refusal( "2631EDCF22E8CCC1FB35B501C9C86", None, Context(), - agent_id="conversation.claude", + agent_id="conversation.claude_conversation", ) assert result.response.response_type == intent.IntentResponseType.ERROR @@ -695,7 +713,7 @@ async def test_extended_thinking( ), ): result = await conversation.async_converse( - hass, "hello", None, Context(), agent_id="conversation.claude" + hass, "hello", None, Context(), agent_id="conversation.claude_conversation" ) chat_log = hass.data.get(conversation.chat_log.DATA_CHAT_LOGS).get( @@ -732,7 +750,7 @@ async def test_redacted_thinking( "8432ECCCE4C1253D5E2D82641AC0E52CC2876CB", None, Context(), - agent_id="conversation.claude", + agent_id="conversation.claude_conversation", ) chat_log = hass.data.get(conversation.chat_log.DATA_CHAT_LOGS).get( @@ -751,7 +769,7 @@ async def test_extended_thinking_tool_call( snapshot: SnapshotAssertion, ) -> None: """Test that thinking blocks and their order are preserved in with tool calls.""" - agent_id = "conversation.claude" + agent_id = "conversation.claude_conversation" context = Context() mock_tool = AsyncMock() @@ -841,7 +859,8 @@ def completion_result(*args, messages, **kwargs): conversation.chat_log.SystemContent("You are a helpful assistant."), conversation.chat_log.UserContent("What shape is a donut?"), conversation.chat_log.AssistantContent( - agent_id="conversation.claude", content="A donut is a torus." + agent_id="conversation.claude_conversation", + content="A donut is a torus.", ), ], [ @@ -849,10 +868,11 @@ def completion_result(*args, messages, **kwargs): conversation.chat_log.UserContent("What shape is a donut?"), conversation.chat_log.UserContent("Can you tell me?"), conversation.chat_log.AssistantContent( - agent_id="conversation.claude", content="A donut is a torus." + agent_id="conversation.claude_conversation", + content="A donut is a torus.", ), conversation.chat_log.AssistantContent( - agent_id="conversation.claude", content="Hope this helps." + agent_id="conversation.claude_conversation", content="Hope this helps." ), ], [ @@ -861,20 +881,21 @@ def completion_result(*args, messages, **kwargs): conversation.chat_log.UserContent("Can you tell me?"), conversation.chat_log.UserContent("Please?"), conversation.chat_log.AssistantContent( - agent_id="conversation.claude", content="A donut is a torus." + agent_id="conversation.claude_conversation", + content="A donut is a torus.", ), conversation.chat_log.AssistantContent( - agent_id="conversation.claude", content="Hope this helps." + agent_id="conversation.claude_conversation", content="Hope this helps." ), conversation.chat_log.AssistantContent( - agent_id="conversation.claude", content="You are welcome." + agent_id="conversation.claude_conversation", content="You are welcome." ), ], [ conversation.chat_log.SystemContent("You are a helpful assistant."), conversation.chat_log.UserContent("Turn off the lights and make me coffee"), conversation.chat_log.AssistantContent( - agent_id="conversation.claude", + agent_id="conversation.claude_conversation", content="Sure.", tool_calls=[ llm.ToolInput( @@ -891,19 +912,19 @@ def completion_result(*args, messages, **kwargs): ), conversation.chat_log.UserContent("Thank you"), conversation.chat_log.ToolResultContent( - agent_id="conversation.claude", + agent_id="conversation.claude_conversation", tool_call_id="mock-tool-call-id", tool_name="HassTurnOff", tool_result={"success": True, "response": "Lights are off."}, ), conversation.chat_log.ToolResultContent( - agent_id="conversation.claude", + agent_id="conversation.claude_conversation", tool_call_id="mock-tool-call-id-2", tool_name="MakeCoffee", tool_result={"success": False, "response": "Not enough milk."}, ), conversation.chat_log.AssistantContent( - agent_id="conversation.claude", + agent_id="conversation.claude_conversation", content="Should I add milk to the shopping list?", ), ], @@ -940,7 +961,7 @@ async def test_history_conversion( "Are you sure?", conversation_id, Context(), - agent_id="conversation.claude", + agent_id="conversation.claude_conversation", ) assert mock_create.mock_calls[0][2]["messages"] == snapshot diff --git a/tests/components/anthropic/test_init.py b/tests/components/anthropic/test_init.py index 305e442f52d931..6295bac67cba44 100644 --- a/tests/components/anthropic/test_init.py +++ b/tests/components/anthropic/test_init.py @@ -11,7 +11,9 @@ from httpx import URL, Request, Response import pytest +from homeassistant.components.anthropic.const import DOMAIN from homeassistant.core import HomeAssistant +from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.setup import async_setup_component from tests.common import MockConfigEntry @@ -61,3 +63,269 @@ async def test_init_error( assert await async_setup_component(hass, "anthropic", {}) await hass.async_block_till_done() assert error in caplog.text + + +async def test_migration_from_v1_to_v2( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, +) -> None: + """Test migration from version 1 to version 2.""" + # Create a v1 config entry with conversation options and an entity + OPTIONS = { + "recommended": True, + "llm_hass_api": ["assist"], + "prompt": "You are a helpful assistant", + "chat_model": "claude-3-haiku-20240307", + } + mock_config_entry = MockConfigEntry( + domain=DOMAIN, + data={"api_key": "1234"}, + options=OPTIONS, + version=1, + title="Claude", + ) + mock_config_entry.add_to_hass(hass) + + device = device_registry.async_get_or_create( + config_entry_id=mock_config_entry.entry_id, + identifiers={(DOMAIN, mock_config_entry.entry_id)}, + name=mock_config_entry.title, + manufacturer="Anthropic", + model="Claude", + entry_type=dr.DeviceEntryType.SERVICE, + ) + entity = entity_registry.async_get_or_create( + "conversation", + DOMAIN, + mock_config_entry.entry_id, + config_entry=mock_config_entry, + device_id=device.id, + suggested_object_id="claude", + ) + + # Run migration + with patch( + "homeassistant.components.anthropic.async_setup_entry", + return_value=True, + ): + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + assert mock_config_entry.version == 2 + assert mock_config_entry.data == {"api_key": "1234"} + assert mock_config_entry.options == {} + + assert len(mock_config_entry.subentries) == 1 + + subentry = next(iter(mock_config_entry.subentries.values())) + assert subentry.unique_id is None + assert subentry.title == "Claude" + assert subentry.subentry_type == "conversation" + assert subentry.data == OPTIONS + + migrated_entity = entity_registry.async_get(entity.entity_id) + assert migrated_entity is not None + assert migrated_entity.config_entry_id == mock_config_entry.entry_id + assert migrated_entity.config_subentry_id == subentry.subentry_id + assert migrated_entity.unique_id == subentry.subentry_id + + # Check device migration + assert not device_registry.async_get_device( + identifiers={(DOMAIN, mock_config_entry.entry_id)} + ) + assert ( + migrated_device := device_registry.async_get_device( + identifiers={(DOMAIN, subentry.subentry_id)} + ) + ) + assert migrated_device.identifiers == {(DOMAIN, subentry.subentry_id)} + assert migrated_device.id == device.id + + +async def test_migration_from_v1_to_v2_with_multiple_keys( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, +) -> None: + """Test migration from version 1 to version 2 with different API keys.""" + # Create two v1 config entries with different API keys + options = { + "recommended": True, + "llm_hass_api": ["assist"], + "prompt": "You are a helpful assistant", + "chat_model": "claude-3-haiku-20240307", + } + mock_config_entry = MockConfigEntry( + domain=DOMAIN, + data={"api_key": "1234"}, + options=options, + version=1, + title="Claude 1", + ) + mock_config_entry.add_to_hass(hass) + mock_config_entry_2 = MockConfigEntry( + domain=DOMAIN, + data={"api_key": "12345"}, + options=options, + version=1, + title="Claude 2", + ) + mock_config_entry_2.add_to_hass(hass) + + device = device_registry.async_get_or_create( + config_entry_id=mock_config_entry.entry_id, + identifiers={(DOMAIN, mock_config_entry.entry_id)}, + name=mock_config_entry.title, + manufacturer="Anthropic", + model="Claude 1", + entry_type=dr.DeviceEntryType.SERVICE, + ) + entity_registry.async_get_or_create( + "conversation", + DOMAIN, + mock_config_entry.entry_id, + config_entry=mock_config_entry, + device_id=device.id, + suggested_object_id="claude_1", + ) + + device_2 = device_registry.async_get_or_create( + config_entry_id=mock_config_entry_2.entry_id, + identifiers={(DOMAIN, mock_config_entry_2.entry_id)}, + name=mock_config_entry_2.title, + manufacturer="Anthropic", + model="Claude 2", + entry_type=dr.DeviceEntryType.SERVICE, + ) + entity_registry.async_get_or_create( + "conversation", + DOMAIN, + mock_config_entry_2.entry_id, + config_entry=mock_config_entry_2, + device_id=device_2.id, + suggested_object_id="claude_2", + ) + + # Run migration + with patch( + "homeassistant.components.anthropic.async_setup_entry", + return_value=True, + ): + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + entries = hass.config_entries.async_entries(DOMAIN) + assert len(entries) == 2 + + for idx, entry in enumerate(entries): + assert entry.version == 2 + assert not entry.options + assert len(entry.subentries) == 1 + subentry = list(entry.subentries.values())[0] + assert subentry.subentry_type == "conversation" + assert subentry.data == options + assert subentry.title == f"Claude {idx + 1}" + + dev = device_registry.async_get_device( + identifiers={(DOMAIN, list(entry.subentries.values())[0].subentry_id)} + ) + assert dev is not None + + +async def test_migration_from_v1_to_v2_with_same_keys( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, +) -> None: + """Test migration from version 1 to version 2 with same API keys consolidates entries.""" + # Create two v1 config entries with the same API key + options = { + "recommended": True, + "llm_hass_api": ["assist"], + "prompt": "You are a helpful assistant", + "chat_model": "claude-3-haiku-20240307", + } + mock_config_entry = MockConfigEntry( + domain=DOMAIN, + data={"api_key": "1234"}, + options=options, + version=1, + title="Claude", + ) + mock_config_entry.add_to_hass(hass) + mock_config_entry_2 = MockConfigEntry( + domain=DOMAIN, + data={"api_key": "1234"}, # Same API key + options=options, + version=1, + title="Claude 2", + ) + mock_config_entry_2.add_to_hass(hass) + + device = device_registry.async_get_or_create( + config_entry_id=mock_config_entry.entry_id, + identifiers={(DOMAIN, mock_config_entry.entry_id)}, + name=mock_config_entry.title, + manufacturer="Anthropic", + model="Claude", + entry_type=dr.DeviceEntryType.SERVICE, + ) + entity_registry.async_get_or_create( + "conversation", + DOMAIN, + mock_config_entry.entry_id, + config_entry=mock_config_entry, + device_id=device.id, + suggested_object_id="claude", + ) + + device_2 = device_registry.async_get_or_create( + config_entry_id=mock_config_entry_2.entry_id, + identifiers={(DOMAIN, mock_config_entry_2.entry_id)}, + name=mock_config_entry_2.title, + manufacturer="Anthropic", + model="Claude", + entry_type=dr.DeviceEntryType.SERVICE, + ) + entity_registry.async_get_or_create( + "conversation", + DOMAIN, + mock_config_entry_2.entry_id, + config_entry=mock_config_entry_2, + device_id=device_2.id, + suggested_object_id="claude_2", + ) + + # Run migration + with patch( + "homeassistant.components.anthropic.async_setup_entry", + return_value=True, + ): + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + # Should have only one entry left (consolidated) + entries = hass.config_entries.async_entries(DOMAIN) + assert len(entries) == 1 + + entry = entries[0] + assert entry.version == 2 + assert not entry.options + assert len(entry.subentries) == 2 # Two subentries from the two original entries + + # Check both subentries exist with correct data + subentries = list(entry.subentries.values()) + titles = [sub.title for sub in subentries] + assert "Claude" in titles + assert "Claude 2" in titles + + for subentry in subentries: + assert subentry.subentry_type == "conversation" + assert subentry.data == options + + # Check devices were migrated correctly + dev = device_registry.async_get_device( + identifiers={(DOMAIN, subentry.subentry_id)} + ) + assert dev is not None diff --git a/tests/components/derivative/test_config_flow.py b/tests/components/derivative/test_config_flow.py index 3f27d2366a5963..440df4959951f0 100644 --- a/tests/components/derivative/test_config_flow.py +++ b/tests/components/derivative/test_config_flow.py @@ -36,6 +36,7 @@ async def test_config_flow(hass: HomeAssistant, platform) -> None: "source": input_sensor_entity_id, "time_window": {"seconds": 0}, "unit_time": "min", + "max_sub_interval": {"minutes": 1}, }, ) await hass.async_block_till_done() @@ -49,6 +50,7 @@ async def test_config_flow(hass: HomeAssistant, platform) -> None: "source": "sensor.input", "time_window": {"seconds": 0.0}, "unit_time": "min", + "max_sub_interval": {"minutes": 1.0}, } assert len(mock_setup_entry.mock_calls) == 1 @@ -60,6 +62,7 @@ async def test_config_flow(hass: HomeAssistant, platform) -> None: "source": "sensor.input", "time_window": {"seconds": 0.0}, "unit_time": "min", + "max_sub_interval": {"minutes": 1.0}, } assert config_entry.title == "My derivative" @@ -78,6 +81,7 @@ async def test_options(hass: HomeAssistant, platform) -> None: "time_window": {"seconds": 0.0}, "unit_prefix": "k", "unit_time": "min", + "max_sub_interval": {"seconds": 30}, }, title="My derivative", ) diff --git a/tests/components/derivative/test_sensor.py b/tests/components/derivative/test_sensor.py index f8d88066f166db..e4e7097341c8dd 100644 --- a/tests/components/derivative/test_sensor.py +++ b/tests/components/derivative/test_sensor.py @@ -9,13 +9,13 @@ from homeassistant.components.derivative.const import DOMAIN from homeassistant.components.sensor import ATTR_STATE_CLASS, SensorStateClass -from homeassistant.const import UnitOfPower, UnitOfTime +from homeassistant.const import STATE_UNAVAILABLE, UnitOfPower, UnitOfTime from homeassistant.core import HomeAssistant, State from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.setup import async_setup_component from homeassistant.util import dt as dt_util -from tests.common import MockConfigEntry +from tests.common import MockConfigEntry, async_fire_time_changed async def test_state(hass: HomeAssistant) -> None: @@ -371,6 +371,177 @@ async def test_double_signal_after_delay(hass: HomeAssistant) -> None: previous = derivative +async def test_sub_intervals_instantaneous(hass: HomeAssistant) -> None: + """Test derivative sensor state.""" + # We simulate the following situation: + # Value changes from 0 to 10 in 5 seconds (derivative = 2) + # The max_sub_interval is 20 seconds + # After max_sub_interval elapses, derivative should change to 0 + # Value changes to 0, 35 seconds after changing to 10 (derivative = -10/35 = -0.29) + # State goes unavailable, derivative stops changing after that. + # State goes back to 0, derivative returns to 0 after a max_sub_interval + + max_sub_interval = 20 + + config, entity_id = await _setup_sensor( + hass, + { + "unit_time": UnitOfTime.SECONDS, + "round": 2, + "max_sub_interval": {"seconds": max_sub_interval}, + }, + ) + + base = dt_util.utcnow() + with freeze_time(base) as freezer: + freezer.move_to(base) + hass.states.async_set(entity_id, 0, {}, force_update=True) + await hass.async_block_till_done() + + now = base + timedelta(seconds=5) + freezer.move_to(now) + hass.states.async_set(entity_id, 10, {}, force_update=True) + await hass.async_block_till_done() + + state = hass.states.get("sensor.power") + derivative = round(float(state.state), config["sensor"]["round"]) + assert derivative == 2 + + # No change yet as sub_interval not elapsed + now += timedelta(seconds=15) + async_fire_time_changed(hass, now) + await hass.async_block_till_done() + + state = hass.states.get("sensor.power") + derivative = round(float(state.state), config["sensor"]["round"]) + assert derivative == 2 + + # After 5 more seconds the sub_interval should fire and derivative should be 0 + now += timedelta(seconds=10) + async_fire_time_changed(hass, now) + await hass.async_block_till_done() + + state = hass.states.get("sensor.power") + derivative = round(float(state.state), config["sensor"]["round"]) + assert derivative == 0 + + now += timedelta(seconds=10) + freezer.move_to(now) + hass.states.async_set(entity_id, 0, {}, force_update=True) + await hass.async_block_till_done() + + state = hass.states.get("sensor.power") + derivative = round(float(state.state), config["sensor"]["round"]) + assert derivative == -0.29 + + now += timedelta(seconds=10) + freezer.move_to(now) + hass.states.async_set(entity_id, STATE_UNAVAILABLE, {}, force_update=True) + await hass.async_block_till_done() + + state = hass.states.get("sensor.power") + derivative = round(float(state.state), config["sensor"]["round"]) + assert derivative == -0.29 + + now += timedelta(seconds=60) + async_fire_time_changed(hass, now) + await hass.async_block_till_done() + + state = hass.states.get("sensor.power") + derivative = round(float(state.state), config["sensor"]["round"]) + assert derivative == -0.29 + + now += timedelta(seconds=10) + freezer.move_to(now) + hass.states.async_set(entity_id, 0, {}, force_update=True) + await hass.async_block_till_done() + + state = hass.states.get("sensor.power") + derivative = round(float(state.state), config["sensor"]["round"]) + assert derivative == -0.29 + + now += timedelta(seconds=max_sub_interval + 1) + async_fire_time_changed(hass, now) + await hass.async_block_till_done() + + state = hass.states.get("sensor.power") + derivative = round(float(state.state), config["sensor"]["round"]) + assert derivative == 0 + + +async def test_sub_intervals_with_time_window(hass: HomeAssistant) -> None: + """Test derivative sensor state.""" + # We simulate the following situation: + # The value rises by 1 every second for 1 minute, then pauses + # The time window is 30 seconds + # The max_sub_interval is 5 seconds + # After the value stops increasing, the derivative should slowly trend back to 0 + + values = [] + for value in range(60): + values += [value] + time_window = 30 + max_sub_interval = 5 + times = values + + config, entity_id = await _setup_sensor( + hass, + { + "time_window": {"seconds": time_window}, + "unit_time": UnitOfTime.SECONDS, + "round": 2, + "max_sub_interval": {"seconds": max_sub_interval}, + }, + ) + + base = dt_util.utcnow() + with freeze_time(base) as freezer: + last_state_change = None + for time, value in zip(times, values, strict=False): + now = base + timedelta(seconds=time) + freezer.move_to(now) + hass.states.async_set(entity_id, value, {}, force_update=True) + last_state_change = now + await hass.async_block_till_done() + + if time_window < time: + state = hass.states.get("sensor.power") + derivative = round(float(state.state), config["sensor"]["round"]) + # Test that the error is never more than + # (time_window_in_minutes / true_derivative * 100) = 1% + ε + assert abs(1 - derivative) <= 0.01 + 1e-6 + + for time in range(60): + now = last_state_change + timedelta(seconds=time) + freezer.move_to(now) + + async_fire_time_changed(hass, now) + await hass.async_block_till_done() + + state = hass.states.get("sensor.power") + derivative = round(float(state.state), config["sensor"]["round"]) + + def calc_expected(elapsed_seconds: int, calculation_delay: int = 0): + last_sub_interval = ( + elapsed_seconds // max_sub_interval + ) * max_sub_interval + return ( + 0 + if (last_sub_interval >= time_window) + else ( + (time_window - last_sub_interval - calculation_delay) + / time_window + ) + ) + + rounding_err = 0.01 + 1e-6 + expect_max = calc_expected(time) + rounding_err + # Allow one second of slop for internal delays + expect_min = calc_expected(time, 1) - rounding_err + + assert expect_min <= derivative <= expect_max, f"Failed at time {time}" + + async def test_prefix(hass: HomeAssistant) -> None: """Test derivative sensor state using a power source.""" config = { diff --git a/tests/components/playstation_network/test_config_flow.py b/tests/components/playstation_network/test_config_flow.py index 031872a7a0bca3..981e459d283d71 100644 --- a/tests/components/playstation_network/test_config_flow.py +++ b/tests/components/playstation_network/test_config_flow.py @@ -11,7 +11,7 @@ PSNAWPNotFoundError, ) from homeassistant.components.playstation_network.const import CONF_NPSSO, DOMAIN -from homeassistant.config_entries import SOURCE_USER +from homeassistant.config_entries import SOURCE_USER, ConfigEntryState from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType @@ -138,3 +138,161 @@ async def test_parse_npsso_token_failures( assert result["data"] == { CONF_NPSSO: NPSSO_TOKEN, } + + +@pytest.mark.usefixtures("mock_psnawpapi") +async def test_flow_reauth( + hass: HomeAssistant, + config_entry: MockConfigEntry, +) -> None: + """Test reauth flow.""" + config_entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + assert config_entry.state is ConfigEntryState.LOADED + + result = await config_entry.start_reauth_flow(hass) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "reauth_confirm" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_NPSSO: "NEW_NPSSO_TOKEN"}, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reauth_successful" + + assert config_entry.data[CONF_NPSSO] == "NEW_NPSSO_TOKEN" + + assert len(hass.config_entries.async_entries()) == 1 + + +@pytest.mark.parametrize( + ("raise_error", "text_error"), + [ + (PSNAWPNotFoundError(), "invalid_account"), + (PSNAWPAuthenticationError(), "invalid_auth"), + (PSNAWPError(), "cannot_connect"), + (Exception(), "unknown"), + ], +) +async def test_flow_reauth_errors( + hass: HomeAssistant, + mock_psnawpapi: MagicMock, + config_entry: MockConfigEntry, + raise_error: Exception, + text_error: str, +) -> None: + """Test reauth flow errors.""" + + config_entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + assert config_entry.state is ConfigEntryState.LOADED + + mock_psnawpapi.user.side_effect = raise_error + result = await config_entry.start_reauth_flow(hass) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "reauth_confirm" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_NPSSO: "NEW_NPSSO_TOKEN"}, + ) + + await hass.async_block_till_done() + + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {"base": text_error} + + mock_psnawpapi.user.side_effect = None + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_NPSSO: "NEW_NPSSO_TOKEN"}, + ) + + await hass.async_block_till_done() + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reauth_successful" + + assert config_entry.data[CONF_NPSSO] == "NEW_NPSSO_TOKEN" + + assert len(hass.config_entries.async_entries()) == 1 + + +@pytest.mark.usefixtures("mock_psnawpapi") +async def test_flow_reauth_token_error( + hass: HomeAssistant, + mock_psnawp_npsso: MagicMock, + config_entry: MockConfigEntry, +) -> None: + """Test reauth flow token error.""" + + config_entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + assert config_entry.state is ConfigEntryState.LOADED + + mock_psnawp_npsso.side_effect = PSNAWPInvalidTokenError + result = await config_entry.start_reauth_flow(hass) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "reauth_confirm" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_NPSSO: "NEW_NPSSO_TOKEN"}, + ) + + await hass.async_block_till_done() + + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {"base": "invalid_account"} + + mock_psnawp_npsso.side_effect = lambda token: token + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_NPSSO: "NEW_NPSSO_TOKEN"}, + ) + + await hass.async_block_till_done() + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reauth_successful" + + assert config_entry.data[CONF_NPSSO] == "NEW_NPSSO_TOKEN" + + assert len(hass.config_entries.async_entries()) == 1 + + +@pytest.mark.usefixtures("mock_psnawpapi") +async def test_flow_reauth_account_mismatch( + hass: HomeAssistant, + config_entry: MockConfigEntry, + mock_user: MagicMock, +) -> None: + """Test reauth flow unique_id mismatch.""" + + config_entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + assert config_entry.state is ConfigEntryState.LOADED + mock_user.account_id = "other_account" + result = await config_entry.start_reauth_flow(hass) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "reauth_confirm" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_NPSSO: "NEW_NPSSO_TOKEN"}, + ) + + await hass.async_block_till_done() + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "unique_id_mismatch" diff --git a/tests/components/shelly/snapshots/test_devices.ambr b/tests/components/shelly/snapshots/test_devices.ambr index 37b0d3ef11ccfb..0b8ec71771bd6a 100644 --- a/tests/components/shelly/snapshots/test_devices.ambr +++ b/tests/components/shelly/snapshots/test_devices.ambr @@ -27,6 +27,7 @@ 'original_name': 'Cloud', 'platform': 'shelly', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '123456789ABC-cloud-cloud', @@ -75,6 +76,7 @@ 'original_name': 'Input 0', 'platform': 'shelly', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '123456789ABC-input:0-input', @@ -123,6 +125,7 @@ 'original_name': 'Input 1', 'platform': 'shelly', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '123456789ABC-input:1-input', @@ -171,6 +174,7 @@ 'original_name': 'Overcurrent', 'platform': 'shelly', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '123456789ABC-cover:0-overcurrent', @@ -219,6 +223,7 @@ 'original_name': 'Overheating', 'platform': 'shelly', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '123456789ABC-cover:0-overtemp', @@ -267,6 +272,7 @@ 'original_name': 'Overpowering', 'platform': 'shelly', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '123456789ABC-cover:0-overpower', @@ -315,6 +321,7 @@ 'original_name': 'Overvoltage', 'platform': 'shelly', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '123456789ABC-cover:0-overvoltage', @@ -363,6 +370,7 @@ 'original_name': 'Restart required', 'platform': 'shelly', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '123456789ABC-sys-restart', @@ -411,6 +419,7 @@ 'original_name': 'Reboot', 'platform': 'shelly', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '123456789ABC_reboot', @@ -459,6 +468,7 @@ 'original_name': None, 'platform': 'shelly', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': , 'translation_key': None, 'unique_id': '123456789ABC-cover:0', @@ -504,12 +514,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Current', 'platform': 'shelly', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '123456789ABC-cover:0-current', @@ -568,6 +582,7 @@ 'original_name': 'Energy', 'platform': 'shelly', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '123456789ABC-cover:0-energy', @@ -623,6 +638,7 @@ 'original_name': 'Frequency', 'platform': 'shelly', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '123456789ABC-cover:0-freq', @@ -669,12 +685,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Power', 'platform': 'shelly', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '123456789ABC-cover:0-power', @@ -727,6 +747,7 @@ 'original_name': 'RSSI', 'platform': 'shelly', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '123456789ABC-wifi-rssi', @@ -782,6 +803,7 @@ 'original_name': 'Temperature', 'platform': 'shelly', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '123456789ABC-cover:0-temperature', @@ -832,6 +854,7 @@ 'original_name': 'Uptime', 'platform': 'shelly', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '123456789ABC-sys-uptime', @@ -885,6 +908,7 @@ 'original_name': 'Voltage', 'platform': 'shelly', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '123456789ABC-cover:0-voltage', @@ -935,6 +959,7 @@ 'original_name': 'Beta firmware', 'platform': 'shelly', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': , 'translation_key': None, 'unique_id': '123456789ABC-sys-fwupdate_beta', @@ -995,6 +1020,7 @@ 'original_name': 'Firmware', 'platform': 'shelly', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': , 'translation_key': None, 'unique_id': '123456789ABC-sys-fwupdate', @@ -1055,6 +1081,7 @@ 'original_name': 'Cloud', 'platform': 'shelly', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '123456789ABC-cloud-cloud', @@ -1103,6 +1130,7 @@ 'original_name': 'Input 0', 'platform': 'shelly', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '123456789ABC-input:0-input', @@ -1151,6 +1179,7 @@ 'original_name': 'Input 1', 'platform': 'shelly', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '123456789ABC-input:1-input', @@ -1199,6 +1228,7 @@ 'original_name': 'Restart required', 'platform': 'shelly', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '123456789ABC-sys-restart', @@ -1247,6 +1277,7 @@ 'original_name': 'Overcurrent', 'platform': 'shelly', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '123456789ABC-switch:0-overcurrent', @@ -1295,6 +1326,7 @@ 'original_name': 'Overheating', 'platform': 'shelly', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '123456789ABC-switch:0-overtemp', @@ -1343,6 +1375,7 @@ 'original_name': 'Overpowering', 'platform': 'shelly', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '123456789ABC-switch:0-overpower', @@ -1391,6 +1424,7 @@ 'original_name': 'Overvoltage', 'platform': 'shelly', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '123456789ABC-switch:0-overvoltage', @@ -1439,6 +1473,7 @@ 'original_name': 'Overcurrent', 'platform': 'shelly', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '123456789ABC-switch:1-overcurrent', @@ -1487,6 +1522,7 @@ 'original_name': 'Overheating', 'platform': 'shelly', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '123456789ABC-switch:1-overtemp', @@ -1535,6 +1571,7 @@ 'original_name': 'Overpowering', 'platform': 'shelly', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '123456789ABC-switch:1-overpower', @@ -1583,6 +1620,7 @@ 'original_name': 'Overvoltage', 'platform': 'shelly', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '123456789ABC-switch:1-overvoltage', @@ -1631,6 +1669,7 @@ 'original_name': 'Reboot', 'platform': 'shelly', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '123456789ABC_reboot', @@ -1681,6 +1720,7 @@ 'original_name': 'RSSI', 'platform': 'shelly', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '123456789ABC-wifi-rssi', @@ -1727,12 +1767,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Current', 'platform': 'shelly', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '123456789ABC-switch:0-current', @@ -1791,6 +1835,7 @@ 'original_name': 'Energy', 'platform': 'shelly', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '123456789ABC-switch:0-energy', @@ -1846,6 +1891,7 @@ 'original_name': 'Frequency', 'platform': 'shelly', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '123456789ABC-switch:0-freq', @@ -1892,12 +1938,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Power', 'platform': 'shelly', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '123456789ABC-switch:0-power', @@ -1956,6 +2006,7 @@ 'original_name': 'Returned energy', 'platform': 'shelly', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '123456789ABC-switch:0-ret_energy', @@ -2011,6 +2062,7 @@ 'original_name': 'Temperature', 'platform': 'shelly', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '123456789ABC-switch:0-temperature', @@ -2066,6 +2118,7 @@ 'original_name': 'Voltage', 'platform': 'shelly', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '123456789ABC-switch:0-voltage', @@ -2112,12 +2165,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Current', 'platform': 'shelly', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '123456789ABC-switch:1-current', @@ -2176,6 +2233,7 @@ 'original_name': 'Energy', 'platform': 'shelly', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '123456789ABC-switch:1-energy', @@ -2231,6 +2289,7 @@ 'original_name': 'Frequency', 'platform': 'shelly', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '123456789ABC-switch:1-freq', @@ -2277,12 +2336,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Power', 'platform': 'shelly', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '123456789ABC-switch:1-power', @@ -2341,6 +2404,7 @@ 'original_name': 'Returned energy', 'platform': 'shelly', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '123456789ABC-switch:1-ret_energy', @@ -2396,6 +2460,7 @@ 'original_name': 'Temperature', 'platform': 'shelly', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '123456789ABC-switch:1-temperature', @@ -2451,6 +2516,7 @@ 'original_name': 'Voltage', 'platform': 'shelly', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '123456789ABC-switch:1-voltage', @@ -2501,6 +2567,7 @@ 'original_name': 'Uptime', 'platform': 'shelly', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '123456789ABC-sys-uptime', @@ -2549,6 +2616,7 @@ 'original_name': None, 'platform': 'shelly', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '123456789ABC-switch:0', @@ -2596,6 +2664,7 @@ 'original_name': None, 'platform': 'shelly', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '123456789ABC-switch:1', @@ -2643,6 +2712,7 @@ 'original_name': 'Beta firmware', 'platform': 'shelly', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': , 'translation_key': None, 'unique_id': '123456789ABC-sys-fwupdate_beta', @@ -2703,6 +2773,7 @@ 'original_name': 'Firmware', 'platform': 'shelly', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': , 'translation_key': None, 'unique_id': '123456789ABC-sys-fwupdate', @@ -2763,6 +2834,7 @@ 'original_name': 'Cloud', 'platform': 'shelly', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '123456789ABC-cloud-cloud', @@ -2811,6 +2883,7 @@ 'original_name': 'Restart required', 'platform': 'shelly', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '123456789ABC-sys-restart', @@ -2859,6 +2932,7 @@ 'original_name': 'Reboot', 'platform': 'shelly', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '123456789ABC_reboot', @@ -2903,12 +2977,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Active power', 'platform': 'shelly', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '123456789ABC-em:0-a_act_power', @@ -2955,12 +3033,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Apparent power', 'platform': 'shelly', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '123456789ABC-em:0-a_aprt_power', @@ -3007,12 +3089,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Current', 'platform': 'shelly', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '123456789ABC-em:0-a_current', @@ -3068,6 +3154,7 @@ 'original_name': 'Frequency', 'platform': 'shelly', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '123456789ABC-em:0-a_freq', @@ -3120,6 +3207,7 @@ 'original_name': 'Power factor', 'platform': 'shelly', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '123456789ABC-em:0-a_pf', @@ -3177,6 +3265,7 @@ 'original_name': 'Total active energy', 'platform': 'shelly', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '123456789ABC-emdata:0-a_total_act_energy', @@ -3235,6 +3324,7 @@ 'original_name': 'Total active returned energy', 'platform': 'shelly', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '123456789ABC-emdata:0-a_total_act_ret_energy', @@ -3281,12 +3371,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Voltage', 'platform': 'shelly', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '123456789ABC-em:0-a_voltage', @@ -3333,12 +3427,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Active power', 'platform': 'shelly', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '123456789ABC-em:0-b_act_power', @@ -3385,12 +3483,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Apparent power', 'platform': 'shelly', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '123456789ABC-em:0-b_aprt_power', @@ -3437,12 +3539,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Current', 'platform': 'shelly', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '123456789ABC-em:0-b_current', @@ -3498,6 +3604,7 @@ 'original_name': 'Frequency', 'platform': 'shelly', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '123456789ABC-em:0-b_freq', @@ -3550,6 +3657,7 @@ 'original_name': 'Power factor', 'platform': 'shelly', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '123456789ABC-em:0-b_pf', @@ -3607,6 +3715,7 @@ 'original_name': 'Total active energy', 'platform': 'shelly', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '123456789ABC-emdata:0-b_total_act_energy', @@ -3665,6 +3774,7 @@ 'original_name': 'Total active returned energy', 'platform': 'shelly', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '123456789ABC-emdata:0-b_total_act_ret_energy', @@ -3711,12 +3821,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Voltage', 'platform': 'shelly', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '123456789ABC-em:0-b_voltage', @@ -3763,12 +3877,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Active power', 'platform': 'shelly', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '123456789ABC-em:0-c_act_power', @@ -3815,12 +3933,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Apparent power', 'platform': 'shelly', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '123456789ABC-em:0-c_aprt_power', @@ -3867,12 +3989,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Current', 'platform': 'shelly', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '123456789ABC-em:0-c_current', @@ -3928,6 +4054,7 @@ 'original_name': 'Frequency', 'platform': 'shelly', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '123456789ABC-em:0-c_freq', @@ -3980,6 +4107,7 @@ 'original_name': 'Power factor', 'platform': 'shelly', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '123456789ABC-em:0-c_pf', @@ -4037,6 +4165,7 @@ 'original_name': 'Total active energy', 'platform': 'shelly', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '123456789ABC-emdata:0-c_total_act_energy', @@ -4095,6 +4224,7 @@ 'original_name': 'Total active returned energy', 'platform': 'shelly', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '123456789ABC-emdata:0-c_total_act_ret_energy', @@ -4141,12 +4271,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Voltage', 'platform': 'shelly', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '123456789ABC-em:0-c_voltage', @@ -4199,6 +4333,7 @@ 'original_name': 'RSSI', 'platform': 'shelly', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '123456789ABC-wifi-rssi', @@ -4254,6 +4389,7 @@ 'original_name': 'Temperature', 'platform': 'shelly', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '123456789ABC-temperature:0-temperature_0', @@ -4312,6 +4448,7 @@ 'original_name': 'Total active energy', 'platform': 'shelly', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '123456789ABC-emdata:0-total_act', @@ -4358,12 +4495,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Total active power', 'platform': 'shelly', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '123456789ABC-em:0-total_act_power', @@ -4422,6 +4563,7 @@ 'original_name': 'Total active returned energy', 'platform': 'shelly', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '123456789ABC-emdata:0-total_act_ret', @@ -4468,12 +4610,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Total apparent power', 'platform': 'shelly', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '123456789ABC-em:0-total_aprt_power', @@ -4520,12 +4666,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Total current', 'platform': 'shelly', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '123456789ABC-em:0-total_current', @@ -4576,6 +4726,7 @@ 'original_name': 'Uptime', 'platform': 'shelly', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '123456789ABC-sys-uptime', @@ -4624,6 +4775,7 @@ 'original_name': 'Beta firmware', 'platform': 'shelly', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': , 'translation_key': None, 'unique_id': '123456789ABC-sys-fwupdate_beta', @@ -4684,6 +4836,7 @@ 'original_name': 'Firmware', 'platform': 'shelly', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': , 'translation_key': None, 'unique_id': '123456789ABC-sys-fwupdate', diff --git a/tests/components/tedee/fixtures/locks.json b/tests/components/tedee/fixtures/locks.json index 6a8eb77d7ee8c2..95a1adf40ec370 100644 --- a/tests/components/tedee/fixtures/locks.json +++ b/tests/components/tedee/fixtures/locks.json @@ -9,7 +9,8 @@ "is_charging": false, "state_change_result": 0, "is_enabled_pullspring": 1, - "duration_pullspring": 2 + "duration_pullspring": 2, + "door_state": 0 }, { "lock_name": "Lock-2C3D", @@ -21,6 +22,7 @@ "is_charging": false, "state_change_result": 0, "is_enabled_pullspring": 0, - "duration_pullspring": 0 + "duration_pullspring": 0, + "door_state": 2 } ] diff --git a/tests/components/tedee/snapshots/test_binary_sensor.ambr b/tests/components/tedee/snapshots/test_binary_sensor.ambr index 05d0e34037e839..dbde7932a6d586 100644 --- a/tests/components/tedee/snapshots/test_binary_sensor.ambr +++ b/tests/components/tedee/snapshots/test_binary_sensor.ambr @@ -242,6 +242,55 @@ 'state': 'off', }) # --- +# name: test_binary_sensors[binary_sensor.lock_2c3d_door-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.lock_2c3d_door', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Door', + 'platform': 'tedee', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '98765-door_state', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensors[binary_sensor.lock_2c3d_door-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'door', + 'friendly_name': 'Lock-2C3D Door', + }), + 'context': , + 'entity_id': 'binary_sensor.lock_2c3d_door', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- # name: test_binary_sensors[binary_sensor.lock_2c3d_lock_uncalibrated-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ diff --git a/tests/components/tedee/snapshots/test_diagnostics.ambr b/tests/components/tedee/snapshots/test_diagnostics.ambr index 63707477df9c65..d66b2601b72655 100644 --- a/tests/components/tedee/snapshots/test_diagnostics.ambr +++ b/tests/components/tedee/snapshots/test_diagnostics.ambr @@ -17,7 +17,7 @@ }), '1': dict({ 'battery_level': 70, - 'door_state': 0, + 'door_state': 2, 'duration_pullspring': 0, 'is_charging': False, 'is_connected': True, diff --git a/tests/helpers/test_llm.py b/tests/helpers/test_llm.py index 98dee920bd9674..b6894505534fea 100644 --- a/tests/helpers/test_llm.py +++ b/tests/helpers/test_llm.py @@ -1125,7 +1125,7 @@ async def test_selector_serializer( "media_content_type": {"type": "string"}, "metadata": {"type": "object", "additionalProperties": True}, }, - "required": ["entity_id", "media_content_id", "media_content_type"], + "required": ["media_content_id", "media_content_type"], } assert selector_serializer(selector.NumberSelector({"mode": "box"})) == { "type": "number" diff --git a/tests/helpers/test_selector.py b/tests/helpers/test_selector.py index 51ee467b029b2f..8947ea8099cd85 100644 --- a/tests/helpers/test_selector.py +++ b/tests/helpers/test_selector.py @@ -590,7 +590,28 @@ def test_action_selector_schema(schema, valid_selections, invalid_selections) -> @pytest.mark.parametrize( ("schema", "valid_selections", "invalid_selections"), - [({}, ("abc123",), ())], + [ + ({}, ("abc123",), ()), + ( + { + "fields": { + "name": { + "required": True, + "selector": {"text": {}}, + }, + "percentage": { + "selector": {"number": {}}, + }, + }, + "multiple": True, + "label_field": "name", + "description_field": "percentage", + }, + (), + (), + ), + ], + [], ) def test_object_selector_schema(schema, valid_selections, invalid_selections) -> None: """Test object selector.""" @@ -817,6 +838,23 @@ def test_theme_selector_schema(schema, valid_selections, invalid_selections) -> ), (None, "abc", {}), ), + ( + { + "accept": ["image/*"], + }, + ( + { + "media_content_id": "abc", + "media_content_type": "def", + }, + { + "media_content_id": "abc", + "media_content_type": "def", + "metadata": {}, + }, + ), + (None, "abc", {}), + ), ], ) def test_media_selector_schema(schema, valid_selections, invalid_selections) -> None: diff --git a/tests/helpers/test_template.py b/tests/helpers/test_template.py index 15c6a4b7251895..82b6434cf3fe3a 100644 --- a/tests/helpers/test_template.py +++ b/tests/helpers/test_template.py @@ -1494,6 +1494,15 @@ def test_from_json(hass: HomeAssistant) -> None: ).async_render() assert actual_result == expected_result + info = render_to_info(hass, "{{ 'garbage string' | from_json }}") + with pytest.raises(TemplateError, match="no default was specified"): + info.result() + + actual_result = template.Template( + "{{ 'garbage string' | from_json('Bar') }}", hass + ).async_render() + assert actual_result == expected_result + def test_average(hass: HomeAssistant) -> None: """Test the average filter."""