diff --git a/.github/workflows/builder.yml b/.github/workflows/builder.yml index 136f1b83d06..dc97e627ea4 100644 --- a/.github/workflows/builder.yml +++ b/.github/workflows/builder.yml @@ -94,7 +94,7 @@ jobs: - name: Download nightly wheels of frontend if: needs.init.outputs.channel == 'dev' - uses: dawidd6/action-download-artifact@v10 + uses: dawidd6/action-download-artifact@v11 with: github_token: ${{secrets.GITHUB_TOKEN}} repo: home-assistant/frontend @@ -105,7 +105,7 @@ jobs: - name: Download nightly wheels of intents if: needs.init.outputs.channel == 'dev' - uses: dawidd6/action-download-artifact@v10 + uses: dawidd6/action-download-artifact@v11 with: github_token: ${{secrets.GITHUB_TOKEN}} repo: home-assistant/intents-package diff --git a/homeassistant/components/alexa_devices/manifest.json b/homeassistant/components/alexa_devices/manifest.json index 96f17d541fc..aeecb5bc96c 100644 --- a/homeassistant/components/alexa_devices/manifest.json +++ b/homeassistant/components/alexa_devices/manifest.json @@ -8,5 +8,5 @@ "iot_class": "cloud_polling", "loggers": ["aioamazondevices"], "quality_scale": "bronze", - "requirements": ["aioamazondevices==3.1.2"] + "requirements": ["aioamazondevices==3.1.12"] } diff --git a/homeassistant/components/anthropic/conversation.py b/homeassistant/components/anthropic/conversation.py index 846249b1caf..f17294fe0e7 100644 --- a/homeassistant/components/anthropic/conversation.py +++ b/homeassistant/components/anthropic/conversation.py @@ -366,11 +366,11 @@ class AnthropicConversationEntity( options = self.entry.options try: - await chat_log.async_update_llm_data( - DOMAIN, - user_input, + await chat_log.async_provide_llm_data( + user_input.as_llm_context(DOMAIN), options.get(CONF_LLM_HASS_API), options.get(CONF_PROMPT), + user_input.extra_system_prompt, ) except conversation.ConverseError as err: return err.as_conversation_result() diff --git a/homeassistant/components/apcupsd/sensor.py b/homeassistant/components/apcupsd/sensor.py index a3faf6b0268..5076b537467 100644 --- a/homeassistant/components/apcupsd/sensor.py +++ b/homeassistant/components/apcupsd/sensor.py @@ -12,6 +12,7 @@ from homeassistant.components.sensor import ( ) from homeassistant.const import ( PERCENTAGE, + EntityCategory, UnitOfApparentPower, UnitOfElectricCurrent, UnitOfElectricPotential, @@ -35,6 +36,7 @@ SENSORS: dict[str, SensorEntityDescription] = { "alarmdel": SensorEntityDescription( key="alarmdel", translation_key="alarm_delay", + entity_category=EntityCategory.DIAGNOSTIC, ), "ambtemp": SensorEntityDescription( key="ambtemp", @@ -47,15 +49,18 @@ SENSORS: dict[str, SensorEntityDescription] = { key="apc", translation_key="apc_status", entity_registry_enabled_default=False, + entity_category=EntityCategory.DIAGNOSTIC, ), "apcmodel": SensorEntityDescription( key="apcmodel", translation_key="apc_model", entity_registry_enabled_default=False, + entity_category=EntityCategory.DIAGNOSTIC, ), "badbatts": SensorEntityDescription( key="badbatts", translation_key="bad_batteries", + entity_category=EntityCategory.DIAGNOSTIC, ), "battdate": SensorEntityDescription( key="battdate", @@ -82,6 +87,7 @@ SENSORS: dict[str, SensorEntityDescription] = { key="cable", translation_key="cable_type", entity_registry_enabled_default=False, + entity_category=EntityCategory.DIAGNOSTIC, ), "cumonbatt": SensorEntityDescription( key="cumonbatt", @@ -94,52 +100,63 @@ SENSORS: dict[str, SensorEntityDescription] = { key="date", translation_key="date", entity_registry_enabled_default=False, + entity_category=EntityCategory.DIAGNOSTIC, ), "dipsw": SensorEntityDescription( key="dipsw", translation_key="dip_switch_settings", + entity_category=EntityCategory.DIAGNOSTIC, ), "dlowbatt": SensorEntityDescription( key="dlowbatt", translation_key="low_battery_signal", + entity_category=EntityCategory.DIAGNOSTIC, ), "driver": SensorEntityDescription( key="driver", translation_key="driver", entity_registry_enabled_default=False, + entity_category=EntityCategory.DIAGNOSTIC, ), "dshutd": SensorEntityDescription( key="dshutd", translation_key="shutdown_delay", + entity_category=EntityCategory.DIAGNOSTIC, ), "dwake": SensorEntityDescription( key="dwake", translation_key="wake_delay", + entity_category=EntityCategory.DIAGNOSTIC, ), "end apc": SensorEntityDescription( key="end apc", translation_key="date_and_time", entity_registry_enabled_default=False, + entity_category=EntityCategory.DIAGNOSTIC, ), "extbatts": SensorEntityDescription( key="extbatts", translation_key="external_batteries", + entity_category=EntityCategory.DIAGNOSTIC, ), "firmware": SensorEntityDescription( key="firmware", translation_key="firmware_version", entity_registry_enabled_default=False, + entity_category=EntityCategory.DIAGNOSTIC, ), "hitrans": SensorEntityDescription( key="hitrans", translation_key="transfer_high", native_unit_of_measurement=UnitOfElectricPotential.VOLT, device_class=SensorDeviceClass.VOLTAGE, + entity_category=EntityCategory.DIAGNOSTIC, ), "hostname": SensorEntityDescription( key="hostname", translation_key="hostname", entity_registry_enabled_default=False, + entity_category=EntityCategory.DIAGNOSTIC, ), "humidity": SensorEntityDescription( key="humidity", @@ -163,10 +180,12 @@ SENSORS: dict[str, SensorEntityDescription] = { key="lastxfer", translation_key="last_transfer", entity_registry_enabled_default=False, + entity_category=EntityCategory.DIAGNOSTIC, ), "linefail": SensorEntityDescription( key="linefail", translation_key="line_failure", + entity_category=EntityCategory.DIAGNOSTIC, ), "linefreq": SensorEntityDescription( key="linefreq", @@ -198,15 +217,18 @@ SENSORS: dict[str, SensorEntityDescription] = { translation_key="transfer_low", native_unit_of_measurement=UnitOfElectricPotential.VOLT, device_class=SensorDeviceClass.VOLTAGE, + entity_category=EntityCategory.DIAGNOSTIC, ), "mandate": SensorEntityDescription( key="mandate", translation_key="manufacture_date", entity_registry_enabled_default=False, + entity_category=EntityCategory.DIAGNOSTIC, ), "masterupd": SensorEntityDescription( key="masterupd", translation_key="master_update", + entity_category=EntityCategory.DIAGNOSTIC, ), "maxlinev": SensorEntityDescription( key="maxlinev", @@ -217,11 +239,13 @@ SENSORS: dict[str, SensorEntityDescription] = { "maxtime": SensorEntityDescription( key="maxtime", translation_key="max_time", + entity_category=EntityCategory.DIAGNOSTIC, ), "mbattchg": SensorEntityDescription( key="mbattchg", translation_key="max_battery_charge", native_unit_of_measurement=PERCENTAGE, + entity_category=EntityCategory.DIAGNOSTIC, ), "minlinev": SensorEntityDescription( key="minlinev", @@ -232,41 +256,48 @@ SENSORS: dict[str, SensorEntityDescription] = { "mintimel": SensorEntityDescription( key="mintimel", translation_key="min_time", + entity_category=EntityCategory.DIAGNOSTIC, ), "model": SensorEntityDescription( key="model", translation_key="model", entity_registry_enabled_default=False, + entity_category=EntityCategory.DIAGNOSTIC, ), "nombattv": SensorEntityDescription( key="nombattv", translation_key="battery_nominal_voltage", native_unit_of_measurement=UnitOfElectricPotential.VOLT, device_class=SensorDeviceClass.VOLTAGE, + entity_category=EntityCategory.DIAGNOSTIC, ), "nominv": SensorEntityDescription( key="nominv", translation_key="nominal_input_voltage", native_unit_of_measurement=UnitOfElectricPotential.VOLT, device_class=SensorDeviceClass.VOLTAGE, + entity_category=EntityCategory.DIAGNOSTIC, ), "nomoutv": SensorEntityDescription( key="nomoutv", translation_key="nominal_output_voltage", native_unit_of_measurement=UnitOfElectricPotential.VOLT, device_class=SensorDeviceClass.VOLTAGE, + entity_category=EntityCategory.DIAGNOSTIC, ), "nompower": SensorEntityDescription( key="nompower", translation_key="nominal_output_power", native_unit_of_measurement=UnitOfPower.WATT, device_class=SensorDeviceClass.POWER, + entity_category=EntityCategory.DIAGNOSTIC, ), "nomapnt": SensorEntityDescription( key="nomapnt", translation_key="nominal_apparent_power", native_unit_of_measurement=UnitOfApparentPower.VOLT_AMPERE, device_class=SensorDeviceClass.APPARENT_POWER, + entity_category=EntityCategory.DIAGNOSTIC, ), "numxfers": SensorEntityDescription( key="numxfers", @@ -291,21 +322,25 @@ SENSORS: dict[str, SensorEntityDescription] = { key="reg1", translation_key="register_1_fault", entity_registry_enabled_default=False, + entity_category=EntityCategory.DIAGNOSTIC, ), "reg2": SensorEntityDescription( key="reg2", translation_key="register_2_fault", entity_registry_enabled_default=False, + entity_category=EntityCategory.DIAGNOSTIC, ), "reg3": SensorEntityDescription( key="reg3", translation_key="register_3_fault", entity_registry_enabled_default=False, + entity_category=EntityCategory.DIAGNOSTIC, ), "retpct": SensorEntityDescription( key="retpct", translation_key="restore_capacity", native_unit_of_measurement=PERCENTAGE, + entity_category=EntityCategory.DIAGNOSTIC, ), "selftest": SensorEntityDescription( key="selftest", @@ -315,20 +350,24 @@ SENSORS: dict[str, SensorEntityDescription] = { key="sense", translation_key="sensitivity", entity_registry_enabled_default=False, + entity_category=EntityCategory.DIAGNOSTIC, ), "serialno": SensorEntityDescription( key="serialno", translation_key="serial_number", entity_registry_enabled_default=False, + entity_category=EntityCategory.DIAGNOSTIC, ), "starttime": SensorEntityDescription( key="starttime", translation_key="startup_time", + entity_category=EntityCategory.DIAGNOSTIC, ), "statflag": SensorEntityDescription( key="statflag", translation_key="online_status", entity_registry_enabled_default=False, + entity_category=EntityCategory.DIAGNOSTIC, ), "status": SensorEntityDescription( key="status", @@ -337,6 +376,7 @@ SENSORS: dict[str, SensorEntityDescription] = { "stesti": SensorEntityDescription( key="stesti", translation_key="self_test_interval", + entity_category=EntityCategory.DIAGNOSTIC, ), "timeleft": SensorEntityDescription( key="timeleft", @@ -360,23 +400,28 @@ SENSORS: dict[str, SensorEntityDescription] = { key="upsname", translation_key="ups_name", entity_registry_enabled_default=False, + entity_category=EntityCategory.DIAGNOSTIC, ), "version": SensorEntityDescription( key="version", translation_key="version", entity_registry_enabled_default=False, + entity_category=EntityCategory.DIAGNOSTIC, ), "xoffbat": SensorEntityDescription( key="xoffbat", translation_key="transfer_from_battery", + entity_category=EntityCategory.DIAGNOSTIC, ), "xoffbatt": SensorEntityDescription( key="xoffbatt", translation_key="transfer_from_battery", + entity_category=EntityCategory.DIAGNOSTIC, ), "xonbatt": SensorEntityDescription( key="xonbatt", translation_key="transfer_to_battery", + entity_category=EntityCategory.DIAGNOSTIC, ), } diff --git a/homeassistant/components/bthome/manifest.json b/homeassistant/components/bthome/manifest.json index 4130606ff5c..0bbdfae50e4 100644 --- a/homeassistant/components/bthome/manifest.json +++ b/homeassistant/components/bthome/manifest.json @@ -20,5 +20,5 @@ "dependencies": ["bluetooth_adapters"], "documentation": "https://www.home-assistant.io/integrations/bthome", "iot_class": "local_push", - "requirements": ["bthome-ble==3.12.4"] + "requirements": ["bthome-ble==3.13.1"] } diff --git a/homeassistant/components/climate/__init__.py b/homeassistant/components/climate/__init__.py index 59749cd58ee..790579d6a73 100644 --- a/homeassistant/components/climate/__init__.py +++ b/homeassistant/components/climate/__init__.py @@ -105,11 +105,6 @@ DEFAULT_MAX_HUMIDITY = 99 CONVERTIBLE_ATTRIBUTE = [ATTR_TEMPERATURE, ATTR_TARGET_TEMP_LOW, ATTR_TARGET_TEMP_HIGH] -# Can be removed in 2025.1 after deprecation period of the new feature flags -CHECK_TURN_ON_OFF_FEATURE_FLAG = ( - ClimateEntityFeature.TURN_ON | ClimateEntityFeature.TURN_OFF -) - SET_TEMPERATURE_SCHEMA = vol.All( cv.has_at_least_one_key( ATTR_TEMPERATURE, ATTR_TARGET_TEMP_HIGH, ATTR_TARGET_TEMP_LOW diff --git a/homeassistant/components/cloud/manifest.json b/homeassistant/components/cloud/manifest.json index faee244a074..0d1aca60c8f 100644 --- a/homeassistant/components/cloud/manifest.json +++ b/homeassistant/components/cloud/manifest.json @@ -13,6 +13,6 @@ "integration_type": "system", "iot_class": "cloud_push", "loggers": ["acme", "hass_nabucasa", "snitun"], - "requirements": ["hass-nabucasa==0.101.0"], + "requirements": ["hass-nabucasa==0.102.0"], "single_config_entry": true } diff --git a/homeassistant/components/conversation/chat_log.py b/homeassistant/components/conversation/chat_log.py index c78f41f3c5c..6322bdb4435 100644 --- a/homeassistant/components/conversation/chat_log.py +++ b/homeassistant/components/conversation/chat_log.py @@ -14,12 +14,11 @@ import voluptuous as vol from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError, TemplateError -from homeassistant.helpers import chat_session, intent, llm, template +from homeassistant.helpers import chat_session, frame, intent, llm, template from homeassistant.util.hass_dict import HassKey from homeassistant.util.json import JsonObjectType from . import trace -from .const import DOMAIN from .models import ConversationInput, ConversationResult DATA_CHAT_LOGS: HassKey[dict[str, ChatLog]] = HassKey("conversation_chat_logs") @@ -359,7 +358,7 @@ class ChatLog: self, llm_context: llm.LLMContext, prompt: str, - language: str, + language: str | None, user_name: str | None = None, ) -> str: try: @@ -373,7 +372,7 @@ class ChatLog: ) except TemplateError as err: LOGGER.error("Error rendering prompt: %s", err) - intent_response = intent.IntentResponse(language=language) + intent_response = intent.IntentResponse(language=language or "") intent_response.async_set_error( intent.IntentResponseErrorCode.UNKNOWN, "Sorry, I had a problem with my template", @@ -392,15 +391,25 @@ class ChatLog: user_llm_prompt: str | None = None, ) -> None: """Set the LLM system prompt.""" - llm_context = llm.LLMContext( - platform=conversing_domain, - context=user_input.context, - user_prompt=user_input.text, - language=user_input.language, - assistant=DOMAIN, - device_id=user_input.device_id, + frame.report_usage( + "ChatLog.async_update_llm_data", + breaks_in_ha_version="2026.1", + ) + return await self.async_provide_llm_data( + llm_context=user_input.as_llm_context(conversing_domain), + user_llm_hass_api=user_llm_hass_api, + user_llm_prompt=user_llm_prompt, + user_extra_system_prompt=user_input.extra_system_prompt, ) + async def async_provide_llm_data( + self, + llm_context: llm.LLMContext, + user_llm_hass_api: str | list[str] | None = None, + user_llm_prompt: str | None = None, + user_extra_system_prompt: str | None = None, + ) -> None: + """Set the LLM system prompt.""" llm_api: llm.APIInstance | None = None if user_llm_hass_api: @@ -414,10 +423,12 @@ class ChatLog: LOGGER.error( "Error getting LLM API %s for %s: %s", user_llm_hass_api, - conversing_domain, + llm_context.platform, err, ) - intent_response = intent.IntentResponse(language=user_input.language) + intent_response = intent.IntentResponse( + language=llm_context.language or "" + ) intent_response.async_set_error( intent.IntentResponseErrorCode.UNKNOWN, "Error preparing LLM API", @@ -431,10 +442,10 @@ class ChatLog: user_name: str | None = None if ( - user_input.context - and user_input.context.user_id + llm_context.context + and llm_context.context.user_id and ( - user := await self.hass.auth.async_get_user(user_input.context.user_id) + user := await self.hass.auth.async_get_user(llm_context.context.user_id) ) ): user_name = user.name @@ -444,7 +455,7 @@ class ChatLog: await self._async_expand_prompt_template( llm_context, (user_llm_prompt or llm.DEFAULT_INSTRUCTIONS_PROMPT), - user_input.language, + llm_context.language, user_name, ) ) @@ -456,14 +467,14 @@ class ChatLog: await self._async_expand_prompt_template( llm_context, llm.BASE_PROMPT, - user_input.language, + llm_context.language, user_name, ) ) if extra_system_prompt := ( # Take new system prompt if one was given - user_input.extra_system_prompt or self.extra_system_prompt + user_extra_system_prompt or self.extra_system_prompt ): prompt_parts.append(extra_system_prompt) diff --git a/homeassistant/components/conversation/models.py b/homeassistant/components/conversation/models.py index 00097f5b4d3..dac1fb862ec 100644 --- a/homeassistant/components/conversation/models.py +++ b/homeassistant/components/conversation/models.py @@ -7,7 +7,9 @@ from dataclasses import dataclass from typing import Any, Literal from homeassistant.core import Context -from homeassistant.helpers import intent +from homeassistant.helpers import intent, llm + +from .const import DOMAIN @dataclass(frozen=True) @@ -56,6 +58,16 @@ class ConversationInput: "extra_system_prompt": self.extra_system_prompt, } + def as_llm_context(self, conversing_domain: str) -> llm.LLMContext: + """Return input as an LLM context.""" + return llm.LLMContext( + platform=conversing_domain, + context=self.context, + language=self.language, + assistant=DOMAIN, + device_id=self.device_id, + ) + @dataclass(slots=True) class ConversationResult: diff --git a/homeassistant/components/cover/__init__.py b/homeassistant/components/cover/__init__.py index 85069b425e3..a77c8bf8ba3 100644 --- a/homeassistant/components/cover/__init__.py +++ b/homeassistant/components/cover/__init__.py @@ -300,10 +300,6 @@ class CoverEntity(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_): def supported_features(self) -> CoverEntityFeature: """Flag supported features.""" if (features := self._attr_supported_features) is not None: - if type(features) is int: - new_features = CoverEntityFeature(features) - self._report_deprecated_supported_features_values(new_features) - return new_features return features supported_features = ( diff --git a/homeassistant/components/devolo_home_control/__init__.py b/homeassistant/components/devolo_home_control/__init__.py index b8dc948913f..331bb5df94a 100644 --- a/homeassistant/components/devolo_home_control/__init__.py +++ b/homeassistant/components/devolo_home_control/__init__.py @@ -91,7 +91,9 @@ async def async_unload_entry( async def async_remove_config_entry_device( - hass: HomeAssistant, config_entry: ConfigEntry, device_entry: DeviceEntry + hass: HomeAssistant, + config_entry: DevoloHomeControlConfigEntry, + device_entry: DeviceEntry, ) -> bool: """Remove a config entry from a device.""" return True diff --git a/homeassistant/components/devolo_home_network/device_tracker.py b/homeassistant/components/devolo_home_network/device_tracker.py index 15ff0e5ac2a..ad3d3e1cffa 100644 --- a/homeassistant/components/devolo_home_network/device_tracker.py +++ b/homeassistant/components/devolo_home_network/device_tracker.py @@ -87,6 +87,7 @@ class DevoloScannerEntity( # pylint: disable=hass-enforce-class-module ): """Representation of a devolo device tracker.""" + _attr_has_entity_name = True _attr_translation_key = "device_tracker" def __init__( @@ -99,6 +100,7 @@ class DevoloScannerEntity( # pylint: disable=hass-enforce-class-module super().__init__(coordinator) self._device = device self._attr_mac_address = mac + self._attr_name = mac @property def extra_state_attributes(self) -> dict[str, str]: diff --git a/homeassistant/components/discord/manifest.json b/homeassistant/components/discord/manifest.json index 5f1ba2a13ef..c795c7ed2ed 100644 --- a/homeassistant/components/discord/manifest.json +++ b/homeassistant/components/discord/manifest.json @@ -7,5 +7,5 @@ "integration_type": "service", "iot_class": "cloud_push", "loggers": ["discord"], - "requirements": ["nextcord==2.6.0"] + "requirements": ["nextcord==3.1.0"] } diff --git a/homeassistant/components/edl21/manifest.json b/homeassistant/components/edl21/manifest.json index faa471e44b1..28b61c4c0e1 100644 --- a/homeassistant/components/edl21/manifest.json +++ b/homeassistant/components/edl21/manifest.json @@ -7,5 +7,5 @@ "integration_type": "hub", "iot_class": "local_push", "loggers": ["sml"], - "requirements": ["pysml==0.0.12"] + "requirements": ["pysml==0.1.5"] } diff --git a/homeassistant/components/eq3btsmart/__init__.py b/homeassistant/components/eq3btsmart/__init__.py index 4493f944db3..b4be3cf5ee9 100644 --- a/homeassistant/components/eq3btsmart/__init__.py +++ b/homeassistant/components/eq3btsmart/__init__.py @@ -6,7 +6,6 @@ from typing import TYPE_CHECKING from eq3btsmart import Thermostat from eq3btsmart.exceptions import Eq3Exception -from eq3btsmart.thermostat_config import ThermostatConfig from homeassistant.components import bluetooth from homeassistant.config_entries import ConfigEntry @@ -53,12 +52,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: Eq3ConfigEntry) -> bool: f"[{eq3_config.mac_address}] Device could not be found" ) - thermostat = Thermostat( - thermostat_config=ThermostatConfig( - mac_address=mac_address, - ), - ble_device=device, - ) + thermostat = Thermostat(mac_address=device) # type: ignore[arg-type] entry.runtime_data = Eq3ConfigEntryData( eq3_config=eq3_config, thermostat=thermostat diff --git a/homeassistant/components/eq3btsmart/binary_sensor.py b/homeassistant/components/eq3btsmart/binary_sensor.py index 55b1f4d6ced..8cec495f017 100644 --- a/homeassistant/components/eq3btsmart/binary_sensor.py +++ b/homeassistant/components/eq3btsmart/binary_sensor.py @@ -2,7 +2,6 @@ from collections.abc import Callable from dataclasses import dataclass -from typing import TYPE_CHECKING from eq3btsmart.models import Status @@ -80,7 +79,4 @@ class Eq3BinarySensorEntity(Eq3Entity, BinarySensorEntity): def is_on(self) -> bool: """Return the state of the binary sensor.""" - if TYPE_CHECKING: - assert self._thermostat.status is not None - return self.entity_description.value_func(self._thermostat.status) diff --git a/homeassistant/components/eq3btsmart/climate.py b/homeassistant/components/eq3btsmart/climate.py index 738efa99187..c11328c7ec3 100644 --- a/homeassistant/components/eq3btsmart/climate.py +++ b/homeassistant/components/eq3btsmart/climate.py @@ -1,9 +1,16 @@ """Platform for eQ-3 climate entities.""" +from datetime import timedelta import logging from typing import Any -from eq3btsmart.const import EQ3BT_MAX_TEMP, EQ3BT_OFF_TEMP, Eq3Preset, OperationMode +from eq3btsmart.const import ( + EQ3_DEFAULT_AWAY_TEMP, + EQ3_MAX_TEMP, + EQ3_OFF_TEMP, + Eq3OperationMode, + Eq3Preset, +) from eq3btsmart.exceptions import Eq3Exception from homeassistant.components.climate import ( @@ -20,9 +27,11 @@ from homeassistant.exceptions import ServiceValidationError from homeassistant.helpers import device_registry as dr from homeassistant.helpers.device_registry import CONNECTION_BLUETOOTH from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback +import homeassistant.util.dt as dt_util from . import Eq3ConfigEntry from .const import ( + DEFAULT_AWAY_HOURS, EQ_TO_HA_HVAC, HA_TO_EQ_HVAC, CurrentTemperatureSelector, @@ -57,8 +66,8 @@ class Eq3Climate(Eq3Entity, ClimateEntity): | ClimateEntityFeature.TURN_ON ) _attr_temperature_unit = UnitOfTemperature.CELSIUS - _attr_min_temp = EQ3BT_OFF_TEMP - _attr_max_temp = EQ3BT_MAX_TEMP + _attr_min_temp = EQ3_OFF_TEMP + _attr_max_temp = EQ3_MAX_TEMP _attr_precision = PRECISION_HALVES _attr_hvac_modes = list(HA_TO_EQ_HVAC.keys()) _attr_preset_modes = list(Preset) @@ -70,38 +79,21 @@ class Eq3Climate(Eq3Entity, ClimateEntity): _target_temperature: float | None = None @callback - def _async_on_updated(self) -> None: - """Handle updated data from the thermostat.""" - - if self._thermostat.status is not None: - self._async_on_status_updated() - - if self._thermostat.device_data is not None: - self._async_on_device_updated() - - super()._async_on_updated() - - @callback - def _async_on_status_updated(self) -> None: + def _async_on_status_updated(self, data: Any) -> None: """Handle updated status from the thermostat.""" - if self._thermostat.status is None: - return - - self._target_temperature = self._thermostat.status.target_temperature.value + self._target_temperature = self._thermostat.status.target_temperature self._attr_hvac_mode = EQ_TO_HA_HVAC[self._thermostat.status.operation_mode] self._attr_current_temperature = self._get_current_temperature() self._attr_target_temperature = self._get_target_temperature() self._attr_preset_mode = self._get_current_preset_mode() self._attr_hvac_action = self._get_current_hvac_action() + super()._async_on_status_updated(data) @callback - def _async_on_device_updated(self) -> None: + def _async_on_device_updated(self, data: Any) -> None: """Handle updated device data from the thermostat.""" - if self._thermostat.device_data is None: - return - device_registry = dr.async_get(self.hass) if device := device_registry.async_get_device( connections={(CONNECTION_BLUETOOTH, self._eq3_config.mac_address)}, @@ -109,8 +101,9 @@ class Eq3Climate(Eq3Entity, ClimateEntity): device_registry.async_update_device( device.id, sw_version=str(self._thermostat.device_data.firmware_version), - serial_number=self._thermostat.device_data.device_serial.value, + serial_number=self._thermostat.device_data.device_serial, ) + super()._async_on_device_updated(data) def _get_current_temperature(self) -> float | None: """Return the current temperature.""" @@ -119,17 +112,11 @@ class Eq3Climate(Eq3Entity, ClimateEntity): case CurrentTemperatureSelector.NOTHING: return None case CurrentTemperatureSelector.VALVE: - if self._thermostat.status is None: - return None - return float(self._thermostat.status.valve_temperature) case CurrentTemperatureSelector.UI: return self._target_temperature case CurrentTemperatureSelector.DEVICE: - if self._thermostat.status is None: - return None - - return float(self._thermostat.status.target_temperature.value) + return float(self._thermostat.status.target_temperature) case CurrentTemperatureSelector.ENTITY: state = self.hass.states.get(self._eq3_config.external_temp_sensor) if state is not None: @@ -147,16 +134,12 @@ class Eq3Climate(Eq3Entity, ClimateEntity): case TargetTemperatureSelector.TARGET: return self._target_temperature case TargetTemperatureSelector.LAST_REPORTED: - if self._thermostat.status is None: - return None - - return float(self._thermostat.status.target_temperature.value) + return float(self._thermostat.status.target_temperature) def _get_current_preset_mode(self) -> str: """Return the current preset mode.""" - if (status := self._thermostat.status) is None: - return PRESET_NONE + status = self._thermostat.status if status.is_window_open: return Preset.WINDOW_OPEN if status.is_boost: @@ -165,7 +148,7 @@ class Eq3Climate(Eq3Entity, ClimateEntity): return Preset.LOW_BATTERY if status.is_away: return Preset.AWAY - if status.operation_mode is OperationMode.ON: + if status.operation_mode is Eq3OperationMode.ON: return Preset.OPEN if status.presets is None: return PRESET_NONE @@ -179,10 +162,7 @@ class Eq3Climate(Eq3Entity, ClimateEntity): def _get_current_hvac_action(self) -> HVACAction: """Return the current hvac action.""" - if ( - self._thermostat.status is None - or self._thermostat.status.operation_mode is OperationMode.OFF - ): + if self._thermostat.status.operation_mode is Eq3OperationMode.OFF: return HVACAction.OFF if self._thermostat.status.valve == 0: return HVACAction.IDLE @@ -227,7 +207,7 @@ class Eq3Climate(Eq3Entity, ClimateEntity): """Set new target hvac mode.""" if hvac_mode is HVACMode.OFF: - await self.async_set_temperature(temperature=EQ3BT_OFF_TEMP) + await self.async_set_temperature(temperature=EQ3_OFF_TEMP) try: await self._thermostat.async_set_mode(HA_TO_EQ_HVAC[hvac_mode]) @@ -241,10 +221,11 @@ class Eq3Climate(Eq3Entity, ClimateEntity): case Preset.BOOST: await self._thermostat.async_set_boost(True) case Preset.AWAY: - await self._thermostat.async_set_away(True) + away_until = dt_util.now() + timedelta(hours=DEFAULT_AWAY_HOURS) + await self._thermostat.async_set_away(away_until, EQ3_DEFAULT_AWAY_TEMP) case Preset.ECO: await self._thermostat.async_set_preset(Eq3Preset.ECO) case Preset.COMFORT: await self._thermostat.async_set_preset(Eq3Preset.COMFORT) case Preset.OPEN: - await self._thermostat.async_set_mode(OperationMode.ON) + await self._thermostat.async_set_mode(Eq3OperationMode.ON) diff --git a/homeassistant/components/eq3btsmart/const.py b/homeassistant/components/eq3btsmart/const.py index a5f7ea2ff95..33698d2d076 100644 --- a/homeassistant/components/eq3btsmart/const.py +++ b/homeassistant/components/eq3btsmart/const.py @@ -2,7 +2,7 @@ from enum import Enum -from eq3btsmart.const import OperationMode +from eq3btsmart.const import Eq3OperationMode from homeassistant.components.climate import ( PRESET_AWAY, @@ -34,17 +34,17 @@ ENTITY_KEY_AWAY_UNTIL = "away_until" GET_DEVICE_TIMEOUT = 5 # seconds -EQ_TO_HA_HVAC: dict[OperationMode, HVACMode] = { - OperationMode.OFF: HVACMode.OFF, - OperationMode.ON: HVACMode.HEAT, - OperationMode.AUTO: HVACMode.AUTO, - OperationMode.MANUAL: HVACMode.HEAT, +EQ_TO_HA_HVAC: dict[Eq3OperationMode, HVACMode] = { + Eq3OperationMode.OFF: HVACMode.OFF, + Eq3OperationMode.ON: HVACMode.HEAT, + Eq3OperationMode.AUTO: HVACMode.AUTO, + Eq3OperationMode.MANUAL: HVACMode.HEAT, } HA_TO_EQ_HVAC = { - HVACMode.OFF: OperationMode.OFF, - HVACMode.AUTO: OperationMode.AUTO, - HVACMode.HEAT: OperationMode.MANUAL, + HVACMode.OFF: Eq3OperationMode.OFF, + HVACMode.AUTO: Eq3OperationMode.AUTO, + HVACMode.HEAT: Eq3OperationMode.MANUAL, } @@ -81,6 +81,7 @@ class TargetTemperatureSelector(str, Enum): DEFAULT_CURRENT_TEMP_SELECTOR = CurrentTemperatureSelector.DEVICE DEFAULT_TARGET_TEMP_SELECTOR = TargetTemperatureSelector.TARGET DEFAULT_SCAN_INTERVAL = 10 # seconds +DEFAULT_AWAY_HOURS = 30 * 24 SIGNAL_THERMOSTAT_DISCONNECTED = f"{DOMAIN}.thermostat_disconnected" SIGNAL_THERMOSTAT_CONNECTED = f"{DOMAIN}.thermostat_connected" diff --git a/homeassistant/components/eq3btsmart/entity.py b/homeassistant/components/eq3btsmart/entity.py index e68545c08c7..e8dbb934289 100644 --- a/homeassistant/components/eq3btsmart/entity.py +++ b/homeassistant/components/eq3btsmart/entity.py @@ -1,5 +1,10 @@ """Base class for all eQ-3 entities.""" +from typing import Any + +from eq3btsmart import Eq3Exception +from eq3btsmart.const import Eq3Event + from homeassistant.core import callback from homeassistant.helpers.device_registry import ( CONNECTION_BLUETOOTH, @@ -45,7 +50,15 @@ class Eq3Entity(Entity): async def async_added_to_hass(self) -> None: """Run when entity about to be added to hass.""" - self._thermostat.register_update_callback(self._async_on_updated) + self._thermostat.register_callback( + Eq3Event.DEVICE_DATA_RECEIVED, self._async_on_device_updated + ) + self._thermostat.register_callback( + Eq3Event.STATUS_RECEIVED, self._async_on_status_updated + ) + self._thermostat.register_callback( + Eq3Event.SCHEDULE_RECEIVED, self._async_on_status_updated + ) self.async_on_remove( async_dispatcher_connect( @@ -65,10 +78,25 @@ class Eq3Entity(Entity): async def async_will_remove_from_hass(self) -> None: """Run when entity will be removed from hass.""" - self._thermostat.unregister_update_callback(self._async_on_updated) + self._thermostat.unregister_callback( + Eq3Event.DEVICE_DATA_RECEIVED, self._async_on_device_updated + ) + self._thermostat.unregister_callback( + Eq3Event.STATUS_RECEIVED, self._async_on_status_updated + ) + self._thermostat.unregister_callback( + Eq3Event.SCHEDULE_RECEIVED, self._async_on_status_updated + ) - def _async_on_updated(self) -> None: - """Handle updated data from the thermostat.""" + @callback + def _async_on_status_updated(self, data: Any) -> None: + """Handle updated status from the thermostat.""" + + self.async_write_ha_state() + + @callback + def _async_on_device_updated(self, data: Any) -> None: + """Handle updated device data from the thermostat.""" self.async_write_ha_state() @@ -90,4 +118,9 @@ class Eq3Entity(Entity): def available(self) -> bool: """Whether the entity is available.""" - return self._thermostat.status is not None and self._attr_available + try: + _ = self._thermostat.status + except Eq3Exception: + return False + + return self._attr_available diff --git a/homeassistant/components/eq3btsmart/manifest.json b/homeassistant/components/eq3btsmart/manifest.json index 889401ffc3e..62128077f2f 100644 --- a/homeassistant/components/eq3btsmart/manifest.json +++ b/homeassistant/components/eq3btsmart/manifest.json @@ -22,5 +22,5 @@ "integration_type": "device", "iot_class": "local_polling", "loggers": ["eq3btsmart"], - "requirements": ["eq3btsmart==1.4.1", "bleak-esphome==2.16.0"] + "requirements": ["eq3btsmart==2.1.0", "bleak-esphome==2.16.0"] } diff --git a/homeassistant/components/eq3btsmart/number.py b/homeassistant/components/eq3btsmart/number.py index c3cbd8eae31..c9601a4437e 100644 --- a/homeassistant/components/eq3btsmart/number.py +++ b/homeassistant/components/eq3btsmart/number.py @@ -1,17 +1,12 @@ """Platform for eq3 number entities.""" -from collections.abc import Awaitable, Callable +from collections.abc import Callable, Coroutine from dataclasses import dataclass from typing import TYPE_CHECKING from eq3btsmart import Thermostat -from eq3btsmart.const import ( - EQ3BT_MAX_OFFSET, - EQ3BT_MAX_TEMP, - EQ3BT_MIN_OFFSET, - EQ3BT_MIN_TEMP, -) -from eq3btsmart.models import Presets +from eq3btsmart.const import EQ3_MAX_OFFSET, EQ3_MAX_TEMP, EQ3_MIN_OFFSET, EQ3_MIN_TEMP +from eq3btsmart.models import Presets, Status from homeassistant.components.number import ( NumberDeviceClass, @@ -42,7 +37,7 @@ class Eq3NumberEntityDescription(NumberEntityDescription): value_func: Callable[[Presets], float] value_set_func: Callable[ [Thermostat], - Callable[[float], Awaitable[None]], + Callable[[float], Coroutine[None, None, Status]], ] mode: NumberMode = NumberMode.BOX entity_category: EntityCategory | None = EntityCategory.CONFIG @@ -51,44 +46,44 @@ class Eq3NumberEntityDescription(NumberEntityDescription): NUMBER_ENTITY_DESCRIPTIONS = [ Eq3NumberEntityDescription( key=ENTITY_KEY_COMFORT, - value_func=lambda presets: presets.comfort_temperature.value, + value_func=lambda presets: presets.comfort_temperature, value_set_func=lambda thermostat: thermostat.async_configure_comfort_temperature, translation_key=ENTITY_KEY_COMFORT, - native_min_value=EQ3BT_MIN_TEMP, - native_max_value=EQ3BT_MAX_TEMP, + native_min_value=EQ3_MIN_TEMP, + native_max_value=EQ3_MAX_TEMP, native_step=EQ3BT_STEP, native_unit_of_measurement=UnitOfTemperature.CELSIUS, device_class=NumberDeviceClass.TEMPERATURE, ), Eq3NumberEntityDescription( key=ENTITY_KEY_ECO, - value_func=lambda presets: presets.eco_temperature.value, + value_func=lambda presets: presets.eco_temperature, value_set_func=lambda thermostat: thermostat.async_configure_eco_temperature, translation_key=ENTITY_KEY_ECO, - native_min_value=EQ3BT_MIN_TEMP, - native_max_value=EQ3BT_MAX_TEMP, + native_min_value=EQ3_MIN_TEMP, + native_max_value=EQ3_MAX_TEMP, native_step=EQ3BT_STEP, native_unit_of_measurement=UnitOfTemperature.CELSIUS, device_class=NumberDeviceClass.TEMPERATURE, ), Eq3NumberEntityDescription( key=ENTITY_KEY_WINDOW_OPEN_TEMPERATURE, - value_func=lambda presets: presets.window_open_temperature.value, + value_func=lambda presets: presets.window_open_temperature, value_set_func=lambda thermostat: thermostat.async_configure_window_open_temperature, translation_key=ENTITY_KEY_WINDOW_OPEN_TEMPERATURE, - native_min_value=EQ3BT_MIN_TEMP, - native_max_value=EQ3BT_MAX_TEMP, + native_min_value=EQ3_MIN_TEMP, + native_max_value=EQ3_MAX_TEMP, native_step=EQ3BT_STEP, native_unit_of_measurement=UnitOfTemperature.CELSIUS, device_class=NumberDeviceClass.TEMPERATURE, ), Eq3NumberEntityDescription( key=ENTITY_KEY_OFFSET, - value_func=lambda presets: presets.offset_temperature.value, + value_func=lambda presets: presets.offset_temperature, value_set_func=lambda thermostat: thermostat.async_configure_temperature_offset, translation_key=ENTITY_KEY_OFFSET, - native_min_value=EQ3BT_MIN_OFFSET, - native_max_value=EQ3BT_MAX_OFFSET, + native_min_value=EQ3_MIN_OFFSET, + native_max_value=EQ3_MAX_OFFSET, native_step=EQ3BT_STEP, native_unit_of_measurement=UnitOfTemperature.CELSIUS, device_class=NumberDeviceClass.TEMPERATURE, @@ -96,7 +91,7 @@ NUMBER_ENTITY_DESCRIPTIONS = [ Eq3NumberEntityDescription( key=ENTITY_KEY_WINDOW_OPEN_TIMEOUT, value_set_func=lambda thermostat: thermostat.async_configure_window_open_duration, - value_func=lambda presets: presets.window_open_time.value.total_seconds() / 60, + value_func=lambda presets: presets.window_open_time.total_seconds() / 60, translation_key=ENTITY_KEY_WINDOW_OPEN_TIMEOUT, native_min_value=0, native_max_value=60, @@ -137,7 +132,6 @@ class Eq3NumberEntity(Eq3Entity, NumberEntity): """Return the state of the entity.""" if TYPE_CHECKING: - assert self._thermostat.status is not None assert self._thermostat.status.presets is not None return self.entity_description.value_func(self._thermostat.status.presets) @@ -152,7 +146,7 @@ class Eq3NumberEntity(Eq3Entity, NumberEntity): """Return whether the entity is available.""" return ( - self._thermostat.status is not None + super().available and self._thermostat.status.presets is not None and self._attr_available ) diff --git a/homeassistant/components/eq3btsmart/schemas.py b/homeassistant/components/eq3btsmart/schemas.py index 643bb4a02a6..daeed5a05e3 100644 --- a/homeassistant/components/eq3btsmart/schemas.py +++ b/homeassistant/components/eq3btsmart/schemas.py @@ -1,12 +1,12 @@ """Voluptuous schemas for eq3btsmart.""" -from eq3btsmart.const import EQ3BT_MAX_TEMP, EQ3BT_MIN_TEMP +from eq3btsmart.const import EQ3_MAX_TEMP, EQ3_MIN_TEMP import voluptuous as vol from homeassistant.const import CONF_MAC from homeassistant.helpers import config_validation as cv -SCHEMA_TEMPERATURE = vol.Range(min=EQ3BT_MIN_TEMP, max=EQ3BT_MAX_TEMP) +SCHEMA_TEMPERATURE = vol.Range(min=EQ3_MIN_TEMP, max=EQ3_MAX_TEMP) SCHEMA_DEVICE = vol.Schema({vol.Required(CONF_MAC): cv.string}) SCHEMA_MAC = vol.Schema( { diff --git a/homeassistant/components/eq3btsmart/sensor.py b/homeassistant/components/eq3btsmart/sensor.py index aab3cbf1925..0f61ef22452 100644 --- a/homeassistant/components/eq3btsmart/sensor.py +++ b/homeassistant/components/eq3btsmart/sensor.py @@ -3,7 +3,6 @@ from collections.abc import Callable from dataclasses import dataclass from datetime import datetime -from typing import TYPE_CHECKING from eq3btsmart.models import Status @@ -40,9 +39,7 @@ SENSOR_ENTITY_DESCRIPTIONS = [ Eq3SensorEntityDescription( key=ENTITY_KEY_AWAY_UNTIL, translation_key=ENTITY_KEY_AWAY_UNTIL, - value_func=lambda status: ( - status.away_until.value if status.away_until else None - ), + value_func=lambda status: (status.away_until if status.away_until else None), device_class=SensorDeviceClass.DATE, ), ] @@ -78,7 +75,4 @@ class Eq3SensorEntity(Eq3Entity, SensorEntity): def native_value(self) -> int | datetime | None: """Return the value reported by the sensor.""" - if TYPE_CHECKING: - assert self._thermostat.status is not None - return self.entity_description.value_func(self._thermostat.status) diff --git a/homeassistant/components/eq3btsmart/switch.py b/homeassistant/components/eq3btsmart/switch.py index 61da133cb71..0d5521fee32 100644 --- a/homeassistant/components/eq3btsmart/switch.py +++ b/homeassistant/components/eq3btsmart/switch.py @@ -1,26 +1,45 @@ """Platform for eq3 switch entities.""" -from collections.abc import Awaitable, Callable +from collections.abc import Callable, Coroutine from dataclasses import dataclass -from typing import TYPE_CHECKING, Any +from datetime import timedelta +from functools import partial +from typing import Any from eq3btsmart import Thermostat +from eq3btsmart.const import EQ3_DEFAULT_AWAY_TEMP, Eq3OperationMode from eq3btsmart.models import Status from homeassistant.components.switch import SwitchEntity, SwitchEntityDescription from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback +import homeassistant.util.dt as dt_util from . import Eq3ConfigEntry -from .const import ENTITY_KEY_AWAY, ENTITY_KEY_BOOST, ENTITY_KEY_LOCK +from .const import ( + DEFAULT_AWAY_HOURS, + ENTITY_KEY_AWAY, + ENTITY_KEY_BOOST, + ENTITY_KEY_LOCK, +) from .entity import Eq3Entity +async def async_set_away(thermostat: Thermostat, enable: bool) -> Status: + """Backport old async_set_away behavior.""" + + if not enable: + return await thermostat.async_set_mode(Eq3OperationMode.AUTO) + + away_until = dt_util.now() + timedelta(hours=DEFAULT_AWAY_HOURS) + return await thermostat.async_set_away(away_until, EQ3_DEFAULT_AWAY_TEMP) + + @dataclass(frozen=True, kw_only=True) class Eq3SwitchEntityDescription(SwitchEntityDescription): """Entity description for eq3 switch entities.""" - toggle_func: Callable[[Thermostat], Callable[[bool], Awaitable[None]]] + toggle_func: Callable[[Thermostat], Callable[[bool], Coroutine[None, None, Status]]] value_func: Callable[[Status], bool] @@ -40,7 +59,7 @@ SWITCH_ENTITY_DESCRIPTIONS = [ Eq3SwitchEntityDescription( key=ENTITY_KEY_AWAY, translation_key=ENTITY_KEY_AWAY, - toggle_func=lambda thermostat: thermostat.async_set_away, + toggle_func=lambda thermostat: partial(async_set_away, thermostat), value_func=lambda status: status.is_away, ), ] @@ -88,7 +107,4 @@ class Eq3SwitchEntity(Eq3Entity, SwitchEntity): def is_on(self) -> bool: """Return the state of the switch.""" - if TYPE_CHECKING: - assert self._thermostat.status is not None - return self.entity_description.value_func(self._thermostat.status) diff --git a/homeassistant/components/esphome/assist_satellite.py b/homeassistant/components/esphome/assist_satellite.py index 073a1ec8ae9..53d5d449be8 100644 --- a/homeassistant/components/esphome/assist_satellite.py +++ b/homeassistant/components/esphome/assist_satellite.py @@ -332,7 +332,7 @@ class EsphomeAssistSatellite( } elif event_type == VoiceAssistantEventType.VOICE_ASSISTANT_RUN_START: assert event.data is not None - if tts_output := event.data["tts_output"]: + if tts_output := event.data.get("tts_output"): path = tts_output["url"] url = async_process_play_media_url(self.hass, path) data_to_send = {"url": url} diff --git a/homeassistant/components/goalzero/sensor.py b/homeassistant/components/goalzero/sensor.py index 7b5f8955947..67441930f7a 100644 --- a/homeassistant/components/goalzero/sensor.py +++ b/homeassistant/components/goalzero/sensor.py @@ -109,6 +109,7 @@ SENSOR_TYPES: tuple[SensorEntityDescription, ...] = ( SensorEntityDescription( key="timestamp", translation_key="timestamp", + device_class=SensorDeviceClass.DURATION, native_unit_of_measurement=UnitOfTime.SECONDS, entity_registry_enabled_default=False, entity_category=EntityCategory.DIAGNOSTIC, diff --git a/homeassistant/components/google/manifest.json b/homeassistant/components/google/manifest.json index fecd245869a..1acfa3a2ad1 100644 --- a/homeassistant/components/google/manifest.json +++ b/homeassistant/components/google/manifest.json @@ -7,5 +7,5 @@ "documentation": "https://www.home-assistant.io/integrations/google", "iot_class": "cloud_polling", "loggers": ["googleapiclient"], - "requirements": ["gcal-sync==7.1.0", "oauth2client==4.1.3", "ical==10.0.0"] + "requirements": ["gcal-sync==7.1.0", "oauth2client==4.1.3", "ical==10.0.4"] } diff --git a/homeassistant/components/google_generative_ai_conversation/__init__.py b/homeassistant/components/google_generative_ai_conversation/__init__.py index 79d092a60c3..7e9ca550275 100644 --- a/homeassistant/components/google_generative_ai_conversation/__init__.py +++ b/homeassistant/components/google_generative_ai_conversation/__init__.py @@ -45,7 +45,10 @@ CONF_IMAGE_FILENAME = "image_filename" CONF_FILENAMES = "filenames" CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN) -PLATFORMS = (Platform.CONVERSATION,) +PLATFORMS = ( + Platform.CONVERSATION, + Platform.TTS, +) type GoogleGenerativeAIConfigEntry = ConfigEntry[Client] diff --git a/homeassistant/components/google_generative_ai_conversation/const.py b/homeassistant/components/google_generative_ai_conversation/const.py index 239b3ff763e..831e7d8f508 100644 --- a/homeassistant/components/google_generative_ai_conversation/const.py +++ b/homeassistant/components/google_generative_ai_conversation/const.py @@ -6,9 +6,11 @@ DOMAIN = "google_generative_ai_conversation" LOGGER = logging.getLogger(__package__) CONF_PROMPT = "prompt" +ATTR_MODEL = "model" CONF_RECOMMENDED = "recommended" CONF_CHAT_MODEL = "chat_model" RECOMMENDED_CHAT_MODEL = "models/gemini-2.0-flash" +RECOMMENDED_TTS_MODEL = "gemini-2.5-flash-preview-tts" CONF_TEMPERATURE = "temperature" RECOMMENDED_TEMPERATURE = 1.0 CONF_TOP_P = "top_p" diff --git a/homeassistant/components/google_generative_ai_conversation/conversation.py b/homeassistant/components/google_generative_ai_conversation/conversation.py index 1038377af68..00199f5fe1f 100644 --- a/homeassistant/components/google_generative_ai_conversation/conversation.py +++ b/homeassistant/components/google_generative_ai_conversation/conversation.py @@ -2,63 +2,18 @@ from __future__ import annotations -import codecs -from collections.abc import AsyncGenerator, Callable -from dataclasses import replace -from typing import Any, Literal, cast - -from google.genai.errors import APIError, ClientError -from google.genai.types import ( - AutomaticFunctionCallingConfig, - Content, - FunctionDeclaration, - GenerateContentConfig, - GenerateContentResponse, - GoogleSearch, - HarmCategory, - Part, - SafetySetting, - Schema, - Tool, -) -from voluptuous_openapi import convert +from typing import Literal from homeassistant.components import assist_pipeline, conversation from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_LLM_HASS_API, MATCH_ALL from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers import device_registry as dr, intent, llm +from homeassistant.helpers import intent from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from .const import ( - CONF_CHAT_MODEL, - CONF_DANGEROUS_BLOCK_THRESHOLD, - CONF_HARASSMENT_BLOCK_THRESHOLD, - CONF_HATE_BLOCK_THRESHOLD, - CONF_MAX_TOKENS, - CONF_PROMPT, - CONF_SEXUAL_BLOCK_THRESHOLD, - CONF_TEMPERATURE, - CONF_TOP_K, - CONF_TOP_P, - CONF_USE_GOOGLE_SEARCH_TOOL, - DOMAIN, - LOGGER, - RECOMMENDED_CHAT_MODEL, - RECOMMENDED_HARM_BLOCK_THRESHOLD, - RECOMMENDED_MAX_TOKENS, - RECOMMENDED_TEMPERATURE, - RECOMMENDED_TOP_K, - RECOMMENDED_TOP_P, -) - -# Max number of back and forth with the LLM to generate a response -MAX_TOOL_ITERATIONS = 10 - -ERROR_GETTING_RESPONSE = ( - "Sorry, I had a problem getting a response from Google Generative AI." -) +from .const import CONF_PROMPT, DOMAIN, LOGGER +from .entity import ERROR_GETTING_RESPONSE, GoogleGenerativeAILLMBaseEntity async def async_setup_entry( @@ -71,267 +26,18 @@ async def async_setup_entry( async_add_entities([agent]) -SUPPORTED_SCHEMA_KEYS = { - # Gemini API does not support all of the OpenAPI schema - # SoT: https://ai.google.dev/api/caching#Schema - "type", - "format", - "description", - "nullable", - "enum", - "max_items", - "min_items", - "properties", - "required", - "items", -} - - -def _camel_to_snake(name: str) -> str: - """Convert camel case to snake case.""" - return "".join(["_" + c.lower() if c.isupper() else c for c in name]).lstrip("_") - - -def _format_schema(schema: dict[str, Any]) -> Schema: - """Format the schema to be compatible with Gemini API.""" - if subschemas := schema.get("allOf"): - for subschema in subschemas: # Gemini API does not support allOf keys - if "type" in subschema: # Fallback to first subschema with 'type' field - return _format_schema(subschema) - return _format_schema( - subschemas[0] - ) # Or, if not found, to any of the subschemas - - result = {} - for key, val in schema.items(): - key = _camel_to_snake(key) - if key not in SUPPORTED_SCHEMA_KEYS: - continue - if key == "type": - val = val.upper() - elif key == "format": - # Gemini API does not support all formats, see: https://ai.google.dev/api/caching#Schema - # formats that are not supported are ignored - if schema.get("type") == "string" and val not in ("enum", "date-time"): - continue - if schema.get("type") == "number" and val not in ("float", "double"): - continue - if schema.get("type") == "integer" and val not in ("int32", "int64"): - continue - if schema.get("type") not in ("string", "number", "integer"): - continue - elif key == "items": - val = _format_schema(val) - elif key == "properties": - val = {k: _format_schema(v) for k, v in val.items()} - result[key] = val - - if result.get("enum") and result.get("type") != "STRING": - # enum is only allowed for STRING type. This is safe as long as the schema - # contains vol.Coerce for the respective type, for example: - # vol.All(vol.Coerce(int), vol.In([1, 2, 3])) - result["type"] = "STRING" - result["enum"] = [str(item) for item in result["enum"]] - - if result.get("type") == "OBJECT" and not result.get("properties"): - # An object with undefined properties is not supported by Gemini API. - # Fallback to JSON string. This will probably fail for most tools that want it, - # but we don't have a better fallback strategy so far. - result["properties"] = {"json": {"type": "STRING"}} - result["required"] = [] - return cast(Schema, result) - - -def _format_tool( - tool: llm.Tool, custom_serializer: Callable[[Any], Any] | None -) -> Tool: - """Format tool specification.""" - - if tool.parameters.schema: - parameters = _format_schema( - convert(tool.parameters, custom_serializer=custom_serializer) - ) - else: - parameters = None - - return Tool( - function_declarations=[ - FunctionDeclaration( - name=tool.name, - description=tool.description, - parameters=parameters, - ) - ] - ) - - -def _escape_decode(value: Any) -> Any: - """Recursively call codecs.escape_decode on all values.""" - if isinstance(value, str): - return codecs.escape_decode(bytes(value, "utf-8"))[0].decode("utf-8") # type: ignore[attr-defined] - if isinstance(value, list): - return [_escape_decode(item) for item in value] - if isinstance(value, dict): - return {k: _escape_decode(v) for k, v in value.items()} - return value - - -def _create_google_tool_response_parts( - parts: list[conversation.ToolResultContent], -) -> list[Part]: - """Create Google tool response parts.""" - return [ - Part.from_function_response( - name=tool_result.tool_name, response=tool_result.tool_result - ) - for tool_result in parts - ] - - -def _create_google_tool_response_content( - content: list[conversation.ToolResultContent], -) -> Content: - """Create a Google tool response content.""" - return Content( - role="user", - parts=_create_google_tool_response_parts(content), - ) - - -def _convert_content( - content: ( - conversation.UserContent - | conversation.AssistantContent - | conversation.SystemContent - ), -) -> Content: - """Convert HA content to Google content.""" - if content.role != "assistant" or not content.tool_calls: - role = "model" if content.role == "assistant" else content.role - return Content( - role=role, - parts=[ - Part.from_text(text=content.content if content.content else ""), - ], - ) - - # Handle the Assistant content with tool calls. - assert type(content) is conversation.AssistantContent - parts: list[Part] = [] - - if content.content: - parts.append(Part.from_text(text=content.content)) - - if content.tool_calls: - parts.extend( - [ - Part.from_function_call( - name=tool_call.tool_name, - args=_escape_decode(tool_call.tool_args), - ) - for tool_call in content.tool_calls - ] - ) - - return Content(role="model", parts=parts) - - -async def _transform_stream( - result: AsyncGenerator[GenerateContentResponse], -) -> AsyncGenerator[conversation.AssistantContentDeltaDict]: - new_message = True - try: - async for response in result: - LOGGER.debug("Received response chunk: %s", response) - chunk: conversation.AssistantContentDeltaDict = {} - - if new_message: - chunk["role"] = "assistant" - new_message = False - - # According to the API docs, this would mean no candidate is returned, so we can safely throw an error here. - if response.prompt_feedback or not response.candidates: - reason = ( - response.prompt_feedback.block_reason_message - if response.prompt_feedback - else "unknown" - ) - raise HomeAssistantError( - f"The message got blocked due to content violations, reason: {reason}" - ) - - candidate = response.candidates[0] - - if ( - candidate.finish_reason is not None - and candidate.finish_reason != "STOP" - ): - # The message ended due to a content error as explained in: https://ai.google.dev/api/generate-content#FinishReason - LOGGER.error( - "Error in Google Generative AI response: %s, see: https://ai.google.dev/api/generate-content#FinishReason", - candidate.finish_reason, - ) - raise HomeAssistantError( - f"{ERROR_GETTING_RESPONSE} Reason: {candidate.finish_reason}" - ) - - response_parts = ( - candidate.content.parts - if candidate.content is not None and candidate.content.parts is not None - else [] - ) - - content = "".join([part.text for part in response_parts if part.text]) - tool_calls = [] - for part in response_parts: - if not part.function_call: - continue - tool_call = part.function_call - tool_name = tool_call.name if tool_call.name else "" - tool_args = _escape_decode(tool_call.args) - tool_calls.append( - llm.ToolInput(tool_name=tool_name, tool_args=tool_args) - ) - - if tool_calls: - chunk["tool_calls"] = tool_calls - - chunk["content"] = content - yield chunk - except ( - APIError, - ValueError, - ) as err: - LOGGER.error("Error sending message: %s %s", type(err), err) - if isinstance(err, APIError): - message = err.message - else: - message = type(err).__name__ - error = f"{ERROR_GETTING_RESPONSE}: {message}" - raise HomeAssistantError(error) from err - - class GoogleGenerativeAIConversationEntity( - conversation.ConversationEntity, conversation.AbstractConversationAgent + conversation.ConversationEntity, + conversation.AbstractConversationAgent, + GoogleGenerativeAILLMBaseEntity, ): """Google Generative AI conversation agent.""" - _attr_has_entity_name = True - _attr_name = None _attr_supports_streaming = True def __init__(self, entry: ConfigEntry) -> None: """Initialize the agent.""" - self.entry = entry - self._genai_client = entry.runtime_data - self._attr_unique_id = entry.entry_id - self._attr_device_info = dr.DeviceInfo( - identifiers={(DOMAIN, entry.entry_id)}, - name=entry.title, - manufacturer="Google", - model="Generative AI", - entry_type=dr.DeviceEntryType.SERVICE, - ) + super().__init__(entry) if self.entry.options.get(CONF_LLM_HASS_API): self._attr_supported_features = ( conversation.ConversationEntityFeature.CONTROL @@ -358,13 +64,6 @@ class GoogleGenerativeAIConversationEntity( conversation.async_unset_agent(self.hass, self.entry) await super().async_will_remove_from_hass() - def _fix_tool_name(self, tool_name: str) -> str: - """Fix tool name if needed.""" - # The Gemini 2.0+ tokenizer seemingly has a issue with the HassListAddItem tool - # name. This makes sure when it incorrectly changes the name, that we change it - # back for HA to call. - return tool_name if tool_name != "HasListAddItem" else "HassListAddItem" - async def _async_handle_message( self, user_input: conversation.ConversationInput, @@ -374,11 +73,11 @@ class GoogleGenerativeAIConversationEntity( options = self.entry.options try: - await chat_log.async_update_llm_data( - DOMAIN, - user_input, + await chat_log.async_provide_llm_data( + user_input.as_llm_context(DOMAIN), options.get(CONF_LLM_HASS_API), options.get(CONF_PROMPT), + user_input.extra_system_prompt, ) except conversation.ConverseError as err: return err.as_conversation_result() @@ -399,163 +98,6 @@ class GoogleGenerativeAIConversationEntity( continue_conversation=chat_log.continue_conversation, ) - async def _async_handle_chat_log( - self, - chat_log: conversation.ChatLog, - ) -> None: - """Generate an answer for the chat log.""" - options = self.entry.options - - tools: list[Tool | Callable[..., Any]] | None = None - if chat_log.llm_api: - tools = [ - _format_tool(tool, chat_log.llm_api.custom_serializer) - for tool in chat_log.llm_api.tools - ] - - # Using search grounding allows the model to retrieve information from the web, - # however, it may interfere with how the model decides to use some tools, or entities - # for example weather entity may be disregarded if the model chooses to Google it. - if options.get(CONF_USE_GOOGLE_SEARCH_TOOL) is True: - tools = tools or [] - tools.append(Tool(google_search=GoogleSearch())) - - model_name = self.entry.options.get(CONF_CHAT_MODEL, RECOMMENDED_CHAT_MODEL) - # Avoid INVALID_ARGUMENT Developer instruction is not enabled for - supports_system_instruction = ( - "gemma" not in model_name - and "gemini-2.0-flash-preview-image-generation" not in model_name - ) - - prompt_content = cast( - conversation.SystemContent, - chat_log.content[0], - ) - - if prompt_content.content: - prompt = prompt_content.content - else: - raise HomeAssistantError("Invalid prompt content") - - messages: list[Content] = [] - - # Google groups tool results, we do not. Group them before sending. - tool_results: list[conversation.ToolResultContent] = [] - - for chat_content in chat_log.content[1:-1]: - if chat_content.role == "tool_result": - tool_results.append(chat_content) - continue - - if ( - not isinstance(chat_content, conversation.ToolResultContent) - and chat_content.content == "" - ): - # Skipping is not possible since the number of function calls need to match the number of function responses - # and skipping one would mean removing the other and hence this would prevent a proper chat log - chat_content = replace(chat_content, content=" ") - - if tool_results: - messages.append(_create_google_tool_response_content(tool_results)) - tool_results.clear() - - messages.append(_convert_content(chat_content)) - - # The SDK requires the first message to be a user message - # This is not the case if user used `start_conversation` - # Workaround from https://github.com/googleapis/python-genai/issues/529#issuecomment-2740964537 - if messages and messages[0].role != "user": - messages.insert( - 0, - Content(role="user", parts=[Part.from_text(text=" ")]), - ) - - if tool_results: - messages.append(_create_google_tool_response_content(tool_results)) - generateContentConfig = GenerateContentConfig( - temperature=self.entry.options.get( - CONF_TEMPERATURE, RECOMMENDED_TEMPERATURE - ), - top_k=self.entry.options.get(CONF_TOP_K, RECOMMENDED_TOP_K), - top_p=self.entry.options.get(CONF_TOP_P, RECOMMENDED_TOP_P), - max_output_tokens=self.entry.options.get( - CONF_MAX_TOKENS, RECOMMENDED_MAX_TOKENS - ), - safety_settings=[ - SafetySetting( - category=HarmCategory.HARM_CATEGORY_HATE_SPEECH, - threshold=self.entry.options.get( - CONF_HATE_BLOCK_THRESHOLD, RECOMMENDED_HARM_BLOCK_THRESHOLD - ), - ), - SafetySetting( - category=HarmCategory.HARM_CATEGORY_HARASSMENT, - threshold=self.entry.options.get( - CONF_HARASSMENT_BLOCK_THRESHOLD, - RECOMMENDED_HARM_BLOCK_THRESHOLD, - ), - ), - SafetySetting( - category=HarmCategory.HARM_CATEGORY_DANGEROUS_CONTENT, - threshold=self.entry.options.get( - CONF_DANGEROUS_BLOCK_THRESHOLD, RECOMMENDED_HARM_BLOCK_THRESHOLD - ), - ), - SafetySetting( - category=HarmCategory.HARM_CATEGORY_SEXUALLY_EXPLICIT, - threshold=self.entry.options.get( - CONF_SEXUAL_BLOCK_THRESHOLD, RECOMMENDED_HARM_BLOCK_THRESHOLD - ), - ), - ], - tools=tools or None, - system_instruction=prompt if supports_system_instruction else None, - automatic_function_calling=AutomaticFunctionCallingConfig( - disable=True, maximum_remote_calls=None - ), - ) - - if not supports_system_instruction: - messages = [ - Content(role="user", parts=[Part.from_text(text=prompt)]), - Content(role="model", parts=[Part.from_text(text="Ok")]), - *messages, - ] - chat = self._genai_client.aio.chats.create( - model=model_name, history=messages, config=generateContentConfig - ) - user_message = chat_log.content[-1] - assert isinstance(user_message, conversation.UserContent) - chat_request: str | list[Part] = user_message.content - # To prevent infinite loops, we limit the number of iterations - for _iteration in range(MAX_TOOL_ITERATIONS): - try: - chat_response_generator = await chat.send_message_stream( - message=chat_request - ) - except ( - APIError, - ClientError, - ValueError, - ) as err: - LOGGER.error("Error sending message: %s %s", type(err), err) - error = ERROR_GETTING_RESPONSE - raise HomeAssistantError(error) from err - - chat_request = _create_google_tool_response_parts( - [ - content - async for content in chat_log.async_add_delta_content_stream( - self.entity_id, - _transform_stream(chat_response_generator), - ) - if isinstance(content, conversation.ToolResultContent) - ] - ) - - if not chat_log.unresponded_tool_results: - break - async def _async_entry_update_listener( self, hass: HomeAssistant, entry: ConfigEntry ) -> None: diff --git a/homeassistant/components/google_generative_ai_conversation/entity.py b/homeassistant/components/google_generative_ai_conversation/entity.py new file mode 100644 index 00000000000..7eef3dbacff --- /dev/null +++ b/homeassistant/components/google_generative_ai_conversation/entity.py @@ -0,0 +1,475 @@ +"""Conversation support for the Google Generative AI Conversation integration.""" + +from __future__ import annotations + +import codecs +from collections.abc import AsyncGenerator, Callable +from dataclasses import replace +from typing import Any, cast + +from google.genai.errors import APIError, ClientError +from google.genai.types import ( + AutomaticFunctionCallingConfig, + Content, + FunctionDeclaration, + GenerateContentConfig, + GenerateContentResponse, + GoogleSearch, + HarmCategory, + Part, + SafetySetting, + Schema, + Tool, +) +from voluptuous_openapi import convert + +from homeassistant.components import conversation +from homeassistant.config_entries import ConfigEntry +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers import device_registry as dr, llm +from homeassistant.helpers.entity import Entity + +from .const import ( + CONF_CHAT_MODEL, + CONF_DANGEROUS_BLOCK_THRESHOLD, + CONF_HARASSMENT_BLOCK_THRESHOLD, + CONF_HATE_BLOCK_THRESHOLD, + CONF_MAX_TOKENS, + CONF_SEXUAL_BLOCK_THRESHOLD, + CONF_TEMPERATURE, + CONF_TOP_K, + CONF_TOP_P, + CONF_USE_GOOGLE_SEARCH_TOOL, + DOMAIN, + LOGGER, + RECOMMENDED_CHAT_MODEL, + RECOMMENDED_HARM_BLOCK_THRESHOLD, + RECOMMENDED_MAX_TOKENS, + RECOMMENDED_TEMPERATURE, + RECOMMENDED_TOP_K, + RECOMMENDED_TOP_P, +) + +# Max number of back and forth with the LLM to generate a response +MAX_TOOL_ITERATIONS = 10 + +ERROR_GETTING_RESPONSE = ( + "Sorry, I had a problem getting a response from Google Generative AI." +) + + +SUPPORTED_SCHEMA_KEYS = { + # Gemini API does not support all of the OpenAPI schema + # SoT: https://ai.google.dev/api/caching#Schema + "type", + "format", + "description", + "nullable", + "enum", + "max_items", + "min_items", + "properties", + "required", + "items", +} + + +def _camel_to_snake(name: str) -> str: + """Convert camel case to snake case.""" + return "".join(["_" + c.lower() if c.isupper() else c for c in name]).lstrip("_") + + +def _format_schema(schema: dict[str, Any]) -> Schema: + """Format the schema to be compatible with Gemini API.""" + if subschemas := schema.get("allOf"): + for subschema in subschemas: # Gemini API does not support allOf keys + if "type" in subschema: # Fallback to first subschema with 'type' field + return _format_schema(subschema) + return _format_schema( + subschemas[0] + ) # Or, if not found, to any of the subschemas + + result = {} + for key, val in schema.items(): + key = _camel_to_snake(key) + if key not in SUPPORTED_SCHEMA_KEYS: + continue + if key == "type": + val = val.upper() + elif key == "format": + # Gemini API does not support all formats, see: https://ai.google.dev/api/caching#Schema + # formats that are not supported are ignored + if schema.get("type") == "string" and val not in ("enum", "date-time"): + continue + if schema.get("type") == "number" and val not in ("float", "double"): + continue + if schema.get("type") == "integer" and val not in ("int32", "int64"): + continue + if schema.get("type") not in ("string", "number", "integer"): + continue + elif key == "items": + val = _format_schema(val) + elif key == "properties": + val = {k: _format_schema(v) for k, v in val.items()} + result[key] = val + + if result.get("enum") and result.get("type") != "STRING": + # enum is only allowed for STRING type. This is safe as long as the schema + # contains vol.Coerce for the respective type, for example: + # vol.All(vol.Coerce(int), vol.In([1, 2, 3])) + result["type"] = "STRING" + result["enum"] = [str(item) for item in result["enum"]] + + if result.get("type") == "OBJECT" and not result.get("properties"): + # An object with undefined properties is not supported by Gemini API. + # Fallback to JSON string. This will probably fail for most tools that want it, + # but we don't have a better fallback strategy so far. + result["properties"] = {"json": {"type": "STRING"}} + result["required"] = [] + return cast(Schema, result) + + +def _format_tool( + tool: llm.Tool, custom_serializer: Callable[[Any], Any] | None +) -> Tool: + """Format tool specification.""" + + if tool.parameters.schema: + parameters = _format_schema( + convert(tool.parameters, custom_serializer=custom_serializer) + ) + else: + parameters = None + + return Tool( + function_declarations=[ + FunctionDeclaration( + name=tool.name, + description=tool.description, + parameters=parameters, + ) + ] + ) + + +def _escape_decode(value: Any) -> Any: + """Recursively call codecs.escape_decode on all values.""" + if isinstance(value, str): + return codecs.escape_decode(bytes(value, "utf-8"))[0].decode("utf-8") # type: ignore[attr-defined] + if isinstance(value, list): + return [_escape_decode(item) for item in value] + if isinstance(value, dict): + return {k: _escape_decode(v) for k, v in value.items()} + return value + + +def _create_google_tool_response_parts( + parts: list[conversation.ToolResultContent], +) -> list[Part]: + """Create Google tool response parts.""" + return [ + Part.from_function_response( + name=tool_result.tool_name, response=tool_result.tool_result + ) + for tool_result in parts + ] + + +def _create_google_tool_response_content( + content: list[conversation.ToolResultContent], +) -> Content: + """Create a Google tool response content.""" + return Content( + role="user", + parts=_create_google_tool_response_parts(content), + ) + + +def _convert_content( + content: ( + conversation.UserContent + | conversation.AssistantContent + | conversation.SystemContent + ), +) -> Content: + """Convert HA content to Google content.""" + if content.role != "assistant" or not content.tool_calls: + role = "model" if content.role == "assistant" else content.role + return Content( + role=role, + parts=[ + Part.from_text(text=content.content if content.content else ""), + ], + ) + + # Handle the Assistant content with tool calls. + assert type(content) is conversation.AssistantContent + parts: list[Part] = [] + + if content.content: + parts.append(Part.from_text(text=content.content)) + + if content.tool_calls: + parts.extend( + [ + Part.from_function_call( + name=tool_call.tool_name, + args=_escape_decode(tool_call.tool_args), + ) + for tool_call in content.tool_calls + ] + ) + + return Content(role="model", parts=parts) + + +async def _transform_stream( + result: AsyncGenerator[GenerateContentResponse], +) -> AsyncGenerator[conversation.AssistantContentDeltaDict]: + new_message = True + try: + async for response in result: + LOGGER.debug("Received response chunk: %s", response) + chunk: conversation.AssistantContentDeltaDict = {} + + if new_message: + chunk["role"] = "assistant" + new_message = False + + # According to the API docs, this would mean no candidate is returned, so we can safely throw an error here. + if response.prompt_feedback or not response.candidates: + reason = ( + response.prompt_feedback.block_reason_message + if response.prompt_feedback + else "unknown" + ) + raise HomeAssistantError( + f"The message got blocked due to content violations, reason: {reason}" + ) + + candidate = response.candidates[0] + + if ( + candidate.finish_reason is not None + and candidate.finish_reason != "STOP" + ): + # The message ended due to a content error as explained in: https://ai.google.dev/api/generate-content#FinishReason + LOGGER.error( + "Error in Google Generative AI response: %s, see: https://ai.google.dev/api/generate-content#FinishReason", + candidate.finish_reason, + ) + raise HomeAssistantError( + f"{ERROR_GETTING_RESPONSE} Reason: {candidate.finish_reason}" + ) + + response_parts = ( + candidate.content.parts + if candidate.content is not None and candidate.content.parts is not None + else [] + ) + + content = "".join([part.text for part in response_parts if part.text]) + tool_calls = [] + for part in response_parts: + if not part.function_call: + continue + tool_call = part.function_call + tool_name = tool_call.name if tool_call.name else "" + tool_args = _escape_decode(tool_call.args) + tool_calls.append( + llm.ToolInput(tool_name=tool_name, tool_args=tool_args) + ) + + if tool_calls: + chunk["tool_calls"] = tool_calls + + chunk["content"] = content + yield chunk + except ( + APIError, + ValueError, + ) as err: + LOGGER.error("Error sending message: %s %s", type(err), err) + if isinstance(err, APIError): + message = err.message + else: + message = type(err).__name__ + error = f"{ERROR_GETTING_RESPONSE}: {message}" + raise HomeAssistantError(error) from err + + +class GoogleGenerativeAILLMBaseEntity(Entity): + """Google Generative AI base entity.""" + + _attr_has_entity_name = True + _attr_name = None + + def __init__(self, entry: ConfigEntry) -> None: + """Initialize the agent.""" + self.entry = entry + self._genai_client = entry.runtime_data + self._attr_unique_id = entry.entry_id + self._attr_device_info = dr.DeviceInfo( + identifiers={(DOMAIN, entry.entry_id)}, + name=entry.title, + manufacturer="Google", + model="Generative AI", + entry_type=dr.DeviceEntryType.SERVICE, + ) + + async def _async_handle_chat_log( + self, + chat_log: conversation.ChatLog, + ) -> None: + """Generate an answer for the chat log.""" + options = self.entry.options + + tools: list[Tool | Callable[..., Any]] | None = None + if chat_log.llm_api: + tools = [ + _format_tool(tool, chat_log.llm_api.custom_serializer) + for tool in chat_log.llm_api.tools + ] + + # Using search grounding allows the model to retrieve information from the web, + # however, it may interfere with how the model decides to use some tools, or entities + # for example weather entity may be disregarded if the model chooses to Google it. + if options.get(CONF_USE_GOOGLE_SEARCH_TOOL) is True: + tools = tools or [] + tools.append(Tool(google_search=GoogleSearch())) + + model_name = self.entry.options.get(CONF_CHAT_MODEL, RECOMMENDED_CHAT_MODEL) + # Avoid INVALID_ARGUMENT Developer instruction is not enabled for + supports_system_instruction = ( + "gemma" not in model_name + and "gemini-2.0-flash-preview-image-generation" not in model_name + ) + + prompt_content = cast( + conversation.SystemContent, + chat_log.content[0], + ) + + if prompt_content.content: + prompt = prompt_content.content + else: + raise HomeAssistantError("Invalid prompt content") + + messages: list[Content] = [] + + # Google groups tool results, we do not. Group them before sending. + tool_results: list[conversation.ToolResultContent] = [] + + for chat_content in chat_log.content[1:-1]: + if chat_content.role == "tool_result": + tool_results.append(chat_content) + continue + + if ( + not isinstance(chat_content, conversation.ToolResultContent) + and chat_content.content == "" + ): + # Skipping is not possible since the number of function calls need to match the number of function responses + # and skipping one would mean removing the other and hence this would prevent a proper chat log + chat_content = replace(chat_content, content=" ") + + if tool_results: + messages.append(_create_google_tool_response_content(tool_results)) + tool_results.clear() + + messages.append(_convert_content(chat_content)) + + # The SDK requires the first message to be a user message + # This is not the case if user used `start_conversation` + # Workaround from https://github.com/googleapis/python-genai/issues/529#issuecomment-2740964537 + if messages and messages[0].role != "user": + messages.insert( + 0, + Content(role="user", parts=[Part.from_text(text=" ")]), + ) + + if tool_results: + messages.append(_create_google_tool_response_content(tool_results)) + generateContentConfig = GenerateContentConfig( + temperature=self.entry.options.get( + CONF_TEMPERATURE, RECOMMENDED_TEMPERATURE + ), + top_k=self.entry.options.get(CONF_TOP_K, RECOMMENDED_TOP_K), + top_p=self.entry.options.get(CONF_TOP_P, RECOMMENDED_TOP_P), + max_output_tokens=self.entry.options.get( + CONF_MAX_TOKENS, RECOMMENDED_MAX_TOKENS + ), + safety_settings=[ + SafetySetting( + category=HarmCategory.HARM_CATEGORY_HATE_SPEECH, + threshold=self.entry.options.get( + CONF_HATE_BLOCK_THRESHOLD, RECOMMENDED_HARM_BLOCK_THRESHOLD + ), + ), + SafetySetting( + category=HarmCategory.HARM_CATEGORY_HARASSMENT, + threshold=self.entry.options.get( + CONF_HARASSMENT_BLOCK_THRESHOLD, + RECOMMENDED_HARM_BLOCK_THRESHOLD, + ), + ), + SafetySetting( + category=HarmCategory.HARM_CATEGORY_DANGEROUS_CONTENT, + threshold=self.entry.options.get( + CONF_DANGEROUS_BLOCK_THRESHOLD, RECOMMENDED_HARM_BLOCK_THRESHOLD + ), + ), + SafetySetting( + category=HarmCategory.HARM_CATEGORY_SEXUALLY_EXPLICIT, + threshold=self.entry.options.get( + CONF_SEXUAL_BLOCK_THRESHOLD, RECOMMENDED_HARM_BLOCK_THRESHOLD + ), + ), + ], + tools=tools or None, + system_instruction=prompt if supports_system_instruction else None, + automatic_function_calling=AutomaticFunctionCallingConfig( + disable=True, maximum_remote_calls=None + ), + ) + + if not supports_system_instruction: + messages = [ + Content(role="user", parts=[Part.from_text(text=prompt)]), + Content(role="model", parts=[Part.from_text(text="Ok")]), + *messages, + ] + chat = self._genai_client.aio.chats.create( + model=model_name, history=messages, config=generateContentConfig + ) + user_message = chat_log.content[-1] + assert isinstance(user_message, conversation.UserContent) + chat_request: str | list[Part] = user_message.content + # To prevent infinite loops, we limit the number of iterations + for _iteration in range(MAX_TOOL_ITERATIONS): + try: + chat_response_generator = await chat.send_message_stream( + message=chat_request + ) + except ( + APIError, + ClientError, + ValueError, + ) as err: + LOGGER.error("Error sending message: %s %s", type(err), err) + error = ERROR_GETTING_RESPONSE + raise HomeAssistantError(error) from err + + chat_request = _create_google_tool_response_parts( + [ + content + async for content in chat_log.async_add_delta_content_stream( + self.entity_id, + _transform_stream(chat_response_generator), + ) + if isinstance(content, conversation.ToolResultContent) + ] + ) + + if not chat_log.unresponded_tool_results: + break diff --git a/homeassistant/components/google_generative_ai_conversation/tts.py b/homeassistant/components/google_generative_ai_conversation/tts.py new file mode 100644 index 00000000000..160048e4897 --- /dev/null +++ b/homeassistant/components/google_generative_ai_conversation/tts.py @@ -0,0 +1,216 @@ +"""Text to speech support for Google Generative AI.""" + +from __future__ import annotations + +from contextlib import suppress +import io +import logging +from typing import Any +import wave + +from google.genai import types + +from homeassistant.components.tts import ( + ATTR_VOICE, + TextToSpeechEntity, + TtsAudioType, + Voice, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant, callback +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers import device_registry as dr +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from .const import ATTR_MODEL, DOMAIN, RECOMMENDED_TTS_MODEL + +_LOGGER = logging.getLogger(__name__) + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up TTS entity.""" + tts_entity = GoogleGenerativeAITextToSpeechEntity(config_entry) + async_add_entities([tts_entity]) + + +class GoogleGenerativeAITextToSpeechEntity(TextToSpeechEntity): + """Google Generative AI text-to-speech entity.""" + + _attr_supported_options = [ATTR_VOICE, ATTR_MODEL] + # See https://ai.google.dev/gemini-api/docs/speech-generation#languages + _attr_supported_languages = [ + "ar-EG", + "bn-BD", + "de-DE", + "en-IN", + "en-US", + "es-US", + "fr-FR", + "hi-IN", + "id-ID", + "it-IT", + "ja-JP", + "ko-KR", + "mr-IN", + "nl-NL", + "pl-PL", + "pt-BR", + "ro-RO", + "ru-RU", + "ta-IN", + "te-IN", + "th-TH", + "tr-TR", + "uk-UA", + "vi-VN", + ] + _attr_default_language = "en-US" + # See https://ai.google.dev/gemini-api/docs/speech-generation#voices + _supported_voices = [ + Voice(voice.split(" ", 1)[0].lower(), voice) + for voice in ( + "Zephyr (Bright)", + "Puck (Upbeat)", + "Charon (Informative)", + "Kore (Firm)", + "Fenrir (Excitable)", + "Leda (Youthful)", + "Orus (Firm)", + "Aoede (Breezy)", + "Callirrhoe (Easy-going)", + "Autonoe (Bright)", + "Enceladus (Breathy)", + "Iapetus (Clear)", + "Umbriel (Easy-going)", + "Algieba (Smooth)", + "Despina (Smooth)", + "Erinome (Clear)", + "Algenib (Gravelly)", + "Rasalgethi (Informative)", + "Laomedeia (Upbeat)", + "Achernar (Soft)", + "Alnilam (Firm)", + "Schedar (Even)", + "Gacrux (Mature)", + "Pulcherrima (Forward)", + "Achird (Friendly)", + "Zubenelgenubi (Casual)", + "Vindemiatrix (Gentle)", + "Sadachbia (Lively)", + "Sadaltager (Knowledgeable)", + "Sulafat (Warm)", + ) + ] + + def __init__(self, entry: ConfigEntry) -> None: + """Initialize Google Generative AI Conversation speech entity.""" + self.entry = entry + self._attr_name = "Google Generative AI TTS" + self._attr_unique_id = f"{entry.entry_id}_tts" + self._attr_device_info = dr.DeviceInfo( + identifiers={(DOMAIN, entry.entry_id)}, + name=entry.title, + manufacturer="Google", + model="Generative AI", + entry_type=dr.DeviceEntryType.SERVICE, + ) + self._genai_client = entry.runtime_data + self._default_voice_id = self._supported_voices[0].voice_id + + @callback + def async_get_supported_voices(self, language: str) -> list[Voice] | None: + """Return a list of supported voices for a language.""" + return self._supported_voices + + async def async_get_tts_audio( + self, message: str, language: str, options: dict[str, Any] + ) -> TtsAudioType: + """Load tts audio file from the engine.""" + try: + response = self._genai_client.models.generate_content( + model=options.get(ATTR_MODEL, RECOMMENDED_TTS_MODEL), + contents=message, + config=types.GenerateContentConfig( + response_modalities=["AUDIO"], + speech_config=types.SpeechConfig( + voice_config=types.VoiceConfig( + prebuilt_voice_config=types.PrebuiltVoiceConfig( + voice_name=options.get( + ATTR_VOICE, self._default_voice_id + ) + ) + ) + ), + ), + ) + + data = response.candidates[0].content.parts[0].inline_data.data + mime_type = response.candidates[0].content.parts[0].inline_data.mime_type + except Exception as exc: + _LOGGER.warning( + "Error during processing of TTS request %s", exc, exc_info=True + ) + raise HomeAssistantError(exc) from exc + return "wav", self._convert_to_wav(data, mime_type) + + def _convert_to_wav(self, audio_data: bytes, mime_type: str) -> bytes: + """Generate a WAV file header for the given audio data and parameters. + + Args: + audio_data: The raw audio data as a bytes object. + mime_type: Mime type of the audio data. + + Returns: + A bytes object representing the WAV file header. + + """ + parameters = self._parse_audio_mime_type(mime_type) + + wav_buffer = io.BytesIO() + with wave.open(wav_buffer, "wb") as wf: + wf.setnchannels(1) + wf.setsampwidth(parameters["bits_per_sample"] // 8) + wf.setframerate(parameters["rate"]) + wf.writeframes(audio_data) + + return wav_buffer.getvalue() + + def _parse_audio_mime_type(self, mime_type: str) -> dict[str, int]: + """Parse bits per sample and rate from an audio MIME type string. + + Assumes bits per sample is encoded like "L16" and rate as "rate=xxxxx". + + Args: + mime_type: The audio MIME type string (e.g., "audio/L16;rate=24000"). + + Returns: + A dictionary with "bits_per_sample" and "rate" keys. Values will be + integers if found, otherwise None. + + """ + if not mime_type.startswith("audio/L"): + _LOGGER.warning("Received unexpected MIME type %s", mime_type) + raise HomeAssistantError(f"Unsupported audio MIME type: {mime_type}") + + bits_per_sample = 16 + rate = 24000 + + # Extract rate from parameters + parts = mime_type.split(";") + for param in parts: # Skip the main type part + param = param.strip() + if param.lower().startswith("rate="): + # Handle cases like "rate=" with no value or non-integer value and keep rate as default + with suppress(ValueError, IndexError): + rate_str = param.split("=", 1)[1] + rate = int(rate_str) + elif param.startswith("audio/L"): + # Keep bits_per_sample as default if conversion fails + with suppress(ValueError, IndexError): + bits_per_sample = int(param.split("L", 1)[1]) + + return {"bits_per_sample": bits_per_sample, "rate": rate} diff --git a/homeassistant/components/google_photos/services.py b/homeassistant/components/google_photos/services.py index a74fabb3b77..c30259416e5 100644 --- a/homeassistant/components/google_photos/services.py +++ b/homeassistant/components/google_photos/services.py @@ -78,86 +78,85 @@ def _read_file_contents( return results +async def _async_handle_upload(call: ServiceCall) -> ServiceResponse: + """Generate content from text and optionally images.""" + config_entry: GooglePhotosConfigEntry | None = ( + call.hass.config_entries.async_get_entry(call.data[CONF_CONFIG_ENTRY_ID]) + ) + if not config_entry: + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="integration_not_found", + translation_placeholders={"target": DOMAIN}, + ) + scopes = config_entry.data["token"]["scope"].split(" ") + if UPLOAD_SCOPE not in scopes: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="missing_upload_permission", + translation_placeholders={"target": DOMAIN}, + ) + coordinator = config_entry.runtime_data + client_api = coordinator.client + upload_tasks = [] + file_results = await call.hass.async_add_executor_job( + _read_file_contents, call.hass, call.data[CONF_FILENAME] + ) + + album = call.data[CONF_ALBUM] + try: + album_id = await coordinator.get_or_create_album(album) + except GooglePhotosApiError as err: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="create_album_error", + translation_placeholders={"message": str(err)}, + ) from err + + for mime_type, content in file_results: + upload_tasks.append(client_api.upload_content(content, mime_type)) + try: + upload_results = await asyncio.gather(*upload_tasks) + except GooglePhotosApiError as err: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="upload_error", + translation_placeholders={"message": str(err)}, + ) from err + try: + upload_result = await client_api.create_media_items( + [ + NewMediaItem(SimpleMediaItem(upload_token=upload_result.upload_token)) + for upload_result in upload_results + ], + album_id=album_id, + ) + except GooglePhotosApiError as err: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="api_error", + translation_placeholders={"message": str(err)}, + ) from err + if call.return_response: + return { + "media_items": [ + {"media_item_id": item_result.media_item.id} + for item_result in upload_result.new_media_item_results + if item_result.media_item and item_result.media_item.id + ], + "album_id": album_id, + } + return None + + @callback def async_setup_services(hass: HomeAssistant) -> None: """Register Google Photos services.""" - async def async_handle_upload(call: ServiceCall) -> ServiceResponse: - """Generate content from text and optionally images.""" - config_entry: GooglePhotosConfigEntry | None = ( - hass.config_entries.async_get_entry(call.data[CONF_CONFIG_ENTRY_ID]) - ) - if not config_entry: - raise ServiceValidationError( - translation_domain=DOMAIN, - translation_key="integration_not_found", - translation_placeholders={"target": DOMAIN}, - ) - scopes = config_entry.data["token"]["scope"].split(" ") - if UPLOAD_SCOPE not in scopes: - raise HomeAssistantError( - translation_domain=DOMAIN, - translation_key="missing_upload_permission", - translation_placeholders={"target": DOMAIN}, - ) - coordinator = config_entry.runtime_data - client_api = coordinator.client - upload_tasks = [] - file_results = await hass.async_add_executor_job( - _read_file_contents, hass, call.data[CONF_FILENAME] - ) - - album = call.data[CONF_ALBUM] - try: - album_id = await coordinator.get_or_create_album(album) - except GooglePhotosApiError as err: - raise HomeAssistantError( - translation_domain=DOMAIN, - translation_key="create_album_error", - translation_placeholders={"message": str(err)}, - ) from err - - for mime_type, content in file_results: - upload_tasks.append(client_api.upload_content(content, mime_type)) - try: - upload_results = await asyncio.gather(*upload_tasks) - except GooglePhotosApiError as err: - raise HomeAssistantError( - translation_domain=DOMAIN, - translation_key="upload_error", - translation_placeholders={"message": str(err)}, - ) from err - try: - upload_result = await client_api.create_media_items( - [ - NewMediaItem( - SimpleMediaItem(upload_token=upload_result.upload_token) - ) - for upload_result in upload_results - ], - album_id=album_id, - ) - except GooglePhotosApiError as err: - raise HomeAssistantError( - translation_domain=DOMAIN, - translation_key="api_error", - translation_placeholders={"message": str(err)}, - ) from err - if call.return_response: - return { - "media_items": [ - {"media_item_id": item_result.media_item.id} - for item_result in upload_result.new_media_item_results - if item_result.media_item and item_result.media_item.id - ], - "album_id": album_id, - } - return None - hass.services.async_register( DOMAIN, UPLOAD_SERVICE, - async_handle_upload, + _async_handle_upload, schema=UPLOAD_SERVICE_SCHEMA, supports_response=SupportsResponse.OPTIONAL, ) diff --git a/homeassistant/components/habitica/services.py b/homeassistant/components/habitica/services.py index c5207ae4ec0..38833f26932 100644 --- a/homeassistant/components/habitica/services.py +++ b/homeassistant/components/habitica/services.py @@ -250,56 +250,203 @@ def get_config_entry(hass: HomeAssistant, entry_id: str) -> HabiticaConfigEntry: return entry -@callback -def async_setup_services(hass: HomeAssistant) -> None: # noqa: C901 - """Set up services for Habitica integration.""" +async def _cast_skill(call: ServiceCall) -> ServiceResponse: + """Skill action.""" + entry = get_config_entry(call.hass, call.data[ATTR_CONFIG_ENTRY]) + coordinator = entry.runtime_data - async def cast_skill(call: ServiceCall) -> ServiceResponse: - """Skill action.""" - entry = get_config_entry(hass, call.data[ATTR_CONFIG_ENTRY]) - coordinator = entry.runtime_data + skill = SKILL_MAP[call.data[ATTR_SKILL]] + cost = COST_MAP[call.data[ATTR_SKILL]] - skill = SKILL_MAP[call.data[ATTR_SKILL]] - cost = COST_MAP[call.data[ATTR_SKILL]] + try: + task_id = next( + task.id + for task in coordinator.data.tasks + if call.data[ATTR_TASK] in (str(task.id), task.alias, task.text) + ) + except StopIteration as e: + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="task_not_found", + translation_placeholders={"task": f"'{call.data[ATTR_TASK]}'"}, + ) from e - try: - task_id = next( - task.id - for task in coordinator.data.tasks - if call.data[ATTR_TASK] in (str(task.id), task.alias, task.text) - ) - except StopIteration as e: + try: + response = await coordinator.habitica.cast_skill(skill, task_id) + except TooManyRequestsError as e: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="setup_rate_limit_exception", + translation_placeholders={"retry_after": str(e.retry_after)}, + ) from e + except NotAuthorizedError as e: + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="not_enough_mana", + translation_placeholders={ + "cost": cost, + "mana": f"{int(coordinator.data.user.stats.mp or 0)} MP", + }, + ) from e + except NotFoundError as e: + # could also be task not found, but the task is looked up + # before the request, so most likely wrong skill selected + # or the skill hasn't been unlocked yet. + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="skill_not_found", + translation_placeholders={"skill": call.data[ATTR_SKILL]}, + ) from e + except HabiticaException as e: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="service_call_exception", + translation_placeholders={"reason": str(e.error.message)}, + ) from e + except ClientError as e: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="service_call_exception", + translation_placeholders={"reason": str(e)}, + ) from e + else: + await coordinator.async_request_refresh() + return asdict(response.data) + + +async def _manage_quests(call: ServiceCall) -> ServiceResponse: + """Accept, reject, start, leave or cancel quests.""" + entry = get_config_entry(call.hass, call.data[ATTR_CONFIG_ENTRY]) + coordinator = entry.runtime_data + + FUNC_MAP = { + SERVICE_ABORT_QUEST: coordinator.habitica.abort_quest, + SERVICE_ACCEPT_QUEST: coordinator.habitica.accept_quest, + SERVICE_CANCEL_QUEST: coordinator.habitica.cancel_quest, + SERVICE_LEAVE_QUEST: coordinator.habitica.leave_quest, + SERVICE_REJECT_QUEST: coordinator.habitica.reject_quest, + SERVICE_START_QUEST: coordinator.habitica.start_quest, + } + + func = FUNC_MAP[call.service] + + try: + response = await func() + except TooManyRequestsError as e: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="setup_rate_limit_exception", + translation_placeholders={"retry_after": str(e.retry_after)}, + ) from e + except NotAuthorizedError as e: + raise ServiceValidationError( + translation_domain=DOMAIN, translation_key="quest_action_unallowed" + ) from e + except NotFoundError as e: + raise ServiceValidationError( + translation_domain=DOMAIN, translation_key="quest_not_found" + ) from e + except HabiticaException as e: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="service_call_exception", + translation_placeholders={"reason": str(e.error.message)}, + ) from e + except ClientError as e: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="service_call_exception", + translation_placeholders={"reason": str(e)}, + ) from e + else: + return asdict(response.data) + + +async def _score_task(call: ServiceCall) -> ServiceResponse: + """Score a task action.""" + entry = get_config_entry(call.hass, call.data[ATTR_CONFIG_ENTRY]) + coordinator = entry.runtime_data + + direction = ( + Direction.DOWN if call.data.get(ATTR_DIRECTION) == "down" else Direction.UP + ) + try: + task_id, task_value = next( + (task.id, task.value) + for task in coordinator.data.tasks + if call.data[ATTR_TASK] in (str(task.id), task.alias, task.text) + ) + except StopIteration as e: + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="task_not_found", + translation_placeholders={"task": f"'{call.data[ATTR_TASK]}'"}, + ) from e + + if TYPE_CHECKING: + assert task_id + try: + response = await coordinator.habitica.update_score(task_id, direction) + except TooManyRequestsError as e: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="setup_rate_limit_exception", + translation_placeholders={"retry_after": str(e.retry_after)}, + ) from e + except NotAuthorizedError as e: + if task_value is not None: raise ServiceValidationError( translation_domain=DOMAIN, - translation_key="task_not_found", - translation_placeholders={"task": f"'{call.data[ATTR_TASK]}'"}, - ) from e - - try: - response = await coordinator.habitica.cast_skill(skill, task_id) - except TooManyRequestsError as e: - raise HomeAssistantError( - translation_domain=DOMAIN, - translation_key="setup_rate_limit_exception", - translation_placeholders={"retry_after": str(e.retry_after)}, - ) from e - except NotAuthorizedError as e: - raise ServiceValidationError( - translation_domain=DOMAIN, - translation_key="not_enough_mana", + translation_key="not_enough_gold", translation_placeholders={ - "cost": cost, - "mana": f"{int(coordinator.data.user.stats.mp or 0)} MP", + "gold": f"{(coordinator.data.user.stats.gp or 0):.2f} GP", + "cost": f"{task_value:.2f} GP", }, ) from e + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="service_call_exception", + translation_placeholders={"reason": e.error.message}, + ) from e + except HabiticaException as e: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="service_call_exception", + translation_placeholders={"reason": str(e.error.message)}, + ) from e + except ClientError as e: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="service_call_exception", + translation_placeholders={"reason": str(e)}, + ) from e + else: + await coordinator.async_request_refresh() + return asdict(response.data) + + +async def _transformation(call: ServiceCall) -> ServiceResponse: + """User a transformation item on a player character.""" + + entry = get_config_entry(call.hass, call.data[ATTR_CONFIG_ENTRY]) + coordinator = entry.runtime_data + + item = ITEMID_MAP[call.data[ATTR_ITEM]] + # check if target is self + if call.data[ATTR_TARGET] in ( + str(coordinator.data.user.id), + coordinator.data.user.profile.name, + coordinator.data.user.auth.local.username, + ): + target_id = coordinator.data.user.id + else: + # check if target is a party member + try: + party = await coordinator.habitica.get_group_members(public_fields=True) except NotFoundError as e: - # could also be task not found, but the task is looked up - # before the request, so most likely wrong skill selected - # or the skill hasn't been unlocked yet. raise ServiceValidationError( translation_domain=DOMAIN, - translation_key="skill_not_found", - translation_placeholders={"skill": call.data[ATTR_SKILL]}, + translation_key="party_not_found", ) from e except HabiticaException as e: raise HomeAssistantError( @@ -313,86 +460,125 @@ def async_setup_services(hass: HomeAssistant) -> None: # noqa: C901 translation_key="service_call_exception", translation_placeholders={"reason": str(e)}, ) from e - else: - await coordinator.async_request_refresh() - return asdict(response.data) + try: + target_id = next( + member.id + for member in party.data + if member.id + and call.data[ATTR_TARGET].lower() + in ( + str(member.id), + str(member.auth.local.username).lower(), + str(member.profile.name).lower(), + ) + ) + except StopIteration as e: + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="target_not_found", + translation_placeholders={"target": f"'{call.data[ATTR_TARGET]}'"}, + ) from e + try: + response = await coordinator.habitica.cast_skill(item, target_id) + except TooManyRequestsError as e: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="setup_rate_limit_exception", + translation_placeholders={"retry_after": str(e.retry_after)}, + ) from e + except NotAuthorizedError as e: + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="item_not_found", + translation_placeholders={"item": call.data[ATTR_ITEM]}, + ) from e + except HabiticaException as e: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="service_call_exception", + translation_placeholders={"reason": str(e.error.message)}, + ) from e + except ClientError as e: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="service_call_exception", + translation_placeholders={"reason": str(e)}, + ) from e + else: + return asdict(response.data) - async def manage_quests(call: ServiceCall) -> ServiceResponse: - """Accept, reject, start, leave or cancel quests.""" - entry = get_config_entry(hass, call.data[ATTR_CONFIG_ENTRY]) - coordinator = entry.runtime_data - FUNC_MAP = { - SERVICE_ABORT_QUEST: coordinator.habitica.abort_quest, - SERVICE_ACCEPT_QUEST: coordinator.habitica.accept_quest, - SERVICE_CANCEL_QUEST: coordinator.habitica.cancel_quest, - SERVICE_LEAVE_QUEST: coordinator.habitica.leave_quest, - SERVICE_REJECT_QUEST: coordinator.habitica.reject_quest, - SERVICE_START_QUEST: coordinator.habitica.start_quest, +async def _get_tasks(call: ServiceCall) -> ServiceResponse: + """Get tasks action.""" + + entry = get_config_entry(call.hass, call.data[ATTR_CONFIG_ENTRY]) + coordinator = entry.runtime_data + response: list[TaskData] = coordinator.data.tasks + + if types := {TaskType[x] for x in call.data.get(ATTR_TYPE, [])}: + response = [task for task in response if task.Type in types] + + if priority := {TaskPriority[x] for x in call.data.get(ATTR_PRIORITY, [])}: + response = [task for task in response if task.priority in priority] + + if tasks := call.data.get(ATTR_TASK): + response = [ + task + for task in response + if str(task.id) in tasks or task.alias in tasks or task.text in tasks + ] + + if tags := call.data.get(ATTR_TAG): + tag_ids = { + tag.id + for tag in coordinator.data.user.tags + if (tag.name and tag.name.lower()) + in (tag.lower() for tag in tags) # Case-insensitive matching + and tag.id } - func = FUNC_MAP[call.service] + response = [ + task + for task in response + if any(tag_id in task.tags for tag_id in tag_ids if task.tags) + ] + if keyword := call.data.get(ATTR_KEYWORD): + keyword = keyword.lower() + response = [ + task + for task in response + if (task.text and keyword in task.text.lower()) + or (task.notes and keyword in task.notes.lower()) + or any(keyword in item.text.lower() for item in task.checklist) + ] + result: dict[str, Any] = { + "tasks": [task.to_dict(omit_none=False) for task in response] + } + return result + + +async def _create_or_update_task(call: ServiceCall) -> ServiceResponse: # noqa: C901 + """Create or update task action.""" + entry = get_config_entry(call.hass, call.data[ATTR_CONFIG_ENTRY]) + coordinator = entry.runtime_data + await coordinator.async_refresh() + is_update = call.service in ( + SERVICE_UPDATE_HABIT, + SERVICE_UPDATE_REWARD, + SERVICE_UPDATE_TODO, + SERVICE_UPDATE_DAILY, + ) + task_type = SERVICE_TASK_TYPE_MAP[call.service] + current_task = None + + if is_update: try: - response = await func() - except TooManyRequestsError as e: - raise HomeAssistantError( - translation_domain=DOMAIN, - translation_key="setup_rate_limit_exception", - translation_placeholders={"retry_after": str(e.retry_after)}, - ) from e - except NotAuthorizedError as e: - raise ServiceValidationError( - translation_domain=DOMAIN, translation_key="quest_action_unallowed" - ) from e - except NotFoundError as e: - raise ServiceValidationError( - translation_domain=DOMAIN, translation_key="quest_not_found" - ) from e - except HabiticaException as e: - raise HomeAssistantError( - translation_domain=DOMAIN, - translation_key="service_call_exception", - translation_placeholders={"reason": str(e.error.message)}, - ) from e - except ClientError as e: - raise HomeAssistantError( - translation_domain=DOMAIN, - translation_key="service_call_exception", - translation_placeholders={"reason": str(e)}, - ) from e - else: - return asdict(response.data) - - for service in ( - SERVICE_ABORT_QUEST, - SERVICE_ACCEPT_QUEST, - SERVICE_CANCEL_QUEST, - SERVICE_LEAVE_QUEST, - SERVICE_REJECT_QUEST, - SERVICE_START_QUEST, - ): - hass.services.async_register( - DOMAIN, - service, - manage_quests, - schema=SERVICE_MANAGE_QUEST_SCHEMA, - supports_response=SupportsResponse.ONLY, - ) - - async def score_task(call: ServiceCall) -> ServiceResponse: - """Score a task action.""" - entry = get_config_entry(hass, call.data[ATTR_CONFIG_ENTRY]) - coordinator = entry.runtime_data - - direction = ( - Direction.DOWN if call.data.get(ATTR_DIRECTION) == "down" else Direction.UP - ) - try: - task_id, task_value = next( - (task.id, task.value) + current_task = next( + task for task in coordinator.data.tasks if call.data[ATTR_TASK] in (str(task.id), task.alias, task.text) + and task.Type is task_type ) except StopIteration as e: raise ServiceValidationError( @@ -401,69 +587,48 @@ def async_setup_services(hass: HomeAssistant) -> None: # noqa: C901 translation_placeholders={"task": f"'{call.data[ATTR_TASK]}'"}, ) from e - if TYPE_CHECKING: - assert task_id - try: - response = await coordinator.habitica.update_score(task_id, direction) - except TooManyRequestsError as e: - raise HomeAssistantError( - translation_domain=DOMAIN, - translation_key="setup_rate_limit_exception", - translation_placeholders={"retry_after": str(e.retry_after)}, - ) from e - except NotAuthorizedError as e: - if task_value is not None: - raise ServiceValidationError( - translation_domain=DOMAIN, - translation_key="not_enough_gold", - translation_placeholders={ - "gold": f"{(coordinator.data.user.stats.gp or 0):.2f} GP", - "cost": f"{task_value:.2f} GP", - }, - ) from e - raise HomeAssistantError( - translation_domain=DOMAIN, - translation_key="service_call_exception", - translation_placeholders={"reason": e.error.message}, - ) from e - except HabiticaException as e: - raise HomeAssistantError( - translation_domain=DOMAIN, - translation_key="service_call_exception", - translation_placeholders={"reason": str(e.error.message)}, - ) from e - except ClientError as e: - raise HomeAssistantError( - translation_domain=DOMAIN, - translation_key="service_call_exception", - translation_placeholders={"reason": str(e)}, - ) from e - else: - await coordinator.async_request_refresh() - return asdict(response.data) + data = Task() - async def transformation(call: ServiceCall) -> ServiceResponse: - """User a transformation item on a player character.""" + if not is_update: + data["type"] = task_type - entry = get_config_entry(hass, call.data[ATTR_CONFIG_ENTRY]) - coordinator = entry.runtime_data + if (text := call.data.get(ATTR_RENAME)) or (text := call.data.get(ATTR_NAME)): + data["text"] = text + + if (notes := call.data.get(ATTR_NOTES)) is not None: + data["notes"] = notes + + tags = cast(list[str], call.data.get(ATTR_TAG)) + remove_tags = cast(list[str], call.data.get(ATTR_REMOVE_TAG)) + + if tags or remove_tags: + update_tags = set(current_task.tags) if current_task else set() + user_tags = { + tag.name.lower(): tag.id + for tag in coordinator.data.user.tags + if tag.id and tag.name + } + + if tags: + # Creates new tag if it doesn't exist + async def create_tag(tag_name: str) -> UUID: + tag_id = (await coordinator.habitica.create_tag(tag_name)).data.id + if TYPE_CHECKING: + assert tag_id + return tag_id - item = ITEMID_MAP[call.data[ATTR_ITEM]] - # check if target is self - if call.data[ATTR_TARGET] in ( - str(coordinator.data.user.id), - coordinator.data.user.profile.name, - coordinator.data.user.auth.local.username, - ): - target_id = coordinator.data.user.id - else: - # check if target is a party member try: - party = await coordinator.habitica.get_group_members(public_fields=True) - except NotFoundError as e: - raise ServiceValidationError( + update_tags.update( + { + user_tags.get(tag_name.lower()) or (await create_tag(tag_name)) + for tag_name in tags + } + ) + except TooManyRequestsError as e: + raise HomeAssistantError( translation_domain=DOMAIN, - translation_key="party_not_found", + translation_key="setup_rate_limit_exception", + translation_placeholders={"retry_after": str(e.retry_after)}, ) from e except HabiticaException as e: raise HomeAssistantError( @@ -477,378 +642,218 @@ def async_setup_services(hass: HomeAssistant) -> None: # noqa: C901 translation_key="service_call_exception", translation_placeholders={"reason": str(e)}, ) from e - try: - target_id = next( - member.id - for member in party.data - if member.id - and call.data[ATTR_TARGET].lower() - in ( - str(member.id), - str(member.auth.local.username).lower(), - str(member.profile.name).lower(), - ) + + if remove_tags: + update_tags.difference_update( + { + user_tags[tag_name.lower()] + for tag_name in remove_tags + if tag_name.lower() in user_tags + } + ) + + data["tags"] = list(update_tags) + + if (alias := call.data.get(ATTR_ALIAS)) is not None: + data["alias"] = alias + + if (cost := call.data.get(ATTR_COST)) is not None: + data["value"] = cost + + if priority := call.data.get(ATTR_PRIORITY): + data["priority"] = TaskPriority[priority] + + if frequency := call.data.get(ATTR_FREQUENCY): + data["frequency"] = frequency + else: + frequency = current_task.frequency if current_task else Frequency.WEEKLY + + if up_down := call.data.get(ATTR_UP_DOWN): + data["up"] = "up" in up_down + data["down"] = "down" in up_down + + if counter_up := call.data.get(ATTR_COUNTER_UP): + data["counterUp"] = counter_up + + if counter_down := call.data.get(ATTR_COUNTER_DOWN): + data["counterDown"] = counter_down + + if due_date := call.data.get(ATTR_DATE): + data["date"] = datetime.combine(due_date, time()) + + if call.data.get(ATTR_CLEAR_DATE): + data["date"] = None + + checklist = current_task.checklist if current_task else [] + + if add_checklist_item := call.data.get(ATTR_ADD_CHECKLIST_ITEM): + checklist.extend( + Checklist(completed=False, id=uuid4(), text=item) + for item in add_checklist_item + if not any(i.text == item for i in checklist) + ) + if remove_checklist_item := call.data.get(ATTR_REMOVE_CHECKLIST_ITEM): + checklist = [ + item for item in checklist if item.text not in remove_checklist_item + ] + + if score_checklist_item := call.data.get(ATTR_SCORE_CHECKLIST_ITEM): + for item in checklist: + if item.text in score_checklist_item: + item.completed = True + + if unscore_checklist_item := call.data.get(ATTR_UNSCORE_CHECKLIST_ITEM): + for item in checklist: + if item.text in unscore_checklist_item: + item.completed = False + if ( + add_checklist_item + or remove_checklist_item + or score_checklist_item + or unscore_checklist_item + ): + data["checklist"] = checklist + + reminders = current_task.reminders if current_task else [] + + if add_reminders := call.data.get(ATTR_REMINDER): + if task_type is TaskType.TODO: + existing_reminder_datetimes = { + r.time.replace(tzinfo=None) for r in reminders + } + + reminders.extend( + Reminders(id=uuid4(), time=r) + for r in add_reminders + if r not in existing_reminder_datetimes + ) + if task_type is TaskType.DAILY: + existing_reminder_times = { + r.time.time().replace(microsecond=0, second=0) for r in reminders + } + + reminders.extend( + Reminders( + id=uuid4(), + time=datetime.combine(date.today(), r, tzinfo=UTC), ) - except StopIteration as e: - raise ServiceValidationError( - translation_domain=DOMAIN, - translation_key="target_not_found", - translation_placeholders={"target": f"'{call.data[ATTR_TARGET]}'"}, - ) from e - try: - response = await coordinator.habitica.cast_skill(item, target_id) - except TooManyRequestsError as e: - raise HomeAssistantError( - translation_domain=DOMAIN, - translation_key="setup_rate_limit_exception", - translation_placeholders={"retry_after": str(e.retry_after)}, - ) from e - except NotAuthorizedError as e: + for r in add_reminders + if r not in existing_reminder_times + ) + + if remove_reminder := call.data.get(ATTR_REMOVE_REMINDER): + if task_type is TaskType.TODO: + reminders = list( + filter( + lambda r: r.time.replace(tzinfo=None) not in remove_reminder, + reminders, + ) + ) + if task_type is TaskType.DAILY: + reminders = list( + filter( + lambda r: r.time.time().replace(second=0, microsecond=0) + not in remove_reminder, + reminders, + ) + ) + + if clear_reminders := call.data.get(ATTR_CLEAR_REMINDER): + reminders = [] + + if add_reminders or remove_reminder or clear_reminders: + data["reminders"] = reminders + + if start_date := call.data.get(ATTR_START_DATE): + data["startDate"] = datetime.combine(start_date, time()) + else: + start_date = ( + current_task.startDate + if current_task and current_task.startDate + else dt_util.start_of_local_day() + ) + if repeat := call.data.get(ATTR_REPEAT): + if frequency is Frequency.WEEKLY: + data["repeat"] = Repeat(**{d: d in repeat for d in WEEK_DAYS}) + else: raise ServiceValidationError( translation_domain=DOMAIN, - translation_key="item_not_found", - translation_placeholders={"item": call.data[ATTR_ITEM]}, - ) from e - except HabiticaException as e: - raise HomeAssistantError( + translation_key="frequency_not_weekly", + ) + if repeat_monthly := call.data.get(ATTR_REPEAT_MONTHLY): + if frequency is not Frequency.MONTHLY: + raise ServiceValidationError( translation_domain=DOMAIN, - translation_key="service_call_exception", - translation_placeholders={"reason": str(e.error.message)}, - ) from e - except ClientError as e: - raise HomeAssistantError( - translation_domain=DOMAIN, - translation_key="service_call_exception", - translation_placeholders={"reason": str(e)}, - ) from e + translation_key="frequency_not_monthly", + ) + + if repeat_monthly == "day_of_week": + weekday = start_date.weekday() + data["weeksOfMonth"] = [(start_date.day - 1) // 7] + data["repeat"] = Repeat( + **{day: i == weekday for i, day in enumerate(WEEK_DAYS)} + ) + data["daysOfMonth"] = [] + else: - return asdict(response.data) + data["daysOfMonth"] = [start_date.day] + data["weeksOfMonth"] = [] - async def get_tasks(call: ServiceCall) -> ServiceResponse: - """Get tasks action.""" + if interval := call.data.get(ATTR_INTERVAL): + data["everyX"] = interval - entry = get_config_entry(hass, call.data[ATTR_CONFIG_ENTRY]) - coordinator = entry.runtime_data - response: list[TaskData] = coordinator.data.tasks - - if types := {TaskType[x] for x in call.data.get(ATTR_TYPE, [])}: - response = [task for task in response if task.Type in types] - - if priority := {TaskPriority[x] for x in call.data.get(ATTR_PRIORITY, [])}: - response = [task for task in response if task.priority in priority] - - if tasks := call.data.get(ATTR_TASK): - response = [ - task - for task in response - if str(task.id) in tasks or task.alias in tasks or task.text in tasks - ] - - if tags := call.data.get(ATTR_TAG): - tag_ids = { - tag.id - for tag in coordinator.data.user.tags - if (tag.name and tag.name.lower()) - in (tag.lower() for tag in tags) # Case-insensitive matching - and tag.id - } - - response = [ - task - for task in response - if any(tag_id in task.tags for tag_id in tag_ids if task.tags) - ] - if keyword := call.data.get(ATTR_KEYWORD): - keyword = keyword.lower() - response = [ - task - for task in response - if (task.text and keyword in task.text.lower()) - or (task.notes and keyword in task.notes.lower()) - or any(keyword in item.text.lower() for item in task.checklist) - ] - result: dict[str, Any] = { - "tasks": [task.to_dict(omit_none=False) for task in response] - } - - return result - - async def create_or_update_task(call: ServiceCall) -> ServiceResponse: # noqa: C901 - """Create or update task action.""" - entry = get_config_entry(hass, call.data[ATTR_CONFIG_ENTRY]) - coordinator = entry.runtime_data - await coordinator.async_refresh() - is_update = call.service in ( - SERVICE_UPDATE_HABIT, - SERVICE_UPDATE_REWARD, - SERVICE_UPDATE_TODO, - SERVICE_UPDATE_DAILY, - ) - task_type = SERVICE_TASK_TYPE_MAP[call.service] - current_task = None + if streak := call.data.get(ATTR_STREAK): + data["streak"] = streak + try: if is_update: - try: - current_task = next( - task - for task in coordinator.data.tasks - if call.data[ATTR_TASK] in (str(task.id), task.alias, task.text) - and task.Type is task_type - ) - except StopIteration as e: - raise ServiceValidationError( - translation_domain=DOMAIN, - translation_key="task_not_found", - translation_placeholders={"task": f"'{call.data[ATTR_TASK]}'"}, - ) from e - - data = Task() - - if not is_update: - data["type"] = task_type - - if (text := call.data.get(ATTR_RENAME)) or (text := call.data.get(ATTR_NAME)): - data["text"] = text - - if (notes := call.data.get(ATTR_NOTES)) is not None: - data["notes"] = notes - - tags = cast(list[str], call.data.get(ATTR_TAG)) - remove_tags = cast(list[str], call.data.get(ATTR_REMOVE_TAG)) - - if tags or remove_tags: - update_tags = set(current_task.tags) if current_task else set() - user_tags = { - tag.name.lower(): tag.id - for tag in coordinator.data.user.tags - if tag.id and tag.name - } - - if tags: - # Creates new tag if it doesn't exist - async def create_tag(tag_name: str) -> UUID: - tag_id = (await coordinator.habitica.create_tag(tag_name)).data.id - if TYPE_CHECKING: - assert tag_id - return tag_id - - try: - update_tags.update( - { - user_tags.get(tag_name.lower()) - or (await create_tag(tag_name)) - for tag_name in tags - } - ) - except TooManyRequestsError as e: - raise HomeAssistantError( - translation_domain=DOMAIN, - translation_key="setup_rate_limit_exception", - translation_placeholders={"retry_after": str(e.retry_after)}, - ) from e - except HabiticaException as e: - raise HomeAssistantError( - translation_domain=DOMAIN, - translation_key="service_call_exception", - translation_placeholders={"reason": str(e.error.message)}, - ) from e - except ClientError as e: - raise HomeAssistantError( - translation_domain=DOMAIN, - translation_key="service_call_exception", - translation_placeholders={"reason": str(e)}, - ) from e - - if remove_tags: - update_tags.difference_update( - { - user_tags[tag_name.lower()] - for tag_name in remove_tags - if tag_name.lower() in user_tags - } - ) - - data["tags"] = list(update_tags) - - if (alias := call.data.get(ATTR_ALIAS)) is not None: - data["alias"] = alias - - if (cost := call.data.get(ATTR_COST)) is not None: - data["value"] = cost - - if priority := call.data.get(ATTR_PRIORITY): - data["priority"] = TaskPriority[priority] - - if frequency := call.data.get(ATTR_FREQUENCY): - data["frequency"] = frequency + if TYPE_CHECKING: + assert current_task + assert current_task.id + response = await coordinator.habitica.update_task(current_task.id, data) else: - frequency = current_task.frequency if current_task else Frequency.WEEKLY + response = await coordinator.habitica.create_task(data) + except TooManyRequestsError as e: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="setup_rate_limit_exception", + translation_placeholders={"retry_after": str(e.retry_after)}, + ) from e + except HabiticaException as e: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="service_call_exception", + translation_placeholders={"reason": str(e.error.message)}, + ) from e + except ClientError as e: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="service_call_exception", + translation_placeholders={"reason": str(e)}, + ) from e + else: + return response.data.to_dict(omit_none=True) - if up_down := call.data.get(ATTR_UP_DOWN): - data["up"] = "up" in up_down - data["down"] = "down" in up_down - if counter_up := call.data.get(ATTR_COUNTER_UP): - data["counterUp"] = counter_up +@callback +def async_setup_services(hass: HomeAssistant) -> None: + """Set up services for Habitica integration.""" - if counter_down := call.data.get(ATTR_COUNTER_DOWN): - data["counterDown"] = counter_down - - if due_date := call.data.get(ATTR_DATE): - data["date"] = datetime.combine(due_date, time()) - - if call.data.get(ATTR_CLEAR_DATE): - data["date"] = None - - checklist = current_task.checklist if current_task else [] - - if add_checklist_item := call.data.get(ATTR_ADD_CHECKLIST_ITEM): - checklist.extend( - Checklist(completed=False, id=uuid4(), text=item) - for item in add_checklist_item - if not any(i.text == item for i in checklist) - ) - if remove_checklist_item := call.data.get(ATTR_REMOVE_CHECKLIST_ITEM): - checklist = [ - item for item in checklist if item.text not in remove_checklist_item - ] - - if score_checklist_item := call.data.get(ATTR_SCORE_CHECKLIST_ITEM): - for item in checklist: - if item.text in score_checklist_item: - item.completed = True - - if unscore_checklist_item := call.data.get(ATTR_UNSCORE_CHECKLIST_ITEM): - for item in checklist: - if item.text in unscore_checklist_item: - item.completed = False - if ( - add_checklist_item - or remove_checklist_item - or score_checklist_item - or unscore_checklist_item - ): - data["checklist"] = checklist - - reminders = current_task.reminders if current_task else [] - - if add_reminders := call.data.get(ATTR_REMINDER): - if task_type is TaskType.TODO: - existing_reminder_datetimes = { - r.time.replace(tzinfo=None) for r in reminders - } - - reminders.extend( - Reminders(id=uuid4(), time=r) - for r in add_reminders - if r not in existing_reminder_datetimes - ) - if task_type is TaskType.DAILY: - existing_reminder_times = { - r.time.time().replace(microsecond=0, second=0) for r in reminders - } - - reminders.extend( - Reminders( - id=uuid4(), - time=datetime.combine(date.today(), r, tzinfo=UTC), - ) - for r in add_reminders - if r not in existing_reminder_times - ) - - if remove_reminder := call.data.get(ATTR_REMOVE_REMINDER): - if task_type is TaskType.TODO: - reminders = list( - filter( - lambda r: r.time.replace(tzinfo=None) not in remove_reminder, - reminders, - ) - ) - if task_type is TaskType.DAILY: - reminders = list( - filter( - lambda r: r.time.time().replace(second=0, microsecond=0) - not in remove_reminder, - reminders, - ) - ) - - if clear_reminders := call.data.get(ATTR_CLEAR_REMINDER): - reminders = [] - - if add_reminders or remove_reminder or clear_reminders: - data["reminders"] = reminders - - if start_date := call.data.get(ATTR_START_DATE): - data["startDate"] = datetime.combine(start_date, time()) - else: - start_date = ( - current_task.startDate - if current_task and current_task.startDate - else dt_util.start_of_local_day() - ) - if repeat := call.data.get(ATTR_REPEAT): - if frequency is Frequency.WEEKLY: - data["repeat"] = Repeat(**{d: d in repeat for d in WEEK_DAYS}) - else: - raise ServiceValidationError( - translation_domain=DOMAIN, - translation_key="frequency_not_weekly", - ) - if repeat_monthly := call.data.get(ATTR_REPEAT_MONTHLY): - if frequency is not Frequency.MONTHLY: - raise ServiceValidationError( - translation_domain=DOMAIN, - translation_key="frequency_not_monthly", - ) - - if repeat_monthly == "day_of_week": - weekday = start_date.weekday() - data["weeksOfMonth"] = [(start_date.day - 1) // 7] - data["repeat"] = Repeat( - **{day: i == weekday for i, day in enumerate(WEEK_DAYS)} - ) - data["daysOfMonth"] = [] - - else: - data["daysOfMonth"] = [start_date.day] - data["weeksOfMonth"] = [] - - if interval := call.data.get(ATTR_INTERVAL): - data["everyX"] = interval - - if streak := call.data.get(ATTR_STREAK): - data["streak"] = streak - - try: - if is_update: - if TYPE_CHECKING: - assert current_task - assert current_task.id - response = await coordinator.habitica.update_task(current_task.id, data) - else: - response = await coordinator.habitica.create_task(data) - except TooManyRequestsError as e: - raise HomeAssistantError( - translation_domain=DOMAIN, - translation_key="setup_rate_limit_exception", - translation_placeholders={"retry_after": str(e.retry_after)}, - ) from e - except HabiticaException as e: - raise HomeAssistantError( - translation_domain=DOMAIN, - translation_key="service_call_exception", - translation_placeholders={"reason": str(e.error.message)}, - ) from e - except ClientError as e: - raise HomeAssistantError( - translation_domain=DOMAIN, - translation_key="service_call_exception", - translation_placeholders={"reason": str(e)}, - ) from e - else: - return response.data.to_dict(omit_none=True) + for service in ( + SERVICE_ABORT_QUEST, + SERVICE_ACCEPT_QUEST, + SERVICE_CANCEL_QUEST, + SERVICE_LEAVE_QUEST, + SERVICE_REJECT_QUEST, + SERVICE_START_QUEST, + ): + hass.services.async_register( + DOMAIN, + service, + _manage_quests, + schema=SERVICE_MANAGE_QUEST_SCHEMA, + supports_response=SupportsResponse.ONLY, + ) for service in ( SERVICE_UPDATE_DAILY, @@ -859,7 +864,7 @@ def async_setup_services(hass: HomeAssistant) -> None: # noqa: C901 hass.services.async_register( DOMAIN, service, - create_or_update_task, + _create_or_update_task, schema=SERVICE_UPDATE_TASK_SCHEMA, supports_response=SupportsResponse.ONLY, ) @@ -872,7 +877,7 @@ def async_setup_services(hass: HomeAssistant) -> None: # noqa: C901 hass.services.async_register( DOMAIN, service, - create_or_update_task, + _create_or_update_task, schema=SERVICE_CREATE_TASK_SCHEMA, supports_response=SupportsResponse.ONLY, ) @@ -880,7 +885,7 @@ def async_setup_services(hass: HomeAssistant) -> None: # noqa: C901 hass.services.async_register( DOMAIN, SERVICE_CAST_SKILL, - cast_skill, + _cast_skill, schema=SERVICE_CAST_SKILL_SCHEMA, supports_response=SupportsResponse.ONLY, ) @@ -888,14 +893,14 @@ def async_setup_services(hass: HomeAssistant) -> None: # noqa: C901 hass.services.async_register( DOMAIN, SERVICE_SCORE_HABIT, - score_task, + _score_task, schema=SERVICE_SCORE_TASK_SCHEMA, supports_response=SupportsResponse.ONLY, ) hass.services.async_register( DOMAIN, SERVICE_SCORE_REWARD, - score_task, + _score_task, schema=SERVICE_SCORE_TASK_SCHEMA, supports_response=SupportsResponse.ONLY, ) @@ -903,14 +908,14 @@ def async_setup_services(hass: HomeAssistant) -> None: # noqa: C901 hass.services.async_register( DOMAIN, SERVICE_TRANSFORMATION, - transformation, + _transformation, schema=SERVICE_TRANSFORMATION_SCHEMA, supports_response=SupportsResponse.ONLY, ) hass.services.async_register( DOMAIN, SERVICE_GET_TASKS, - get_tasks, + _get_tasks, schema=SERVICE_GET_TASKS_SCHEMA, supports_response=SupportsResponse.ONLY, ) diff --git a/homeassistant/components/here_travel_time/coordinator.py b/homeassistant/components/here_travel_time/coordinator.py index 447a45f5d2b..2c678316939 100644 --- a/homeassistant/components/here_travel_time/coordinator.py +++ b/homeassistant/components/here_travel_time/coordinator.py @@ -133,8 +133,8 @@ class HERERoutingDataUpdateCoordinator(DataUpdateCoordinator[HERETravelTimeData] def _parse_routing_response(self, response: dict[str, Any]) -> HERETravelTimeData: """Parse the routing response dict to a HERETravelTimeData.""" distance: float = 0.0 - duration: float = 0.0 - duration_in_traffic: float = 0.0 + duration: int = 0 + duration_in_traffic: int = 0 for section in response["routes"][0]["sections"]: distance += DistanceConverter.convert( @@ -167,8 +167,8 @@ class HERERoutingDataUpdateCoordinator(DataUpdateCoordinator[HERETravelTimeData] destination_name = names[0]["value"] return HERETravelTimeData( attribution=None, - duration=round(duration / 60), - duration_in_traffic=round(duration_in_traffic / 60), + duration=duration, + duration_in_traffic=duration_in_traffic, distance=distance, origin=f"{mapped_origin_lat},{mapped_origin_lon}", destination=f"{mapped_destination_lat},{mapped_destination_lon}", @@ -271,13 +271,13 @@ class HERETransitDataUpdateCoordinator( UnitOfLength.METERS, UnitOfLength.KILOMETERS, ) - duration: float = sum( + duration: int = sum( section["travelSummary"]["duration"] for section in sections ) return HERETravelTimeData( attribution=attribution, - duration=round(duration / 60), - duration_in_traffic=round(duration / 60), + duration=duration, + duration_in_traffic=duration, distance=distance, origin=f"{mapped_origin_lat},{mapped_origin_lon}", destination=f"{mapped_destination_lat},{mapped_destination_lon}", diff --git a/homeassistant/components/here_travel_time/sensor.py b/homeassistant/components/here_travel_time/sensor.py index bbaabb56d46..da93c6e301e 100644 --- a/homeassistant/components/here_travel_time/sensor.py +++ b/homeassistant/components/here_travel_time/sensor.py @@ -55,14 +55,18 @@ def sensor_descriptions(travel_mode: str) -> tuple[SensorEntityDescription, ...] icon=ICONS.get(travel_mode, ICON_CAR), key=ATTR_DURATION, state_class=SensorStateClass.MEASUREMENT, - native_unit_of_measurement=UnitOfTime.MINUTES, + device_class=SensorDeviceClass.DURATION, + native_unit_of_measurement=UnitOfTime.SECONDS, + suggested_unit_of_measurement=UnitOfTime.MINUTES, ), SensorEntityDescription( translation_key="duration_in_traffic", icon=ICONS.get(travel_mode, ICON_CAR), key=ATTR_DURATION_IN_TRAFFIC, state_class=SensorStateClass.MEASUREMENT, - native_unit_of_measurement=UnitOfTime.MINUTES, + device_class=SensorDeviceClass.DURATION, + native_unit_of_measurement=UnitOfTime.SECONDS, + suggested_unit_of_measurement=UnitOfTime.MINUTES, ), SensorEntityDescription( translation_key="distance", diff --git a/homeassistant/components/holiday/manifest.json b/homeassistant/components/holiday/manifest.json index 5a5f1daf967..c76d6638730 100644 --- a/homeassistant/components/holiday/manifest.json +++ b/homeassistant/components/holiday/manifest.json @@ -5,5 +5,5 @@ "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/holiday", "iot_class": "local_polling", - "requirements": ["holidays==0.74", "babel==2.15.0"] + "requirements": ["holidays==0.75", "babel==2.15.0"] } diff --git a/homeassistant/components/home_connect/manifest.json b/homeassistant/components/home_connect/manifest.json index d4b37552fb7..5e296ba18ac 100644 --- a/homeassistant/components/home_connect/manifest.json +++ b/homeassistant/components/home_connect/manifest.json @@ -21,6 +21,7 @@ "documentation": "https://www.home-assistant.io/integrations/home_connect", "iot_class": "cloud_push", "loggers": ["aiohomeconnect"], - "requirements": ["aiohomeconnect==0.17.1"], + "quality_scale": "platinum", + "requirements": ["aiohomeconnect==0.18.0"], "zeroconf": ["_homeconnect._tcp.local."] } diff --git a/homeassistant/components/home_connect/quality_scale.yaml b/homeassistant/components/home_connect/quality_scale.yaml new file mode 100644 index 00000000000..b89af885f38 --- /dev/null +++ b/homeassistant/components/home_connect/quality_scale.yaml @@ -0,0 +1,71 @@ +rules: + # Bronze + action-setup: done + appropriate-polling: + status: done + comment: | + Full polling is performed at the configuration entry setup and + device polling is performed when a CONNECTED or a PAIRED event is received. + If many CONNECTED or PAIRED events are received for a device within a short time span, + the integration will stop polling for that device and will create a repair issue. + brands: done + common-modules: done + config-flow-test-coverage: done + config-flow: done + dependency-transparency: done + docs-actions: done + docs-high-level-description: done + docs-installation-instructions: done + docs-removal-instructions: done + entity-event-setup: done + entity-unique-id: done + has-entity-name: done + runtime-data: done + test-before-configure: done + test-before-setup: done + unique-config-entry: done + + # Silver + action-exceptions: done + config-entry-unloading: done + docs-configuration-parameters: done + docs-installation-parameters: done + entity-unavailable: done + integration-owner: done + log-when-unavailable: done + parallel-updates: done + reauthentication-flow: done + test-coverage: done + # Gold + devices: done + diagnostics: done + discovery-update-info: done + discovery: done + docs-data-update: done + docs-examples: done + docs-known-limitations: done + docs-supported-devices: done + docs-supported-functions: done + docs-troubleshooting: done + docs-use-cases: done + dynamic-devices: done + entity-category: done + entity-device-class: done + entity-disabled-by-default: + status: done + comment: | + Event entities are disabled by default to prevent user confusion regarding + which events are supported by its appliance. + entity-translations: done + exception-translations: done + icon-translations: done + reconfiguration-flow: + status: exempt + comment: | + This integration doesn't have settings in its configuration flow. + repair-issues: done + stale-devices: done + # Platinum + async-dependency: done + inject-websession: done + strict-typing: done diff --git a/homeassistant/components/homee/event.py b/homeassistant/components/homee/event.py index 047d9e2e122..73c315e8695 100644 --- a/homeassistant/components/homee/event.py +++ b/homeassistant/components/homee/event.py @@ -1,9 +1,13 @@ """The homee event platform.""" -from pyHomee.const import AttributeType +from pyHomee.const import AttributeType, NodeProfile from pyHomee.model import HomeeAttribute -from homeassistant.components.event import EventDeviceClass, EventEntity +from homeassistant.components.event import ( + EventDeviceClass, + EventEntity, + EventEntityDescription, +) from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback @@ -13,6 +17,38 @@ from .entity import HomeeEntity PARALLEL_UPDATES = 0 +REMOTE_PROFILES = [ + NodeProfile.REMOTE, + NodeProfile.TWO_BUTTON_REMOTE, + NodeProfile.THREE_BUTTON_REMOTE, + NodeProfile.FOUR_BUTTON_REMOTE, +] + +EVENT_DESCRIPTIONS: dict[AttributeType, EventEntityDescription] = { + AttributeType.BUTTON_STATE: EventEntityDescription( + key="button_state", + device_class=EventDeviceClass.BUTTON, + event_types=["upper", "lower", "released"], + ), + AttributeType.UP_DOWN_REMOTE: EventEntityDescription( + key="up_down_remote", + device_class=EventDeviceClass.BUTTON, + event_types=[ + "released", + "up", + "down", + "stop", + "up_long", + "down_long", + "stop_long", + "c_button", + "b_button", + "a_button", + ], + ), +} + + async def async_setup_entry( hass: HomeAssistant, config_entry: HomeeConfigEntry, @@ -21,30 +57,31 @@ async def async_setup_entry( """Add event entities for homee.""" async_add_entities( - HomeeEvent(attribute, config_entry) + HomeeEvent(attribute, config_entry, EVENT_DESCRIPTIONS[attribute.type]) for node in config_entry.runtime_data.nodes for attribute in node.attributes - if attribute.type == AttributeType.UP_DOWN_REMOTE + if attribute.type in EVENT_DESCRIPTIONS + and node.profile in REMOTE_PROFILES + and not attribute.editable ) class HomeeEvent(HomeeEntity, EventEntity): """Representation of a homee event.""" - _attr_translation_key = "up_down_remote" - _attr_event_types = [ - "released", - "up", - "down", - "stop", - "up_long", - "down_long", - "stop_long", - "c_button", - "b_button", - "a_button", - ] - _attr_device_class = EventDeviceClass.BUTTON + def __init__( + self, + attribute: HomeeAttribute, + entry: HomeeConfigEntry, + description: EventEntityDescription, + ) -> None: + """Initialize the homee event entity.""" + super().__init__(attribute, entry) + self.entity_description = description + self._attr_translation_key = description.key + if attribute.instance > 0: + self._attr_translation_key = f"{self._attr_translation_key}_instance" + self._attr_translation_placeholders = {"instance": str(attribute.instance)} async def async_added_to_hass(self) -> None: """Add the homee event entity to home assistant.""" @@ -56,6 +93,5 @@ class HomeeEvent(HomeeEntity, EventEntity): @callback def _event_triggered(self, event: HomeeAttribute) -> None: """Handle a homee event.""" - if event.type == AttributeType.UP_DOWN_REMOTE: - self._trigger_event(self.event_types[int(event.current_value)]) - self.schedule_update_ha_state() + self._trigger_event(self.event_types[int(event.current_value)]) + self.schedule_update_ha_state() diff --git a/homeassistant/components/homee/strings.json b/homeassistant/components/homee/strings.json index e2e4c6659d6..8b10b3ebb8a 100644 --- a/homeassistant/components/homee/strings.json +++ b/homeassistant/components/homee/strings.json @@ -160,12 +160,36 @@ } }, "event": { + "button_state": { + "name": "Switch", + "state_attributes": { + "event_type": { + "state": { + "upper": "Upper button", + "lower": "Lower button", + "released": "Released" + } + } + } + }, + "button_state_instance": { + "name": "Switch {instance}", + "state_attributes": { + "event_type": { + "state": { + "upper": "[%key:component::homee::entity::event::button_state::state_attributes::event_type::state::upper%]", + "lower": "[%key:component::homee::entity::event::button_state::state_attributes::event_type::state::lower%]", + "released": "[%key:component::homee::entity::event::button_state::state_attributes::event_type::state::released%]" + } + } + } + }, "up_down_remote": { "name": "Up/down remote", "state_attributes": { "event_type": { "state": { - "release": "Released", + "release": "[%key:component::homee::entity::event::button_state::state_attributes::event_type::state::released%]", "up": "Up", "down": "Down", "stop": "Stop", diff --git a/homeassistant/components/homematicip_cloud/hap.py b/homeassistant/components/homematicip_cloud/hap.py index f3681a89110..c42ebff200d 100644 --- a/homeassistant/components/homematicip_cloud/hap.py +++ b/homeassistant/components/homematicip_cloud/hap.py @@ -128,6 +128,7 @@ class HomematicipHAP: self.config_entry.data.get(HMIPC_AUTHTOKEN), self.config_entry.data.get(HMIPC_NAME), ) + except HmipcConnectionError as err: raise ConfigEntryNotReady from err except Exception as err: # noqa: BLE001 @@ -210,41 +211,13 @@ class HomematicipHAP: for device in self.home.devices: device.fire_update_event() - async def async_connect(self) -> None: - """Start WebSocket connection.""" - tries = 0 - while True: - retry_delay = 2 ** min(tries, 8) + async def async_connect(self, home: AsyncHome) -> None: + """Connect to HomematicIP Cloud Websocket.""" + await home.enable_events() - try: - await self.home.get_current_state_async() - hmip_events = self.home.enable_events() - self.home.set_on_connected_handler(self.ws_connected_handler) - self.home.set_on_disconnected_handler(self.ws_disconnected_handler) - tries = 0 - await hmip_events - except HmipConnectionError: - _LOGGER.error( - ( - "Error connecting to HomematicIP with HAP %s. " - "Retrying in %d seconds" - ), - self.config_entry.unique_id, - retry_delay, - ) - - if self._ws_close_requested: - break - self._ws_close_requested = False - tries += 1 - - try: - self._retry_task = self.hass.async_create_task( - asyncio.sleep(retry_delay) - ) - await self._retry_task - except asyncio.CancelledError: - break + home.set_on_connected_handler(self.ws_connected_handler) + home.set_on_disconnected_handler(self.ws_disconnected_handler) + home.set_on_reconnect_handler(self.ws_reconnected_handler) async def async_reset(self) -> bool: """Close the websocket connection.""" @@ -272,14 +245,22 @@ class HomematicipHAP: async def ws_connected_handler(self) -> None: """Handle websocket connected.""" - _LOGGER.debug("WebSocket connection to HomematicIP established") + _LOGGER.info("Websocket connection to HomematicIP Cloud established") if self._ws_connection_closed.is_set(): await self.get_state() self._ws_connection_closed.clear() async def ws_disconnected_handler(self) -> None: """Handle websocket disconnection.""" - _LOGGER.warning("WebSocket connection to HomematicIP closed") + _LOGGER.warning("Websocket connection to HomematicIP Cloud closed") + self._ws_connection_closed.set() + + async def ws_reconnected_handler(self, reason: str) -> None: + """Handle websocket reconnection.""" + _LOGGER.info( + "Websocket connection to HomematicIP Cloud re-established due to reason: %s", + reason, + ) self._ws_connection_closed.set() async def get_hap( @@ -306,6 +287,6 @@ class HomematicipHAP: home.on_update(self.async_update) home.on_create(self.async_create_entity) - hass.loop.create_task(self.async_connect()) + await self.async_connect(home) return home diff --git a/homeassistant/components/homematicip_cloud/manifest.json b/homeassistant/components/homematicip_cloud/manifest.json index fc4a1cb831f..163f3c402dc 100644 --- a/homeassistant/components/homematicip_cloud/manifest.json +++ b/homeassistant/components/homematicip_cloud/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/homematicip_cloud", "iot_class": "cloud_push", "loggers": ["homematicip"], - "requirements": ["homematicip==2.0.4"] + "requirements": ["homematicip==2.0.5"] } diff --git a/homeassistant/components/homematicip_cloud/switch.py b/homeassistant/components/homematicip_cloud/switch.py index 66a40229c7e..ca591adbf5e 100644 --- a/homeassistant/components/homematicip_cloud/switch.py +++ b/homeassistant/components/homematicip_cloud/switch.py @@ -4,13 +4,14 @@ from __future__ import annotations from typing import Any -from homematicip.base.enums import DeviceType +from homematicip.base.enums import DeviceType, FunctionalChannelType from homematicip.device import ( BrandSwitch2, DinRailSwitch, DinRailSwitch4, FullFlushInputSwitch, HeatingSwitch2, + MotionDetectorSwitchOutdoor, MultiIOBox, OpenCollector8Module, PlugableSwitch, @@ -47,18 +48,34 @@ async def async_setup_entry( and getattr(device, "deviceType", None) != DeviceType.BRAND_SWITCH_MEASURING ): entities.append(HomematicipSwitchMeasuring(hap, device)) - elif isinstance(device, WiredSwitch8): + elif isinstance( + device, + ( + WiredSwitch8, + OpenCollector8Module, + BrandSwitch2, + PrintedCircuitBoardSwitch2, + HeatingSwitch2, + MultiIOBox, + MotionDetectorSwitchOutdoor, + DinRailSwitch, + DinRailSwitch4, + ), + ): + channel_indices = [ + ch.index + for ch in device.functionalChannels + if ch.functionalChannelType + in ( + FunctionalChannelType.SWITCH_CHANNEL, + FunctionalChannelType.MULTI_MODE_INPUT_SWITCH_CHANNEL, + ) + ] entities.extend( HomematicipMultiSwitch(hap, device, channel=channel) - for channel in range(1, 9) - ) - elif isinstance(device, DinRailSwitch): - entities.append(HomematicipMultiSwitch(hap, device, channel=1)) - elif isinstance(device, DinRailSwitch4): - entities.extend( - HomematicipMultiSwitch(hap, device, channel=channel) - for channel in range(1, 5) + for channel in channel_indices ) + elif isinstance( device, ( @@ -68,24 +85,6 @@ async def async_setup_entry( ), ): entities.append(HomematicipSwitch(hap, device)) - elif isinstance(device, OpenCollector8Module): - entities.extend( - HomematicipMultiSwitch(hap, device, channel=channel) - for channel in range(1, 9) - ) - elif isinstance( - device, - ( - BrandSwitch2, - PrintedCircuitBoardSwitch2, - HeatingSwitch2, - MultiIOBox, - ), - ): - entities.extend( - HomematicipMultiSwitch(hap, device, channel=channel) - for channel in range(1, 3) - ) async_add_entities(entities) @@ -108,15 +107,15 @@ class HomematicipMultiSwitch(HomematicipGenericEntity, SwitchEntity): @property def is_on(self) -> bool: """Return true if switch is on.""" - return self._device.functionalChannels[self._channel].on + return self.functional_channel.on async def async_turn_on(self, **kwargs: Any) -> None: """Turn the switch on.""" - await self._device.turn_on_async(self._channel) + await self.functional_channel.async_turn_on() async def async_turn_off(self, **kwargs: Any) -> None: """Turn the switch off.""" - await self._device.turn_off_async(self._channel) + await self.functional_channel.async_turn_off() class HomematicipSwitch(HomematicipMultiSwitch, SwitchEntity): diff --git a/homeassistant/components/homewizard/__init__.py b/homeassistant/components/homewizard/__init__.py index 3831146aed8..6c9530db72c 100644 --- a/homeassistant/components/homewizard/__init__.py +++ b/homeassistant/components/homewizard/__init__.py @@ -23,9 +23,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: HomeWizardConfigEntry) - api: HomeWizardEnergy - is_battery = entry.unique_id.startswith("HWE-BAT") if entry.unique_id else False - - if (token := entry.data.get(CONF_TOKEN)) and is_battery: + if token := entry.data.get(CONF_TOKEN): api = HomeWizardEnergyV2( entry.data[CONF_IP_ADDRESS], token=token, @@ -37,8 +35,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: HomeWizardConfigEntry) - clientsession=async_get_clientsession(hass), ) - if is_battery: - await async_check_v2_support_and_create_issue(hass, entry) + await async_check_v2_support_and_create_issue(hass, entry) coordinator = HWEnergyDeviceUpdateCoordinator(hass, entry, api) try: diff --git a/homeassistant/components/husqvarna_automower/manifest.json b/homeassistant/components/husqvarna_automower/manifest.json index 705975bb966..29a4fafb8c0 100644 --- a/homeassistant/components/husqvarna_automower/manifest.json +++ b/homeassistant/components/husqvarna_automower/manifest.json @@ -8,5 +8,5 @@ "iot_class": "cloud_push", "loggers": ["aioautomower"], "quality_scale": "silver", - "requirements": ["aioautomower==2025.5.1"] + "requirements": ["aioautomower==2025.6.0"] } diff --git a/homeassistant/components/lamarzocco/manifest.json b/homeassistant/components/lamarzocco/manifest.json index 46a29427264..7fdafc4dda1 100644 --- a/homeassistant/components/lamarzocco/manifest.json +++ b/homeassistant/components/lamarzocco/manifest.json @@ -37,5 +37,5 @@ "iot_class": "cloud_push", "loggers": ["pylamarzocco"], "quality_scale": "platinum", - "requirements": ["pylamarzocco==2.0.8"] + "requirements": ["pylamarzocco==2.0.9"] } diff --git a/homeassistant/components/lamarzocco/number.py b/homeassistant/components/lamarzocco/number.py index 980a08c09ae..f8cb8b1d6fe 100644 --- a/homeassistant/components/lamarzocco/number.py +++ b/homeassistant/components/lamarzocco/number.py @@ -58,6 +58,10 @@ ENTITIES: tuple[LaMarzoccoNumberEntityDescription, ...] = ( CoffeeBoiler, machine.dashboard.config[WidgetType.CM_COFFEE_BOILER] ).target_temperature ), + available_fn=( + lambda coordinator: WidgetType.CM_COFFEE_BOILER + in coordinator.device.dashboard.config + ), ), LaMarzoccoNumberEntityDescription( key="smart_standby_time", @@ -221,7 +225,7 @@ class LaMarzoccoNumberEntity(LaMarzoccoEntity, NumberEntity): entity_description: LaMarzoccoNumberEntityDescription @property - def native_value(self) -> float: + def native_value(self) -> float | int: """Return the current value.""" return self.entity_description.native_value_fn(self.coordinator.device) diff --git a/homeassistant/components/lamarzocco/sensor.py b/homeassistant/components/lamarzocco/sensor.py index 29f1c6209ec..c76f51c3488 100644 --- a/homeassistant/components/lamarzocco/sensor.py +++ b/homeassistant/components/lamarzocco/sensor.py @@ -57,6 +57,10 @@ ENTITIES: tuple[LaMarzoccoSensorEntityDescription, ...] = ( ).ready_start_time ), entity_category=EntityCategory.DIAGNOSTIC, + available_fn=( + lambda coordinator: WidgetType.CM_COFFEE_BOILER + in coordinator.device.dashboard.config + ), ), LaMarzoccoSensorEntityDescription( key="steam_boiler_ready_time", diff --git a/homeassistant/components/lcn/manifest.json b/homeassistant/components/lcn/manifest.json index 9575c01515b..30584bc33f6 100644 --- a/homeassistant/components/lcn/manifest.json +++ b/homeassistant/components/lcn/manifest.json @@ -8,5 +8,5 @@ "documentation": "https://www.home-assistant.io/integrations/lcn", "iot_class": "local_push", "loggers": ["pypck"], - "requirements": ["pypck==0.8.7", "lcn-frontend==0.2.5"] + "requirements": ["pypck==0.8.8", "lcn-frontend==0.2.5"] } diff --git a/homeassistant/components/letpot/coordinator.py b/homeassistant/components/letpot/coordinator.py index bd787157482..39e49348663 100644 --- a/homeassistant/components/letpot/coordinator.py +++ b/homeassistant/components/letpot/coordinator.py @@ -3,6 +3,7 @@ from __future__ import annotations import asyncio +from datetime import timedelta import logging from letpot.deviceclient import LetPotDeviceClient @@ -42,6 +43,7 @@ class LetPotDeviceCoordinator(DataUpdateCoordinator[LetPotDeviceStatus]): _LOGGER, config_entry=config_entry, name=f"LetPot {device.serial_number}", + update_interval=timedelta(minutes=10), ) self._info = info self.device = device diff --git a/homeassistant/components/letpot/quality_scale.yaml b/homeassistant/components/letpot/quality_scale.yaml index 9804a5ec3a4..f5e88bfc369 100644 --- a/homeassistant/components/letpot/quality_scale.yaml +++ b/homeassistant/components/letpot/quality_scale.yaml @@ -5,9 +5,9 @@ rules: comment: | This integration does not provide additional actions. appropriate-polling: - status: exempt + status: done comment: | - This integration only receives push-based updates. + Primarily uses push, but polls with a long interval for availability and missed updates. brands: done common-modules: done config-flow-test-coverage: done @@ -39,7 +39,7 @@ rules: comment: | The integration does not have configuration options. docs-installation-parameters: done - entity-unavailable: todo + entity-unavailable: done integration-owner: done log-when-unavailable: todo parallel-updates: done diff --git a/homeassistant/components/litterrobot/manifest.json b/homeassistant/components/litterrobot/manifest.json index f7563296711..81f987f8c1f 100644 --- a/homeassistant/components/litterrobot/manifest.json +++ b/homeassistant/components/litterrobot/manifest.json @@ -13,5 +13,5 @@ "iot_class": "cloud_push", "loggers": ["pylitterbot"], "quality_scale": "bronze", - "requirements": ["pylitterbot==2024.0.0"] + "requirements": ["pylitterbot==2024.2.0"] } diff --git a/homeassistant/components/local_calendar/manifest.json b/homeassistant/components/local_calendar/manifest.json index e0b08313d63..3bf00f30624 100644 --- a/homeassistant/components/local_calendar/manifest.json +++ b/homeassistant/components/local_calendar/manifest.json @@ -7,5 +7,5 @@ "documentation": "https://www.home-assistant.io/integrations/local_calendar", "iot_class": "local_polling", "loggers": ["ical"], - "requirements": ["ical==10.0.0"] + "requirements": ["ical==10.0.4"] } diff --git a/homeassistant/components/local_todo/manifest.json b/homeassistant/components/local_todo/manifest.json index c8e80e4f91b..134cea5293b 100644 --- a/homeassistant/components/local_todo/manifest.json +++ b/homeassistant/components/local_todo/manifest.json @@ -5,5 +5,5 @@ "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/local_todo", "iot_class": "local_polling", - "requirements": ["ical==10.0.0"] + "requirements": ["ical==10.0.4"] } diff --git a/homeassistant/components/mcp_server/http.py b/homeassistant/components/mcp_server/http.py index bc8fdbd56c8..07284b29434 100644 --- a/homeassistant/components/mcp_server/http.py +++ b/homeassistant/components/mcp_server/http.py @@ -88,7 +88,6 @@ class ModelContextProtocolSSEView(HomeAssistantView): context = llm.LLMContext( platform=DOMAIN, context=self.context(request), - user_prompt=None, language="*", assistant=conversation.DOMAIN, device_id=None, diff --git a/homeassistant/components/meater/__init__.py b/homeassistant/components/meater/__init__.py index 50eff40c0e8..0a9fa77f902 100644 --- a/homeassistant/components/meater/__init__.py +++ b/homeassistant/components/meater/__init__.py @@ -1,93 +1,28 @@ """The Meater Temperature Probe integration.""" -import asyncio -from datetime import timedelta -import logging - -from meater import ( - AuthenticationError, - MeaterApi, - ServiceUnavailableError, - TooManyRequestsError, -) -from meater.MeaterApi import MeaterProbe - -from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_PASSWORD, CONF_USERNAME, Platform +from homeassistant.const import Platform from homeassistant.core import HomeAssistant -from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady -from homeassistant.helpers.aiohttp_client import async_get_clientsession -from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from .const import DOMAIN +from .coordinator import MeaterConfigEntry, MeaterCoordinator PLATFORMS = [Platform.SENSOR] -_LOGGER = logging.getLogger(__name__) - -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_setup_entry(hass: HomeAssistant, entry: MeaterConfigEntry) -> bool: """Set up Meater Temperature Probe from a config entry.""" - # Store an API object to access - session = async_get_clientsession(hass) - meater_api = MeaterApi(session) - # Add the credentials - try: - _LOGGER.debug("Authenticating with the Meater API") - await meater_api.authenticate( - entry.data[CONF_USERNAME], entry.data[CONF_PASSWORD] - ) - except (ServiceUnavailableError, TooManyRequestsError) as err: - raise ConfigEntryNotReady from err - except AuthenticationError as err: - raise ConfigEntryAuthFailed( - f"Unable to authenticate with the Meater API: {err}" - ) from err - - async def async_update_data() -> dict[str, MeaterProbe]: - """Fetch data from API endpoint.""" - try: - # Note: TimeoutError and aiohttp.ClientError are already - # handled by the data update coordinator. - async with asyncio.timeout(10): - devices: list[MeaterProbe] = await meater_api.get_all_devices() - except AuthenticationError as err: - raise ConfigEntryAuthFailed("The API call wasn't authenticated") from err - except TooManyRequestsError as err: - raise UpdateFailed( - "Too many requests have been made to the API, rate limiting is in place" - ) from err - - return {device.id: device for device in devices} - - coordinator = DataUpdateCoordinator( - hass, - _LOGGER, - config_entry=entry, - # Name of the data. For logging purposes. - name="meater_api", - update_method=async_update_data, - # Polling interval. Will only be polled if there are subscribers. - update_interval=timedelta(seconds=30), - ) + coordinator = MeaterCoordinator(hass, entry) await coordinator.async_config_entry_first_refresh() - hass.data.setdefault(DOMAIN, {}) - hass.data[DOMAIN].setdefault("known_probes", set()) + hass.data.setdefault(DOMAIN, {}).setdefault("known_probes", set()) - hass.data[DOMAIN][entry.entry_id] = { - "api": meater_api, - "coordinator": coordinator, - } + entry.runtime_data = coordinator await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) return True -async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_unload_entry(hass: HomeAssistant, entry: MeaterConfigEntry) -> bool: """Unload a config entry.""" - if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS): - hass.data[DOMAIN].pop(entry.entry_id) - - return unload_ok + return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/homeassistant/components/meater/coordinator.py b/homeassistant/components/meater/coordinator.py new file mode 100644 index 00000000000..042a3c87b0c --- /dev/null +++ b/homeassistant/components/meater/coordinator.py @@ -0,0 +1,77 @@ +"""Meater Coordinator.""" + +import asyncio +from datetime import timedelta +import logging + +from meater.MeaterApi import ( + AuthenticationError, + MeaterApi, + MeaterProbe, + ServiceUnavailableError, + TooManyRequestsError, +) + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_PASSWORD, CONF_USERNAME +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryAuthFailed +from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed + +_LOGGER = logging.getLogger(__name__) + +type MeaterConfigEntry = ConfigEntry[MeaterCoordinator] + + +class MeaterCoordinator(DataUpdateCoordinator[dict[str, MeaterProbe]]): + """Meater Coordinator.""" + + config_entry: MeaterConfigEntry + + def __init__( + self, + hass: HomeAssistant, + entry: MeaterConfigEntry, + ) -> None: + """Initialize the Meater Coordinator.""" + super().__init__( + hass, + _LOGGER, + config_entry=entry, + name=f"Meater {entry.title}", + update_interval=timedelta(seconds=30), + ) + session = async_get_clientsession(hass) + self.client = MeaterApi(session) + + async def _async_setup(self) -> None: + """Set up the Meater Coordinator.""" + try: + _LOGGER.debug("Authenticating with the Meater API") + await self.client.authenticate( + self.config_entry.data[CONF_USERNAME], + self.config_entry.data[CONF_PASSWORD], + ) + except (ServiceUnavailableError, TooManyRequestsError) as err: + raise UpdateFailed from err + except AuthenticationError as err: + raise ConfigEntryAuthFailed( + f"Unable to authenticate with the Meater API: {err}" + ) from err + + async def _async_update_data(self) -> dict[str, MeaterProbe]: + """Fetch data from API endpoint.""" + try: + # Note: TimeoutError and aiohttp.ClientError are already + # handled by the data update coordinator. + async with asyncio.timeout(10): + devices: list[MeaterProbe] = await self.client.get_all_devices() + except AuthenticationError as err: + raise ConfigEntryAuthFailed("The API call wasn't authenticated") from err + except TooManyRequestsError as err: + raise UpdateFailed( + "Too many requests have been made to the API, rate limiting is in place" + ) from err + + return {device.id: device for device in devices} diff --git a/homeassistant/components/meater/diagnostics.py b/homeassistant/components/meater/diagnostics.py new file mode 100644 index 00000000000..247457d0bc8 --- /dev/null +++ b/homeassistant/components/meater/diagnostics.py @@ -0,0 +1,55 @@ +"""Diagnostics support for the Meater integration.""" + +from __future__ import annotations + +from typing import Any + +from homeassistant.core import HomeAssistant + +from . import MeaterConfigEntry + + +async def async_get_config_entry_diagnostics( + hass: HomeAssistant, config_entry: MeaterConfigEntry +) -> dict[str, Any]: + """Return diagnostics for a config entry.""" + coordinator = config_entry.runtime_data + + return { + identifier: { + "id": probe.id, + "internal_temperature": probe.internal_temperature, + "ambient_temperature": probe.ambient_temperature, + "time_updated": probe.time_updated.isoformat(), + "cook": ( + { + "id": probe.cook.id, + "name": probe.cook.name, + "state": probe.cook.state, + "target_temperature": ( + probe.cook.target_temperature + if hasattr(probe.cook, "target_temperature") + else None + ), + "peak_temperature": ( + probe.cook.peak_temperature + if hasattr(probe.cook, "peak_temperature") + else None + ), + "time_remaining": ( + probe.cook.time_remaining + if hasattr(probe.cook, "time_remaining") + else None + ), + "time_elapsed": ( + probe.cook.time_elapsed + if hasattr(probe.cook, "time_elapsed") + else None + ), + } + if probe.cook + else None + ), + } + for identifier, probe in coordinator.data.items() + } diff --git a/homeassistant/components/meater/sensor.py b/homeassistant/components/meater/sensor.py index 00fc28b8718..61833babd47 100644 --- a/homeassistant/components/meater/sensor.py +++ b/homeassistant/components/meater/sensor.py @@ -14,18 +14,28 @@ from homeassistant.components.sensor import ( SensorEntityDescription, SensorStateClass, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import UnitOfTemperature from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from homeassistant.helpers.update_coordinator import ( - CoordinatorEntity, - DataUpdateCoordinator, -) +from homeassistant.helpers.update_coordinator import CoordinatorEntity from homeassistant.util import dt as dt_util +from . import MeaterCoordinator from .const import DOMAIN +from .coordinator import MeaterConfigEntry + +COOK_STATES = { + "Not Started": "not_started", + "Configured": "configured", + "Started": "started", + "Ready For Resting": "ready_for_resting", + "Resting": "resting", + "Slightly Underdone": "slightly_underdone", + "Finished": "finished", + "Slightly Overdone": "slightly_overdone", + "OVERCOOK!": "overcooked", +} @dataclass(frozen=True, kw_only=True) @@ -82,13 +92,13 @@ SENSOR_TYPES = ( available=lambda probe: probe is not None and probe.cook is not None, value=lambda probe: probe.cook.name if probe.cook else None, ), - # One of Not Started, Configured, Started, Ready For Resting, Resting, - # Slightly Underdone, Finished, Slightly Overdone, OVERCOOK!. Not translated. MeaterSensorEntityDescription( key="cook_state", translation_key="cook_state", available=lambda probe: probe is not None and probe.cook is not None, - value=lambda probe: probe.cook.state if probe.cook else None, + device_class=SensorDeviceClass.ENUM, + options=list(COOK_STATES.values()), + value=lambda probe: COOK_STATES.get(probe.cook.state) if probe.cook else None, ), # Target temperature MeaterSensorEntityDescription( @@ -137,13 +147,11 @@ SENSOR_TYPES = ( async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: MeaterConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the entry.""" - coordinator: DataUpdateCoordinator[dict[str, MeaterProbe]] = hass.data[DOMAIN][ - entry.entry_id - ]["coordinator"] + coordinator = entry.runtime_data @callback def async_update_data(): @@ -174,11 +182,10 @@ async def async_setup_entry( # Add a subscriber to the coordinator to discover new temperature probes coordinator.async_add_listener(async_update_data) + async_update_data() -class MeaterProbeTemperature( - SensorEntity, CoordinatorEntity[DataUpdateCoordinator[dict[str, MeaterProbe]]] -): +class MeaterProbeTemperature(SensorEntity, CoordinatorEntity[MeaterCoordinator]): """Meater Temperature Sensor Entity.""" entity_description: MeaterSensorEntityDescription diff --git a/homeassistant/components/meater/strings.json b/homeassistant/components/meater/strings.json index 20dd2919026..a578f895a8c 100644 --- a/homeassistant/components/meater/strings.json +++ b/homeassistant/components/meater/strings.json @@ -40,7 +40,18 @@ "name": "Cooking" }, "cook_state": { - "name": "Cook state" + "name": "Cook state", + "state": { + "not_started": "Not started", + "configured": "Configured", + "started": "Started", + "ready_for_resting": "Ready for resting", + "resting": "Resting", + "slightly_underdone": "Slightly underdone", + "finished": "Finished", + "slightly_overdone": "Slightly overdone", + "overcooked": "Overcooked" + } }, "cook_target_temp": { "name": "Target temperature" diff --git a/homeassistant/components/media_player/__init__.py b/homeassistant/components/media_player/__init__.py index 0979852ecce..d0c6bcabfcf 100644 --- a/homeassistant/components/media_player/__init__.py +++ b/homeassistant/components/media_player/__init__.py @@ -814,19 +814,6 @@ class MediaPlayerEntity(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_): """Flag media player features that are supported.""" return self._attr_supported_features - @property - def supported_features_compat(self) -> MediaPlayerEntityFeature: - """Return the supported features as MediaPlayerEntityFeature. - - Remove this compatibility shim in 2025.1 or later. - """ - features = self.supported_features - if type(features) is int: - new_features = MediaPlayerEntityFeature(features) - self._report_deprecated_supported_features_values(new_features) - return new_features - return features - def turn_on(self) -> None: """Turn the media player on.""" raise NotImplementedError @@ -966,87 +953,85 @@ class MediaPlayerEntity(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_): @property def support_play(self) -> bool: """Boolean if play is supported.""" - return MediaPlayerEntityFeature.PLAY in self.supported_features_compat + return MediaPlayerEntityFeature.PLAY in self.supported_features @final @property def support_pause(self) -> bool: """Boolean if pause is supported.""" - return MediaPlayerEntityFeature.PAUSE in self.supported_features_compat + return MediaPlayerEntityFeature.PAUSE in self.supported_features @final @property def support_stop(self) -> bool: """Boolean if stop is supported.""" - return MediaPlayerEntityFeature.STOP in self.supported_features_compat + return MediaPlayerEntityFeature.STOP in self.supported_features @final @property def support_seek(self) -> bool: """Boolean if seek is supported.""" - return MediaPlayerEntityFeature.SEEK in self.supported_features_compat + return MediaPlayerEntityFeature.SEEK in self.supported_features @final @property def support_volume_set(self) -> bool: """Boolean if setting volume is supported.""" - return MediaPlayerEntityFeature.VOLUME_SET in self.supported_features_compat + return MediaPlayerEntityFeature.VOLUME_SET in self.supported_features @final @property def support_volume_mute(self) -> bool: """Boolean if muting volume is supported.""" - return MediaPlayerEntityFeature.VOLUME_MUTE in self.supported_features_compat + return MediaPlayerEntityFeature.VOLUME_MUTE in self.supported_features @final @property def support_previous_track(self) -> bool: """Boolean if previous track command supported.""" - return MediaPlayerEntityFeature.PREVIOUS_TRACK in self.supported_features_compat + return MediaPlayerEntityFeature.PREVIOUS_TRACK in self.supported_features @final @property def support_next_track(self) -> bool: """Boolean if next track command supported.""" - return MediaPlayerEntityFeature.NEXT_TRACK in self.supported_features_compat + return MediaPlayerEntityFeature.NEXT_TRACK in self.supported_features @final @property def support_play_media(self) -> bool: """Boolean if play media command supported.""" - return MediaPlayerEntityFeature.PLAY_MEDIA in self.supported_features_compat + return MediaPlayerEntityFeature.PLAY_MEDIA in self.supported_features @final @property def support_select_source(self) -> bool: """Boolean if select source command supported.""" - return MediaPlayerEntityFeature.SELECT_SOURCE in self.supported_features_compat + return MediaPlayerEntityFeature.SELECT_SOURCE in self.supported_features @final @property def support_select_sound_mode(self) -> bool: """Boolean if select sound mode command supported.""" - return ( - MediaPlayerEntityFeature.SELECT_SOUND_MODE in self.supported_features_compat - ) + return MediaPlayerEntityFeature.SELECT_SOUND_MODE in self.supported_features @final @property def support_clear_playlist(self) -> bool: """Boolean if clear playlist command supported.""" - return MediaPlayerEntityFeature.CLEAR_PLAYLIST in self.supported_features_compat + return MediaPlayerEntityFeature.CLEAR_PLAYLIST in self.supported_features @final @property def support_shuffle_set(self) -> bool: """Boolean if shuffle is supported.""" - return MediaPlayerEntityFeature.SHUFFLE_SET in self.supported_features_compat + return MediaPlayerEntityFeature.SHUFFLE_SET in self.supported_features @final @property def support_grouping(self) -> bool: """Boolean if player grouping is supported.""" - return MediaPlayerEntityFeature.GROUPING in self.supported_features_compat + return MediaPlayerEntityFeature.GROUPING in self.supported_features async def async_toggle(self) -> None: """Toggle the power on the media player.""" @@ -1074,7 +1059,7 @@ class MediaPlayerEntity(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_): if ( self.volume_level is not None and self.volume_level < 1 - and MediaPlayerEntityFeature.VOLUME_SET in self.supported_features_compat + and MediaPlayerEntityFeature.VOLUME_SET in self.supported_features ): await self.async_set_volume_level( min(1, self.volume_level + self.volume_step) @@ -1092,7 +1077,7 @@ class MediaPlayerEntity(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_): if ( self.volume_level is not None and self.volume_level > 0 - and MediaPlayerEntityFeature.VOLUME_SET in self.supported_features_compat + and MediaPlayerEntityFeature.VOLUME_SET in self.supported_features ): await self.async_set_volume_level( max(0, self.volume_level - self.volume_step) @@ -1135,7 +1120,7 @@ class MediaPlayerEntity(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_): def capability_attributes(self) -> dict[str, Any]: """Return capability attributes.""" data: dict[str, Any] = {} - supported_features = self.supported_features_compat + supported_features = self.supported_features if ( source_list := self.source_list @@ -1364,7 +1349,7 @@ async def websocket_browse_media( connection.send_error(msg["id"], "entity_not_found", "Entity not found") return - if MediaPlayerEntityFeature.BROWSE_MEDIA not in player.supported_features_compat: + if MediaPlayerEntityFeature.BROWSE_MEDIA not in player.supported_features: connection.send_message( websocket_api.error_message( msg["id"], ERR_NOT_SUPPORTED, "Player does not support browsing media" @@ -1447,7 +1432,7 @@ async def websocket_search_media( connection.send_error(msg["id"], "entity_not_found", "Entity not found") return - if MediaPlayerEntityFeature.SEARCH_MEDIA not in player.supported_features_compat: + if MediaPlayerEntityFeature.SEARCH_MEDIA not in player.supported_features: connection.send_message( websocket_api.error_message( msg["id"], ERR_NOT_SUPPORTED, "Player does not support searching media" diff --git a/homeassistant/components/minecraft_server/__init__.py b/homeassistant/components/minecraft_server/__init__.py index d8f60380a6c..e74b78446e5 100644 --- a/homeassistant/components/minecraft_server/__init__.py +++ b/homeassistant/components/minecraft_server/__init__.py @@ -5,6 +5,7 @@ from __future__ import annotations import logging from typing import Any +import dns.asyncresolver import dns.rdata import dns.rdataclass import dns.rdatatype @@ -22,20 +23,23 @@ PLATFORMS = [Platform.BINARY_SENSOR, Platform.SENSOR] _LOGGER = logging.getLogger(__name__) -def load_dnspython_rdata_classes() -> None: - """Load dnspython rdata classes used by mcstatus.""" +def prevent_dnspython_blocking_operations() -> None: + """Prevent dnspython blocking operations by pre-loading required data.""" + + # Blocking import: https://github.com/rthalley/dnspython/issues/1083 for rdtype in dns.rdatatype.RdataType: if not dns.rdatatype.is_metatype(rdtype) or rdtype == dns.rdatatype.OPT: dns.rdata.get_rdata_class(dns.rdataclass.IN, rdtype) # type: ignore[no-untyped-call] + # Blocking open: https://github.com/rthalley/dnspython/issues/1200 + dns.asyncresolver.get_default_resolver() + async def async_setup_entry( hass: HomeAssistant, entry: MinecraftServerConfigEntry ) -> bool: """Set up Minecraft Server from a config entry.""" - - # Workaround to avoid blocking imports from dnspython (https://github.com/rthalley/dnspython/issues/1083) - await hass.async_add_executor_job(load_dnspython_rdata_classes) + await hass.async_add_executor_job(prevent_dnspython_blocking_operations) # Create coordinator instance and store it. coordinator = MinecraftServerCoordinator(hass, entry) diff --git a/homeassistant/components/motion_blinds/cover.py b/homeassistant/components/motion_blinds/cover.py index 165c4c19675..9cff2956a5f 100644 --- a/homeassistant/components/motion_blinds/cover.py +++ b/homeassistant/components/motion_blinds/cover.py @@ -62,6 +62,7 @@ TILT_DEVICE_MAP = { BlindType.VerticalBlind: CoverDeviceClass.BLIND, BlindType.VerticalBlindLeft: CoverDeviceClass.BLIND, BlindType.VerticalBlindRight: CoverDeviceClass.BLIND, + BlindType.RollerTiltMotor: CoverDeviceClass.BLIND, } TILT_ONLY_DEVICE_MAP = { diff --git a/homeassistant/components/motion_blinds/manifest.json b/homeassistant/components/motion_blinds/manifest.json index 1a6c9c5f82f..a82da20396f 100644 --- a/homeassistant/components/motion_blinds/manifest.json +++ b/homeassistant/components/motion_blinds/manifest.json @@ -21,5 +21,5 @@ "documentation": "https://www.home-assistant.io/integrations/motion_blinds", "iot_class": "local_push", "loggers": ["motionblinds"], - "requirements": ["motionblinds==0.6.27"] + "requirements": ["motionblinds==0.6.28"] } diff --git a/homeassistant/components/mqtt/sensor.py b/homeassistant/components/mqtt/sensor.py index 46d475fcee8..783a0b30b14 100644 --- a/homeassistant/components/mqtt/sensor.py +++ b/homeassistant/components/mqtt/sensor.py @@ -35,7 +35,6 @@ from homeassistant.core import CALLBACK_TYPE, HomeAssistant, State, callback from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.event import async_call_later -from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue from homeassistant.helpers.service_info.mqtt import ReceivePayloadType from homeassistant.helpers.typing import ConfigType, VolSchemaType from homeassistant.util import dt as dt_util @@ -48,7 +47,6 @@ from .const import ( CONF_OPTIONS, CONF_STATE_TOPIC, CONF_SUGGESTED_DISPLAY_PRECISION, - DOMAIN, PAYLOAD_NONE, ) from .entity import MqttAvailabilityMixin, MqttEntity, async_setup_entity_entry_helper @@ -138,12 +136,9 @@ def validate_sensor_state_and_device_class_config(config: ConfigType) -> ConfigT device_class in DEVICE_CLASS_UNITS and unit_of_measurement not in DEVICE_CLASS_UNITS[device_class] ): - _LOGGER.warning( - "The unit of measurement `%s` is not valid " - "together with device class `%s`. " - "this will stop working in HA Core 2025.7.0", - unit_of_measurement, - device_class, + raise vol.Invalid( + f"The unit of measurement `{unit_of_measurement}` is not valid " + f"together with device class `{device_class}`", ) return config @@ -194,40 +189,8 @@ class MqttSensor(MqttEntity, RestoreSensor): None ) - @callback - def async_check_uom(self) -> None: - """Check if the unit of measurement is valid with the device class.""" - if ( - self._discovery_data is not None - or self.device_class is None - or self.native_unit_of_measurement is None - ): - return - if ( - self.device_class in DEVICE_CLASS_UNITS - and self.native_unit_of_measurement - not in DEVICE_CLASS_UNITS[self.device_class] - ): - async_create_issue( - self.hass, - DOMAIN, - self.entity_id, - issue_domain=sensor.DOMAIN, - is_fixable=False, - severity=IssueSeverity.WARNING, - learn_more_url=URL_DOCS_SUPPORTED_SENSOR_UOM, - translation_placeholders={ - "uom": self.native_unit_of_measurement, - "device_class": self.device_class.value, - "entity_id": self.entity_id, - }, - translation_key="invalid_unit_of_measurement", - breaks_in_ha_version="2025.7.0", - ) - async def mqtt_async_added_to_hass(self) -> None: """Restore state for entities with expire_after set.""" - self.async_check_uom() last_state: State | None last_sensor_data: SensorExtraStoredData | None if ( diff --git a/homeassistant/components/mqtt/strings.json b/homeassistant/components/mqtt/strings.json index a132fdc27d8..ed7da6fc112 100644 --- a/homeassistant/components/mqtt/strings.json +++ b/homeassistant/components/mqtt/strings.json @@ -3,10 +3,6 @@ "invalid_platform_config": { "title": "Invalid config found for MQTT {domain} item", "description": "Home Assistant detected an invalid config for a manually configured item.\n\nPlatform domain: **{domain}**\nConfiguration file: **{config_file}**\nNear line: **{line}**\nConfiguration found:\n```yaml\n{config}\n```\nError: **{error}**.\n\nMake sure the configuration is valid and [reload](/developer-tools/yaml) the manually configured MQTT items or restart Home Assistant to fix this issue." - }, - "invalid_unit_of_measurement": { - "title": "Sensor with invalid unit of measurement", - "description": "Manual configured Sensor entity **{entity_id}** has a configured unit of measurement **{uom}** which is not valid with configured device class **{device_class}**. Make sure a valid unit of measurement is configured or remove the device class, and [reload](/developer-tools/yaml) the manually configured MQTT items or restart Home Assistant to fix this issue." } }, "config": { diff --git a/homeassistant/components/mysensors/manifest.json b/homeassistant/components/mysensors/manifest.json index b272a610516..a4b802f001c 100644 --- a/homeassistant/components/mysensors/manifest.json +++ b/homeassistant/components/mysensors/manifest.json @@ -7,5 +7,5 @@ "documentation": "https://www.home-assistant.io/integrations/mysensors", "iot_class": "local_push", "loggers": ["mysensors"], - "requirements": ["pymysensors==0.24.0"] + "requirements": ["pymysensors==0.25.0"] } diff --git a/homeassistant/components/ness_alarm/manifest.json b/homeassistant/components/ness_alarm/manifest.json index 3d97e3290e0..79227e8564b 100644 --- a/homeassistant/components/ness_alarm/manifest.json +++ b/homeassistant/components/ness_alarm/manifest.json @@ -6,5 +6,5 @@ "iot_class": "local_push", "loggers": ["nessclient"], "quality_scale": "legacy", - "requirements": ["nessclient==1.1.2"] + "requirements": ["nessclient==1.2.0"] } diff --git a/homeassistant/components/nextbus/manifest.json b/homeassistant/components/nextbus/manifest.json index 4b7057f7142..c1da33f2555 100644 --- a/homeassistant/components/nextbus/manifest.json +++ b/homeassistant/components/nextbus/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/nextbus", "iot_class": "cloud_polling", "loggers": ["py_nextbus"], - "requirements": ["py-nextbusnext==2.2.0"] + "requirements": ["py-nextbusnext==2.3.0"] } diff --git a/homeassistant/components/nextdns/binary_sensor.py b/homeassistant/components/nextdns/binary_sensor.py index ed244146efc..5107fcd00d6 100644 --- a/homeassistant/components/nextdns/binary_sensor.py +++ b/homeassistant/components/nextdns/binary_sensor.py @@ -13,14 +13,13 @@ from homeassistant.components.binary_sensor import ( BinarySensorEntityDescription, ) from homeassistant.const import EntityCategory -from homeassistant.core import HomeAssistant, callback +from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from homeassistant.helpers.update_coordinator import CoordinatorEntity from . import NextDnsConfigEntry -from .coordinator import NextDnsUpdateCoordinator +from .entity import NextDnsEntity -PARALLEL_UPDATES = 1 +PARALLEL_UPDATES = 0 @dataclass(frozen=True, kw_only=True) @@ -61,30 +60,14 @@ async def async_setup_entry( ) -class NextDnsBinarySensor( - CoordinatorEntity[NextDnsUpdateCoordinator[ConnectionStatus]], BinarySensorEntity -): +class NextDnsBinarySensor(NextDnsEntity, BinarySensorEntity): """Define an NextDNS binary sensor.""" - _attr_has_entity_name = True entity_description: NextDnsBinarySensorEntityDescription - def __init__( - self, - coordinator: NextDnsUpdateCoordinator[ConnectionStatus], - description: NextDnsBinarySensorEntityDescription, - ) -> None: - """Initialize.""" - super().__init__(coordinator) - self._attr_device_info = coordinator.device_info - self._attr_unique_id = f"{coordinator.profile_id}_{description.key}" - self._attr_is_on = description.state(coordinator.data, coordinator.profile_id) - self.entity_description = description - - @callback - def _handle_coordinator_update(self) -> None: - """Handle updated data from the coordinator.""" - self._attr_is_on = self.entity_description.state( + @property + def is_on(self) -> bool: + """Return True if the binary sensor is on.""" + return self.entity_description.state( self.coordinator.data, self.coordinator.profile_id ) - self.async_write_ha_state() diff --git a/homeassistant/components/nextdns/button.py b/homeassistant/components/nextdns/button.py index 2adccaa304f..5c78d794120 100644 --- a/homeassistant/components/nextdns/button.py +++ b/homeassistant/components/nextdns/button.py @@ -4,21 +4,21 @@ from __future__ import annotations from aiohttp import ClientError from aiohttp.client_exceptions import ClientConnectorError -from nextdns import AnalyticsStatus, ApiError, InvalidApiKeyError +from nextdns import ApiError, InvalidApiKeyError from homeassistant.components.button import ButtonEntity, ButtonEntityDescription from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from homeassistant.helpers.update_coordinator import CoordinatorEntity from . import NextDnsConfigEntry from .const import DOMAIN -from .coordinator import NextDnsUpdateCoordinator +from .entity import NextDnsEntity PARALLEL_UPDATES = 1 + CLEAR_LOGS_BUTTON = ButtonEntityDescription( key="clear_logs", translation_key="clear_logs", @@ -37,24 +37,9 @@ async def async_setup_entry( async_add_entities([NextDnsButton(coordinator, CLEAR_LOGS_BUTTON)]) -class NextDnsButton( - CoordinatorEntity[NextDnsUpdateCoordinator[AnalyticsStatus]], ButtonEntity -): +class NextDnsButton(NextDnsEntity, ButtonEntity): """Define an NextDNS button.""" - _attr_has_entity_name = True - - def __init__( - self, - coordinator: NextDnsUpdateCoordinator[AnalyticsStatus], - description: ButtonEntityDescription, - ) -> None: - """Initialize.""" - super().__init__(coordinator) - self._attr_device_info = coordinator.device_info - self._attr_unique_id = f"{coordinator.profile_id}_{description.key}" - self.entity_description = description - async def async_press(self) -> None: """Trigger cleaning logs.""" try: diff --git a/homeassistant/components/nextdns/coordinator.py b/homeassistant/components/nextdns/coordinator.py index 41f6ff43a2a..3bc5dfe60d1 100644 --- a/homeassistant/components/nextdns/coordinator.py +++ b/homeassistant/components/nextdns/coordinator.py @@ -24,7 +24,6 @@ from tenacity import RetryError from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryAuthFailed -from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed if TYPE_CHECKING: @@ -53,14 +52,6 @@ class NextDnsUpdateCoordinator(DataUpdateCoordinator[CoordinatorDataT]): """Initialize.""" self.nextdns = nextdns self.profile_id = profile_id - self.profile_name = nextdns.get_profile_name(profile_id) - self.device_info = DeviceInfo( - configuration_url=f"https://my.nextdns.io/{profile_id}/setup", - entry_type=DeviceEntryType.SERVICE, - identifiers={(DOMAIN, str(profile_id))}, - manufacturer="NextDNS Inc.", - name=self.profile_name, - ) super().__init__( hass, diff --git a/homeassistant/components/nextdns/entity.py b/homeassistant/components/nextdns/entity.py new file mode 100644 index 00000000000..26e0a5dd9ef --- /dev/null +++ b/homeassistant/components/nextdns/entity.py @@ -0,0 +1,31 @@ +"""Define NextDNS entities.""" + +from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo +from homeassistant.helpers.entity import EntityDescription +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from .const import DOMAIN +from .coordinator import CoordinatorDataT, NextDnsUpdateCoordinator + + +class NextDnsEntity(CoordinatorEntity[NextDnsUpdateCoordinator[CoordinatorDataT]]): + """Define NextDNS entity.""" + + _attr_has_entity_name = True + + def __init__( + self, + coordinator: NextDnsUpdateCoordinator[CoordinatorDataT], + description: EntityDescription, + ) -> None: + """Initialize.""" + super().__init__(coordinator) + self._attr_device_info = DeviceInfo( + configuration_url=f"https://my.nextdns.io/{coordinator.profile_id}/setup", + entry_type=DeviceEntryType.SERVICE, + identifiers={(DOMAIN, str(coordinator.profile_id))}, + manufacturer="NextDNS Inc.", + name=coordinator.nextdns.get_profile_name(coordinator.profile_id), + ) + self._attr_unique_id = f"{coordinator.profile_id}_{description.key}" + self.entity_description = description diff --git a/homeassistant/components/nextdns/sensor.py b/homeassistant/components/nextdns/sensor.py index 0a4a8eaad8f..b03f262cbeb 100644 --- a/homeassistant/components/nextdns/sensor.py +++ b/homeassistant/components/nextdns/sensor.py @@ -20,10 +20,9 @@ from homeassistant.components.sensor import ( SensorStateClass, ) from homeassistant.const import PERCENTAGE, EntityCategory -from homeassistant.core import HomeAssistant, callback +from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.typing import StateType -from homeassistant.helpers.update_coordinator import CoordinatorEntity from . import NextDnsConfigEntry from .const import ( @@ -33,9 +32,10 @@ from .const import ( ATTR_PROTOCOLS, ATTR_STATUS, ) -from .coordinator import CoordinatorDataT, NextDnsUpdateCoordinator +from .coordinator import CoordinatorDataT +from .entity import NextDnsEntity -PARALLEL_UPDATES = 1 +PARALLEL_UPDATES = 0 @dataclass(frozen=True, kw_only=True) @@ -297,27 +297,12 @@ async def async_setup_entry( ) -class NextDnsSensor( - CoordinatorEntity[NextDnsUpdateCoordinator[CoordinatorDataT]], SensorEntity -): +class NextDnsSensor(NextDnsEntity, SensorEntity): """Define an NextDNS sensor.""" - _attr_has_entity_name = True + entity_description: NextDnsSensorEntityDescription - def __init__( - self, - coordinator: NextDnsUpdateCoordinator[CoordinatorDataT], - description: NextDnsSensorEntityDescription, - ) -> None: - """Initialize.""" - super().__init__(coordinator) - self._attr_device_info = coordinator.device_info - self._attr_unique_id = f"{coordinator.profile_id}_{description.key}" - self._attr_native_value = description.value(coordinator.data) - self.entity_description: NextDnsSensorEntityDescription = description - - @callback - def _handle_coordinator_update(self) -> None: - """Handle updated data from the coordinator.""" - self._attr_native_value = self.entity_description.value(self.coordinator.data) - self.async_write_ha_state() + @property + def native_value(self) -> StateType: + """Return the state of the sensor.""" + return self.entity_description.value(self.coordinator.data) diff --git a/homeassistant/components/nextdns/strings.json b/homeassistant/components/nextdns/strings.json index 38944a0711e..8d7bd6a215f 100644 --- a/homeassistant/components/nextdns/strings.json +++ b/homeassistant/components/nextdns/strings.json @@ -4,16 +4,25 @@ "user": { "data": { "api_key": "[%key:common::config_flow::data::api_key%]" + }, + "data_description": { + "api_key": "The API key for your NextDNS account" } }, "profiles": { "data": { - "profile": "Profile" + "profile_name": "Profile" + }, + "data_description": { + "profile_name": "The NextDNS configuration profile you want to integrate" } }, "reauth_confirm": { "data": { "api_key": "[%key:common::config_flow::data::api_key%]" + }, + "data_description": { + "api_key": "[%key:component::nextdns::config::step::user::data_description::api_key%]" } } }, diff --git a/homeassistant/components/nextdns/switch.py b/homeassistant/components/nextdns/switch.py index 8bdca76b955..872f7430b3d 100644 --- a/homeassistant/components/nextdns/switch.py +++ b/homeassistant/components/nextdns/switch.py @@ -15,11 +15,11 @@ from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from homeassistant.helpers.update_coordinator import CoordinatorEntity from . import NextDnsConfigEntry from .const import DOMAIN from .coordinator import NextDnsUpdateCoordinator +from .entity import NextDnsEntity PARALLEL_UPDATES = 1 @@ -536,12 +536,9 @@ async def async_setup_entry( ) -class NextDnsSwitch( - CoordinatorEntity[NextDnsUpdateCoordinator[Settings]], SwitchEntity -): +class NextDnsSwitch(NextDnsEntity, SwitchEntity): """Define an NextDNS switch.""" - _attr_has_entity_name = True entity_description: NextDnsSwitchEntityDescription def __init__( @@ -550,11 +547,8 @@ class NextDnsSwitch( description: NextDnsSwitchEntityDescription, ) -> None: """Initialize.""" - super().__init__(coordinator) - self._attr_device_info = coordinator.device_info - self._attr_unique_id = f"{coordinator.profile_id}_{description.key}" + super().__init__(coordinator, description) self._attr_is_on = description.state(coordinator.data) - self.entity_description = description @callback def _handle_coordinator_update(self) -> None: diff --git a/homeassistant/components/ollama/conversation.py b/homeassistant/components/ollama/conversation.py index 4b4f79d4eed..e304a39f061 100644 --- a/homeassistant/components/ollama/conversation.py +++ b/homeassistant/components/ollama/conversation.py @@ -219,11 +219,11 @@ class OllamaConversationEntity( settings = {**self.entry.data, **self.entry.options} try: - await chat_log.async_update_llm_data( - DOMAIN, - user_input, + await chat_log.async_provide_llm_data( + user_input.as_llm_context(DOMAIN), settings.get(CONF_LLM_HASS_API), settings.get(CONF_PROMPT), + user_input.extra_system_prompt, ) except conversation.ConverseError as err: return err.as_conversation_result() diff --git a/homeassistant/components/onedrive/coordinator.py b/homeassistant/components/onedrive/coordinator.py index 3eb7d762712..07a8dbd203b 100644 --- a/homeassistant/components/onedrive/coordinator.py +++ b/homeassistant/components/onedrive/coordinator.py @@ -66,6 +66,7 @@ class OneDriveUpdateCoordinator(DataUpdateCoordinator[Drive]): translation_domain=DOMAIN, translation_key="authentication_failed" ) from err except OneDriveException as err: + _LOGGER.debug("Failed to fetch drive data: %s", err, exc_info=True) raise UpdateFailed( translation_domain=DOMAIN, translation_key="update_failed" ) from err diff --git a/homeassistant/components/openai_conversation/conversation.py b/homeassistant/components/openai_conversation/conversation.py index a129400194b..8fea4613ce0 100644 --- a/homeassistant/components/openai_conversation/conversation.py +++ b/homeassistant/components/openai_conversation/conversation.py @@ -279,11 +279,11 @@ class OpenAIConversationEntity( options = self.entry.options try: - await chat_log.async_update_llm_data( - DOMAIN, - user_input, + await chat_log.async_provide_llm_data( + user_input.as_llm_context(DOMAIN), options.get(CONF_LLM_HASS_API), options.get(CONF_PROMPT), + user_input.extra_system_prompt, ) except conversation.ConverseError as err: return err.as_conversation_result() diff --git a/homeassistant/components/osoenergy/manifest.json b/homeassistant/components/osoenergy/manifest.json index c7b81177a2b..6129aa379f7 100644 --- a/homeassistant/components/osoenergy/manifest.json +++ b/homeassistant/components/osoenergy/manifest.json @@ -5,5 +5,5 @@ "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/osoenergy", "iot_class": "cloud_polling", - "requirements": ["pyosoenergyapi==1.1.4"] + "requirements": ["pyosoenergyapi==1.1.5"] } diff --git a/homeassistant/components/paperless_ngx/__init__.py b/homeassistant/components/paperless_ngx/__init__.py index 4d60f47e1e8..0fea90b7ea3 100644 --- a/homeassistant/components/paperless_ngx/__init__.py +++ b/homeassistant/components/paperless_ngx/__init__.py @@ -9,7 +9,7 @@ from pypaperless.exceptions import ( PaperlessInvalidTokenError, ) -from homeassistant.const import CONF_API_KEY, CONF_URL, Platform +from homeassistant.const import CONF_API_KEY, CONF_URL, CONF_VERIFY_SSL, Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import ( ConfigEntryAuthFailed, @@ -69,7 +69,7 @@ async def _get_paperless_api( api = Paperless( entry.data[CONF_URL], entry.data[CONF_API_KEY], - session=async_get_clientsession(hass), + session=async_get_clientsession(hass, entry.data.get(CONF_VERIFY_SSL, True)), ) try: diff --git a/homeassistant/components/paperless_ngx/config_flow.py b/homeassistant/components/paperless_ngx/config_flow.py index c0c1dc4ce19..9a8ea05d168 100644 --- a/homeassistant/components/paperless_ngx/config_flow.py +++ b/homeassistant/components/paperless_ngx/config_flow.py @@ -16,7 +16,7 @@ from pypaperless.exceptions import ( import voluptuous as vol from homeassistant.config_entries import ConfigFlow, ConfigFlowResult -from homeassistant.const import CONF_API_KEY, CONF_URL +from homeassistant.const import CONF_API_KEY, CONF_URL, CONF_VERIFY_SSL from homeassistant.helpers.aiohttp_client import async_get_clientsession from .const import DOMAIN, LOGGER @@ -25,6 +25,7 @@ STEP_USER_DATA_SCHEMA = vol.Schema( { vol.Required(CONF_URL): str, vol.Required(CONF_API_KEY): str, + vol.Required(CONF_VERIFY_SSL, default=True): bool, } ) @@ -78,15 +79,19 @@ class PaperlessConfigFlow(ConfigFlow, domain=DOMAIN): if not errors: return self.async_update_reload_and_abort(entry, data=user_input) + if user_input is not None: + suggested_values = user_input + else: + suggested_values = { + CONF_URL: entry.data[CONF_URL], + CONF_VERIFY_SSL: entry.data.get(CONF_VERIFY_SSL, True), + } + return self.async_show_form( step_id="reconfigure", data_schema=self.add_suggested_values_to_schema( data_schema=STEP_USER_DATA_SCHEMA, - suggested_values={ - CONF_URL: user_input[CONF_URL] - if user_input is not None - else entry.data[CONF_URL], - }, + suggested_values=suggested_values, ), errors=errors, ) @@ -122,13 +127,15 @@ class PaperlessConfigFlow(ConfigFlow, domain=DOMAIN): errors=errors, ) - async def _validate_input(self, user_input: dict[str, str]) -> dict[str, str]: + async def _validate_input(self, user_input: dict[str, Any]) -> dict[str, str]: errors: dict[str, str] = {} client = Paperless( user_input[CONF_URL], user_input[CONF_API_KEY], - session=async_get_clientsession(self.hass), + session=async_get_clientsession( + self.hass, user_input.get(CONF_VERIFY_SSL, True) + ), ) try: diff --git a/homeassistant/components/paperless_ngx/strings.json b/homeassistant/components/paperless_ngx/strings.json index 1347dc83e98..aa3f7ada943 100644 --- a/homeassistant/components/paperless_ngx/strings.json +++ b/homeassistant/components/paperless_ngx/strings.json @@ -4,11 +4,13 @@ "user": { "data": { "url": "[%key:common::config_flow::data::url%]", - "api_key": "[%key:common::config_flow::data::api_key%]" + "api_key": "[%key:common::config_flow::data::api_key%]", + "verify_ssl": "[%key:common::config_flow::data::verify_ssl%]" }, "data_description": { "url": "URL to connect to the Paperless-ngx instance", - "api_key": "API key to connect to the Paperless-ngx API" + "api_key": "API key to connect to the Paperless-ngx API", + "verify_ssl": "Verify the SSL certificate of the Paperless-ngx instance. Disable this option if you’re using a self-signed certificate." }, "title": "Add Paperless-ngx instance" }, @@ -24,11 +26,13 @@ "reconfigure": { "data": { "url": "[%key:common::config_flow::data::url%]", - "api_key": "[%key:common::config_flow::data::api_key%]" + "api_key": "[%key:common::config_flow::data::api_key%]", + "verify_ssl": "[%key:common::config_flow::data::verify_ssl%]" }, "data_description": { "url": "[%key:component::paperless_ngx::config::step::user::data_description::url%]", - "api_key": "[%key:component::paperless_ngx::config::step::user::data_description::api_key%]" + "api_key": "[%key:component::paperless_ngx::config::step::user::data_description::api_key%]", + "verify_ssl": "[%key:component::paperless_ngx::config::step::user::data_description::verify_ssl%]" }, "title": "Reconfigure Paperless-ngx instance" } diff --git a/homeassistant/components/remote_calendar/manifest.json b/homeassistant/components/remote_calendar/manifest.json index 052b409dfe7..6ba1dea55ed 100644 --- a/homeassistant/components/remote_calendar/manifest.json +++ b/homeassistant/components/remote_calendar/manifest.json @@ -8,5 +8,5 @@ "iot_class": "cloud_polling", "loggers": ["ical"], "quality_scale": "silver", - "requirements": ["ical==10.0.0"] + "requirements": ["ical==10.0.4"] } diff --git a/homeassistant/components/reolink/entity.py b/homeassistant/components/reolink/entity.py index 2e0f1ac9e6a..467472fef9c 100644 --- a/homeassistant/components/reolink/entity.py +++ b/homeassistant/components/reolink/entity.py @@ -75,7 +75,10 @@ class ReolinkHostCoordinatorEntity(CoordinatorEntity[DataUpdateCoordinator[None] ) http_s = "https" if self._host.api.use_https else "http" - self._conf_url = f"{http_s}://{self._host.api.host}:{self._host.api.port}" + if self._host.api.baichuan_only: + self._conf_url = None + else: + self._conf_url = f"{http_s}://{self._host.api.host}:{self._host.api.port}" self._dev_id = self._host.unique_id self._attr_device_info = DeviceInfo( identifiers={(DOMAIN, self._dev_id)}, @@ -184,6 +187,11 @@ class ReolinkChannelCoordinatorEntity(ReolinkHostCoordinatorEntity): if mac := self._host.api.baichuan.mac_address(dev_ch): connections.add((CONNECTION_NETWORK_MAC, mac)) + if self._conf_url is None: + conf_url = None + else: + conf_url = f"{self._conf_url}/?ch={dev_ch}" + self._attr_device_info = DeviceInfo( identifiers={(DOMAIN, self._dev_id)}, connections=connections, @@ -195,7 +203,7 @@ class ReolinkChannelCoordinatorEntity(ReolinkHostCoordinatorEntity): hw_version=self._host.api.camera_hardware_version(dev_ch), sw_version=self._host.api.camera_sw_version(dev_ch), serial_number=self._host.api.camera_uid(dev_ch), - configuration_url=f"{self._conf_url}/?ch={dev_ch}", + configuration_url=conf_url, ) @property diff --git a/homeassistant/components/reolink/icons.json b/homeassistant/components/reolink/icons.json index c79d9b895fa..cf3079e51e8 100644 --- a/homeassistant/components/reolink/icons.json +++ b/homeassistant/components/reolink/icons.json @@ -220,6 +220,9 @@ "ai_animal_sensitivity": { "default": "mdi:paw" }, + "cry_sensitivity": { + "default": "mdi:emoticon-cry-outline" + }, "crossline_sensitivity": { "default": "mdi:fence" }, @@ -488,6 +491,12 @@ "state": { "on": "mdi:eye-off" } + }, + "privacy_mask": { + "default": "mdi:eye", + "state": { + "on": "mdi:eye-off" + } } } }, diff --git a/homeassistant/components/reolink/manifest.json b/homeassistant/components/reolink/manifest.json index 917ef9e73f7..04996689bf7 100644 --- a/homeassistant/components/reolink/manifest.json +++ b/homeassistant/components/reolink/manifest.json @@ -19,5 +19,5 @@ "iot_class": "local_push", "loggers": ["reolink_aio"], "quality_scale": "platinum", - "requirements": ["reolink-aio==0.14.0"] + "requirements": ["reolink-aio==0.14.1"] } diff --git a/homeassistant/components/reolink/number.py b/homeassistant/components/reolink/number.py index e28b8c97697..6de702a0395 100644 --- a/homeassistant/components/reolink/number.py +++ b/homeassistant/components/reolink/number.py @@ -272,6 +272,18 @@ NUMBER_ENTITIES = ( value=lambda api, ch: api.ai_sensitivity(ch, "dog_cat"), method=lambda api, ch, value: api.set_ai_sensitivity(ch, int(value), "dog_cat"), ), + ReolinkNumberEntityDescription( + key="cry_sensitivity", + cmd_key="299", + translation_key="cry_sensitivity", + entity_category=EntityCategory.CONFIG, + native_step=1, + native_min_value=1, + native_max_value=5, + supported=lambda api, ch: api.supported(ch, "ai_cry"), + value=lambda api, ch: api.baichuan.cry_sensitivity(ch), + method=lambda api, ch, value: api.baichuan.set_cry_detection(ch, int(value)), + ), ReolinkNumberEntityDescription( key="ai_face_delay", cmd_key="GetAiAlarm", diff --git a/homeassistant/components/reolink/strings.json b/homeassistant/components/reolink/strings.json index 45448e2a03c..e7a970ec1c8 100644 --- a/homeassistant/components/reolink/strings.json +++ b/homeassistant/components/reolink/strings.json @@ -571,6 +571,9 @@ "ai_animal_sensitivity": { "name": "AI animal sensitivity" }, + "cry_sensitivity": { + "name": "Baby cry sensitivity" + }, "crossline_sensitivity": { "name": "AI crossline {zone_name} sensitivity" }, @@ -957,6 +960,9 @@ }, "privacy_mode": { "name": "Privacy mode" + }, + "privacy_mask": { + "name": "Privacy mask" } } } diff --git a/homeassistant/components/reolink/switch.py b/homeassistant/components/reolink/switch.py index d9f192a3faa..47b14f7f4ad 100644 --- a/homeassistant/components/reolink/switch.py +++ b/homeassistant/components/reolink/switch.py @@ -216,6 +216,15 @@ SWITCH_ENTITIES = ( value=lambda api, ch: api.baichuan.privacy_mode(ch), method=lambda api, ch, value: api.baichuan.set_privacy_mode(ch, value), ), + ReolinkSwitchEntityDescription( + key="privacy_mask", + cmd_key="GetMask", + translation_key="privacy_mask", + entity_category=EntityCategory.CONFIG, + supported=lambda api, ch: api.supported(ch, "privacy_mask"), + value=lambda api, ch: api.privacy_mask_enabled(ch), + method=lambda api, ch, value: api.set_privacy_mask(ch, enable=value), + ), ReolinkSwitchEntityDescription( key="hardwired_chime_enabled", cmd_key="483", diff --git a/homeassistant/components/rflink/manifest.json b/homeassistant/components/rflink/manifest.json index f5f372d2d33..206b31ab86f 100644 --- a/homeassistant/components/rflink/manifest.json +++ b/homeassistant/components/rflink/manifest.json @@ -6,5 +6,5 @@ "iot_class": "assumed_state", "loggers": ["rflink"], "quality_scale": "legacy", - "requirements": ["rflink==0.0.66"] + "requirements": ["rflink==0.0.67"] } diff --git a/homeassistant/components/roku/manifest.json b/homeassistant/components/roku/manifest.json index 7fe2fb3b686..d5e2e2e5224 100644 --- a/homeassistant/components/roku/manifest.json +++ b/homeassistant/components/roku/manifest.json @@ -10,7 +10,7 @@ "integration_type": "device", "iot_class": "local_polling", "loggers": ["rokuecp"], - "requirements": ["rokuecp==0.19.3"], + "requirements": ["rokuecp==0.19.5"], "ssdp": [ { "st": "roku:ecp", diff --git a/homeassistant/components/russound_rio/manifest.json b/homeassistant/components/russound_rio/manifest.json index e16e589e648..30b9205f439 100644 --- a/homeassistant/components/russound_rio/manifest.json +++ b/homeassistant/components/russound_rio/manifest.json @@ -7,6 +7,6 @@ "iot_class": "local_push", "loggers": ["aiorussound"], "quality_scale": "silver", - "requirements": ["aiorussound==4.5.2"], + "requirements": ["aiorussound==4.6.0"], "zeroconf": ["_rio._tcp.local."] } diff --git a/homeassistant/components/shelly/button.py b/homeassistant/components/shelly/button.py index eab7514514d..ad03a373dba 100644 --- a/homeassistant/components/shelly/button.py +++ b/homeassistant/components/shelly/button.py @@ -235,11 +235,15 @@ class ShellyButton(ShellyBaseButton): self._attr_unique_id = f"{coordinator.mac}_{description.key}" if isinstance(coordinator, ShellyBlockCoordinator): self._attr_device_info = get_block_device_info( - coordinator.device, coordinator.mac + coordinator.device, + coordinator.mac, + suggested_area=coordinator.suggested_area, ) else: self._attr_device_info = get_rpc_device_info( - coordinator.device, coordinator.mac + coordinator.device, + coordinator.mac, + suggested_area=coordinator.suggested_area, ) self._attr_device_info = DeviceInfo( connections={(CONNECTION_NETWORK_MAC, coordinator.mac)} diff --git a/homeassistant/components/shelly/climate.py b/homeassistant/components/shelly/climate.py index 26fabe7e8b5..abc387f3efd 100644 --- a/homeassistant/components/shelly/climate.py +++ b/homeassistant/components/shelly/climate.py @@ -211,7 +211,10 @@ class BlockSleepingClimate( elif entry is not None: self._unique_id = entry.unique_id self._attr_device_info = get_block_device_info( - coordinator.device, coordinator.mac, sensor_block + coordinator.device, + coordinator.mac, + sensor_block, + suggested_area=coordinator.suggested_area, ) self._attr_name = get_block_entity_name( self.coordinator.device, sensor_block, None diff --git a/homeassistant/components/shelly/coordinator.py b/homeassistant/components/shelly/coordinator.py index f980ba8f914..cba559a9773 100644 --- a/homeassistant/components/shelly/coordinator.py +++ b/homeassistant/components/shelly/coordinator.py @@ -31,7 +31,11 @@ from homeassistant.const import ( Platform, ) from homeassistant.core import CALLBACK_TYPE, Event, HomeAssistant, callback -from homeassistant.helpers import device_registry as dr, issue_registry as ir +from homeassistant.helpers import ( + area_registry as ar, + device_registry as dr, + issue_registry as ir, +) from homeassistant.helpers.debounce import Debouncer from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC, format_mac from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed @@ -114,6 +118,7 @@ class ShellyCoordinatorBase[_DeviceT: BlockDevice | RpcDevice]( self.device = device self.device_id: str | None = None self._pending_platforms: list[Platform] | None = None + self.suggested_area: str | None = None device_name = device.name if device.initialized else entry.title interval_td = timedelta(seconds=update_interval) # The device has come online at least once. In the case of a sleeping RPC @@ -176,6 +181,11 @@ class ShellyCoordinatorBase[_DeviceT: BlockDevice | RpcDevice]( hw_version=f"gen{get_device_entry_gen(self.config_entry)}", configuration_url=f"http://{get_host(self.config_entry.data[CONF_HOST])}:{get_http_port(self.config_entry.data)}", ) + # We want to use the main device area as the suggested area for sub-devices. + if (area_id := device_entry.area_id) is not None: + area_registry = ar.async_get(self.hass) + if (area := area_registry.async_get_area(area_id)) is not None: + self.suggested_area = area.name self.device_id = device_entry.id async def shutdown(self) -> None: diff --git a/homeassistant/components/shelly/entity.py b/homeassistant/components/shelly/entity.py index 1b0078890af..2c1678d56d9 100644 --- a/homeassistant/components/shelly/entity.py +++ b/homeassistant/components/shelly/entity.py @@ -362,7 +362,10 @@ class ShellyBlockEntity(CoordinatorEntity[ShellyBlockCoordinator]): self.block = block self._attr_name = get_block_entity_name(coordinator.device, block) self._attr_device_info = get_block_device_info( - coordinator.device, coordinator.mac, block + coordinator.device, + coordinator.mac, + block, + suggested_area=coordinator.suggested_area, ) self._attr_unique_id = f"{coordinator.mac}-{block.description}" @@ -405,7 +408,10 @@ class ShellyRpcEntity(CoordinatorEntity[ShellyRpcCoordinator]): super().__init__(coordinator) self.key = key self._attr_device_info = get_rpc_device_info( - coordinator.device, coordinator.mac, key + coordinator.device, + coordinator.mac, + key, + suggested_area=coordinator.suggested_area, ) self._attr_unique_id = f"{coordinator.mac}-{key}" self._attr_name = get_rpc_entity_name(coordinator.device, key) @@ -521,7 +527,9 @@ class ShellyRestAttributeEntity(CoordinatorEntity[ShellyBlockCoordinator]): ) self._attr_unique_id = f"{coordinator.mac}-{attribute}" self._attr_device_info = get_block_device_info( - coordinator.device, coordinator.mac + coordinator.device, + coordinator.mac, + suggested_area=coordinator.suggested_area, ) self._last_value = None @@ -630,7 +638,10 @@ class ShellySleepingBlockAttributeEntity(ShellyBlockAttributeEntity): self.entity_description = description self._attr_device_info = get_block_device_info( - coordinator.device, coordinator.mac, block + coordinator.device, + coordinator.mac, + block, + suggested_area=coordinator.suggested_area, ) if block is not None: @@ -698,7 +709,10 @@ class ShellySleepingRpcAttributeEntity(ShellyRpcAttributeEntity): self.entity_description = description self._attr_device_info = get_rpc_device_info( - coordinator.device, coordinator.mac, key + coordinator.device, + coordinator.mac, + key, + suggested_area=coordinator.suggested_area, ) self._attr_unique_id = self._attr_unique_id = ( f"{coordinator.mac}-{key}-{attribute}" diff --git a/homeassistant/components/shelly/event.py b/homeassistant/components/shelly/event.py index 677ea1f6138..2eb9ff00964 100644 --- a/homeassistant/components/shelly/event.py +++ b/homeassistant/components/shelly/event.py @@ -207,7 +207,10 @@ class ShellyRpcEvent(CoordinatorEntity[ShellyRpcCoordinator], EventEntity): super().__init__(coordinator) self.event_id = int(key.split(":")[-1]) self._attr_device_info = get_rpc_device_info( - coordinator.device, coordinator.mac, key + coordinator.device, + coordinator.mac, + key, + suggested_area=coordinator.suggested_area, ) self._attr_unique_id = f"{coordinator.mac}-{key}" self._attr_name = get_rpc_entity_name(coordinator.device, key) diff --git a/homeassistant/components/shelly/sensor.py b/homeassistant/components/shelly/sensor.py index 0ea246c7734..3a6f5f221c5 100644 --- a/homeassistant/components/shelly/sensor.py +++ b/homeassistant/components/shelly/sensor.py @@ -139,7 +139,11 @@ class RpcEmeterPhaseSensor(RpcSensor): super().__init__(coordinator, key, attribute, description) self._attr_device_info = get_rpc_device_info( - coordinator.device, coordinator.mac, key, description.emeter_phase + coordinator.device, + coordinator.mac, + key, + emeter_phase=description.emeter_phase, + suggested_area=coordinator.suggested_area, ) diff --git a/homeassistant/components/shelly/utils.py b/homeassistant/components/shelly/utils.py index cc0f2cf75d5..953fcbace06 100644 --- a/homeassistant/components/shelly/utils.py +++ b/homeassistant/components/shelly/utils.py @@ -751,6 +751,7 @@ def get_rpc_device_info( mac: str, key: str | None = None, emeter_phase: str | None = None, + suggested_area: str | None = None, ) -> DeviceInfo: """Return device info for RPC device.""" if key is None: @@ -770,6 +771,7 @@ def get_rpc_device_info( identifiers={(DOMAIN, f"{mac}-{key}-{emeter_phase.lower()}")}, name=get_rpc_sub_device_name(device, key, emeter_phase), manufacturer="Shelly", + suggested_area=suggested_area, via_device=(DOMAIN, mac), ) @@ -784,6 +786,7 @@ def get_rpc_device_info( identifiers={(DOMAIN, f"{mac}-{key}")}, name=get_rpc_sub_device_name(device, key), manufacturer="Shelly", + suggested_area=suggested_area, via_device=(DOMAIN, mac), ) @@ -805,7 +808,10 @@ def get_blu_trv_device_info( def get_block_device_info( - device: BlockDevice, mac: str, block: Block | None = None + device: BlockDevice, + mac: str, + block: Block | None = None, + suggested_area: str | None = None, ) -> DeviceInfo: """Return device info for Block device.""" if ( @@ -820,6 +826,7 @@ def get_block_device_info( identifiers={(DOMAIN, f"{mac}-{block.description}")}, name=get_block_sub_device_name(device, block), manufacturer="Shelly", + suggested_area=suggested_area, via_device=(DOMAIN, mac), ) diff --git a/homeassistant/components/smarla/__init__.py b/homeassistant/components/smarla/__init__.py index c55b1067735..2de3fcfa242 100644 --- a/homeassistant/components/smarla/__init__.py +++ b/homeassistant/components/smarla/__init__.py @@ -22,12 +22,13 @@ async def async_setup_entry(hass: HomeAssistant, entry: FederwiegeConfigEntry) - federwiege = Federwiege(hass.loop, connection) federwiege.register() - federwiege.connect() entry.runtime_data = federwiege await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + federwiege.connect() + return True diff --git a/homeassistant/components/smarla/manifest.json b/homeassistant/components/smarla/manifest.json index 5e572c78536..8f7786bdf72 100644 --- a/homeassistant/components/smarla/manifest.json +++ b/homeassistant/components/smarla/manifest.json @@ -8,5 +8,5 @@ "iot_class": "cloud_push", "loggers": ["pysmarlaapi", "pysignalr"], "quality_scale": "bronze", - "requirements": ["pysmarlaapi==0.8.2"] + "requirements": ["pysmarlaapi==0.9.0"] } diff --git a/homeassistant/components/smarla/number.py b/homeassistant/components/smarla/number.py index d2421962b07..c1a236e4557 100644 --- a/homeassistant/components/smarla/number.py +++ b/homeassistant/components/smarla/number.py @@ -53,9 +53,10 @@ class SmarlaNumber(SmarlaBaseEntity, NumberEntity): _property: Property[int] @property - def native_value(self) -> float: + def native_value(self) -> float | None: """Return the entity value to represent the entity state.""" - return self._property.get() + v = self._property.get() + return float(v) if v is not None else None def set_native_value(self, value: float) -> None: """Update to the smarla device.""" diff --git a/homeassistant/components/smarla/switch.py b/homeassistant/components/smarla/switch.py index d68f3428a77..f9b56fdea7e 100644 --- a/homeassistant/components/smarla/switch.py +++ b/homeassistant/components/smarla/switch.py @@ -52,7 +52,7 @@ class SmarlaSwitch(SmarlaBaseEntity, SwitchEntity): _property: Property[bool] @property - def is_on(self) -> bool: + def is_on(self) -> bool | None: """Return the entity value to represent the entity state.""" return self._property.get() diff --git a/homeassistant/components/smartthings/manifest.json b/homeassistant/components/smartthings/manifest.json index 481048c3bdb..7c3fc47e512 100644 --- a/homeassistant/components/smartthings/manifest.json +++ b/homeassistant/components/smartthings/manifest.json @@ -30,5 +30,5 @@ "iot_class": "cloud_push", "loggers": ["pysmartthings"], "quality_scale": "bronze", - "requirements": ["pysmartthings==3.2.4"] + "requirements": ["pysmartthings==3.2.5"] } diff --git a/homeassistant/components/smartthings/strings.json b/homeassistant/components/smartthings/strings.json index b322b73062b..5a1d111b617 100644 --- a/homeassistant/components/smartthings/strings.json +++ b/homeassistant/components/smartthings/strings.json @@ -605,7 +605,10 @@ "name": "Wrinkle prevent" }, "ice_maker": { - "name": "Ice maker" + "name": "Cubed ice" + }, + "ice_maker_2": { + "name": "Ice Bites" }, "sabbath_mode": { "name": "Sabbath mode" diff --git a/homeassistant/components/smartthings/switch.py b/homeassistant/components/smartthings/switch.py index 56096dc6ab5..1f75e1976f6 100644 --- a/homeassistant/components/smartthings/switch.py +++ b/homeassistant/components/smartthings/switch.py @@ -95,6 +95,7 @@ CAPABILITY_TO_SWITCHES: dict[Capability | str, SmartThingsSwitchEntityDescriptio status_attribute=Attribute.SWITCH, component_translation_key={ "icemaker": "ice_maker", + "icemaker-02": "ice_maker_2", }, ), Capability.SAMSUNG_CE_SABBATH_MODE: SmartThingsSwitchEntityDescription( diff --git a/homeassistant/components/telegram_bot/__init__.py b/homeassistant/components/telegram_bot/__init__.py index f9472c50cae..554ddd8fc4e 100644 --- a/homeassistant/components/telegram_bot/__init__.py +++ b/homeassistant/components/telegram_bot/__init__.py @@ -46,6 +46,7 @@ from .const import ( ATTR_DISABLE_WEB_PREV, ATTR_FILE, ATTR_IS_ANONYMOUS, + ATTR_IS_BIG, ATTR_KEYBOARD, ATTR_KEYBOARD_INLINE, ATTR_MESSAGE, @@ -58,6 +59,7 @@ from .const import ( ATTR_PARSER, ATTR_PASSWORD, ATTR_QUESTION, + ATTR_REACTION, ATTR_RESIZE_KEYBOARD, ATTR_SHOW_ALERT, ATTR_STICKER_ID, @@ -94,6 +96,7 @@ from .const import ( SERVICE_SEND_STICKER, SERVICE_SEND_VIDEO, SERVICE_SEND_VOICE, + SERVICE_SET_MESSAGE_REACTION, ) _LOGGER = logging.getLogger(__name__) @@ -250,6 +253,19 @@ SERVICE_SCHEMA_LEAVE_CHAT = vol.Schema( } ) +SERVICE_SCHEMA_SET_MESSAGE_REACTION = vol.Schema( + { + vol.Optional(CONF_CONFIG_ENTRY_ID): cv.string, + vol.Required(ATTR_MESSAGEID): vol.Any( + cv.positive_int, vol.All(cv.string, "last") + ), + vol.Required(ATTR_CHAT_ID): vol.Coerce(int), + vol.Required(ATTR_REACTION): cv.string, + vol.Optional(ATTR_IS_BIG, default=False): cv.boolean, + }, + extra=vol.ALLOW_EXTRA, +) + SERVICE_MAP = { SERVICE_SEND_MESSAGE: SERVICE_SCHEMA_SEND_MESSAGE, SERVICE_SEND_PHOTO: SERVICE_SCHEMA_SEND_FILE, @@ -266,6 +282,7 @@ SERVICE_MAP = { SERVICE_ANSWER_CALLBACK_QUERY: SERVICE_SCHEMA_ANSWER_CALLBACK_QUERY, SERVICE_DELETE_MESSAGE: SERVICE_SCHEMA_DELETE_MESSAGE, SERVICE_LEAVE_CHAT: SERVICE_SCHEMA_LEAVE_CHAT, + SERVICE_SET_MESSAGE_REACTION: SERVICE_SCHEMA_SET_MESSAGE_REACTION, } @@ -378,6 +395,8 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: messages = await notify_service.leave_chat( context=service.context, **kwargs ) + elif msgtype == SERVICE_SET_MESSAGE_REACTION: + await notify_service.set_message_reaction(context=service.context, **kwargs) else: await notify_service.edit_message( msgtype, context=service.context, **kwargs diff --git a/homeassistant/components/telegram_bot/bot.py b/homeassistant/components/telegram_bot/bot.py index 7749c7f1183..534923b3568 100644 --- a/homeassistant/components/telegram_bot/bot.py +++ b/homeassistant/components/telegram_bot/bot.py @@ -786,6 +786,39 @@ class TelegramNotificationService: self.bot.leave_chat, "Error leaving chat", None, chat_id, context=context ) + async def set_message_reaction( + self, + chat_id: int, + reaction: str, + is_big: bool = False, + context: Context | None = None, + **kwargs, + ) -> None: + """Set the bot's reaction for a given message.""" + chat_id = self._get_target_chat_ids(chat_id)[0] + message_id, _ = self._get_msg_ids(kwargs, chat_id) + params = self._get_msg_kwargs(kwargs) + + _LOGGER.debug( + "Set reaction to message %s in chat ID %s to %s with params: %s", + message_id, + chat_id, + reaction, + params, + ) + + await self._send_msg( + self.bot.set_message_reaction, + "Error setting message reaction", + params[ATTR_MESSAGE_TAG], + chat_id, + message_id, + reaction=reaction, + is_big=is_big, + read_timeout=params[ATTR_TIMEOUT], + context=context, + ) + def initialize_bot(hass: HomeAssistant, p_config: MappingProxyType[str, Any]) -> Bot: """Initialize telegram bot with proxy support.""" diff --git a/homeassistant/components/telegram_bot/const.py b/homeassistant/components/telegram_bot/const.py index ca79fc868cf..4abdbaf9738 100644 --- a/homeassistant/components/telegram_bot/const.py +++ b/homeassistant/components/telegram_bot/const.py @@ -43,6 +43,7 @@ SERVICE_SEND_VOICE = "send_voice" SERVICE_SEND_DOCUMENT = "send_document" SERVICE_SEND_LOCATION = "send_location" SERVICE_SEND_POLL = "send_poll" +SERVICE_SET_MESSAGE_REACTION = "set_message_reaction" SERVICE_EDIT_MESSAGE = "edit_message" SERVICE_EDIT_CAPTION = "edit_caption" SERVICE_EDIT_REPLYMARKUP = "edit_replymarkup" @@ -87,6 +88,8 @@ ATTR_MSG = "message" ATTR_MSGID = "id" ATTR_PARSER = "parse_mode" ATTR_PASSWORD = "password" +ATTR_REACTION = "reaction" +ATTR_IS_BIG = "is_big" ATTR_REPLY_TO_MSGID = "reply_to_message_id" ATTR_REPLYMARKUP = "reply_markup" ATTR_SHOW_ALERT = "show_alert" diff --git a/homeassistant/components/telegram_bot/icons.json b/homeassistant/components/telegram_bot/icons.json index 8deecfb9c27..3a53e2b4118 100644 --- a/homeassistant/components/telegram_bot/icons.json +++ b/homeassistant/components/telegram_bot/icons.json @@ -44,6 +44,9 @@ }, "leave_chat": { "service": "mdi:exit-run" + }, + "set_message_reaction": { + "service": "mdi:emoticon-happy" } } } diff --git a/homeassistant/components/telegram_bot/services.yaml b/homeassistant/components/telegram_bot/services.yaml index 1577d76b527..d5fc0e134d5 100644 --- a/homeassistant/components/telegram_bot/services.yaml +++ b/homeassistant/components/telegram_bot/services.yaml @@ -787,3 +787,29 @@ leave_chat: example: 12345 selector: text: + +set_message_reaction: + fields: + config_entry_id: + selector: + config_entry: + integration: telegram_bot + message_id: + required: true + example: 54321 + selector: + text: + chat_id: + required: true + example: 12345 + selector: + text: + reaction: + required: true + example: 👍 + selector: + text: + is_big: + required: false + selector: + boolean: diff --git a/homeassistant/components/telegram_bot/strings.json b/homeassistant/components/telegram_bot/strings.json index d772edf1945..9fcc0740970 100644 --- a/homeassistant/components/telegram_bot/strings.json +++ b/homeassistant/components/telegram_bot/strings.json @@ -857,6 +857,32 @@ "description": "Chat ID of the group from which the bot should be removed." } } + }, + "set_message_reaction": { + "name": "Set message reaction", + "description": "Sets the bot's reaction for a given message.", + "fields": { + "config_entry_id": { + "name": "[%key:component::telegram_bot::services::send_message::fields::config_entry_id::name%]", + "description": "The config entry representing the Telegram bot to set the message reaction." + }, + "message_id": { + "name": "[%key:component::telegram_bot::services::edit_message::fields::message_id::name%]", + "description": "ID of the message to react to." + }, + "chat_id": { + "name": "[%key:component::telegram_bot::services::edit_message::fields::chat_id::name%]", + "description": "ID of the chat containing the message." + }, + "reaction": { + "name": "Reaction", + "description": "Emoji reaction to use." + }, + "is_big": { + "name": "Large animation", + "description": "Whether the reaction animation should be large." + } + } } }, "exceptions": { diff --git a/homeassistant/components/template/alarm_control_panel.py b/homeassistant/components/template/alarm_control_panel.py index 725a73338fa..29c71973f42 100644 --- a/homeassistant/components/template/alarm_control_panel.py +++ b/homeassistant/components/template/alarm_control_panel.py @@ -41,13 +41,12 @@ from homeassistant.helpers.restore_state import RestoreEntity from homeassistant.helpers.script import Script from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType -from .const import CONF_OBJECT_ID, CONF_PICTURE, DOMAIN +from .const import CONF_OBJECT_ID, DOMAIN from .entity import AbstractTemplateEntity from .template_entity import ( LEGACY_FIELDS as TEMPLATE_ENTITY_LEGACY_FIELDS, - TEMPLATE_ENTITY_AVAILABILITY_SCHEMA, - TEMPLATE_ENTITY_ICON_SCHEMA, TemplateEntity, + make_template_entity_common_modern_schema, rewrite_common_legacy_to_modern_conf, ) @@ -105,15 +104,10 @@ ALARM_CONTROL_PANEL_SCHEMA = vol.All( CONF_CODE_FORMAT, default=TemplateCodeFormat.number.name ): cv.enum(TemplateCodeFormat), vol.Optional(CONF_DISARM_ACTION): cv.SCRIPT_SCHEMA, - vol.Optional(CONF_NAME): cv.template, - vol.Optional(CONF_PICTURE): cv.template, vol.Optional(CONF_STATE): cv.template, vol.Optional(CONF_TRIGGER_ACTION): cv.SCRIPT_SCHEMA, - vol.Optional(CONF_UNIQUE_ID): cv.string, } - ) - .extend(TEMPLATE_ENTITY_AVAILABILITY_SCHEMA.schema) - .extend(TEMPLATE_ENTITY_ICON_SCHEMA.schema), + ).extend(make_template_entity_common_modern_schema(DEFAULT_NAME).schema) ) @@ -419,9 +413,7 @@ class AlarmControlPanelTemplate(TemplateEntity, AbstractTemplateAlarmControlPane unique_id: str | None, ) -> None: """Initialize the panel.""" - TemplateEntity.__init__( - self, hass, config=config, fallback_name=None, unique_id=unique_id - ) + TemplateEntity.__init__(self, hass, config=config, unique_id=unique_id) AbstractTemplateAlarmControlPanel.__init__(self, config) if (object_id := config.get(CONF_OBJECT_ID)) is not None: self.entity_id = async_generate_entity_id( diff --git a/homeassistant/components/template/button.py b/homeassistant/components/template/button.py index 4ee8844d6e7..07aa41b3811 100644 --- a/homeassistant/components/template/button.py +++ b/homeassistant/components/template/button.py @@ -26,29 +26,19 @@ from homeassistant.helpers.entity_platform import ( from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from .const import CONF_PRESS, DOMAIN -from .template_entity import ( - TEMPLATE_ENTITY_AVAILABILITY_SCHEMA, - TEMPLATE_ENTITY_ICON_SCHEMA, - TemplateEntity, -) +from .template_entity import TemplateEntity, make_template_entity_common_modern_schema _LOGGER = logging.getLogger(__name__) DEFAULT_NAME = "Template Button" DEFAULT_OPTIMISTIC = False -BUTTON_SCHEMA = ( - vol.Schema( - { - vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.template, - vol.Required(CONF_PRESS): cv.SCRIPT_SCHEMA, - vol.Optional(CONF_DEVICE_CLASS): DEVICE_CLASSES_SCHEMA, - vol.Optional(CONF_UNIQUE_ID): cv.string, - } - ) - .extend(TEMPLATE_ENTITY_AVAILABILITY_SCHEMA.schema) - .extend(TEMPLATE_ENTITY_ICON_SCHEMA.schema) -) +BUTTON_SCHEMA = vol.Schema( + { + vol.Required(CONF_PRESS): cv.SCRIPT_SCHEMA, + vol.Optional(CONF_DEVICE_CLASS): DEVICE_CLASSES_SCHEMA, + } +).extend(make_template_entity_common_modern_schema(DEFAULT_NAME).schema) CONFIG_BUTTON_SCHEMA = vol.Schema( { diff --git a/homeassistant/components/template/cover.py b/homeassistant/components/template/cover.py index 0b2009e83e3..68645c718b2 100644 --- a/homeassistant/components/template/cover.py +++ b/homeassistant/components/template/cover.py @@ -37,14 +37,13 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from . import TriggerUpdateCoordinator -from .const import CONF_OBJECT_ID, CONF_PICTURE, DOMAIN +from .const import CONF_OBJECT_ID, DOMAIN from .entity import AbstractTemplateEntity from .template_entity import ( LEGACY_FIELDS as TEMPLATE_ENTITY_LEGACY_FIELDS, - TEMPLATE_ENTITY_AVAILABILITY_SCHEMA, TEMPLATE_ENTITY_COMMON_SCHEMA_LEGACY, - TEMPLATE_ENTITY_ICON_SCHEMA, TemplateEntity, + make_template_entity_common_modern_schema, rewrite_common_legacy_to_modern_conf, ) from .trigger_entity import TriggerEntity @@ -100,21 +99,16 @@ COVER_SCHEMA = vol.All( vol.Inclusive(CLOSE_ACTION, CONF_OPEN_AND_CLOSE): cv.SCRIPT_SCHEMA, vol.Inclusive(OPEN_ACTION, CONF_OPEN_AND_CLOSE): cv.SCRIPT_SCHEMA, vol.Optional(CONF_DEVICE_CLASS): DEVICE_CLASSES_SCHEMA, - vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.template, vol.Optional(CONF_OPTIMISTIC): cv.boolean, - vol.Optional(CONF_PICTURE): cv.template, vol.Optional(CONF_POSITION): cv.template, vol.Optional(CONF_STATE): cv.template, vol.Optional(CONF_TILT_OPTIMISTIC): cv.boolean, vol.Optional(CONF_TILT): cv.template, - vol.Optional(CONF_UNIQUE_ID): cv.string, vol.Optional(POSITION_ACTION): cv.SCRIPT_SCHEMA, vol.Optional(STOP_ACTION): cv.SCRIPT_SCHEMA, vol.Optional(TILT_ACTION): cv.SCRIPT_SCHEMA, } - ) - .extend(TEMPLATE_ENTITY_AVAILABILITY_SCHEMA.schema) - .extend(TEMPLATE_ENTITY_ICON_SCHEMA.schema), + ).extend(make_template_entity_common_modern_schema(DEFAULT_NAME).schema), cv.has_at_least_one_key(OPEN_ACTION, POSITION_ACTION), ) @@ -463,9 +457,7 @@ class CoverTemplate(TemplateEntity, AbstractTemplateCover): unique_id, ) -> None: """Initialize the Template cover.""" - TemplateEntity.__init__( - self, hass, config=config, fallback_name=None, unique_id=unique_id - ) + TemplateEntity.__init__(self, hass, config=config, unique_id=unique_id) AbstractTemplateCover.__init__(self, config) if (object_id := config.get(CONF_OBJECT_ID)) is not None: self.entity_id = async_generate_entity_id( diff --git a/homeassistant/components/template/fan.py b/homeassistant/components/template/fan.py index c353fca48df..4837ded9029 100644 --- a/homeassistant/components/template/fan.py +++ b/homeassistant/components/template/fan.py @@ -37,14 +37,13 @@ from homeassistant.helpers.entity import async_generate_entity_id from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType -from .const import CONF_OBJECT_ID, CONF_PICTURE, DOMAIN +from .const import CONF_OBJECT_ID, DOMAIN from .entity import AbstractTemplateEntity from .template_entity import ( LEGACY_FIELDS as TEMPLATE_ENTITY_LEGACY_FIELDS, - TEMPLATE_ENTITY_AVAILABILITY_SCHEMA, TEMPLATE_ENTITY_AVAILABILITY_SCHEMA_LEGACY, - TEMPLATE_ENTITY_ICON_SCHEMA, TemplateEntity, + make_template_entity_common_modern_schema, rewrite_common_legacy_to_modern_conf, ) @@ -85,12 +84,10 @@ FAN_SCHEMA = vol.All( vol.Schema( { vol.Optional(CONF_DIRECTION): cv.template, - vol.Optional(CONF_NAME): cv.template, vol.Required(CONF_OFF_ACTION): cv.SCRIPT_SCHEMA, vol.Required(CONF_ON_ACTION): cv.SCRIPT_SCHEMA, vol.Optional(CONF_OSCILLATING): cv.template, vol.Optional(CONF_PERCENTAGE): cv.template, - vol.Optional(CONF_PICTURE): cv.template, vol.Optional(CONF_PRESET_MODE): cv.template, vol.Optional(CONF_PRESET_MODES): cv.ensure_list, vol.Optional(CONF_SET_DIRECTION_ACTION): cv.SCRIPT_SCHEMA, @@ -99,11 +96,8 @@ FAN_SCHEMA = vol.All( vol.Optional(CONF_SET_PRESET_MODE_ACTION): cv.SCRIPT_SCHEMA, vol.Optional(CONF_SPEED_COUNT): vol.Coerce(int), vol.Optional(CONF_STATE): cv.template, - vol.Optional(CONF_UNIQUE_ID): cv.string, } - ) - .extend(TEMPLATE_ENTITY_AVAILABILITY_SCHEMA.schema) - .extend(TEMPLATE_ENTITY_ICON_SCHEMA.schema), + ).extend(make_template_entity_common_modern_schema(DEFAULT_NAME).schema) ) LEGACY_FAN_SCHEMA = vol.All( @@ -488,9 +482,7 @@ class TemplateFan(TemplateEntity, AbstractTemplateFan): unique_id, ) -> None: """Initialize the fan.""" - TemplateEntity.__init__( - self, hass, config=config, fallback_name=None, unique_id=unique_id - ) + TemplateEntity.__init__(self, hass, config=config, unique_id=unique_id) AbstractTemplateFan.__init__(self, config) if (object_id := config.get(CONF_OBJECT_ID)) is not None: self.entity_id = async_generate_entity_id( diff --git a/homeassistant/components/template/image.py b/homeassistant/components/template/image.py index 5afbca55cbb..d286a2f6b4d 100644 --- a/homeassistant/components/template/image.py +++ b/homeassistant/components/template/image.py @@ -29,7 +29,10 @@ from homeassistant.util import dt as dt_util from . import TriggerUpdateCoordinator from .const import CONF_PICTURE -from .template_entity import TemplateEntity, make_template_entity_common_schema +from .template_entity import ( + TemplateEntity, + make_template_entity_common_modern_attributes_schema, +) from .trigger_entity import TriggerEntity _LOGGER = logging.getLogger(__name__) @@ -43,7 +46,7 @@ IMAGE_SCHEMA = vol.Schema( vol.Required(CONF_URL): cv.template, vol.Optional(CONF_VERIFY_SSL, default=True): bool, } -).extend(make_template_entity_common_schema(DEFAULT_NAME).schema) +).extend(make_template_entity_common_modern_attributes_schema(DEFAULT_NAME).schema) IMAGE_CONFIG_SCHEMA = vol.Schema( diff --git a/homeassistant/components/template/light.py b/homeassistant/components/template/light.py index c852ee1808d..fa393c76ab4 100644 --- a/homeassistant/components/template/light.py +++ b/homeassistant/components/template/light.py @@ -49,14 +49,13 @@ from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from homeassistant.util import color as color_util from . import TriggerUpdateCoordinator -from .const import CONF_OBJECT_ID, CONF_PICTURE, DOMAIN +from .const import CONF_OBJECT_ID, DOMAIN from .entity import AbstractTemplateEntity from .template_entity import ( LEGACY_FIELDS as TEMPLATE_ENTITY_LEGACY_FIELDS, - TEMPLATE_ENTITY_AVAILABILITY_SCHEMA, TEMPLATE_ENTITY_COMMON_SCHEMA_LEGACY, - TEMPLATE_ENTITY_ICON_SCHEMA, TemplateEntity, + make_template_entity_common_modern_schema, rewrite_common_legacy_to_modern_conf, ) from .trigger_entity import TriggerEntity @@ -124,38 +123,31 @@ LEGACY_FIELDS = TEMPLATE_ENTITY_LEGACY_FIELDS | { DEFAULT_NAME = "Template Light" -LIGHT_SCHEMA = ( - vol.Schema( - { - vol.Inclusive(CONF_EFFECT_ACTION, "effect"): cv.SCRIPT_SCHEMA, - vol.Inclusive(CONF_EFFECT_LIST, "effect"): cv.template, - vol.Inclusive(CONF_EFFECT, "effect"): cv.template, - vol.Optional(CONF_HS_ACTION): cv.SCRIPT_SCHEMA, - vol.Optional(CONF_HS): cv.template, - vol.Optional(CONF_LEVEL_ACTION): cv.SCRIPT_SCHEMA, - vol.Optional(CONF_LEVEL): cv.template, - vol.Optional(CONF_MAX_MIREDS): cv.template, - vol.Optional(CONF_MIN_MIREDS): cv.template, - vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.template, - vol.Optional(CONF_PICTURE): cv.template, - vol.Optional(CONF_RGB_ACTION): cv.SCRIPT_SCHEMA, - vol.Optional(CONF_RGB): cv.template, - vol.Optional(CONF_RGBW_ACTION): cv.SCRIPT_SCHEMA, - vol.Optional(CONF_RGBW): cv.template, - vol.Optional(CONF_RGBWW_ACTION): cv.SCRIPT_SCHEMA, - vol.Optional(CONF_RGBWW): cv.template, - vol.Optional(CONF_STATE): cv.template, - vol.Optional(CONF_SUPPORTS_TRANSITION): cv.template, - vol.Optional(CONF_TEMPERATURE_ACTION): cv.SCRIPT_SCHEMA, - vol.Optional(CONF_TEMPERATURE): cv.template, - vol.Optional(CONF_UNIQUE_ID): cv.string, - vol.Required(CONF_OFF_ACTION): cv.SCRIPT_SCHEMA, - vol.Required(CONF_ON_ACTION): cv.SCRIPT_SCHEMA, - } - ) - .extend(TEMPLATE_ENTITY_AVAILABILITY_SCHEMA.schema) - .extend(TEMPLATE_ENTITY_ICON_SCHEMA.schema) -) +LIGHT_SCHEMA = vol.Schema( + { + vol.Inclusive(CONF_EFFECT_ACTION, "effect"): cv.SCRIPT_SCHEMA, + vol.Inclusive(CONF_EFFECT_LIST, "effect"): cv.template, + vol.Inclusive(CONF_EFFECT, "effect"): cv.template, + vol.Optional(CONF_HS_ACTION): cv.SCRIPT_SCHEMA, + vol.Optional(CONF_HS): cv.template, + vol.Optional(CONF_LEVEL_ACTION): cv.SCRIPT_SCHEMA, + vol.Optional(CONF_LEVEL): cv.template, + vol.Optional(CONF_MAX_MIREDS): cv.template, + vol.Optional(CONF_MIN_MIREDS): cv.template, + vol.Optional(CONF_RGB_ACTION): cv.SCRIPT_SCHEMA, + vol.Optional(CONF_RGB): cv.template, + vol.Optional(CONF_RGBW_ACTION): cv.SCRIPT_SCHEMA, + vol.Optional(CONF_RGBW): cv.template, + vol.Optional(CONF_RGBWW_ACTION): cv.SCRIPT_SCHEMA, + vol.Optional(CONF_RGBWW): cv.template, + vol.Optional(CONF_STATE): cv.template, + vol.Optional(CONF_SUPPORTS_TRANSITION): cv.template, + vol.Optional(CONF_TEMPERATURE_ACTION): cv.SCRIPT_SCHEMA, + vol.Optional(CONF_TEMPERATURE): cv.template, + vol.Required(CONF_OFF_ACTION): cv.SCRIPT_SCHEMA, + vol.Required(CONF_ON_ACTION): cv.SCRIPT_SCHEMA, + } +).extend(make_template_entity_common_modern_schema(DEFAULT_NAME).schema) LEGACY_LIGHT_SCHEMA = vol.All( cv.deprecated(CONF_ENTITY_ID), @@ -955,9 +947,7 @@ class LightTemplate(TemplateEntity, AbstractTemplateLight): unique_id: str | None, ) -> None: """Initialize the light.""" - TemplateEntity.__init__( - self, hass, config=config, fallback_name=None, unique_id=unique_id - ) + TemplateEntity.__init__(self, hass, config=config, unique_id=unique_id) AbstractTemplateLight.__init__(self, config) if (object_id := config.get(CONF_OBJECT_ID)) is not None: self.entity_id = async_generate_entity_id( diff --git a/homeassistant/components/template/lock.py b/homeassistant/components/template/lock.py index 25eac8c35e4..8ed8a004e92 100644 --- a/homeassistant/components/template/lock.py +++ b/homeassistant/components/template/lock.py @@ -31,10 +31,9 @@ from .const import CONF_PICTURE, DOMAIN from .entity import AbstractTemplateEntity from .template_entity import ( LEGACY_FIELDS as TEMPLATE_ENTITY_LEGACY_FIELDS, - TEMPLATE_ENTITY_AVAILABILITY_SCHEMA, TEMPLATE_ENTITY_AVAILABILITY_SCHEMA_LEGACY, - TEMPLATE_ENTITY_ICON_SCHEMA, TemplateEntity, + make_template_entity_common_modern_schema, rewrite_common_legacy_to_modern_conf, ) @@ -57,17 +56,13 @@ LOCK_SCHEMA = vol.All( { vol.Optional(CONF_CODE_FORMAT): cv.template, vol.Required(CONF_LOCK): cv.SCRIPT_SCHEMA, - vol.Optional(CONF_NAME): cv.template, vol.Optional(CONF_OPEN): cv.SCRIPT_SCHEMA, vol.Optional(CONF_OPTIMISTIC, default=DEFAULT_OPTIMISTIC): cv.boolean, vol.Optional(CONF_PICTURE): cv.template, vol.Required(CONF_STATE): cv.template, - vol.Optional(CONF_UNIQUE_ID): cv.string, vol.Required(CONF_UNLOCK): cv.SCRIPT_SCHEMA, } - ) - .extend(TEMPLATE_ENTITY_AVAILABILITY_SCHEMA.schema) - .extend(TEMPLATE_ENTITY_ICON_SCHEMA.schema), + ).extend(make_template_entity_common_modern_schema(DEFAULT_NAME).schema) ) @@ -313,9 +308,7 @@ class TemplateLock(TemplateEntity, AbstractTemplateLock): unique_id: str | None, ) -> None: """Initialize the lock.""" - TemplateEntity.__init__( - self, hass, config=config, fallback_name=DEFAULT_NAME, unique_id=unique_id - ) + TemplateEntity.__init__(self, hass, config=config, unique_id=unique_id) AbstractTemplateLock.__init__(self, config) name = self._attr_name if TYPE_CHECKING: diff --git a/homeassistant/components/template/number.py b/homeassistant/components/template/number.py index 3ecf1db565a..4d9eaff0b2d 100644 --- a/homeassistant/components/template/number.py +++ b/homeassistant/components/template/number.py @@ -35,11 +35,7 @@ from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from . import TriggerUpdateCoordinator from .const import CONF_MAX, CONF_MIN, CONF_STEP, DOMAIN -from .template_entity import ( - TEMPLATE_ENTITY_AVAILABILITY_SCHEMA, - TEMPLATE_ENTITY_ICON_SCHEMA, - TemplateEntity, -) +from .template_entity import TemplateEntity, make_template_entity_common_modern_schema from .trigger_entity import TriggerEntity _LOGGER = logging.getLogger(__name__) @@ -49,23 +45,17 @@ CONF_SET_VALUE = "set_value" DEFAULT_NAME = "Template Number" DEFAULT_OPTIMISTIC = False -NUMBER_SCHEMA = ( - vol.Schema( - { - vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.template, - vol.Required(CONF_STATE): cv.template, - vol.Required(CONF_SET_VALUE): cv.SCRIPT_SCHEMA, - vol.Required(CONF_STEP): cv.template, - vol.Optional(CONF_MIN, default=DEFAULT_MIN_VALUE): cv.template, - vol.Optional(CONF_MAX, default=DEFAULT_MAX_VALUE): cv.template, - vol.Optional(CONF_UNIT_OF_MEASUREMENT): cv.string, - vol.Optional(CONF_OPTIMISTIC, default=DEFAULT_OPTIMISTIC): cv.boolean, - vol.Optional(CONF_UNIQUE_ID): cv.string, - } - ) - .extend(TEMPLATE_ENTITY_AVAILABILITY_SCHEMA.schema) - .extend(TEMPLATE_ENTITY_ICON_SCHEMA.schema) -) +NUMBER_SCHEMA = vol.Schema( + { + vol.Required(CONF_STATE): cv.template, + vol.Required(CONF_SET_VALUE): cv.SCRIPT_SCHEMA, + vol.Required(CONF_STEP): cv.template, + vol.Optional(CONF_MIN, default=DEFAULT_MIN_VALUE): cv.template, + vol.Optional(CONF_MAX, default=DEFAULT_MAX_VALUE): cv.template, + vol.Optional(CONF_UNIT_OF_MEASUREMENT): cv.string, + vol.Optional(CONF_OPTIMISTIC, default=DEFAULT_OPTIMISTIC): cv.boolean, + } +).extend(make_template_entity_common_modern_schema(DEFAULT_NAME).schema) NUMBER_CONFIG_SCHEMA = vol.Schema( { vol.Required(CONF_NAME): cv.template, diff --git a/homeassistant/components/template/select.py b/homeassistant/components/template/select.py index 74d88ee96c4..8c05e8e2592 100644 --- a/homeassistant/components/template/select.py +++ b/homeassistant/components/template/select.py @@ -32,11 +32,7 @@ from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from . import TriggerUpdateCoordinator from .const import DOMAIN -from .template_entity import ( - TEMPLATE_ENTITY_AVAILABILITY_SCHEMA, - TEMPLATE_ENTITY_ICON_SCHEMA, - TemplateEntity, -) +from .template_entity import TemplateEntity, make_template_entity_common_modern_schema from .trigger_entity import TriggerEntity _LOGGER = logging.getLogger(__name__) @@ -47,20 +43,14 @@ CONF_SELECT_OPTION = "select_option" DEFAULT_NAME = "Template Select" DEFAULT_OPTIMISTIC = False -SELECT_SCHEMA = ( - vol.Schema( - { - vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.template, - vol.Required(CONF_STATE): cv.template, - vol.Required(CONF_SELECT_OPTION): cv.SCRIPT_SCHEMA, - vol.Required(ATTR_OPTIONS): cv.template, - vol.Optional(CONF_OPTIMISTIC, default=DEFAULT_OPTIMISTIC): cv.boolean, - vol.Optional(CONF_UNIQUE_ID): cv.string, - } - ) - .extend(TEMPLATE_ENTITY_AVAILABILITY_SCHEMA.schema) - .extend(TEMPLATE_ENTITY_ICON_SCHEMA.schema) -) +SELECT_SCHEMA = vol.Schema( + { + vol.Required(CONF_STATE): cv.template, + vol.Required(CONF_SELECT_OPTION): cv.SCRIPT_SCHEMA, + vol.Required(ATTR_OPTIONS): cv.template, + vol.Optional(CONF_OPTIMISTIC, default=DEFAULT_OPTIMISTIC): cv.boolean, + } +).extend(make_template_entity_common_modern_schema(DEFAULT_NAME).schema) SELECT_CONFIG_SCHEMA = vol.Schema( diff --git a/homeassistant/components/template/switch.py b/homeassistant/components/template/switch.py index 0f6d45f46ca..677686ea8d8 100644 --- a/homeassistant/components/template/switch.py +++ b/homeassistant/components/template/switch.py @@ -40,13 +40,12 @@ from homeassistant.helpers.restore_state import RestoreEntity from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from . import TriggerUpdateCoordinator -from .const import CONF_OBJECT_ID, CONF_PICTURE, CONF_TURN_OFF, CONF_TURN_ON, DOMAIN +from .const import CONF_OBJECT_ID, CONF_TURN_OFF, CONF_TURN_ON, DOMAIN from .template_entity import ( LEGACY_FIELDS as TEMPLATE_ENTITY_LEGACY_FIELDS, - TEMPLATE_ENTITY_AVAILABILITY_SCHEMA, TEMPLATE_ENTITY_COMMON_SCHEMA_LEGACY, - TEMPLATE_ENTITY_ICON_SCHEMA, TemplateEntity, + make_template_entity_common_modern_schema, rewrite_common_legacy_to_modern_conf, ) from .trigger_entity import TriggerEntity @@ -60,20 +59,13 @@ LEGACY_FIELDS = TEMPLATE_ENTITY_LEGACY_FIELDS | { DEFAULT_NAME = "Template Switch" -SWITCH_SCHEMA = ( - vol.Schema( - { - vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.template, - vol.Optional(CONF_STATE): cv.template, - vol.Required(CONF_TURN_ON): cv.SCRIPT_SCHEMA, - vol.Required(CONF_TURN_OFF): cv.SCRIPT_SCHEMA, - vol.Optional(CONF_UNIQUE_ID): cv.string, - vol.Optional(CONF_PICTURE): cv.template, - } - ) - .extend(TEMPLATE_ENTITY_AVAILABILITY_SCHEMA.schema) - .extend(TEMPLATE_ENTITY_ICON_SCHEMA.schema) -) +SWITCH_SCHEMA = vol.Schema( + { + vol.Optional(CONF_STATE): cv.template, + vol.Required(CONF_TURN_ON): cv.SCRIPT_SCHEMA, + vol.Required(CONF_TURN_OFF): cv.SCRIPT_SCHEMA, + } +).extend(make_template_entity_common_modern_schema(DEFAULT_NAME).schema) LEGACY_SWITCH_SCHEMA = vol.All( cv.deprecated(ATTR_ENTITY_ID), @@ -228,7 +220,7 @@ class SwitchTemplate(TemplateEntity, SwitchEntity, RestoreEntity): unique_id: str | None, ) -> None: """Initialize the Template switch.""" - super().__init__(hass, config=config, fallback_name=None, unique_id=unique_id) + super().__init__(hass, config=config, unique_id=unique_id) if (object_id := config.get(CONF_OBJECT_ID)) is not None: self.entity_id = async_generate_entity_id( ENTITY_ID_FORMAT, object_id, hass=hass diff --git a/homeassistant/components/template/template_entity.py b/homeassistant/components/template/template_entity.py index f879c60ed9e..3157a60347e 100644 --- a/homeassistant/components/template/template_entity.py +++ b/homeassistant/components/template/template_entity.py @@ -94,16 +94,24 @@ TEMPLATE_ENTITY_COMMON_SCHEMA = ( ) -def make_template_entity_common_schema(default_name: str) -> vol.Schema: +def make_template_entity_common_modern_schema( + default_name: str, +) -> vol.Schema: """Return a schema with default name.""" - return ( - vol.Schema( - { - vol.Optional(CONF_AVAILABILITY): cv.template, - } - ) - .extend(make_template_entity_base_schema(default_name).schema) - .extend(TEMPLATE_ENTITY_ATTRIBUTES_SCHEMA.schema) + return vol.Schema( + { + vol.Optional(CONF_AVAILABILITY): cv.template, + vol.Optional(CONF_VARIABLES): cv.SCRIPT_VARIABLES_SCHEMA, + } + ).extend(make_template_entity_base_schema(default_name).schema) + + +def make_template_entity_common_modern_attributes_schema( + default_name: str, +) -> vol.Schema: + """Return a schema with default name.""" + return make_template_entity_common_modern_schema(default_name).extend( + TEMPLATE_ENTITY_ATTRIBUTES_SCHEMA.schema ) diff --git a/homeassistant/components/template/vacuum.py b/homeassistant/components/template/vacuum.py index f50751012b3..79e00e7e1c0 100644 --- a/homeassistant/components/template/vacuum.py +++ b/homeassistant/components/template/vacuum.py @@ -38,16 +38,14 @@ from homeassistant.helpers.entity import async_generate_entity_id from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType -from .const import CONF_OBJECT_ID, CONF_PICTURE, DOMAIN +from .const import CONF_OBJECT_ID, DOMAIN from .entity import AbstractTemplateEntity from .template_entity import ( LEGACY_FIELDS as TEMPLATE_ENTITY_LEGACY_FIELDS, - TEMPLATE_ENTITY_ATTRIBUTES_SCHEMA, TEMPLATE_ENTITY_ATTRIBUTES_SCHEMA_LEGACY, - TEMPLATE_ENTITY_AVAILABILITY_SCHEMA, TEMPLATE_ENTITY_AVAILABILITY_SCHEMA_LEGACY, - TEMPLATE_ENTITY_ICON_SCHEMA, TemplateEntity, + make_template_entity_common_modern_attributes_schema, rewrite_common_legacy_to_modern_conf, ) @@ -60,6 +58,8 @@ CONF_FAN_SPEED_LIST = "fan_speeds" CONF_FAN_SPEED = "fan_speed" CONF_FAN_SPEED_TEMPLATE = "fan_speed_template" +DEFAULT_NAME = "Template Vacuum" + ENTITY_ID_FORMAT = VACUUM_DOMAIN + ".{}" _VALID_STATES = [ VacuumActivity.CLEANING, @@ -80,13 +80,9 @@ VACUUM_SCHEMA = vol.All( vol.Schema( { vol.Optional(CONF_BATTERY_LEVEL): cv.template, - vol.Optional(CONF_ENTITY_ID): cv.entity_ids, vol.Optional(CONF_FAN_SPEED_LIST, default=[]): cv.ensure_list, vol.Optional(CONF_FAN_SPEED): cv.template, - vol.Optional(CONF_NAME): cv.template, - vol.Optional(CONF_PICTURE): cv.template, vol.Optional(CONF_STATE): cv.template, - vol.Optional(CONF_UNIQUE_ID): cv.string, vol.Optional(SERVICE_CLEAN_SPOT): cv.SCRIPT_SCHEMA, vol.Optional(SERVICE_LOCATE): cv.SCRIPT_SCHEMA, vol.Optional(SERVICE_PAUSE): cv.SCRIPT_SCHEMA, @@ -95,10 +91,7 @@ VACUUM_SCHEMA = vol.All( vol.Required(SERVICE_START): cv.SCRIPT_SCHEMA, vol.Optional(SERVICE_STOP): cv.SCRIPT_SCHEMA, } - ) - .extend(TEMPLATE_ENTITY_ATTRIBUTES_SCHEMA.schema) - .extend(TEMPLATE_ENTITY_AVAILABILITY_SCHEMA.schema) - .extend(TEMPLATE_ENTITY_ICON_SCHEMA.schema), + ).extend(make_template_entity_common_modern_attributes_schema(DEFAULT_NAME).schema) ) LEGACY_VACUUM_SCHEMA = vol.All( @@ -353,9 +346,7 @@ class TemplateVacuum(TemplateEntity, AbstractTemplateVacuum): unique_id, ) -> None: """Initialize the vacuum.""" - TemplateEntity.__init__( - self, hass, config=config, fallback_name=None, unique_id=unique_id - ) + TemplateEntity.__init__(self, hass, config=config, unique_id=unique_id) AbstractTemplateVacuum.__init__(self, config) if (object_id := config.get(CONF_OBJECT_ID)) is not None: self.entity_id = async_generate_entity_id( diff --git a/homeassistant/components/template/weather.py b/homeassistant/components/template/weather.py index 86bab6f5ad1..ee834e757a3 100644 --- a/homeassistant/components/template/weather.py +++ b/homeassistant/components/template/weather.py @@ -32,7 +32,6 @@ from homeassistant.components.weather import ( WeatherEntityFeature, ) from homeassistant.const import ( - CONF_NAME, CONF_TEMPERATURE_UNIT, CONF_UNIQUE_ID, STATE_UNAVAILABLE, @@ -53,7 +52,11 @@ from homeassistant.util.unit_conversion import ( ) from .coordinator import TriggerUpdateCoordinator -from .template_entity import TemplateEntity, rewrite_common_legacy_to_modern_conf +from .template_entity import ( + TemplateEntity, + make_template_entity_common_modern_schema, + rewrite_common_legacy_to_modern_conf, +) from .trigger_entity import TriggerEntity CHECK_FORECAST_KEYS = ( @@ -104,33 +107,33 @@ CONF_CLOUD_COVERAGE_TEMPLATE = "cloud_coverage_template" CONF_DEW_POINT_TEMPLATE = "dew_point_template" CONF_APPARENT_TEMPERATURE_TEMPLATE = "apparent_temperature_template" +DEFAULT_NAME = "Template Weather" + WEATHER_SCHEMA = vol.Schema( { - vol.Required(CONF_NAME): cv.template, - vol.Required(CONF_CONDITION_TEMPLATE): cv.template, - vol.Required(CONF_TEMPERATURE_TEMPLATE): cv.template, - vol.Required(CONF_HUMIDITY_TEMPLATE): cv.template, + vol.Optional(CONF_APPARENT_TEMPERATURE_TEMPLATE): cv.template, vol.Optional(CONF_ATTRIBUTION_TEMPLATE): cv.template, - vol.Optional(CONF_PRESSURE_TEMPLATE): cv.template, - vol.Optional(CONF_WIND_SPEED_TEMPLATE): cv.template, - vol.Optional(CONF_WIND_BEARING_TEMPLATE): cv.template, - vol.Optional(CONF_OZONE_TEMPLATE): cv.template, - vol.Optional(CONF_VISIBILITY_TEMPLATE): cv.template, + vol.Optional(CONF_CLOUD_COVERAGE_TEMPLATE): cv.template, + vol.Required(CONF_CONDITION_TEMPLATE): cv.template, + vol.Optional(CONF_DEW_POINT_TEMPLATE): cv.template, + vol.Required(CONF_HUMIDITY_TEMPLATE): cv.template, vol.Optional(CONF_FORECAST_DAILY_TEMPLATE): cv.template, vol.Optional(CONF_FORECAST_HOURLY_TEMPLATE): cv.template, vol.Optional(CONF_FORECAST_TWICE_DAILY_TEMPLATE): cv.template, - vol.Optional(CONF_UNIQUE_ID): cv.string, - vol.Optional(CONF_TEMPERATURE_UNIT): vol.In(TemperatureConverter.VALID_UNITS), - vol.Optional(CONF_PRESSURE_UNIT): vol.In(PressureConverter.VALID_UNITS), - vol.Optional(CONF_WIND_SPEED_UNIT): vol.In(SpeedConverter.VALID_UNITS), - vol.Optional(CONF_VISIBILITY_UNIT): vol.In(DistanceConverter.VALID_UNITS), + vol.Optional(CONF_OZONE_TEMPLATE): cv.template, vol.Optional(CONF_PRECIPITATION_UNIT): vol.In(DistanceConverter.VALID_UNITS), + vol.Optional(CONF_PRESSURE_TEMPLATE): cv.template, + vol.Optional(CONF_PRESSURE_UNIT): vol.In(PressureConverter.VALID_UNITS), + vol.Required(CONF_TEMPERATURE_TEMPLATE): cv.template, + vol.Optional(CONF_TEMPERATURE_UNIT): vol.In(TemperatureConverter.VALID_UNITS), + vol.Optional(CONF_VISIBILITY_TEMPLATE): cv.template, + vol.Optional(CONF_VISIBILITY_UNIT): vol.In(DistanceConverter.VALID_UNITS), + vol.Optional(CONF_WIND_BEARING_TEMPLATE): cv.template, vol.Optional(CONF_WIND_GUST_SPEED_TEMPLATE): cv.template, - vol.Optional(CONF_CLOUD_COVERAGE_TEMPLATE): cv.template, - vol.Optional(CONF_DEW_POINT_TEMPLATE): cv.template, - vol.Optional(CONF_APPARENT_TEMPERATURE_TEMPLATE): cv.template, + vol.Optional(CONF_WIND_SPEED_TEMPLATE): cv.template, + vol.Optional(CONF_WIND_SPEED_UNIT): vol.In(SpeedConverter.VALID_UNITS), } -) +).extend(make_template_entity_common_modern_schema(DEFAULT_NAME).schema) PLATFORM_SCHEMA = WEATHER_PLATFORM_SCHEMA.extend(WEATHER_SCHEMA.schema) diff --git a/homeassistant/components/tesla_fleet/config_flow.py b/homeassistant/components/tesla_fleet/config_flow.py index feeb5e74ca6..ac55a380abb 100644 --- a/homeassistant/components/tesla_fleet/config_flow.py +++ b/homeassistant/components/tesla_fleet/config_flow.py @@ -4,14 +4,30 @@ from __future__ import annotations from collections.abc import Mapping import logging -from typing import Any +import re +from typing import Any, cast import jwt +from tesla_fleet_api import TeslaFleetApi +from tesla_fleet_api.const import SERVERS +from tesla_fleet_api.exceptions import ( + InvalidResponse, + PreconditionFailed, + TeslaFleetError, +) +import voluptuous as vol from homeassistant.config_entries import SOURCE_REAUTH, ConfigFlowResult from homeassistant.helpers import config_entry_oauth2_flow +from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.helpers.selector import ( + QrCodeSelector, + QrCodeSelectorConfig, + QrErrorCorrectionLevel, +) -from .const import DOMAIN, LOGGER +from .const import CONF_DOMAIN, DOMAIN, LOGGER +from .oauth import TeslaUserImplementation class OAuth2FlowHandler( @@ -21,36 +37,173 @@ class OAuth2FlowHandler( DOMAIN = DOMAIN + def __init__(self) -> None: + """Initialize config flow.""" + super().__init__() + self.domain: str | None = None + self.registration_status: dict[str, bool] = {} + self.tesla_apis: dict[str, TeslaFleetApi] = {} + self.failed_regions: list[str] = [] + self.data: dict[str, Any] = {} + self.uid: str | None = None + self.api: TeslaFleetApi | None = None + @property def logger(self) -> logging.Logger: """Return logger.""" return LOGGER - async def async_step_user( - self, user_input: dict[str, Any] | None = None - ) -> ConfigFlowResult: - """Handle a flow start.""" - return await super().async_step_user() - async def async_oauth_create_entry( self, data: dict[str, Any], ) -> ConfigFlowResult: - """Handle the initial step.""" - + """Handle OAuth completion and proceed to domain registration.""" token = jwt.decode( data["token"]["access_token"], options={"verify_signature": False} ) - uid = token["sub"] - await self.async_set_unique_id(uid) + self.data = data + self.uid = token["sub"] + server = SERVERS[token["ou_code"].lower()] + + await self.async_set_unique_id(self.uid) if self.source == SOURCE_REAUTH: self._abort_if_unique_id_mismatch(reason="reauth_account_mismatch") return self.async_update_reload_and_abort( self._get_reauth_entry(), data=data ) self._abort_if_unique_id_configured() - return self.async_create_entry(title=uid, data=data) + + # OAuth done, setup a Partner API connection + implementation = cast(TeslaUserImplementation, self.flow_impl) + + session = async_get_clientsession(self.hass) + self.api = TeslaFleetApi( + session=session, + server=server, + partner_scope=True, + charging_scope=False, + energy_scope=False, + user_scope=False, + vehicle_scope=False, + ) + await self.api.get_private_key(self.hass.config.path("tesla_fleet.key")) + await self.api.partner_login( + implementation.client_id, implementation.client_secret + ) + + return await self.async_step_domain_input() + + async def async_step_domain_input( + self, + user_input: dict[str, Any] | None = None, + errors: dict[str, str] | None = None, + ) -> ConfigFlowResult: + """Handle domain input step.""" + + errors = errors or {} + + if user_input is not None: + domain = user_input[CONF_DOMAIN].strip().lower() + + # Validate domain format + if not self._is_valid_domain(domain): + errors[CONF_DOMAIN] = "invalid_domain" + else: + self.domain = domain + return await self.async_step_domain_registration() + + return self.async_show_form( + step_id="domain_input", + description_placeholders={ + "dashboard": "https://developer.tesla.com/en_AU/dashboard/" + }, + data_schema=vol.Schema( + { + vol.Required(CONF_DOMAIN): str, + } + ), + errors=errors, + ) + + async def async_step_domain_registration( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle domain registration for both regions.""" + + assert self.api + assert self.api.private_key + assert self.domain + + errors = {} + description_placeholders = { + "public_key_url": f"https://{self.domain}/.well-known/appspecific/com.tesla.3p.public-key.pem", + "pem": self.api.public_pem, + } + + try: + register_response = await self.api.partner.register(self.domain) + except PreconditionFailed: + return await self.async_step_domain_input( + errors={CONF_DOMAIN: "precondition_failed"} + ) + except InvalidResponse: + errors["base"] = "invalid_response" + except TeslaFleetError as e: + errors["base"] = "unknown_error" + description_placeholders["error"] = e.message + else: + # Get public key from response + registered_public_key = register_response.get("response", {}).get( + "public_key" + ) + + if not registered_public_key: + errors["base"] = "public_key_not_found" + elif ( + registered_public_key.lower() + != self.api.public_uncompressed_point.lower() + ): + errors["base"] = "public_key_mismatch" + else: + return await self.async_step_registration_complete() + + return self.async_show_form( + step_id="domain_registration", + description_placeholders=description_placeholders, + errors=errors, + ) + + async def async_step_registration_complete( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Show completion and virtual key installation.""" + if user_input is not None and self.uid and self.data: + return self.async_create_entry(title=self.uid, data=self.data) + + if not self.domain: + return await self.async_step_domain_input() + + virtual_key_url = f"https://www.tesla.com/_ak/{self.domain}" + data_schema = vol.Schema({}).extend( + { + vol.Optional("qr_code"): QrCodeSelector( + config=QrCodeSelectorConfig( + data=virtual_key_url, + scale=6, + error_correction_level=QrErrorCorrectionLevel.QUARTILE, + ) + ), + } + ) + + return self.async_show_form( + step_id="registration_complete", + data_schema=data_schema, + description_placeholders={ + "virtual_key_url": virtual_key_url, + }, + ) async def async_step_reauth( self, entry_data: Mapping[str, Any] @@ -67,4 +220,11 @@ class OAuth2FlowHandler( step_id="reauth_confirm", description_placeholders={"name": "Tesla Fleet"}, ) - return await self.async_step_user() + # For reauth, skip domain registration and go straight to OAuth + return await super().async_step_user() + + def _is_valid_domain(self, domain: str) -> bool: + """Validate domain format.""" + # Basic domain validation regex + domain_pattern = re.compile(r"^(?:[a-zA-Z0-9]+\.)+[a-zA-Z0-9-]+$") + return bool(domain_pattern.match(domain)) diff --git a/homeassistant/components/tesla_fleet/const.py b/homeassistant/components/tesla_fleet/const.py index 5d2dc84c49e..d73234b1fdd 100644 --- a/homeassistant/components/tesla_fleet/const.py +++ b/homeassistant/components/tesla_fleet/const.py @@ -9,6 +9,7 @@ from tesla_fleet_api.const import Scope DOMAIN = "tesla_fleet" +CONF_DOMAIN = "domain" CONF_REFRESH_TOKEN = "refresh_token" LOGGER = logging.getLogger(__package__) diff --git a/homeassistant/components/tesla_fleet/manifest.json b/homeassistant/components/tesla_fleet/manifest.json index 8f5ba1468a5..4c92e0bd222 100644 --- a/homeassistant/components/tesla_fleet/manifest.json +++ b/homeassistant/components/tesla_fleet/manifest.json @@ -7,5 +7,5 @@ "documentation": "https://www.home-assistant.io/integrations/tesla_fleet", "iot_class": "cloud_polling", "loggers": ["tesla-fleet-api"], - "requirements": ["tesla-fleet-api==1.1.1"] + "requirements": ["tesla-fleet-api==1.2.0"] } diff --git a/homeassistant/components/tesla_fleet/strings.json b/homeassistant/components/tesla_fleet/strings.json index 276858bb3dd..a9b1cfc4845 100644 --- a/homeassistant/components/tesla_fleet/strings.json +++ b/homeassistant/components/tesla_fleet/strings.json @@ -4,6 +4,7 @@ "authorize_url_timeout": "[%key:common::config_flow::abort::oauth2_authorize_url_timeout%]", "missing_configuration": "[%key:common::config_flow::abort::oauth2_missing_configuration%]", "already_configured": "Configuration updated for profile.", + "already_in_progress": "[%key:common::config_flow::abort::already_in_progress%]", "no_url_available": "[%key:common::config_flow::abort::oauth2_no_url_available%]", "oauth_error": "[%key:common::config_flow::abort::oauth2_error%]", "oauth_timeout": "[%key:common::config_flow::abort::oauth2_timeout%]", @@ -13,7 +14,12 @@ "reauth_account_mismatch": "The reauthentication account does not match the original account" }, "error": { - "already_configured": "[%key:common::config_flow::abort::already_configured_account%]" + "invalid_domain": "Invalid domain format. Please enter a valid domain name.", + "public_key_not_found": "Public key not found.", + "public_key_mismatch": "The public key hosted at your domain does not match the expected key. Please ensure the correct public key is hosted at the specified location.", + "precondition_failed": "The domain does not match the application's allowed origins.", + "invalid_response": "The registration was rejected by Tesla", + "unknown_error": "An unknown error occurred: {error}" }, "step": { "pick_implementation": { @@ -25,6 +31,21 @@ "implementation": "[%key:common::config_flow::description::implementation%]" } }, + "domain_input": { + "title": "Tesla Fleet domain registration", + "description": "Enter the domain that will host your public key. This is typically the domain of the origin you specified during registration at {dashboard}.", + "data": { + "domain": "Domain" + } + }, + "domain_registration": { + "title": "Registering public key", + "description": "You must host the public key at:\n\n{public_key_url}\n\n```\n{pem}\n```" + }, + "registration_complete": { + "title": "Command signing", + "description": "To enable command signing, you must open the Tesla app, select your vehicle, and then visit the following URL to set up a virtual key. You must repeat this process for each vehicle.\n\n{virtual_key_url}" + }, "reauth_confirm": { "title": "[%key:common::config_flow::title::reauth%]", "description": "The {name} integration needs to re-authenticate your account" diff --git a/homeassistant/components/teslemetry/manifest.json b/homeassistant/components/teslemetry/manifest.json index 7fc621eeeae..f58783e04a4 100644 --- a/homeassistant/components/teslemetry/manifest.json +++ b/homeassistant/components/teslemetry/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/teslemetry", "iot_class": "cloud_polling", "loggers": ["tesla-fleet-api"], - "requirements": ["tesla-fleet-api==1.1.1", "teslemetry-stream==0.7.9"] + "requirements": ["tesla-fleet-api==1.2.0", "teslemetry-stream==0.7.9"] } diff --git a/homeassistant/components/tessie/manifest.json b/homeassistant/components/tessie/manifest.json index 9ad87e9dbbe..c0cbc2ea431 100644 --- a/homeassistant/components/tessie/manifest.json +++ b/homeassistant/components/tessie/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/tessie", "iot_class": "cloud_polling", "loggers": ["tessie", "tesla-fleet-api"], - "requirements": ["tessie-api==0.1.1", "tesla-fleet-api==1.1.1"] + "requirements": ["tessie-api==0.1.1", "tesla-fleet-api==1.2.0"] } diff --git a/homeassistant/components/vacuum/__init__.py b/homeassistant/components/vacuum/__init__.py index 3b1eee8509c..83c68fb61b6 100644 --- a/homeassistant/components/vacuum/__init__.py +++ b/homeassistant/components/vacuum/__init__.py @@ -312,7 +312,7 @@ class StateVacuumEntity( @property def capability_attributes(self) -> dict[str, Any] | None: """Return capability attributes.""" - if VacuumEntityFeature.FAN_SPEED in self.supported_features_compat: + if VacuumEntityFeature.FAN_SPEED in self.supported_features: return {ATTR_FAN_SPEED_LIST: self.fan_speed_list} return None @@ -330,7 +330,7 @@ class StateVacuumEntity( def state_attributes(self) -> dict[str, Any]: """Return the state attributes of the vacuum cleaner.""" data: dict[str, Any] = {} - supported_features = self.supported_features_compat + supported_features = self.supported_features if VacuumEntityFeature.BATTERY in supported_features: data[ATTR_BATTERY_LEVEL] = self.battery_level @@ -369,19 +369,6 @@ class StateVacuumEntity( """Flag vacuum cleaner features that are supported.""" return self._attr_supported_features - @property - def supported_features_compat(self) -> VacuumEntityFeature: - """Return the supported features as VacuumEntityFeature. - - Remove this compatibility shim in 2025.1 or later. - """ - features = self.supported_features - if type(features) is int: - new_features = VacuumEntityFeature(features) - self._report_deprecated_supported_features_values(new_features) - return new_features - return features - def stop(self, **kwargs: Any) -> None: """Stop the vacuum cleaner.""" raise NotImplementedError diff --git a/homeassistant/components/wallbox/const.py b/homeassistant/components/wallbox/const.py index d978e1ec7c9..5aa659a0527 100644 --- a/homeassistant/components/wallbox/const.py +++ b/homeassistant/components/wallbox/const.py @@ -74,3 +74,4 @@ class EcoSmartMode(StrEnum): OFF = "off" ECO_MODE = "eco_mode" FULL_SOLAR = "full_solar" + DISABLED = "disabled" diff --git a/homeassistant/components/wallbox/coordinator.py b/homeassistant/components/wallbox/coordinator.py index 60f062e57cc..8276ee14eaf 100644 --- a/homeassistant/components/wallbox/coordinator.py +++ b/homeassistant/components/wallbox/coordinator.py @@ -166,13 +166,20 @@ class WallboxCoordinator(DataUpdateCoordinator[dict[str, Any]]): ) # Set current solar charging mode - eco_smart_enabled = data[CHARGER_DATA_KEY][CHARGER_ECO_SMART_KEY][ - CHARGER_ECO_SMART_STATUS_KEY - ] - eco_smart_mode = data[CHARGER_DATA_KEY][CHARGER_ECO_SMART_KEY][ - CHARGER_ECO_SMART_MODE_KEY - ] - if eco_smart_enabled is False: + eco_smart_enabled = ( + data[CHARGER_DATA_KEY] + .get(CHARGER_ECO_SMART_KEY, {}) + .get(CHARGER_ECO_SMART_STATUS_KEY) + ) + + eco_smart_mode = ( + data[CHARGER_DATA_KEY] + .get(CHARGER_ECO_SMART_KEY, {}) + .get(CHARGER_ECO_SMART_MODE_KEY) + ) + if eco_smart_mode is None: + data[CHARGER_ECO_SMART_KEY] = EcoSmartMode.DISABLED + elif eco_smart_enabled is False: data[CHARGER_ECO_SMART_KEY] = EcoSmartMode.OFF elif eco_smart_mode == 0: data[CHARGER_ECO_SMART_KEY] = EcoSmartMode.ECO_MODE diff --git a/homeassistant/components/wallbox/select.py b/homeassistant/components/wallbox/select.py index 7ad7a135bc8..0048aa35c7c 100644 --- a/homeassistant/components/wallbox/select.py +++ b/homeassistant/components/wallbox/select.py @@ -63,15 +63,15 @@ async def async_setup_entry( ) -> None: """Create wallbox select entities in HASS.""" coordinator: WallboxCoordinator = hass.data[DOMAIN][entry.entry_id] - - async_add_entities( - WallboxSelect(coordinator, description) - for ent in coordinator.data - if ( - (description := SELECT_TYPES.get(ent)) - and description.supported_fn(coordinator) + if coordinator.data[CHARGER_ECO_SMART_KEY] != EcoSmartMode.DISABLED: + async_add_entities( + WallboxSelect(coordinator, description) + for ent in coordinator.data + if ( + (description := SELECT_TYPES.get(ent)) + and description.supported_fn(coordinator) + ) ) - ) class WallboxSelect(WallboxEntity, SelectEntity): diff --git a/homeassistant/components/webdav/manifest.json b/homeassistant/components/webdav/manifest.json index 63d093745d1..9e9e1c8866e 100644 --- a/homeassistant/components/webdav/manifest.json +++ b/homeassistant/components/webdav/manifest.json @@ -8,5 +8,5 @@ "iot_class": "cloud_polling", "loggers": ["aiowebdav2"], "quality_scale": "bronze", - "requirements": ["aiowebdav2==0.4.5"] + "requirements": ["aiowebdav2==0.4.6"] } diff --git a/homeassistant/components/workday/manifest.json b/homeassistant/components/workday/manifest.json index 9091dd131dd..f9fae38f1f5 100644 --- a/homeassistant/components/workday/manifest.json +++ b/homeassistant/components/workday/manifest.json @@ -7,5 +7,5 @@ "iot_class": "local_polling", "loggers": ["holidays"], "quality_scale": "internal", - "requirements": ["holidays==0.74"] + "requirements": ["holidays==0.75"] } diff --git a/homeassistant/components/wyoming/assist_satellite.py b/homeassistant/components/wyoming/assist_satellite.py index 88939f0ba77..75c227f8537 100644 --- a/homeassistant/components/wyoming/assist_satellite.py +++ b/homeassistant/components/wyoming/assist_satellite.py @@ -6,6 +6,7 @@ import asyncio from collections.abc import AsyncGenerator import io import logging +import time from typing import Any, Final import wave @@ -36,6 +37,7 @@ from homeassistant.components.assist_satellite import ( from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback +from homeassistant.util.ulid import ulid_now from .const import DOMAIN, SAMPLE_CHANNELS, SAMPLE_WIDTH from .data import WyomingService @@ -53,6 +55,7 @@ _PING_SEND_DELAY: Final = 2 _PIPELINE_FINISH_TIMEOUT: Final = 1 _TTS_SAMPLE_RATE: Final = 22050 _ANNOUNCE_CHUNK_BYTES: Final = 2048 # 1024 samples +_TTS_TIMEOUT_EXTRA: Final = 1.0 # Wyoming stage -> Assist stage _STAGES: dict[PipelineStage, assist_pipeline.PipelineStage] = { @@ -125,6 +128,10 @@ class WyomingAssistSatellite(WyomingSatelliteEntity, AssistSatelliteEntity): self._ffmpeg_manager: ffmpeg.FFmpegManager | None = None self._played_event_received: asyncio.Event | None = None + # Randomly set on each pipeline loop run. + # Used to ensure TTS timeout is acted on correctly. + self._run_loop_id: str | None = None + @property def pipeline_entity_id(self) -> str | None: """Return the entity ID of the pipeline to use for the next conversation.""" @@ -511,6 +518,7 @@ class WyomingAssistSatellite(WyomingSatelliteEntity, AssistSatelliteEntity): wake_word_phrase: str | None = None run_pipeline: RunPipeline | None = None send_ping = True + self._run_loop_id = ulid_now() # Read events and check for pipeline end in parallel pipeline_ended_task = self.config_entry.async_create_background_task( @@ -698,38 +706,52 @@ class WyomingAssistSatellite(WyomingSatelliteEntity, AssistSatelliteEntity): f"Cannot stream audio format to satellite: {tts_result.extension}" ) - data = b"".join([chunk async for chunk in tts_result.async_stream_result()]) + # Track the total duration of TTS audio for response timeout + total_seconds = 0.0 + start_time = time.monotonic() - with io.BytesIO(data) as wav_io, wave.open(wav_io, "rb") as wav_file: - sample_rate = wav_file.getframerate() - sample_width = wav_file.getsampwidth() - sample_channels = wav_file.getnchannels() - _LOGGER.debug("Streaming %s TTS sample(s)", wav_file.getnframes()) + try: + data = b"".join([chunk async for chunk in tts_result.async_stream_result()]) - timestamp = 0 - await self._client.write_event( - AudioStart( - rate=sample_rate, - width=sample_width, - channels=sample_channels, - timestamp=timestamp, - ).event() - ) + with io.BytesIO(data) as wav_io, wave.open(wav_io, "rb") as wav_file: + sample_rate = wav_file.getframerate() + sample_width = wav_file.getsampwidth() + sample_channels = wav_file.getnchannels() + _LOGGER.debug("Streaming %s TTS sample(s)", wav_file.getnframes()) - # Stream audio chunks - while audio_bytes := wav_file.readframes(_SAMPLES_PER_CHUNK): - chunk = AudioChunk( - rate=sample_rate, - width=sample_width, - channels=sample_channels, - audio=audio_bytes, - timestamp=timestamp, + timestamp = 0 + await self._client.write_event( + AudioStart( + rate=sample_rate, + width=sample_width, + channels=sample_channels, + timestamp=timestamp, + ).event() ) - await self._client.write_event(chunk.event()) - timestamp += chunk.seconds - await self._client.write_event(AudioStop(timestamp=timestamp).event()) - _LOGGER.debug("TTS streaming complete") + # Stream audio chunks + while audio_bytes := wav_file.readframes(_SAMPLES_PER_CHUNK): + chunk = AudioChunk( + rate=sample_rate, + width=sample_width, + channels=sample_channels, + audio=audio_bytes, + timestamp=timestamp, + ) + await self._client.write_event(chunk.event()) + timestamp += chunk.seconds + total_seconds += chunk.seconds + + await self._client.write_event(AudioStop(timestamp=timestamp).event()) + _LOGGER.debug("TTS streaming complete") + finally: + send_duration = time.monotonic() - start_time + timeout_seconds = max(0, total_seconds - send_duration + _TTS_TIMEOUT_EXTRA) + self.config_entry.async_create_background_task( + self.hass, + self._tts_timeout(timeout_seconds, self._run_loop_id), + name="wyoming TTS timeout", + ) async def _stt_stream(self) -> AsyncGenerator[bytes]: """Yield audio chunks from a queue.""" @@ -744,6 +766,18 @@ class WyomingAssistSatellite(WyomingSatelliteEntity, AssistSatelliteEntity): yield chunk + async def _tts_timeout( + self, timeout_seconds: float, run_loop_id: str | None + ) -> None: + """Force state change to IDLE in case TTS played event isn't received.""" + await asyncio.sleep(timeout_seconds + _TTS_TIMEOUT_EXTRA) + + if run_loop_id != self._run_loop_id: + # On a different pipeline run now + return + + self.tts_response_finished() + @callback def _handle_timer( self, event_type: intent.TimerEventType, timer: intent.TimerInfo diff --git a/homeassistant/components/xiaomi_miio/manifest.json b/homeassistant/components/xiaomi_miio/manifest.json index abda8703e02..129acf53740 100644 --- a/homeassistant/components/xiaomi_miio/manifest.json +++ b/homeassistant/components/xiaomi_miio/manifest.json @@ -1,6 +1,6 @@ { "domain": "xiaomi_miio", - "name": "Xiaomi Miio", + "name": "Xiaomi Home", "codeowners": ["@rytilahti", "@syssi", "@starkillerOG"], "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/xiaomi_miio", diff --git a/homeassistant/components/xiaomi_miio/strings.json b/homeassistant/components/xiaomi_miio/strings.json index a5af3d8bd1f..fef185daf41 100644 --- a/homeassistant/components/xiaomi_miio/strings.json +++ b/homeassistant/components/xiaomi_miio/strings.json @@ -5,37 +5,37 @@ "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", "already_in_progress": "[%key:common::config_flow::abort::already_in_progress%]", "incomplete_info": "Incomplete information to set up device, no host or token supplied.", - "not_xiaomi_miio": "Device is not (yet) supported by Xiaomi Miio.", + "not_xiaomi_miio": "Device is not (yet) supported by Xiaomi Home integration.", "unknown": "[%key:common::config_flow::error::unknown%]" }, "error": { "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", "wrong_token": "Checksum error, wrong token", "unknown_device": "The device model is not known, not able to set up the device using config flow.", - "cloud_no_devices": "No devices found in this Xiaomi Miio cloud account.", - "cloud_credentials_incomplete": "Cloud credentials incomplete, please fill in username, password and country", - "cloud_login_error": "Could not log in to Xiaomi Miio Cloud, check the credentials." + "cloud_no_devices": "No devices found in this Xiaomi Home account.", + "cloud_credentials_incomplete": "Credentials incomplete, please fill in username, password and server region", + "cloud_login_error": "Could not log in to Xiaomi Home, check the credentials." }, "flow_title": "{name}", "step": { "reauth_confirm": { - "description": "The Xiaomi Miio integration needs to re-authenticate your account in order to update the tokens or add missing cloud credentials.", + "description": "The Xiaomi Home integration needs to re-authenticate your account in order to update the tokens or add missing credentials.", "title": "[%key:common::config_flow::title::reauth%]" }, "cloud": { "data": { - "cloud_username": "Cloud username", - "cloud_password": "Cloud password", - "cloud_country": "Cloud server country", + "cloud_username": "[%key:common::config_flow::data::username%]", + "cloud_password": "[%key:common::config_flow::data::password%]", + "cloud_country": "Server region", "manual": "Configure manually (not recommended)" }, - "description": "Log in to the Xiaomi Miio cloud, see https://www.openhab.org/addons/bindings/miio/#country-servers for the cloud server to use." + "description": "Log in to Xiaomi Home, see https://www.openhab.org/addons/bindings/miio/#country-servers for the server region to use." }, "select": { "data": { - "select_device": "Miio device" + "select_device": "[%key:common::config_flow::data::device%]" }, - "description": "Select the Xiaomi Miio device to set up." + "description": "Select the Xiaomi Home device to set up." }, "manual": { "data": { @@ -58,7 +58,7 @@ "step": { "init": { "data": { - "cloud_subdevices": "Use cloud to get connected subdevices" + "cloud_subdevices": "Use Xiaomi Home service to get connected subdevices" } } } @@ -331,7 +331,7 @@ "fields": { "entity_id": { "name": "Entity ID", - "description": "Name of the Xiaomi Miio entity." + "description": "Name of the Xiaomi Home entity." } } }, diff --git a/homeassistant/components/zha/manifest.json b/homeassistant/components/zha/manifest.json index 4a5ec7be1dc..4908298847b 100644 --- a/homeassistant/components/zha/manifest.json +++ b/homeassistant/components/zha/manifest.json @@ -21,7 +21,7 @@ "zha", "universal_silabs_flasher" ], - "requirements": ["zha==0.0.59"], + "requirements": ["zha==0.0.60"], "usb": [ { "vid": "10C4", diff --git a/homeassistant/components/zwave_js/discovery.py b/homeassistant/components/zwave_js/discovery.py index b46735e4040..4e9a3321beb 100644 --- a/homeassistant/components/zwave_js/discovery.py +++ b/homeassistant/components/zwave_js/discovery.py @@ -896,6 +896,7 @@ DISCOVERY_SCHEMAS = [ writeable=False, ), entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, ), # generic text sensors ZWaveDiscoverySchema( @@ -932,6 +933,7 @@ DISCOVERY_SCHEMAS = [ ), data_template=NumericSensorDataTemplate(), entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, ), # Meter sensors for Meter CC ZWaveDiscoverySchema( @@ -957,6 +959,7 @@ DISCOVERY_SCHEMAS = [ writeable=True, ), entity_category=EntityCategory.CONFIG, + entity_registry_enabled_default=False, ), # button for Indicator CC ZWaveDiscoverySchema( @@ -980,6 +983,7 @@ DISCOVERY_SCHEMAS = [ writeable=True, ), entity_category=EntityCategory.CONFIG, + entity_registry_enabled_default=False, ), # binary switch # barrier operator signaling states @@ -1184,6 +1188,7 @@ DISCOVERY_SCHEMAS = [ any_available_states={(0, "idle")}, ), allow_multi=True, + entity_registry_enabled_default=False, ), # event # stateful = False diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index ca527d117f1..3795bd838ea 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -7475,7 +7475,7 @@ "integration_type": "hub", "config_flow": true, "iot_class": "local_polling", - "name": "Xiaomi Miio" + "name": "Xiaomi Home" }, "xiaomi_tv": { "integration_type": "hub", diff --git a/homeassistant/helpers/llm.py b/homeassistant/helpers/llm.py index adf113e0f30..51b5510495f 100644 --- a/homeassistant/helpers/llm.py +++ b/homeassistant/helpers/llm.py @@ -160,11 +160,19 @@ class LLMContext: """Tool input to be processed.""" platform: str + """Integration that is handling the LLM request.""" + context: Context | None - user_prompt: str | None + """Context of the LLM request.""" + language: str | None + """Language of the LLM request.""" + assistant: str | None + """Assistant domain that is handling the LLM request.""" + device_id: str | None + """Device that is making the request.""" @dataclass(slots=True) @@ -302,7 +310,7 @@ class IntentTool(Tool): platform=llm_context.platform, intent_type=self.name, slots=slots, - text_input=llm_context.user_prompt, + text_input=None, context=llm_context.context, language=llm_context.language, assistant=llm_context.assistant, diff --git a/homeassistant/helpers/service.py b/homeassistant/helpers/service.py index f157e82bc53..6e1988fe4cd 100644 --- a/homeassistant/helpers/service.py +++ b/homeassistant/helpers/service.py @@ -682,9 +682,12 @@ def _load_services_file(hass: HomeAssistant, integration: Integration) -> JSON_T def _load_services_files( hass: HomeAssistant, integrations: Iterable[Integration] -) -> list[JSON_TYPE]: +) -> dict[str, JSON_TYPE]: """Load service files for multiple integrations.""" - return [_load_services_file(hass, integration) for integration in integrations] + return { + integration.domain: _load_services_file(hass, integration) + for integration in integrations + } @callback @@ -744,10 +747,9 @@ async def async_get_all_descriptions( _LOGGER.error("Failed to load integration: %s", domain, exc_info=int_or_exc) if integrations: - contents = await hass.async_add_executor_job( + loaded = await hass.async_add_executor_job( _load_services_files, hass, integrations ) - loaded = dict(zip(domains_with_missing_services, contents, strict=False)) # Load translations for all service domains translations = await translation.async_get_translations( diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 7e6dea5022e..df0c6ef7452 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -7,7 +7,7 @@ aiofiles==24.1.0 aiohasupervisor==0.3.1 aiohttp-asyncmdnsresolver==0.1.1 aiohttp-fast-zlib==0.3.0 -aiohttp==3.12.12 +aiohttp==3.12.13 aiohttp_cors==0.8.1 aiousbwatcher==1.1.1 aiozoneinfo==0.2.3 @@ -36,7 +36,7 @@ fnv-hash-fast==1.5.0 go2rtc-client==0.2.1 ha-ffmpeg==3.2.2 habluetooth==3.49.0 -hass-nabucasa==0.101.0 +hass-nabucasa==0.102.0 hassil==2.2.3 home-assistant-bluetooth==1.13.1 home-assistant-frontend==20250531.3 @@ -131,7 +131,7 @@ multidict>=6.0.2 backoff>=2.0 # ensure pydantic version does not float since it might have breaking changes -pydantic==2.11.6 +pydantic==2.11.7 # Required for Python 3.12.4 compatibility (#119223). mashumaro>=3.13.1 diff --git a/homeassistant/scripts/__init__.py b/homeassistant/scripts/__init__.py index f0600b70f48..52d96109bf2 100644 --- a/homeassistant/scripts/__init__.py +++ b/homeassistant/scripts/__init__.py @@ -46,10 +46,8 @@ def run(args: list[str]) -> int: config_dir = extract_config_dir() - loop = asyncio.get_event_loop() - if not is_virtual_env(): - loop.run_until_complete(async_mount_local_lib_path(config_dir)) + asyncio.run(async_mount_local_lib_path(config_dir)) _pip_kwargs = pip_kwargs(config_dir) diff --git a/pyproject.toml b/pyproject.toml index 284a0d39bfe..c4cfe7a8593 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -29,7 +29,7 @@ dependencies = [ # change behavior based on presence of supervisor. Deprecated with #127228 # Lib can be removed with 2025.11 "aiohasupervisor==0.3.1", - "aiohttp==3.12.12", + "aiohttp==3.12.13", "aiohttp_cors==0.8.1", "aiohttp-fast-zlib==0.3.0", "aiohttp-asyncmdnsresolver==0.1.1", @@ -53,7 +53,7 @@ dependencies = [ "ha-ffmpeg==3.2.2", # hass-nabucasa is imported by helpers which don't depend on the cloud # integration - "hass-nabucasa==0.101.0", + "hass-nabucasa==0.102.0", # hassil is indirectly imported from onboarding via the import chain # onboarding->cloud->assist_pipeline->conversation->hassil. Onboarding needs # to be setup in stage 0, but we don't want to also promote cloud with all its diff --git a/requirements.txt b/requirements.txt index c96a62b355d..bf963ecc52d 100644 --- a/requirements.txt +++ b/requirements.txt @@ -6,7 +6,7 @@ aiodns==3.5.0 aiofiles==24.1.0 aiohasupervisor==0.3.1 -aiohttp==3.12.12 +aiohttp==3.12.13 aiohttp_cors==0.8.1 aiohttp-fast-zlib==0.3.0 aiohttp-asyncmdnsresolver==0.1.1 @@ -24,7 +24,7 @@ ciso8601==2.3.2 cronsim==2.6 fnv-hash-fast==1.5.0 ha-ffmpeg==3.2.2 -hass-nabucasa==0.101.0 +hass-nabucasa==0.102.0 hassil==2.2.3 httpx==0.28.1 home-assistant-bluetooth==1.13.1 diff --git a/requirements_all.txt b/requirements_all.txt index 40f0e21ef80..b69e1e69ccd 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -182,7 +182,7 @@ aioairzone-cloud==0.6.12 aioairzone==1.0.0 # homeassistant.components.alexa_devices -aioamazondevices==3.1.2 +aioamazondevices==3.1.12 # homeassistant.components.ambient_network # homeassistant.components.ambient_station @@ -201,7 +201,7 @@ aioaseko==1.0.0 aioasuswrt==1.4.0 # homeassistant.components.husqvarna_automower -aioautomower==2025.5.1 +aioautomower==2025.6.0 # homeassistant.components.azure_devops aioazuredevops==2.2.1 @@ -265,7 +265,7 @@ aioharmony==0.5.2 aiohasupervisor==0.3.1 # homeassistant.components.home_connect -aiohomeconnect==0.17.1 +aiohomeconnect==0.18.0 # homeassistant.components.homekit_controller aiohomekit==3.2.15 @@ -369,7 +369,7 @@ aioridwell==2024.01.0 aioruckus==0.42 # homeassistant.components.russound_rio -aiorussound==4.5.2 +aiorussound==4.6.0 # homeassistant.components.ruuvi_gateway aioruuvigateway==0.1.0 @@ -429,7 +429,7 @@ aiowaqi==3.1.0 aiowatttime==0.1.1 # homeassistant.components.webdav -aiowebdav2==0.4.5 +aiowebdav2==0.4.6 # homeassistant.components.webostv aiowebostv==0.7.3 @@ -683,7 +683,7 @@ brunt==1.2.0 bt-proximity==0.2.1 # homeassistant.components.bthome -bthome-ble==3.12.4 +bthome-ble==3.13.1 # homeassistant.components.bt_home_hub_5 bthomehub5-devicelist==0.1.1 @@ -893,7 +893,7 @@ epion==0.0.3 epson-projector==0.5.1 # homeassistant.components.eq3btsmart -eq3btsmart==1.4.1 +eq3btsmart==2.1.0 # homeassistant.components.esphome esphome-dashboard-api==1.3.0 @@ -1124,7 +1124,7 @@ habiticalib==0.4.0 habluetooth==3.49.0 # homeassistant.components.cloud -hass-nabucasa==0.101.0 +hass-nabucasa==0.102.0 # homeassistant.components.splunk hass-splunk==0.1.1 @@ -1161,7 +1161,7 @@ hole==0.8.0 # homeassistant.components.holiday # homeassistant.components.workday -holidays==0.74 +holidays==0.75 # homeassistant.components.frontend home-assistant-frontend==20250531.3 @@ -1170,7 +1170,7 @@ home-assistant-frontend==20250531.3 home-assistant-intents==2025.6.10 # homeassistant.components.homematicip_cloud -homematicip==2.0.4 +homematicip==2.0.5 # homeassistant.components.horizon horimote==0.4.1 @@ -1203,7 +1203,7 @@ ibmiotf==0.3.4 # homeassistant.components.local_calendar # homeassistant.components.local_todo # homeassistant.components.remote_calendar -ical==10.0.0 +ical==10.0.4 # homeassistant.components.caldav icalendar==6.1.0 @@ -1448,7 +1448,7 @@ monzopy==1.4.2 mopeka-iot-ble==0.8.0 # homeassistant.components.motion_blinds -motionblinds==0.6.27 +motionblinds==0.6.28 # homeassistant.components.motionblinds_ble motionblindsble==0.1.3 @@ -1484,7 +1484,7 @@ nad-receiver==0.3.0 ndms2-client==0.1.2 # homeassistant.components.ness_alarm -nessclient==1.1.2 +nessclient==1.2.0 # homeassistant.components.netdata netdata==1.3.0 @@ -1505,7 +1505,7 @@ nexia==2.10.0 nextcloudmonitor==1.5.1 # homeassistant.components.discord -nextcord==2.6.0 +nextcord==3.1.0 # homeassistant.components.nextdns nextdns==4.0.0 @@ -1762,7 +1762,7 @@ py-madvr2==1.6.32 py-melissa-climate==2.1.4 # homeassistant.components.nextbus -py-nextbusnext==2.2.0 +py-nextbusnext==2.3.0 # homeassistant.components.nightscout py-nightscout==1.2.2 @@ -2096,7 +2096,7 @@ pykwb==0.0.8 pylacrosse==0.4 # homeassistant.components.lamarzocco -pylamarzocco==2.0.8 +pylamarzocco==2.0.9 # homeassistant.components.lastfm pylast==5.1.0 @@ -2114,7 +2114,7 @@ pylibrespot-java==0.1.1 pylitejet==0.6.3 # homeassistant.components.litterrobot -pylitterbot==2024.0.0 +pylitterbot==2024.2.0 # homeassistant.components.lutron_caseta pylutron-caseta==0.24.0 @@ -2156,7 +2156,7 @@ pymonoprice==0.4 pymsteams==0.1.12 # homeassistant.components.mysensors -pymysensors==0.24.0 +pymysensors==0.25.0 # homeassistant.components.iron_os pynecil==4.1.0 @@ -2210,7 +2210,7 @@ pyopnsense==0.4.0 pyoppleio-legacy==1.0.8 # homeassistant.components.osoenergy -pyosoenergyapi==1.1.4 +pyosoenergyapi==1.1.5 # homeassistant.components.opentherm_gw pyotgw==2.2.2 @@ -2236,7 +2236,7 @@ pypaperless==4.1.0 pypca==0.0.7 # homeassistant.components.lcn -pypck==0.8.7 +pypck==0.8.8 # homeassistant.components.pglab pypglab==0.0.5 @@ -2338,10 +2338,10 @@ pysma==0.7.5 pysmappee==0.2.29 # homeassistant.components.smarla -pysmarlaapi==0.8.2 +pysmarlaapi==0.9.0 # homeassistant.components.smartthings -pysmartthings==3.2.4 +pysmartthings==3.2.5 # homeassistant.components.smarty pysmarty2==0.10.2 @@ -2350,7 +2350,7 @@ pysmarty2==0.10.2 pysmhi==1.0.2 # homeassistant.components.edl21 -pysml==0.0.12 +pysml==0.1.5 # homeassistant.components.smlight pysmlight==0.2.6 @@ -2652,13 +2652,13 @@ renault-api==0.3.1 renson-endura-delta==1.7.2 # homeassistant.components.reolink -reolink-aio==0.14.0 +reolink-aio==0.14.1 # homeassistant.components.idteck_prox rfk101py==0.0.1 # homeassistant.components.rflink -rflink==0.0.66 +rflink==0.0.67 # homeassistant.components.ring ring-doorbell==0.9.13 @@ -2673,7 +2673,7 @@ rjpl==0.3.6 rocketchat-API==0.6.1 # homeassistant.components.roku -rokuecp==0.19.3 +rokuecp==0.19.5 # homeassistant.components.romy romy==0.0.10 @@ -2900,7 +2900,7 @@ temperusb==1.6.1 # homeassistant.components.tesla_fleet # homeassistant.components.teslemetry # homeassistant.components.tessie -tesla-fleet-api==1.1.1 +tesla-fleet-api==1.2.0 # homeassistant.components.powerwall tesla-powerwall==0.5.2 @@ -3180,7 +3180,7 @@ zeroconf==0.147.0 zeversolar==0.3.2 # homeassistant.components.zha -zha==0.0.59 +zha==0.0.60 # homeassistant.components.zhong_hong zhong-hong-hvac==1.0.13 diff --git a/requirements_test.txt b/requirements_test.txt index ebdbc35720b..29d2618c69d 100644 --- a/requirements_test.txt +++ b/requirements_test.txt @@ -15,7 +15,7 @@ license-expression==30.4.1 mock-open==1.4.0 mypy-dev==1.17.0a2 pre-commit==4.2.0 -pydantic==2.11.6 +pydantic==2.11.7 pylint==3.3.7 pylint-per-file-ignores==1.4.0 pipdeptree==2.26.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 4b4f177b3e0..8254ce9f261 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -170,7 +170,7 @@ aioairzone-cloud==0.6.12 aioairzone==1.0.0 # homeassistant.components.alexa_devices -aioamazondevices==3.1.2 +aioamazondevices==3.1.12 # homeassistant.components.ambient_network # homeassistant.components.ambient_station @@ -189,7 +189,7 @@ aioaseko==1.0.0 aioasuswrt==1.4.0 # homeassistant.components.husqvarna_automower -aioautomower==2025.5.1 +aioautomower==2025.6.0 # homeassistant.components.azure_devops aioazuredevops==2.2.1 @@ -250,7 +250,7 @@ aioharmony==0.5.2 aiohasupervisor==0.3.1 # homeassistant.components.home_connect -aiohomeconnect==0.17.1 +aiohomeconnect==0.18.0 # homeassistant.components.homekit_controller aiohomekit==3.2.15 @@ -351,7 +351,7 @@ aioridwell==2024.01.0 aioruckus==0.42 # homeassistant.components.russound_rio -aiorussound==4.5.2 +aiorussound==4.6.0 # homeassistant.components.ruuvi_gateway aioruuvigateway==0.1.0 @@ -411,7 +411,7 @@ aiowaqi==3.1.0 aiowatttime==0.1.1 # homeassistant.components.webdav -aiowebdav2==0.4.5 +aiowebdav2==0.4.6 # homeassistant.components.webostv aiowebostv==0.7.3 @@ -607,7 +607,7 @@ brottsplatskartan==1.0.5 brunt==1.2.0 # homeassistant.components.bthome -bthome-ble==3.12.4 +bthome-ble==3.13.1 # homeassistant.components.buienradar buienradar==1.0.6 @@ -772,7 +772,7 @@ epion==0.0.3 epson-projector==0.5.1 # homeassistant.components.eq3btsmart -eq3btsmart==1.4.1 +eq3btsmart==2.1.0 # homeassistant.components.esphome esphome-dashboard-api==1.3.0 @@ -982,7 +982,7 @@ habiticalib==0.4.0 habluetooth==3.49.0 # homeassistant.components.cloud -hass-nabucasa==0.101.0 +hass-nabucasa==0.102.0 # homeassistant.components.conversation hassil==2.2.3 @@ -1007,7 +1007,7 @@ hole==0.8.0 # homeassistant.components.holiday # homeassistant.components.workday -holidays==0.74 +holidays==0.75 # homeassistant.components.frontend home-assistant-frontend==20250531.3 @@ -1016,7 +1016,7 @@ home-assistant-frontend==20250531.3 home-assistant-intents==2025.6.10 # homeassistant.components.homematicip_cloud -homematicip==2.0.4 +homematicip==2.0.5 # homeassistant.components.remember_the_milk httplib2==0.20.4 @@ -1040,7 +1040,7 @@ ibeacon-ble==1.2.0 # homeassistant.components.local_calendar # homeassistant.components.local_todo # homeassistant.components.remote_calendar -ical==10.0.0 +ical==10.0.4 # homeassistant.components.caldav icalendar==6.1.0 @@ -1237,7 +1237,7 @@ monzopy==1.4.2 mopeka-iot-ble==0.8.0 # homeassistant.components.motion_blinds -motionblinds==0.6.27 +motionblinds==0.6.28 # homeassistant.components.motionblinds_ble motionblindsble==0.1.3 @@ -1270,7 +1270,7 @@ myuplink==0.7.0 ndms2-client==0.1.2 # homeassistant.components.ness_alarm -nessclient==1.1.2 +nessclient==1.2.0 # homeassistant.components.nmap_tracker netmap==0.7.0.2 @@ -1285,7 +1285,7 @@ nexia==2.10.0 nextcloudmonitor==1.5.1 # homeassistant.components.discord -nextcord==2.6.0 +nextcord==3.1.0 # homeassistant.components.nextdns nextdns==4.0.0 @@ -1485,7 +1485,7 @@ py-madvr2==1.6.32 py-melissa-climate==2.1.4 # homeassistant.components.nextbus -py-nextbusnext==2.2.0 +py-nextbusnext==2.3.0 # homeassistant.components.nightscout py-nightscout==1.2.2 @@ -1738,7 +1738,7 @@ pykrakenapi==0.1.8 pykulersky==0.5.8 # homeassistant.components.lamarzocco -pylamarzocco==2.0.8 +pylamarzocco==2.0.9 # homeassistant.components.lastfm pylast==5.1.0 @@ -1756,7 +1756,7 @@ pylibrespot-java==0.1.1 pylitejet==0.6.3 # homeassistant.components.litterrobot -pylitterbot==2024.0.0 +pylitterbot==2024.2.0 # homeassistant.components.lutron_caseta pylutron-caseta==0.24.0 @@ -1789,7 +1789,7 @@ pymodbus==3.9.2 pymonoprice==0.4 # homeassistant.components.mysensors -pymysensors==0.24.0 +pymysensors==0.25.0 # homeassistant.components.iron_os pynecil==4.1.0 @@ -1834,7 +1834,7 @@ pyopenweathermap==0.2.2 pyopnsense==0.4.0 # homeassistant.components.osoenergy -pyosoenergyapi==1.1.4 +pyosoenergyapi==1.1.5 # homeassistant.components.opentherm_gw pyotgw==2.2.2 @@ -1857,7 +1857,7 @@ pypalazzetti==0.1.19 pypaperless==4.1.0 # homeassistant.components.lcn -pypck==0.8.7 +pypck==0.8.8 # homeassistant.components.pglab pypglab==0.0.5 @@ -1938,10 +1938,10 @@ pysma==0.7.5 pysmappee==0.2.29 # homeassistant.components.smarla -pysmarlaapi==0.8.2 +pysmarlaapi==0.9.0 # homeassistant.components.smartthings -pysmartthings==3.2.4 +pysmartthings==3.2.5 # homeassistant.components.smarty pysmarty2==0.10.2 @@ -1950,7 +1950,7 @@ pysmarty2==0.10.2 pysmhi==1.0.2 # homeassistant.components.edl21 -pysml==0.0.12 +pysml==0.1.5 # homeassistant.components.smlight pysmlight==0.2.6 @@ -2195,16 +2195,16 @@ renault-api==0.3.1 renson-endura-delta==1.7.2 # homeassistant.components.reolink -reolink-aio==0.14.0 +reolink-aio==0.14.1 # homeassistant.components.rflink -rflink==0.0.66 +rflink==0.0.67 # homeassistant.components.ring ring-doorbell==0.9.13 # homeassistant.components.roku -rokuecp==0.19.3 +rokuecp==0.19.5 # homeassistant.components.romy romy==0.0.10 @@ -2383,7 +2383,7 @@ temperusb==1.6.1 # homeassistant.components.tesla_fleet # homeassistant.components.teslemetry # homeassistant.components.tessie -tesla-fleet-api==1.1.1 +tesla-fleet-api==1.2.0 # homeassistant.components.powerwall tesla-powerwall==0.5.2 @@ -2621,7 +2621,7 @@ zeroconf==0.147.0 zeversolar==0.3.2 # homeassistant.components.zha -zha==0.0.59 +zha==0.0.60 # homeassistant.components.zwave_js zwave-js-server-python==0.63.0 diff --git a/script/gen_requirements_all.py b/script/gen_requirements_all.py index e8fd6b0f7a8..2e3ecccf5d2 100755 --- a/script/gen_requirements_all.py +++ b/script/gen_requirements_all.py @@ -155,7 +155,7 @@ multidict>=6.0.2 backoff>=2.0 # ensure pydantic version does not float since it might have breaking changes -pydantic==2.11.6 +pydantic==2.11.7 # Required for Python 3.12.4 compatibility (#119223). mashumaro>=3.13.1 diff --git a/script/hassfest/quality_scale.py b/script/hassfest/quality_scale.py index 73cd0bc37d9..52e5f935117 100644 --- a/script/hassfest/quality_scale.py +++ b/script/hassfest/quality_scale.py @@ -480,7 +480,6 @@ INTEGRATIONS_WITHOUT_QUALITY_SCALE_FILE = [ "hko", "hlk_sw16", "holiday", - "home_connect", "homekit", "homekit_controller", "homematic", @@ -1528,7 +1527,6 @@ INTEGRATIONS_WITHOUT_SCALE = [ "hko", "hlk_sw16", "holiday", - "home_connect", "homekit", "homekit_controller", "homematic", diff --git a/script/hassfest/requirements.py b/script/hassfest/requirements.py index b8265e4e58d..48066ff6bf0 100644 --- a/script/hassfest/requirements.py +++ b/script/hassfest/requirements.py @@ -109,11 +109,6 @@ FORBIDDEN_PACKAGE_EXCEPTIONS: dict[str, dict[str, set[str]]] = { "devialet": {"async-upnp-client": {"async-timeout"}}, "dlna_dmr": {"async-upnp-client": {"async-timeout"}}, "dlna_dms": {"async-upnp-client": {"async-timeout"}}, - "edl21": { - # https://github.com/mtdcr/pysml/issues/21 - # pysml > pyserial-asyncio - "pysml": {"pyserial-asyncio", "async-timeout"}, - }, "efergy": { # https://github.com/tkdrob/pyefergy/issues/46 # pyefergy > codecov @@ -225,21 +220,11 @@ FORBIDDEN_PACKAGE_EXCEPTIONS: dict[str, dict[str, set[str]]] = { # pymonoprice > pyserial-asyncio "pymonoprice": {"pyserial-asyncio"} }, - "mysensors": { - # https://github.com/theolind/pymysensors/issues/818 - # pymysensors > pyserial-asyncio - "pymysensors": {"pyserial-asyncio"} - }, "mystrom": { # https://github.com/home-assistant-ecosystem/python-mystrom/issues/55 # python-mystrom > setuptools "python-mystrom": {"setuptools"} }, - "ness_alarm": { - # https://github.com/nickw444/nessclient/issues/73 - # nessclient > pyserial-asyncio - "nessclient": {"pyserial-asyncio"} - }, "nibe_heatpump": {"nibe": {"async-timeout"}}, "norway_air": {"pymetno": {"async-timeout"}}, "nx584": { @@ -260,11 +245,6 @@ FORBIDDEN_PACKAGE_EXCEPTIONS: dict[str, dict[str, set[str]]] = { # opower > arrow > types-python-dateutil "arrow": {"types-python-dateutil"} }, - "osoenergy": { - # https://github.com/osohotwateriot/apyosohotwaterapi/pull/4 - # pyosoenergyapi > unasync > setuptools - "unasync": {"setuptools"} - }, "ovo_energy": { # https://github.com/timmo001/ovoenergy/issues/132 # ovoenergy > incremental > setuptools @@ -277,11 +257,6 @@ FORBIDDEN_PACKAGE_EXCEPTIONS: dict[str, dict[str, set[str]]] = { # gpiozero > colorzero > setuptools "colorzero": {"setuptools"} }, - "rflink": { - # https://github.com/aequitas/python-rflink/issues/78 - # rflink > pyserial-asyncio - "rflink": {"pyserial-asyncio", "async-timeout"} - }, "ring": {"ring-doorbell": {"async-timeout"}}, "rmvtransport": {"pyrmvtransport": {"async-timeout"}}, "roborock": {"python-roborock": {"async-timeout"}}, @@ -331,10 +306,6 @@ PYTHON_VERSION_CHECK_EXCEPTIONS: dict[str, dict[str, set[str]]] = { # https://github.com/hbldh/bleak/pull/1718 (not yet released) "homeassistant": {"bleak"} }, - "eq3btsmart": { - # https://github.com/EuleMitKeule/eq3btsmart/releases/tag/2.0.0 - "homeassistant": {"eq3btsmart"} - }, "python_script": { # Security audits are needed for each Python version "homeassistant": {"restrictedpython"} diff --git a/tests/components/anthropic/test_conversation.py b/tests/components/anthropic/test_conversation.py index 3e01e91976d..6aadcf3eeb4 100644 --- a/tests/components/anthropic/test_conversation.py +++ b/tests/components/anthropic/test_conversation.py @@ -415,7 +415,6 @@ async def test_function_call( llm.LLMContext( platform="anthropic", context=context, - user_prompt="Please call the test function", language="en", assistant="conversation", device_id=None, @@ -510,7 +509,6 @@ async def test_function_exception( llm.LLMContext( platform="anthropic", context=context, - user_prompt="Please call the test function", language="en", assistant="conversation", device_id=None, diff --git a/tests/components/apcupsd/snapshots/test_sensor.ambr b/tests/components/apcupsd/snapshots/test_sensor.ambr index 9c0b2de4fdc..2e991d7cfa6 100644 --- a/tests/components/apcupsd/snapshots/test_sensor.ambr +++ b/tests/components/apcupsd/snapshots/test_sensor.ambr @@ -11,7 +11,7 @@ 'device_id': , 'disabled_by': None, 'domain': 'sensor', - 'entity_category': None, + 'entity_category': , 'entity_id': 'sensor.myups_alarm_delay', 'has_entity_name': True, 'hidden_by': None, @@ -113,7 +113,7 @@ 'device_id': , 'disabled_by': None, 'domain': 'sensor', - 'entity_category': None, + 'entity_category': , 'entity_id': 'sensor.myups_battery_nominal_voltage', 'has_entity_name': True, 'hidden_by': None, @@ -214,7 +214,7 @@ 'device_id': , 'disabled_by': None, 'domain': 'sensor', - 'entity_category': None, + 'entity_category': , 'entity_id': 'sensor.myups_battery_shutdown', 'has_entity_name': True, 'hidden_by': None, @@ -263,7 +263,7 @@ 'device_id': , 'disabled_by': None, 'domain': 'sensor', - 'entity_category': None, + 'entity_category': , 'entity_id': 'sensor.myups_battery_timeout', 'has_entity_name': True, 'hidden_by': None, @@ -368,7 +368,7 @@ 'device_id': , 'disabled_by': None, 'domain': 'sensor', - 'entity_category': None, + 'entity_category': , 'entity_id': 'sensor.myups_cable_type', 'has_entity_name': True, 'hidden_by': None, @@ -416,7 +416,7 @@ 'device_id': , 'disabled_by': None, 'domain': 'sensor', - 'entity_category': None, + 'entity_category': , 'entity_id': 'sensor.myups_daemon_version', 'has_entity_name': True, 'hidden_by': None, @@ -464,7 +464,7 @@ 'device_id': , 'disabled_by': None, 'domain': 'sensor', - 'entity_category': None, + 'entity_category': , 'entity_id': 'sensor.myups_date_and_time', 'has_entity_name': True, 'hidden_by': None, @@ -512,7 +512,7 @@ 'device_id': , 'disabled_by': None, 'domain': 'sensor', - 'entity_category': None, + 'entity_category': , 'entity_id': 'sensor.myups_driver', 'has_entity_name': True, 'hidden_by': None, @@ -560,7 +560,7 @@ 'device_id': , 'disabled_by': None, 'domain': 'sensor', - 'entity_category': None, + 'entity_category': , 'entity_id': 'sensor.myups_firmware_version', 'has_entity_name': True, 'hidden_by': None, @@ -768,7 +768,7 @@ 'device_id': , 'disabled_by': None, 'domain': 'sensor', - 'entity_category': None, + 'entity_category': , 'entity_id': 'sensor.myups_last_transfer', 'has_entity_name': True, 'hidden_by': None, @@ -916,7 +916,7 @@ 'device_id': , 'disabled_by': None, 'domain': 'sensor', - 'entity_category': None, + 'entity_category': , 'entity_id': 'sensor.myups_model', 'has_entity_name': True, 'hidden_by': None, @@ -964,7 +964,7 @@ 'device_id': , 'disabled_by': None, 'domain': 'sensor', - 'entity_category': None, + 'entity_category': , 'entity_id': 'sensor.myups_name', 'has_entity_name': True, 'hidden_by': None, @@ -1012,7 +1012,7 @@ 'device_id': , 'disabled_by': None, 'domain': 'sensor', - 'entity_category': None, + 'entity_category': , 'entity_id': 'sensor.myups_nominal_apparent_power', 'has_entity_name': True, 'hidden_by': None, @@ -1065,7 +1065,7 @@ 'device_id': , 'disabled_by': None, 'domain': 'sensor', - 'entity_category': None, + 'entity_category': , 'entity_id': 'sensor.myups_nominal_input_voltage', 'has_entity_name': True, 'hidden_by': None, @@ -1118,7 +1118,7 @@ 'device_id': , 'disabled_by': None, 'domain': 'sensor', - 'entity_category': None, + 'entity_category': , 'entity_id': 'sensor.myups_nominal_output_power', 'has_entity_name': True, 'hidden_by': None, @@ -1227,7 +1227,7 @@ 'device_id': , 'disabled_by': None, 'domain': 'sensor', - 'entity_category': None, + 'entity_category': , 'entity_id': 'sensor.myups_self_test_interval', 'has_entity_name': True, 'hidden_by': None, @@ -1324,7 +1324,7 @@ 'device_id': , 'disabled_by': None, 'domain': 'sensor', - 'entity_category': None, + 'entity_category': , 'entity_id': 'sensor.myups_sensitivity', 'has_entity_name': True, 'hidden_by': None, @@ -1372,7 +1372,7 @@ 'device_id': , 'disabled_by': None, 'domain': 'sensor', - 'entity_category': None, + 'entity_category': , 'entity_id': 'sensor.myups_serial_number', 'has_entity_name': True, 'hidden_by': None, @@ -1420,7 +1420,7 @@ 'device_id': , 'disabled_by': None, 'domain': 'sensor', - 'entity_category': None, + 'entity_category': , 'entity_id': 'sensor.myups_shutdown_time', 'has_entity_name': True, 'hidden_by': None, @@ -1517,7 +1517,7 @@ 'device_id': , 'disabled_by': None, 'domain': 'sensor', - 'entity_category': None, + 'entity_category': , 'entity_id': 'sensor.myups_status_data', 'has_entity_name': True, 'hidden_by': None, @@ -1565,7 +1565,7 @@ 'device_id': , 'disabled_by': None, 'domain': 'sensor', - 'entity_category': None, + 'entity_category': , 'entity_id': 'sensor.myups_status_date', 'has_entity_name': True, 'hidden_by': None, @@ -1613,7 +1613,7 @@ 'device_id': , 'disabled_by': None, 'domain': 'sensor', - 'entity_category': None, + 'entity_category': , 'entity_id': 'sensor.myups_status_flag', 'has_entity_name': True, 'hidden_by': None, @@ -1880,7 +1880,7 @@ 'device_id': , 'disabled_by': None, 'domain': 'sensor', - 'entity_category': None, + 'entity_category': , 'entity_id': 'sensor.myups_transfer_from_battery', 'has_entity_name': True, 'hidden_by': None, @@ -1928,7 +1928,7 @@ 'device_id': , 'disabled_by': None, 'domain': 'sensor', - 'entity_category': None, + 'entity_category': , 'entity_id': 'sensor.myups_transfer_high', 'has_entity_name': True, 'hidden_by': None, @@ -1981,7 +1981,7 @@ 'device_id': , 'disabled_by': None, 'domain': 'sensor', - 'entity_category': None, + 'entity_category': , 'entity_id': 'sensor.myups_transfer_low', 'has_entity_name': True, 'hidden_by': None, @@ -2034,7 +2034,7 @@ 'device_id': , 'disabled_by': None, 'domain': 'sensor', - 'entity_category': None, + 'entity_category': , 'entity_id': 'sensor.myups_transfer_to_battery', 'has_entity_name': True, 'hidden_by': None, diff --git a/tests/components/assist_pipeline/test_pipeline.py b/tests/components/assist_pipeline/test_pipeline.py index abdcb55054c..9ea3802d9f6 100644 --- a/tests/components/assist_pipeline/test_pipeline.py +++ b/tests/components/assist_pipeline/test_pipeline.py @@ -1779,11 +1779,11 @@ async def test_chat_log_tts_streaming( conversation_input, ) as chat_log, ): - await chat_log.async_update_llm_data( - conversing_domain="test", - user_input=conversation_input, + await chat_log.async_provide_llm_data( + conversation_input.as_llm_context("test"), user_llm_hass_api="assist", user_llm_prompt=None, + user_extra_system_prompt=conversation_input.extra_system_prompt, ) async for _content in chat_log.async_add_delta_content_stream( agent_id, stream_llm_response() diff --git a/tests/components/conversation/test_chat_log.py b/tests/components/conversation/test_chat_log.py index c9e72ae5a03..0e2a384f1da 100644 --- a/tests/components/conversation/test_chat_log.py +++ b/tests/components/conversation/test_chat_log.py @@ -106,9 +106,8 @@ async def test_llm_api( chat_session.async_get_chat_session(hass) as session, async_get_chat_log(hass, session, mock_conversation_input) as chat_log, ): - await chat_log.async_update_llm_data( - conversing_domain="test", - user_input=mock_conversation_input, + await chat_log.async_provide_llm_data( + mock_conversation_input.as_llm_context("test"), user_llm_hass_api="assist", user_llm_prompt=None, ) @@ -128,9 +127,8 @@ async def test_unknown_llm_api( async_get_chat_log(hass, session, mock_conversation_input) as chat_log, pytest.raises(ConverseError) as exc_info, ): - await chat_log.async_update_llm_data( - conversing_domain="test", - user_input=mock_conversation_input, + await chat_log.async_provide_llm_data( + mock_conversation_input.as_llm_context("test"), user_llm_hass_api="unknown-api", user_llm_prompt=None, ) @@ -170,9 +168,8 @@ async def test_multiple_llm_apis( chat_session.async_get_chat_session(hass) as session, async_get_chat_log(hass, session, mock_conversation_input) as chat_log, ): - await chat_log.async_update_llm_data( - conversing_domain="test", - user_input=mock_conversation_input, + await chat_log.async_provide_llm_data( + mock_conversation_input.as_llm_context("test"), user_llm_hass_api=["assist", "my-api"], user_llm_prompt=None, ) @@ -192,9 +189,8 @@ async def test_template_error( async_get_chat_log(hass, session, mock_conversation_input) as chat_log, pytest.raises(ConverseError) as exc_info, ): - await chat_log.async_update_llm_data( - conversing_domain="test", - user_input=mock_conversation_input, + await chat_log.async_provide_llm_data( + mock_conversation_input.as_llm_context("test"), user_llm_hass_api=None, user_llm_prompt="{{ invalid_syntax", ) @@ -217,9 +213,8 @@ async def test_template_variables( async_get_chat_log(hass, session, mock_conversation_input) as chat_log, patch("homeassistant.auth.AuthManager.async_get_user", return_value=mock_user), ): - await chat_log.async_update_llm_data( - conversing_domain="test", - user_input=mock_conversation_input, + await chat_log.async_provide_llm_data( + mock_conversation_input.as_llm_context("test"), user_llm_hass_api=None, user_llm_prompt=( "The instance name is {{ ha_name }}. " @@ -249,11 +244,11 @@ async def test_extra_systen_prompt( chat_session.async_get_chat_session(hass) as session, async_get_chat_log(hass, session, mock_conversation_input) as chat_log, ): - await chat_log.async_update_llm_data( - conversing_domain="test", - user_input=mock_conversation_input, + await chat_log.async_provide_llm_data( + mock_conversation_input.as_llm_context("test"), user_llm_hass_api=None, user_llm_prompt=None, + user_extra_system_prompt=mock_conversation_input.extra_system_prompt, ) chat_log.async_add_assistant_content_without_tools( AssistantContent( @@ -273,11 +268,11 @@ async def test_extra_systen_prompt( chat_session.async_get_chat_session(hass, conversation_id) as session, async_get_chat_log(hass, session, mock_conversation_input) as chat_log, ): - await chat_log.async_update_llm_data( - conversing_domain="test", - user_input=mock_conversation_input, + await chat_log.async_provide_llm_data( + mock_conversation_input.as_llm_context("test"), user_llm_hass_api=None, user_llm_prompt=None, + user_extra_system_prompt=mock_conversation_input.extra_system_prompt, ) assert chat_log.extra_system_prompt == extra_system_prompt @@ -290,11 +285,11 @@ async def test_extra_systen_prompt( chat_session.async_get_chat_session(hass, conversation_id) as session, async_get_chat_log(hass, session, mock_conversation_input) as chat_log, ): - await chat_log.async_update_llm_data( - conversing_domain="test", - user_input=mock_conversation_input, + await chat_log.async_provide_llm_data( + mock_conversation_input.as_llm_context("test"), user_llm_hass_api=None, user_llm_prompt=None, + user_extra_system_prompt=mock_conversation_input.extra_system_prompt, ) chat_log.async_add_assistant_content_without_tools( AssistantContent( @@ -314,11 +309,11 @@ async def test_extra_systen_prompt( chat_session.async_get_chat_session(hass, conversation_id) as session, async_get_chat_log(hass, session, mock_conversation_input) as chat_log, ): - await chat_log.async_update_llm_data( - conversing_domain="test", - user_input=mock_conversation_input, + await chat_log.async_provide_llm_data( + mock_conversation_input.as_llm_context("test"), user_llm_hass_api=None, user_llm_prompt=None, + user_extra_system_prompt=mock_conversation_input.extra_system_prompt, ) assert chat_log.extra_system_prompt == extra_system_prompt2 @@ -357,9 +352,8 @@ async def test_tool_call( chat_session.async_get_chat_session(hass) as session, async_get_chat_log(hass, session, mock_conversation_input) as chat_log, ): - await chat_log.async_update_llm_data( - conversing_domain="test", - user_input=mock_conversation_input, + await chat_log.async_provide_llm_data( + mock_conversation_input.as_llm_context("test"), user_llm_hass_api="assist", user_llm_prompt=None, ) @@ -434,9 +428,8 @@ async def test_tool_call_exception( async_get_chat_log(hass, session, mock_conversation_input) as chat_log, ): mock_get_tools.return_value = [mock_tool] - await chat_log.async_update_llm_data( - conversing_domain="test", - user_input=mock_conversation_input, + await chat_log.async_provide_llm_data( + mock_conversation_input.as_llm_context("test"), user_llm_hass_api="assist", user_llm_prompt=None, ) @@ -595,9 +588,8 @@ async def test_add_delta_content_stream( ) as chat_log, ): mock_get_tools.return_value = [mock_tool] - await chat_log.async_update_llm_data( - conversing_domain="test", - user_input=mock_conversation_input, + await chat_log.async_provide_llm_data( + mock_conversation_input.as_llm_context("test"), user_llm_hass_api="assist", user_llm_prompt=None, ) diff --git a/tests/components/cover/test_init.py b/tests/components/cover/test_init.py index f1997066638..e43b64b16a7 100644 --- a/tests/components/cover/test_init.py +++ b/tests/components/cover/test_init.py @@ -2,8 +2,6 @@ from enum import Enum -import pytest - from homeassistant.components import cover from homeassistant.components.cover import CoverState from homeassistant.const import ATTR_ENTITY_ID, CONF_PLATFORM, SERVICE_TOGGLE @@ -13,11 +11,7 @@ from homeassistant.setup import async_setup_component from .common import MockCover -from tests.common import ( - MockEntityPlatform, - help_test_all, - setup_test_component_platform, -) +from tests.common import help_test_all, setup_test_component_platform async def test_services( @@ -159,24 +153,3 @@ def _create_tuples(enum: type[Enum], constant_prefix: str) -> list[tuple[Enum, s def test_all() -> None: """Test module.__all__ is correctly set.""" help_test_all(cover) - - -def test_deprecated_supported_features_ints( - hass: HomeAssistant, caplog: pytest.LogCaptureFixture -) -> None: - """Test deprecated supported features ints.""" - - class MockCoverEntity(cover.CoverEntity): - _attr_supported_features = 1 - - entity = MockCoverEntity() - entity.hass = hass - entity.platform = MockEntityPlatform(hass) - assert entity.supported_features is cover.CoverEntityFeature(1) - assert "MockCoverEntity" in caplog.text - assert "is using deprecated supported features values" in caplog.text - assert "Instead it should use" in caplog.text - assert "CoverEntityFeature.OPEN" in caplog.text - caplog.clear() - assert entity.supported_features is cover.CoverEntityFeature(1) - assert "is using deprecated supported features values" not in caplog.text diff --git a/tests/components/devolo_home_control/test_binary_sensor.py b/tests/components/devolo_home_control/test_binary_sensor.py index fd28ce2fdf6..b2a58ef5038 100644 --- a/tests/components/devolo_home_control/test_binary_sensor.py +++ b/tests/components/devolo_home_control/test_binary_sensor.py @@ -2,7 +2,6 @@ from unittest.mock import patch -import pytest from syrupy.assertion import SnapshotAssertion from homeassistant.components.binary_sensor import DOMAIN as BINARY_SENSOR_DOMAIN @@ -19,7 +18,6 @@ from .mocks import ( ) -@pytest.mark.usefixtures("mock_zeroconf") async def test_binary_sensor( hass: HomeAssistant, entity_registry: er.EntityRegistry, snapshot: SnapshotAssertion ) -> None: @@ -58,7 +56,6 @@ async def test_binary_sensor( ) -@pytest.mark.usefixtures("mock_zeroconf") async def test_remote_control( hass: HomeAssistant, entity_registry: er.EntityRegistry, snapshot: SnapshotAssertion ) -> None: @@ -99,7 +96,6 @@ async def test_remote_control( ) -@pytest.mark.usefixtures("mock_zeroconf") async def test_disabled(hass: HomeAssistant) -> None: """Test setup of a disabled device.""" entry = configure_integration(hass) @@ -113,7 +109,6 @@ async def test_disabled(hass: HomeAssistant) -> None: assert hass.states.get(f"{BINARY_SENSOR_DOMAIN}.test_door") is None -@pytest.mark.usefixtures("mock_zeroconf") async def test_remove_from_hass(hass: HomeAssistant) -> None: """Test removing entity.""" entry = configure_integration(hass) diff --git a/tests/components/devolo_home_control/test_config_flow.py b/tests/components/devolo_home_control/test_config_flow.py index aab3e69b38f..9367d746d2e 100644 --- a/tests/components/devolo_home_control/test_config_flow.py +++ b/tests/components/devolo_home_control/test_config_flow.py @@ -66,44 +66,6 @@ async def test_form_already_configured(hass: HomeAssistant) -> None: assert result["reason"] == "already_configured" -async def test_form_advanced_options(hass: HomeAssistant) -> None: - """Test if we get the advanced options if user has enabled it.""" - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": config_entries.SOURCE_USER, "show_advanced_options": True}, - ) - assert result["type"] is FlowResultType.FORM - assert result["errors"] == {} - - with ( - patch( - "homeassistant.components.devolo_home_control.async_setup_entry", - return_value=True, - ) as mock_setup_entry, - patch( - "homeassistant.components.devolo_home_control.Mydevolo.uuid", - return_value="123456", - ), - ): - result2 = await hass.config_entries.flow.async_configure( - result["flow_id"], - { - "username": "test-username", - "password": "test-password", - }, - ) - await hass.async_block_till_done() - - assert result2["type"] is FlowResultType.CREATE_ENTRY - assert result2["title"] == "devolo Home Control" - assert result2["data"] == { - "username": "test-username", - "password": "test-password", - } - - assert len(mock_setup_entry.mock_calls) == 1 - - async def test_form_zeroconf(hass: HomeAssistant) -> None: """Test that the zeroconf confirmation form is served.""" result = await hass.config_entries.flow.async_init( diff --git a/tests/components/devolo_home_control/test_diagnostics.py b/tests/components/devolo_home_control/test_diagnostics.py index dfadc4d1c4b..558ed6394fa 100644 --- a/tests/components/devolo_home_control/test_diagnostics.py +++ b/tests/components/devolo_home_control/test_diagnostics.py @@ -1,7 +1,5 @@ """Tests for the devolo Home Control diagnostics.""" -from __future__ import annotations - from unittest.mock import patch from syrupy.assertion import SnapshotAssertion diff --git a/tests/components/devolo_home_control/test_init.py b/tests/components/devolo_home_control/test_init.py index da007303688..fb97447264d 100644 --- a/tests/components/devolo_home_control/test_init.py +++ b/tests/components/devolo_home_control/test_init.py @@ -19,7 +19,6 @@ from .mocks import HomeControlMock, HomeControlMockBinarySensor from tests.typing import WebSocketGenerator -@pytest.mark.usefixtures("mock_zeroconf") async def test_setup_entry(hass: HomeAssistant) -> None: """Test setup entry.""" entry = configure_integration(hass) @@ -44,7 +43,6 @@ async def test_setup_entry_maintenance(hass: HomeAssistant) -> None: assert entry.state is ConfigEntryState.SETUP_RETRY -@pytest.mark.usefixtures("mock_zeroconf") async def test_setup_gateway_offline(hass: HomeAssistant) -> None: """Test setup entry fails on gateway offline.""" entry = configure_integration(hass) diff --git a/tests/components/devolo_home_control/test_siren.py b/tests/components/devolo_home_control/test_siren.py index 71f4dfdd34d..7c943e05cef 100644 --- a/tests/components/devolo_home_control/test_siren.py +++ b/tests/components/devolo_home_control/test_siren.py @@ -2,7 +2,6 @@ from unittest.mock import patch -import pytest from syrupy.assertion import SnapshotAssertion from homeassistant.components.siren import DOMAIN as SIREN_DOMAIN @@ -14,7 +13,6 @@ from . import configure_integration from .mocks import HomeControlMock, HomeControlMockSiren -@pytest.mark.usefixtures("mock_zeroconf") async def test_siren( hass: HomeAssistant, entity_registry: er.EntityRegistry, snapshot: SnapshotAssertion ) -> None: @@ -45,7 +43,6 @@ async def test_siren( assert hass.states.get(f"{SIREN_DOMAIN}.test").state == STATE_UNAVAILABLE -@pytest.mark.usefixtures("mock_zeroconf") async def test_siren_switching( hass: HomeAssistant, entity_registry: er.EntityRegistry, snapshot: SnapshotAssertion ) -> None: @@ -98,7 +95,6 @@ async def test_siren_switching( property_set.assert_called_once_with(0) -@pytest.mark.usefixtures("mock_zeroconf") async def test_siren_change_default_tone( hass: HomeAssistant, entity_registry: er.EntityRegistry, snapshot: SnapshotAssertion ) -> None: @@ -130,7 +126,6 @@ async def test_siren_change_default_tone( property_set.assert_called_once_with(2) -@pytest.mark.usefixtures("mock_zeroconf") async def test_remove_from_hass(hass: HomeAssistant) -> None: """Test removing entity.""" entry = configure_integration(hass) diff --git a/tests/components/devolo_home_network/snapshots/test_device_tracker.ambr b/tests/components/devolo_home_network/snapshots/test_device_tracker.ambr index 950aff87752..9011439c42b 100644 --- a/tests/components/devolo_home_network/snapshots/test_device_tracker.ambr +++ b/tests/components/devolo_home_network/snapshots/test_device_tracker.ambr @@ -3,12 +3,13 @@ StateSnapshot({ 'attributes': ReadOnlyDict({ 'band': '5 GHz', + 'friendly_name': 'AA:BB:CC:DD:EE:FF', 'mac': 'AA:BB:CC:DD:EE:FF', 'source_type': , 'wifi': 'Main', }), 'context': , - 'entity_id': 'device_tracker.devolo_home_network_1234567890_aa_bb_cc_dd_ee_ff', + 'entity_id': 'device_tracker.aa_bb_cc_dd_ee_ff', 'last_changed': , 'last_reported': , 'last_updated': , diff --git a/tests/components/devolo_home_network/test_device_tracker.py b/tests/components/devolo_home_network/test_device_tracker.py index 2af6a1e3759..cb92b8bc3d9 100644 --- a/tests/components/devolo_home_network/test_device_tracker.py +++ b/tests/components/devolo_home_network/test_device_tracker.py @@ -17,13 +17,12 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er from . import configure_integration -from .const import CONNECTED_STATIONS, DISCOVERY_INFO, NO_CONNECTED_STATIONS +from .const import CONNECTED_STATIONS, NO_CONNECTED_STATIONS from .mock import MockDevice from tests.common import async_fire_time_changed STATION = CONNECTED_STATIONS[0] -SERIAL = DISCOVERY_INFO.properties["SN"] @pytest.mark.usefixtures("entity_registry_enabled_by_default") @@ -35,9 +34,7 @@ async def test_device_tracker( snapshot: SnapshotAssertion, ) -> None: """Test device tracker states.""" - state_key = ( - f"{PLATFORM}.{DOMAIN}_{SERIAL}_{STATION.mac_address.lower().replace(':', '_')}" - ) + state_key = f"{PLATFORM}.{STATION.mac_address.lower().replace(':', '_')}" entry = configure_integration(hass) await hass.config_entries.async_setup(entry.entry_id) await hass.async_block_till_done() @@ -77,14 +74,12 @@ async def test_restoring_clients( entity_registry: er.EntityRegistry, ) -> None: """Test restoring existing device_tracker entities.""" - state_key = ( - f"{PLATFORM}.{DOMAIN}_{SERIAL}_{STATION.mac_address.lower().replace(':', '_')}" - ) + state_key = f"{PLATFORM}.{STATION.mac_address.lower().replace(':', '_')}" entry = configure_integration(hass) entity_registry.async_get_or_create( PLATFORM, DOMAIN, - f"{SERIAL}_{STATION.mac_address}", + f"{STATION.mac_address}", config_entry=entry, ) diff --git a/tests/components/goalzero/test_sensor.py b/tests/components/goalzero/test_sensor.py index 6421f0c526c..0ac829d07b5 100644 --- a/tests/components/goalzero/test_sensor.py +++ b/tests/components/goalzero/test_sensor.py @@ -97,7 +97,7 @@ async def test_sensors( assert state.attributes.get(ATTR_STATE_CLASS) is None state = hass.states.get(f"sensor.{DEFAULT_NAME}_total_run_time") assert state.state == "1720984" - assert state.attributes.get(ATTR_DEVICE_CLASS) is None + assert state.attributes.get(ATTR_DEVICE_CLASS) == SensorDeviceClass.DURATION assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == UnitOfTime.SECONDS assert state.attributes.get(ATTR_STATE_CLASS) is None state = hass.states.get(f"sensor.{DEFAULT_NAME}_wi_fi_ssid") diff --git a/tests/components/google_generative_ai_conversation/conftest.py b/tests/components/google_generative_ai_conversation/conftest.py index 6ec147da2ab..f499f18bc15 100644 --- a/tests/components/google_generative_ai_conversation/conftest.py +++ b/tests/components/google_generative_ai_conversation/conftest.py @@ -1,10 +1,11 @@ """Tests helpers.""" -from unittest.mock import Mock, patch +from collections.abc import Generator +from unittest.mock import AsyncMock, Mock, patch import pytest -from homeassistant.components.google_generative_ai_conversation.conversation import ( +from homeassistant.components.google_generative_ai_conversation.entity import ( CONF_USE_GOOGLE_SEARCH_TOOL, ) from homeassistant.config_entries import ConfigEntry @@ -77,3 +78,22 @@ async def mock_init_component( async def setup_ha(hass: HomeAssistant) -> None: """Set up Home Assistant.""" assert await async_setup_component(hass, "homeassistant", {}) + + +@pytest.fixture +def mock_send_message_stream() -> Generator[AsyncMock]: + """Mock stream response.""" + + async def mock_generator(stream): + for value in stream: + yield value + + with patch( + "google.genai.chats.AsyncChat.send_message_stream", + AsyncMock(), + ) as mock_send_message_stream: + mock_send_message_stream.side_effect = lambda **kwargs: mock_generator( + mock_send_message_stream.return_value.pop(0) + ) + + yield mock_send_message_stream diff --git a/tests/components/google_generative_ai_conversation/test_conversation.py b/tests/components/google_generative_ai_conversation/test_conversation.py index 2d1a46393fd..92aa6f08d42 100644 --- a/tests/components/google_generative_ai_conversation/test_conversation.py +++ b/tests/components/google_generative_ai_conversation/test_conversation.py @@ -1,6 +1,5 @@ """Tests for the Google Generative AI Conversation integration conversation platform.""" -from collections.abc import Generator from unittest.mock import AsyncMock, patch from freezegun import freeze_time @@ -9,7 +8,7 @@ import pytest from homeassistant.components import conversation from homeassistant.components.conversation import UserContent -from homeassistant.components.google_generative_ai_conversation.conversation import ( +from homeassistant.components.google_generative_ai_conversation.entity import ( ERROR_GETTING_RESPONSE, _escape_decode, _format_schema, @@ -41,25 +40,6 @@ def mock_ulid_tools(): yield -@pytest.fixture -def mock_send_message_stream() -> Generator[AsyncMock]: - """Mock stream response.""" - - async def mock_generator(stream): - for value in stream: - yield value - - with patch( - "google.genai.chats.AsyncChat.send_message_stream", - AsyncMock(), - ) as mock_send_message_stream: - mock_send_message_stream.side_effect = lambda **kwargs: mock_generator( - mock_send_message_stream.return_value.pop(0) - ) - - yield mock_send_message_stream - - @pytest.mark.parametrize( ("error"), [ diff --git a/tests/components/google_generative_ai_conversation/test_tts.py b/tests/components/google_generative_ai_conversation/test_tts.py new file mode 100644 index 00000000000..5ea056307b5 --- /dev/null +++ b/tests/components/google_generative_ai_conversation/test_tts.py @@ -0,0 +1,413 @@ +"""Tests for the Google Generative AI Conversation TTS entity.""" + +from __future__ import annotations + +from collections.abc import Generator +from http import HTTPStatus +from pathlib import Path +from typing import Any +from unittest.mock import AsyncMock, MagicMock, Mock, patch + +from google.genai import types +import pytest + +from homeassistant.components import tts +from homeassistant.components.google_generative_ai_conversation.tts import ( + ATTR_MODEL, + DOMAIN, + RECOMMENDED_TTS_MODEL, +) +from homeassistant.components.media_player import ( + ATTR_MEDIA_CONTENT_ID, + DOMAIN as DOMAIN_MP, + SERVICE_PLAY_MEDIA, +) +from homeassistant.const import ATTR_ENTITY_ID, CONF_API_KEY, CONF_PLATFORM +from homeassistant.core import HomeAssistant, ServiceCall +from homeassistant.core_config import async_process_ha_core_config +from homeassistant.setup import async_setup_component + +from . import API_ERROR_500 + +from tests.common import MockConfigEntry, async_mock_service +from tests.components.tts.common import retrieve_media +from tests.typing import ClientSessionGenerator + + +@pytest.fixture(autouse=True) +def tts_mutagen_mock_fixture_autouse(tts_mutagen_mock: MagicMock) -> None: + """Mock writing tags.""" + + +@pytest.fixture(autouse=True) +def mock_tts_cache_dir_autouse(mock_tts_cache_dir: Path) -> None: + """Mock the TTS cache dir with empty dir.""" + + +@pytest.fixture +async def calls(hass: HomeAssistant) -> list[ServiceCall]: + """Mock media player calls.""" + return async_mock_service(hass, DOMAIN_MP, SERVICE_PLAY_MEDIA) + + +@pytest.fixture(autouse=True) +async def setup_internal_url(hass: HomeAssistant) -> None: + """Set up internal url.""" + await async_process_ha_core_config( + hass, {"internal_url": "http://example.local:8123"} + ) + + +@pytest.fixture +def mock_genai_client() -> Generator[AsyncMock]: + """Mock genai_client.""" + client = Mock() + client.aio.models.get = AsyncMock() + client.models.generate_content.return_value = types.GenerateContentResponse( + candidates=( + types.Candidate( + content=types.Content( + parts=( + types.Part( + inline_data=types.Blob( + data=b"raw-audio-bytes", + mime_type="audio/L16;rate=24000", + ) + ), + ) + ) + ), + ) + ) + with patch( + "homeassistant.components.google_generative_ai_conversation.Client", + return_value=client, + ) as mock_client: + yield mock_client + + +@pytest.fixture(name="setup") +async def setup_fixture( + hass: HomeAssistant, + config: dict[str, Any], + request: pytest.FixtureRequest, + mock_genai_client: AsyncMock, +) -> None: + """Set up the test environment.""" + if request.param == "mock_setup": + await mock_setup(hass, config) + if request.param == "mock_config_entry_setup": + await mock_config_entry_setup(hass, config) + else: + raise RuntimeError("Invalid setup fixture") + + await hass.async_block_till_done() + + +@pytest.fixture(name="config") +def config_fixture() -> dict[str, Any]: + """Return config.""" + return { + CONF_API_KEY: "bla", + } + + +async def mock_setup(hass: HomeAssistant, config: dict[str, Any]) -> None: + """Mock setup.""" + assert await async_setup_component( + hass, tts.DOMAIN, {tts.DOMAIN: {CONF_PLATFORM: DOMAIN} | config} + ) + + +async def mock_config_entry_setup(hass: HomeAssistant, config: dict[str, Any]) -> None: + """Mock config entry setup.""" + default_config = {tts.CONF_LANG: "en-US"} + config_entry = MockConfigEntry(domain=DOMAIN, data=default_config | config) + + client_mock = Mock() + client_mock.models.get = None + client_mock.models.generate_content.return_value = types.GenerateContentResponse( + candidates=( + types.Candidate( + content=types.Content( + parts=( + types.Part( + inline_data=types.Blob( + data=b"raw-audio-bytes", + mime_type="audio/L16;rate=24000", + ) + ), + ) + ) + ), + ) + ) + config_entry.runtime_data = client_mock + config_entry.add_to_hass(hass) + + assert await hass.config_entries.async_setup(config_entry.entry_id) + + +@pytest.mark.parametrize( + ("setup", "tts_service", "service_data"), + [ + ( + "mock_config_entry_setup", + "speak", + { + ATTR_ENTITY_ID: "tts.google_generative_ai_tts", + tts.ATTR_MEDIA_PLAYER_ENTITY_ID: "media_player.something", + tts.ATTR_MESSAGE: "There is a person at the front door.", + tts.ATTR_OPTIONS: {}, + }, + ), + ( + "mock_config_entry_setup", + "speak", + { + ATTR_ENTITY_ID: "tts.google_generative_ai_tts", + tts.ATTR_MEDIA_PLAYER_ENTITY_ID: "media_player.something", + tts.ATTR_MESSAGE: "There is a person at the front door.", + tts.ATTR_OPTIONS: {tts.ATTR_VOICE: "voice2"}, + }, + ), + ( + "mock_config_entry_setup", + "speak", + { + ATTR_ENTITY_ID: "tts.google_generative_ai_tts", + tts.ATTR_MEDIA_PLAYER_ENTITY_ID: "media_player.something", + tts.ATTR_MESSAGE: "There is a person at the front door.", + tts.ATTR_OPTIONS: {ATTR_MODEL: "model2"}, + }, + ), + ( + "mock_config_entry_setup", + "speak", + { + ATTR_ENTITY_ID: "tts.google_generative_ai_tts", + tts.ATTR_MEDIA_PLAYER_ENTITY_ID: "media_player.something", + tts.ATTR_MESSAGE: "There is a person at the front door.", + tts.ATTR_OPTIONS: {tts.ATTR_VOICE: "voice2", ATTR_MODEL: "model2"}, + }, + ), + ], + indirect=["setup"], +) +async def test_tts_service_speak( + setup: AsyncMock, + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + calls: list[ServiceCall], + tts_service: str, + service_data: dict[str, Any], +) -> None: + """Test tts service.""" + tts_entity = hass.data[tts.DOMAIN].get_entity(service_data[ATTR_ENTITY_ID]) + tts_entity._genai_client.models.generate_content.reset_mock() + + await hass.services.async_call( + tts.DOMAIN, + tts_service, + service_data, + blocking=True, + ) + + assert len(calls) == 1 + assert ( + await retrieve_media(hass, hass_client, calls[0].data[ATTR_MEDIA_CONTENT_ID]) + == HTTPStatus.OK + ) + voice_id = service_data[tts.ATTR_OPTIONS].get(tts.ATTR_VOICE, "zephyr") + model_id = service_data[tts.ATTR_OPTIONS].get(ATTR_MODEL, RECOMMENDED_TTS_MODEL) + + tts_entity._genai_client.models.generate_content.assert_called_once_with( + model=model_id, + contents="There is a person at the front door.", + config=types.GenerateContentConfig( + response_modalities=["AUDIO"], + speech_config=types.SpeechConfig( + voice_config=types.VoiceConfig( + prebuilt_voice_config=types.PrebuiltVoiceConfig(voice_name=voice_id) + ) + ), + ), + ) + + +@pytest.mark.parametrize( + ("setup", "tts_service", "service_data"), + [ + ( + "mock_config_entry_setup", + "speak", + { + ATTR_ENTITY_ID: "tts.google_generative_ai_tts", + tts.ATTR_MEDIA_PLAYER_ENTITY_ID: "media_player.something", + tts.ATTR_MESSAGE: "There is a person at the front door.", + tts.ATTR_LANGUAGE: "de-DE", + tts.ATTR_OPTIONS: {tts.ATTR_VOICE: "voice1"}, + }, + ), + ( + "mock_config_entry_setup", + "speak", + { + ATTR_ENTITY_ID: "tts.google_generative_ai_tts", + tts.ATTR_MEDIA_PLAYER_ENTITY_ID: "media_player.something", + tts.ATTR_MESSAGE: "There is a person at the front door.", + tts.ATTR_LANGUAGE: "it-IT", + tts.ATTR_OPTIONS: {tts.ATTR_VOICE: "voice1"}, + }, + ), + ], + indirect=["setup"], +) +async def test_tts_service_speak_lang_config( + setup: AsyncMock, + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + calls: list[ServiceCall], + tts_service: str, + service_data: dict[str, Any], +) -> None: + """Test service call with languages in the config.""" + tts_entity = hass.data[tts.DOMAIN].get_entity(service_data[ATTR_ENTITY_ID]) + tts_entity._genai_client.models.generate_content.reset_mock() + + await hass.services.async_call( + tts.DOMAIN, + tts_service, + service_data, + blocking=True, + ) + + assert len(calls) == 1 + assert ( + await retrieve_media(hass, hass_client, calls[0].data[ATTR_MEDIA_CONTENT_ID]) + == HTTPStatus.OK + ) + + tts_entity._genai_client.models.generate_content.assert_called_once_with( + model=RECOMMENDED_TTS_MODEL, + contents="There is a person at the front door.", + config=types.GenerateContentConfig( + response_modalities=["AUDIO"], + speech_config=types.SpeechConfig( + voice_config=types.VoiceConfig( + prebuilt_voice_config=types.PrebuiltVoiceConfig(voice_name="voice1") + ) + ), + ), + ) + + +@pytest.mark.parametrize( + ("setup", "tts_service", "service_data"), + [ + ( + "mock_config_entry_setup", + "speak", + { + ATTR_ENTITY_ID: "tts.google_generative_ai_tts", + tts.ATTR_MEDIA_PLAYER_ENTITY_ID: "media_player.something", + tts.ATTR_MESSAGE: "There is a person at the front door.", + tts.ATTR_OPTIONS: {tts.ATTR_VOICE: "voice1"}, + }, + ), + ], + indirect=["setup"], +) +async def test_tts_service_speak_error( + setup: AsyncMock, + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + calls: list[ServiceCall], + tts_service: str, + service_data: dict[str, Any], +) -> None: + """Test service call with HTTP response 500.""" + tts_entity = hass.data[tts.DOMAIN].get_entity(service_data[ATTR_ENTITY_ID]) + tts_entity._genai_client.models.generate_content.reset_mock() + tts_entity._genai_client.models.generate_content.side_effect = API_ERROR_500 + + await hass.services.async_call( + tts.DOMAIN, + tts_service, + service_data, + blocking=True, + ) + + assert len(calls) == 1 + assert ( + await retrieve_media(hass, hass_client, calls[0].data[ATTR_MEDIA_CONTENT_ID]) + == HTTPStatus.INTERNAL_SERVER_ERROR + ) + + tts_entity._genai_client.models.generate_content.assert_called_once_with( + model=RECOMMENDED_TTS_MODEL, + contents="There is a person at the front door.", + config=types.GenerateContentConfig( + response_modalities=["AUDIO"], + speech_config=types.SpeechConfig( + voice_config=types.VoiceConfig( + prebuilt_voice_config=types.PrebuiltVoiceConfig(voice_name="voice1") + ) + ), + ), + ) + + +@pytest.mark.parametrize( + ("setup", "tts_service", "service_data"), + [ + ( + "mock_config_entry_setup", + "speak", + { + ATTR_ENTITY_ID: "tts.google_generative_ai_tts", + tts.ATTR_MEDIA_PLAYER_ENTITY_ID: "media_player.something", + tts.ATTR_MESSAGE: "There is a person at the front door.", + tts.ATTR_OPTIONS: {}, + }, + ), + ], + indirect=["setup"], +) +async def test_tts_service_speak_without_options( + setup: AsyncMock, + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + calls: list[ServiceCall], + tts_service: str, + service_data: dict[str, Any], +) -> None: + """Test service call with HTTP response 200.""" + tts_entity = hass.data[tts.DOMAIN].get_entity(service_data[ATTR_ENTITY_ID]) + tts_entity._genai_client.models.generate_content.reset_mock() + + await hass.services.async_call( + tts.DOMAIN, + tts_service, + service_data, + blocking=True, + ) + + assert len(calls) == 1 + assert ( + await retrieve_media(hass, hass_client, calls[0].data[ATTR_MEDIA_CONTENT_ID]) + == HTTPStatus.OK + ) + + tts_entity._genai_client.models.generate_content.assert_called_once_with( + model=RECOMMENDED_TTS_MODEL, + contents="There is a person at the front door.", + config=types.GenerateContentConfig( + response_modalities=["AUDIO"], + speech_config=types.SpeechConfig( + voice_config=types.VoiceConfig( + prebuilt_voice_config=types.PrebuiltVoiceConfig(voice_name="zephyr") + ) + ), + ), + ) diff --git a/tests/components/here_travel_time/test_sensor.py b/tests/components/here_travel_time/test_sensor.py index 0231ac6428f..22042f863bc 100644 --- a/tests/components/here_travel_time/test_sensor.py +++ b/tests/components/here_travel_time/test_sensor.py @@ -150,10 +150,10 @@ async def test_sensor( duration = hass.states.get("sensor.test_duration") assert duration.attributes.get("unit_of_measurement") == UnitOfTime.MINUTES assert duration.attributes.get(ATTR_ICON) == icon - assert duration.state == "26" + assert duration.state == "26.1833333333333" assert float(hass.states.get("sensor.test_distance").state) == pytest.approx(13.682) - assert hass.states.get("sensor.test_duration_in_traffic").state == "30" + assert hass.states.get("sensor.test_duration_in_traffic").state == "29.6" assert hass.states.get("sensor.test_origin").state == "22nd St NW" assert ( hass.states.get("sensor.test_origin").attributes.get(ATTR_LATITUDE) @@ -501,13 +501,13 @@ async def test_restore_state(hass: HomeAssistant) -> None: "1234", attributes={ ATTR_LAST_RESET: last_reset, - ATTR_UNIT_OF_MEASUREMENT: UnitOfTime.MINUTES, + ATTR_UNIT_OF_MEASUREMENT: UnitOfTime.SECONDS, ATTR_STATE_CLASS: SensorStateClass.MEASUREMENT, }, ), { "native_value": 1234, - "native_unit_of_measurement": UnitOfTime.MINUTES, + "native_unit_of_measurement": UnitOfTime.SECONDS, "icon": "mdi:car", "last_reset": last_reset, }, @@ -518,13 +518,13 @@ async def test_restore_state(hass: HomeAssistant) -> None: "5678", attributes={ ATTR_LAST_RESET: last_reset, - ATTR_UNIT_OF_MEASUREMENT: UnitOfTime.MINUTES, + ATTR_UNIT_OF_MEASUREMENT: UnitOfTime.SECONDS, ATTR_STATE_CLASS: SensorStateClass.MEASUREMENT, }, ), { "native_value": 5678, - "native_unit_of_measurement": UnitOfTime.MINUTES, + "native_unit_of_measurement": UnitOfTime.SECONDS, "icon": "mdi:car", "last_reset": last_reset, }, @@ -596,12 +596,12 @@ async def test_restore_state(hass: HomeAssistant) -> None: # restore from cache state = hass.states.get("sensor.test_duration") - assert state.state == "1234" + assert state.state == "20.5666666666667" assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == UnitOfTime.MINUTES assert state.attributes.get(ATTR_STATE_CLASS) == SensorStateClass.MEASUREMENT state = hass.states.get("sensor.test_duration_in_traffic") - assert state.state == "5678" + assert state.state == "94.6333333333333" assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == UnitOfTime.MINUTES assert state.attributes.get(ATTR_STATE_CLASS) == SensorStateClass.MEASUREMENT @@ -799,10 +799,12 @@ async def test_multiple_sections( await hass.async_block_till_done() duration = hass.states.get("sensor.test_duration") - assert duration.state == "18" + assert duration.state == "18.4833333333333" assert float(hass.states.get("sensor.test_distance").state) == pytest.approx(3.583) - assert hass.states.get("sensor.test_duration_in_traffic").state == "18" + assert ( + hass.states.get("sensor.test_duration_in_traffic").state == "18.4833333333333" + ) assert hass.states.get("sensor.test_origin").state == "Chemin de Halage" assert ( hass.states.get("sensor.test_origin").attributes.get(ATTR_LATITUDE) diff --git a/tests/components/homee/fixtures/events.json b/tests/components/homee/fixtures/events.json index 351d35ec497..dc541bca597 100644 --- a/tests/components/homee/fixtures/events.json +++ b/tests/components/homee/fixtures/events.json @@ -41,6 +41,48 @@ "options": { "observed_by": [145] } + }, + { + "id": 2, + "node_id": 1, + "instance": 1, + "minimum": 0, + "maximum": 3, + "current_value": 2.0, + "target_value": 2.0, + "last_value": 0.0, + "unit": "n/a", + "step_value": 1.0, + "editable": 0, + "type": 40, + "state": 1, + "last_changed": 1749885830, + "changed_by": 1, + "changed_by_id": 0, + "based_on": 1, + "data": "", + "name": "" + }, + { + "id": 3, + "node_id": 1, + "instance": 2, + "minimum": 0, + "maximum": 3, + "current_value": 2.0, + "target_value": 2.0, + "last_value": 2.0, + "unit": "n/a", + "step_value": 1.0, + "editable": 0, + "type": 40, + "state": 1, + "last_changed": 1749885830, + "changed_by": 1, + "changed_by_id": 0, + "based_on": 1, + "data": "", + "name": "" } ] } diff --git a/tests/components/homee/snapshots/test_event.ambr b/tests/components/homee/snapshots/test_event.ambr index b3f544bcc4e..40b9a99fcc4 100644 --- a/tests/components/homee/snapshots/test_event.ambr +++ b/tests/components/homee/snapshots/test_event.ambr @@ -1,4 +1,126 @@ # serializer version: 1 +# name: test_event_snapshot[event.remote_control_switch_1-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'event_types': list([ + 'upper', + 'lower', + 'released', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'event', + 'entity_category': None, + 'entity_id': 'event.remote_control_switch_1', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Switch 1', + 'platform': 'homee', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'button_state_instance', + 'unique_id': '00055511EECC-1-2', + 'unit_of_measurement': None, + }) +# --- +# name: test_event_snapshot[event.remote_control_switch_1-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'button', + 'event_type': None, + 'event_types': list([ + 'upper', + 'lower', + 'released', + ]), + 'friendly_name': 'Remote Control Switch 1', + }), + 'context': , + 'entity_id': 'event.remote_control_switch_1', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_event_snapshot[event.remote_control_switch_2-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'event_types': list([ + 'upper', + 'lower', + 'released', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'event', + 'entity_category': None, + 'entity_id': 'event.remote_control_switch_2', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Switch 2', + 'platform': 'homee', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'button_state_instance', + 'unique_id': '00055511EECC-1-3', + 'unit_of_measurement': None, + }) +# --- +# name: test_event_snapshot[event.remote_control_switch_2-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'button', + 'event_type': None, + 'event_types': list([ + 'upper', + 'lower', + 'released', + ]), + 'friendly_name': 'Remote Control Switch 2', + }), + 'context': , + 'entity_id': 'event.remote_control_switch_2', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- # name: test_event_snapshot[event.remote_control_up_down_remote-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ diff --git a/tests/components/homee/test_event.py b/tests/components/homee/test_event.py index 0ffa7cd8530..176f1e9a053 100644 --- a/tests/components/homee/test_event.py +++ b/tests/components/homee/test_event.py @@ -2,6 +2,7 @@ from unittest.mock import MagicMock, patch +import pytest from syrupy.assertion import SnapshotAssertion from homeassistant.components.event import ATTR_EVENT_TYPE @@ -14,38 +15,54 @@ from . import build_mock_node, setup_integration from tests.common import MockConfigEntry, snapshot_platform -async def test_event_fires( +@pytest.mark.parametrize( + ("entity_id", "attribute_id", "expected_event_types"), + [ + ( + "event.remote_control_up_down_remote", + 1, + [ + "released", + "up", + "down", + "stop", + "up_long", + "down_long", + "stop_long", + "c_button", + "b_button", + "a_button", + ], + ), + ( + "event.remote_control_switch_2", + 3, + ["upper", "lower", "released"], + ), + ], +) +async def test_event_triggers( hass: HomeAssistant, mock_homee: MagicMock, mock_config_entry: MockConfigEntry, + entity_id: str, + attribute_id: int, + expected_event_types: list[str], ) -> None: """Test that the correct event fires when the attribute changes.""" - - EVENT_TYPES = [ - "released", - "up", - "down", - "stop", - "up_long", - "down_long", - "stop_long", - "c_button", - "b_button", - "a_button", - ] mock_homee.nodes = [build_mock_node("events.json")] mock_homee.get_node_by_id.return_value = mock_homee.nodes[0] await setup_integration(hass, mock_config_entry) # Simulate the event triggers. - attribute = mock_homee.nodes[0].attributes[0] - for i, event_type in enumerate(EVENT_TYPES): + attribute = mock_homee.nodes[0].attributes[attribute_id - 1] + for i, event_type in enumerate(expected_event_types): attribute.current_value = i attribute.add_on_changed_listener.call_args_list[1][0][0](attribute) await hass.async_block_till_done() # Check if the event was fired - state = hass.states.get("event.remote_control_up_down_remote") + state = hass.states.get(entity_id) assert state.attributes[ATTR_EVENT_TYPE] == event_type diff --git a/tests/components/homematicip_cloud/test_hap.py b/tests/components/homematicip_cloud/test_hap.py index a8aab422eb9..ae094f7dded 100644 --- a/tests/components/homematicip_cloud/test_hap.py +++ b/tests/components/homematicip_cloud/test_hap.py @@ -1,6 +1,6 @@ """Test HomematicIP Cloud accesspoint.""" -from unittest.mock import Mock, patch +from unittest.mock import AsyncMock, Mock, patch from homematicip.auth import Auth from homematicip.connection.connection_context import ConnectionContext @@ -16,6 +16,7 @@ from homeassistant.components.homematicip_cloud.const import ( ) from homeassistant.components.homematicip_cloud.errors import HmipcConnectionError from homeassistant.components.homematicip_cloud.hap import ( + AsyncHome, HomematicipAuth, HomematicipHAP, ) @@ -251,3 +252,21 @@ async def test_get_state_after_disconnect( assert hap._ws_connection_closed.is_set() await hap.ws_connected_handler() mock_get_state.assert_called_once() + + +async def test_async_connect( + hass: HomeAssistant, hmip_config_entry: MockConfigEntry, simple_mock_home +) -> None: + """Test async_connect.""" + hass.config.components.add(DOMAIN) + hap = HomematicipHAP(hass, hmip_config_entry) + assert hap + + simple_mock_home = AsyncMock(spec=AsyncHome, autospec=True) + + await hap.async_connect(simple_mock_home) + + simple_mock_home.set_on_connected_handler.assert_called_once() + simple_mock_home.set_on_disconnected_handler.assert_called_once() + simple_mock_home.set_on_reconnect_handler.assert_called_once() + simple_mock_home.enable_events.assert_called_once() diff --git a/tests/components/homematicip_cloud/test_switch.py b/tests/components/homematicip_cloud/test_switch.py index 1a728bfecd4..50d527775bd 100644 --- a/tests/components/homematicip_cloud/test_switch.py +++ b/tests/components/homematicip_cloud/test_switch.py @@ -25,14 +25,14 @@ async def test_hmip_switch( ) assert ha_state.state == STATE_ON - service_call_counter = len(hmip_device.mock_calls) + service_call_counter = len(hmip_device.functionalChannels[1].mock_calls) await hass.services.async_call( "switch", "turn_off", {"entity_id": entity_id}, blocking=True ) - assert len(hmip_device.mock_calls) == service_call_counter + 1 - assert hmip_device.mock_calls[-1][0] == "turn_off_async" - assert hmip_device.mock_calls[-1][1] == (1,) + assert len(hmip_device.functionalChannels[1].mock_calls) == service_call_counter + 1 + assert hmip_device.functionalChannels[1].mock_calls[-1][0] == "async_turn_off" + assert hmip_device.functionalChannels[1].mock_calls[-1][1] == () await async_manipulate_test_data(hass, hmip_device, "on", False) ha_state = hass.states.get(entity_id) assert ha_state.state == STATE_OFF @@ -40,9 +40,9 @@ async def test_hmip_switch( await hass.services.async_call( "switch", "turn_on", {"entity_id": entity_id}, blocking=True ) - assert len(hmip_device.mock_calls) == service_call_counter + 3 - assert hmip_device.mock_calls[-1][0] == "turn_on_async" - assert hmip_device.mock_calls[-1][1] == (1,) + assert len(hmip_device.functionalChannels[1].mock_calls) == service_call_counter + 2 + assert hmip_device.functionalChannels[1].mock_calls[-1][0] == "async_turn_on" + assert hmip_device.functionalChannels[1].mock_calls[-1][1] == () await async_manipulate_test_data(hass, hmip_device, "on", True) ha_state = hass.states.get(entity_id) assert ha_state.state == STATE_ON @@ -64,14 +64,14 @@ async def test_hmip_switch_input( ) assert ha_state.state == STATE_ON - service_call_counter = len(hmip_device.mock_calls) + service_call_counter = len(hmip_device.functionalChannels[1].mock_calls) await hass.services.async_call( "switch", "turn_off", {"entity_id": entity_id}, blocking=True ) - assert len(hmip_device.mock_calls) == service_call_counter + 1 - assert hmip_device.mock_calls[-1][0] == "turn_off_async" - assert hmip_device.mock_calls[-1][1] == (1,) + assert len(hmip_device.functionalChannels[1].mock_calls) == service_call_counter + 1 + assert hmip_device.functionalChannels[1].mock_calls[-1][0] == "async_turn_off" + assert hmip_device.functionalChannels[1].mock_calls[-1][1] == () await async_manipulate_test_data(hass, hmip_device, "on", False) ha_state = hass.states.get(entity_id) assert ha_state.state == STATE_OFF @@ -79,9 +79,9 @@ async def test_hmip_switch_input( await hass.services.async_call( "switch", "turn_on", {"entity_id": entity_id}, blocking=True ) - assert len(hmip_device.mock_calls) == service_call_counter + 3 - assert hmip_device.mock_calls[-1][0] == "turn_on_async" - assert hmip_device.mock_calls[-1][1] == (1,) + assert len(hmip_device.functionalChannels[1].mock_calls) == service_call_counter + 2 + assert hmip_device.functionalChannels[1].mock_calls[-1][0] == "async_turn_on" + assert hmip_device.functionalChannels[1].mock_calls[-1][1] == () await async_manipulate_test_data(hass, hmip_device, "on", True) ha_state = hass.states.get(entity_id) assert ha_state.state == STATE_ON @@ -103,14 +103,14 @@ async def test_hmip_switch_measuring( ) assert ha_state.state == STATE_ON - service_call_counter = len(hmip_device.mock_calls) + service_call_counter = len(hmip_device.functionalChannels[1].mock_calls) await hass.services.async_call( "switch", "turn_off", {"entity_id": entity_id}, blocking=True ) - assert len(hmip_device.mock_calls) == service_call_counter + 1 - assert hmip_device.mock_calls[-1][0] == "turn_off_async" - assert hmip_device.mock_calls[-1][1] == (1,) + assert len(hmip_device.functionalChannels[1].mock_calls) == service_call_counter + 1 + assert hmip_device.functionalChannels[1].mock_calls[-1][0] == "async_turn_off" + assert hmip_device.functionalChannels[1].mock_calls[-1][1] == () await async_manipulate_test_data(hass, hmip_device, "on", False) ha_state = hass.states.get(entity_id) assert ha_state.state == STATE_OFF @@ -118,9 +118,9 @@ async def test_hmip_switch_measuring( await hass.services.async_call( "switch", "turn_on", {"entity_id": entity_id}, blocking=True ) - assert len(hmip_device.mock_calls) == service_call_counter + 3 - assert hmip_device.mock_calls[-1][0] == "turn_on_async" - assert hmip_device.mock_calls[-1][1] == (1,) + assert len(hmip_device.functionalChannels[1].mock_calls) == service_call_counter + 2 + assert hmip_device.functionalChannels[1].mock_calls[-1][0] == "async_turn_on" + assert hmip_device.functionalChannels[1].mock_calls[-1][1] == () await async_manipulate_test_data(hass, hmip_device, "on", True) await async_manipulate_test_data(hass, hmip_device, "currentPowerConsumption", 50) ha_state = hass.states.get(entity_id) @@ -191,14 +191,14 @@ async def test_hmip_multi_switch( ) assert ha_state.state == STATE_OFF - service_call_counter = len(hmip_device.mock_calls) + service_call_counter = len(hmip_device.functionalChannels[1].mock_calls) await hass.services.async_call( "switch", "turn_on", {"entity_id": entity_id}, blocking=True ) - assert len(hmip_device.mock_calls) == service_call_counter + 1 - assert hmip_device.mock_calls[-1][0] == "turn_on_async" - assert hmip_device.mock_calls[-1][1] == (1,) + assert len(hmip_device.functionalChannels[1].mock_calls) == service_call_counter + 1 + assert hmip_device.functionalChannels[1].mock_calls[-1][0] == "async_turn_on" + assert hmip_device.functionalChannels[1].mock_calls[-1][1] == () await async_manipulate_test_data(hass, hmip_device, "on", True) ha_state = hass.states.get(entity_id) assert ha_state.state == STATE_ON @@ -206,9 +206,9 @@ async def test_hmip_multi_switch( await hass.services.async_call( "switch", "turn_off", {"entity_id": entity_id}, blocking=True ) - assert len(hmip_device.mock_calls) == service_call_counter + 3 - assert hmip_device.mock_calls[-1][0] == "turn_off_async" - assert hmip_device.mock_calls[-1][1] == (1,) + assert len(hmip_device.functionalChannels[1].mock_calls) == service_call_counter + 2 + assert hmip_device.functionalChannels[1].mock_calls[-1][0] == "async_turn_off" + assert hmip_device.functionalChannels[1].mock_calls[-1][1] == () await async_manipulate_test_data(hass, hmip_device, "on", False) ha_state = hass.states.get(entity_id) assert ha_state.state == STATE_OFF @@ -242,14 +242,14 @@ async def test_hmip_wired_multi_switch( ) assert ha_state.state == STATE_ON - service_call_counter = len(hmip_device.mock_calls) + service_call_counter = len(hmip_device.functionalChannels[1].mock_calls) await hass.services.async_call( "switch", "turn_off", {"entity_id": entity_id}, blocking=True ) - assert len(hmip_device.mock_calls) == service_call_counter + 1 - assert hmip_device.mock_calls[-1][0] == "turn_off_async" - assert hmip_device.mock_calls[-1][1] == (1,) + assert len(hmip_device.functionalChannels[1].mock_calls) == service_call_counter + 1 + assert hmip_device.functionalChannels[1].mock_calls[-1][0] == "async_turn_off" + assert hmip_device.functionalChannels[1].mock_calls[-1][1] == () await async_manipulate_test_data(hass, hmip_device, "on", False) ha_state = hass.states.get(entity_id) assert ha_state.state == STATE_OFF @@ -257,9 +257,9 @@ async def test_hmip_wired_multi_switch( await hass.services.async_call( "switch", "turn_on", {"entity_id": entity_id}, blocking=True ) - assert len(hmip_device.mock_calls) == service_call_counter + 3 - assert hmip_device.mock_calls[-1][0] == "turn_on_async" - assert hmip_device.mock_calls[-1][1] == (1,) + assert len(hmip_device.functionalChannels[1].mock_calls) == service_call_counter + 2 + assert hmip_device.functionalChannels[1].mock_calls[-1][0] == "async_turn_on" + assert hmip_device.functionalChannels[1].mock_calls[-1][1] == () await async_manipulate_test_data(hass, hmip_device, "on", True) ha_state = hass.states.get(entity_id) assert ha_state.state == STATE_ON diff --git a/tests/components/homewizard/test_init.py b/tests/components/homewizard/test_init.py index 9139ef80d12..be811355e1d 100644 --- a/tests/components/homewizard/test_init.py +++ b/tests/components/homewizard/test_init.py @@ -10,7 +10,6 @@ import pytest from homeassistant.components.homewizard.const import DOMAIN from homeassistant.config_entries import SOURCE_REAUTH, ConfigEntryState -from homeassistant.const import CONF_IP_ADDRESS, CONF_TOKEN from homeassistant.core import HomeAssistant from tests.common import MockConfigEntry, async_fire_time_changed @@ -58,36 +57,6 @@ async def test_load_unload_v2( assert mock_config_entry_v2.state is ConfigEntryState.NOT_LOADED -async def test_load_unload_v2_as_v1( - hass: HomeAssistant, - mock_homewizardenergy: MagicMock, -) -> None: - """Test loading and unloading of integration with v2 config, but without using it.""" - - # Simulate v2 config but as a P1 Meter - mock_config_entry = MockConfigEntry( - title="Device", - domain=DOMAIN, - data={ - CONF_IP_ADDRESS: "127.0.0.1", - CONF_TOKEN: "00112233445566778899ABCDEFABCDEF", - }, - unique_id="HWE-P1_5c2fafabcdef", - ) - - mock_config_entry.add_to_hass(hass) - await hass.config_entries.async_setup(mock_config_entry.entry_id) - await hass.async_block_till_done() - - assert mock_config_entry.state is ConfigEntryState.LOADED - assert len(mock_homewizardenergy.combined.mock_calls) == 1 - - await hass.config_entries.async_unload(mock_config_entry.entry_id) - await hass.async_block_till_done() - - assert mock_config_entry.state is ConfigEntryState.NOT_LOADED - - async def test_load_failed_host_unavailable( hass: HomeAssistant, mock_config_entry: MockConfigEntry, diff --git a/tests/components/matter/fixtures/nodes/solar_power.json b/tests/components/matter/fixtures/nodes/solar_power.json index 4b7c4af5b43..1147ff202ca 100644 --- a/tests/components/matter/fixtures/nodes/solar_power.json +++ b/tests/components/matter/fixtures/nodes/solar_power.json @@ -243,7 +243,14 @@ "1/29/1": [3, 29, 47, 144, 145, 156], "1/29/2": [], "1/29/3": [], - "1/29/4": [], + "1/29/4": [ + { + "0": null, + "1": 15, + "2": 2, + "3": "Solar" + } + ], "1/29/65532": 0, "1/29/65533": 2, "1/29/65528": [], diff --git a/tests/components/mcp/test_init.py b/tests/components/mcp/test_init.py index 045fb99e181..00666e71d05 100644 --- a/tests/components/mcp/test_init.py +++ b/tests/components/mcp/test_init.py @@ -58,7 +58,6 @@ def create_llm_context() -> llm.LLMContext: return llm.LLMContext( platform="test_platform", context=Context(), - user_prompt="test_text", language="*", assistant="conversation", device_id=None, diff --git a/tests/components/meater/__init__.py b/tests/components/meater/__init__.py index ef96dafe88c..48d576ce79b 100644 --- a/tests/components/meater/__init__.py +++ b/tests/components/meater/__init__.py @@ -1 +1,13 @@ """Tests for the Meater integration.""" + +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry + + +async def setup_integration(hass: HomeAssistant, config_entry: MockConfigEntry) -> None: + """Fixture for setting up the component.""" + config_entry.add_to_hass(hass) + + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() diff --git a/tests/components/meater/conftest.py b/tests/components/meater/conftest.py new file mode 100644 index 00000000000..ccaa48437f3 --- /dev/null +++ b/tests/components/meater/conftest.py @@ -0,0 +1,80 @@ +"""Meater tests configuration.""" + +from collections.abc import Generator +from datetime import datetime +from unittest.mock import AsyncMock, Mock, patch + +from meater.MeaterApi import MeaterCook, MeaterProbe +import pytest + +from homeassistant.components.meater.const import DOMAIN +from homeassistant.const import CONF_PASSWORD, CONF_USERNAME + +from .const import PROBE_ID + +from tests.common import MockConfigEntry + + +@pytest.fixture +def mock_setup_entry() -> Generator[AsyncMock]: + """Override async_setup_entry.""" + with patch( + "homeassistant.components.meater.async_setup_entry", + return_value=True, + ) as mock_setup_entry: + yield mock_setup_entry + + +@pytest.fixture +def mock_meater_client(mock_probe: Mock) -> Generator[AsyncMock]: + """Mock a Meater client.""" + with ( + patch( + "homeassistant.components.meater.coordinator.MeaterApi", + autospec=True, + ) as mock_client, + patch( + "homeassistant.components.meater.config_flow.MeaterApi", + new=mock_client, + ), + ): + client = mock_client.return_value + client.get_all_devices.return_value = [mock_probe] + yield client + + +@pytest.fixture +def mock_config_entry() -> MockConfigEntry: + """Mock a config entry.""" + return MockConfigEntry( + domain=DOMAIN, + title="Meater", + data={CONF_USERNAME: "user@host.com", CONF_PASSWORD: "password123"}, + unique_id="user@host.com", + ) + + +@pytest.fixture +def mock_cook() -> Mock: + """Mock a cook.""" + mock = Mock(spec=MeaterCook) + mock.id = "123123" + mock.name = "Whole chicken" + mock.state = "Started" + mock.target_temperature = 25.0 + mock.peak_temperature = 27.0 + mock.time_remaining = 32 + mock.time_elapsed = 32 + return mock + + +@pytest.fixture +def mock_probe(mock_cook: Mock) -> Mock: + """Mock a probe.""" + mock = Mock(spec=MeaterProbe) + mock.id = PROBE_ID + mock.internal_temperature = 26.0 + mock.ambient_temperature = 28.0 + mock.cook = mock_cook + mock.time_updated = datetime.fromisoformat("2025-06-16T13:53:51+00:00") + return mock diff --git a/tests/components/meater/const.py b/tests/components/meater/const.py new file mode 100644 index 00000000000..52ba9ac3feb --- /dev/null +++ b/tests/components/meater/const.py @@ -0,0 +1,3 @@ +"""Constants for the Meater tests.""" + +PROBE_ID = "40a72384fa80349314dfd97c84b73a5c1c9da57b59e26d67b573d618fe0d6e58" diff --git a/tests/components/meater/snapshots/test_diagnostics.ambr b/tests/components/meater/snapshots/test_diagnostics.ambr new file mode 100644 index 00000000000..ced779eb114 --- /dev/null +++ b/tests/components/meater/snapshots/test_diagnostics.ambr @@ -0,0 +1,20 @@ +# serializer version: 1 +# name: test_entry_diagnostics + dict({ + '40a72384fa80349314dfd97c84b73a5c1c9da57b59e26d67b573d618fe0d6e58': dict({ + 'ambient_temperature': 28.0, + 'cook': dict({ + 'id': '123123', + 'name': 'Whole chicken', + 'peak_temperature': 27.0, + 'state': 'Started', + 'target_temperature': 25.0, + 'time_elapsed': 32, + 'time_remaining': 32, + }), + 'id': '40a72384fa80349314dfd97c84b73a5c1c9da57b59e26d67b573d618fe0d6e58', + 'internal_temperature': 26.0, + 'time_updated': '2025-06-16T13:53:51+00:00', + }), + }) +# --- diff --git a/tests/components/meater/snapshots/test_init.ambr b/tests/components/meater/snapshots/test_init.ambr new file mode 100644 index 00000000000..582fd68efb1 --- /dev/null +++ b/tests/components/meater/snapshots/test_init.ambr @@ -0,0 +1,34 @@ +# serializer version: 1 +# name: test_device_info + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'config_entries_subentries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'meater', + '40a72384fa80349314dfd97c84b73a5c1c9da57b59e26d67b573d618fe0d6e58', + ), + }), + 'is_new': False, + 'labels': set({ + }), + 'manufacturer': 'Apption Labs', + 'model': 'Meater Probe', + 'model_id': None, + 'name': 'Meater Probe 40a72384fa80349314dfd97c84b73a5c1c9da57b59e26d67b573d618fe0d6e58', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'suggested_area': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- diff --git a/tests/components/meater/snapshots/test_sensor.ambr b/tests/components/meater/snapshots/test_sensor.ambr new file mode 100644 index 00000000000..aaec1db296a --- /dev/null +++ b/tests/components/meater/snapshots/test_sensor.ambr @@ -0,0 +1,435 @@ +# serializer version: 1 +# name: test_entities[sensor.meater_40a72384fa80349314dfd97c84b73a5c1c9da57b59e26d67b573d618fe0d6e58_ambient-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.meater_40a72384fa80349314dfd97c84b73a5c1c9da57b59e26d67b573d618fe0d6e58_ambient', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': None, + 'platform': 'meater', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'ambient', + 'unique_id': '40a72384fa80349314dfd97c84b73a5c1c9da57b59e26d67b573d618fe0d6e58-ambient', + 'unit_of_measurement': , + }) +# --- +# name: test_entities[sensor.meater_40a72384fa80349314dfd97c84b73a5c1c9da57b59e26d67b573d618fe0d6e58_ambient-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.meater_40a72384fa80349314dfd97c84b73a5c1c9da57b59e26d67b573d618fe0d6e58_ambient', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '28.0', + }) +# --- +# name: test_entities[sensor.meater_40a72384fa80349314dfd97c84b73a5c1c9da57b59e26d67b573d618fe0d6e58_cook_name-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.meater_40a72384fa80349314dfd97c84b73a5c1c9da57b59e26d67b573d618fe0d6e58_cook_name', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': None, + 'platform': 'meater', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'cook_name', + 'unique_id': '40a72384fa80349314dfd97c84b73a5c1c9da57b59e26d67b573d618fe0d6e58-cook_name', + 'unit_of_measurement': None, + }) +# --- +# name: test_entities[sensor.meater_40a72384fa80349314dfd97c84b73a5c1c9da57b59e26d67b573d618fe0d6e58_cook_name-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + }), + 'context': , + 'entity_id': 'sensor.meater_40a72384fa80349314dfd97c84b73a5c1c9da57b59e26d67b573d618fe0d6e58_cook_name', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'Whole chicken', + }) +# --- +# name: test_entities[sensor.meater_40a72384fa80349314dfd97c84b73a5c1c9da57b59e26d67b573d618fe0d6e58_cook_peak_temp-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.meater_40a72384fa80349314dfd97c84b73a5c1c9da57b59e26d67b573d618fe0d6e58_cook_peak_temp', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': None, + 'platform': 'meater', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'cook_peak_temp', + 'unique_id': '40a72384fa80349314dfd97c84b73a5c1c9da57b59e26d67b573d618fe0d6e58-cook_peak_temp', + 'unit_of_measurement': , + }) +# --- +# name: test_entities[sensor.meater_40a72384fa80349314dfd97c84b73a5c1c9da57b59e26d67b573d618fe0d6e58_cook_peak_temp-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.meater_40a72384fa80349314dfd97c84b73a5c1c9da57b59e26d67b573d618fe0d6e58_cook_peak_temp', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '27.0', + }) +# --- +# name: test_entities[sensor.meater_40a72384fa80349314dfd97c84b73a5c1c9da57b59e26d67b573d618fe0d6e58_cook_state-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'not_started', + 'configured', + 'started', + 'ready_for_resting', + 'resting', + 'slightly_underdone', + 'finished', + 'slightly_overdone', + 'overcooked', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.meater_40a72384fa80349314dfd97c84b73a5c1c9da57b59e26d67b573d618fe0d6e58_cook_state', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': None, + 'platform': 'meater', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'cook_state', + 'unique_id': '40a72384fa80349314dfd97c84b73a5c1c9da57b59e26d67b573d618fe0d6e58-cook_state', + 'unit_of_measurement': None, + }) +# --- +# name: test_entities[sensor.meater_40a72384fa80349314dfd97c84b73a5c1c9da57b59e26d67b573d618fe0d6e58_cook_state-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'enum', + 'options': list([ + 'not_started', + 'configured', + 'started', + 'ready_for_resting', + 'resting', + 'slightly_underdone', + 'finished', + 'slightly_overdone', + 'overcooked', + ]), + }), + 'context': , + 'entity_id': 'sensor.meater_40a72384fa80349314dfd97c84b73a5c1c9da57b59e26d67b573d618fe0d6e58_cook_state', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'started', + }) +# --- +# name: test_entities[sensor.meater_40a72384fa80349314dfd97c84b73a5c1c9da57b59e26d67b573d618fe0d6e58_cook_target_temp-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.meater_40a72384fa80349314dfd97c84b73a5c1c9da57b59e26d67b573d618fe0d6e58_cook_target_temp', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': None, + 'platform': 'meater', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'cook_target_temp', + 'unique_id': '40a72384fa80349314dfd97c84b73a5c1c9da57b59e26d67b573d618fe0d6e58-cook_target_temp', + 'unit_of_measurement': , + }) +# --- +# name: test_entities[sensor.meater_40a72384fa80349314dfd97c84b73a5c1c9da57b59e26d67b573d618fe0d6e58_cook_target_temp-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.meater_40a72384fa80349314dfd97c84b73a5c1c9da57b59e26d67b573d618fe0d6e58_cook_target_temp', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '25.0', + }) +# --- +# name: test_entities[sensor.meater_40a72384fa80349314dfd97c84b73a5c1c9da57b59e26d67b573d618fe0d6e58_cook_time_elapsed-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.meater_40a72384fa80349314dfd97c84b73a5c1c9da57b59e26d67b573d618fe0d6e58_cook_time_elapsed', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': None, + 'platform': 'meater', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'cook_time_elapsed', + 'unique_id': '40a72384fa80349314dfd97c84b73a5c1c9da57b59e26d67b573d618fe0d6e58-cook_time_elapsed', + 'unit_of_measurement': None, + }) +# --- +# name: test_entities[sensor.meater_40a72384fa80349314dfd97c84b73a5c1c9da57b59e26d67b573d618fe0d6e58_cook_time_elapsed-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'timestamp', + }), + 'context': , + 'entity_id': 'sensor.meater_40a72384fa80349314dfd97c84b73a5c1c9da57b59e26d67b573d618fe0d6e58_cook_time_elapsed', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '2023-10-20T23:59:28+00:00', + }) +# --- +# name: test_entities[sensor.meater_40a72384fa80349314dfd97c84b73a5c1c9da57b59e26d67b573d618fe0d6e58_cook_time_remaining-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.meater_40a72384fa80349314dfd97c84b73a5c1c9da57b59e26d67b573d618fe0d6e58_cook_time_remaining', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': None, + 'platform': 'meater', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'cook_time_remaining', + 'unique_id': '40a72384fa80349314dfd97c84b73a5c1c9da57b59e26d67b573d618fe0d6e58-cook_time_remaining', + 'unit_of_measurement': None, + }) +# --- +# name: test_entities[sensor.meater_40a72384fa80349314dfd97c84b73a5c1c9da57b59e26d67b573d618fe0d6e58_cook_time_remaining-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'timestamp', + }), + 'context': , + 'entity_id': 'sensor.meater_40a72384fa80349314dfd97c84b73a5c1c9da57b59e26d67b573d618fe0d6e58_cook_time_remaining', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '2023-10-21T00:00:32+00:00', + }) +# --- +# name: test_entities[sensor.meater_40a72384fa80349314dfd97c84b73a5c1c9da57b59e26d67b573d618fe0d6e58_internal-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.meater_40a72384fa80349314dfd97c84b73a5c1c9da57b59e26d67b573d618fe0d6e58_internal', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': None, + 'platform': 'meater', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'internal', + 'unique_id': '40a72384fa80349314dfd97c84b73a5c1c9da57b59e26d67b573d618fe0d6e58-internal', + 'unit_of_measurement': , + }) +# --- +# name: test_entities[sensor.meater_40a72384fa80349314dfd97c84b73a5c1c9da57b59e26d67b573d618fe0d6e58_internal-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.meater_40a72384fa80349314dfd97c84b73a5c1c9da57b59e26d67b573d618fe0d6e58_internal', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '26.0', + }) +# --- diff --git a/tests/components/meater/test_config_flow.py b/tests/components/meater/test_config_flow.py index 9049cf4ac9a..c6704f2f3f7 100644 --- a/tests/components/meater/test_config_flow.py +++ b/tests/components/meater/test_config_flow.py @@ -1,12 +1,12 @@ """Define tests for the Meater config flow.""" -from unittest.mock import AsyncMock, patch +from unittest.mock import AsyncMock from meater import AuthenticationError, ServiceUnavailableError import pytest -from homeassistant import config_entries from homeassistant.components.meater import DOMAIN +from homeassistant.config_entries import SOURCE_USER from homeassistant.const import CONF_PASSWORD, CONF_USERNAME from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType @@ -14,132 +14,114 @@ from homeassistant.data_entry_flow import FlowResultType from tests.common import MockConfigEntry -@pytest.fixture -def mock_client(): - """Define a fixture for authentication coroutine.""" - return AsyncMock(return_value=None) - - -@pytest.fixture -def mock_meater(mock_client): - """Mock the meater library.""" - with patch("homeassistant.components.meater.MeaterApi.authenticate") as mock_: - mock_.side_effect = mock_client - yield mock_ - - -async def test_duplicate_error(hass: HomeAssistant) -> None: - """Test that errors are shown when duplicates are added.""" - conf = {CONF_USERNAME: "user@host.com", CONF_PASSWORD: "password123"} - - MockConfigEntry(domain=DOMAIN, unique_id="user@host.com", data=conf).add_to_hass( - hass - ) - - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_USER}, data=conf - ) - - assert result["type"] is FlowResultType.ABORT - assert result["reason"] == "already_configured" - - -@pytest.mark.parametrize("mock_client", [AsyncMock(side_effect=Exception)]) -async def test_unknown_auth_error(hass: HomeAssistant, mock_meater) -> None: - """Test that an invalid API/App Key throws an error.""" - conf = {CONF_USERNAME: "user@host.com", CONF_PASSWORD: "password123"} - - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_USER}, data=conf - ) - assert result["errors"] == {"base": "unknown_auth_error"} - - -@pytest.mark.parametrize("mock_client", [AsyncMock(side_effect=AuthenticationError)]) -async def test_invalid_credentials(hass: HomeAssistant, mock_meater) -> None: - """Test that an invalid API/App Key throws an error.""" - conf = {CONF_USERNAME: "user@host.com", CONF_PASSWORD: "password123"} - - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_USER}, data=conf - ) - assert result["errors"] == {"base": "invalid_auth"} - - -@pytest.mark.parametrize( - "mock_client", [AsyncMock(side_effect=ServiceUnavailableError)] -) -async def test_service_unavailable(hass: HomeAssistant, mock_meater) -> None: - """Test that an invalid API/App Key throws an error.""" - conf = {CONF_USERNAME: "user@host.com", CONF_PASSWORD: "password123"} - - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_USER}, data=conf - ) - assert result["errors"] == {"base": "service_unavailable_error"} - - -async def test_user_flow(hass: HomeAssistant, mock_meater) -> None: +async def test_user_flow( + hass: HomeAssistant, mock_setup_entry: AsyncMock, mock_meater_client: AsyncMock +) -> None: """Test that the user flow works.""" - conf = {CONF_USERNAME: "user@host.com", CONF_PASSWORD: "password123"} - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_USER}, data=None + DOMAIN, context={"source": SOURCE_USER} ) assert result["type"] is FlowResultType.FORM assert result["step_id"] == "user" - with patch( - "homeassistant.components.meater.async_setup_entry", - return_value=True, - ) as mock_setup_entry: - result = await hass.config_entries.flow.async_configure(result["flow_id"], conf) - await hass.async_block_till_done() + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_USERNAME: "user@host.com", CONF_PASSWORD: "password123"}, + ) assert result["type"] is FlowResultType.CREATE_ENTRY assert result["data"] == { CONF_USERNAME: "user@host.com", CONF_PASSWORD: "password123", } + assert result["result"].unique_id == "user@host.com" assert len(mock_setup_entry.mock_calls) == 1 - config_entry = hass.config_entries.async_entries(DOMAIN)[0] - assert config_entry.data == { - CONF_USERNAME: "user@host.com", - CONF_PASSWORD: "password123", - } - -async def test_reauth_flow(hass: HomeAssistant, mock_meater) -> None: - """Test that the reauth flow works.""" - data = { - CONF_USERNAME: "user@host.com", - CONF_PASSWORD: "password123", - } - mock_config = MockConfigEntry( - domain=DOMAIN, - unique_id="user@host.com", - data=data, +@pytest.mark.parametrize( + ("exception", "error"), + [ + (AuthenticationError, "invalid_auth"), + (ServiceUnavailableError, "service_unavailable_error"), + (Exception, "unknown_auth_error"), + ], +) +async def test_user_flow_exceptions( + hass: HomeAssistant, + mock_setup_entry: AsyncMock, + mock_meater_client: AsyncMock, + exception: Exception, + error: str, +) -> None: + """Test that an invalid API/App Key throws an error.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} ) - mock_config.add_to_hass(hass) - result = await mock_config.start_reauth_flow(hass) + mock_meater_client.authenticate.side_effect = exception + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_USERNAME: "user@host.com", CONF_PASSWORD: "password123"}, + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + assert result["errors"] == {"base": error} + + mock_meater_client.authenticate.side_effect = None + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_USERNAME: "user@host.com", CONF_PASSWORD: "password123"}, + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + + +async def test_duplicate_error( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_meater_client: AsyncMock, +) -> None: + """Test that errors are shown when duplicates are added.""" + mock_config_entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_USERNAME: "user@host.com", CONF_PASSWORD: "password123"}, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" + + +async def test_reauth_flow( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_meater_client: AsyncMock, +) -> None: + """Test that the reauth flow works.""" + mock_config_entry.add_to_hass(hass) + + result = await mock_config_entry.start_reauth_flow(hass) assert result["type"] is FlowResultType.FORM assert result["step_id"] == "reauth_confirm" - assert result["errors"] is None + assert not result["errors"] - result2 = await hass.config_entries.flow.async_configure( + result = await hass.config_entries.flow.async_configure( result["flow_id"], - {"password": "passwordabc"}, + {CONF_PASSWORD: "passwordabc"}, ) - await hass.async_block_till_done() - assert result2["type"] is FlowResultType.ABORT - assert result2["reason"] == "reauth_successful" - - config_entry = hass.config_entries.async_entries(DOMAIN)[0] - assert config_entry.data == { + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reauth_successful" + assert mock_config_entry.data == { CONF_USERNAME: "user@host.com", CONF_PASSWORD: "passwordabc", } diff --git a/tests/components/meater/test_diagnostics.py b/tests/components/meater/test_diagnostics.py new file mode 100644 index 00000000000..9d78828a92f --- /dev/null +++ b/tests/components/meater/test_diagnostics.py @@ -0,0 +1,28 @@ +"""Test Meater diagnostics.""" + +from unittest.mock import AsyncMock + +from syrupy.assertion import SnapshotAssertion + +from homeassistant.core import HomeAssistant + +from . import setup_integration + +from tests.common import MockConfigEntry +from tests.components.diagnostics import get_diagnostics_for_config_entry +from tests.typing import ClientSessionGenerator + + +async def test_entry_diagnostics( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + mock_meater_client: AsyncMock, + mock_config_entry: MockConfigEntry, + snapshot: SnapshotAssertion, +) -> None: + """Test config entry diagnostics.""" + await setup_integration(hass, mock_config_entry) + assert ( + await get_diagnostics_for_config_entry(hass, hass_client, mock_config_entry) + == snapshot + ) diff --git a/tests/components/meater/test_init.py b/tests/components/meater/test_init.py new file mode 100644 index 00000000000..52f6b29d488 --- /dev/null +++ b/tests/components/meater/test_init.py @@ -0,0 +1,28 @@ +"""Tests for the Meater integration.""" + +from unittest.mock import AsyncMock + +from syrupy.assertion import SnapshotAssertion + +from homeassistant.components.meater.const import DOMAIN +from homeassistant.core import HomeAssistant +from homeassistant.helpers import device_registry as dr + +from . import setup_integration +from .const import PROBE_ID + +from tests.common import MockConfigEntry + + +async def test_device_info( + hass: HomeAssistant, + snapshot: SnapshotAssertion, + mock_meater_client: AsyncMock, + mock_config_entry: MockConfigEntry, + device_registry: dr.DeviceRegistry, +) -> None: + """Test device registry integration.""" + await setup_integration(hass, mock_config_entry) + device_entry = device_registry.async_get_device(identifiers={(DOMAIN, PROBE_ID)}) + assert device_entry is not None + assert device_entry == snapshot diff --git a/tests/components/meater/test_sensor.py b/tests/components/meater/test_sensor.py new file mode 100644 index 00000000000..8ddd5fbb590 --- /dev/null +++ b/tests/components/meater/test_sensor.py @@ -0,0 +1,29 @@ +"""Tests for the Meater sensors.""" + +from unittest.mock import AsyncMock, patch + +import pytest +from syrupy.assertion import SnapshotAssertion + +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from . import setup_integration + +from tests.common import MockConfigEntry, snapshot_platform + + +@pytest.mark.freeze_time("2023-10-21") +async def test_entities( + hass: HomeAssistant, + snapshot: SnapshotAssertion, + entity_registry: er.EntityRegistry, + mock_meater_client: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test the sensor entities.""" + with patch("homeassistant.components.meater.PLATFORMS", [Platform.SENSOR]): + await setup_integration(hass, mock_config_entry) + + await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) diff --git a/tests/components/media_player/test_init.py b/tests/components/media_player/test_init.py index 090ea9f27e2..2e270eb3b2e 100644 --- a/tests/components/media_player/test_init.py +++ b/tests/components/media_player/test_init.py @@ -152,7 +152,9 @@ def test_support_properties(hass: HomeAssistant, property_suffix: str) -> None: entity4 = MediaPlayerEntity() entity4.hass = hass entity4.platform = MockEntityPlatform(hass) - entity4._attr_supported_features = all_features - feature + entity4._attr_supported_features = media_player.MediaPlayerEntityFeature( + all_features - feature + ) assert getattr(entity1, f"support_{property_suffix}") is False assert getattr(entity2, f"support_{property_suffix}") is True @@ -652,27 +654,3 @@ async def test_get_async_get_browse_image_quoting( url = player.get_browse_image_url("album", media_content_id) await client.get(url) mock_browse_image.assert_called_with("album", media_content_id, None) - - -def test_deprecated_supported_features_ints( - hass: HomeAssistant, caplog: pytest.LogCaptureFixture -) -> None: - """Test deprecated supported features ints.""" - - class MockMediaPlayerEntity(MediaPlayerEntity): - @property - def supported_features(self) -> int: - """Return supported features.""" - return 1 - - entity = MockMediaPlayerEntity() - entity.hass = hass - entity.platform = MockEntityPlatform(hass) - assert entity.supported_features_compat is MediaPlayerEntityFeature(1) - assert "MockMediaPlayerEntity" in caplog.text - assert "is using deprecated supported features values" in caplog.text - assert "Instead it should use" in caplog.text - assert "MediaPlayerEntityFeature.PAUSE" in caplog.text - caplog.clear() - assert entity.supported_features_compat is MediaPlayerEntityFeature(1) - assert "is using deprecated supported features values" not in caplog.text diff --git a/tests/components/mqtt/test_sensor.py b/tests/components/mqtt/test_sensor.py index ea1b7e186e2..997c014cd13 100644 --- a/tests/components/mqtt/test_sensor.py +++ b/tests/components/mqtt/test_sensor.py @@ -898,42 +898,12 @@ async def test_invalid_unit_of_measurement( "The unit of measurement `ppm` is not valid together with device class `energy`" in caplog.text ) - # A repair issue was logged + # A repair issue was logged for the failing YAML config assert len(events) == 1 - assert events[0].data["issue_id"] == "sensor.test" - # Assert the sensor works - async_fire_mqtt_message(hass, "test-topic", "100") - await hass.async_block_till_done() + assert events[0].data["domain"] == mqtt.DOMAIN + # Assert the sensor is not created state = hass.states.get("sensor.test") - assert state is not None - assert state.state == "100" - - caplog.clear() - - discovery_payload = { - "name": "bla", - "state_topic": "test-topic2", - "device_class": "temperature", - "unit_of_measurement": "C", - } - # Now discover an other invalid sensor - async_fire_mqtt_message( - hass, "homeassistant/sensor/bla/config", json.dumps(discovery_payload) - ) - await hass.async_block_till_done() - assert ( - "The unit of measurement `C` is not valid together with device class `temperature`" - in caplog.text - ) - # Assert the sensor works - async_fire_mqtt_message(hass, "test-topic2", "21") - await hass.async_block_till_done() - state = hass.states.get("sensor.bla") - assert state is not None - assert state.state == "21" - - # No new issue was registered for the discovered entity - assert len(events) == 1 + assert state is None @pytest.mark.parametrize( diff --git a/tests/components/ollama/test_conversation.py b/tests/components/ollama/test_conversation.py index 8e54018a14d..e83c2a3495f 100644 --- a/tests/components/ollama/test_conversation.py +++ b/tests/components/ollama/test_conversation.py @@ -284,7 +284,6 @@ async def test_function_call( llm.LLMContext( platform="ollama", context=context, - user_prompt="Please call the test function", language="en", assistant="conversation", device_id=None, @@ -369,7 +368,6 @@ async def test_function_exception( llm.LLMContext( platform="ollama", context=context, - user_prompt="Please call the test function", language="en", assistant="conversation", device_id=None, diff --git a/tests/components/paperless_ngx/const.py b/tests/components/paperless_ngx/const.py index addfd54a001..36f62b507dd 100644 --- a/tests/components/paperless_ngx/const.py +++ b/tests/components/paperless_ngx/const.py @@ -1,15 +1,17 @@ """Constants for the Paperless NGX integration tests.""" -from homeassistant.const import CONF_API_KEY, CONF_URL +from homeassistant.const import CONF_API_KEY, CONF_URL, CONF_VERIFY_SSL USER_INPUT_ONE = { CONF_URL: "https://192.168.69.16:8000", CONF_API_KEY: "12345678", + CONF_VERIFY_SSL: True, } USER_INPUT_TWO = { CONF_URL: "https://paperless.example.de", CONF_API_KEY: "87654321", + CONF_VERIFY_SSL: True, } USER_INPUT_REAUTH = {CONF_API_KEY: "192837465"} diff --git a/tests/components/reolink/conftest.py b/tests/components/reolink/conftest.py index c94dd8d7d37..d96931aaf26 100644 --- a/tests/components/reolink/conftest.py +++ b/tests/components/reolink/conftest.py @@ -62,6 +62,100 @@ def mock_setup_entry() -> Generator[AsyncMock]: yield mock_setup_entry +def _init_host_mock(host_mock: MagicMock) -> None: + host_mock.get_host_data = AsyncMock(return_value=None) + host_mock.get_states = AsyncMock(return_value=None) + host_mock.check_new_firmware = AsyncMock(return_value=False) + host_mock.unsubscribe = AsyncMock(return_value=True) + host_mock.logout = AsyncMock(return_value=True) + host_mock.reboot = AsyncMock() + host_mock.set_ptz_command = AsyncMock() + host_mock.is_nvr = True + host_mock.is_hub = False + host_mock.mac_address = TEST_MAC + host_mock.uid = TEST_UID + host_mock.onvif_enabled = True + host_mock.rtmp_enabled = True + host_mock.rtsp_enabled = True + host_mock.nvr_name = TEST_NVR_NAME + host_mock.port = TEST_PORT + host_mock.use_https = TEST_USE_HTTPS + host_mock.is_admin = True + host_mock.user_level = "admin" + host_mock.protocol = "rtsp" + host_mock.channels = [0] + host_mock.stream_channels = [0] + host_mock.new_devices = False + host_mock.sw_version_update_required = False + host_mock.hardware_version = "IPC_00000" + host_mock.sw_version = "v1.0.0.0.0.0000" + host_mock.sw_upload_progress.return_value = 100 + host_mock.manufacturer = "Reolink" + host_mock.model = TEST_HOST_MODEL + host_mock.supported.return_value = True + host_mock.item_number.return_value = TEST_ITEM_NUMBER + host_mock.camera_model.return_value = TEST_CAM_MODEL + host_mock.camera_name.return_value = TEST_NVR_NAME + host_mock.camera_hardware_version.return_value = "IPC_00001" + host_mock.camera_sw_version.return_value = "v1.1.0.0.0.0000" + host_mock.camera_sw_version_update_required.return_value = False + host_mock.camera_uid.return_value = TEST_UID_CAM + host_mock.camera_online.return_value = True + host_mock.channel_for_uid.return_value = 0 + host_mock.get_encoding.return_value = "h264" + host_mock.firmware_update_available.return_value = False + host_mock.session_active = True + host_mock.timeout = 60 + host_mock.renewtimer.return_value = 600 + host_mock.wifi_connection = False + host_mock.wifi_signal = None + host_mock.whiteled_mode_list.return_value = [] + host_mock.zoom_range.return_value = { + "zoom": {"pos": {"min": 0, "max": 100}}, + "focus": {"pos": {"min": 0, "max": 100}}, + } + host_mock.capabilities = {"Host": ["RTSP"], "0": ["motion_detection"]} + host_mock.checked_api_versions = {"GetEvents": 1} + host_mock.abilities = {"abilityChn": [{"aiTrack": {"permit": 0, "ver": 0}}]} + host_mock.get_raw_host_data.return_value = ( + "{'host':'TEST_RESPONSE','channel':'TEST_RESPONSE'}" + ) + + # enums + host_mock.whiteled_mode.return_value = 1 + host_mock.whiteled_mode_list.return_value = ["off", "auto"] + host_mock.doorbell_led.return_value = "Off" + host_mock.doorbell_led_list.return_value = ["stayoff", "auto"] + host_mock.auto_track_method.return_value = 3 + host_mock.daynight_state.return_value = "Black&White" + host_mock.hub_alarm_tone_id.return_value = 1 + host_mock.hub_visitor_tone_id.return_value = 1 + host_mock.recording_packing_time_list = ["30 Minutes", "60 Minutes"] + host_mock.recording_packing_time = "60 Minutes" + + # Baichuan + host_mock.baichuan_only = False + # Disable tcp push by default for tests + host_mock.baichuan.port = TEST_BC_PORT + host_mock.baichuan.events_active = False + host_mock.baichuan.unsubscribe_events = AsyncMock() + host_mock.baichuan.check_subscribe_events = AsyncMock() + host_mock.baichuan.mac_address.return_value = TEST_MAC_CAM + host_mock.baichuan.privacy_mode.return_value = False + host_mock.baichuan.day_night_state.return_value = "day" + host_mock.baichuan.subscribe_events.side_effect = ReolinkError("Test error") + host_mock.baichuan.active_scene = "off" + host_mock.baichuan.scene_names = ["off", "home"] + host_mock.baichuan.abilities = { + 0: {"chnID": 0, "aitype": 34615}, + "Host": {"pushAlarm": 7}, + } + host_mock.baichuan.smart_location_list.return_value = [0] + host_mock.baichuan.smart_ai_type_list.return_value = ["people"] + host_mock.baichuan.smart_ai_index.return_value = 1 + host_mock.baichuan.smart_ai_name.return_value = "zone1" + + @pytest.fixture(scope="module") def reolink_connect_class() -> Generator[MagicMock]: """Mock reolink connection and return both the host_mock and host_mock_class.""" @@ -71,97 +165,8 @@ def reolink_connect_class() -> Generator[MagicMock]: ) as host_mock_class, ): host_mock = host_mock_class.return_value - host_mock.get_host_data.return_value = None - host_mock.get_states.return_value = None - host_mock.supported.return_value = True - host_mock.check_new_firmware.return_value = False - host_mock.unsubscribe.return_value = True - host_mock.logout.return_value = True - host_mock.is_nvr = True - host_mock.is_hub = False - host_mock.mac_address = TEST_MAC - host_mock.uid = TEST_UID - host_mock.onvif_enabled = True - host_mock.rtmp_enabled = True - host_mock.rtsp_enabled = True - host_mock.nvr_name = TEST_NVR_NAME - host_mock.port = TEST_PORT - host_mock.use_https = TEST_USE_HTTPS - host_mock.is_admin = True - host_mock.user_level = "admin" - host_mock.protocol = "rtsp" - host_mock.channels = [0] - host_mock.stream_channels = [0] - host_mock.new_devices = False - host_mock.sw_version_update_required = False - host_mock.hardware_version = "IPC_00000" - host_mock.sw_version = "v1.0.0.0.0.0000" - host_mock.sw_upload_progress.return_value = 100 - host_mock.manufacturer = "Reolink" - host_mock.model = TEST_HOST_MODEL - host_mock.item_number.return_value = TEST_ITEM_NUMBER - host_mock.camera_model.return_value = TEST_CAM_MODEL - host_mock.camera_name.return_value = TEST_NVR_NAME - host_mock.camera_hardware_version.return_value = "IPC_00001" - host_mock.camera_sw_version.return_value = "v1.1.0.0.0.0000" - host_mock.camera_sw_version_update_required.return_value = False - host_mock.camera_uid.return_value = TEST_UID_CAM - host_mock.camera_online.return_value = True - host_mock.channel_for_uid.return_value = 0 - host_mock.get_encoding.return_value = "h264" - host_mock.firmware_update_available.return_value = False - host_mock.session_active = True - host_mock.timeout = 60 - host_mock.renewtimer.return_value = 600 - host_mock.wifi_connection = False - host_mock.wifi_signal = None - host_mock.whiteled_mode_list.return_value = [] - host_mock.zoom_range.return_value = { - "zoom": {"pos": {"min": 0, "max": 100}}, - "focus": {"pos": {"min": 0, "max": 100}}, - } - host_mock.capabilities = {"Host": ["RTSP"], "0": ["motion_detection"]} - host_mock.checked_api_versions = {"GetEvents": 1} - host_mock.abilities = {"abilityChn": [{"aiTrack": {"permit": 0, "ver": 0}}]} - host_mock.get_raw_host_data.return_value = ( - "{'host':'TEST_RESPONSE','channel':'TEST_RESPONSE'}" - ) - - reolink_connect.chime_list = [] - - # enums - host_mock.whiteled_mode.return_value = 1 - host_mock.whiteled_mode_list.return_value = ["off", "auto"] - host_mock.doorbell_led.return_value = "Off" - host_mock.doorbell_led_list.return_value = ["stayoff", "auto"] - host_mock.auto_track_method.return_value = 3 - host_mock.daynight_state.return_value = "Black&White" - host_mock.hub_alarm_tone_id.return_value = 1 - host_mock.hub_visitor_tone_id.return_value = 1 - host_mock.recording_packing_time_list = ["30 Minutes", "60 Minutes"] - host_mock.recording_packing_time = "60 Minutes" - - # Baichuan host_mock.baichuan = create_autospec(Baichuan) - host_mock.baichuan_only = False - # Disable tcp push by default for tests - host_mock.baichuan.port = TEST_BC_PORT - host_mock.baichuan.events_active = False - host_mock.baichuan.mac_address.return_value = TEST_MAC_CAM - host_mock.baichuan.privacy_mode.return_value = False - host_mock.baichuan.day_night_state.return_value = "day" - host_mock.baichuan.subscribe_events.side_effect = ReolinkError("Test error") - host_mock.baichuan.active_scene = "off" - host_mock.baichuan.scene_names = ["off", "home"] - host_mock.baichuan.abilities = { - 0: {"chnID": 0, "aitype": 34615}, - "Host": {"pushAlarm": 7}, - } - host_mock.baichuan.smart_location_list.return_value = [0] - host_mock.baichuan.smart_ai_type_list.return_value = ["people"] - host_mock.baichuan.smart_ai_index.return_value = 1 - host_mock.baichuan.smart_ai_name.return_value = "zone1" - + _init_host_mock(host_mock) yield host_mock_class @@ -173,6 +178,18 @@ def reolink_connect( return reolink_connect_class.return_value +@pytest.fixture +def reolink_host() -> Generator[MagicMock]: + """Mock reolink Host class.""" + with patch( + "homeassistant.components.reolink.host.Host", autospec=False + ) as host_mock_class: + host_mock = host_mock_class.return_value + host_mock.baichuan = MagicMock() + _init_host_mock(host_mock) + yield host_mock + + @pytest.fixture def reolink_platforms() -> Generator[None]: """Mock reolink entry setup.""" diff --git a/tests/components/reolink/snapshots/test_diagnostics.ambr b/tests/components/reolink/snapshots/test_diagnostics.ambr index 771aeba2a9e..a6d7f14a149 100644 --- a/tests/components/reolink/snapshots/test_diagnostics.ambr +++ b/tests/components/reolink/snapshots/test_diagnostics.ambr @@ -72,6 +72,10 @@ '0': 1, 'null': 1, }), + '299': dict({ + '0': 1, + 'null': 1, + }), 'DingDongOpt': dict({ '0': 2, 'null': 2, @@ -144,6 +148,10 @@ '0': 1, 'null': 1, }), + 'GetMask': dict({ + '0': 1, + 'null': 1, + }), 'GetMdAlarm': dict({ '0': 1, 'null': 1, diff --git a/tests/components/reolink/test_button.py b/tests/components/reolink/test_button.py index 126fbb6b29a..ee51d0f0b99 100644 --- a/tests/components/reolink/test_button.py +++ b/tests/components/reolink/test_button.py @@ -21,7 +21,7 @@ from tests.common import MockConfigEntry async def test_button( hass: HomeAssistant, config_entry: MockConfigEntry, - reolink_connect: MagicMock, + reolink_host: MagicMock, ) -> None: """Test button entity with ptz up.""" with patch("homeassistant.components.reolink.PLATFORMS", [Platform.BUTTON]): @@ -37,9 +37,9 @@ async def test_button( {ATTR_ENTITY_ID: entity_id}, blocking=True, ) - reolink_connect.set_ptz_command.assert_called_once() + reolink_host.set_ptz_command.assert_called_once() - reolink_connect.set_ptz_command.side_effect = ReolinkError("Test error") + reolink_host.set_ptz_command.side_effect = ReolinkError("Test error") with pytest.raises(HomeAssistantError): await hass.services.async_call( BUTTON_DOMAIN, @@ -48,13 +48,11 @@ async def test_button( blocking=True, ) - reolink_connect.set_ptz_command.reset_mock(side_effect=True) - async def test_ptz_move_service( hass: HomeAssistant, config_entry: MockConfigEntry, - reolink_connect: MagicMock, + reolink_host: MagicMock, ) -> None: """Test ptz_move entity service using PTZ button entity.""" with patch("homeassistant.components.reolink.PLATFORMS", [Platform.BUTTON]): @@ -70,9 +68,9 @@ async def test_ptz_move_service( {ATTR_ENTITY_ID: entity_id, ATTR_SPEED: 5}, blocking=True, ) - reolink_connect.set_ptz_command.assert_called_with(0, command="Up", speed=5) + reolink_host.set_ptz_command.assert_called_with(0, command="Up", speed=5) - reolink_connect.set_ptz_command.side_effect = ReolinkError("Test error") + reolink_host.set_ptz_command.side_effect = ReolinkError("Test error") with pytest.raises(HomeAssistantError): await hass.services.async_call( DOMAIN, @@ -81,14 +79,12 @@ async def test_ptz_move_service( blocking=True, ) - reolink_connect.set_ptz_command.reset_mock(side_effect=True) - @pytest.mark.usefixtures("entity_registry_enabled_by_default") async def test_host_button( hass: HomeAssistant, config_entry: MockConfigEntry, - reolink_connect: MagicMock, + reolink_host: MagicMock, ) -> None: """Test host button entity with reboot.""" with patch("homeassistant.components.reolink.PLATFORMS", [Platform.BUTTON]): @@ -104,9 +100,9 @@ async def test_host_button( {ATTR_ENTITY_ID: entity_id}, blocking=True, ) - reolink_connect.reboot.assert_called_once() + reolink_host.reboot.assert_called_once() - reolink_connect.reboot.side_effect = ReolinkError("Test error") + reolink_host.reboot.side_effect = ReolinkError("Test error") with pytest.raises(HomeAssistantError): await hass.services.async_call( BUTTON_DOMAIN, @@ -114,5 +110,3 @@ async def test_host_button( {ATTR_ENTITY_ID: entity_id}, blocking=True, ) - - reolink_connect.reboot.reset_mock(side_effect=True) diff --git a/tests/components/reolink/test_host.py b/tests/components/reolink/test_host.py index c777e4064f0..3c2f434ccc7 100644 --- a/tests/components/reolink/test_host.py +++ b/tests/components/reolink/test_host.py @@ -118,6 +118,7 @@ async def test_webhook_callback( reolink_connect.motion_detected.return_value = True reolink_connect.ONVIF_event_callback.return_value = None await client.post(f"/api/webhook/{webhook_id}") + await hass.async_block_till_done() signal_all.assert_called_once() assert hass.states.get(entity_id).state == STATE_ON @@ -129,6 +130,7 @@ async def test_webhook_callback( signal_all.reset_mock() reolink_connect.get_motion_state_all_ch.return_value = False await client.post(f"/api/webhook/{webhook_id}") + await hass.async_block_till_done() signal_all.assert_not_called() assert hass.states.get(entity_id).state == STATE_ON @@ -137,6 +139,7 @@ async def test_webhook_callback( reolink_connect.motion_detected.return_value = False reolink_connect.ONVIF_event_callback.return_value = [0] await client.post(f"/api/webhook/{webhook_id}", data="test_data") + await hass.async_block_till_done() signal_ch.assert_called_once() assert hass.states.get(entity_id).state == STATE_OFF @@ -144,6 +147,7 @@ async def test_webhook_callback( signal_ch.reset_mock() reolink_connect.ONVIF_event_callback.side_effect = Exception("Test error") await client.post(f"/api/webhook/{webhook_id}", data="test_data") + await hass.async_block_till_done() signal_ch.assert_not_called() # test failure to read date from webhook post diff --git a/tests/components/reolink/test_init.py b/tests/components/reolink/test_init.py index 86c4ed861a1..482928560b9 100644 --- a/tests/components/reolink/test_init.py +++ b/tests/components/reolink/test_init.py @@ -1186,6 +1186,19 @@ async def test_camera_wake_callback( assert hass.states.get(entity_id).state == STATE_OFF +async def test_baichaun_only( + hass: HomeAssistant, + reolink_connect: MagicMock, + config_entry: MockConfigEntry, +) -> None: + """Test initializing a baichuan only device.""" + reolink_connect.baichuan_only = True + + with patch("homeassistant.components.reolink.PLATFORMS", [Platform.SWITCH]): + assert await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + async def test_remove( hass: HomeAssistant, reolink_connect: MagicMock, diff --git a/tests/components/smarla/snapshots/test_number.ambr b/tests/components/smarla/snapshots/test_number.ambr index 3232795c277..50312e09920 100644 --- a/tests/components/smarla/snapshots/test_number.ambr +++ b/tests/components/smarla/snapshots/test_number.ambr @@ -53,6 +53,6 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '1', + 'state': '1.0', }) # --- diff --git a/tests/components/smarla/test_number.py b/tests/components/smarla/test_number.py index 642b39f33fb..3589829e56c 100644 --- a/tests/components/smarla/test_number.py +++ b/tests/components/smarla/test_number.py @@ -93,11 +93,11 @@ async def test_number_state_update( entity_id = entity_info["entity_id"] - assert hass.states.get(entity_id).state == "1" + assert hass.states.get(entity_id).state == "1.0" mock_number_property.get.return_value = 100 await update_property_listeners(mock_number_property) await hass.async_block_till_done() - assert hass.states.get(entity_id).state == "100" + assert hass.states.get(entity_id).state == "100.0" diff --git a/tests/components/smartthings/fixtures/device_status/da_ref_normal_01011.json b/tests/components/smartthings/fixtures/device_status/da_ref_normal_01011.json index 350a0ee14bb..dbb4519ca61 100644 --- a/tests/components/smartthings/fixtures/device_status/da_ref_normal_01011.json +++ b/tests/components/smartthings/fixtures/device_status/da_ref_normal_01011.json @@ -105,12 +105,14 @@ "icemaker": { "custom.disabledCapabilities": { "disabledCapabilities": { - "value": null + "value": [], + "timestamp": "2024-12-19T19:47:51.861Z" } }, "switch": { "switch": { - "value": null + "value": "on", + "timestamp": "2025-06-16T07:20:04.493Z" } } }, @@ -134,13 +136,13 @@ "samsungce.unavailableCapabilities": { "unavailableCommands": { "value": [], - "timestamp": "2024-12-01T18:22:19.337Z" + "timestamp": "2024-12-19T19:47:51.861Z" } }, "custom.disabledCapabilities": { "disabledCapabilities": { "value": ["samsungce.freezerConvertMode", "custom.fridgeMode"], - "timestamp": "2024-12-01T18:22:20.155Z" + "timestamp": "2024-12-19T19:47:55.421Z" } }, "samsungce.temperatureSetting": { @@ -229,19 +231,19 @@ "contactSensor": { "contact": { "value": "closed", - "timestamp": "2025-03-30T18:36:45.151Z" + "timestamp": "2025-06-16T15:59:26.313Z" } }, "samsungce.unavailableCapabilities": { "unavailableCommands": { "value": [], - "timestamp": "2024-12-01T18:22:19.337Z" + "timestamp": "2024-12-19T19:47:51.861Z" } }, "custom.disabledCapabilities": { "disabledCapabilities": { "value": ["custom.fridgeMode", "samsungce.temperatureSetting"], - "timestamp": "2024-12-01T18:22:22.081Z" + "timestamp": "2024-12-19T19:47:56.956Z" } }, "samsungce.temperatureSetting": { @@ -257,37 +259,37 @@ "value": null }, "temperature": { - "value": 6, - "unit": "C", - "timestamp": "2025-03-30T17:41:42.863Z" + "value": 36, + "unit": "F", + "timestamp": "2025-06-07T07:52:37.532Z" } }, "custom.thermostatSetpointControl": { "minimumSetpoint": { - "value": 1, - "unit": "C", - "timestamp": "2024-12-01T18:22:19.337Z" + "value": 34, + "unit": "F", + "timestamp": "2025-05-25T02:26:23.832Z" }, "maximumSetpoint": { - "value": 7, - "unit": "C", - "timestamp": "2024-12-01T18:22:19.337Z" + "value": 44, + "unit": "F", + "timestamp": "2025-05-25T02:26:23.832Z" } }, "thermostatCoolingSetpoint": { "coolingSetpointRange": { "value": { - "minimum": 1, - "maximum": 7, + "minimum": 34, + "maximum": 44, "step": 1 }, - "unit": "C", - "timestamp": "2024-12-01T18:22:19.337Z" + "unit": "F", + "timestamp": "2025-05-25T02:26:23.832Z" }, "coolingSetpoint": { - "value": 6, - "unit": "C", - "timestamp": "2025-03-30T17:33:48.530Z" + "value": 36, + "unit": "F", + "timestamp": "2025-06-07T07:48:40.490Z" } } }, @@ -306,13 +308,13 @@ "contactSensor": { "contact": { "value": "closed", - "timestamp": "2024-12-01T18:22:19.331Z" + "timestamp": "2025-06-16T15:01:16.141Z" } }, "samsungce.unavailableCapabilities": { "unavailableCommands": { "value": [], - "timestamp": "2024-12-01T18:22:19.337Z" + "timestamp": "2024-12-19T19:47:51.861Z" } }, "custom.disabledCapabilities": { @@ -322,7 +324,7 @@ "samsungce.temperatureSetting", "samsungce.freezerConvertMode" ], - "timestamp": "2024-12-01T18:22:22.081Z" + "timestamp": "2024-12-19T19:47:56.956Z" } }, "samsungce.temperatureSetting": { @@ -338,26 +340,27 @@ "value": null }, "temperature": { - "value": -17, - "unit": "C", - "timestamp": "2025-03-30T17:35:48.599Z" + "value": -8, + "unit": "F", + "timestamp": "2025-06-07T07:50:37.311Z" } }, "custom.thermostatSetpointControl": { "minimumSetpoint": { - "value": -23, - "unit": "C", - "timestamp": "2024-12-01T18:22:19.337Z" + "value": -8, + "unit": "F", + "timestamp": "2025-05-25T02:26:23.832Z" }, "maximumSetpoint": { - "value": -15, - "unit": "C", - "timestamp": "2024-12-01T18:22:19.337Z" + "value": 5, + "unit": "F", + "timestamp": "2025-05-25T02:26:23.832Z" } }, "samsungce.freezerConvertMode": { "supportedFreezerConvertModes": { - "value": null + "value": [], + "timestamp": "2025-05-25T02:26:23.578Z" }, "freezerConvertMode": { "value": null @@ -366,17 +369,17 @@ "thermostatCoolingSetpoint": { "coolingSetpointRange": { "value": { - "minimum": -23, - "maximum": -15, + "minimum": -8, + "maximum": 5, "step": 1 }, - "unit": "C", - "timestamp": "2024-12-01T18:22:19.337Z" + "unit": "F", + "timestamp": "2025-05-25T02:26:23.832Z" }, "coolingSetpoint": { - "value": -17, - "unit": "C", - "timestamp": "2025-03-30T17:32:34.710Z" + "value": -8, + "unit": "F", + "timestamp": "2025-06-07T07:48:42.385Z" } } }, @@ -411,7 +414,8 @@ }, "samsungce.deviceIdentification": { "micomAssayCode": { - "value": null + "value": "00176141", + "timestamp": "2025-06-13T04:49:15.194Z" }, "modelName": { "value": null @@ -423,23 +427,26 @@ "value": null }, "modelClassificationCode": { - "value": null + "value": "0000083C031813294103010041030000", + "timestamp": "2025-06-13T04:49:15.194Z" }, "description": { - "value": null + "value": "TP1X_REF_21K", + "timestamp": "2025-06-13T04:49:15.194Z" }, "releaseYear": { - "value": null + "value": 24, + "timestamp": "2025-06-13T04:49:14.072Z" }, "binaryId": { "value": "TP1X_REF_21K", - "timestamp": "2025-03-23T21:53:15.900Z" + "timestamp": "2025-06-16T07:20:04.493Z" } }, "samsungce.quickControl": { "version": { "value": "1.0", - "timestamp": "2025-02-12T21:52:01.494Z" + "timestamp": "2025-05-25T02:26:25.302Z" } }, "custom.fridgeMode": { @@ -461,66 +468,65 @@ "value": null }, "mnfv": { - "value": "A-RFWW-TP1-22-REV1_20241030", - "timestamp": "2025-02-12T21:51:58.927Z" + "value": "A-RFWW-TP1-24-T4-COM_20250216", + "timestamp": "2025-04-12T15:30:22.827Z" }, "mnhw": { "value": "Realtek", - "timestamp": "2025-02-12T21:51:58.927Z" + "timestamp": "2025-04-12T15:30:22.827Z" }, "di": { - "value": "5758b2ec-563e-f39b-ec39-208e54aabf60", - "timestamp": "2025-02-12T21:51:58.927Z" + "value": "5ff1ef72-56ce-6559-4bd3-be42c31f3395", + "timestamp": "2025-04-12T15:30:22.827Z" }, "mnsl": { "value": "http://www.samsung.com", - "timestamp": "2025-02-12T21:51:58.927Z" + "timestamp": "2025-04-12T15:30:22.827Z" }, "dmv": { - "value": "1.2.1", - "timestamp": "2025-02-12T21:51:58.927Z" + "value": "res.1.1.0,sh.1.1.0", + "timestamp": "2025-04-12T15:30:22.827Z" }, "n": { "value": "Samsung-Refrigerator", - "timestamp": "2025-02-12T21:51:58.927Z" + "timestamp": "2025-04-12T15:30:22.827Z" }, "mnmo": { - "value": "TP1X_REF_21K|00156941|00050126001611304100000030010000", - "timestamp": "2025-02-12T21:51:58.927Z" + "value": "TP1X_REF_21K|00176141|0000083C031813294103010041030000", + "timestamp": "2025-04-12T15:30:22.827Z" }, "vid": { "value": "DA-REF-NORMAL-01011", - "timestamp": "2025-02-12T21:51:58.927Z" + "timestamp": "2025-04-12T15:30:22.827Z" }, "mnmn": { "value": "Samsung Electronics", - "timestamp": "2025-02-12T21:51:58.927Z" + "timestamp": "2025-04-12T15:30:22.827Z" }, "mnml": { "value": "http://www.samsung.com", - "timestamp": "2025-02-12T21:51:58.927Z" + "timestamp": "2025-04-12T15:30:22.827Z" }, "mnpv": { - "value": "DAWIT 2.0", - "timestamp": "2025-02-12T21:51:58.927Z" + "value": "SYSTEM 2.0", + "timestamp": "2025-04-12T15:30:22.827Z" }, "mnos": { - "value": "TizenRT 3.1", - "timestamp": "2025-02-12T21:51:58.927Z" + "value": "TizenRT 4.0", + "timestamp": "2025-04-12T15:30:22.827Z" }, "pi": { - "value": "5758b2ec-563e-f39b-ec39-208e54aabf60", - "timestamp": "2025-02-12T21:51:58.927Z" + "value": "5ff1ef72-56ce-6559-4bd3-be42c31f3395", + "timestamp": "2025-04-12T15:30:22.827Z" }, "icv": { "value": "core.1.1.0", - "timestamp": "2025-02-12T21:51:58.927Z" + "timestamp": "2025-04-12T15:30:22.827Z" } }, "samsungce.fridgeVacationMode": { "vacationMode": { - "value": "off", - "timestamp": "2024-12-01T18:22:19.337Z" + "value": null } }, "custom.disabledCapabilities": { @@ -530,56 +536,56 @@ "thermostatCoolingSetpoint", "custom.fridgeMode", "custom.deodorFilter", - "custom.waterFilter", "custom.dustFilter", "samsungce.viewInside", "samsungce.fridgeWelcomeLighting", - "samsungce.sabbathMode" + "sec.smartthingsHub", + "samsungce.fridgeVacationMode" ], - "timestamp": "2025-02-12T21:52:01.494Z" + "timestamp": "2025-03-31T03:05:25.793Z" } }, "samsungce.driverVersion": { "versionNumber": { - "value": 24090102, - "timestamp": "2024-12-01T18:22:19.337Z" + "value": 25040101, + "timestamp": "2025-06-13T04:49:16.828Z" } }, "sec.diagnosticsInformation": { "logType": { "value": ["errCode", "dump"], - "timestamp": "2024-12-01T18:22:19.337Z" + "timestamp": "2025-05-25T02:26:23.664Z" }, "endpoint": { "value": "SSM", - "timestamp": "2024-12-01T18:22:19.337Z" + "timestamp": "2025-05-25T02:26:23.664Z" }, "minVersion": { "value": "3.0", - "timestamp": "2025-02-12T21:52:00.460Z" + "timestamp": "2025-05-25T02:26:23.664Z" }, "signinPermission": { "value": null }, "setupId": { - "value": "RB0", - "timestamp": "2024-12-01T18:22:19.337Z" + "value": "RRD", + "timestamp": "2025-05-25T02:26:23.664Z" }, "protocolType": { "value": "ble_ocf", - "timestamp": "2025-02-12T21:52:00.460Z" + "timestamp": "2025-05-25T02:26:23.664Z" }, "tsId": { "value": "DA01", - "timestamp": "2025-02-12T21:52:00.460Z" + "timestamp": "2025-05-25T02:26:23.664Z" }, "mnId": { "value": "0AJT", - "timestamp": "2024-12-01T18:22:19.337Z" + "timestamp": "2025-05-25T02:26:23.664Z" }, "dumpType": { "value": "file", - "timestamp": "2024-12-01T18:22:19.337Z" + "timestamp": "2025-05-25T02:26:23.664Z" } }, "temperatureMeasurement": { @@ -598,11 +604,11 @@ "value": { "state": "disabled" }, - "timestamp": "2024-12-01T18:22:19.337Z" + "timestamp": "2025-05-25T02:26:23.815Z" }, "reportStatePeriod": { "value": "enabled", - "timestamp": "2024-12-01T18:22:19.337Z" + "timestamp": "2025-05-25T02:26:23.815Z" } }, "thermostatCoolingSetpoint": { @@ -616,8 +622,6 @@ "custom.disabledComponents": { "disabledComponents": { "value": [ - "icemaker", - "icemaker-02", "icemaker-03", "pantry-01", "pantry-02", @@ -626,7 +630,7 @@ "cvroom", "onedoor" ], - "timestamp": "2024-12-01T18:22:19.337Z" + "timestamp": "2024-12-19T19:47:51.861Z" } }, "demandResponseLoadControl": { @@ -637,31 +641,33 @@ "duration": 0, "override": false }, - "timestamp": "2024-12-01T18:22:19.337Z" + "timestamp": "2025-05-25T02:26:23.225Z" } }, "samsungce.sabbathMode": { "supportedActions": { - "value": null + "value": ["on", "off"], + "timestamp": "2025-05-25T02:26:23.696Z" }, "status": { - "value": null + "value": "off", + "timestamp": "2025-05-25T02:26:23.696Z" } }, "powerConsumptionReport": { "powerConsumption": { "value": { - "energy": 66571, - "deltaEnergy": 19, - "power": 61, - "powerEnergy": 18.91178222020467, + "energy": 229226, + "deltaEnergy": 10, + "power": 17, + "powerEnergy": 14.351180554098551, "persistedEnergy": 0, "energySaved": 0, "persistedSavedEnergy": 0, - "start": "2025-03-30T18:21:37Z", - "end": "2025-03-30T18:38:18Z" + "start": "2025-06-16T16:30:09Z", + "end": "2025-06-16T16:45:48Z" }, - "timestamp": "2025-03-30T18:38:18.219Z" + "timestamp": "2025-06-16T16:45:48.369Z" } }, "refresh": {}, @@ -673,44 +679,63 @@ "sec.wifiConfiguration": { "autoReconnection": { "value": true, - "timestamp": "2024-12-01T18:22:19.337Z" + "timestamp": "2025-05-25T02:26:25.567Z" }, "minVersion": { "value": "1.0", - "timestamp": "2024-12-01T18:22:19.337Z" + "timestamp": "2025-05-25T02:26:25.567Z" }, "supportedWiFiFreq": { "value": ["2.4G"], - "timestamp": "2024-12-01T18:22:19.331Z" + "timestamp": "2025-05-25T02:26:25.567Z" }, "supportedAuthType": { "value": ["OPEN", "WEP", "WPA-PSK", "WPA2-PSK", "SAE"], - "timestamp": "2024-12-01T18:22:19.331Z" + "timestamp": "2025-05-25T02:26:25.567Z" }, "protocolType": { - "value": ["helper_hotspot"], - "timestamp": "2024-12-01T18:22:19.331Z" + "value": ["helper_hotspot", "ble_ocf"], + "timestamp": "2025-05-25T02:26:25.567Z" } }, "samsungce.selfCheck": { "result": { "value": "passed", - "timestamp": "2024-12-01T18:22:19.337Z" + "timestamp": "2025-05-25T02:26:23.843Z" }, "supportedActions": { "value": ["start"], - "timestamp": "2024-12-01T18:22:19.337Z" + "timestamp": "2025-05-25T02:26:23.843Z" }, "progress": { "value": null }, "errors": { "value": [], - "timestamp": "2024-12-01T18:22:19.337Z" + "timestamp": "2025-05-25T02:26:23.843Z" }, "status": { "value": "ready", - "timestamp": "2024-12-01T18:22:19.337Z" + "timestamp": "2025-05-25T02:26:23.843Z" + } + }, + "samsungce.softwareVersion": { + "versions": { + "value": [ + { + "id": "0", + "swType": "Software", + "versionNumber": "250216", + "description": "WiFi Module" + }, + { + "id": "1", + "swType": "Firmware", + "versionNumber": "24120326, 24030400, 24061400, FFFFFFFF", + "description": "Micom" + } + ], + "timestamp": "2025-05-25T02:26:23.664Z" } }, "custom.dustFilter": { @@ -735,15 +760,16 @@ }, "refrigeration": { "defrost": { - "value": null + "value": "off", + "timestamp": "2025-05-25T02:26:22.999Z" }, "rapidCooling": { "value": "off", - "timestamp": "2024-12-01T18:22:19.337Z" + "timestamp": "2025-05-25T02:26:23.827Z" }, "rapidFreezing": { "value": "off", - "timestamp": "2024-12-01T18:22:19.337Z" + "timestamp": "2025-05-25T06:58:12.005Z" } }, "custom.deodorFilter": { @@ -769,88 +795,134 @@ "samsungce.powerCool": { "activated": { "value": false, - "timestamp": "2024-12-01T18:22:19.337Z" + "timestamp": "2025-05-25T02:26:23.827Z" } }, "custom.energyType": { "energyType": { "value": "2.0", - "timestamp": "2024-12-01T18:22:19.337Z" + "timestamp": "2024-12-19T19:47:51.861Z" }, "energySavingSupport": { "value": true, - "timestamp": "2025-03-06T23:10:37.429Z" + "timestamp": "2025-05-23T06:02:34.025Z" }, "drMaxDuration": { "value": 99999999, "unit": "min", - "timestamp": "2024-12-01T18:22:20.756Z" + "timestamp": "2024-12-19T19:47:54.446Z" }, "energySavingLevel": { "value": 1, - "timestamp": "2024-12-01T18:22:19.337Z" + "timestamp": "2024-12-19T19:47:51.861Z" }, "energySavingInfo": { "value": null }, "supportedEnergySavingLevels": { - "value": [1, 2], - "timestamp": "2024-12-01T18:22:19.337Z" + "value": [1], + "timestamp": "2024-12-19T19:47:51.861Z" }, "energySavingOperation": { "value": false, - "timestamp": "2024-12-01T18:22:19.337Z" + "timestamp": "2025-05-25T02:26:23.225Z" }, "notificationTemplateID": { "value": null }, "energySavingOperationSupport": { "value": true, - "timestamp": "2024-12-01T18:22:19.337Z" + "timestamp": "2024-12-19T19:47:51.861Z" } }, "samsungce.softwareUpdate": { "targetModule": { "value": {}, - "timestamp": "2024-12-01T18:55:10.062Z" + "timestamp": "2025-05-25T02:26:23.686Z" }, "otnDUID": { - "value": "MTCB2ZD4B6BT4", - "timestamp": "2024-12-01T18:22:19.337Z" + "value": "XTCB2ZD4CVZDG", + "timestamp": "2025-05-25T02:26:23.664Z" }, "lastUpdatedDate": { "value": null }, "availableModules": { "value": [], - "timestamp": "2024-12-01T18:22:19.337Z" + "timestamp": "2025-05-25T02:26:23.664Z" }, "newVersionAvailable": { "value": false, - "timestamp": "2024-12-01T18:22:19.337Z" + "timestamp": "2025-05-25T02:26:23.664Z" }, "operatingState": { "value": "none", - "timestamp": "2024-12-01T18:28:40.492Z" + "timestamp": "2025-05-25T02:26:23.686Z" }, "progress": { "value": 0, "unit": "%", - "timestamp": "2024-12-01T18:43:42.645Z" + "timestamp": "2025-05-25T02:26:23.686Z" } }, "samsungce.powerFreeze": { "activated": { "value": false, - "timestamp": "2024-12-01T18:22:19.337Z" + "timestamp": "2025-05-25T06:58:12.005Z" + } + }, + "sec.smartthingsHub": { + "threadHardwareAvailability": { + "value": null + }, + "availability": { + "value": null + }, + "deviceId": { + "value": null + }, + "zigbeeHardwareAvailability": { + "value": null + }, + "version": { + "value": null + }, + "threadRequiresExternalHardware": { + "value": null + }, + "zigbeeRequiresExternalHardware": { + "value": null + }, + "eui": { + "value": null + }, + "lastOnboardingResult": { + "value": null + }, + "zwaveHardwareAvailability": { + "value": null + }, + "zwaveRequiresExternalHardware": { + "value": null + }, + "state": { + "value": null + }, + "onboardingProgress": { + "value": null + }, + "lastOnboardingErrorCode": { + "value": null } }, "custom.waterFilter": { "waterFilterUsageStep": { - "value": null + "value": 1, + "timestamp": "2025-05-25T02:26:23.401Z" }, "waterFilterResetType": { - "value": null + "value": ["replaceable"], + "timestamp": "2025-05-25T02:26:23.401Z" }, "waterFilterCapacity": { "value": null @@ -859,10 +931,12 @@ "value": null }, "waterFilterUsage": { - "value": null + "value": 97, + "timestamp": "2025-06-16T13:02:17.608Z" }, "waterFilterStatus": { - "value": null + "value": "normal", + "timestamp": "2025-05-25T02:26:23.401Z" } } }, @@ -872,10 +946,18 @@ "value": null }, "fridgeMode": { - "value": null + "value": "CV_TTYPE_RF9000A_FRUIT_VEGGIES", + "timestamp": "2025-05-25T02:26:23.578Z" }, "supportedFridgeModes": { - "value": null + "value": [ + "CV_TTYPE_RF9000A_FREEZE", + "CV_TTYPE_RF9000A_SOFTFREEZE", + "CV_TTYPE_RF9000A_MEAT_FISH", + "CV_TTYPE_RF9000A_FRUIT_VEGGIES", + "CV_TTYPE_RF9000A_BEVERAGE" + ], + "timestamp": "2025-05-25T02:26:23.578Z" } }, "contactSensor": { @@ -908,12 +990,14 @@ "icemaker-02": { "custom.disabledCapabilities": { "disabledCapabilities": { - "value": null + "value": [], + "timestamp": "2024-12-19T19:47:51.861Z" } }, "switch": { "switch": { - "value": null + "value": "on", + "timestamp": "2025-06-16T14:00:28.428Z" } } }, diff --git a/tests/components/smartthings/fixtures/devices/da_ref_normal_01011.json b/tests/components/smartthings/fixtures/devices/da_ref_normal_01011.json index 9be5db0bda9..2cde305ca3d 100644 --- a/tests/components/smartthings/fixtures/devices/da_ref_normal_01011.json +++ b/tests/components/smartthings/fixtures/devices/da_ref_normal_01011.json @@ -128,6 +128,10 @@ "id": "samsungce.quickControl", "version": 1 }, + { + "id": "samsungce.softwareVersion", + "version": 1 + }, { "id": "sec.diagnosticsInformation", "version": 1 @@ -135,6 +139,11 @@ { "id": "sec.wifiConfiguration", "version": 1 + }, + { + "id": "sec.smartthingsHub", + "version": 1, + "ephemeral": true } ], "categories": [ @@ -142,7 +151,8 @@ "name": "Refrigerator", "categoryType": "manufacturer" } - ] + ], + "optional": false }, { "id": "freezer", @@ -190,7 +200,8 @@ "name": "Other", "categoryType": "manufacturer" } - ] + ], + "optional": false }, { "id": "cooler", @@ -234,7 +245,8 @@ "name": "Other", "categoryType": "manufacturer" } - ] + ], + "optional": false }, { "id": "cvroom", @@ -266,7 +278,8 @@ "name": "Other", "categoryType": "manufacturer" } - ] + ], + "optional": false }, { "id": "onedoor", @@ -314,7 +327,8 @@ "name": "Other", "categoryType": "manufacturer" } - ] + ], + "optional": false }, { "id": "icemaker", @@ -334,7 +348,8 @@ "name": "Other", "categoryType": "manufacturer" } - ] + ], + "optional": false }, { "id": "icemaker-02", @@ -354,7 +369,8 @@ "name": "Other", "categoryType": "manufacturer" } - ] + ], + "optional": false }, { "id": "icemaker-03", @@ -374,7 +390,8 @@ "name": "Other", "categoryType": "manufacturer" } - ] + ], + "optional": false }, { "id": "scale-10", @@ -402,7 +419,8 @@ "name": "Other", "categoryType": "manufacturer" } - ] + ], + "optional": false }, { "id": "scale-11", @@ -422,7 +440,8 @@ "name": "Other", "categoryType": "manufacturer" } - ] + ], + "optional": false }, { "id": "pantry-01", @@ -454,7 +473,8 @@ "name": "Other", "categoryType": "manufacturer" } - ] + ], + "optional": false }, { "id": "pantry-02", @@ -486,7 +506,8 @@ "name": "Other", "categoryType": "manufacturer" } - ] + ], + "optional": false } ], "createTime": "2024-12-01T18:22:14.880Z", diff --git a/tests/components/smartthings/snapshots/test_button.ambr b/tests/components/smartthings/snapshots/test_button.ambr index ad8e0ff276b..a49aad2f897 100644 --- a/tests/components/smartthings/snapshots/test_button.ambr +++ b/tests/components/smartthings/snapshots/test_button.ambr @@ -239,3 +239,51 @@ 'state': 'unknown', }) # --- +# name: test_all_entities[da_ref_normal_01011][button.frigo_reset_water_filter-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'button', + 'entity_category': None, + 'entity_id': 'button.frigo_reset_water_filter', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Reset water filter', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'reset_water_filter', + 'unique_id': '5758b2ec-563e-f39b-ec39-208e54aabf60_main_custom.waterFilter_resetWaterFilter', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[da_ref_normal_01011][button.frigo_reset_water_filter-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Frigo Reset water filter', + }), + 'context': , + 'entity_id': 'button.frigo_reset_water_filter', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- diff --git a/tests/components/smartthings/snapshots/test_number.ambr b/tests/components/smartthings/snapshots/test_number.ambr index e02b2ecc9b4..b9af2605f1d 100644 --- a/tests/components/smartthings/snapshots/test_number.ambr +++ b/tests/components/smartthings/snapshots/test_number.ambr @@ -298,8 +298,8 @@ }), 'area_id': None, 'capabilities': dict({ - 'max': -15, - 'min': -23, + 'max': -15.0, + 'min': -23.0, 'mode': , 'step': 1, }), @@ -337,8 +337,8 @@ 'attributes': ReadOnlyDict({ 'device_class': 'temperature', 'friendly_name': 'Frigo Freezer temperature', - 'max': -15, - 'min': -23, + 'max': -15.0, + 'min': -23.0, 'mode': , 'step': 1, 'unit_of_measurement': , @@ -348,7 +348,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '-17', + 'state': '-22.0', }) # --- # name: test_all_entities[da_ref_normal_01011][number.frigo_fridge_temperature-entry] @@ -357,8 +357,8 @@ }), 'area_id': None, 'capabilities': dict({ - 'max': 7, - 'min': 1, + 'max': 7.0, + 'min': 1.0, 'mode': , 'step': 1, }), @@ -396,8 +396,8 @@ 'attributes': ReadOnlyDict({ 'device_class': 'temperature', 'friendly_name': 'Frigo Fridge temperature', - 'max': 7, - 'min': 1, + 'max': 7.0, + 'min': 1.0, 'mode': , 'step': 1, 'unit_of_measurement': , @@ -407,7 +407,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '6', + 'state': '2.0', }) # --- # name: test_all_entities[da_wm_wm_000001][number.washer_rinse_cycles-entry] diff --git a/tests/components/smartthings/snapshots/test_sensor.ambr b/tests/components/smartthings/snapshots/test_sensor.ambr index e85ec4620e9..40180b88bca 100644 --- a/tests/components/smartthings/snapshots/test_sensor.ambr +++ b/tests/components/smartthings/snapshots/test_sensor.ambr @@ -5569,7 +5569,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '66.571', + 'state': '229.226', }) # --- # name: test_all_entities[da_ref_normal_01011][sensor.frigo_energy_difference-entry] @@ -5625,7 +5625,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '0.019', + 'state': '0.01', }) # --- # name: test_all_entities[da_ref_normal_01011][sensor.frigo_energy_saved-entry] @@ -5737,7 +5737,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '-17', + 'state': '-22.2222222222222', }) # --- # name: test_all_entities[da_ref_normal_01011][sensor.frigo_fridge_temperature-entry] @@ -5793,7 +5793,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '6', + 'state': '2.22222222222222', }) # --- # name: test_all_entities[da_ref_normal_01011][sensor.frigo_power-entry] @@ -5841,8 +5841,8 @@ 'attributes': ReadOnlyDict({ 'device_class': 'power', 'friendly_name': 'Frigo Power', - 'power_consumption_end': '2025-03-30T18:38:18Z', - 'power_consumption_start': '2025-03-30T18:21:37Z', + 'power_consumption_end': '2025-06-16T16:45:48Z', + 'power_consumption_start': '2025-06-16T16:30:09Z', 'state_class': , 'unit_of_measurement': , }), @@ -5851,7 +5851,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '61', + 'state': '17', }) # --- # name: test_all_entities[da_ref_normal_01011][sensor.frigo_power_energy-entry] @@ -5907,7 +5907,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '0.0189117822202047', + 'state': '0.0143511805540986', }) # --- # name: test_all_entities[da_rvc_normal_000001][sensor.robot_vacuum_battery-entry] diff --git a/tests/components/smartthings/snapshots/test_switch.ambr b/tests/components/smartthings/snapshots/test_switch.ambr index 1323230e7ea..d0ea3dbcdad 100644 --- a/tests/components/smartthings/snapshots/test_switch.ambr +++ b/tests/components/smartthings/snapshots/test_switch.ambr @@ -47,7 +47,7 @@ 'state': 'on', }) # --- -# name: test_all_entities[da_ref_normal_000001][switch.refrigerator_ice_maker-entry] +# name: test_all_entities[da_ref_normal_000001][switch.refrigerator_cubed_ice-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -60,7 +60,7 @@ 'disabled_by': None, 'domain': 'switch', 'entity_category': None, - 'entity_id': 'switch.refrigerator_ice_maker', + 'entity_id': 'switch.refrigerator_cubed_ice', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -72,7 +72,7 @@ }), 'original_device_class': None, 'original_icon': None, - 'original_name': 'Ice maker', + 'original_name': 'Cubed ice', 'platform': 'smartthings', 'previous_unique_id': None, 'suggested_object_id': None, @@ -82,13 +82,13 @@ 'unit_of_measurement': None, }) # --- -# name: test_all_entities[da_ref_normal_000001][switch.refrigerator_ice_maker-state] +# name: test_all_entities[da_ref_normal_000001][switch.refrigerator_cubed_ice-state] StateSnapshot({ 'attributes': ReadOnlyDict({ - 'friendly_name': 'Refrigerator Ice maker', + 'friendly_name': 'Refrigerator Cubed ice', }), 'context': , - 'entity_id': 'switch.refrigerator_ice_maker', + 'entity_id': 'switch.refrigerator_cubed_ice', 'last_changed': , 'last_reported': , 'last_updated': , @@ -239,7 +239,7 @@ 'state': 'off', }) # --- -# name: test_all_entities[da_ref_normal_01001][switch.refrigerator_ice_maker-entry] +# name: test_all_entities[da_ref_normal_01001][switch.refrigerator_cubed_ice-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -252,7 +252,7 @@ 'disabled_by': None, 'domain': 'switch', 'entity_category': None, - 'entity_id': 'switch.refrigerator_ice_maker', + 'entity_id': 'switch.refrigerator_cubed_ice', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -264,7 +264,7 @@ }), 'original_device_class': None, 'original_icon': None, - 'original_name': 'Ice maker', + 'original_name': 'Cubed ice', 'platform': 'smartthings', 'previous_unique_id': None, 'suggested_object_id': None, @@ -274,13 +274,13 @@ 'unit_of_measurement': None, }) # --- -# name: test_all_entities[da_ref_normal_01001][switch.refrigerator_ice_maker-state] +# name: test_all_entities[da_ref_normal_01001][switch.refrigerator_cubed_ice-state] StateSnapshot({ 'attributes': ReadOnlyDict({ - 'friendly_name': 'Refrigerator Ice maker', + 'friendly_name': 'Refrigerator Cubed ice', }), 'context': , - 'entity_id': 'switch.refrigerator_ice_maker', + 'entity_id': 'switch.refrigerator_cubed_ice', 'last_changed': , 'last_reported': , 'last_updated': , @@ -383,6 +383,102 @@ 'state': 'off', }) # --- +# name: test_all_entities[da_ref_normal_01011][switch.frigo_cubed_ice-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': None, + 'entity_id': 'switch.frigo_cubed_ice', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Cubed ice', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'ice_maker', + 'unique_id': '5758b2ec-563e-f39b-ec39-208e54aabf60_icemaker_switch_switch_switch', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[da_ref_normal_01011][switch.frigo_cubed_ice-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Frigo Cubed ice', + }), + 'context': , + 'entity_id': 'switch.frigo_cubed_ice', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_all_entities[da_ref_normal_01011][switch.frigo_ice_bites-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': None, + 'entity_id': 'switch.frigo_ice_bites', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Ice Bites', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'ice_maker_2', + 'unique_id': '5758b2ec-563e-f39b-ec39-208e54aabf60_icemaker-02_switch_switch_switch', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[da_ref_normal_01011][switch.frigo_ice_bites-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Frigo Ice Bites', + }), + 'context': , + 'entity_id': 'switch.frigo_ice_bites', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- # name: test_all_entities[da_ref_normal_01011][switch.frigo_power_cool-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -479,6 +575,54 @@ 'state': 'off', }) # --- +# name: test_all_entities[da_ref_normal_01011][switch.frigo_sabbath_mode-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': , + 'entity_id': 'switch.frigo_sabbath_mode', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Sabbath mode', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'sabbath_mode', + 'unique_id': '5758b2ec-563e-f39b-ec39-208e54aabf60_main_samsungce.sabbathMode_status_status', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[da_ref_normal_01011][switch.frigo_sabbath_mode-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Frigo Sabbath mode', + }), + 'context': , + 'entity_id': 'switch.frigo_sabbath_mode', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- # name: test_all_entities[da_rvc_normal_000001][switch.robot_vacuum-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ diff --git a/tests/components/sonos/conftest.py b/tests/components/sonos/conftest.py index 4994d36f1bf..d121d5a4a12 100644 --- a/tests/components/sonos/conftest.py +++ b/tests/components/sonos/conftest.py @@ -85,6 +85,15 @@ class SonosMockService: self.subscribe = AsyncMock(return_value=SonosMockSubscribe(ip_address)) +class SonosMockRenderingService(SonosMockService): + """Mock rendering service.""" + + def __init__(self, return_value: dict[str, str], ip_address="192.168.42.2") -> None: + """Initialize the instance.""" + super().__init__("RenderingControl", ip_address) + self.GetVolume = Mock(return_value=30) + + class SonosMockAlarmClock(SonosMockService): """Mock a Sonos AlarmClock Service used in callbacks.""" @@ -239,7 +248,7 @@ class SoCoMockFactory: mock_soco.avTransport.GetPositionInfo = Mock( return_value=self.current_track_info ) - mock_soco.renderingControl = SonosMockService("RenderingControl", ip_address) + mock_soco.renderingControl = SonosMockRenderingService(ip_address) mock_soco.zoneGroupTopology = SonosMockService("ZoneGroupTopology", ip_address) mock_soco.contentDirectory = SonosMockService("ContentDirectory", ip_address) mock_soco.deviceProperties = SonosMockService("DeviceProperties", ip_address) diff --git a/tests/components/sonos/test_init.py b/tests/components/sonos/test_init.py index c6be606eb20..1bc8baff752 100644 --- a/tests/components/sonos/test_init.py +++ b/tests/components/sonos/test_init.py @@ -3,15 +3,15 @@ import asyncio from datetime import timedelta import logging -from unittest.mock import Mock, patch +from unittest.mock import Mock, PropertyMock, patch +from freezegun.api import FrozenDateTimeFactory import pytest from homeassistant import config_entries from homeassistant.components import sonos -from homeassistant.components.sonos import SonosDiscoveryManager from homeassistant.components.sonos.const import ( - DATA_SONOS_DISCOVERY_MANAGER, + DISCOVERY_INTERVAL, SONOS_SPEAKER_ACTIVITY, ) from homeassistant.components.sonos.exception import SonosUpdateError @@ -87,76 +87,73 @@ async def test_not_configuring_sonos_not_creates_entry(hass: HomeAssistant) -> N async def test_async_poll_manual_hosts_warnings( - hass: HomeAssistant, caplog: pytest.LogCaptureFixture + hass: HomeAssistant, + caplog: pytest.LogCaptureFixture, + soco_factory: SoCoMockFactory, + freezer: FrozenDateTimeFactory, ) -> None: """Test that host warnings are not logged repeatedly.""" - await async_setup_component( - hass, - sonos.DOMAIN, - {"sonos": {"media_player": {"interface_addr": "127.0.0.1"}}}, - ) - await hass.async_block_till_done() - manager: SonosDiscoveryManager = hass.data[DATA_SONOS_DISCOVERY_MANAGER] - manager.hosts.add("10.10.10.10") + + soco = soco_factory.cache_mock(MockSoCo(), "10.10.10.1", "Bedroom") with ( caplog.at_level(logging.DEBUG), - patch.object(manager, "_async_handle_discovery_message"), - patch( - "homeassistant.components.sonos.async_call_later" - ) as mock_async_call_later, - patch("homeassistant.components.sonos.async_dispatcher_send"), - patch( - "homeassistant.components.sonos.sync_get_visible_zones", - side_effect=[ - OSError(), - OSError(), - [], - [], - OSError(), - ], - ), + patch.object( + type(soco), "visible_zones", new_callable=PropertyMock + ) as mock_visible_zones, ): # First call fails, it should be logged as a WARNING message + mock_visible_zones.side_effect = OSError() caplog.clear() - await manager.async_poll_manual_hosts() - assert len(caplog.messages) == 1 - record = caplog.records[0] - assert record.levelname == "WARNING" - assert "Could not get visible Sonos devices from" in record.message - assert mock_async_call_later.call_count == 1 + await _setup_hass(hass) + assert [ + rec.levelname + for rec in caplog.records + if "Could not get visible Sonos devices from" in rec.message + ] == ["WARNING"] # Second call fails again, it should be logged as a DEBUG message + mock_visible_zones.side_effect = OSError() caplog.clear() - await manager.async_poll_manual_hosts() - assert len(caplog.messages) == 1 - record = caplog.records[0] - assert record.levelname == "DEBUG" - assert "Could not get visible Sonos devices from" in record.message - assert mock_async_call_later.call_count == 2 + freezer.tick(DISCOVERY_INTERVAL) + async_fire_time_changed(hass) + await hass.async_block_till_done(wait_background_tasks=True) + assert [ + rec.levelname + for rec in caplog.records + if "Could not get visible Sonos devices from" in rec.message + ] == ["DEBUG"] - # Third call succeeds, it should log an info message + # Third call succeeds, logs message indicating reconnect + mock_visible_zones.return_value = {soco} + mock_visible_zones.side_effect = None caplog.clear() - await manager.async_poll_manual_hosts() - assert len(caplog.messages) == 1 - record = caplog.records[0] - assert record.levelname == "WARNING" - assert "Connection reestablished to Sonos device" in record.message - assert mock_async_call_later.call_count == 3 + freezer.tick(DISCOVERY_INTERVAL) + async_fire_time_changed(hass) + await hass.async_block_till_done(wait_background_tasks=True) + assert [ + rec.levelname + for rec in caplog.records + if "Connection reestablished to Sonos device" in rec.message + ] == ["WARNING"] - # Fourth call succeeds again, no need to log + # Fourth call succeeds, it should log nothing caplog.clear() - await manager.async_poll_manual_hosts() - assert len(caplog.messages) == 0 - assert mock_async_call_later.call_count == 4 + freezer.tick(DISCOVERY_INTERVAL) + async_fire_time_changed(hass) + await hass.async_block_till_done(wait_background_tasks=True) + assert "Connection reestablished to Sonos device" not in caplog.text - # Fifth call fail again again, should be logged as a WARNING message + # Fifth call fails again again, should be logged as a WARNING message + mock_visible_zones.side_effect = OSError() caplog.clear() - await manager.async_poll_manual_hosts() - assert len(caplog.messages) == 1 - record = caplog.records[0] - assert record.levelname == "WARNING" - assert "Could not get visible Sonos devices from" in record.message - assert mock_async_call_later.call_count == 5 + freezer.tick(DISCOVERY_INTERVAL) + async_fire_time_changed(hass) + await hass.async_block_till_done() + assert [ + rec.levelname + for rec in caplog.records + if "Could not get visible Sonos devices from" in rec.message + ] == ["WARNING"] class _MockSoCoOsError(MockSoCo): diff --git a/tests/components/telegram_bot/test_config_flow.py b/tests/components/telegram_bot/test_config_flow.py index 47b6d99b9ce..0287ccc5dfa 100644 --- a/tests/components/telegram_bot/test_config_flow.py +++ b/tests/components/telegram_bot/test_config_flow.py @@ -1,6 +1,6 @@ """Config flow tests for the Telegram Bot integration.""" -from unittest.mock import patch +from unittest.mock import AsyncMock, patch from telegram import ChatFullInfo, User from telegram.constants import AccentColor @@ -305,10 +305,19 @@ async def test_reauth_flow( # test: valid - with patch( - "homeassistant.components.telegram_bot.config_flow.Bot.get_me", - return_value=User(123456, "Testbot", True), + with ( + patch( + "homeassistant.components.telegram_bot.config_flow.Bot.get_me", + return_value=User(123456, "Testbot", True), + ), + patch( + "homeassistant.components.telegram_bot.webhooks.PushBot", + ) as mock_pushbot, ): + mock_pushbot.return_value.start_application = AsyncMock() + mock_pushbot.return_value.register_webhook = AsyncMock() + mock_pushbot.return_value.shutdown = AsyncMock() + result = await hass.config_entries.flow.async_configure( result["flow_id"], {CONF_API_KEY: "new mock api key"}, diff --git a/tests/components/telegram_bot/test_telegram_bot.py b/tests/components/telegram_bot/test_telegram_bot.py index d276d72c8a6..24b6deb27b5 100644 --- a/tests/components/telegram_bot/test_telegram_bot.py +++ b/tests/components/telegram_bot/test_telegram_bot.py @@ -30,6 +30,7 @@ from homeassistant.components.telegram_bot import ( ATTR_OPTIONS, ATTR_PASSWORD, ATTR_QUESTION, + ATTR_SHOW_ALERT, ATTR_STICKER_ID, ATTR_TARGET, ATTR_URL, @@ -752,20 +753,27 @@ async def test_answer_callback_query( await hass.async_block_till_done() with patch( - "homeassistant.components.telegram_bot.bot.TelegramNotificationService.answer_callback_query" + "homeassistant.components.telegram_bot.bot.Bot.answer_callback_query" ) as mock: await hass.services.async_call( DOMAIN, SERVICE_ANSWER_CALLBACK_QUERY, { ATTR_MESSAGE: "mock message", - ATTR_CALLBACK_QUERY_ID: 12345, + ATTR_CALLBACK_QUERY_ID: 123456, + ATTR_SHOW_ALERT: True, }, blocking=True, ) await hass.async_block_till_done() mock.assert_called_once() + mock.assert_called_with( + 123456, + text="mock message", + show_alert=True, + read_timeout=None, + ) async def test_leave_chat( @@ -779,20 +787,23 @@ async def test_leave_chat( await hass.async_block_till_done() with patch( - "homeassistant.components.telegram_bot.bot.TelegramNotificationService.leave_chat", + "homeassistant.components.telegram_bot.bot.Bot.leave_chat", AsyncMock(return_value=True), ) as mock: await hass.services.async_call( DOMAIN, SERVICE_LEAVE_CHAT, { - ATTR_CHAT_ID: 12345, + ATTR_CHAT_ID: 123456, }, blocking=True, ) await hass.async_block_till_done() mock.assert_called_once() + mock.assert_called_with( + 123456, + ) async def test_send_video( @@ -974,3 +985,39 @@ async def test_send_video( await hass.async_block_till_done() assert mock_get.call_count > 0 assert response["chats"][0]["message_id"] == 12345 + + +async def test_set_message_reaction( + hass: HomeAssistant, + mock_broadcast_config_entry: MockConfigEntry, + mock_external_calls: None, +) -> None: + """Test set message reaction.""" + mock_broadcast_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_broadcast_config_entry.entry_id) + await hass.async_block_till_done() + + with patch( + "homeassistant.components.telegram_bot.bot.Bot.set_message_reaction", + AsyncMock(return_value=True), + ) as mock: + await hass.services.async_call( + DOMAIN, + "set_message_reaction", + { + ATTR_CHAT_ID: 123456, + ATTR_MESSAGEID: 54321, + "reaction": "👍", + "is_big": True, + }, + blocking=True, + ) + + await hass.async_block_till_done() + mock.assert_called_once_with( + 123456, + 54321, + reaction="👍", + is_big=True, + read_timeout=None, + ) diff --git a/tests/components/template/test_blueprint.py b/tests/components/template/test_blueprint.py index 312c04b670c..fd45c3b008b 100644 --- a/tests/components/template/test_blueprint.py +++ b/tests/components/template/test_blueprint.py @@ -16,6 +16,22 @@ from homeassistant.components.blueprint import ( DomainBlueprints, ) from homeassistant.components.template import DOMAIN, SERVICE_RELOAD +from homeassistant.components.template.config import ( + DOMAIN_ALARM_CONTROL_PANEL, + DOMAIN_BINARY_SENSOR, + DOMAIN_COVER, + DOMAIN_FAN, + DOMAIN_IMAGE, + DOMAIN_LIGHT, + DOMAIN_LOCK, + DOMAIN_NUMBER, + DOMAIN_SELECT, + DOMAIN_SENSOR, + DOMAIN_SWITCH, + DOMAIN_VACUUM, + DOMAIN_WEATHER, +) +from homeassistant.const import STATE_ON from homeassistant.core import Context, HomeAssistant, callback from homeassistant.helpers import device_registry as dr from homeassistant.setup import async_setup_component @@ -459,3 +475,51 @@ async def test_no_blueprint(hass: HomeAssistant) -> None: template.helpers.blueprint_in_template(hass, "binary_sensor.test_entity") is None ) + + +@pytest.mark.parametrize( + ("domain", "set_state", "expected"), + [ + (DOMAIN_ALARM_CONTROL_PANEL, STATE_ON, "armed_home"), + (DOMAIN_BINARY_SENSOR, STATE_ON, STATE_ON), + (DOMAIN_COVER, STATE_ON, "open"), + (DOMAIN_FAN, STATE_ON, STATE_ON), + (DOMAIN_IMAGE, "test.jpg", "2025-06-13T00:00:00+00:00"), + (DOMAIN_LIGHT, STATE_ON, STATE_ON), + (DOMAIN_LOCK, STATE_ON, "locked"), + (DOMAIN_NUMBER, "1", "1.0"), + (DOMAIN_SELECT, "option1", "option1"), + (DOMAIN_SENSOR, "foo", "foo"), + (DOMAIN_SWITCH, STATE_ON, STATE_ON), + (DOMAIN_VACUUM, "cleaning", "cleaning"), + (DOMAIN_WEATHER, "sunny", "sunny"), + ], +) +@pytest.mark.freeze_time("2025-06-13 00:00:00+00:00") +async def test_variables_for_entity( + hass: HomeAssistant, domain: str, set_state: str, expected: str +) -> None: + """Test regular template entities via blueprint with variables defined.""" + hass.states.async_set("sensor.test_state", set_state) + await hass.async_block_till_done() + + assert await async_setup_component( + hass, + "template", + { + "template": [ + { + "use_blueprint": { + "path": f"test_{domain}_with_variables.yaml", + "input": {"sensor": "sensor.test_state"}, + }, + "name": "Test", + }, + ] + }, + ) + await hass.async_block_till_done() + + state = hass.states.get(f"{domain}.test") + assert state is not None + assert state.state == expected diff --git a/tests/components/template/test_button.py b/tests/components/template/test_button.py index 31239dbaf92..77d316ce89d 100644 --- a/tests/components/template/test_button.py +++ b/tests/components/template/test_button.py @@ -11,7 +11,10 @@ from homeassistant import setup from homeassistant.components.button import DOMAIN as BUTTON_DOMAIN, SERVICE_PRESS from homeassistant.components.template import DOMAIN from homeassistant.components.template.button import DEFAULT_NAME +from homeassistant.components.template.const import CONF_PICTURE from homeassistant.const import ( + ATTR_ENTITY_PICTURE, + ATTR_ICON, CONF_DEVICE_CLASS, CONF_ENTITY_ID, CONF_FRIENDLY_NAME, @@ -247,6 +250,49 @@ async def test_name_template(hass: HomeAssistant) -> None: ) +@pytest.mark.parametrize( + ("field", "attribute", "test_template", "expected"), + [ + (CONF_ICON, ATTR_ICON, "mdi:test{{ 1 + 1 }}", "mdi:test2"), + (CONF_PICTURE, ATTR_ENTITY_PICTURE, "test{{ 1 + 1 }}.jpg", "test2.jpg"), + ], +) +async def test_templated_optional_config( + hass: HomeAssistant, + field: str, + attribute: str, + test_template: str, + expected: str, +) -> None: + """Test optional config templates.""" + with assert_setup_component(1, "template"): + assert await setup.async_setup_component( + hass, + "template", + { + "template": { + "button": { + "press": {"service": "script.press"}, + field: test_template, + }, + } + }, + ) + + await hass.async_block_till_done() + await hass.async_start() + await hass.async_block_till_done() + + _verify( + hass, + STATE_UNKNOWN, + { + attribute: expected, + }, + "button.template_button", + ) + + async def test_unique_id(hass: HomeAssistant) -> None: """Test: unique id is ok.""" with assert_setup_component(1, "template"): diff --git a/tests/components/template/test_number.py b/tests/components/template/test_number.py index 5201541e2e0..a15ae1e46c0 100644 --- a/tests/components/template/test_number.py +++ b/tests/components/template/test_number.py @@ -21,10 +21,13 @@ from homeassistant.components.number import ( SERVICE_SET_VALUE as NUMBER_SERVICE_SET_VALUE, ) from homeassistant.components.template import DOMAIN +from homeassistant.components.template.const import CONF_PICTURE from homeassistant.const import ( ATTR_ENTITY_ID, + ATTR_ENTITY_PICTURE, ATTR_ICON, CONF_ENTITY_ID, + CONF_ICON, CONF_UNIT_OF_MEASUREMENT, STATE_UNKNOWN, ) @@ -58,6 +61,20 @@ _VALUE_INPUT_NUMBER_CONFIG = { } } +TEST_STATE_ENTITY_ID = "number.test_state" + +TEST_STATE_TRIGGER = { + "trigger": { + "trigger": "state", + "entity_id": [TEST_STATE_ENTITY_ID], + }, + "variables": {"triggering_entity": "{{ trigger.entity_id }}"}, + "action": [ + {"event": "action_event", "event_data": {"what": "{{ triggering_entity }}"}} + ], +} +TEST_REQUIRED = {"state": "0", "step": "1", "set_value": []} + async def async_setup_modern_format( hass: HomeAssistant, count: int, number_config: dict[str, Any] @@ -77,6 +94,24 @@ async def async_setup_modern_format( await hass.async_block_till_done() +async def async_setup_trigger_format( + hass: HomeAssistant, count: int, number_config: dict[str, Any] +) -> None: + """Do setup of number integration via trigger format.""" + config = {"template": {**TEST_STATE_TRIGGER, "number": number_config}} + + with assert_setup_component(count, template.DOMAIN): + assert await async_setup_component( + hass, + template.DOMAIN, + config, + ) + + await hass.async_block_till_done() + await hass.async_start() + await hass.async_block_till_done() + + @pytest.fixture async def setup_number( hass: HomeAssistant, @@ -89,6 +124,10 @@ async def setup_number( await async_setup_modern_format( hass, count, {"name": _TEST_OBJECT_ID, **number_config} ) + if style == ConfigurationStyle.TRIGGER: + await async_setup_trigger_format( + hass, count, {"name": _TEST_OBJECT_ID, **number_config} + ) async def test_setup_config_entry( @@ -446,119 +485,49 @@ def _verify( assert attributes.get(CONF_UNIT_OF_MEASUREMENT) == expected_unit_of_measurement -async def test_icon_template(hass: HomeAssistant) -> None: - """Test template numbers with icon templates.""" - with assert_setup_component(1, "input_number"): - assert await setup.async_setup_component( - hass, - "input_number", - {"input_number": _VALUE_INPUT_NUMBER_CONFIG}, - ) - - with assert_setup_component(1, "template"): - assert await setup.async_setup_component( - hass, - "template", +@pytest.mark.parametrize("count", [1]) +@pytest.mark.parametrize( + ("style", "initial_expected_state"), + [(ConfigurationStyle.MODERN, ""), (ConfigurationStyle.TRIGGER, None)], +) +@pytest.mark.parametrize( + ("number_config", "attribute", "expected"), + [ + ( { - "template": { - "unique_id": "b", - "number": { - "state": f"{{{{ states('{_VALUE_INPUT_NUMBER}') }}}}", - "step": 1, - "min": 0, - "max": 100, - "set_value": { - "service": "input_number.set_value", - "data_template": { - "entity_id": _VALUE_INPUT_NUMBER, - "value": "{{ value }}", - }, - }, - "icon": "{% if ((states.input_number.value.state or 0) | int) > 50 %}mdi:greater{% else %}mdi:less{% endif %}", - }, - } + CONF_ICON: "{% if states.number.test_state.state == '1' %}mdi:check{% endif %}", + **TEST_REQUIRED, }, - ) - - hass.states.async_set(_VALUE_INPUT_NUMBER, 49) - - await hass.async_block_till_done() - await hass.async_start() - await hass.async_block_till_done() - - state = hass.states.get(_TEST_NUMBER) - assert float(state.state) == 49 - assert state.attributes[ATTR_ICON] == "mdi:less" - - await hass.services.async_call( - INPUT_NUMBER_DOMAIN, - INPUT_NUMBER_SERVICE_SET_VALUE, - {CONF_ENTITY_ID: _VALUE_INPUT_NUMBER, INPUT_NUMBER_ATTR_VALUE: 51}, - blocking=True, - ) - await hass.async_block_till_done() - - state = hass.states.get(_TEST_NUMBER) - assert float(state.state) == 51 - assert state.attributes[ATTR_ICON] == "mdi:greater" - - -async def test_icon_template_with_trigger(hass: HomeAssistant) -> None: - """Test template numbers with icon templates.""" - with assert_setup_component(1, "input_number"): - assert await setup.async_setup_component( - hass, - "input_number", - {"input_number": _VALUE_INPUT_NUMBER_CONFIG}, - ) - - with assert_setup_component(1, "template"): - assert await setup.async_setup_component( - hass, - "template", + ATTR_ICON, + "mdi:check", + ), + ( { - "template": { - "trigger": {"platform": "state", "entity_id": _VALUE_INPUT_NUMBER}, - "unique_id": "b", - "number": { - "state": "{{ trigger.to_state.state }}", - "step": 1, - "min": 0, - "max": 100, - "set_value": { - "service": "input_number.set_value", - "data_template": { - "entity_id": _VALUE_INPUT_NUMBER, - "value": "{{ value }}", - }, - }, - "icon": "{% if ((trigger.to_state.state or 0) | int) > 50 %}mdi:greater{% else %}mdi:less{% endif %}", - }, - } + CONF_PICTURE: "{% if states.number.test_state.state == '1' %}check.jpg{% endif %}", + **TEST_REQUIRED, }, - ) + ATTR_ENTITY_PICTURE, + "check.jpg", + ), + ], +) +@pytest.mark.usefixtures("setup_number") +async def test_templated_optional_config( + hass: HomeAssistant, + attribute: str, + expected: str, + initial_expected_state: str | None, +) -> None: + """Test optional config templates.""" + state = hass.states.get(_TEST_NUMBER) + assert state.attributes.get(attribute) == initial_expected_state - hass.states.async_set(_VALUE_INPUT_NUMBER, 49) - - await hass.async_block_till_done() - await hass.async_start() + state = hass.states.async_set(TEST_STATE_ENTITY_ID, "1") await hass.async_block_till_done() state = hass.states.get(_TEST_NUMBER) - assert float(state.state) == 49 - assert state.attributes[ATTR_ICON] == "mdi:less" - await hass.services.async_call( - INPUT_NUMBER_DOMAIN, - INPUT_NUMBER_SERVICE_SET_VALUE, - {CONF_ENTITY_ID: _VALUE_INPUT_NUMBER, INPUT_NUMBER_ATTR_VALUE: 51}, - blocking=True, - ) - await hass.async_block_till_done() - - state = hass.states.get(_TEST_NUMBER) - assert float(state.state) == 51 - assert state.attributes[ATTR_ICON] == "mdi:greater" + assert state.attributes[attribute] == expected async def test_device_id( diff --git a/tests/components/template/test_select.py b/tests/components/template/test_select.py index b2bc56af44a..5e29993f0f6 100644 --- a/tests/components/template/test_select.py +++ b/tests/components/template/test_select.py @@ -21,7 +21,15 @@ from homeassistant.components.select import ( SERVICE_SELECT_OPTION as SELECT_SERVICE_SELECT_OPTION, ) from homeassistant.components.template import DOMAIN -from homeassistant.const import ATTR_ENTITY_ID, ATTR_ICON, CONF_ENTITY_ID, STATE_UNKNOWN +from homeassistant.components.template.const import CONF_PICTURE +from homeassistant.const import ( + ATTR_ENTITY_ID, + ATTR_ENTITY_PICTURE, + ATTR_ICON, + CONF_ENTITY_ID, + CONF_ICON, + STATE_UNKNOWN, +) from homeassistant.core import Context, HomeAssistant, ServiceCall from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.setup import async_setup_component @@ -34,6 +42,24 @@ _TEST_OBJECT_ID = "template_select" _TEST_SELECT = f"select.{_TEST_OBJECT_ID}" # Represent for select's current_option _OPTION_INPUT_SELECT = "input_select.option" +TEST_STATE_ENTITY_ID = "select.test_state" + +TEST_STATE_TRIGGER = { + "trigger": { + "trigger": "state", + "entity_id": [_OPTION_INPUT_SELECT, TEST_STATE_ENTITY_ID], + }, + "variables": {"triggering_entity": "{{ trigger.entity_id }}"}, + "action": [ + {"event": "action_event", "event_data": {"what": "{{ triggering_entity }}"}} + ], +} + +TEST_OPTIONS = { + "state": "test", + "options": "{{ ['test', 'yes', 'no'] }}", + "select_option": [], +} async def async_setup_modern_format( @@ -54,6 +80,24 @@ async def async_setup_modern_format( await hass.async_block_till_done() +async def async_setup_trigger_format( + hass: HomeAssistant, count: int, select_config: dict[str, Any] +) -> None: + """Do setup of select integration via trigger format.""" + config = {"template": {**TEST_STATE_TRIGGER, "select": select_config}} + + with assert_setup_component(count, template.DOMAIN): + assert await async_setup_component( + hass, + template.DOMAIN, + config, + ) + + await hass.async_block_till_done() + await hass.async_start() + await hass.async_block_till_done() + + @pytest.fixture async def setup_select( hass: HomeAssistant, @@ -66,6 +110,10 @@ async def setup_select( await async_setup_modern_format( hass, count, {"name": _TEST_OBJECT_ID, **select_config} ) + if style == ConfigurationStyle.TRIGGER: + await async_setup_trigger_format( + hass, count, {"name": _TEST_OBJECT_ID, **select_config} + ) async def test_setup_config_entry( @@ -395,138 +443,49 @@ def _verify( assert attributes.get(SELECT_ATTR_OPTIONS) == expected_options -async def test_template_icon_with_entities(hass: HomeAssistant) -> None: - """Test templates with values from other entities.""" - with assert_setup_component(1, "input_select"): - assert await setup.async_setup_component( - hass, - "input_select", +@pytest.mark.parametrize("count", [1]) +@pytest.mark.parametrize( + ("style", "initial_expected_state"), + [(ConfigurationStyle.MODERN, ""), (ConfigurationStyle.TRIGGER, None)], +) +@pytest.mark.parametrize( + ("select_config", "attribute", "expected"), + [ + ( { - "input_select": { - "option": { - "options": ["a", "b"], - "initial": "a", - "name": "Option", - }, - } + **TEST_OPTIONS, + CONF_ICON: "{% if states.select.test_state.state == 'yes' %}mdi:check{% endif %}", }, - ) - - with assert_setup_component(1, "template"): - assert await setup.async_setup_component( - hass, - "template", + ATTR_ICON, + "mdi:check", + ), + ( { - "template": { - "unique_id": "b", - "select": { - "state": f"{{{{ states('{_OPTION_INPUT_SELECT}') }}}}", - "options": f"{{{{ state_attr('{_OPTION_INPUT_SELECT}', '{INPUT_SELECT_ATTR_OPTIONS}') }}}}", - "select_option": { - "service": "input_select.select_option", - "data": { - "entity_id": _OPTION_INPUT_SELECT, - "option": "{{ option }}", - }, - }, - "optimistic": True, - "unique_id": "a", - "icon": f"{{% if (states('{_OPTION_INPUT_SELECT}') == 'a') %}}mdi:greater{{% else %}}mdi:less{{% endif %}}", - }, - } + **TEST_OPTIONS, + CONF_PICTURE: "{% if states.select.test_state.state == 'yes' %}check.jpg{% endif %}", }, - ) + ATTR_ENTITY_PICTURE, + "check.jpg", + ), + ], +) +@pytest.mark.usefixtures("setup_select") +async def test_templated_optional_config( + hass: HomeAssistant, + attribute: str, + expected: str, + initial_expected_state: str | None, +) -> None: + """Test optional config templates.""" + state = hass.states.get(_TEST_SELECT) + assert state.attributes.get(attribute) == initial_expected_state - await hass.async_block_till_done() - await hass.async_start() + state = hass.states.async_set(TEST_STATE_ENTITY_ID, "yes") await hass.async_block_till_done() state = hass.states.get(_TEST_SELECT) - assert state.state == "a" - assert state.attributes[ATTR_ICON] == "mdi:greater" - await hass.services.async_call( - INPUT_SELECT_DOMAIN, - INPUT_SELECT_SERVICE_SELECT_OPTION, - {CONF_ENTITY_ID: _OPTION_INPUT_SELECT, INPUT_SELECT_ATTR_OPTION: "b"}, - blocking=True, - ) - await hass.async_block_till_done() - - state = hass.states.get(_TEST_SELECT) - assert state.state == "b" - assert state.attributes[ATTR_ICON] == "mdi:less" - - -async def test_template_icon_with_trigger(hass: HomeAssistant) -> None: - """Test trigger based template select.""" - with assert_setup_component(1, "input_select"): - assert await setup.async_setup_component( - hass, - "input_select", - { - "input_select": { - "option": { - "options": ["a", "b"], - "initial": "a", - "name": "Option", - }, - } - }, - ) - - assert await setup.async_setup_component( - hass, - "template", - { - "template": { - "trigger": {"platform": "state", "entity_id": _OPTION_INPUT_SELECT}, - "select": { - "unique_id": "b", - "state": "{{ trigger.to_state.state }}", - "options": f"{{{{ state_attr('{_OPTION_INPUT_SELECT}', '{INPUT_SELECT_ATTR_OPTIONS}') }}}}", - "select_option": { - "service": "input_select.select_option", - "data": { - "entity_id": _OPTION_INPUT_SELECT, - "option": "{{ option }}", - }, - }, - "optimistic": True, - "icon": "{% if (trigger.to_state.state or '') == 'a' %}mdi:greater{% else %}mdi:less{% endif %}", - }, - }, - }, - ) - - await hass.async_block_till_done() - await hass.async_start() - await hass.async_block_till_done() - - await hass.services.async_call( - INPUT_SELECT_DOMAIN, - INPUT_SELECT_SERVICE_SELECT_OPTION, - {CONF_ENTITY_ID: _OPTION_INPUT_SELECT, INPUT_SELECT_ATTR_OPTION: "b"}, - blocking=True, - ) - await hass.async_block_till_done() - - state = hass.states.get(_TEST_SELECT) - assert state is not None - assert state.state == "b" - assert state.attributes[ATTR_ICON] == "mdi:less" - - await hass.services.async_call( - INPUT_SELECT_DOMAIN, - INPUT_SELECT_SERVICE_SELECT_OPTION, - {CONF_ENTITY_ID: _OPTION_INPUT_SELECT, INPUT_SELECT_ATTR_OPTION: "a"}, - blocking=True, - ) - await hass.async_block_till_done() - - state = hass.states.get(_TEST_SELECT) - assert state.state == "a" - assert state.attributes[ATTR_ICON] == "mdi:greater" + assert state.attributes[attribute] == expected async def test_device_id( diff --git a/tests/components/template/test_weather.py b/tests/components/template/test_weather.py index 5db6a000ccc..443b0aa6e77 100644 --- a/tests/components/template/test_weather.py +++ b/tests/components/template/test_weather.py @@ -5,6 +5,8 @@ from typing import Any import pytest from syrupy.assertion import SnapshotAssertion +from homeassistant.components import template +from homeassistant.components.template.const import CONF_PICTURE from homeassistant.components.weather import ( ATTR_WEATHER_APPARENT_TEMPERATURE, ATTR_WEATHER_CLOUD_COVERAGE, @@ -21,12 +23,21 @@ from homeassistant.components.weather import ( SERVICE_GET_FORECASTS, Forecast, ) -from homeassistant.const import ATTR_ATTRIBUTION, STATE_UNAVAILABLE, STATE_UNKNOWN +from homeassistant.const import ( + ATTR_ATTRIBUTION, + ATTR_ENTITY_PICTURE, + ATTR_ICON, + CONF_ICON, + STATE_UNAVAILABLE, + STATE_UNKNOWN, +) from homeassistant.core import Context, HomeAssistant, State from homeassistant.helpers.restore_state import STORAGE_KEY as RESTORE_STATE_KEY from homeassistant.setup import async_setup_component from homeassistant.util import dt as dt_util +from .conftest import ConfigurationStyle + from tests.common import ( assert_setup_component, async_mock_restore_state_shutdown_restart, @@ -35,6 +46,80 @@ from tests.common import ( ATTR_FORECAST = "forecast" +TEST_OBJECT_ID = "template_weather" +TEST_WEATHER = f"weather.{TEST_OBJECT_ID}" +TEST_STATE_ENTITY_ID = "weather.test_state" + +TEST_STATE_TRIGGER = { + "trigger": { + "trigger": "state", + "entity_id": [TEST_STATE_ENTITY_ID], + }, + "variables": {"triggering_entity": "{{ trigger.entity_id }}"}, + "action": [ + {"event": "action_event", "event_data": {"what": "{{ triggering_entity }}"}} + ], +} +TEST_REQUIRED = { + "condition_template": "cloudy", + "temperature_template": "{{ 20 }}", + "humidity_template": "{{ 25 }}", +} + + +async def async_setup_modern_format( + hass: HomeAssistant, count: int, weather_config: dict[str, Any] +) -> None: + """Do setup of weather integration via new format.""" + config = {"template": {"weather": weather_config}} + + with assert_setup_component(count, template.DOMAIN): + assert await async_setup_component( + hass, + template.DOMAIN, + config, + ) + + await hass.async_block_till_done() + await hass.async_start() + await hass.async_block_till_done() + + +async def async_setup_trigger_format( + hass: HomeAssistant, count: int, weather_config: dict[str, Any] +) -> None: + """Do setup of weather integration via trigger format.""" + config = {"template": {**TEST_STATE_TRIGGER, "weather": weather_config}} + + with assert_setup_component(count, template.DOMAIN): + assert await async_setup_component( + hass, + template.DOMAIN, + config, + ) + + await hass.async_block_till_done() + await hass.async_start() + await hass.async_block_till_done() + + +@pytest.fixture +async def setup_weather( + hass: HomeAssistant, + count: int, + style: ConfigurationStyle, + weather_config: dict[str, Any], +) -> None: + """Do setup of weather integration.""" + if style == ConfigurationStyle.MODERN: + await async_setup_modern_format( + hass, count, {"name": TEST_OBJECT_ID, **weather_config} + ) + if style == ConfigurationStyle.TRIGGER: + await async_setup_trigger_format( + hass, count, {"name": TEST_OBJECT_ID, **weather_config} + ) + @pytest.mark.parametrize(("count", "domain"), [(1, WEATHER_DOMAIN)]) @pytest.mark.parametrize( @@ -990,3 +1075,48 @@ async def test_new_style_template_state_text(hass: HomeAssistant) -> None: assert state is not None assert state.state == "sunny" assert state.attributes.get(v_attr) == value + + +@pytest.mark.parametrize("count", [1]) +@pytest.mark.parametrize( + ("style", "initial_expected_state"), + [(ConfigurationStyle.MODERN, ""), (ConfigurationStyle.TRIGGER, None)], +) +@pytest.mark.parametrize( + ("weather_config", "attribute", "expected"), + [ + ( + { + CONF_ICON: "{% if states.weather.test_state.state == 'sunny' %}mdi:check{% endif %}", + **TEST_REQUIRED, + }, + ATTR_ICON, + "mdi:check", + ), + ( + { + CONF_PICTURE: "{% if states.weather.test_state.state == 'sunny' %}check.jpg{% endif %}", + **TEST_REQUIRED, + }, + ATTR_ENTITY_PICTURE, + "check.jpg", + ), + ], +) +@pytest.mark.usefixtures("setup_weather") +async def test_templated_optional_config( + hass: HomeAssistant, + attribute: str, + expected: str, + initial_expected_state: str | None, +) -> None: + """Test optional config templates.""" + state = hass.states.get(TEST_WEATHER) + assert state.attributes.get(attribute) == initial_expected_state + + state = hass.states.async_set(TEST_STATE_ENTITY_ID, "sunny") + await hass.async_block_till_done() + + state = hass.states.get(TEST_WEATHER) + + assert state.attributes[attribute] == expected diff --git a/tests/components/tesla_fleet/test_config_flow.py b/tests/components/tesla_fleet/test_config_flow.py index 6cb8c60ac0c..4a8142a2d85 100644 --- a/tests/components/tesla_fleet/test_config_flow.py +++ b/tests/components/tesla_fleet/test_config_flow.py @@ -1,16 +1,23 @@ """Test the Tesla Fleet config flow.""" -from unittest.mock import patch +from unittest.mock import AsyncMock, Mock, patch from urllib.parse import parse_qs, urlparse import pytest +from tesla_fleet_api.exceptions import ( + InvalidResponse, + PreconditionFailed, + TeslaFleetError, +) from homeassistant.components.application_credentials import ( ClientCredential, async_import_client_credential, ) +from homeassistant.components.tesla_fleet.config_flow import OAuth2FlowHandler from homeassistant.components.tesla_fleet.const import ( AUTHORIZE_URL, + CONF_DOMAIN, DOMAIN, SCOPES, TOKEN_URL, @@ -64,15 +71,30 @@ async def create_credential(hass: HomeAssistant) -> None: ) +@pytest.fixture +def mock_private_key(): + """Mock private key for testing.""" + private_key = Mock() + public_key = Mock() + private_key.public_key.return_value = public_key + public_key.public_bytes.side_effect = [ + b"-----BEGIN PUBLIC KEY-----\nMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA\n-----END PUBLIC KEY-----", + bytes.fromhex( + "0404112233445566778899aabbccddeeff112233445566778899aabbccddeeff112233445566778899aabbccddeeff112233445566778899aabbccddeeff1122" + ), + ] + return private_key + + @pytest.mark.usefixtures("current_request_with_host") -async def test_full_flow_user_cred( +async def test_full_flow_with_domain_registration( hass: HomeAssistant, hass_client_no_auth: ClientSessionGenerator, aioclient_mock: AiohttpClientMocker, access_token: str, + mock_private_key, ) -> None: - """Check full flow.""" - + """Test full flow with domain registration.""" result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_USER} ) @@ -95,7 +117,7 @@ async def test_full_flow_user_cred( assert parsed_query["redirect_uri"][0] == REDIRECT assert parsed_query["state"][0] == state assert parsed_query["scope"][0] == " ".join(SCOPES) - assert "code_challenge" not in parsed_query # Ensure not a PKCE flow + assert "code_challenge" not in parsed_query client = await hass_client_no_auth() resp = await client.get(f"/auth/external/callback?code=abcd&state={state}") @@ -112,21 +134,416 @@ async def test_full_flow_user_cred( "expires_in": 60, }, ) - with patch( - "homeassistant.components.tesla_fleet.async_setup_entry", return_value=True - ) as mock_setup: - result = await hass.config_entries.flow.async_configure(result["flow_id"]) - assert len(hass.config_entries.async_entries(DOMAIN)) == 1 - assert len(mock_setup.mock_calls) == 1 + with ( + patch( + "homeassistant.components.tesla_fleet.config_flow.TeslaFleetApi" + ) as mock_api_class, + patch( + "homeassistant.components.tesla_fleet.async_setup_entry", return_value=True + ), + ): + mock_api = AsyncMock() + mock_api.private_key = mock_private_key + mock_api.get_private_key = AsyncMock() + mock_api.partner_login = AsyncMock() + mock_api.public_uncompressed_point = "0404112233445566778899aabbccddeeff112233445566778899aabbccddeeff112233445566778899aabbccddeeff112233445566778899aabbccddeeff1122" + mock_api.partner.register.return_value = { + "response": { + "public_key": "0404112233445566778899aabbccddeeff112233445566778899aabbccddeeff112233445566778899aabbccddeeff112233445566778899aabbccddeeff1122" + } + } + mock_api_class.return_value = mock_api + + # Complete OAuth + result = await hass.config_entries.flow.async_configure(result["flow_id"]) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "domain_input" + + # Enter domain - this should automatically register and go to registration_complete + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {CONF_DOMAIN: "example.com"} + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "registration_complete" + + # Complete flow - provide user input to complete registration + result = await hass.config_entries.flow.async_configure(result["flow_id"], {}) assert result["type"] is FlowResultType.CREATE_ENTRY assert result["title"] == UNIQUE_ID - assert "result" in result assert result["result"].unique_id == UNIQUE_ID - assert "token" in result["result"].data - assert result["result"].data["token"]["access_token"] == access_token - assert result["result"].data["token"]["refresh_token"] == "mock-refresh-token" + + +@pytest.mark.usefixtures("current_request_with_host") +async def test_domain_input_invalid_domain( + hass: HomeAssistant, + hass_client_no_auth: ClientSessionGenerator, + aioclient_mock: AiohttpClientMocker, + access_token: str, + mock_private_key, +) -> None: + """Test domain input with invalid domain.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + + state = config_entry_oauth2_flow._encode_jwt( + hass, + { + "flow_id": result["flow_id"], + "redirect_uri": REDIRECT, + }, + ) + + client = await hass_client_no_auth() + await client.get(f"/auth/external/callback?code=abcd&state={state}") + + aioclient_mock.post( + TOKEN_URL, + json={ + "refresh_token": "mock-refresh-token", + "access_token": access_token, + "type": "Bearer", + "expires_in": 60, + }, + ) + + with ( + patch( + "homeassistant.components.tesla_fleet.config_flow.TeslaFleetApi" + ) as mock_api_class, + ): + mock_api = AsyncMock() + mock_api.private_key = mock_private_key + mock_api.get_private_key = AsyncMock() + mock_api.partner_login = AsyncMock() + mock_api_class.return_value = mock_api + + # Complete OAuth + result = await hass.config_entries.flow.async_configure(result["flow_id"]) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "domain_input" + + # Enter invalid domain + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {CONF_DOMAIN: "invalid-domain"} + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "domain_input" + assert result["errors"] == {CONF_DOMAIN: "invalid_domain"} + + # Enter valid domain - this should automatically register and go to registration_complete + mock_api.public_uncompressed_point = "0404112233445566778899aabbccddeeff112233445566778899aabbccddeeff112233445566778899aabbccddeeff112233445566778899aabbccddeeff1122" + mock_api.partner.register.return_value = { + "response": { + "public_key": "0404112233445566778899aabbccddeeff112233445566778899aabbccddeeff112233445566778899aabbccddeeff112233445566778899aabbccddeeff1122" + } + } + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {CONF_DOMAIN: "example.com"} + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "registration_complete" + + +@pytest.mark.parametrize( + ("side_effect", "expected_error"), + [ + (InvalidResponse, "invalid_response"), + (TeslaFleetError("Custom error"), "unknown_error"), + ], +) +@pytest.mark.usefixtures("current_request_with_host") +async def test_domain_registration_errors( + hass: HomeAssistant, + hass_client_no_auth: ClientSessionGenerator, + aioclient_mock: AiohttpClientMocker, + access_token: str, + mock_private_key, + side_effect, + expected_error, +) -> None: + """Test domain registration with errors that stay on domain_registration step.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + + state = config_entry_oauth2_flow._encode_jwt( + hass, + { + "flow_id": result["flow_id"], + "redirect_uri": REDIRECT, + }, + ) + + client = await hass_client_no_auth() + await client.get(f"/auth/external/callback?code=abcd&state={state}") + + aioclient_mock.post( + TOKEN_URL, + json={ + "refresh_token": "mock-refresh-token", + "access_token": access_token, + "type": "Bearer", + "expires_in": 60, + }, + ) + + with ( + patch( + "homeassistant.components.tesla_fleet.config_flow.TeslaFleetApi" + ) as mock_api_class, + ): + mock_api = AsyncMock() + mock_api.private_key = mock_private_key + mock_api.get_private_key = AsyncMock() + mock_api.partner_login = AsyncMock() + mock_api.public_uncompressed_point = "test_point" + mock_api.partner.register.side_effect = side_effect + mock_api_class.return_value = mock_api + + # Complete OAuth + result = await hass.config_entries.flow.async_configure(result["flow_id"]) + + # Enter domain - this should fail and stay on domain_registration + with patch( + "homeassistant.helpers.translation.async_get_translations", return_value={} + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {CONF_DOMAIN: "example.com"} + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "domain_registration" + assert result["errors"] == {"base": expected_error} + + +@pytest.mark.usefixtures("current_request_with_host") +async def test_domain_registration_precondition_failed( + hass: HomeAssistant, + hass_client_no_auth: ClientSessionGenerator, + aioclient_mock: AiohttpClientMocker, + access_token: str, + mock_private_key, +) -> None: + """Test domain registration with PreconditionFailed redirects to domain_input.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + + state = config_entry_oauth2_flow._encode_jwt( + hass, + { + "flow_id": result["flow_id"], + "redirect_uri": REDIRECT, + }, + ) + + client = await hass_client_no_auth() + await client.get(f"/auth/external/callback?code=abcd&state={state}") + + aioclient_mock.post( + TOKEN_URL, + json={ + "refresh_token": "mock-refresh-token", + "access_token": access_token, + "type": "Bearer", + "expires_in": 60, + }, + ) + + with ( + patch( + "homeassistant.components.tesla_fleet.config_flow.TeslaFleetApi" + ) as mock_api_class, + ): + mock_api = AsyncMock() + mock_api.private_key = mock_private_key + mock_api.get_private_key = AsyncMock() + mock_api.partner_login = AsyncMock() + mock_api.public_uncompressed_point = "test_point" + mock_api.partner.register.side_effect = PreconditionFailed + mock_api_class.return_value = mock_api + + # Complete OAuth + result = await hass.config_entries.flow.async_configure(result["flow_id"]) + + # Enter domain - this should go to domain_registration and then fail back to domain_input + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {CONF_DOMAIN: "example.com"} + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "domain_input" + assert result["errors"] == {CONF_DOMAIN: "precondition_failed"} + + +@pytest.mark.usefixtures("current_request_with_host") +async def test_domain_registration_public_key_not_found( + hass: HomeAssistant, + hass_client_no_auth: ClientSessionGenerator, + aioclient_mock: AiohttpClientMocker, + access_token: str, + mock_private_key, +) -> None: + """Test domain registration with missing public key.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + + state = config_entry_oauth2_flow._encode_jwt( + hass, + { + "flow_id": result["flow_id"], + "redirect_uri": REDIRECT, + }, + ) + + client = await hass_client_no_auth() + await client.get(f"/auth/external/callback?code=abcd&state={state}") + + aioclient_mock.post( + TOKEN_URL, + json={ + "refresh_token": "mock-refresh-token", + "access_token": access_token, + "type": "Bearer", + "expires_in": 60, + }, + ) + + with ( + patch( + "homeassistant.components.tesla_fleet.config_flow.TeslaFleetApi" + ) as mock_api_class, + ): + mock_api = AsyncMock() + mock_api.private_key = mock_private_key + mock_api.get_private_key = AsyncMock() + mock_api.partner_login = AsyncMock() + mock_api.public_uncompressed_point = "test_point" + mock_api.partner.register.return_value = {"response": {}} + mock_api_class.return_value = mock_api + + # Complete OAuth + result = await hass.config_entries.flow.async_configure(result["flow_id"]) + + # Enter domain - this should fail and stay on domain_registration + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {CONF_DOMAIN: "example.com"} + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "domain_registration" + assert result["errors"] == {"base": "public_key_not_found"} + + +@pytest.mark.usefixtures("current_request_with_host") +async def test_domain_registration_public_key_mismatch( + hass: HomeAssistant, + hass_client_no_auth: ClientSessionGenerator, + aioclient_mock: AiohttpClientMocker, + access_token: str, + mock_private_key, +) -> None: + """Test domain registration with public key mismatch.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + + state = config_entry_oauth2_flow._encode_jwt( + hass, + { + "flow_id": result["flow_id"], + "redirect_uri": REDIRECT, + }, + ) + + client = await hass_client_no_auth() + await client.get(f"/auth/external/callback?code=abcd&state={state}") + + aioclient_mock.post( + TOKEN_URL, + json={ + "refresh_token": "mock-refresh-token", + "access_token": access_token, + "type": "Bearer", + "expires_in": 60, + }, + ) + + with ( + patch( + "homeassistant.components.tesla_fleet.config_flow.TeslaFleetApi" + ) as mock_api_class, + ): + mock_api = AsyncMock() + mock_api.private_key = mock_private_key + mock_api.get_private_key = AsyncMock() + mock_api.partner_login = AsyncMock() + mock_api.public_uncompressed_point = "expected_key" + mock_api.partner.register.return_value = { + "response": {"public_key": "different_key"} + } + mock_api_class.return_value = mock_api + + # Complete OAuth + result = await hass.config_entries.flow.async_configure(result["flow_id"]) + + # Enter domain - this should fail and stay on domain_registration + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {CONF_DOMAIN: "example.com"} + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "domain_registration" + assert result["errors"] == {"base": "public_key_mismatch"} + + +@pytest.mark.usefixtures("current_request_with_host") +async def test_registration_complete_no_domain( + hass: HomeAssistant, +) -> None: + """Test registration complete step without domain.""" + + flow_instance = OAuth2FlowHandler() + flow_instance.hass = hass + flow_instance.domain = None + + result = await flow_instance.async_step_registration_complete({}) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "domain_input" + + +async def test_registration_complete_with_domain_and_user_input( + hass: HomeAssistant, +) -> None: + """Test registration complete step with domain and user input.""" + + flow_instance = OAuth2FlowHandler() + flow_instance.hass = hass + flow_instance.domain = "example.com" + flow_instance.uid = UNIQUE_ID + flow_instance.data = {"token": {"access_token": "test"}} + + result = await flow_instance.async_step_registration_complete({"complete": True}) + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == UNIQUE_ID + + +async def test_registration_complete_with_domain_no_user_input( + hass: HomeAssistant, +) -> None: + """Test registration complete step with domain but no user input.""" + + flow_instance = OAuth2FlowHandler() + flow_instance.hass = hass + flow_instance.domain = "example.com" + + result = await flow_instance.async_step_registration_complete(None) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "registration_complete" + assert ( + result["description_placeholders"]["virtual_key_url"] + == "https://www.tesla.com/_ak/example.com" + ) @pytest.mark.usefixtures("current_request_with_host") @@ -225,3 +642,89 @@ async def test_reauth_account_mismatch( assert result["type"] is FlowResultType.ABORT assert result["reason"] == "reauth_account_mismatch" + + +@pytest.mark.usefixtures("current_request_with_host") +async def test_duplicate_unique_id_abort( + hass: HomeAssistant, + hass_client_no_auth: ClientSessionGenerator, + aioclient_mock: AiohttpClientMocker, + access_token: str, +) -> None: + """Test duplicate unique ID aborts flow.""" + # Create existing entry + existing_entry = MockConfigEntry( + domain=DOMAIN, + unique_id=UNIQUE_ID, + version=1, + data={}, + ) + existing_entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + + state = config_entry_oauth2_flow._encode_jwt( + hass, + { + "flow_id": result["flow_id"], + "redirect_uri": REDIRECT, + }, + ) + + client = await hass_client_no_auth() + await client.get(f"/auth/external/callback?code=abcd&state={state}") + + aioclient_mock.post( + TOKEN_URL, + json={ + "refresh_token": "mock-refresh-token", + "access_token": access_token, + "type": "Bearer", + "expires_in": 60, + }, + ) + + # Complete OAuth - should abort due to duplicate unique_id + result = await hass.config_entries.flow.async_configure(result["flow_id"]) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" + + +async def test_reauth_confirm_form(hass: HomeAssistant) -> None: + """Test reauth confirm form display.""" + old_entry = MockConfigEntry( + domain=DOMAIN, + unique_id=UNIQUE_ID, + version=1, + data={}, + ) + old_entry.add_to_hass(hass) + + result = await old_entry.start_reauth_flow(hass) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "reauth_confirm" + assert result["description_placeholders"] == {"name": "Tesla Fleet"} + + +@pytest.mark.parametrize( + ("domain", "expected_valid"), + [ + ("example.com", True), + ("test.example.com", True), + ("sub.domain.example.org", True), + ("https://example.com", False), + ("invalid-domain", False), + ("", False), + ("example", False), + ("example.", False), + (".example.com", False), + ("exam ple.com", False), + ], +) +def test_is_valid_domain(domain: str, expected_valid: bool) -> None: + """Test domain validation.""" + + assert OAuth2FlowHandler()._is_valid_domain(domain) == expected_valid diff --git a/tests/components/vacuum/test_init.py b/tests/components/vacuum/test_init.py index b4fab54e98d..b3e5d17c728 100644 --- a/tests/components/vacuum/test_init.py +++ b/tests/components/vacuum/test_init.py @@ -31,7 +31,6 @@ from .common import async_start from tests.common import ( MockConfigEntry, MockEntity, - MockEntityPlatform, MockModule, help_test_all, import_and_test_deprecated_constant_enum, @@ -263,44 +262,6 @@ async def test_send_command(hass: HomeAssistant, config_flow_fixture: None) -> N assert "test" in strings -async def test_supported_features_compat(hass: HomeAssistant) -> None: - """Test StateVacuumEntity using deprecated feature constants features.""" - - features = ( - VacuumEntityFeature.BATTERY - | VacuumEntityFeature.FAN_SPEED - | VacuumEntityFeature.START - | VacuumEntityFeature.STOP - | VacuumEntityFeature.PAUSE - ) - - class _LegacyConstantsStateVacuum(StateVacuumEntity): - _attr_supported_features = int(features) - _attr_fan_speed_list = ["silent", "normal", "pet hair"] - - entity = _LegacyConstantsStateVacuum() - entity.hass = hass - entity.platform = MockEntityPlatform(hass) - assert isinstance(entity.supported_features, int) - assert entity.supported_features == int(features) - assert entity.supported_features_compat is ( - VacuumEntityFeature.BATTERY - | VacuumEntityFeature.FAN_SPEED - | VacuumEntityFeature.START - | VacuumEntityFeature.STOP - | VacuumEntityFeature.PAUSE - ) - assert entity.state_attributes == { - "battery_level": None, - "battery_icon": "mdi:battery-unknown", - "fan_speed": None, - } - assert entity.capability_attributes == { - "fan_speed_list": ["silent", "normal", "pet hair"] - } - assert entity._deprecated_supported_features_reported - - async def test_vacuum_not_log_deprecated_state_warning( hass: HomeAssistant, mock_vacuum_entity: MockVacuum, diff --git a/tests/components/wallbox/__init__.py b/tests/components/wallbox/__init__.py index d347777f7e8..83e39d2f602 100644 --- a/tests/components/wallbox/__init__.py +++ b/tests/components/wallbox/__init__.py @@ -216,6 +216,31 @@ async def setup_integration(hass: HomeAssistant, entry: MockConfigEntry) -> None await hass.async_block_till_done() +async def setup_integration_no_eco_mode( + hass: HomeAssistant, entry: MockConfigEntry +) -> None: + """Test wallbox sensor class setup.""" + with requests_mock.Mocker() as mock_request: + mock_request.get( + "https://user-api.wall-box.com/users/signin", + json=authorisation_response, + status_code=HTTPStatus.OK, + ) + mock_request.get( + "https://api.wall-box.com/chargers/status/12345", + json=test_response_no_power_boost, + status_code=HTTPStatus.OK, + ) + mock_request.put( + "https://api.wall-box.com/v2/charger/12345", + json={CHARGER_MAX_CHARGING_CURRENT_KEY: 20}, + status_code=HTTPStatus.OK, + ) + + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + async def setup_integration_select( hass: HomeAssistant, entry: MockConfigEntry, response ) -> None: diff --git a/tests/components/wallbox/test_init.py b/tests/components/wallbox/test_init.py index b4b5a199243..6d6a5cd1417 100644 --- a/tests/components/wallbox/test_init.py +++ b/tests/components/wallbox/test_init.py @@ -13,6 +13,7 @@ from . import ( authorisation_response, setup_integration, setup_integration_connection_error, + setup_integration_no_eco_mode, setup_integration_read_only, test_response, ) @@ -138,3 +139,15 @@ async def test_wallbox_refresh_failed_read_only( assert await hass.config_entries.async_unload(entry.entry_id) assert entry.state is ConfigEntryState.NOT_LOADED + + +async def test_wallbox_setup_load_entry_no_eco_mode( + hass: HomeAssistant, entry: MockConfigEntry +) -> None: + """Test Wallbox Unload.""" + + await setup_integration_no_eco_mode(hass, entry) + assert entry.state is ConfigEntryState.LOADED + + assert await hass.config_entries.async_unload(entry.entry_id) + assert entry.state is ConfigEntryState.NOT_LOADED diff --git a/tests/components/wyoming/test_satellite.py b/tests/components/wyoming/test_satellite.py index 800870f4604..dec5d6cbebd 100644 --- a/tests/components/wyoming/test_satellite.py +++ b/tests/components/wyoming/test_satellite.py @@ -1365,3 +1365,110 @@ async def test_announce( # Stop the satellite await hass.config_entries.async_unload(entry.entry_id) await hass.async_block_till_done() + + +async def test_tts_timeout( + hass: HomeAssistant, entity_registry: er.EntityRegistry +) -> None: + """Test entity state goes back to IDLE on a timeout.""" + events = [ + Info(satellite=SATELLITE_INFO.satellite).event(), + RunPipeline(start_stage=PipelineStage.TTS, end_stage=PipelineStage.TTS).event(), + ] + + pipeline_kwargs: dict[str, Any] = {} + pipeline_event_callback: Callable[[assist_pipeline.PipelineEvent], None] | None = ( + None + ) + run_pipeline_called = asyncio.Event() + + async def async_pipeline_from_audio_stream( + hass: HomeAssistant, + context, + event_callback, + stt_metadata, + stt_stream, + **kwargs, + ) -> None: + nonlocal pipeline_kwargs, pipeline_event_callback + pipeline_kwargs = kwargs + pipeline_event_callback = event_callback + + run_pipeline_called.set() + + response_finished = asyncio.Event() + + def tts_response_finished(self): + response_finished.set() + + with ( + patch( + "homeassistant.components.wyoming.data.load_wyoming_info", + return_value=SATELLITE_INFO, + ), + patch( + "homeassistant.components.wyoming.assist_satellite.AsyncTcpClient", + SatelliteAsyncTcpClient(events), + ), + patch( + "homeassistant.components.assist_satellite.entity.async_pipeline_from_audio_stream", + async_pipeline_from_audio_stream, + ), + patch("homeassistant.components.wyoming.assist_satellite._PING_SEND_DELAY", 0), + patch( + "homeassistant.components.wyoming.assist_satellite.WyomingAssistSatellite.tts_response_finished", + tts_response_finished, + ), + patch( + "homeassistant.components.wyoming.assist_satellite._TTS_TIMEOUT_EXTRA", + 0, + ), + ): + entry = await setup_config_entry(hass) + device: SatelliteDevice = hass.data[wyoming.DOMAIN][entry.entry_id].device + assert device is not None + + satellite_entry = next( + ( + maybe_entry + for maybe_entry in er.async_entries_for_device( + entity_registry, device.device_id + ) + if maybe_entry.domain == assist_satellite.DOMAIN + ), + None, + ) + assert satellite_entry is not None + + async with asyncio.timeout(1): + await run_pipeline_called.wait() + + # Reset so we can check the pipeline is automatically restarted below + run_pipeline_called.clear() + + assert pipeline_event_callback is not None + assert pipeline_kwargs.get("device_id") == device.device_id + + pipeline_event_callback( + assist_pipeline.PipelineEvent( + assist_pipeline.PipelineEventType.TTS_START, + { + "tts_input": "test text to speak", + "voice": "test voice", + }, + ) + ) + mock_tts_result_stream = MockResultStream(hass, "wav", get_test_wav()) + pipeline_event_callback( + assist_pipeline.PipelineEvent( + assist_pipeline.PipelineEventType.TTS_END, + {"tts_output": {"token": mock_tts_result_stream.token}}, + ) + ) + async with asyncio.timeout(1): + # tts_response_finished should be called on timeout + await response_finished.wait() + + # Stop the satellite + await hass.config_entries.async_unload(entry.entry_id) + await hass.async_block_till_done() diff --git a/tests/components/zwave_js/snapshots/test_diagnostics.ambr b/tests/components/zwave_js/snapshots/test_diagnostics.ambr index dc0dbba59b5..40ed3bbf836 100644 --- a/tests/components/zwave_js/snapshots/test_diagnostics.ambr +++ b/tests/components/zwave_js/snapshots/test_diagnostics.ambr @@ -97,8 +97,8 @@ 'value_id': '52-113-0-Home Security-Cover status', }), dict({ - 'disabled': False, - 'disabled_by': None, + 'disabled': True, + 'disabled_by': 'integration', 'domain': 'button', 'entity_category': 'config', 'entity_id': 'button.multisensor_6_idle_home_security_cover_status', @@ -120,8 +120,8 @@ 'value_id': '52-113-0-Home Security-Cover status', }), dict({ - 'disabled': False, - 'disabled_by': None, + 'disabled': True, + 'disabled_by': 'integration', 'domain': 'button', 'entity_category': 'config', 'entity_id': 'button.multisensor_6_idle_home_security_motion_sensor_status', diff --git a/tests/components/zwave_js/test_button.py b/tests/components/zwave_js/test_button.py index 0282a268b54..422888cab23 100644 --- a/tests/components/zwave_js/test_button.py +++ b/tests/components/zwave_js/test_button.py @@ -1,13 +1,21 @@ """Test the Z-Wave JS button entities.""" +from datetime import timedelta +from unittest.mock import MagicMock + import pytest +from zwave_js_server.model.node import Node from homeassistant.components.button import DOMAIN as BUTTON_DOMAIN, SERVICE_PRESS from homeassistant.components.zwave_js.const import DOMAIN, SERVICE_REFRESH_VALUE from homeassistant.components.zwave_js.helpers import get_valueless_base_unique_id -from homeassistant.const import ATTR_ENTITY_ID, Platform +from homeassistant.config_entries import RELOAD_AFTER_UPDATE_DELAY +from homeassistant.const import ATTR_ENTITY_ID, EntityCategory, Platform from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er +from homeassistant.util import dt as dt_util + +from tests.common import MockConfigEntry, async_fire_time_changed @pytest.fixture @@ -71,11 +79,32 @@ async def test_ping_entity( async def test_notification_idle_button( - hass: HomeAssistant, client, multisensor_6, integration + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + client: MagicMock, + multisensor_6: Node, + integration: MockConfigEntry, ) -> None: """Test Notification idle button.""" node = multisensor_6 - state = hass.states.get("button.multisensor_6_idle_home_security_cover_status") + entity_id = "button.multisensor_6_idle_home_security_cover_status" + entity_entry = entity_registry.async_get(entity_id) + assert entity_entry + assert entity_entry.entity_category is EntityCategory.CONFIG + assert entity_entry.disabled_by is er.RegistryEntryDisabler.INTEGRATION + assert hass.states.get(entity_id) is None # disabled by default + + entity_registry.async_update_entity( + entity_id, + disabled_by=None, + ) + async_fire_time_changed( + hass, + dt_util.utcnow() + timedelta(seconds=RELOAD_AFTER_UPDATE_DELAY + 1), + ) + await hass.async_block_till_done() + + state = hass.states.get(entity_id) assert state assert state.state == "unknown" assert ( @@ -88,13 +117,13 @@ async def test_notification_idle_button( BUTTON_DOMAIN, SERVICE_PRESS, { - ATTR_ENTITY_ID: "button.multisensor_6_idle_home_security_cover_status", + ATTR_ENTITY_ID: entity_id, }, blocking=True, ) - assert len(client.async_send_command_no_wait.call_args_list) == 1 - args = client.async_send_command_no_wait.call_args_list[0][0][0] + assert client.async_send_command_no_wait.call_count == 1 + args = client.async_send_command_no_wait.call_args[0][0] assert args["command"] == "node.manually_idle_notification_value" assert args["nodeId"] == node.node_id assert args["valueId"] == { diff --git a/tests/components/zwave_js/test_discovery.py b/tests/components/zwave_js/test_discovery.py index 7ef5f0e480f..02296262d1f 100644 --- a/tests/components/zwave_js/test_discovery.py +++ b/tests/components/zwave_js/test_discovery.py @@ -1,10 +1,12 @@ """Test entity discovery for device-specific schemas for the Z-Wave JS integration.""" +from datetime import timedelta +from unittest.mock import MagicMock + import pytest from zwave_js_server.event import Event from zwave_js_server.model.node import Node -from homeassistant.components.binary_sensor import DOMAIN as BINARY_SENSOR_DOMAIN from homeassistant.components.button import DOMAIN as BUTTON_DOMAIN, SERVICE_PRESS from homeassistant.components.light import ATTR_SUPPORTED_COLOR_MODES, ColorMode from homeassistant.components.number import ( @@ -12,7 +14,6 @@ from homeassistant.components.number import ( DOMAIN as NUMBER_DOMAIN, SERVICE_SET_VALUE, ) -from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN from homeassistant.components.switch import ( DOMAIN as SWITCH_DOMAIN, SERVICE_TURN_OFF, @@ -26,12 +27,13 @@ from homeassistant.components.zwave_js.discovery import ( from homeassistant.components.zwave_js.discovery_data_template import ( DynamicCurrentTempClimateDataTemplate, ) -from homeassistant.components.zwave_js.helpers import get_device_id +from homeassistant.config_entries import RELOAD_AFTER_UPDATE_DELAY from homeassistant.const import ATTR_ENTITY_ID, STATE_OFF, STATE_UNKNOWN, EntityCategory from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr, entity_registry as er +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_aeon_smart_switch_6_state( @@ -222,17 +224,24 @@ async def test_merten_507801_disabled_enitites( async def test_zooz_zen72( hass: HomeAssistant, entity_registry: er.EntityRegistry, - client, - switch_zooz_zen72, - integration, + client: MagicMock, + switch_zooz_zen72: Node, + integration: MockConfigEntry, ) -> None: """Test that Zooz ZEN72 Indicators are discovered as number entities.""" - assert len(hass.states.async_entity_ids(NUMBER_DOMAIN)) == 1 - assert len(hass.states.async_entity_ids(BUTTON_DOMAIN)) == 2 # includes ping entity_id = "number.z_wave_plus_700_series_dimmer_switch_indicator_value" - entry = entity_registry.async_get(entity_id) - assert entry - assert entry.entity_category == EntityCategory.CONFIG + entity_entry = entity_registry.async_get(entity_id) + assert entity_entry + assert entity_entry.entity_category == EntityCategory.CONFIG + assert entity_entry.disabled_by is er.RegistryEntryDisabler.INTEGRATION + assert hass.states.get(entity_id) is None # disabled by default + entity_registry.async_update_entity(entity_id, disabled_by=None) + async_fire_time_changed( + hass, + dt_util.utcnow() + timedelta(seconds=RELOAD_AFTER_UPDATE_DELAY + 1), + ) + await hass.async_block_till_done() + client.async_send_command.reset_mock() state = hass.states.get(entity_id) assert state assert state.state == STATE_UNKNOWN @@ -246,7 +255,7 @@ async def test_zooz_zen72( }, blocking=True, ) - assert len(client.async_send_command.call_args_list) == 1 + assert client.async_send_command.call_count == 1 args = client.async_send_command.call_args[0][0] assert args["command"] == "node.set_value" assert args["nodeId"] == switch_zooz_zen72.node_id @@ -260,16 +269,18 @@ async def test_zooz_zen72( client.async_send_command.reset_mock() entity_id = "button.z_wave_plus_700_series_dimmer_switch_identify" - entry = entity_registry.async_get(entity_id) - assert entry - assert entry.entity_category == EntityCategory.CONFIG + entity_entry = entity_registry.async_get(entity_id) + assert entity_entry + assert entity_entry.entity_category == EntityCategory.CONFIG + assert entity_entry.disabled_by is None + assert hass.states.get(entity_id) is not None await hass.services.async_call( BUTTON_DOMAIN, SERVICE_PRESS, {ATTR_ENTITY_ID: entity_id}, blocking=True, ) - assert len(client.async_send_command.call_args_list) == 1 + assert client.async_send_command.call_count == 1 args = client.async_send_command.call_args[0][0] assert args["command"] == "node.set_value" assert args["nodeId"] == switch_zooz_zen72.node_id @@ -285,53 +296,55 @@ async def test_indicator_test( hass: HomeAssistant, device_registry: dr.DeviceRegistry, entity_registry: er.EntityRegistry, - client, - indicator_test, - integration, + client: MagicMock, + indicator_test: Node, + integration: MockConfigEntry, ) -> None: """Test that Indicators are discovered properly. This test covers indicators that we don't already have device fixtures for. """ - device = device_registry.async_get_device( - identifiers={get_device_id(client.driver, indicator_test)} + binary_sensor_entity_id = "binary_sensor.this_is_a_fake_device_binary_sensor" + sensor_entity_id = "sensor.this_is_a_fake_device_sensor" + switch_entity_id = "switch.this_is_a_fake_device_switch" + + for entity_id in ( + binary_sensor_entity_id, + sensor_entity_id, + ): + entity_entry = entity_registry.async_get(entity_id) + assert entity_entry + assert entity_entry.entity_category == EntityCategory.DIAGNOSTIC + assert entity_entry.disabled_by is er.RegistryEntryDisabler.INTEGRATION + assert hass.states.get(entity_id) is None # disabled by default + entity_registry.async_update_entity(entity_id, disabled_by=None) + + entity_id = switch_entity_id + entity_entry = entity_registry.async_get(entity_id) + assert entity_entry + assert entity_entry.entity_category == EntityCategory.CONFIG + assert entity_entry.disabled_by is er.RegistryEntryDisabler.INTEGRATION + assert hass.states.get(entity_id) is None # disabled by default + entity_registry.async_update_entity(entity_id, disabled_by=None) + + async_fire_time_changed( + hass, + dt_util.utcnow() + timedelta(seconds=RELOAD_AFTER_UPDATE_DELAY + 1), ) - assert device - entities = er.async_entries_for_device(entity_registry, device.id) + await hass.async_block_till_done() + client.async_send_command.reset_mock() - def len_domain(domain): - return len([entity for entity in entities if entity.domain == domain]) - - assert len_domain(NUMBER_DOMAIN) == 0 - assert len_domain(BUTTON_DOMAIN) == 1 # only ping - assert len_domain(BINARY_SENSOR_DOMAIN) == 1 - assert len_domain(SENSOR_DOMAIN) == 3 # include node status + last seen - assert len_domain(SWITCH_DOMAIN) == 1 - - entity_id = "binary_sensor.this_is_a_fake_device_binary_sensor" - entry = entity_registry.async_get(entity_id) - assert entry - assert entry.entity_category == EntityCategory.DIAGNOSTIC + entity_id = binary_sensor_entity_id state = hass.states.get(entity_id) assert state assert state.state == STATE_OFF - client.async_send_command.reset_mock() - - entity_id = "sensor.this_is_a_fake_device_sensor" - entry = entity_registry.async_get(entity_id) - assert entry - assert entry.entity_category == EntityCategory.DIAGNOSTIC + entity_id = sensor_entity_id state = hass.states.get(entity_id) assert state assert state.state == "0.0" - client.async_send_command.reset_mock() - - entity_id = "switch.this_is_a_fake_device_switch" - entry = entity_registry.async_get(entity_id) - assert entry - assert entry.entity_category == EntityCategory.CONFIG + entity_id = switch_entity_id state = hass.states.get(entity_id) assert state assert state.state == STATE_OFF @@ -342,7 +355,7 @@ async def test_indicator_test( {ATTR_ENTITY_ID: entity_id}, blocking=True, ) - assert len(client.async_send_command.call_args_list) == 1 + assert client.async_send_command.call_count == 1 args = client.async_send_command.call_args[0][0] assert args["command"] == "node.set_value" assert args["nodeId"] == indicator_test.node_id @@ -362,7 +375,7 @@ async def test_indicator_test( {ATTR_ENTITY_ID: entity_id}, blocking=True, ) - assert len(client.async_send_command.call_args_list) == 1 + assert client.async_send_command.call_count == 1 args = client.async_send_command.call_args[0][0] assert args["command"] == "node.set_value" assert args["nodeId"] == indicator_test.node_id diff --git a/tests/components/zwave_js/test_init.py b/tests/components/zwave_js/test_init.py index ef74373ad9e..fa82b051e59 100644 --- a/tests/components/zwave_js/test_init.py +++ b/tests/components/zwave_js/test_init.py @@ -1812,7 +1812,8 @@ async def test_disabled_node_status_entity_on_node_replaced( assert state.state == STATE_UNAVAILABLE -async def test_disabled_entity_on_value_removed( +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +async def test_remove_entity_on_value_removed( hass: HomeAssistant, zp3111: Node, client: MagicMock, @@ -1823,15 +1824,6 @@ async def test_disabled_entity_on_value_removed( "button.4_in_1_sensor_idle_home_security_cover_status" ) - # must reload the integration when enabling an entity - await hass.config_entries.async_unload(integration.entry_id) - await hass.async_block_till_done() - assert integration.state is ConfigEntryState.NOT_LOADED - integration.add_to_hass(hass) - await hass.config_entries.async_setup(integration.entry_id) - await hass.async_block_till_done() - assert integration.state is ConfigEntryState.LOADED - state = hass.states.get(idle_cover_status_button_entity) assert state assert state.state != STATE_UNAVAILABLE diff --git a/tests/helpers/test_llm.py b/tests/helpers/test_llm.py index 1a9225c505b..98dee920bd9 100644 --- a/tests/helpers/test_llm.py +++ b/tests/helpers/test_llm.py @@ -36,7 +36,6 @@ def llm_context() -> llm.LLMContext: return llm.LLMContext( platform="", context=None, - user_prompt=None, language=None, assistant=None, device_id=None, @@ -162,7 +161,6 @@ async def test_assist_api( llm_context = llm.LLMContext( platform="test_platform", context=test_context, - user_prompt="test_text", language="*", assistant="conversation", device_id=None, @@ -237,7 +235,7 @@ async def test_assist_api( "area": {"value": "kitchen"}, "floor": {"value": "ground_floor"}, }, - text_input="test_text", + text_input=None, context=test_context, language="*", assistant="conversation", @@ -296,7 +294,7 @@ async def test_assist_api( "preferred_area_id": {"value": area.id}, "preferred_floor_id": {"value": floor.floor_id}, }, - text_input="test_text", + text_input=None, context=test_context, language="*", assistant="conversation", @@ -412,7 +410,6 @@ async def test_assist_api_prompt( llm_context = llm.LLMContext( platform="test_platform", context=context, - user_prompt="test_text", language="*", assistant="conversation", device_id=None, @@ -760,7 +757,6 @@ async def test_script_tool( llm_context = llm.LLMContext( platform="test_platform", context=context, - user_prompt="test_text", language="*", assistant="conversation", device_id=None, @@ -961,7 +957,6 @@ async def test_script_tool_name(hass: HomeAssistant) -> None: llm_context = llm.LLMContext( platform="test_platform", context=context, - user_prompt="test_text", language="*", assistant="conversation", device_id=None, @@ -1241,7 +1236,6 @@ async def test_calendar_get_events_tool(hass: HomeAssistant) -> None: llm_context = llm.LLMContext( platform="test_platform", context=context, - user_prompt="test_text", language="*", assistant="conversation", device_id=None, @@ -1344,7 +1338,6 @@ async def test_todo_get_items_tool(hass: HomeAssistant) -> None: llm_context = llm.LLMContext( platform="test_platform", context=context, - user_prompt="test_text", language="*", assistant="conversation", device_id=None, @@ -1451,7 +1444,6 @@ async def test_no_tools_exposed(hass: HomeAssistant) -> None: llm_context = llm.LLMContext( platform="test_platform", context=context, - user_prompt="test_text", language="*", assistant="conversation", device_id=None, diff --git a/tests/helpers/test_service.py b/tests/helpers/test_service.py index 38e7e1ae452..6b464faa110 100644 --- a/tests/helpers/test_service.py +++ b/tests/helpers/test_service.py @@ -16,6 +16,7 @@ from homeassistant import exceptions from homeassistant.auth.permissions import PolicyPermissions import homeassistant.components # noqa: F401 from homeassistant.components.group import DOMAIN as DOMAIN_GROUP, Group +from homeassistant.components.input_button import DOMAIN as DOMAIN_INPUT_BUTTON from homeassistant.components.logger import DOMAIN as DOMAIN_LOGGER from homeassistant.components.shell_command import DOMAIN as DOMAIN_SHELL_COMMAND from homeassistant.components.system_health import DOMAIN as DOMAIN_SYSTEM_HEALTH @@ -42,7 +43,12 @@ from homeassistant.helpers import ( entity_registry as er, service, ) -from homeassistant.loader import async_get_integration +from homeassistant.helpers.translation import async_get_translations +from homeassistant.loader import ( + Integration, + async_get_integration, + async_get_integrations, +) from homeassistant.setup import async_setup_component from homeassistant.util.yaml.loader import parse_yaml @@ -1092,38 +1098,66 @@ async def test_async_get_all_descriptions_failing_integration( """Test async_get_all_descriptions when async_get_integrations returns an exception.""" group_config = {DOMAIN_GROUP: {}} await async_setup_component(hass, DOMAIN_GROUP, group_config) - descriptions = await service.async_get_all_descriptions(hass) - - assert len(descriptions) == 1 - - assert "description" in descriptions["group"]["reload"] - assert "fields" in descriptions["group"]["reload"] logger_config = {DOMAIN_LOGGER: {}} await async_setup_component(hass, DOMAIN_LOGGER, logger_config) + + input_button_config = {DOMAIN_INPUT_BUTTON: {}} + await async_setup_component(hass, DOMAIN_INPUT_BUTTON, input_button_config) + + async def wrap_get_integrations( + hass: HomeAssistant, domains: Iterable[str] + ) -> dict[str, Integration | Exception]: + integrations = await async_get_integrations(hass, domains) + integrations[DOMAIN_LOGGER] = ImportError("Failed to load services.yaml") + return integrations + + async def wrap_get_translations( + hass: HomeAssistant, + language: str, + category: str, + integrations: Iterable[str] | None = None, + config_flow: bool | None = None, + ) -> dict[str, str]: + translations = await async_get_translations( + hass, language, category, integrations, config_flow + ) + return { + key: value + for key, value in translations.items() + if not key.startswith("component.logger.services.") + } + with ( patch( "homeassistant.helpers.service.async_get_integrations", - return_value={"logger": ImportError}, + wraps=wrap_get_integrations, ), patch( "homeassistant.helpers.service.translation.async_get_translations", - return_value={}, + wrap_get_translations, ), ): descriptions = await service.async_get_all_descriptions(hass) - assert len(descriptions) == 2 + assert len(descriptions) == 3 assert "Failed to load integration: logger" in caplog.text # Services are empty defaults if the load fails but should # not raise + assert descriptions[DOMAIN_GROUP]["remove"]["description"] + assert descriptions[DOMAIN_GROUP]["remove"]["fields"] + assert descriptions[DOMAIN_LOGGER]["set_level"] == { "description": "", "fields": {}, "name": "", } + assert descriptions[DOMAIN_INPUT_BUTTON]["press"]["description"] + assert descriptions[DOMAIN_INPUT_BUTTON]["press"]["fields"] == {} + assert "target" in descriptions[DOMAIN_INPUT_BUTTON]["press"] + hass.services.async_register(DOMAIN_LOGGER, "new_service", lambda x: None, None) service.async_set_service_schema( hass, DOMAIN_LOGGER, "new_service", {"description": "new service"} diff --git a/tests/scripts/test_check_config.py b/tests/scripts/test_check_config.py index 3a2007060ae..2bb58cd4d68 100644 --- a/tests/scripts/test_check_config.py +++ b/tests/scripts/test_check_config.py @@ -43,7 +43,7 @@ def mock_is_file(): """Mock is_file.""" # All files exist except for the old entity registry file with patch( - "os.path.isfile", lambda path: not path.endswith("entity_registry.yaml") + "os.path.isfile", lambda path: not str(path).endswith("entity_registry.yaml") ): yield diff --git a/tests/test_bootstrap.py b/tests/test_bootstrap.py index 2af7ef4dc07..9e1f246b551 100644 --- a/tests/test_bootstrap.py +++ b/tests/test_bootstrap.py @@ -85,6 +85,17 @@ async def test_async_enable_logging( hass: HomeAssistant, caplog: pytest.LogCaptureFixture ) -> None: """Test to ensure logging is migrated to the queue handlers.""" + config_log_file_pattern = get_test_config_dir("home-assistant.log*") + arg_log_file_pattern = "test.log*" + + # Ensure we start with a clean slate + for f in glob.glob(arg_log_file_pattern): + os.remove(f) + for f in glob.glob(config_log_file_pattern): + os.remove(f) + assert len(glob.glob(config_log_file_pattern)) == 0 + assert len(glob.glob(arg_log_file_pattern)) == 0 + with ( patch("logging.getLogger"), patch( @@ -97,6 +108,8 @@ async def test_async_enable_logging( ): await bootstrap.async_enable_logging(hass) mock_async_activate_log_queue_handler.assert_called_once() + assert len(glob.glob(config_log_file_pattern)) > 0 + mock_async_activate_log_queue_handler.reset_mock() await bootstrap.async_enable_logging( hass, @@ -104,13 +117,15 @@ async def test_async_enable_logging( log_file="test.log", ) mock_async_activate_log_queue_handler.assert_called_once() - for f in glob.glob("test.log*"): - os.remove(f) - for f in glob.glob("testing_config/home-assistant.log*"): - os.remove(f) + assert len(glob.glob(arg_log_file_pattern)) > 0 assert "Error rolling over log file" in caplog.text + for f in glob.glob(arg_log_file_pattern): + os.remove(f) + for f in glob.glob(config_log_file_pattern): + os.remove(f) + async def test_load_hassio(hass: HomeAssistant) -> None: """Test that we load the hassio integration when using Supervisor.""" diff --git a/tests/testing_config/blueprints/template/test_alarm_control_panel_with_variables.yaml b/tests/testing_config/blueprints/template/test_alarm_control_panel_with_variables.yaml new file mode 100644 index 00000000000..94a13f699ec --- /dev/null +++ b/tests/testing_config/blueprints/template/test_alarm_control_panel_with_variables.yaml @@ -0,0 +1,16 @@ +blueprint: + name: Test With Variables + description: Creates a test with variables + domain: template + input: + sensor: + name: Sensor Entity + description: The sensor entity + selector: + entity: + domain: sensor +variables: + sensor: !input sensor +alarm_control_panel: + availability: "{{ sensor | has_value }}" + state: "{{ 'armed_home' if is_state(sensor,'on') else 'disarmed' }}" diff --git a/tests/testing_config/blueprints/template/test_binary_sensor_with_variables.yaml b/tests/testing_config/blueprints/template/test_binary_sensor_with_variables.yaml new file mode 100644 index 00000000000..3cdda37644b --- /dev/null +++ b/tests/testing_config/blueprints/template/test_binary_sensor_with_variables.yaml @@ -0,0 +1,16 @@ +blueprint: + name: Test With Variables + description: Creates a test with variables + domain: template + input: + sensor: + name: Sensor Entity + description: The sensor entity + selector: + entity: + domain: sensor +variables: + sensor: !input sensor +binary_sensor: + availability: "{{ sensor | has_value }}" + state: "{{ is_state(sensor, 'on') }}" diff --git a/tests/testing_config/blueprints/template/test_cover_with_variables.yaml b/tests/testing_config/blueprints/template/test_cover_with_variables.yaml new file mode 100644 index 00000000000..dcef425f3a0 --- /dev/null +++ b/tests/testing_config/blueprints/template/test_cover_with_variables.yaml @@ -0,0 +1,18 @@ +blueprint: + name: Test With Variables + description: Creates a test with variables + domain: template + input: + sensor: + name: Sensor Entity + description: The sensor entity + selector: + entity: + domain: sensor +variables: + sensor: !input sensor +cover: + availability: "{{ sensor | has_value }}" + state: "{{ is_state(sensor,'on') }}" + open_cover: [] + close_cover: [] diff --git a/tests/testing_config/blueprints/template/test_fan_with_variables.yaml b/tests/testing_config/blueprints/template/test_fan_with_variables.yaml new file mode 100644 index 00000000000..c37cd325420 --- /dev/null +++ b/tests/testing_config/blueprints/template/test_fan_with_variables.yaml @@ -0,0 +1,18 @@ +blueprint: + name: Test With Variables + description: Creates a test with variables + domain: template + input: + sensor: + name: Sensor Entity + description: The sensor entity + selector: + entity: + domain: sensor +variables: + sensor: !input sensor +fan: + availability: "{{ sensor | has_value }}" + state: "{{ is_state(sensor,'on') }}" + turn_on: [] + turn_off: [] diff --git a/tests/testing_config/blueprints/template/test_image_with_variables.yaml b/tests/testing_config/blueprints/template/test_image_with_variables.yaml new file mode 100644 index 00000000000..990cf403f0c --- /dev/null +++ b/tests/testing_config/blueprints/template/test_image_with_variables.yaml @@ -0,0 +1,16 @@ +blueprint: + name: Test With Variables + description: Creates a test with variables + domain: template + input: + sensor: + name: Sensor Entity + description: The sensor entity + selector: + entity: + domain: sensor +variables: + sensor: !input sensor +image: + availability: "{{ sensor | has_value }}" + url: "{{ states(sensor) }}" diff --git a/tests/testing_config/blueprints/template/test_light_with_variables.yaml b/tests/testing_config/blueprints/template/test_light_with_variables.yaml new file mode 100644 index 00000000000..90b70d12105 --- /dev/null +++ b/tests/testing_config/blueprints/template/test_light_with_variables.yaml @@ -0,0 +1,18 @@ +blueprint: + name: Test With Variables + description: Creates a test with variables + domain: template + input: + sensor: + name: Sensor Entity + description: The sensor entity + selector: + entity: + domain: sensor +variables: + sensor: !input sensor +light: + availability: "{{ sensor | has_value }}" + state: "{{ is_state(sensor,'on') }}" + turn_on: [] + turn_off: [] diff --git a/tests/testing_config/blueprints/template/test_lock_with_variables.yaml b/tests/testing_config/blueprints/template/test_lock_with_variables.yaml new file mode 100644 index 00000000000..3c2e53bdff4 --- /dev/null +++ b/tests/testing_config/blueprints/template/test_lock_with_variables.yaml @@ -0,0 +1,18 @@ +blueprint: + name: Test With Variables + description: Creates a test with variables + domain: template + input: + sensor: + name: Sensor Entity + description: The sensor entity + selector: + entity: + domain: sensor +variables: + sensor: !input sensor +lock: + availability: "{{ sensor | has_value }}" + state: "{{ is_state(sensor,'on') }}" + lock: [] + unlock: [] diff --git a/tests/testing_config/blueprints/template/test_number_with_variables.yaml b/tests/testing_config/blueprints/template/test_number_with_variables.yaml new file mode 100644 index 00000000000..55c829a4a6e --- /dev/null +++ b/tests/testing_config/blueprints/template/test_number_with_variables.yaml @@ -0,0 +1,18 @@ +blueprint: + name: Test With Variables + description: Creates a test with variables + domain: template + input: + sensor: + name: Sensor Entity + description: The sensor entity + selector: + entity: + domain: sensor +variables: + sensor: !input sensor +number: + availability: "{{ sensor | has_value }}" + state: "{{ states(sensor) }}" + set_value: [] + step: 1 diff --git a/tests/testing_config/blueprints/template/test_select_with_variables.yaml b/tests/testing_config/blueprints/template/test_select_with_variables.yaml new file mode 100644 index 00000000000..35d55f1abe9 --- /dev/null +++ b/tests/testing_config/blueprints/template/test_select_with_variables.yaml @@ -0,0 +1,18 @@ +blueprint: + name: Test With Variables + description: Creates a test with variables + domain: template + input: + sensor: + name: Sensor Entity + description: The sensor entity + selector: + entity: + domain: sensor +variables: + sensor: !input sensor +select: + availability: "{{ sensor | has_value }}" + state: "{{ states(sensor) }}" + options: "{{ ['option1', 'option2'] }}" + select_option: [] diff --git a/tests/testing_config/blueprints/template/test_sensor_with_variables.yaml b/tests/testing_config/blueprints/template/test_sensor_with_variables.yaml new file mode 100644 index 00000000000..41d5dcf5bb6 --- /dev/null +++ b/tests/testing_config/blueprints/template/test_sensor_with_variables.yaml @@ -0,0 +1,16 @@ +blueprint: + name: Test With Variables + description: Creates a test with variables + domain: template + input: + sensor: + name: Sensor Entity + description: The sensor entity + selector: + entity: + domain: sensor +variables: + sensor: !input sensor +sensor: + availability: "{{ sensor | has_value }}" + state: "{{ states(sensor) }}" diff --git a/tests/testing_config/blueprints/template/test_switch_with_variables.yaml b/tests/testing_config/blueprints/template/test_switch_with_variables.yaml new file mode 100644 index 00000000000..7e145de9976 --- /dev/null +++ b/tests/testing_config/blueprints/template/test_switch_with_variables.yaml @@ -0,0 +1,18 @@ +blueprint: + name: Test With Variables + description: Creates a test with variables + domain: template + input: + sensor: + name: Sensor Entity + description: The sensor entity + selector: + entity: + domain: sensor +variables: + sensor: !input sensor +switch: + availability: "{{ sensor | has_value }}" + state: "{{ is_state(sensor,'on') }}" + turn_on: [] + turn_off: [] diff --git a/tests/testing_config/blueprints/template/test_vacuum_with_variables.yaml b/tests/testing_config/blueprints/template/test_vacuum_with_variables.yaml new file mode 100644 index 00000000000..63858da9943 --- /dev/null +++ b/tests/testing_config/blueprints/template/test_vacuum_with_variables.yaml @@ -0,0 +1,17 @@ +blueprint: + name: Test With Variables + description: Creates a test with variables + domain: template + input: + sensor: + name: Sensor Entity + description: The sensor entity + selector: + entity: + domain: sensor +variables: + sensor: !input sensor +vacuum: + availability: "{{ sensor | has_value }}" + state: "{{ states(sensor) }}" + start: [] diff --git a/tests/testing_config/blueprints/template/test_weather_with_variables.yaml b/tests/testing_config/blueprints/template/test_weather_with_variables.yaml new file mode 100644 index 00000000000..d50702bde81 --- /dev/null +++ b/tests/testing_config/blueprints/template/test_weather_with_variables.yaml @@ -0,0 +1,18 @@ +blueprint: + name: Test With Variables + description: Creates a test with variables + domain: template + input: + sensor: + name: Sensor Entity + description: The sensor entity + selector: + entity: + domain: sensor +variables: + sensor: !input sensor +weather: + availability: "{{ sensor | has_value }}" + condition_template: "{{ states(sensor) }}" + temperature_template: "{{ 20 }}" + humidity_template: "{{ 25 }}"