diff --git a/homeassistant/components/ai_task/__init__.py b/homeassistant/components/ai_task/__init__.py index 1e317186ee4931..daaf190fc55b86 100644 --- a/homeassistant/components/ai_task/__init__.py +++ b/homeassistant/components/ai_task/__init__.py @@ -126,7 +126,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: schema=vol.Schema( { vol.Required(ATTR_TASK_NAME): cv.string, - vol.Required(ATTR_ENTITY_ID): cv.entity_id, + vol.Optional(ATTR_ENTITY_ID): cv.entity_id, vol.Required(ATTR_INSTRUCTIONS): cv.string, vol.Optional(ATTR_ATTACHMENTS): vol.All( cv.ensure_list, [selector.MediaSelector({"accept": ["*/*"]})] @@ -163,9 +163,10 @@ async def async_service_generate_image(call: ServiceCall) -> ServiceResponse: class AITaskPreferences: """AI Task preferences.""" - KEYS = ("gen_data_entity_id",) + KEYS = ("gen_data_entity_id", "gen_image_entity_id") gen_data_entity_id: str | None = None + gen_image_entity_id: str | None = None def __init__(self, hass: HomeAssistant) -> None: """Initialize the preferences.""" @@ -179,17 +180,21 @@ async def async_load(self) -> None: if data is None: return for key in self.KEYS: - setattr(self, key, data[key]) + setattr(self, key, data.get(key)) @callback def async_set_preferences( self, *, gen_data_entity_id: str | None | UndefinedType = UNDEFINED, + gen_image_entity_id: str | None | UndefinedType = UNDEFINED, ) -> None: """Set the preferences.""" changed = False - for key, value in (("gen_data_entity_id", gen_data_entity_id),): + for key, value in ( + ("gen_data_entity_id", gen_data_entity_id), + ("gen_image_entity_id", gen_image_entity_id), + ): if value is not UNDEFINED: if getattr(self, key) != value: setattr(self, key, value) diff --git a/homeassistant/components/ai_task/entity.py b/homeassistant/components/ai_task/entity.py index 5b11fe95f2824d..aea776b21001ed 100644 --- a/homeassistant/components/ai_task/entity.py +++ b/homeassistant/components/ai_task/entity.py @@ -60,6 +60,10 @@ async def _async_get_ai_task_chat_log( task: GenDataTask | GenImageTask, ) -> AsyncGenerator[ChatLog]: """Context manager used to manage the ChatLog used during an AI Task.""" + user_llm_hass_api: llm.API | None = None + if isinstance(task, GenDataTask): + user_llm_hass_api = task.llm_api + # pylint: disable-next=contextmanager-generator-missing-cleanup with ( async_get_chat_log( @@ -77,6 +81,7 @@ async def _async_get_ai_task_chat_log( device_id=None, ), user_llm_prompt=DEFAULT_SYSTEM_PROMPT, + user_llm_hass_api=user_llm_hass_api, ) chat_log.async_add_user_content( diff --git a/homeassistant/components/ai_task/http.py b/homeassistant/components/ai_task/http.py index 5deffa84008a1f..ba6aa63415beae 100644 --- a/homeassistant/components/ai_task/http.py +++ b/homeassistant/components/ai_task/http.py @@ -37,6 +37,7 @@ def websocket_get_preferences( { vol.Required("type"): "ai_task/preferences/set", vol.Optional("gen_data_entity_id"): vol.Any(str, None), + vol.Optional("gen_image_entity_id"): vol.Any(str, None), } ) @websocket_api.require_admin diff --git a/homeassistant/components/ai_task/task.py b/homeassistant/components/ai_task/task.py index cc333cc7b621cc..372ac650add040 100644 --- a/homeassistant/components/ai_task/task.py +++ b/homeassistant/components/ai_task/task.py @@ -16,6 +16,7 @@ from homeassistant.components.http.auth import async_sign_path from homeassistant.core import HomeAssistant, ServiceResponse, callback from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers import llm from homeassistant.helpers.chat_session import ChatSession, async_get_chat_session from homeassistant.helpers.event import async_call_later from homeassistant.helpers.network import get_url @@ -116,6 +117,7 @@ async def async_generate_data( instructions: str, structure: vol.Schema | None = None, attachments: list[dict] | None = None, + llm_api: llm.API | None = None, ) -> GenDataTaskResult: """Run a data generation task in the AI Task integration.""" if entity_id is None: @@ -151,6 +153,7 @@ async def async_generate_data( instructions=instructions, structure=structure, attachments=resolved_attachments or None, + llm_api=llm_api, ), ) @@ -177,11 +180,17 @@ async def async_generate_image( hass: HomeAssistant, *, task_name: str, - entity_id: str, + entity_id: str | None = None, instructions: str, attachments: list[dict] | None = None, ) -> ServiceResponse: """Run an image generation task in the AI Task integration.""" + if entity_id is None: + entity_id = hass.data[DATA_PREFERENCES].gen_image_entity_id + + if entity_id is None: + raise HomeAssistantError("No entity_id provided and no preferred entity set") + entity = hass.data[DATA_COMPONENT].get_entity(entity_id) if entity is None: raise HomeAssistantError(f"AI Task entity {entity_id} not found") @@ -266,6 +275,9 @@ class GenDataTask: attachments: list[conversation.Attachment] | None = None """List of attachments to go along the instructions.""" + llm_api: llm.API | None = None + """API to provide to the LLM.""" + def __str__(self) -> str: """Return task as a string.""" return f"" diff --git a/homeassistant/components/airzone/manifest.json b/homeassistant/components/airzone/manifest.json index 1b636de0a475d2..6e4b0b50c4c028 100644 --- a/homeassistant/components/airzone/manifest.json +++ b/homeassistant/components/airzone/manifest.json @@ -11,5 +11,5 @@ "documentation": "https://www.home-assistant.io/integrations/airzone", "iot_class": "local_polling", "loggers": ["aioairzone"], - "requirements": ["aioairzone==1.0.0"] + "requirements": ["aioairzone==1.0.1"] } diff --git a/homeassistant/components/alexa_devices/__init__.py b/homeassistant/components/alexa_devices/__init__.py index 9407a2d8987fdc..af0a3d7818cc34 100644 --- a/homeassistant/components/alexa_devices/__init__.py +++ b/homeassistant/components/alexa_devices/__init__.py @@ -5,7 +5,7 @@ from homeassistant.helpers import aiohttp_client, config_validation as cv from homeassistant.helpers.typing import ConfigType -from .const import _LOGGER, CONF_LOGIN_DATA, COUNTRY_DOMAINS, DOMAIN +from .const import _LOGGER, CONF_LOGIN_DATA, CONF_SITE, COUNTRY_DOMAINS, DOMAIN from .coordinator import AmazonConfigEntry, AmazonDevicesCoordinator from .services import async_setup_services @@ -42,7 +42,23 @@ async def async_setup_entry(hass: HomeAssistant, entry: AmazonConfigEntry) -> bo async def async_migrate_entry(hass: HomeAssistant, entry: AmazonConfigEntry) -> bool: """Migrate old entry.""" - if entry.version == 1 and entry.minor_version == 1: + + if entry.version == 1 and entry.minor_version < 3: + if CONF_SITE in entry.data: + # Site in data (wrong place), just move to login data + new_data = entry.data.copy() + new_data[CONF_LOGIN_DATA][CONF_SITE] = new_data[CONF_SITE] + new_data.pop(CONF_SITE) + hass.config_entries.async_update_entry( + entry, data=new_data, version=1, minor_version=3 + ) + return True + + if CONF_SITE in entry.data[CONF_LOGIN_DATA]: + # Site is there, just update version to avoid future migrations + hass.config_entries.async_update_entry(entry, version=1, minor_version=3) + return True + _LOGGER.debug( "Migrating from version %s.%s", entry.version, entry.minor_version ) @@ -53,10 +69,10 @@ async def async_migrate_entry(hass: HomeAssistant, entry: AmazonConfigEntry) -> # Add site to login data new_data = entry.data.copy() - new_data[CONF_LOGIN_DATA]["site"] = f"https://www.amazon.{domain}" + new_data[CONF_LOGIN_DATA][CONF_SITE] = f"https://www.amazon.{domain}" hass.config_entries.async_update_entry( - entry, data=new_data, version=1, minor_version=2 + entry, data=new_data, version=1, minor_version=3 ) _LOGGER.info( diff --git a/homeassistant/components/alexa_devices/config_flow.py b/homeassistant/components/alexa_devices/config_flow.py index ccf18fd45585fd..f266a8688547e5 100644 --- a/homeassistant/components/alexa_devices/config_flow.py +++ b/homeassistant/components/alexa_devices/config_flow.py @@ -52,7 +52,7 @@ class AmazonDevicesConfigFlow(ConfigFlow, domain=DOMAIN): """Handle a config flow for Alexa Devices.""" VERSION = 1 - MINOR_VERSION = 2 + MINOR_VERSION = 3 async def async_step_user( self, user_input: dict[str, Any] | None = None diff --git a/homeassistant/components/alexa_devices/const.py b/homeassistant/components/alexa_devices/const.py index c60096bae57133..e783f67f503411 100644 --- a/homeassistant/components/alexa_devices/const.py +++ b/homeassistant/components/alexa_devices/const.py @@ -6,6 +6,7 @@ DOMAIN = "alexa_devices" CONF_LOGIN_DATA = "login_data" +CONF_SITE = "site" DEFAULT_DOMAIN = "com" COUNTRY_DOMAINS = { diff --git a/homeassistant/components/androidtv_remote/config_flow.py b/homeassistant/components/androidtv_remote/config_flow.py index cddc9e8ff7c1c7..9e7b30afa13ccf 100644 --- a/homeassistant/components/androidtv_remote/config_flow.py +++ b/homeassistant/components/androidtv_remote/config_flow.py @@ -37,7 +37,7 @@ _LOGGER = logging.getLogger(__name__) -APPS_NEW_ID = "NewApp" +APPS_NEW_ID = "add_new" CONF_APP_DELETE = "app_delete" CONF_APP_ID = "app_id" @@ -287,7 +287,9 @@ async def async_step_init( { vol.Optional(CONF_APPS): SelectSelector( SelectSelectorConfig( - options=apps, mode=SelectSelectorMode.DROPDOWN + options=apps, + mode=SelectSelectorMode.DROPDOWN, + translation_key="apps", ) ), vol.Required( diff --git a/homeassistant/components/androidtv_remote/manifest.json b/homeassistant/components/androidtv_remote/manifest.json index 9f41d8230c6cc6..822f514ca7c24f 100644 --- a/homeassistant/components/androidtv_remote/manifest.json +++ b/homeassistant/components/androidtv_remote/manifest.json @@ -7,6 +7,7 @@ "integration_type": "device", "iot_class": "local_push", "loggers": ["androidtvremote2"], + "quality_scale": "platinum", "requirements": ["androidtvremote2==0.2.3"], "zeroconf": ["_androidtvremote2._tcp.local."] } diff --git a/homeassistant/components/androidtv_remote/quality_scale.yaml b/homeassistant/components/androidtv_remote/quality_scale.yaml new file mode 100644 index 00000000000000..7669f4c4165556 --- /dev/null +++ b/homeassistant/components/androidtv_remote/quality_scale.yaml @@ -0,0 +1,78 @@ +rules: + # Bronze + action-setup: + status: exempt + comment: No integration-specific service actions are defined. + appropriate-polling: + status: exempt + comment: This is a push-based integration. + 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: + status: exempt + comment: The integration is configured on a per-device basis, so there are no dynamic devices to add. + entity-category: + status: exempt + comment: All entities are primary and do not require a specific category. + entity-device-class: done + entity-disabled-by-default: + status: exempt + comment: The integration provides only primary entities that should be enabled. + entity-translations: done + exception-translations: done + icon-translations: + status: exempt + comment: Icons are provided by the entity's device class, and no state-based icons are needed. + reconfiguration-flow: done + repair-issues: + status: exempt + comment: The integration uses the reauth flow for authentication issues, and no other repairable issues have been identified. + stale-devices: + status: exempt + comment: The integration manages a single device per config entry. Stale device removal is handled by removing the config entry. + + # Platinum + async-dependency: done + inject-websession: + status: exempt + comment: The underlying library does not use HTTP for communication. + strict-typing: done diff --git a/homeassistant/components/androidtv_remote/strings.json b/homeassistant/components/androidtv_remote/strings.json index 0014958717a6eb..971ee477b746e2 100644 --- a/homeassistant/components/androidtv_remote/strings.json +++ b/homeassistant/components/androidtv_remote/strings.json @@ -92,5 +92,12 @@ "invalid_media_type": { "message": "Invalid media type: {media_type}" } + }, + "selector": { + "apps": { + "options": { + "add_new": "Add new" + } + } } } diff --git a/homeassistant/components/bluetooth/manifest.json b/homeassistant/components/bluetooth/manifest.json index b87e4d5a2f28bf..ffffc3ec6f3e8f 100644 --- a/homeassistant/components/bluetooth/manifest.json +++ b/homeassistant/components/bluetooth/manifest.json @@ -21,6 +21,6 @@ "bluetooth-auto-recovery==1.5.2", "bluetooth-data-tools==1.28.2", "dbus-fast==2.44.3", - "habluetooth==5.6.0" + "habluetooth==5.6.2" ] } diff --git a/homeassistant/components/conversation/chat_log.py b/homeassistant/components/conversation/chat_log.py index 2f5e3b0cf8223f..56a0b46f52b2b8 100644 --- a/homeassistant/components/conversation/chat_log.py +++ b/homeassistant/components/conversation/chat_log.py @@ -507,14 +507,18 @@ async def async_update_llm_data( async def async_provide_llm_data( self, llm_context: llm.LLMContext, - user_llm_hass_api: str | list[str] | None = None, + user_llm_hass_api: str | list[str] | llm.API | 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: + if user_llm_hass_api is None: + pass + elif isinstance(user_llm_hass_api, llm.API): + llm_api = await user_llm_hass_api.async_get_api_instance(llm_context) + else: try: llm_api = await llm.async_get_api( self.hass, diff --git a/homeassistant/components/ecowitt/sensor.py b/homeassistant/components/ecowitt/sensor.py index 096c213b708109..9ad00c69ab140b 100644 --- a/homeassistant/components/ecowitt/sensor.py +++ b/homeassistant/components/ecowitt/sensor.py @@ -230,6 +230,17 @@ native_unit_of_measurement=PERCENTAGE, state_class=SensorStateClass.MEASUREMENT, ), + EcoWittSensorTypes.PM1: SensorEntityDescription( + key="PM1", + device_class=SensorDeviceClass.PM1, + native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, + state_class=SensorStateClass.MEASUREMENT, + ), + EcoWittSensorTypes.PM4: SensorEntityDescription( + key="PM4", + native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, + state_class=SensorStateClass.MEASUREMENT, + ), } diff --git a/homeassistant/components/esphome/climate.py b/homeassistant/components/esphome/climate.py index 927ea87e0bf791..8c4e06031916bc 100644 --- a/homeassistant/components/esphome/climate.py +++ b/homeassistant/components/esphome/climate.py @@ -55,7 +55,9 @@ UnitOfTemperature, ) from homeassistant.core import callback +from homeassistant.exceptions import ServiceValidationError +from .const import DOMAIN from .entity import ( EsphomeEntity, convert_api_error_ha_error, @@ -161,11 +163,9 @@ def _on_static_info_update(self, static_info: EntityInfo) -> None: self._attr_max_temp = static_info.visual_max_temperature self._attr_min_humidity = round(static_info.visual_min_humidity) self._attr_max_humidity = round(static_info.visual_max_humidity) - features = ClimateEntityFeature(0) + features = ClimateEntityFeature.TARGET_TEMPERATURE if static_info.supports_two_point_target_temperature: features |= ClimateEntityFeature.TARGET_TEMPERATURE_RANGE - else: - features |= ClimateEntityFeature.TARGET_TEMPERATURE if static_info.supports_target_humidity: features |= ClimateEntityFeature.TARGET_HUMIDITY if self.preset_modes: @@ -253,18 +253,31 @@ def current_humidity(self) -> int | None: @esphome_float_state_property def target_temperature(self) -> float | None: """Return the temperature we try to reach.""" - return self._state.target_temperature + if ( + not self._static_info.supports_two_point_target_temperature + and self.hvac_mode != HVACMode.AUTO + ): + return self._state.target_temperature + if self.hvac_mode == HVACMode.HEAT: + return self._state.target_temperature_low + if self.hvac_mode == HVACMode.COOL: + return self._state.target_temperature_high + return None @property @esphome_float_state_property def target_temperature_low(self) -> float | None: """Return the lowbound target temperature we try to reach.""" + if self.hvac_mode == HVACMode.AUTO: + return None return self._state.target_temperature_low @property @esphome_float_state_property def target_temperature_high(self) -> float | None: """Return the highbound target temperature we try to reach.""" + if self.hvac_mode == HVACMode.AUTO: + return None return self._state.target_temperature_high @property @@ -282,7 +295,27 @@ async def async_set_temperature(self, **kwargs: Any) -> None: cast(HVACMode, kwargs[ATTR_HVAC_MODE]) ) if ATTR_TEMPERATURE in kwargs: - data["target_temperature"] = kwargs[ATTR_TEMPERATURE] + if not self._static_info.supports_two_point_target_temperature: + data["target_temperature"] = kwargs[ATTR_TEMPERATURE] + else: + hvac_mode = kwargs.get(ATTR_HVAC_MODE) or self.hvac_mode + if hvac_mode == HVACMode.HEAT: + data["target_temperature_low"] = kwargs[ATTR_TEMPERATURE] + elif hvac_mode == HVACMode.COOL: + data["target_temperature_high"] = kwargs[ATTR_TEMPERATURE] + else: + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="action_call_failed", + translation_placeholders={ + "call_name": "climate.set_temperature", + "device_name": self._static_info.name, + "error": ( + f"Setting target_temperature is only supported in " + f"{HVACMode.HEAT} or {HVACMode.COOL} modes" + ), + }, + ) if ATTR_TARGET_TEMP_LOW in kwargs: data["target_temperature_low"] = kwargs[ATTR_TARGET_TEMP_LOW] if ATTR_TARGET_TEMP_HIGH in kwargs: diff --git a/homeassistant/components/esphome/manifest.json b/homeassistant/components/esphome/manifest.json index 95e9aec11c4208..0e4a2c40d4657f 100644 --- a/homeassistant/components/esphome/manifest.json +++ b/homeassistant/components/esphome/manifest.json @@ -17,7 +17,7 @@ "mqtt": ["esphome/discover/#"], "quality_scale": "platinum", "requirements": [ - "aioesphomeapi==40.0.1", + "aioesphomeapi==40.1.0", "esphome-dashboard-api==1.3.0", "bleak-esphome==3.3.0" ], diff --git a/homeassistant/components/hue/v2/group.py b/homeassistant/components/hue/v2/group.py index 41956824ab2221..eb57d99956a638 100644 --- a/homeassistant/components/hue/v2/group.py +++ b/homeassistant/components/hue/v2/group.py @@ -9,6 +9,7 @@ from aiohue.v2.controllers.events import EventType from aiohue.v2.controllers.groups import GroupedLight, Room, Zone from aiohue.v2.models.feature import DynamicStatus +from aiohue.v2.models.resource import ResourceTypes from homeassistant.components.light import ( ATTR_BRIGHTNESS, @@ -66,7 +67,11 @@ async def async_add_light(event_type: EventType, resource: GroupedLight) -> None # add current items for item in api.groups.grouped_light.items: - await async_add_light(EventType.RESOURCE_ADDED, item) + if item.owner.rtype not in [ + ResourceTypes.BRIDGE_HOME, + ResourceTypes.PRIVATE_GROUP, + ]: + await async_add_light(EventType.RESOURCE_ADDED, item) # register listener for new grouped_light config_entry.async_on_unload( diff --git a/homeassistant/components/ntfy/__init__.py b/homeassistant/components/ntfy/__init__.py index 72dbb4d2afb0bf..ccaf50ebef1dba 100644 --- a/homeassistant/components/ntfy/__init__.py +++ b/homeassistant/components/ntfy/__init__.py @@ -21,7 +21,7 @@ from .coordinator import NtfyConfigEntry, NtfyDataUpdateCoordinator _LOGGER = logging.getLogger(__name__) -PLATFORMS: list[Platform] = [Platform.NOTIFY, Platform.SENSOR] +PLATFORMS: list[Platform] = [Platform.EVENT, Platform.NOTIFY, Platform.SENSOR] async def async_setup_entry(hass: HomeAssistant, entry: NtfyConfigEntry) -> bool: diff --git a/homeassistant/components/ntfy/config_flow.py b/homeassistant/components/ntfy/config_flow.py index ba409070c76064..c817cf4ba367b1 100644 --- a/homeassistant/components/ntfy/config_flow.py +++ b/homeassistant/components/ntfy/config_flow.py @@ -38,12 +38,25 @@ from homeassistant.core import callback from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.selector import ( + SelectSelector, + SelectSelectorConfig, + SelectSelectorMode, TextSelector, TextSelectorConfig, TextSelectorType, ) -from .const import CONF_TOPIC, DEFAULT_URL, DOMAIN, SECTION_AUTH +from .const import ( + CONF_MESSAGE, + CONF_PRIORITY, + CONF_TAGS, + CONF_TITLE, + CONF_TOPIC, + DEFAULT_URL, + DOMAIN, + SECTION_AUTH, + SECTION_FILTER, +) _LOGGER = logging.getLogger(__name__) @@ -112,6 +125,29 @@ { vol.Required(CONF_TOPIC): str, vol.Optional(CONF_NAME): str, + vol.Required(SECTION_FILTER): data_entry_flow.section( + vol.Schema( + { + vol.Optional(CONF_PRIORITY): SelectSelector( + SelectSelectorConfig( + multiple=True, + options=["5", "4", "3", "2", "1"], + mode=SelectSelectorMode.DROPDOWN, + translation_key="priority", + ) + ), + vol.Optional(CONF_TAGS): TextSelector( + TextSelectorConfig( + type=TextSelectorType.TEXT, + multiple=True, + ), + ), + vol.Optional(CONF_TITLE): str, + vol.Optional(CONF_MESSAGE): str, + } + ), + {"collapsed": True}, + ), } ) @@ -408,7 +444,10 @@ async def async_step_add_topic( return self.async_create_entry( title=user_input.get(CONF_NAME, user_input[CONF_TOPIC]), - data={CONF_TOPIC: user_input[CONF_TOPIC]}, + data={ + CONF_TOPIC: user_input[CONF_TOPIC], + **user_input[SECTION_FILTER], + }, unique_id=user_input[CONF_TOPIC], ) return self.async_show_form( diff --git a/homeassistant/components/ntfy/const.py b/homeassistant/components/ntfy/const.py index 78355f7e828da7..5fb500917d67cf 100644 --- a/homeassistant/components/ntfy/const.py +++ b/homeassistant/components/ntfy/const.py @@ -6,4 +6,10 @@ DEFAULT_URL: Final = "https://ntfy.sh" CONF_TOPIC = "topic" +CONF_PRIORITY = "filter_priority" +CONF_TITLE = "filter_title" +CONF_MESSAGE = "filter_message" +CONF_TAGS = "filter_tags" SECTION_AUTH = "auth" +SECTION_FILTER = "filter" +NTFY_EVENT = "ntfy_event" diff --git a/homeassistant/components/ntfy/entity.py b/homeassistant/components/ntfy/entity.py new file mode 100644 index 00000000000000..d03d953799f058 --- /dev/null +++ b/homeassistant/components/ntfy/entity.py @@ -0,0 +1,43 @@ +"""Base entity for ntfy integration.""" + +from __future__ import annotations + +from yarl import URL + +from homeassistant.config_entries import ConfigSubentry +from homeassistant.const import CONF_URL +from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo +from homeassistant.helpers.entity import Entity + +from .const import CONF_TOPIC, DOMAIN +from .coordinator import NtfyConfigEntry + + +class NtfyBaseEntity(Entity): + """Base entity.""" + + _attr_has_entity_name = True + _attr_should_poll = False + + def __init__( + self, + config_entry: NtfyConfigEntry, + subentry: ConfigSubentry, + ) -> None: + """Initialize the entity.""" + self.topic = subentry.data[CONF_TOPIC] + + self._attr_unique_id = f"{config_entry.entry_id}_{subentry.subentry_id}_{self.entity_description.key}" + + self._attr_device_info = DeviceInfo( + entry_type=DeviceEntryType.SERVICE, + manufacturer="ntfy LLC", + model="ntfy", + name=subentry.title, + configuration_url=URL(config_entry.data[CONF_URL]) / self.topic, + identifiers={(DOMAIN, f"{config_entry.entry_id}_{subentry.subentry_id}")}, + via_device=(DOMAIN, config_entry.entry_id), + ) + self.ntfy = config_entry.runtime_data.ntfy + self.config_entry = config_entry + self.subentry = subentry diff --git a/homeassistant/components/ntfy/event.py b/homeassistant/components/ntfy/event.py new file mode 100644 index 00000000000000..d961b67dcb8578 --- /dev/null +++ b/homeassistant/components/ntfy/event.py @@ -0,0 +1,151 @@ +"""Event platform for ntfy integration.""" + +from __future__ import annotations + +import asyncio +import logging +from typing import TYPE_CHECKING + +from aiontfy import Event, Notification +from aiontfy.exceptions import ( + NtfyConnectionError, + NtfyForbiddenError, + NtfyHTTPError, + NtfyTimeoutError, +) + +from homeassistant.components.event import EventEntity, EventEntityDescription +from homeassistant.config_entries import ConfigSubentry +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from .const import CONF_MESSAGE, CONF_PRIORITY, CONF_TAGS, CONF_TITLE +from .coordinator import NtfyConfigEntry +from .entity import NtfyBaseEntity + +_LOGGER = logging.getLogger(__name__) + +PARALLEL_UPDATES = 0 +RECONNECT_INTERVAL = 10 + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: NtfyConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up the event platform.""" + + for subentry_id, subentry in config_entry.subentries.items(): + async_add_entities( + [NtfyEventEntity(config_entry, subentry)], config_subentry_id=subentry_id + ) + + +class NtfyEventEntity(NtfyBaseEntity, EventEntity): + """An event entity.""" + + entity_description = EventEntityDescription( + key="subscribe", + translation_key="subscribe", + name=None, + event_types=["triggered"], + ) + + def __init__( + self, + config_entry: NtfyConfigEntry, + subentry: ConfigSubentry, + ) -> None: + """Initialize the entity.""" + super().__init__(config_entry, subentry) + self._ws: asyncio.Task | None = None + + @callback + def _async_handle_event(self, notification: Notification) -> None: + """Handle the ntfy event.""" + if notification.topic == self.topic and notification.event is Event.MESSAGE: + event = ( + f"{notification.title}: {notification.message}" + if notification.title + else notification.message + ) + if TYPE_CHECKING: + assert event + self._attr_event_types = [event] + self._trigger_event(event, notification.to_dict()) + self.async_write_ha_state() + + async def async_added_to_hass(self) -> None: + """Run when entity about to be added to hass.""" + + self.config_entry.async_create_background_task( + self.hass, + self.ws_connect(), + "websocket_watchdog", + ) + + async def ws_connect(self) -> None: + """Connect websocket.""" + while True: + try: + if self._ws and (exc := self._ws.exception()): + raise exc # noqa: TRY301 + except asyncio.InvalidStateError: + self._attr_available = True + except asyncio.CancelledError: + self._attr_available = False + return + except NtfyForbiddenError: + if self._attr_available: + _LOGGER.error("Failed to subscribe to topic. Topic is protected") + self._attr_available = False + return + except NtfyHTTPError as e: + if self._attr_available: + _LOGGER.error( + "Failed to connect to ntfy service due to a server error: %s (%s)", + e.error, + e.link, + ) + self._attr_available = False + except NtfyConnectionError: + if self._attr_available: + _LOGGER.error( + "Failed to connect to ntfy service due to a connection error" + ) + self._attr_available = False + except NtfyTimeoutError: + if self._attr_available: + _LOGGER.error( + "Failed to connect to ntfy service due to a connection timeout" + ) + self._attr_available = False + except Exception: + if self._attr_available: + _LOGGER.exception( + "Failed to connect to ntfy service due to an unexpected exception" + ) + self._attr_available = False + finally: + if self._ws is None or self._ws.done(): + self._ws = self.config_entry.async_create_background_task( + self.hass, + target=self.ntfy.subscribe( + topics=[self.topic], + callback=self._async_handle_event, + title=self.subentry.data.get(CONF_TITLE), + message=self.subentry.data.get(CONF_MESSAGE), + priority=self.subentry.data.get(CONF_PRIORITY), + tags=self.subentry.data.get(CONF_TAGS), + ), + name="ntfy_websocket", + ) + self.async_write_ha_state() + await asyncio.sleep(RECONNECT_INTERVAL) + + @property + def entity_picture(self) -> str | None: + """Return the entity picture to use in the frontend, if any.""" + + return self.state_attributes.get("icon") or super().entity_picture diff --git a/homeassistant/components/ntfy/icons.json b/homeassistant/components/ntfy/icons.json index 66489413b5b38a..4b04a16f69f5ff 100644 --- a/homeassistant/components/ntfy/icons.json +++ b/homeassistant/components/ntfy/icons.json @@ -5,6 +5,11 @@ "default": "mdi:console-line" } }, + "event": { + "subscribe": { + "default": "mdi:message-outline" + } + }, "sensor": { "messages": { "default": "mdi:message-arrow-right-outline" @@ -67,5 +72,10 @@ "default": "mdi:star" } } + }, + "services": { + "publish": { + "service": "mdi:send" + } } } diff --git a/homeassistant/components/ntfy/manifest.json b/homeassistant/components/ntfy/manifest.json index f041b02b6d674a..ba18dcb4f50a6c 100644 --- a/homeassistant/components/ntfy/manifest.json +++ b/homeassistant/components/ntfy/manifest.json @@ -7,5 +7,5 @@ "iot_class": "cloud_push", "loggers": ["aionfty"], "quality_scale": "bronze", - "requirements": ["aiontfy==0.5.4"] + "requirements": ["aiontfy==0.5.5"] } diff --git a/homeassistant/components/ntfy/notify.py b/homeassistant/components/ntfy/notify.py index 214e3d7e125c1b..176dddd7a44ce2 100644 --- a/homeassistant/components/ntfy/notify.py +++ b/homeassistant/components/ntfy/notify.py @@ -2,32 +2,68 @@ from __future__ import annotations +from datetime import timedelta +from typing import Any + from aiontfy import Message from aiontfy.exceptions import ( NtfyException, NtfyHTTPError, NtfyUnauthorizedAuthenticationError, ) +import voluptuous as vol from yarl import URL from homeassistant.components.notify import ( + ATTR_MESSAGE, + ATTR_TITLE, NotifyEntity, NotifyEntityDescription, NotifyEntityFeature, ) -from homeassistant.config_entries import ConfigSubentry -from homeassistant.const import CONF_URL from homeassistant.core import HomeAssistant -from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo +from homeassistant.exceptions import HomeAssistantError, ServiceValidationError +from homeassistant.helpers import config_validation as cv, entity_platform from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from .const import CONF_TOPIC, DOMAIN +from .const import DOMAIN from .coordinator import NtfyConfigEntry +from .entity import NtfyBaseEntity PARALLEL_UPDATES = 0 +SERVICE_PUBLISH = "publish" +ATTR_ATTACH = "attach" +ATTR_CALL = "call" +ATTR_CLICK = "click" +ATTR_DELAY = "delay" +ATTR_EMAIL = "email" +ATTR_ICON = "icon" +ATTR_MARKDOWN = "markdown" +ATTR_PRIORITY = "priority" +ATTR_TAGS = "tags" + +SERVICE_PUBLISH_SCHEMA = cv.make_entity_service_schema( + { + vol.Optional(ATTR_TITLE): cv.string, + vol.Optional(ATTR_MESSAGE): cv.string, + vol.Optional(ATTR_MARKDOWN): cv.boolean, + vol.Optional(ATTR_TAGS): vol.All(cv.ensure_list, [str]), + vol.Optional(ATTR_PRIORITY): vol.All(vol.Coerce(int), vol.Range(1, 5)), + vol.Optional(ATTR_CLICK): vol.All(vol.Url(), vol.Coerce(URL)), + vol.Optional(ATTR_DELAY): vol.All( + cv.time_period, + vol.Range(min=timedelta(seconds=10), max=timedelta(days=3)), + ), + vol.Optional(ATTR_ATTACH): vol.All(vol.Url(), vol.Coerce(URL)), + vol.Optional(ATTR_EMAIL): vol.Email(), + vol.Optional(ATTR_CALL): cv.string, + vol.Optional(ATTR_ICON): vol.All(vol.Url(), vol.Coerce(URL)), + } +) + + async def async_setup_entry( hass: HomeAssistant, config_entry: NtfyConfigEntry, @@ -40,43 +76,47 @@ async def async_setup_entry( [NtfyNotifyEntity(config_entry, subentry)], config_subentry_id=subentry_id ) + platform = entity_platform.async_get_current_platform() + platform.async_register_entity_service( + SERVICE_PUBLISH, + SERVICE_PUBLISH_SCHEMA, + "publish", + ) + -class NtfyNotifyEntity(NotifyEntity): +class NtfyNotifyEntity(NtfyBaseEntity, NotifyEntity): """Representation of a ntfy notification entity.""" entity_description = NotifyEntityDescription( key="publish", translation_key="publish", name=None, - has_entity_name=True, ) _attr_supported_features = NotifyEntityFeature.TITLE - def __init__( - self, - config_entry: NtfyConfigEntry, - subentry: ConfigSubentry, - ) -> None: - """Initialize a notification entity.""" - - self._attr_unique_id = f"{config_entry.entry_id}_{subentry.subentry_id}_{self.entity_description.key}" - self.topic = subentry.data[CONF_TOPIC] - - self._attr_device_info = DeviceInfo( - entry_type=DeviceEntryType.SERVICE, - manufacturer="ntfy LLC", - model="ntfy", - name=subentry.title, - configuration_url=URL(config_entry.data[CONF_URL]) / self.topic, - identifiers={(DOMAIN, f"{config_entry.entry_id}_{subentry.subentry_id}")}, - via_device=(DOMAIN, config_entry.entry_id), - ) - self.config_entry = config_entry - self.ntfy = config_entry.runtime_data.ntfy - async def async_send_message(self, message: str, title: str | None = None) -> None: """Publish a message to a topic.""" - msg = Message(topic=self.topic, message=message, title=title) + await self.publish(message=message, title=title) + + async def publish(self, **kwargs: Any) -> None: + """Publish a message to a topic.""" + + params: dict[str, Any] = kwargs + delay: timedelta | None = params.get("delay") + if delay: + params["delay"] = f"{delay.total_seconds()}s" + if params.get("email"): + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="delay_no_email", + ) + if params.get("call"): + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="delay_no_call", + ) + + msg = Message(topic=self.topic, **params) try: await self.ntfy.publish(msg) except NtfyUnauthorizedAuthenticationError as e: diff --git a/homeassistant/components/ntfy/quality_scale.yaml b/homeassistant/components/ntfy/quality_scale.yaml index 43a96135baf383..2e2a7910bba0da 100644 --- a/homeassistant/components/ntfy/quality_scale.yaml +++ b/homeassistant/components/ntfy/quality_scale.yaml @@ -7,21 +7,15 @@ rules: status: exempt comment: the integration does not poll brands: done - common-modules: - status: exempt - comment: the integration currently implements only one platform and has no coordinator + common-modules: done config-flow-test-coverage: done config-flow: done dependency-transparency: done - docs-actions: - status: exempt - comment: integration has only entity actions + docs-actions: done docs-high-level-description: done docs-installation-instructions: done docs-removal-instructions: done - entity-event-setup: - status: exempt - comment: the integration does not subscribe to events + entity-event-setup: done entity-unique-id: done has-entity-name: done runtime-data: done @@ -36,13 +30,9 @@ rules: status: exempt comment: the integration has no options docs-installation-parameters: done - entity-unavailable: - status: exempt - comment: the integration only implements a stateless notify entity. + entity-unavailable: done integration-owner: done - log-when-unavailable: - status: exempt - comment: the integration only integrates state-less entities + log-when-unavailable: done parallel-updates: done reauthentication-flow: done test-coverage: done diff --git a/homeassistant/components/ntfy/services.yaml b/homeassistant/components/ntfy/services.yaml new file mode 100644 index 00000000000000..2c8e00746e5a27 --- /dev/null +++ b/homeassistant/components/ntfy/services.yaml @@ -0,0 +1,90 @@ +publish: + target: + entity: + domain: notify + integration: ntfy + fields: + title: + required: false + selector: + text: + example: Hello + message: + required: false + selector: + text: + multiline: true + example: World + markdown: + required: false + selector: + constant: + value: true + label: "" + example: true + tags: + required: false + selector: + text: + multiple: true + example: '["partying_face", "grin"]' + priority: + required: false + selector: + select: + options: + - value: "5" + label: "max" + - value: "4" + label: "high" + - value: "3" + label: "default" + - value: "2" + label: "low" + - value: "1" + label: "min" + mode: dropdown + translation_key: "priority" + sort: false + example: "5" + click: + required: false + selector: + text: + type: url + autocomplete: url + example: https://example.org + delay: + required: false + selector: + duration: + enable_day: true + example: '{"seconds": 30}' + attach: + required: false + selector: + text: + type: url + autocomplete: url + example: https://example.org/download.zip + email: + required: false + selector: + text: + type: email + autocomplete: email + example: mail@example.org + call: + required: false + selector: + text: + type: tel + autocomplete: tel + example: "1234567890" + icon: + required: false + selector: + text: + type: url + autocomplete: url + example: https://example.org/logo.png diff --git a/homeassistant/components/ntfy/strings.json b/homeassistant/components/ntfy/strings.json index 08a0a20a30a1d6..f5bf85e4243dc2 100644 --- a/homeassistant/components/ntfy/strings.json +++ b/homeassistant/components/ntfy/strings.json @@ -102,6 +102,24 @@ "data_description": { "topic": "Enter the name of the topic you want to use for notifications. Topics may not be password-protected, so choose a name that's not easy to guess.", "name": "Set an alternative name to display instead of the topic name. This helps identify topics with complex or hard-to-read names more easily." + }, + "sections": { + "filter": { + "data": { + "filter_priority": "Filter by priority", + "filter_tags": "Filter by tags", + "filter_title": "Filter by title", + "filter_message": "Filter by message content" + }, + "data_description": { + "filter_priority": "Include messages that match any of the selected priority levels. If no priority is selected, all messages are included by default", + "filter_tags": "Only include messages that have all selected tags", + "filter_title": "Include messages with a title that exactly matches the specified text", + "filter_message": "Include messages with content that exactly matches the specified text" + }, + "name": "Message filters (optional)", + "description": "Apply filters to narrow down the messages received when Home Assistant subscribes to the topic. Filters apply only to the event entity." + } } } }, @@ -121,6 +139,28 @@ } }, "entity": { + "event": { + "subscribe": { + "state_attributes": { + "event_type": { + "state": { + "triggered": "Triggered" + } + }, + "time": { "name": "Time" }, + "expires": { "name": "Expires" }, + "topic": { "name": "[%key:component::ntfy::common::topic%]" }, + "message": { "name": "Message" }, + "title": { "name": "Title" }, + "tags": { "name": "Tags" }, + "priority": { "name": "Priority" }, + "click": { "name": "Click" }, + "icon": { "name": "Icon" }, + "actions": { "name": "Actions" }, + "attachment": { "name": "Attachment" } + } + } + }, "sensor": { "messages": { "name": "Messages published", @@ -221,6 +261,81 @@ }, "timeout_error": { "message": "Failed to connect to ntfy service due to a connection timeout" + }, + "entity_not_found": { + "message": "The selected ntfy entity could not be found." + }, + "entry_not_loaded": { + "message": "The selected ntfy service is currently not loaded or disabled in Home Assistant." + }, + "delay_no_email": { + "message": "Delayed email notifications are not supported" + }, + "delay_no_call": { + "message": "Delayed call notifications are not supported" + } + }, + "services": { + "publish": { + "name": "Publish notification", + "description": "Publishes a notification message to a ntfy topic", + "fields": { + "title": { + "name": "[%key:component::notify::services::send_message::fields::title::name%]", + "description": "[%key:component::notify::services::send_message::fields::title::description%]" + }, + "message": { + "name": "[%key:component::notify::services::send_message::fields::message::name%]", + "description": "[%key:component::notify::services::send_message::fields::message::description%]" + }, + "markdown": { + "name": "Format as Markdown", + "description": "Enable Markdown formatting for the message body (Web app only). See the Markdown guide for syntax details: https://www.markdownguide.org/basic-syntax/." + }, + "tags": { + "name": "Tags/Emojis", + "description": "Add tags or emojis to the notification. Emojis (using shortcodes like smile) will appear in the notification title or message. Other tags will be displayed below the notification content." + }, + "priority": { + "name": "Message priority", + "description": "All messages have a priority that defines how urgently your phone notifies you, depending on the configured vibration patterns, notification sounds, and visibility in the notification drawer or pop-over." + }, + "click": { + "name": "Click URL", + "description": "URL that is opened when notification is clicked." + }, + "delay": { + "name": "Delay delivery", + "description": "Set a delay for message delivery. Minimum delay is 10 seconds, maximum is 3 days." + }, + "attach": { + "name": "Attachment URL", + "description": "Attach images or other files by URL." + }, + "email": { + "name": "Forward to email", + "description": "Specify the address to forward the notification to, for example mail@example.com" + }, + "call": { + "name": "Phone call", + "description": "Phone number to call and read the message out loud using text-to-speech. Requires ntfy Pro and prior phone number verification." + }, + "icon": { + "name": "Icon URL", + "description": "Include an icon that will appear next to the text of the notification. Only JPEG and PNG images are supported." + } + } + } + }, + "selector": { + "priority": { + "options": { + "1": "Minimum", + "2": "[%key:common::state::low%]", + "3": "Default", + "4": "[%key:common::state::high%]", + "5": "Maximum" + } } } } diff --git a/homeassistant/components/squeezebox/media_player.py b/homeassistant/components/squeezebox/media_player.py index a857602a584110..a5f5288807f324 100644 --- a/homeassistant/components/squeezebox/media_player.py +++ b/homeassistant/components/squeezebox/media_player.py @@ -607,7 +607,7 @@ async def async_search_media( _media_content_type_list = ( query.media_content_type.lower().replace(", ", ",").split(",") if query.media_content_type - else ["albums", "tracks", "artists", "genres"] + else ["albums", "tracks", "artists", "genres", "playlists"] ) if query.media_content_type and set(_media_content_type_list).difference( diff --git a/homeassistant/components/ssdp/server.py b/homeassistant/components/ssdp/server.py index b6e105b9560341..a9cea01a51788a 100644 --- a/homeassistant/components/ssdp/server.py +++ b/homeassistant/components/ssdp/server.py @@ -3,6 +3,7 @@ from __future__ import annotations import asyncio +from contextlib import ExitStack import logging import socket from time import time @@ -89,22 +90,29 @@ class HassUpnpServiceDevice(UpnpServerDevice): SERVICES: list[type[UpnpServerService]] = [] -async def _async_find_next_available_port(source: AddressTupleVXType) -> int: +async def _async_find_next_available_port( + source: AddressTupleVXType, +) -> tuple[int, socket.socket]: """Get a free TCP port.""" family = socket.AF_INET if is_ipv4_address(source) else socket.AF_INET6 - test_socket = socket.socket(family, socket.SOCK_STREAM) - test_socket.setblocking(False) - test_socket.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) - - for port in range(UPNP_SERVER_MIN_PORT, UPNP_SERVER_MAX_PORT): - addr = (source[0], port, *source[2:]) - try: - test_socket.bind(addr) - except OSError: - if port == UPNP_SERVER_MAX_PORT - 1: - raise - else: - return port + # We use an ExitStack to ensure the socket is closed if we fail to find a port. + with ExitStack() as stack: + test_socket = stack.enter_context(socket.socket(family, socket.SOCK_STREAM)) + test_socket.setblocking(False) + test_socket.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) + + for port in range(UPNP_SERVER_MIN_PORT, UPNP_SERVER_MAX_PORT): + addr = (source[0], port, *source[2:]) + try: + test_socket.bind(addr) + except OSError: + if port == UPNP_SERVER_MAX_PORT - 1: + raise + else: + # The socket will be dealt by the caller, so we detach it from the stack + # before returning it to prevent it from being closed. + stack.pop_all() + return port, test_socket raise RuntimeError("unreachable") @@ -167,35 +175,43 @@ async def _async_start_upnp_servers(self, event: Event) -> None: # Start a server on all source IPs. boot_id = int(time()) - for source_ip in await async_build_source_set(self.hass): - source_ip_str = str(source_ip) - if source_ip.version == 6: - assert source_ip.scope_id is not None - source_tuple: AddressTupleVXType = ( - source_ip_str, - 0, - 0, - int(source_ip.scope_id), + # We use an ExitStack to ensure that all sockets are closed. + # The socket is created in _async_find_next_available_port, + # and should be kept open until UpnpServer is started to + # keep the kernel from reassigning the port. + with ExitStack() as stack: + for source_ip in await async_build_source_set(self.hass): + source_ip_str = str(source_ip) + if source_ip.version == 6: + assert source_ip.scope_id is not None + source_tuple: AddressTupleVXType = ( + source_ip_str, + 0, + 0, + int(source_ip.scope_id), + ) + else: + source_tuple = (source_ip_str, 0) + source, target = determine_source_target(source_tuple) + source = fix_ipv6_address_scope_id(source) or source + http_port, http_socket = await _async_find_next_available_port(source) + stack.enter_context(http_socket) + _LOGGER.debug( + "Binding UPnP HTTP server to: %s:%s", source_ip, http_port ) - else: - source_tuple = (source_ip_str, 0) - source, target = determine_source_target(source_tuple) - source = fix_ipv6_address_scope_id(source) or source - http_port = await _async_find_next_available_port(source) - _LOGGER.debug("Binding UPnP HTTP server to: %s:%s", source_ip, http_port) - self._upnp_servers.append( - UpnpServer( - source=source, - target=target, - http_port=http_port, - server_device=HassUpnpServiceDevice, - boot_id=boot_id, + self._upnp_servers.append( + UpnpServer( + source=source, + target=target, + http_port=http_port, + server_device=HassUpnpServiceDevice, + boot_id=boot_id, + ) ) + results = await asyncio.gather( + *(upnp_server.async_start() for upnp_server in self._upnp_servers), + return_exceptions=True, ) - results = await asyncio.gather( - *(upnp_server.async_start() for upnp_server in self._upnp_servers), - return_exceptions=True, - ) failed_servers = [] for idx, result in enumerate(results): if isinstance(result, Exception): diff --git a/homeassistant/components/tasmota/manifest.json b/homeassistant/components/tasmota/manifest.json index 2e0d8af233833d..6c2d7ee271b098 100644 --- a/homeassistant/components/tasmota/manifest.json +++ b/homeassistant/components/tasmota/manifest.json @@ -8,5 +8,5 @@ "iot_class": "local_push", "loggers": ["hatasmota"], "mqtt": ["tasmota/discovery/#"], - "requirements": ["HATasmota==0.10.0"] + "requirements": ["HATasmota==0.10.1"] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 3b012c8b6eccff..1bf0613c4a22c1 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -34,7 +34,7 @@ dbus-fast==2.44.3 fnv-hash-fast==1.5.0 go2rtc-client==0.2.1 ha-ffmpeg==3.2.2 -habluetooth==5.6.0 +habluetooth==5.6.2 hass-nabucasa==1.1.0 hassil==3.2.0 home-assistant-bluetooth==1.13.1 diff --git a/requirements_all.txt b/requirements_all.txt index d17cdcdae681ac..300dba8c4eda3c 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -19,7 +19,7 @@ DoorBirdPy==3.0.8 HAP-python==5.0.0 # homeassistant.components.tasmota -HATasmota==0.10.0 +HATasmota==0.10.1 # homeassistant.components.mastodon Mastodon.py==2.1.2 @@ -182,7 +182,7 @@ aioairq==0.4.6 aioairzone-cloud==0.7.2 # homeassistant.components.airzone -aioairzone==1.0.0 +aioairzone==1.0.1 # homeassistant.components.alexa_devices aioamazondevices==6.0.0 @@ -247,7 +247,7 @@ aioelectricitymaps==1.1.1 aioemonitor==1.0.5 # homeassistant.components.esphome -aioesphomeapi==40.0.1 +aioesphomeapi==40.1.0 # homeassistant.components.flo aioflo==2021.11.0 @@ -325,7 +325,7 @@ aionanoleaf==0.2.1 aionotion==2024.03.0 # homeassistant.components.ntfy -aiontfy==0.5.4 +aiontfy==0.5.5 # homeassistant.components.nut aionut==4.3.4 @@ -1137,7 +1137,7 @@ ha-silabs-firmware-client==0.2.0 habiticalib==0.4.5 # homeassistant.components.bluetooth -habluetooth==5.6.0 +habluetooth==5.6.2 # homeassistant.components.cloud hass-nabucasa==1.1.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 9861e410741d30..2d7a0289e49a75 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -19,7 +19,7 @@ DoorBirdPy==3.0.8 HAP-python==5.0.0 # homeassistant.components.tasmota -HATasmota==0.10.0 +HATasmota==0.10.1 # homeassistant.components.mastodon Mastodon.py==2.1.2 @@ -170,7 +170,7 @@ aioairq==0.4.6 aioairzone-cloud==0.7.2 # homeassistant.components.airzone -aioairzone==1.0.0 +aioairzone==1.0.1 # homeassistant.components.alexa_devices aioamazondevices==6.0.0 @@ -235,7 +235,7 @@ aioelectricitymaps==1.1.1 aioemonitor==1.0.5 # homeassistant.components.esphome -aioesphomeapi==40.0.1 +aioesphomeapi==40.1.0 # homeassistant.components.flo aioflo==2021.11.0 @@ -307,7 +307,7 @@ aionanoleaf==0.2.1 aionotion==2024.03.0 # homeassistant.components.ntfy -aiontfy==0.5.4 +aiontfy==0.5.5 # homeassistant.components.nut aionut==4.3.4 @@ -998,7 +998,7 @@ ha-silabs-firmware-client==0.2.0 habiticalib==0.4.5 # homeassistant.components.bluetooth -habluetooth==5.6.0 +habluetooth==5.6.2 # homeassistant.components.cloud hass-nabucasa==1.1.0 diff --git a/script/hassfest/quality_scale.py b/script/hassfest/quality_scale.py index 707360dd3a39f1..62074b9a1cf108 100644 --- a/script/hassfest/quality_scale.py +++ b/script/hassfest/quality_scale.py @@ -152,7 +152,6 @@ class Rule: "analytics", "android_ip_webcam", "androidtv", - "androidtv_remote", "anel_pwrctrl", "anova", "anthemav", @@ -1178,7 +1177,6 @@ class Rule: "analytics_insights", "android_ip_webcam", "androidtv", - "androidtv_remote", "anel_pwrctrl", "anova", "anthemav", diff --git a/tests/components/ai_task/test_http.py b/tests/components/ai_task/test_http.py index a2eecfddf74337..545dce0c1c2ad4 100644 --- a/tests/components/ai_task/test_http.py +++ b/tests/components/ai_task/test_http.py @@ -19,6 +19,7 @@ async def test_ws_preferences( assert msg["success"] assert msg["result"] == { "gen_data_entity_id": None, + "gen_image_entity_id": None, } # Set preferences @@ -32,6 +33,7 @@ async def test_ws_preferences( assert msg["success"] assert msg["result"] == { "gen_data_entity_id": "ai_task.summary_1", + "gen_image_entity_id": None, } # Get updated preferences @@ -40,6 +42,7 @@ async def test_ws_preferences( assert msg["success"] assert msg["result"] == { "gen_data_entity_id": "ai_task.summary_1", + "gen_image_entity_id": None, } # Update an existing preference @@ -53,6 +56,7 @@ async def test_ws_preferences( assert msg["success"] assert msg["result"] == { "gen_data_entity_id": "ai_task.summary_2", + "gen_image_entity_id": None, } # Get updated preferences @@ -61,6 +65,7 @@ async def test_ws_preferences( assert msg["success"] assert msg["result"] == { "gen_data_entity_id": "ai_task.summary_2", + "gen_image_entity_id": None, } # No preferences set will preserve existing preferences @@ -73,6 +78,7 @@ async def test_ws_preferences( assert msg["success"] assert msg["result"] == { "gen_data_entity_id": "ai_task.summary_2", + "gen_image_entity_id": None, } # Get updated preferences @@ -81,4 +87,43 @@ async def test_ws_preferences( assert msg["success"] assert msg["result"] == { "gen_data_entity_id": "ai_task.summary_2", + "gen_image_entity_id": None, + } + + # Set gen_image_entity_id preference + await client.send_json_auto_id( + { + "type": "ai_task/preferences/set", + "gen_image_entity_id": "ai_task.image_gen_1", + } + ) + msg = await client.receive_json() + assert msg["success"] + assert msg["result"] == { + "gen_data_entity_id": "ai_task.summary_2", + "gen_image_entity_id": "ai_task.image_gen_1", + } + + # Update both preferences + await client.send_json_auto_id( + { + "type": "ai_task/preferences/set", + "gen_data_entity_id": "ai_task.summary_3", + "gen_image_entity_id": "ai_task.image_gen_2", + } + ) + msg = await client.receive_json() + assert msg["success"] + assert msg["result"] == { + "gen_data_entity_id": "ai_task.summary_3", + "gen_image_entity_id": "ai_task.image_gen_2", + } + + # Get final preferences + await client.send_json_auto_id({"type": "ai_task/preferences/get"}) + msg = await client.receive_json() + assert msg["success"] + assert msg["result"] == { + "gen_data_entity_id": "ai_task.summary_3", + "gen_image_entity_id": "ai_task.image_gen_2", } diff --git a/tests/components/ai_task/test_init.py b/tests/components/ai_task/test_init.py index 09ee926c187c0a..e89e4cea670721 100644 --- a/tests/components/ai_task/test_init.py +++ b/tests/components/ai_task/test_init.py @@ -12,6 +12,7 @@ from homeassistant.components.ai_task import AITaskPreferences from homeassistant.components.ai_task.const import DATA_PREFERENCES from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import selector from .conftest import TEST_ENTITY_ID, MockAITaskEntity @@ -277,3 +278,70 @@ async def test_generate_data_service_invalid_structure( blocking=True, return_response=True, ) + + +@pytest.mark.parametrize( + ("set_preferences", "msg_extra"), + [ + ({}, {"entity_id": TEST_ENTITY_ID}), + ({"gen_image_entity_id": TEST_ENTITY_ID}, {}), + ( + {"gen_image_entity_id": "ai_task.other_entity"}, + {"entity_id": TEST_ENTITY_ID}, + ), + ], +) +async def test_generate_image_service( + hass: HomeAssistant, + init_components: None, + set_preferences: dict[str, str | None], + msg_extra: dict[str, str], + mock_ai_task_entity: MockAITaskEntity, +) -> None: + """Test the generate image service.""" + preferences = hass.data[DATA_PREFERENCES] + preferences.async_set_preferences(**set_preferences) + + result = await hass.services.async_call( + "ai_task", + "generate_image", + { + "task_name": "Test Image", + "instructions": "Generate a test image", + } + | msg_extra, + blocking=True, + return_response=True, + ) + + assert "image_data" not in result + assert result["media_source_id"].startswith("media-source://ai_task/images/") + assert result["url"].startswith("http://10.10.10.10:8123/api/ai_task/images/") + assert result["mime_type"] == "image/png" + assert result["model"] == "mock_model" + assert result["revised_prompt"] == "mock_revised_prompt" + + assert len(mock_ai_task_entity.mock_generate_image_tasks) == 1 + task = mock_ai_task_entity.mock_generate_image_tasks[0] + assert task.instructions == "Generate a test image" + + +async def test_generate_image_service_no_entity( + hass: HomeAssistant, + init_components: None, +) -> None: + """Test the generate image service with no entity specified.""" + with pytest.raises( + HomeAssistantError, + match="No entity_id provided and no preferred entity set", + ): + await hass.services.async_call( + "ai_task", + "generate_image", + { + "task_name": "Test Image", + "instructions": "Generate a test image", + }, + blocking=True, + return_response=True, + ) diff --git a/tests/components/ai_task/test_task.py b/tests/components/ai_task/test_task.py index 288f907ee6dff3..780f44b7e50129 100644 --- a/tests/components/ai_task/test_task.py +++ b/tests/components/ai_task/test_task.py @@ -20,7 +20,7 @@ from homeassistant.const import STATE_UNKNOWN from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers import chat_session +from homeassistant.helpers import chat_session, llm from homeassistant.util import dt as dt_util from .conftest import TEST_ENTITY_ID, MockAITaskEntity @@ -78,10 +78,12 @@ async def test_generate_data_preferred_entity( assert state is not None assert state.state == STATE_UNKNOWN + llm_api = llm.AssistAPI(hass) result = await async_generate_data( hass, task_name="Test Task", instructions="Test prompt", + llm_api=llm_api, ) assert result.data == "Mock result" as_dict = result.as_dict() @@ -91,6 +93,12 @@ async def test_generate_data_preferred_entity( assert state is not None assert state.state != STATE_UNKNOWN + with ( + chat_session.async_get_chat_session(hass, result.conversation_id) as session, + async_get_chat_log(hass, session) as chat_log, + ): + assert chat_log.llm_api.api is llm_api + mock_ai_task_entity.supported_features = AITaskEntityFeature(0) with pytest.raises( HomeAssistantError, diff --git a/tests/components/airzone/snapshots/test_diagnostics.ambr b/tests/components/airzone/snapshots/test_diagnostics.ambr index 09dea8c354c8d6..0895073e0aaf37 100644 --- a/tests/components/airzone/snapshots/test_diagnostics.ambr +++ b/tests/components/airzone/snapshots/test_diagnostics.ambr @@ -340,6 +340,7 @@ 5, ]), 'problems': False, + 'q-adapt': 0, }), '2': dict({ 'available': True, diff --git a/tests/components/airzone/util.py b/tests/components/airzone/util.py index 55cb32b67a56f0..7c35b39010a473 100644 --- a/tests/components/airzone/util.py +++ b/tests/components/airzone/util.py @@ -38,6 +38,7 @@ API_NAME, API_ON, API_POWER, + API_Q_ADAPT, API_ROOM_TEMP, API_SET_POINT, API_SLEEP, @@ -353,6 +354,7 @@ API_POWER: 0, API_SYSTEM_FIRMWARE: "3.31", API_SYSTEM_TYPE: 1, + API_Q_ADAPT: 0, } ] } diff --git a/tests/components/alexa_devices/conftest.py b/tests/components/alexa_devices/conftest.py index 236f7b23dc48ed..2ef2c2431dc136 100644 --- a/tests/components/alexa_devices/conftest.py +++ b/tests/components/alexa_devices/conftest.py @@ -7,7 +7,11 @@ from aioamazondevices.const import DEVICE_TYPE_TO_MODEL import pytest -from homeassistant.components.alexa_devices.const import CONF_LOGIN_DATA, DOMAIN +from homeassistant.components.alexa_devices.const import ( + CONF_LOGIN_DATA, + CONF_SITE, + DOMAIN, +) from homeassistant.const import CONF_PASSWORD, CONF_USERNAME from .const import TEST_PASSWORD, TEST_SERIAL_NUMBER, TEST_USERNAME @@ -81,9 +85,12 @@ def mock_config_entry() -> MockConfigEntry: data={ CONF_USERNAME: TEST_USERNAME, CONF_PASSWORD: TEST_PASSWORD, - CONF_LOGIN_DATA: {"session": "test-session"}, + CONF_LOGIN_DATA: { + "session": "test-session", + CONF_SITE: "https://www.amazon.com", + }, }, unique_id=TEST_USERNAME, version=1, - minor_version=2, + minor_version=3, ) diff --git a/tests/components/alexa_devices/snapshots/test_diagnostics.ambr b/tests/components/alexa_devices/snapshots/test_diagnostics.ambr index 6f9dc9a5cc3e0a..9ae5832ce33430 100644 --- a/tests/components/alexa_devices/snapshots/test_diagnostics.ambr +++ b/tests/components/alexa_devices/snapshots/test_diagnostics.ambr @@ -49,6 +49,7 @@ 'data': dict({ 'login_data': dict({ 'session': 'test-session', + 'site': 'https://www.amazon.com', }), 'password': '**REDACTED**', 'username': '**REDACTED**', @@ -57,7 +58,7 @@ 'discovery_keys': dict({ }), 'domain': 'alexa_devices', - 'minor_version': 2, + 'minor_version': 3, 'options': dict({ }), 'pref_disable_new_entities': False, diff --git a/tests/components/alexa_devices/test_init.py b/tests/components/alexa_devices/test_init.py index 6c3faffd27b83c..328654682e911b 100644 --- a/tests/components/alexa_devices/test_init.py +++ b/tests/components/alexa_devices/test_init.py @@ -2,9 +2,14 @@ from unittest.mock import AsyncMock +import pytest from syrupy.assertion import SnapshotAssertion -from homeassistant.components.alexa_devices.const import CONF_LOGIN_DATA, DOMAIN +from homeassistant.components.alexa_devices.const import ( + CONF_LOGIN_DATA, + CONF_SITE, + DOMAIN, +) from homeassistant.config_entries import ConfigEntryState from homeassistant.const import CONF_COUNTRY, CONF_PASSWORD, CONF_USERNAME from homeassistant.core import HomeAssistant @@ -32,24 +37,81 @@ async def test_device_info( assert device_entry == snapshot +@pytest.mark.parametrize( + ("minor_version", "extra_data"), + [ + # Standard migration case + ( + 1, + { + CONF_COUNTRY: "US", + CONF_LOGIN_DATA: { + "session": "test-session", + }, + }, + ), + # Edge case #1: no country, site already in login data, minor version 1 + ( + 1, + { + CONF_LOGIN_DATA: { + "session": "test-session", + CONF_SITE: "https://www.amazon.com", + }, + }, + ), + # Edge case #2: no country, site in data (wrong place), minor version 1 + ( + 1, + { + CONF_SITE: "https://www.amazon.com", + CONF_LOGIN_DATA: { + "session": "test-session", + }, + }, + ), + # Edge case #3: no country, site already in login data, minor version 2 + ( + 2, + { + CONF_LOGIN_DATA: { + "session": "test-session", + CONF_SITE: "https://www.amazon.com", + }, + }, + ), + # Edge case #4: no country, site in data (wrong place), minor version 2 + ( + 2, + { + CONF_SITE: "https://www.amazon.com", + CONF_LOGIN_DATA: { + "session": "test-session", + }, + }, + ), + ], +) async def test_migrate_entry( hass: HomeAssistant, mock_amazon_devices_client: AsyncMock, mock_config_entry: MockConfigEntry, + minor_version: int, + extra_data: dict[str, str], ) -> None: """Test successful migration of entry data.""" + config_entry = MockConfigEntry( domain=DOMAIN, title="Amazon Test Account", data={ - CONF_COUNTRY: "US", # country should be in COUNTRY_DOMAINS exceptions CONF_USERNAME: TEST_USERNAME, CONF_PASSWORD: TEST_PASSWORD, - CONF_LOGIN_DATA: {"session": "test-session"}, + **(extra_data), }, unique_id=TEST_USERNAME, version=1, - minor_version=1, + minor_version=minor_version, ) config_entry.add_to_hass(hass) await hass.config_entries.async_setup(config_entry.entry_id) @@ -57,5 +119,5 @@ async def test_migrate_entry( assert len(hass.config_entries.async_entries(DOMAIN)) == 1 assert config_entry.state is ConfigEntryState.LOADED - assert config_entry.minor_version == 2 - assert config_entry.data[CONF_LOGIN_DATA]["site"] == "https://www.amazon.com" + assert config_entry.minor_version == 3 + assert config_entry.data[CONF_LOGIN_DATA][CONF_SITE] == "https://www.amazon.com" diff --git a/tests/components/esphome/test_climate.py b/tests/components/esphome/test_climate.py index c574764e3c9f86..216421cd8b0186 100644 --- a/tests/components/esphome/test_climate.py +++ b/tests/components/esphome/test_climate.py @@ -75,11 +75,9 @@ async def test_climate_entity( swing_mode=ClimateSwingMode.BOTH, ) ] - user_service = [] await mock_generic_device_entry( mock_client=mock_client, entity_info=entity_info, - user_service=user_service, states=states, ) state = hass.states.get("climate.test_my_climate") @@ -130,24 +128,32 @@ async def test_climate_entity_with_step_and_two_point( swing_mode=ClimateSwingMode.BOTH, ) ] - user_service = [] await mock_generic_device_entry( mock_client=mock_client, entity_info=entity_info, - user_service=user_service, states=states, ) state = hass.states.get("climate.test_my_climate") assert state is not None assert state.state == HVACMode.COOL - with pytest.raises(ServiceValidationError): - await hass.services.async_call( - CLIMATE_DOMAIN, - SERVICE_SET_TEMPERATURE, - {ATTR_ENTITY_ID: "climate.test_my_climate", ATTR_TEMPERATURE: 25}, - blocking=True, - ) + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_TEMPERATURE, + {ATTR_ENTITY_ID: "climate.test_my_climate", ATTR_TEMPERATURE: 25}, + blocking=True, + ) + + mock_client.climate_command.assert_has_calls( + [ + call( + key=1, + target_temperature_high=25.0, + device_id=0, + ) + ] + ) + mock_client.climate_command.reset_mock() await hass.services.async_call( CLIMATE_DOMAIN, @@ -210,11 +216,9 @@ async def test_climate_entity_with_step_and_target_temp( swing_mode=ClimateSwingMode.BOTH, ) ] - user_service = [] await mock_generic_device_entry( mock_client=mock_client, entity_info=entity_info, - user_service=user_service, states=states, ) state = hass.states.get("climate.test_my_climate") @@ -366,11 +370,9 @@ async def test_climate_entity_with_humidity( target_humidity=25.7, ) ] - user_service = [] await mock_generic_device_entry( mock_client=mock_client, entity_info=entity_info, - user_service=user_service, states=states, ) state = hass.states.get("climate.test_my_climate") @@ -394,6 +396,162 @@ async def test_climate_entity_with_humidity( mock_client.climate_command.reset_mock() +async def test_climate_entity_with_heat( + hass: HomeAssistant, + mock_client: APIClient, + mock_generic_device_entry: MockGenericDeviceEntryType, +) -> None: + """Test a generic climate entity with heat.""" + entity_info = [ + ClimateInfo( + object_id="myclimate", + key=1, + name="my climate", + supports_current_temperature=True, + supports_two_point_target_temperature=True, + supports_action=True, + visual_min_temperature=10.0, + visual_max_temperature=30.0, + supported_modes=[ClimateMode.COOL, ClimateMode.HEAT, ClimateMode.AUTO], + ) + ] + states = [ + ClimateState( + key=1, + mode=ClimateMode.HEAT, + action=ClimateAction.HEATING, + current_temperature=18, + target_temperature=22, + ) + ] + await mock_generic_device_entry( + mock_client=mock_client, + entity_info=entity_info, + states=states, + ) + state = hass.states.get("climate.test_my_climate") + assert state is not None + assert state.state == HVACMode.HEAT + + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_TEMPERATURE, + {ATTR_ENTITY_ID: "climate.test_my_climate", ATTR_TEMPERATURE: 23}, + blocking=True, + ) + mock_client.climate_command.assert_has_calls( + [call(key=1, target_temperature_low=23, device_id=0)] + ) + mock_client.climate_command.reset_mock() + + +async def test_climate_entity_with_heat_cool( + hass: HomeAssistant, + mock_client: APIClient, + mock_generic_device_entry: MockGenericDeviceEntryType, +) -> None: + """Test a generic climate entity with heat.""" + entity_info = [ + ClimateInfo( + object_id="myclimate", + key=1, + name="my climate", + supports_current_temperature=True, + supports_two_point_target_temperature=True, + supports_action=True, + visual_min_temperature=10.0, + visual_max_temperature=30.0, + supported_modes=[ClimateMode.COOL, ClimateMode.HEAT, ClimateMode.HEAT_COOL], + ) + ] + states = [ + ClimateState( + key=1, + mode=ClimateMode.HEAT_COOL, + action=ClimateAction.HEATING, + current_temperature=18, + target_temperature=22, + ) + ] + await mock_generic_device_entry( + mock_client=mock_client, + entity_info=entity_info, + states=states, + ) + state = hass.states.get("climate.test_my_climate") + assert state is not None + assert state.state == HVACMode.HEAT_COOL + + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_TEMPERATURE, + { + ATTR_ENTITY_ID: "climate.test_my_climate", + ATTR_TARGET_TEMP_HIGH: 23, + ATTR_TARGET_TEMP_LOW: 20, + }, + blocking=True, + ) + mock_client.climate_command.assert_has_calls( + [ + call( + key=1, + target_temperature_high=23, + target_temperature_low=20, + device_id=0, + ) + ] + ) + mock_client.climate_command.reset_mock() + + +async def test_climate_set_temperature_unsupported_mode( + hass: HomeAssistant, + mock_client: APIClient, + mock_generic_device_entry: MockGenericDeviceEntryType, +) -> None: + """Test setting temperature in unsupported mode with two-point temperature support.""" + entity_info = [ + ClimateInfo( + object_id="myclimate", + key=1, + name="my climate", + supports_two_point_target_temperature=True, + supported_modes=[ClimateMode.HEAT, ClimateMode.COOL, ClimateMode.AUTO], + visual_min_temperature=10.0, + visual_max_temperature=30.0, + ) + ] + states = [ + ClimateState( + key=1, + mode=ClimateMode.AUTO, + target_temperature=20, + ) + ] + await mock_generic_device_entry( + mock_client=mock_client, + entity_info=entity_info, + states=states, + ) + + with pytest.raises( + ServiceValidationError, + match="Setting target_temperature is only supported in heat or cool modes", + ): + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_TEMPERATURE, + { + ATTR_ENTITY_ID: "climate.test_my_climate", + ATTR_TEMPERATURE: 25, + }, + blocking=True, + ) + + mock_client.climate_command.assert_not_called() + + async def test_climate_entity_with_inf_value( hass: HomeAssistant, mock_client: APIClient, @@ -429,11 +587,9 @@ async def test_climate_entity_with_inf_value( target_humidity=25.7, ) ] - user_service = [] await mock_generic_device_entry( mock_client=mock_client, entity_info=entity_info, - user_service=user_service, states=states, ) state = hass.states.get("climate.test_my_climate") @@ -444,7 +600,7 @@ async def test_climate_entity_with_inf_value( assert attributes[ATTR_HUMIDITY] == 26 assert attributes[ATTR_MAX_HUMIDITY] == 30 assert attributes[ATTR_MIN_HUMIDITY] == 10 - assert ATTR_TEMPERATURE not in attributes + assert attributes[ATTR_TEMPERATURE] is None assert attributes[ATTR_CURRENT_TEMPERATURE] is None @@ -490,11 +646,9 @@ async def test_climate_entity_attributes( swing_mode=ClimateSwingMode.BOTH, ) ] - user_service = [] await mock_generic_device_entry( mock_client=mock_client, entity_info=entity_info, - user_service=user_service, states=states, ) state = hass.states.get("climate.test_my_climate") @@ -523,11 +677,9 @@ async def test_climate_entity_attribute_current_temperature_unsupported( current_temperature=30, ) ] - user_service = [] await mock_generic_device_entry( mock_client=mock_client, entity_info=entity_info, - user_service=user_service, states=states, ) state = hass.states.get("climate.test_my_climate") diff --git a/tests/components/ntfy/conftest.py b/tests/components/ntfy/conftest.py index d9bc620b464aeb..91e2e1ee5f8cc2 100644 --- a/tests/components/ntfy/conftest.py +++ b/tests/components/ntfy/conftest.py @@ -1,10 +1,11 @@ """Common fixtures for the ntfy tests.""" -from collections.abc import Generator -from datetime import datetime -from unittest.mock import AsyncMock, MagicMock, patch +import asyncio +from collections.abc import Callable, Generator +from datetime import UTC, datetime +from unittest.mock import AsyncMock, MagicMock, Mock, patch -from aiontfy import Account, AccountTokenResponse +from aiontfy import Account, AccountTokenResponse, Event, Notification import pytest from homeassistant.components.ntfy.const import CONF_TOPIC, DOMAIN @@ -40,6 +41,50 @@ def mock_aiontfy() -> Generator[AsyncMock]: client.generate_token.return_value = AccountTokenResponse( token="token", last_access=datetime.now() ) + + resp = Mock( + id="h6Y2hKA5sy0U", + time=datetime(2025, 3, 28, 17, 58, 46, tzinfo=UTC), + expires=datetime(2025, 3, 29, 5, 58, 46, tzinfo=UTC), + event=Event.MESSAGE, + topic="mytopic", + message="Hello", + title="Title", + tags=["octopus"], + priority=3, + click="https://example.com/", + icon="https://example.com/icon.png", + actions=[], + attachment=None, + content_type=None, + ) + + resp.to_dict.return_value = { + "id": "h6Y2hKA5sy0U", + "time": datetime(2025, 3, 28, 17, 58, 46, tzinfo=UTC), + "expires": datetime(2025, 3, 29, 5, 58, 46, tzinfo=UTC), + "event": Event.MESSAGE, + "topic": "mytopic", + "message": "Hello", + "title": "Title", + "tags": ["octopus"], + "priority": 3, + "click": "https://example.com/", + "icon": "https://example.com/icon.png", + "actions": [], + "attachment": None, + "content_type": None, + } + + async def mock_ws( + topics: list[str], callback: Callable[[Notification], None], **kwargs + ): + callback(resp) + while True: + await asyncio.sleep(1) + + client.subscribe.side_effect = mock_ws + yield client diff --git a/tests/components/ntfy/snapshots/test_event.ambr b/tests/components/ntfy/snapshots/test_event.ambr new file mode 100644 index 00000000000000..ed6095f0888ed4 --- /dev/null +++ b/tests/components/ntfy/snapshots/test_event.ambr @@ -0,0 +1,75 @@ +# serializer version: 1 +# name: test_event_platform[event.mytopic-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'event_types': list([ + 'Title: Hello', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'event', + 'entity_category': None, + 'entity_id': 'event.mytopic', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': None, + 'platform': 'ntfy', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'subscribe', + 'unique_id': '123456789_ABCDEF_subscribe', + 'unit_of_measurement': None, + }) +# --- +# name: test_event_platform[event.mytopic-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'actions': list([ + ]), + 'attachment': None, + 'click': 'https://example.com/', + 'content_type': None, + 'entity_picture': 'https://example.com/icon.png', + 'event': , + 'event_type': 'Title: Hello', + 'event_types': list([ + 'Title: Hello', + ]), + 'expires': datetime.datetime(2025, 3, 29, 5, 58, 46, tzinfo=datetime.timezone.utc), + 'friendly_name': 'mytopic', + 'icon': 'https://example.com/icon.png', + 'id': 'h6Y2hKA5sy0U', + 'message': 'Hello', + 'priority': 3, + 'tags': list([ + 'octopus', + ]), + 'time': datetime.datetime(2025, 3, 28, 17, 58, 46, tzinfo=datetime.timezone.utc), + 'title': 'Title', + 'topic': 'mytopic', + }), + 'context': , + 'entity_id': 'event.mytopic', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '2025-09-03T22:00:00.000+00:00', + }) +# --- diff --git a/tests/components/ntfy/test_config_flow.py b/tests/components/ntfy/test_config_flow.py index 0bc488337024a7..e38ea26d98221b 100644 --- a/tests/components/ntfy/test_config_flow.py +++ b/tests/components/ntfy/test_config_flow.py @@ -12,7 +12,12 @@ ) import pytest -from homeassistant.components.ntfy.const import CONF_TOPIC, DOMAIN, SECTION_AUTH +from homeassistant.components.ntfy.const import ( + CONF_TOPIC, + DOMAIN, + SECTION_AUTH, + SECTION_FILTER, +) from homeassistant.config_entries import SOURCE_USER, ConfigSubentry from homeassistant.const import ( CONF_NAME, @@ -204,7 +209,10 @@ async def test_add_topic_flow(hass: HomeAssistant) -> None: result = await hass.config_entries.subentries.async_configure( result["flow_id"], - user_input={CONF_TOPIC: "mytopic"}, + user_input={ + CONF_TOPIC: "mytopic", + SECTION_FILTER: {}, + }, ) assert result["type"] is FlowResultType.CREATE_ENTRY subentry_id = list(config_entry.subentries)[0] @@ -252,14 +260,21 @@ async def test_generated_topic(hass: HomeAssistant, mock_random: AsyncMock) -> N result = await hass.config_entries.subentries.async_configure( result["flow_id"], - user_input={CONF_TOPIC: ""}, + user_input={ + CONF_TOPIC: "", + SECTION_FILTER: {}, + }, ) mock_random.assert_called_once() result = await hass.config_entries.subentries.async_configure( result["flow_id"], - user_input={CONF_TOPIC: "randomtopic", CONF_NAME: "mytopic"}, + user_input={ + CONF_TOPIC: "randomtopic", + CONF_NAME: "mytopic", + SECTION_FILTER: {}, + }, ) assert result["type"] is FlowResultType.CREATE_ENTRY @@ -306,7 +321,10 @@ async def test_invalid_topic(hass: HomeAssistant, mock_random: AsyncMock) -> Non result = await hass.config_entries.subentries.async_configure( result["flow_id"], - user_input={CONF_TOPIC: "invalid,topic"}, + user_input={ + CONF_TOPIC: "invalid,topic", + SECTION_FILTER: {}, + }, ) assert result["type"] == FlowResultType.FORM @@ -314,7 +332,10 @@ async def test_invalid_topic(hass: HomeAssistant, mock_random: AsyncMock) -> Non result = await hass.config_entries.subentries.async_configure( result["flow_id"], - user_input={CONF_TOPIC: "mytopic"}, + user_input={ + CONF_TOPIC: "mytopic", + SECTION_FILTER: {}, + }, ) assert result["type"] is FlowResultType.CREATE_ENTRY @@ -360,7 +381,10 @@ async def test_topic_already_configured( result = await hass.config_entries.subentries.async_configure( result["flow_id"], - user_input={CONF_TOPIC: "mytopic"}, + user_input={ + CONF_TOPIC: "mytopic", + SECTION_FILTER: {}, + }, ) assert result["type"] is FlowResultType.ABORT diff --git a/tests/components/ntfy/test_event.py b/tests/components/ntfy/test_event.py new file mode 100644 index 00000000000000..92e01b1ba2c3b3 --- /dev/null +++ b/tests/components/ntfy/test_event.py @@ -0,0 +1,158 @@ +"""Tests for the ntfy event platform.""" + +import asyncio +from collections.abc import AsyncGenerator +from datetime import UTC, datetime, timedelta +from unittest.mock import AsyncMock, patch + +from aiontfy import Event +from aiontfy.exceptions import ( + NtfyConnectionError, + NtfyForbiddenError, + NtfyHTTPError, + NtfyTimeoutError, + NtfyUnauthorizedAuthenticationError, +) +from freezegun.api import FrozenDateTimeFactory, freeze_time +import pytest +from syrupy.assertion import SnapshotAssertion + +from homeassistant.config_entries import ConfigEntryState +from homeassistant.const import STATE_UNAVAILABLE, STATE_UNKNOWN, Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from tests.common import MockConfigEntry, async_fire_time_changed, snapshot_platform + + +@pytest.fixture(autouse=True) +async def event_only() -> AsyncGenerator[None]: + """Enable only the event platform.""" + with patch( + "homeassistant.components.ntfy.PLATFORMS", + [Platform.EVENT], + ): + yield + + +@pytest.mark.usefixtures("mock_aiontfy") +@freeze_time("2025-09-03T22:00:00.000Z") +async def test_event_platform( + hass: HomeAssistant, + config_entry: MockConfigEntry, + snapshot: SnapshotAssertion, + entity_registry: er.EntityRegistry, +) -> None: + """Test setup of the ntfy event platform.""" + + config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + assert config_entry.state is ConfigEntryState.LOADED + + await snapshot_platform(hass, entity_registry, snapshot, config_entry.entry_id) + + +@pytest.mark.usefixtures("mock_aiontfy") +async def test_event( + hass: HomeAssistant, + config_entry: MockConfigEntry, +) -> None: + """Test ntfy events.""" + + config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + assert config_entry.state is ConfigEntryState.LOADED + + assert (state := hass.states.get("event.mytopic")) + assert state.state != STATE_UNKNOWN + + assert state.attributes == { + "actions": [], + "attachment": None, + "click": "https://example.com/", + "content_type": None, + "entity_picture": "https://example.com/icon.png", + "event": Event.MESSAGE, + "event_type": "Title: Hello", + "event_types": [ + "Title: Hello", + ], + "expires": datetime(2025, 3, 29, 5, 58, 46, tzinfo=UTC), + "friendly_name": "mytopic", + "icon": "https://example.com/icon.png", + "id": "h6Y2hKA5sy0U", + "message": "Hello", + "priority": 3, + "tags": [ + "octopus", + ], + "time": datetime(2025, 3, 28, 17, 58, 46, tzinfo=UTC), + "title": "Title", + "topic": "mytopic", + } + + +@pytest.mark.parametrize( + ("exception", "expected_state"), + [ + ( + NtfyHTTPError(41801, 418, "I'm a teapot", ""), + STATE_UNAVAILABLE, + ), + ( + NtfyConnectionError, + STATE_UNAVAILABLE, + ), + ( + NtfyTimeoutError, + STATE_UNAVAILABLE, + ), + ( + NtfyUnauthorizedAuthenticationError(40101, 401, "unauthorized"), + STATE_UNAVAILABLE, + ), + ( + NtfyForbiddenError(403, 403, "forbidden"), + STATE_UNAVAILABLE, + ), + ( + asyncio.CancelledError, + STATE_UNAVAILABLE, + ), + ( + asyncio.InvalidStateError, + STATE_UNKNOWN, + ), + ( + ValueError, + STATE_UNAVAILABLE, + ), + ], +) +async def test_event_exceptions( + hass: HomeAssistant, + config_entry: MockConfigEntry, + mock_aiontfy: AsyncMock, + freezer: FrozenDateTimeFactory, + exception: Exception, + expected_state: str, +) -> None: + """Test ntfy events exceptions.""" + mock_aiontfy.subscribe.side_effect = exception + + config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + assert config_entry.state is ConfigEntryState.LOADED + + freezer.tick(timedelta(seconds=10)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + assert (state := hass.states.get("event.mytopic")) + assert state.state == expected_state diff --git a/tests/components/ntfy/test_services.py b/tests/components/ntfy/test_services.py new file mode 100644 index 00000000000000..d07df40264f6a1 --- /dev/null +++ b/tests/components/ntfy/test_services.py @@ -0,0 +1,209 @@ +"""Tests for the ntfy notify platform.""" + +from typing import Any + +from aiontfy import Message +from aiontfy.exceptions import ( + NtfyException, + NtfyHTTPError, + NtfyUnauthorizedAuthenticationError, +) +import pytest +from yarl import URL + +from homeassistant.components.notify import ATTR_MESSAGE, ATTR_TITLE +from homeassistant.components.ntfy.const import DOMAIN +from homeassistant.components.ntfy.notify import ( + ATTR_ATTACH, + ATTR_CALL, + ATTR_CLICK, + ATTR_DELAY, + ATTR_EMAIL, + ATTR_ICON, + ATTR_MARKDOWN, + ATTR_PRIORITY, + ATTR_TAGS, + SERVICE_PUBLISH, +) +from homeassistant.config_entries import SOURCE_REAUTH, ConfigEntryState +from homeassistant.const import ATTR_ENTITY_ID +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError, ServiceValidationError + +from tests.common import AsyncMock, MockConfigEntry + + +async def test_ntfy_publish( + hass: HomeAssistant, + config_entry: MockConfigEntry, + mock_aiontfy: AsyncMock, +) -> None: + """Test publishing ntfy message via ntfy.publish action.""" + + config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + assert config_entry.state is ConfigEntryState.LOADED + + await hass.services.async_call( + DOMAIN, + SERVICE_PUBLISH, + { + ATTR_ENTITY_ID: "notify.mytopic", + ATTR_MESSAGE: "Hello", + ATTR_TITLE: "World", + ATTR_ATTACH: "https://example.org/download.zip", + ATTR_CLICK: "https://example.org", + ATTR_DELAY: {"days": 1, "seconds": 30}, + ATTR_ICON: "https://example.org/logo.png", + ATTR_MARKDOWN: True, + ATTR_PRIORITY: "5", + ATTR_TAGS: ["partying_face", "grin"], + }, + blocking=True, + ) + + mock_aiontfy.publish.assert_called_once_with( + Message( + topic="mytopic", + message="Hello", + title="World", + tags=["partying_face", "grin"], + priority=5, + click=URL("https://example.org"), + attach=URL("https://example.org/download.zip"), + markdown=True, + icon=URL("https://example.org/logo.png"), + delay="86430.0s", + ) + ) + + +@pytest.mark.parametrize( + ("exception", "error_msg"), + [ + ( + NtfyHTTPError(41801, 418, "I'm a teapot", ""), + "Failed to publish notification: I'm a teapot", + ), + ( + NtfyException, + "Failed to publish notification due to a connection error", + ), + ( + NtfyUnauthorizedAuthenticationError(40101, 401, "unauthorized"), + "Failed to authenticate with ntfy service. Please verify your credentials", + ), + ], +) +async def test_send_message_exception( + hass: HomeAssistant, + config_entry: MockConfigEntry, + mock_aiontfy: AsyncMock, + exception: Exception, + error_msg: str, +) -> None: + """Test publish message exceptions.""" + + config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + assert config_entry.state is ConfigEntryState.LOADED + + mock_aiontfy.publish.side_effect = exception + + with pytest.raises(HomeAssistantError, match=error_msg): + await hass.services.async_call( + DOMAIN, + SERVICE_PUBLISH, + { + ATTR_ENTITY_ID: "notify.mytopic", + ATTR_MESSAGE: "triggered", + ATTR_TITLE: "test", + }, + blocking=True, + ) + + mock_aiontfy.publish.assert_called_once_with( + Message(topic="mytopic", message="triggered", title="test") + ) + + +@pytest.mark.parametrize( + ("payload", "error_msg"), + [ + ( + {ATTR_DELAY: {"days": 1, "seconds": 30}, ATTR_CALL: "1234567890"}, + "Delayed call notifications are not supported", + ), + ( + {ATTR_DELAY: {"days": 1, "seconds": 30}, ATTR_EMAIL: "mail@example.org"}, + "Delayed email notifications are not supported", + ), + ], +) +async def test_send_message_validation_errors( + hass: HomeAssistant, + config_entry: MockConfigEntry, + mock_aiontfy: AsyncMock, + payload: dict[str, Any], + error_msg: str, +) -> None: + """Test publish message service validation errors.""" + + config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + assert config_entry.state is ConfigEntryState.LOADED + + with pytest.raises(ServiceValidationError, match=error_msg): + await hass.services.async_call( + DOMAIN, + SERVICE_PUBLISH, + {ATTR_ENTITY_ID: "notify.mytopic", **payload}, + blocking=True, + ) + + +async def test_send_message_reauth_flow( + hass: HomeAssistant, + config_entry: MockConfigEntry, + mock_aiontfy: AsyncMock, +) -> None: + """Test unauthorized exception initiates reauth flow.""" + + config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + assert config_entry.state is ConfigEntryState.LOADED + + mock_aiontfy.publish.side_effect = ( + NtfyUnauthorizedAuthenticationError(40101, 401, "unauthorized"), + ) + + with pytest.raises(HomeAssistantError): + await hass.services.async_call( + DOMAIN, + SERVICE_PUBLISH, + { + ATTR_ENTITY_ID: "notify.mytopic", + ATTR_MESSAGE: "triggered", + ATTR_TITLE: "test", + }, + blocking=True, + ) + + flows = hass.config_entries.flow.async_progress() + assert len(flows) == 1 + + flow = flows[0] + assert flow.get("step_id") == "reauth_confirm" + assert flow.get("handler") == DOMAIN + + assert "context" in flow + assert flow["context"].get("source") == SOURCE_REAUTH + assert flow["context"].get("entry_id") == config_entry.entry_id diff --git a/tests/components/ssdp/conftest.py b/tests/components/ssdp/conftest.py index 61c763ce7d4091..644f449fe385a8 100644 --- a/tests/components/ssdp/conftest.py +++ b/tests/components/ssdp/conftest.py @@ -1,7 +1,8 @@ """Configuration for SSDP tests.""" from collections.abc import Generator -from unittest.mock import AsyncMock, patch +import socket +from unittest.mock import AsyncMock, MagicMock, patch from async_upnp_client.server import UpnpServer from async_upnp_client.ssdp_listener import SsdpListener @@ -29,7 +30,10 @@ async def disabled_upnp_server(): with ( patch("homeassistant.components.ssdp.server.UpnpServer.async_start"), patch("homeassistant.components.ssdp.server.UpnpServer.async_stop"), - patch("homeassistant.components.ssdp.server._async_find_next_available_port"), + patch( + "homeassistant.components.ssdp.server._async_find_next_available_port", + return_value=(40000, MagicMock(spec_set=socket.socket)), + ), ): yield UpnpServer