diff --git a/.strict-typing b/.strict-typing index 8b1f044aee5e7d..d3213c0cd8e37a 100644 --- a/.strict-typing +++ b/.strict-typing @@ -257,6 +257,7 @@ homeassistant.components.google_sheets.* homeassistant.components.google_weather.* homeassistant.components.govee_ble.* homeassistant.components.gpsd.* +homeassistant.components.gree_infrared.* homeassistant.components.greeneye_monitor.* homeassistant.components.group.* homeassistant.components.guardian.* diff --git a/CODEOWNERS b/CODEOWNERS index e2bc0385e51818..444ba17de98097 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -720,6 +720,8 @@ CLAUDE.md @home-assistant/core /tests/components/gpsd/ @fabaff @jrieger /homeassistant/components/gree/ @cmroche /tests/components/gree/ @cmroche +/homeassistant/components/gree_infrared/ @Dr-Blank +/tests/components/gree_infrared/ @Dr-Blank /homeassistant/components/green_planet_energy/ @petschni /tests/components/green_planet_energy/ @petschni /homeassistant/components/greencell/ @BrzezowskiGC diff --git a/homeassistant/brands/gree.json b/homeassistant/brands/gree.json new file mode 100644 index 00000000000000..b622e03036821e --- /dev/null +++ b/homeassistant/brands/gree.json @@ -0,0 +1,5 @@ +{ + "domain": "gree", + "name": "Gree", + "integrations": ["gree", "gree_infrared"] +} diff --git a/homeassistant/components/ai_task/__init__.py b/homeassistant/components/ai_task/__init__.py index 831e140c353e5e..af32d5efcc168f 100644 --- a/homeassistant/components/ai_task/__init__.py +++ b/homeassistant/components/ai_task/__init__.py @@ -1,41 +1,19 @@ """Integration to offer AI tasks to Home Assistant.""" import logging -from typing import Any - -import probatio from homeassistant.components.media_source import local_source from homeassistant.config_entries import ConfigEntry -from homeassistant.const import ATTR_ENTITY_ID, CONF_DESCRIPTION, CONF_SELECTOR -from homeassistant.core import ( - HassJobType, - HomeAssistant, - ServiceCall, - ServiceResponse, - SupportsResponse, - callback, -) -from homeassistant.helpers import config_validation as cv, selector, storage +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers import config_validation as cv, storage from homeassistant.helpers.entity_component import EntityComponent from homeassistant.helpers.typing import UNDEFINED, ConfigType, UndefinedType -from .const import ( - ATTR_ATTACHMENTS, - ATTR_INSTRUCTIONS, - ATTR_REQUIRED, - ATTR_STRUCTURE, - ATTR_TASK_NAME, - DATA_COMPONENT, - DATA_PREFERENCES, - DOMAIN, - SERVICE_GENERATE_DATA, - SERVICE_GENERATE_IMAGE, - AITaskEntityFeature, -) +from .const import DATA_COMPONENT, DATA_PREFERENCES, DOMAIN, AITaskEntityFeature from .entity import AITaskEntity from .http import async_setup as async_setup_http from .media_source import async_get_media_source +from .services import async_setup_services from .task import ( GenDataTask, GenDataTaskResult, @@ -61,32 +39,9 @@ CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN) -STRUCTURE_FIELD_SCHEMA = probatio.Schema( - { - probatio.Optional(CONF_DESCRIPTION): str, - probatio.Optional(ATTR_REQUIRED): bool, - probatio.Required(CONF_SELECTOR): selector.validate_selector, - } -) - - -def _validate_structure_fields(value: dict[str, Any]) -> probatio.Schema: - """Validate the structure fields as a probatio Schema.""" - if not isinstance(value, dict): - raise probatio.Invalid("Structure must be a dictionary") - fields = {} - for k, v in value.items(): - field_class = ( - probatio.Required if v.get(ATTR_REQUIRED, False) else probatio.Optional - ) - fields[field_class(k, description=v.get(CONF_DESCRIPTION))] = selector.selector( - v[CONF_SELECTOR] - ) - return probatio.Schema(fields, extra=probatio.PREVENT_EXTRA) - async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: - """Register the process service.""" + """Set up AI Task.""" entity_component = EntityComponent[AITaskEntity](_LOGGER, DOMAIN, hass) hass.data[DATA_COMPONENT] = entity_component hass.data[DATA_PREFERENCES] = AITaskPreferences(hass) @@ -95,44 +50,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: if hass.config.media_dirs: source = await async_get_media_source(hass) hass.http.register_view(local_source.LocalMediaView(hass, source)) - hass.services.async_register( - DOMAIN, - SERVICE_GENERATE_DATA, - async_service_generate_data, - schema=probatio.Schema( - { - probatio.Required(ATTR_TASK_NAME): cv.string, - probatio.Optional(ATTR_ENTITY_ID): cv.entity_id, - probatio.Required(ATTR_INSTRUCTIONS): cv.string, - probatio.Optional(ATTR_STRUCTURE): probatio.All( - probatio.Schema({str: STRUCTURE_FIELD_SCHEMA}), - _validate_structure_fields, - ), - probatio.Optional(ATTR_ATTACHMENTS): selector.MediaSelector( - {"accept": ["*/*"], "multiple": True} - ), - } - ), - supports_response=SupportsResponse.ONLY, - job_type=HassJobType.Coroutinefunction, - ) - hass.services.async_register( - DOMAIN, - SERVICE_GENERATE_IMAGE, - async_service_generate_image, - schema=probatio.Schema( - { - probatio.Required(ATTR_TASK_NAME): cv.string, - probatio.Optional(ATTR_ENTITY_ID): cv.entity_id, - probatio.Required(ATTR_INSTRUCTIONS): cv.string, - probatio.Optional(ATTR_ATTACHMENTS): selector.MediaSelector( - {"accept": ["*/*"], "multiple": True} - ), - } - ), - supports_response=SupportsResponse.ONLY, - job_type=HassJobType.Coroutinefunction, - ) + async_setup_services(hass) return True @@ -146,19 +64,6 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: return await hass.data[DATA_COMPONENT].async_unload_entry(entry) -async def async_service_generate_data(call: ServiceCall) -> ServiceResponse: - """Run the data task service.""" - result = await async_generate_data( - hass=call.hass, context=call.context, **call.data - ) - return result.as_dict() - - -async def async_service_generate_image(call: ServiceCall) -> ServiceResponse: - """Run the image task service.""" - return await async_generate_image(hass=call.hass, context=call.context, **call.data) - - class AITaskPreferences: """AI Task preferences.""" diff --git a/homeassistant/components/ai_task/services.py b/homeassistant/components/ai_task/services.py new file mode 100644 index 00000000000000..7a6bfae8a7c101 --- /dev/null +++ b/homeassistant/components/ai_task/services.py @@ -0,0 +1,107 @@ +"""Services for the AI Task integration.""" + +from typing import Any + +import probatio + +from homeassistant.const import ATTR_ENTITY_ID, CONF_DESCRIPTION, CONF_SELECTOR +from homeassistant.core import ( + HassJobType, + HomeAssistant, + ServiceCall, + ServiceResponse, + SupportsResponse, + callback, +) +from homeassistant.helpers import config_validation as cv, selector + +from .const import ( + ATTR_ATTACHMENTS, + ATTR_INSTRUCTIONS, + ATTR_REQUIRED, + ATTR_STRUCTURE, + ATTR_TASK_NAME, + DOMAIN, + SERVICE_GENERATE_DATA, + SERVICE_GENERATE_IMAGE, +) +from .task import async_generate_data, async_generate_image + +STRUCTURE_FIELD_SCHEMA = probatio.Schema( + { + probatio.Optional(CONF_DESCRIPTION): str, + probatio.Optional(ATTR_REQUIRED): bool, + probatio.Required(CONF_SELECTOR): selector.validate_selector, + } +) + + +def _validate_structure_fields(value: dict[str, Any]) -> probatio.Schema: + """Validate the structure fields as a probatio Schema.""" + if not isinstance(value, dict): + raise probatio.Invalid("Structure must be a dictionary") + fields = {} + for k, v in value.items(): + field_class = ( + probatio.Required if v.get(ATTR_REQUIRED, False) else probatio.Optional + ) + fields[field_class(k, description=v.get(CONF_DESCRIPTION))] = selector.selector( + v[CONF_SELECTOR] + ) + return probatio.Schema(fields, extra=probatio.PREVENT_EXTRA) + + +async def async_service_generate_data(call: ServiceCall) -> ServiceResponse: + """Run the data task service.""" + result = await async_generate_data( + hass=call.hass, context=call.context, **call.data + ) + return result.as_dict() + + +async def async_service_generate_image(call: ServiceCall) -> ServiceResponse: + """Run the image task service.""" + return await async_generate_image(hass=call.hass, context=call.context, **call.data) + + +@callback +def async_setup_services(hass: HomeAssistant) -> None: + """Register the AI Task services.""" + hass.services.async_register( + DOMAIN, + SERVICE_GENERATE_DATA, + async_service_generate_data, + schema=probatio.Schema( + { + probatio.Required(ATTR_TASK_NAME): cv.string, + probatio.Optional(ATTR_ENTITY_ID): cv.entity_id, + probatio.Required(ATTR_INSTRUCTIONS): cv.string, + probatio.Optional(ATTR_STRUCTURE): probatio.All( + probatio.Schema({str: STRUCTURE_FIELD_SCHEMA}), + _validate_structure_fields, + ), + probatio.Optional(ATTR_ATTACHMENTS): selector.MediaSelector( + {"accept": ["*/*"], "multiple": True} + ), + } + ), + supports_response=SupportsResponse.ONLY, + job_type=HassJobType.Coroutinefunction, + ) + hass.services.async_register( + DOMAIN, + SERVICE_GENERATE_IMAGE, + async_service_generate_image, + schema=probatio.Schema( + { + probatio.Required(ATTR_TASK_NAME): cv.string, + probatio.Optional(ATTR_ENTITY_ID): cv.entity_id, + probatio.Required(ATTR_INSTRUCTIONS): cv.string, + probatio.Optional(ATTR_ATTACHMENTS): selector.MediaSelector( + {"accept": ["*/*"], "multiple": True} + ), + } + ), + supports_response=SupportsResponse.ONLY, + job_type=HassJobType.Coroutinefunction, + ) diff --git a/homeassistant/components/alexa_devices/manifest.json b/homeassistant/components/alexa_devices/manifest.json index 19e5dce6baa670..419c774133c71d 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": "platinum", - "requirements": ["aioamazondevices==15.2.0"] + "requirements": ["aioamazondevices==16.3.0"] } diff --git a/homeassistant/components/assist_satellite/__init__.py b/homeassistant/components/assist_satellite/__init__.py index c353f9ad192648..2bb1ba7c9e0498 100644 --- a/homeassistant/components/assist_satellite/__init__.py +++ b/homeassistant/components/assist_satellite/__init__.py @@ -1,27 +1,11 @@ """Base class for assist satellite entities.""" -from dataclasses import asdict import logging from pathlib import Path -import re -from typing import Any -from hassil.parse_expression import parse_sentence -from hassil.parser import ParseError -from hassil.util import ( - PUNCTUATION_END, - PUNCTUATION_END_WORD, - PUNCTUATION_START, - PUNCTUATION_START_WORD, -) -import probatio - -from homeassistant.auth.permissions.const import CAT_ENTITIES, POLICY_CONTROL from homeassistant.components.http import StaticPathConfig from homeassistant.config_entries import ConfigEntry -from homeassistant.const import ATTR_ENTITY_ID -from homeassistant.core import HomeAssistant, ServiceCall, SupportsResponse -from homeassistant.exceptions import HomeAssistantError, Unauthorized, UnknownUser +from homeassistant.core import HomeAssistant from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_component import EntityComponent from homeassistant.helpers.typing import ConfigType @@ -44,6 +28,7 @@ AssistSatelliteWakeWord, ) from .errors import SatelliteBusyError +from .services import async_setup_services from .websocket_api import async_register_websocket_api __all__ = [ @@ -69,115 +54,8 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: ) await component.async_setup(config) - component.async_register_entity_service( - "announce", - probatio.All( - cv.make_entity_service_schema( - { - probatio.Optional("message"): str, - probatio.Optional("media_id"): _media_id_validator, - probatio.Optional("preannounce", default=True): bool, - probatio.Optional("preannounce_media_id"): _media_id_validator, - } - ), - cv.has_at_least_one_key("message", "media_id"), - ), - "async_internal_announce", - [AssistSatelliteEntityFeature.ANNOUNCE], - ) - - component.async_register_entity_service( - "start_conversation", - probatio.All( - cv.make_entity_service_schema( - { - probatio.Optional("start_message"): str, - probatio.Optional("start_media_id"): _media_id_validator, - probatio.Optional("preannounce", default=True): bool, - probatio.Optional("preannounce_media_id"): _media_id_validator, - probatio.Optional("extra_system_prompt"): str, - } - ), - cv.has_at_least_one_key("start_message", "start_media_id"), - ), - "async_internal_start_conversation", - [AssistSatelliteEntityFeature.START_CONVERSATION], - ) - - async def handle_ask_question(call: ServiceCall) -> dict[str, Any]: - """Handle a Show View service call.""" - satellite_entity_id: str = call.data[ATTR_ENTITY_ID] - if call.context.user_id: - user = await hass.auth.async_get_user(call.context.user_id) - if user is None: - raise UnknownUser( - context=call.context, - permission=POLICY_CONTROL, - user_id=call.context.user_id, - ) - if not user.permissions.check_entity(satellite_entity_id, POLICY_CONTROL): - raise Unauthorized( - context=call.context, - permission=POLICY_CONTROL, - user_id=call.context.user_id, - perm_category=CAT_ENTITIES, - ) - - satellite_entity: AssistSatelliteEntity | None = component.get_entity( - satellite_entity_id - ) - if satellite_entity is None: - raise HomeAssistantError( - f"Invalid Assist satellite entity id: {satellite_entity_id}" - ) + async_setup_services(hass) - satellite_entity.async_set_context(call.context) - - ask_question_args = { - "question": call.data.get("question"), - "question_media_id": call.data.get("question_media_id"), - "preannounce": call.data.get("preannounce", True), - "answers": call.data.get("answers"), - } - - if preannounce_media_id := call.data.get("preannounce_media_id"): - ask_question_args["preannounce_media_id"] = preannounce_media_id - - answer = await satellite_entity.async_internal_ask_question(**ask_question_args) - - if answer is None: - raise HomeAssistantError("No answer from satellite") - - return asdict(answer) - - hass.services.async_register( - domain=DOMAIN, - service="ask_question", - service_func=handle_ask_question, - schema=probatio.All( - { - probatio.Required(ATTR_ENTITY_ID): cv.entity_domain(DOMAIN), - probatio.Optional("question"): str, - probatio.Optional("question_media_id"): _media_id_validator, - probatio.Optional("preannounce", default=True): bool, - probatio.Optional("preannounce_media_id"): _media_id_validator, - probatio.Optional("answers"): [ - { - probatio.Required("id"): str, - probatio.Required("sentences"): probatio.All( - cv.ensure_list, - [cv.string], - has_one_non_empty_item, - has_no_punctuation, - is_valid_sentence, - ), - } - ], - }, - cv.has_at_least_one_key("question", "question_media_id"), - ), - supports_response=SupportsResponse.ONLY, - ) hass.data[CONNECTION_TEST_DATA] = {} async_register_websocket_api(hass) hass.http.register_view(ConnectionTestView()) @@ -202,63 +80,3 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Unload a config entry.""" return await hass.data[DATA_COMPONENT].async_unload_entry(entry) - - -def has_no_punctuation(value: list[str]) -> list[str]: - """Validate result does not contain punctuation.""" - for sentence in value: - # Exclude {list_references} which may contain punctuation characters. - sentence = _remove_list_references(sentence) - if ( - PUNCTUATION_START.search(sentence) - or PUNCTUATION_END.search(sentence) - or PUNCTUATION_START_WORD.search(sentence) - or PUNCTUATION_END_WORD.search(sentence) - ): - raise probatio.Invalid("sentence should not contain punctuation") - - return value - - -def _remove_list_references(sentence: str) -> str: - """Remove {list_references} from a sentence for linting.""" - return re.sub(r"(? list[str]: - """Validate result can be parsed by hassil.""" - for sentence in value: - try: - parse_sentence(sentence) - except ParseError as err: - raise probatio.Invalid(f"invalid sentence: {err}") from err - return value - - -def has_one_non_empty_item(value: list[str]) -> list[str]: - """Validate result has at least one item.""" - if len(value) < 1: - raise probatio.Invalid("at least one sentence is required") - - for sentence in value: - if not sentence: - raise probatio.Invalid("sentences cannot be empty") - - return value - - -# Validator for media_id fields that accepts both string and media selector format -_media_id_validator = probatio.Any( - cv.string, # Plain string format - probatio.All( - probatio.Schema( - { - probatio.Required("media_content_id"): cv.string, - probatio.Required("media_content_type"): cv.string, - probatio.Remove("metadata"): dict, # Ignore metadata if present - } - ), - # Extract media_content_id from media selector format - lambda x: x["media_content_id"], - ), -) diff --git a/homeassistant/components/assist_satellite/services.py b/homeassistant/components/assist_satellite/services.py new file mode 100644 index 00000000000000..e38a21810d8586 --- /dev/null +++ b/homeassistant/components/assist_satellite/services.py @@ -0,0 +1,200 @@ +"""Services for the Assist satellite integration.""" + +from dataclasses import asdict +import re +from typing import Any + +from hassil.parse_expression import parse_sentence +from hassil.parser import ParseError +from hassil.util import ( + PUNCTUATION_END, + PUNCTUATION_END_WORD, + PUNCTUATION_START, + PUNCTUATION_START_WORD, +) +import probatio + +from homeassistant.auth.permissions.const import CAT_ENTITIES, POLICY_CONTROL +from homeassistant.const import ATTR_ENTITY_ID +from homeassistant.core import HomeAssistant, ServiceCall, SupportsResponse, callback +from homeassistant.exceptions import HomeAssistantError, Unauthorized, UnknownUser +from homeassistant.helpers import config_validation as cv + +from .const import DATA_COMPONENT, DOMAIN, AssistSatelliteEntityFeature +from .entity import AssistSatelliteEntity + + +def has_no_punctuation(value: list[str]) -> list[str]: + """Validate result does not contain punctuation.""" + for sentence in value: + # Exclude {list_references} which may contain punctuation characters. + sentence = _remove_list_references(sentence) + if ( + PUNCTUATION_START.search(sentence) + or PUNCTUATION_END.search(sentence) + or PUNCTUATION_START_WORD.search(sentence) + or PUNCTUATION_END_WORD.search(sentence) + ): + raise probatio.Invalid("sentence should not contain punctuation") + + return value + + +def _remove_list_references(sentence: str) -> str: + """Remove {list_references} from a sentence for linting.""" + return re.sub(r"(? list[str]: + """Validate result can be parsed by hassil.""" + for sentence in value: + try: + parse_sentence(sentence) + except ParseError as err: + raise probatio.Invalid(f"invalid sentence: {err}") from err + return value + + +def has_one_non_empty_item(value: list[str]) -> list[str]: + """Validate result has at least one item.""" + if len(value) < 1: + raise probatio.Invalid("at least one sentence is required") + + for sentence in value: + if not sentence: + raise probatio.Invalid("sentences cannot be empty") + + return value + + +# Validator for media_id fields that accepts both string and media selector format +_media_id_validator = probatio.Any( + cv.string, # Plain string format + probatio.All( + probatio.Schema( + { + probatio.Required("media_content_id"): cv.string, + probatio.Required("media_content_type"): cv.string, + probatio.Remove("metadata"): dict, # Ignore metadata if present + } + ), + # Extract media_content_id from media selector format + lambda x: x["media_content_id"], + ), +) + + +@callback +def async_setup_services(hass: HomeAssistant) -> None: + """Register the Assist satellite services.""" + component = hass.data[DATA_COMPONENT] + + component.async_register_entity_service( + "announce", + probatio.All( + cv.make_entity_service_schema( + { + probatio.Optional("message"): str, + probatio.Optional("media_id"): _media_id_validator, + probatio.Optional("preannounce", default=True): bool, + probatio.Optional("preannounce_media_id"): _media_id_validator, + } + ), + cv.has_at_least_one_key("message", "media_id"), + ), + "async_internal_announce", + [AssistSatelliteEntityFeature.ANNOUNCE], + ) + + component.async_register_entity_service( + "start_conversation", + probatio.All( + cv.make_entity_service_schema( + { + probatio.Optional("start_message"): str, + probatio.Optional("start_media_id"): _media_id_validator, + probatio.Optional("preannounce", default=True): bool, + probatio.Optional("preannounce_media_id"): _media_id_validator, + probatio.Optional("extra_system_prompt"): str, + } + ), + cv.has_at_least_one_key("start_message", "start_media_id"), + ), + "async_internal_start_conversation", + [AssistSatelliteEntityFeature.START_CONVERSATION], + ) + + async def handle_ask_question(call: ServiceCall) -> dict[str, Any]: + """Handle a Show View service call.""" + satellite_entity_id: str = call.data[ATTR_ENTITY_ID] + if call.context.user_id: + user = await hass.auth.async_get_user(call.context.user_id) + if user is None: + raise UnknownUser( + context=call.context, + permission=POLICY_CONTROL, + user_id=call.context.user_id, + ) + if not user.permissions.check_entity(satellite_entity_id, POLICY_CONTROL): + raise Unauthorized( + context=call.context, + permission=POLICY_CONTROL, + user_id=call.context.user_id, + perm_category=CAT_ENTITIES, + ) + + satellite_entity: AssistSatelliteEntity | None = component.get_entity( + satellite_entity_id + ) + if satellite_entity is None: + raise HomeAssistantError( + f"Invalid Assist satellite entity id: {satellite_entity_id}" + ) + + satellite_entity.async_set_context(call.context) + + ask_question_args = { + "question": call.data.get("question"), + "question_media_id": call.data.get("question_media_id"), + "preannounce": call.data.get("preannounce", True), + "answers": call.data.get("answers"), + } + + if preannounce_media_id := call.data.get("preannounce_media_id"): + ask_question_args["preannounce_media_id"] = preannounce_media_id + + answer = await satellite_entity.async_internal_ask_question(**ask_question_args) + + if answer is None: + raise HomeAssistantError("No answer from satellite") + + return asdict(answer) + + hass.services.async_register( + domain=DOMAIN, + service="ask_question", + service_func=handle_ask_question, + schema=probatio.All( + { + probatio.Required(ATTR_ENTITY_ID): cv.entity_domain(DOMAIN), + probatio.Optional("question"): str, + probatio.Optional("question_media_id"): _media_id_validator, + probatio.Optional("preannounce", default=True): bool, + probatio.Optional("preannounce_media_id"): _media_id_validator, + probatio.Optional("answers"): [ + { + probatio.Required("id"): str, + probatio.Required("sentences"): probatio.All( + cv.ensure_list, + [cv.string], + has_one_non_empty_item, + has_no_punctuation, + is_valid_sentence, + ), + } + ], + }, + cv.has_at_least_one_key("question", "question_media_id"), + ), + supports_response=SupportsResponse.ONLY, + ) diff --git a/homeassistant/components/collection_image/image.py b/homeassistant/components/collection_image/image.py index ad601054ee9f7b..d01ddfd2cbc7cc 100644 --- a/homeassistant/components/collection_image/image.py +++ b/homeassistant/components/collection_image/image.py @@ -80,6 +80,11 @@ def set_unavailable(self) -> None: self._attr_image_url = UNDEFINED self._cached_image = None self.async_write_ha_state() + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="no_images", + translation_placeholders={"entity": self.entity_id}, + ) async def get_valid_images(self) -> list[BrowseMedia]: """Given the configured media directory for the entity, get a list of all child images.""" @@ -107,15 +112,15 @@ async def get_valid_images(self) -> list[BrowseMedia]: media_content_id, ) + if not images: + self.set_unavailable() + return images async def get_random_image(self) -> None: """Update the image entity with a random image from the source media.""" filtered = await self.get_valid_images() - if not filtered: - self.set_unavailable() - return # Don't allow random shuffle to return the same image we are currently viewing. if self._current_image_id: @@ -151,9 +156,6 @@ async def _get_image_at_position(self, position: Literal[0, -1]) -> None: """Get the first or last image.""" filtered = await self.get_valid_images() - if not filtered: - self.set_unavailable() - return child = filtered[position] self._attr_available = True @@ -165,9 +167,6 @@ async def _get_next_sequential_image( """Get the next or previous image.""" filtered = await self.get_valid_images() - if not filtered: - self.set_unavailable() - return current_index = next( ( @@ -197,13 +196,19 @@ async def update_image(self, image_id: str) -> None: try: resolved = await async_resolve_media(self.hass, image_id, self.entity_id) except Unresolvable as err: - _LOGGER.warning("%s: %s", self.entity_id, str(err)) self._attr_image_last_updated = None self.path = None self._attr_image_url = UNDEFINED self._attr_content_type = DEFAULT_CONTENT_TYPE self.async_write_ha_state() - return + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="unresolvable", + translation_placeholders={ + "entity": self.entity_id, + "id": image_id, + }, + ) from err finally: self._current_image_id = image_id @@ -223,7 +228,12 @@ async def async_added_to_hass(self) -> None: """Initialize the first image after entity has been created.""" async def get_random_image_on_start(_hass: HomeAssistant) -> None: - await self.get_random_image() + try: + await self.get_random_image() + except HomeAssistantError: + _LOGGER.exception( + "Unable to get an initial image", + ) self.async_on_remove(async_at_started(self.hass, get_random_image_on_start)) diff --git a/homeassistant/components/collection_image/quality_scale.yaml b/homeassistant/components/collection_image/quality_scale.yaml index 34c064ff239f6f..2d86de188c75a4 100644 --- a/homeassistant/components/collection_image/quality_scale.yaml +++ b/homeassistant/components/collection_image/quality_scale.yaml @@ -44,7 +44,7 @@ rules: comment: Integration does not connect to any physical object or service. # Silver - action-exceptions: todo + action-exceptions: done config-entry-unloading: done docs-configuration-parameters: done docs-installation-parameters: done diff --git a/homeassistant/components/collection_image/strings.json b/homeassistant/components/collection_image/strings.json index 2106c05a7011a7..2b252132ea5916 100644 --- a/homeassistant/components/collection_image/strings.json +++ b/homeassistant/components/collection_image/strings.json @@ -30,6 +30,12 @@ "exceptions": { "image_read_error": { "message": "Error reading image from {path}: {error}" + }, + "no_images": { + "message": "No image files were found in the configured media. {entity} will now be unavailable. Reload the config entry after correcting the media source." + }, + "unresolvable": { + "message": "Selected media id {id} failed to resolve for {entity}." } }, "services": { diff --git a/homeassistant/components/command_line/notify.py b/homeassistant/components/command_line/notify.py index 21871e01519451..8b48bde931d83b 100644 --- a/homeassistant/components/command_line/notify.py +++ b/homeassistant/components/command_line/notify.py @@ -1,7 +1,5 @@ """Support for command line notification services.""" -import asyncio -from contextlib import suppress from typing import Any, override from homeassistant.components.notify import ( @@ -14,7 +12,11 @@ from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from .const import CONF_COMMAND_TIMEOUT, DOMAIN, LOGGER -from .utils import create_platform_yaml_not_supported_issue, render_template_args +from .utils import ( + async_run_shell_command, + create_platform_yaml_not_supported_issue, + render_template_args, +) async def async_get_service( @@ -51,46 +53,24 @@ async def async_send_message(self, message: str = "", **kwargs: Any) -> None: LOGGER.debug("Running with message: %s", message) try: - proc = await asyncio.create_subprocess_shell( # shell by design - command, - stdin=asyncio.subprocess.PIPE, - close_fds=False, # required for posix_spawn + proc, _ = await async_run_shell_command( + command, self._timeout, stdin=message.encode() ) - except OSError as err: - LOGGER.debug("Error trying to exec command: %s", command) - raise HomeAssistantError( - translation_domain=DOMAIN, - translation_key="command_error", - translation_placeholders={"command": command, "error": str(err)}, - ) from err - - try: - async with asyncio.timeout(self._timeout): - await proc.communicate(input=message.encode()) except TimeoutError as err: + # TimeoutError subclasses OSError, so it must be caught first. LOGGER.debug("Timeout for command: %s", command) - with suppress(ProcessLookupError): - # The command may have exited between the timeout and the kill. - proc.kill() - if (stdin := proc.stdin) is not None and ( - not stdin.is_closing() or stdin.transport.get_write_buffer_size() - ): - # A still connected stdin pipe keeps proc.wait() pending forever, - # see https://bugs.python.org/issue43884. - stdin.transport.abort() - await proc.wait() raise HomeAssistantError( translation_domain=DOMAIN, translation_key="timeout_error", translation_placeholders={"command": command}, ) from err - except asyncio.CancelledError: - # Kill synchronously so the child isn't orphaned; the event loop - # reaps it without awaiting wait(), which cancellation would - # interrupt anyway. - with suppress(ProcessLookupError): - proc.kill() - raise + except OSError as err: + LOGGER.debug("Error trying to exec command: %s", command) + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="command_error", + translation_placeholders={"command": command, "error": str(err)}, + ) from err if proc.returncode != 0: LOGGER.error( diff --git a/homeassistant/components/command_line/utils.py b/homeassistant/components/command_line/utils.py index 1c9de6b55c4851..2d9e73a1778147 100644 --- a/homeassistant/components/command_line/utils.py +++ b/homeassistant/components/command_line/utils.py @@ -1,6 +1,8 @@ """The command_line component utils.""" import asyncio +from contextlib import suppress +from typing import Literal, overload from homeassistant.core import HomeAssistant from homeassistant.exceptions import TemplateError @@ -14,6 +16,72 @@ _EXEC_FAILED_CODE = 127 +@overload +async def async_run_shell_command( + command: str, + timeout: int, + *, + stdin: bytes | None = ..., + capture_stdout: Literal[False] = ..., +) -> tuple[asyncio.subprocess.Process, None]: ... + + +@overload +async def async_run_shell_command( + command: str, + timeout: int, + *, + stdin: bytes | None = ..., + capture_stdout: Literal[True], +) -> tuple[asyncio.subprocess.Process, bytes]: ... + + +async def async_run_shell_command( + command: str, + timeout: int, + *, + stdin: bytes | None = None, + capture_stdout: bool = False, +) -> tuple[asyncio.subprocess.Process, bytes | None]: + """Run a shell command with a timeout and return the process and stdout. + + The returned stdout is the captured bytes when capture_stdout is set, else None. + An OSError from spawning propagates; TimeoutError propagates after stdin cleanup + when stdin is provided. + """ + proc = await asyncio.create_subprocess_shell( # shell by design + command, + stdin=asyncio.subprocess.PIPE if stdin is not None else None, + stdout=asyncio.subprocess.PIPE if capture_stdout else None, + close_fds=False, # required for posix_spawn + ) + try: + async with asyncio.timeout(timeout): + stdout, _ = await proc.communicate(input=stdin) + except TimeoutError: + if stdin is not None: + with suppress(ProcessLookupError): + # The command may have exited between the timeout and the kill. + proc.kill() + if (proc_stdin := proc.stdin) is not None and ( + not proc_stdin.is_closing() + or proc_stdin.transport.get_write_buffer_size() + ): + # A still connected stdin pipe keeps proc.wait() pending forever, + # see https://bugs.python.org/issue43884. + proc_stdin.transport.abort() + await proc.wait() + raise + except asyncio.CancelledError: + # Kill synchronously so the child isn't orphaned; the event loop + # reaps it without awaiting wait(), which cancellation would + # interrupt anyway. + with suppress(ProcessLookupError): + proc.kill() + raise + return proc, stdout + + async def async_call_shell_with_timeout( command: str, timeout: int, *, log_return_code: bool = True ) -> int: @@ -22,14 +90,9 @@ async def async_call_shell_with_timeout( If log_return_code is set to False, it will not print an error if a non-zero return code is returned. """ + LOGGER.debug("Running command: %s", command) try: - LOGGER.debug("Running command: %s", command) - proc = await asyncio.create_subprocess_shell( # shell by design - command, - close_fds=False, # required for posix_spawn - ) - async with asyncio.timeout(timeout): - await proc.communicate() + proc, _ = await async_run_shell_command(command, timeout) except TimeoutError: LOGGER.error("Timeout for command: %s", command) return -1 @@ -49,24 +112,19 @@ async def async_call_shell_with_timeout( async def async_check_output_or_log(command: str, timeout: int) -> str | None: """Run a shell command with a timeout and return the output.""" try: - proc = await asyncio.create_subprocess_shell( # shell by design - command, - close_fds=False, # required for posix_spawn - stdout=asyncio.subprocess.PIPE, + proc, stdout = await async_run_shell_command( + command, timeout, capture_stdout=True ) - async with asyncio.timeout(timeout): - stdout, _ = await proc.communicate() - - if proc.returncode != 0: - LOGGER.error( - "Command failed (with return code %s): %s", proc.returncode, command - ) - else: - return stdout.strip().decode("utf-8") except TimeoutError: LOGGER.error("Timeout for command: %s", command) + return None - return None + if proc.returncode != 0: + LOGGER.error( + "Command failed (with return code %s): %s", proc.returncode, command + ) + return None + return stdout.strip().decode("utf-8") def render_template_args(hass: HomeAssistant, command: str) -> str | None: diff --git a/homeassistant/components/danfoss_air/__init__.py b/homeassistant/components/danfoss_air/__init__.py index f8d73b49f2a6e8..e395c596479418 100644 --- a/homeassistant/components/danfoss_air/__init__.py +++ b/homeassistant/components/danfoss_air/__init__.py @@ -79,7 +79,9 @@ def update(self): self._client.command(ReadCommand.filterPercent), 2 ) self._data[ReadCommand.bypass] = self._client.command(ReadCommand.bypass) - self._data[ReadCommand.fan_step] = self._client.command(ReadCommand.fan_step) + self._data[ReadCommand.fan_speed_percent] = self._client.command( + ReadCommand.fan_speed_percent + ) self._data[ReadCommand.supply_fan_speed] = self._client.command( ReadCommand.supply_fan_speed ) diff --git a/homeassistant/components/danfoss_air/manifest.json b/homeassistant/components/danfoss_air/manifest.json index 57cb1aa7218bcb..dab5a986044d8d 100644 --- a/homeassistant/components/danfoss_air/manifest.json +++ b/homeassistant/components/danfoss_air/manifest.json @@ -6,5 +6,5 @@ "iot_class": "local_polling", "loggers": ["pydanfossair"], "quality_scale": "legacy", - "requirements": ["pydanfossair==0.1.0"] + "requirements": ["pydanfossair==1.0.0"] } diff --git a/homeassistant/components/danfoss_air/sensor.py b/homeassistant/components/danfoss_air/sensor.py index b7f2b0eaf9a5c9..b723e4c687e0e4 100644 --- a/homeassistant/components/danfoss_air/sensor.py +++ b/homeassistant/components/danfoss_air/sensor.py @@ -71,7 +71,13 @@ def setup_platform( SensorDeviceClass.HUMIDITY, SensorStateClass.MEASUREMENT, ], - ["Danfoss Air Fan Step", PERCENTAGE, ReadCommand.fan_step, None, None], + [ + "Danfoss Air Fan Step", + PERCENTAGE, + ReadCommand.fan_speed_percent, + None, + None, + ], [ "Danfoss Air Exhaust Fan Speed", REVOLUTIONS_PER_MINUTE, diff --git a/homeassistant/components/dnsip/sensor.py b/homeassistant/components/dnsip/sensor.py index 8524e8e849a141..90febb05f3cf17 100644 --- a/homeassistant/components/dnsip/sensor.py +++ b/homeassistant/components/dnsip/sensor.py @@ -153,7 +153,7 @@ async def async_resolve(self, hostname: str) -> list[str]: await self._resolver.close() return ips except DNSError as err: - _LOGGER.warning("Exception while resolving host: %s", err) + _LOGGER.debug("Exception while resolving host: %s", err) await self._resolver.close() return ips diff --git a/homeassistant/components/enphase_envoy/manifest.json b/homeassistant/components/enphase_envoy/manifest.json index 9e6e53093e0c8e..f25df7adda1457 100644 --- a/homeassistant/components/enphase_envoy/manifest.json +++ b/homeassistant/components/enphase_envoy/manifest.json @@ -8,7 +8,7 @@ "iot_class": "local_polling", "loggers": ["pyenphase"], "quality_scale": "platinum", - "requirements": ["pyenphase==4.0.3"], + "requirements": ["pyenphase==4.0.5"], "zeroconf": [ { "type": "_enphase-envoy._tcp.local." diff --git a/homeassistant/components/fressnapf_tracker/manifest.json b/homeassistant/components/fressnapf_tracker/manifest.json index 66b4982c2fc5a5..9d2803593907aa 100644 --- a/homeassistant/components/fressnapf_tracker/manifest.json +++ b/homeassistant/components/fressnapf_tracker/manifest.json @@ -7,5 +7,5 @@ "integration_type": "hub", "iot_class": "cloud_polling", "quality_scale": "bronze", - "requirements": ["fressnapftracker==0.2.2"] + "requirements": ["fressnapftracker==0.3.1"] } diff --git a/homeassistant/components/gree_infrared/__init__.py b/homeassistant/components/gree_infrared/__init__.py new file mode 100644 index 00000000000000..a225466303aa8d --- /dev/null +++ b/homeassistant/components/gree_infrared/__init__.py @@ -0,0 +1,18 @@ +"""Gree IR Remote integration for Home Assistant.""" + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant + +PLATFORMS = [Platform.CLIMATE] + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Set up Gree IR from a config entry.""" + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + return True + + +async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Unload a Gree IR config entry.""" + return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/homeassistant/components/gree_infrared/climate.py b/homeassistant/components/gree_infrared/climate.py new file mode 100644 index 00000000000000..68289b50da356b --- /dev/null +++ b/homeassistant/components/gree_infrared/climate.py @@ -0,0 +1,291 @@ +"""Climate platform for Gree IR integration — Gree AC.""" + +from dataclasses import dataclass +from typing import Any, override + +from infrared_protocols.commands.gree_ac import ( + MAX_TEMP, + MIN_TEMP, + GreeAcCommand, + GreeAcFanSpeed, + GreeAcMode, +) + +from homeassistant.components.climate import ( + ATTR_FAN_MODE, + ATTR_HVAC_MODE, + FAN_AUTO, + FAN_HIGH, + FAN_LOW, + FAN_MEDIUM, + ClimateEntity, + ClimateEntityFeature, + HVACMode, +) +from homeassistant.components.infrared import ( + InfraredEmitterConsumerEntity, + InfraredReceivedSignal, + InfraredReceiverConsumerEntity, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import ( + ATTR_TEMPERATURE, + STATE_UNAVAILABLE, + STATE_UNKNOWN, + UnitOfTemperature, +) +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback +from homeassistant.helpers.restore_state import ExtraStoredData, RestoreEntity +from homeassistant.util.unit_conversion import TemperatureConverter + +from .const import ( + CONF_HVAC_MODES, + CONF_INFRARED_EMITTER_ENTITY_ID, + CONF_INFRARED_RECEIVER_ENTITY_ID, +) +from .entity import GreeIrEntity + +PARALLEL_UPDATES = 1 + +_HA_FAN_TO_LIB: dict[str, GreeAcFanSpeed] = { + FAN_AUTO: GreeAcFanSpeed.AUTO, + FAN_LOW: GreeAcFanSpeed.LOW, + FAN_MEDIUM: GreeAcFanSpeed.MEDIUM, + FAN_HIGH: GreeAcFanSpeed.HIGH, +} +_LIB_FAN_TO_HA: dict[GreeAcFanSpeed, str] = {v: k for k, v in _HA_FAN_TO_LIB.items()} + +# Every mode other than OFF; the protocol has no OFF mode of its own, power is a +# separate field, so this dict intentionally has no HVACMode.OFF entry. +_HA_MODE_TO_LIB: dict[HVACMode, GreeAcMode] = { + HVACMode.AUTO: GreeAcMode.AUTO, + HVACMode.COOL: GreeAcMode.COOL, + HVACMode.HEAT: GreeAcMode.HEAT, + HVACMode.DRY: GreeAcMode.DRY, + HVACMode.FAN_ONLY: GreeAcMode.FAN_ONLY, +} +_LIB_MODE_TO_HA: dict[GreeAcMode, HVACMode] = {v: k for k, v in _HA_MODE_TO_LIB.items()} + + +@dataclass +class _GreeAcExtraStoredData(ExtraStoredData): + """Extra data restored alongside the entity's visible state. + + Holds the mode the unit was last actively in. The visible state only records + OFF once the unit is off, but off frames still carry a mode field, so this + cannot be recovered from last_state.state alone. + """ + + last_active_hvac_mode: str + + @override + def as_dict(self) -> dict[str, Any]: + """Return a dict representation for storage.""" + return {"last_active_hvac_mode": self.last_active_hvac_mode} + + @classmethod + def from_dict(cls, restored: dict[str, Any]) -> _GreeAcExtraStoredData | None: + """Build from a stored dict, or None if it doesn't look valid.""" + last_active_hvac_mode = restored.get("last_active_hvac_mode") + if not isinstance(last_active_hvac_mode, str): + return None + return cls(last_active_hvac_mode=last_active_hvac_mode) + + +async def async_setup_entry( + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up Gree AC climate entity from config entry.""" + emitter_entity_id = entry.data[CONF_INFRARED_EMITTER_ENTITY_ID] + if receiver_entity_id := entry.data.get(CONF_INFRARED_RECEIVER_ENTITY_ID): + async_add_entities( + [GreeAcClimateWithReceiver(entry, emitter_entity_id, receiver_entity_id)] + ) + else: + async_add_entities([GreeAcClimateEntity(entry, emitter_entity_id)]) + + +class GreeAcClimateEntity( + GreeIrEntity, InfraredEmitterConsumerEntity, ClimateEntity, RestoreEntity +): + """Gree AC climate entity controlled via infrared emitter.""" + + _attr_name = None + _attr_temperature_unit = UnitOfTemperature.CELSIUS + _attr_target_temperature_step = 1.0 + _attr_min_temp = float(MIN_TEMP) + _attr_max_temp = float(MAX_TEMP) + _attr_should_poll = False + _attr_assumed_state = True + # Every mode's frame carries a temperature and a fan field, so both features are + # always supported regardless of which modes are configured. + _attr_supported_features = ( + ClimateEntityFeature.TARGET_TEMPERATURE | ClimateEntityFeature.FAN_MODE + ) + _attr_fan_modes = [FAN_AUTO, FAN_LOW, FAN_MEDIUM, FAN_HIGH] + + def __init__(self, entry: ConfigEntry, emitter_entity_id: str) -> None: + """Initialize Gree AC climate entity.""" + super().__init__(entry) + self._infrared_emitter_entity_id = emitter_entity_id + + configured_modes = entry.data.get( + CONF_HVAC_MODES, [HVACMode.COOL, HVACMode.DRY] + ) + self._attr_hvac_modes = [HVACMode.OFF] + [HVACMode(m) for m in configured_modes] + self._attr_hvac_mode = HVACMode.OFF + self._attr_target_temperature = float(MIN_TEMP) + self._attr_fan_mode = FAN_AUTO + # Power-off frames still need a mode field; this tracks the mode to send it + # with, since the protocol has no dedicated OFF mode. + self._last_active_hvac_mode = self._attr_hvac_modes[1] + + @override + async def async_added_to_hass(self) -> None: + """Restore the assumed state, as infrared cannot read it back from the AC.""" + await super().async_added_to_hass() + + last_state = await self.async_get_last_state() + if last_state is not None and last_state.state not in ( + STATE_UNAVAILABLE, + STATE_UNKNOWN, + ): + if last_state.state in self._attr_hvac_modes: + self._attr_hvac_mode = HVACMode(last_state.state) + if (fan_mode := last_state.attributes.get(ATTR_FAN_MODE)) in _HA_FAN_TO_LIB: + self._attr_fan_mode = fan_mode + if (temperature := last_state.attributes.get(ATTR_TEMPERATURE)) is not None: + self._attr_target_temperature = float( + round( + TemperatureConverter.convert( + float(temperature), + self.hass.config.units.temperature_unit, + self.temperature_unit, + ) + ) + ) + + current_mode = self._attr_hvac_mode + if current_mode is not None and current_mode is not HVACMode.OFF: + self._last_active_hvac_mode = current_mode + elif (last_extra_data := await self.async_get_last_extra_data()) is not None: + restored = _GreeAcExtraStoredData.from_dict(last_extra_data.as_dict()) + if restored is not None and restored.last_active_hvac_mode in ( + mode.value for mode in self._attr_hvac_modes if mode is not HVACMode.OFF + ): + self._last_active_hvac_mode = HVACMode(restored.last_active_hvac_mode) + + @property + @override + def extra_restore_state_data(self) -> ExtraStoredData: + """Return extra data to be restored alongside the entity's state.""" + return _GreeAcExtraStoredData( + last_active_hvac_mode=self._last_active_hvac_mode.value + ) + + async def _async_send_state( + self, hvac_mode: HVACMode, temp: int, fan_mode: str + ) -> None: + """Send a full-state frame for the given target state.""" + power = hvac_mode is not HVACMode.OFF + active_hvac_mode = hvac_mode if power else self._last_active_hvac_mode + await self._send_command( + self._build_command(active_hvac_mode, power, temp, fan_mode) + ) + if power: + self._last_active_hvac_mode = hvac_mode + + @override + async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None: + """Set HVAC mode.""" + await self._async_send_state( + hvac_mode, + int(self._attr_target_temperature or MIN_TEMP), + self._attr_fan_mode or FAN_AUTO, + ) + self._attr_hvac_mode = hvac_mode + self.async_write_ha_state() + + @override + async def async_set_temperature(self, **kwargs: Any) -> None: + """Set the target temperature, switching the HVAC mode when one is given.""" + temp = round(kwargs[ATTR_TEMPERATURE]) + hvac_mode: HVACMode | None = kwargs.get(ATTR_HVAC_MODE) + if hvac_mode is not None: + self._valid_mode_or_raise("hvac", hvac_mode, self.hvac_modes) + + effective_mode = hvac_mode or self._attr_hvac_mode or HVACMode.OFF + # A temperature change on its own has nothing to send while the unit is off. + if effective_mode is not HVACMode.OFF or hvac_mode is HVACMode.OFF: + await self._async_send_state( + effective_mode, temp, self._attr_fan_mode or FAN_AUTO + ) + + if hvac_mode is not None: + self._attr_hvac_mode = hvac_mode + + self._attr_target_temperature = float(temp) + self.async_write_ha_state() + + @override + async def async_set_fan_mode(self, fan_mode: str) -> None: + """Set fan mode.""" + hvac_mode = self._attr_hvac_mode + if hvac_mode is not None and hvac_mode is not HVACMode.OFF: + await self._async_send_state( + hvac_mode, int(self._attr_target_temperature or MIN_TEMP), fan_mode + ) + self._attr_fan_mode = fan_mode + self.async_write_ha_state() + + def _build_command( + self, hvac_mode: HVACMode, power: bool, temp: int, fan_mode: str + ) -> GreeAcCommand: + """Build a command from a mode, power state, a temperature and a fan mode.""" + return GreeAcCommand( + power=power, + mode=_HA_MODE_TO_LIB[hvac_mode], + temperature=temp, + fan=_HA_FAN_TO_LIB[fan_mode], + swing_v=False, + swing_h=False, + turbo=False, + display=True, + blow=False, + ) + + +class GreeAcClimateWithReceiver(GreeAcClimateEntity, InfraredReceiverConsumerEntity): + """Gree AC climate entity that also tracks a configured infrared receiver.""" + + def __init__( + self, entry: ConfigEntry, emitter_entity_id: str, receiver_entity_id: str + ) -> None: + """Initialize Gree AC climate entity with a receiver.""" + super().__init__(entry, emitter_entity_id) + self._infrared_receiver_entity_id = receiver_entity_id + + @override + @callback + def _handle_signal(self, signal: InfraredReceivedSignal) -> None: + """Update state from a physical remote signal.""" + command = GreeAcCommand.from_raw_timings(signal.timings) + if command is None: + return + + # Off frames carry a mode field too, so the mode is recorded either way. + embedded_hvac_mode = _LIB_MODE_TO_HA[command.mode] + if embedded_hvac_mode in self._attr_hvac_modes: + self._last_active_hvac_mode = embedded_hvac_mode + elif command.power: + return + + hvac_mode = embedded_hvac_mode if command.power else HVACMode.OFF + + self._attr_hvac_mode = hvac_mode + self._attr_fan_mode = _LIB_FAN_TO_HA[command.fan] + self._attr_target_temperature = float(command.temperature) + self.async_write_ha_state() diff --git a/homeassistant/components/gree_infrared/config_flow.py b/homeassistant/components/gree_infrared/config_flow.py new file mode 100644 index 00000000000000..9e5c8c69191b37 --- /dev/null +++ b/homeassistant/components/gree_infrared/config_flow.py @@ -0,0 +1,108 @@ +"""Config flow for Gree IR integration.""" + +from typing import Any, override + +import probatio + +from homeassistant.components.climate import HVACMode +from homeassistant.components.infrared import ( + DOMAIN as INFRARED_DOMAIN, + async_get_emitters, + async_get_receivers, +) +from homeassistant.config_entries import ConfigFlow, ConfigFlowResult +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers import entity_registry as er +from homeassistant.helpers.selector import ( + EntitySelector, + EntitySelectorConfig, + SelectSelector, + SelectSelectorConfig, + SelectSelectorMode, +) + +from .const import ( + CONF_HVAC_MODES, + CONF_INFRARED_EMITTER_ENTITY_ID, + CONF_INFRARED_RECEIVER_ENTITY_ID, + DOMAIN, +) + +_HVAC_MODE_OPTIONS = [ + HVACMode.COOL, + HVACMode.HEAT, + HVACMode.DRY, + HVACMode.FAN_ONLY, + HVACMode.AUTO, +] +_DEFAULT_HVAC_MODES = [HVACMode.COOL, HVACMode.DRY] + + +@callback +def _user_schema(hass: HomeAssistant) -> probatio.Schema: + """Return the emitter/receiver/mode selection schema.""" + return probatio.Schema( + { + probatio.Required(CONF_INFRARED_EMITTER_ENTITY_ID): EntitySelector( + EntitySelectorConfig( + domain=INFRARED_DOMAIN, + include_entities=async_get_emitters(hass), + ) + ), + probatio.Optional(CONF_INFRARED_RECEIVER_ENTITY_ID): EntitySelector( + EntitySelectorConfig( + domain=INFRARED_DOMAIN, + include_entities=async_get_receivers(hass), + ) + ), + probatio.Required( + CONF_HVAC_MODES, default=_DEFAULT_HVAC_MODES + ): probatio.All( + SelectSelector( + SelectSelectorConfig( + options=[mode.value for mode in _HVAC_MODE_OPTIONS], + translation_key=CONF_HVAC_MODES, + mode=SelectSelectorMode.LIST, + multiple=True, + ) + ), + probatio.Length(min=1, msg="no_hvac_modes"), + ), + } + ) + + +class GreeIrConfigFlow(ConfigFlow, domain=DOMAIN): + """Handle config flow for Gree IR.""" + + VERSION = 1 + + def _entity_name(self, entity_id: str) -> str: + ent_reg = er.async_get(self.hass) + entry = ent_reg.async_get(entity_id) + return entry.name or entry.original_name or entity_id if entry else entity_id + + @override + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle emitter, receiver and mode selection.""" + if not async_get_emitters(self.hass): + return self.async_abort(reason="no_infrared_emitters") + + if user_input is not None: + emitter_id = user_input[CONF_INFRARED_EMITTER_ENTITY_ID] + self._async_abort_entries_match( + {CONF_INFRARED_EMITTER_ENTITY_ID: emitter_id} + ) + if receiver_id := user_input.get(CONF_INFRARED_RECEIVER_ENTITY_ID): + self._async_abort_entries_match( + {CONF_INFRARED_RECEIVER_ENTITY_ID: receiver_id} + ) + + return self.async_create_entry( + title=f"Gree AC via {self._entity_name(emitter_id)}", + data=user_input, + ) + + return self.async_show_form(step_id="user", data_schema=_user_schema(self.hass)) diff --git a/homeassistant/components/gree_infrared/const.py b/homeassistant/components/gree_infrared/const.py new file mode 100644 index 00000000000000..90e8877589272a --- /dev/null +++ b/homeassistant/components/gree_infrared/const.py @@ -0,0 +1,6 @@ +"""Constants for the Gree IR integration.""" + +DOMAIN = "gree_infrared" +CONF_INFRARED_EMITTER_ENTITY_ID = "infrared_emitter_entity_id" +CONF_INFRARED_RECEIVER_ENTITY_ID = "infrared_receiver_entity_id" +CONF_HVAC_MODES = "hvac_modes" diff --git a/homeassistant/components/gree_infrared/entity.py b/homeassistant/components/gree_infrared/entity.py new file mode 100644 index 00000000000000..45db4ef1a80222 --- /dev/null +++ b/homeassistant/components/gree_infrared/entity.py @@ -0,0 +1,31 @@ +"""Common entity for Gree IR integration.""" + +from homeassistant.config_entries import ConfigEntry +from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.entity import Entity + +from .const import DOMAIN + + +class GreeIrEntity(Entity): + """Gree IR base entity providing common device info.""" + + _attr_has_entity_name = True + + def __init__( + self, + entry: ConfigEntry, + unique_id_suffix: str | None = None, + device_name: str = "Gree AC", + ) -> None: + """Initialize Gree IR entity.""" + self._attr_unique_id = ( + entry.entry_id + if unique_id_suffix is None + else f"{entry.entry_id}_{unique_id_suffix}" + ) + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, entry.entry_id)}, + name=device_name, + manufacturer="Gree", + ) diff --git a/homeassistant/components/gree_infrared/manifest.json b/homeassistant/components/gree_infrared/manifest.json new file mode 100644 index 00000000000000..531de602455b8e --- /dev/null +++ b/homeassistant/components/gree_infrared/manifest.json @@ -0,0 +1,11 @@ +{ + "domain": "gree_infrared", + "name": "Gree Infrared", + "codeowners": ["@Dr-Blank"], + "config_flow": true, + "dependencies": ["infrared"], + "documentation": "https://www.home-assistant.io/integrations/gree_infrared", + "integration_type": "device", + "iot_class": "assumed_state", + "quality_scale": "silver" +} diff --git a/homeassistant/components/gree_infrared/quality_scale.yaml b/homeassistant/components/gree_infrared/quality_scale.yaml new file mode 100644 index 00000000000000..bb30f59922766e --- /dev/null +++ b/homeassistant/components/gree_infrared/quality_scale.yaml @@ -0,0 +1,125 @@ +rules: + # Bronze + action-setup: + status: exempt + comment: | + This integration does not provide additional actions. + appropriate-polling: + status: exempt + comment: | + This integration does not poll. + brands: done + common-modules: done + config-flow-test-coverage: done + config-flow: done + dependency-transparency: done + docs-actions: + status: exempt + comment: | + This integration does not provide additional actions. + docs-conditions: + status: exempt + comment: This integration does not have any conditions. + docs-high-level-description: done + docs-installation-instructions: done + docs-removal-instructions: done + docs-triggers: + status: exempt + comment: This integration does not have any triggers. + entity-event-setup: done + entity-unique-id: done + has-entity-name: done + runtime-data: + status: exempt + comment: | + This integration does not store runtime data. + test-before-configure: + status: exempt + comment: | + The config flow only selects an existing infrared emitter entity and the + supported modes, so there is no device connection to validate before + creating the entry. + test-before-setup: + status: exempt + comment: | + This integration only proxies commands through an existing infrared + entity, so there is no separate connection to validate during setup. + unique-config-entry: done + # Silver + action-exceptions: + status: exempt + comment: | + This integration does not register custom actions. + 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: + status: exempt + comment: | + This integration does not require authentication. + test-coverage: done + # Gold + devices: done + diagnostics: todo + discovery-update-info: + status: exempt + comment: | + This integration does not support discovery. + discovery: + status: exempt + comment: | + This integration is configured manually via config flow. + docs-data-update: + status: exempt + comment: | + This integration does not fetch data from devices. + docs-examples: todo + docs-known-limitations: done + docs-supported-devices: done + docs-supported-functions: done + docs-troubleshooting: todo + docs-use-cases: todo + dynamic-devices: + status: exempt + comment: | + Each config entry creates a single device. + entity-category: done + entity-device-class: done + entity-disabled-by-default: + status: exempt + comment: | + No entities should be disabled by default. + entity-translations: done + exception-translations: + status: exempt + comment: | + This integration does not raise exceptions. + icon-translations: + status: exempt + comment: | + This integration does not use custom icons. + reconfiguration-flow: todo + repair-issues: + status: exempt + comment: | + This integration does not have repairable issues. + stale-devices: + status: exempt + comment: | + Each config entry manages exactly one device. + + # Platinum + async-dependency: + status: exempt + comment: | + This integration depends on infrared_protocols, which provides only code + definitions with no I/O, so async dependency does not apply. + inject-websession: + status: exempt + comment: | + This integration does not make HTTP requests. + strict-typing: done diff --git a/homeassistant/components/gree_infrared/strings.json b/homeassistant/components/gree_infrared/strings.json new file mode 100644 index 00000000000000..1bffd00302e8d7 --- /dev/null +++ b/homeassistant/components/gree_infrared/strings.json @@ -0,0 +1,38 @@ +{ + "config": { + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", + "no_infrared_emitters": "[%key:common::config_flow::abort::no_infrared_emitters%]" + }, + "error": { + "no_hvac_modes": "Select at least one supported mode." + }, + "step": { + "user": { + "data": { + "hvac_modes": "Supported modes", + "infrared_emitter_entity_id": "Infrared emitter", + "infrared_receiver_entity_id": "[%key:common::config_flow::data::infrared_receiver_entity_id%]" + }, + "data_description": { + "hvac_modes": "Select the operating modes your AC unit supports. Heat is not available on all models.", + "infrared_emitter_entity_id": "The infrared emitter entity to use for sending commands.", + "infrared_receiver_entity_id": "Optional — allows the integration to update state when the physical remote is used." + }, + "description": "Select an infrared emitter and the modes your Gree AC supports.", + "title": "Set up Gree AC" + } + } + }, + "selector": { + "hvac_modes": { + "options": { + "auto": "[%key:common::state::auto%]", + "cool": "Cool", + "dry": "Dry", + "fan_only": "Fan only", + "heat": "Heat" + } + } + } +} diff --git a/homeassistant/components/heos/media_player.py b/homeassistant/components/heos/media_player.py index c9710c3825bea6..ed1d9f304c8ed5 100644 --- a/homeassistant/components/heos/media_player.py +++ b/homeassistant/components/heos/media_player.py @@ -452,6 +452,13 @@ async def async_group_volume_up(self) -> None: async def async_join_players(self, group_members: list[str]) -> None: """Join `group_members` as a player group with the current player.""" player_ids: list[int] = [self._player.player_id] + # Keep the members of the group this player already leads. HEOS replaces + # the group with the players provided, so members that are not sent + # again are removed when another player is added to the group. + for group in self.coordinator.heos.groups.values(): + if group.lead_player_id == self._player.player_id: + player_ids.extend(group.member_player_ids) + break # Resolve entity_ids to player_ids entity_registry = er.async_get(self.hass) for entity_id in group_members: diff --git a/homeassistant/components/izone/config_flow.py b/homeassistant/components/izone/config_flow.py index 88e43a62f58aa2..03daad4d382bcf 100644 --- a/homeassistant/components/izone/config_flow.py +++ b/homeassistant/components/izone/config_flow.py @@ -14,7 +14,6 @@ from homeassistant.config_entries import ConfigFlow, ConfigFlowResult, FlowType from homeassistant.const import CONF_HOST from homeassistant.core import callback -from homeassistant.helpers import discovery_flow from homeassistant.helpers.selector import ( SelectOptionDict, SelectSelector, @@ -34,6 +33,8 @@ # Wait after IASD for ASPort replies (matches pizone discover_all wait). USER_SCAN_WAIT_SECONDS = SCAN_TIMEOUT +STEP_MANUAL_HOST_SCHEMA = probatio.Schema({probatio.Required(CONF_HOST): str}) + @dataclass(frozen=True, slots=True) class _ShelfCandidate: @@ -57,7 +58,8 @@ class IZoneConfigFlow(ConfigFlow, domain=DOMAIN): VERSION = 2 - _discovered_controller_ip: str | None = None + _discovered_controller_host: str | None = None + _discovered_controller_uid: str | None = None _user_discovery_task: asyncio.Task[None] | None = None _user_discovery_failed: bool = False @@ -103,8 +105,11 @@ async def async_step_import( async def async_step_user( self, _user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: - """User-started flow: search the LAN, then offer discovered controllers.""" - return await self.async_step_discover() + """User-started flow: search the LAN or enter a controller host.""" + return self.async_show_menu( + step_id="user", + menu_options=["discover", "manual_host"], + ) async def _async_run_user_discovery(self) -> None: """Scan and wait for the progress step (no unique_id work here).""" @@ -147,14 +152,16 @@ async def async_step_discover( async def async_step_discovery_done( self, _user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: - """After Search scan: abort, hand off the sole shelf flow, or choose.""" + """After Search scan: nudge to Enter host, hand off, or choose.""" if self._user_discovery_failed: return self.async_abort(reason="discovery_failed") candidates = self._async_user_candidates() if not candidates: _LOGGER.debug("No controllers found on the Discovered shelf") - return self.async_abort(reason="no_devices_found") + return self._async_show_manual_host_form( + errors={"base": "no_devices_found"} + ) if len(candidates) == 1: return self.async_abort( reason="continue_setup", @@ -208,6 +215,21 @@ async def async_step_select_controller( return self.async_abort(reason="already_configured") return self.async_abort(reason="no_devices_found") + async def async_step_manual_host( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Enter a controller IP or hostname, then hand off or confirm.""" + if user_input is None: + return self._async_show_manual_host_form() + + host = user_input[CONF_HOST].strip() + if not host: + return self._async_show_manual_host_form( + errors={CONF_HOST: "required"}, + suggested_values=user_input, + ) + return await self._async_manual_host_submit(host) + @override async def async_step_homekit( self, discovery_info: ZeroconfServiceInfo @@ -244,7 +266,7 @@ async def async_step_homekit( _LOGGER.debug("Unable to start iZone discovery service", exc_info=True) return self.async_abort(reason="discovery_failed") - self._discovered_controller_ip = endpoint.host + self._discovered_controller_host = endpoint.host # Re-check after awaiting discovery to catch mid-flight configuration. self._abort_if_unique_id_configured() @@ -269,15 +291,15 @@ async def async_step_integration_discovery( await self.async_set_unique_id(uid) self._abort_if_unique_id_configured() # Persist through confirm into entry data as CONF_HOST. - self._discovered_controller_ip = host + self._discovered_controller_host = host return await self.async_step_confirm() async def async_step_confirm( self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: - """Confirm adding a controller found via HomeKit or discovery.""" - controller_uid = self.unique_id - host = self._discovered_controller_ip + """Confirm adding a controller found via HomeKit, discovery, or Ignore replace.""" + controller_uid = self.unique_id or self._discovered_controller_uid + host = self._discovered_controller_host assert isinstance(controller_uid, str) assert controller_uid assert host is not None @@ -327,14 +349,114 @@ def _async_user_candidates(self) -> list[_ShelfCandidate]: return sorted(candidates, key=lambda candidate: (candidate.uid, candidate.host)) @callback - def _async_schedule_integration_discovery_flow( + def _async_shelf_candidate_for_host(self, host: str) -> _ShelfCandidate | None: + """Return the shelf candidate whose host matches *host*, if any.""" + for candidate in self._async_user_candidates(): + if candidate.host == host: + return candidate + return None + + @callback + def _async_shelf_candidate_for_uid(self, uid: str) -> _ShelfCandidate | None: + """Return the shelf candidate whose UID matches *uid*, if any.""" + for candidate in self._async_user_candidates(): + if candidate.uid == uid: + return candidate + return None + + @callback + def _async_handoff_to_shelf(self, candidate: _ShelfCandidate) -> ConfigFlowResult: + """Abort the user flow into the shelf confirm for *candidate*.""" + return self.async_abort( + reason="continue_setup", + next_flow=(FlowType.CONFIG_FLOW, candidate.flow_id), + ) + + @callback + def _async_show_manual_host_form( + self, + *, + errors: dict[str, str] | None = None, + suggested_values: dict[str, Any] | None = None, + ) -> ConfigFlowResult: + """Show the Enter host form.""" + return self.async_show_form( + step_id="manual_host", + data_schema=self.add_suggested_values_to_schema( + STEP_MANUAL_HOST_SCHEMA, suggested_values + ), + errors=errors, + ) + + async def _async_manual_host_submit(self, host: str) -> ConfigFlowResult: + """Handoff a shelf hit, else probe and shelve or Ignore-replace.""" + # Placeholder host is good enough to skip a probe; after probe, match UID. + if (candidate := self._async_shelf_candidate_for_host(host)) is not None: + return self._async_handoff_to_shelf(candidate) + + try: + endpoint = await izone_discovery.async_discover_by_host(self.hass, host) + except OSError: + _LOGGER.debug("Unable to start iZone discovery service", exc_info=True) + return self.async_abort(reason="discovery_failed") + except pizone.UnpairedBridgeError: + return self._async_show_manual_host_form( + errors={"base": "unpaired_bridge"}, + suggested_values={CONF_HOST: host}, + ) + except pizone.ControllerAlreadyClaimedError: + return self._async_show_manual_host_form( + errors={"base": "already_configured"}, + suggested_values={CONF_HOST: host}, + ) + + if endpoint is None: + return self._async_show_manual_host_form( + errors={"base": "cannot_connect"}, + suggested_values={CONF_HOST: host}, + ) + + if endpoint.uid in izone_discovery.yaml_excluded_uids(self.hass): + return self._async_show_manual_host_form( + errors={"base": "no_devices_found"}, + suggested_values={CONF_HOST: host}, + ) + + existing = self.hass.config_entries.async_entry_for_domain_unique_id( + DOMAIN, endpoint.uid + ) + if existing is not None: + if existing.source == config_entries.SOURCE_IGNORE: + self._discovered_controller_uid = endpoint.uid + self._discovered_controller_host = endpoint.host + return await self.async_step_confirm() + return self._async_show_manual_host_form( + errors={"base": "already_configured"}, + suggested_values={CONF_HOST: host}, + ) + + if (candidate := self._async_shelf_candidate_for_uid(endpoint.uid)) is not None: + if candidate.host != endpoint.host: + # Shelf still shows an older discovery address; replace that card. + self.hass.config_entries.flow.async_abort(candidate.flow_id) + else: + return self._async_handoff_to_shelf(candidate) + + await self._async_shelve_integration_discovery_flow(endpoint.uid, endpoint.host) + if (candidate := self._async_shelf_candidate_for_uid(endpoint.uid)) is not None: + return self._async_handoff_to_shelf(candidate) + return self._async_show_manual_host_form( + errors={"base": "no_devices_found"}, + suggested_values={CONF_HOST: host}, + ) + + async def _async_shelve_integration_discovery_flow( self, uid: str, host: str, ) -> None: - """Queue integration discovery (import fan-out or HomeKit sibling).""" - discovery_flow.async_create_flow( - self.hass, + """Await a shelf confirm flow for manual host.""" + await self.hass.config_entries.flow.async_init( DOMAIN, context={ "source": config_entries.SOURCE_INTEGRATION_DISCOVERY, @@ -378,7 +500,4 @@ def _async_fan_out_discovered_endpoints( continue if candidate.uid in current_ids or candidate.uid in in_progress_ids: continue - self._async_schedule_integration_discovery_flow( - candidate.uid, - candidate.host, - ) + izone_discovery.async_note_integration_discovery(self.hass, candidate) diff --git a/homeassistant/components/izone/discovery.py b/homeassistant/components/izone/discovery.py index 05fc54c180a224..806638ac6b5bba 100644 --- a/homeassistant/components/izone/discovery.py +++ b/homeassistant/components/izone/discovery.py @@ -234,6 +234,22 @@ async def async_discover_endpoint( return await service.discover_by_uid(uid) +async def async_discover_by_host( + hass: HomeAssistant, host: str +) -> pizone.ControllerEndpoint | None: + """HTTP-probe a controller at *host* for config-flow validation. + + Starts shared discovery if needed. + + Raises: + OSError: Discovery UDP socket could not be bound. + UnpairedBridgeError: Probed UID is the unpaired placeholder. + ControllerAlreadyClaimedError: Host or UID is already claimed on the service. + """ + service = await async_ensure_discovery(hass) + return await service.discover_by_host(host) + + async def async_maybe_stop_discovery(hass: HomeAssistant) -> None: """Stop discovery when nothing actionable remains. diff --git a/homeassistant/components/izone/strings.json b/homeassistant/components/izone/strings.json index 1bb081da18cdd4..90bfed3fa3555b 100644 --- a/homeassistant/components/izone/strings.json +++ b/homeassistant/components/izone/strings.json @@ -6,7 +6,15 @@ "continue_setup": "Continue setting up the discovered iZone controller.", "discovery_failed": "Failed to start iZone discovery. Make sure your network is properly configured.", "discovery_started": "iZone discovery has started. Your controllers will appear as discovered devices under Settings \u003e Devices \u0026 services.", - "no_devices_found": "[%key:common::config_flow::abort::no_devices_found%]" + "no_devices_found": "[%key:common::config_flow::abort::no_devices_found%]", + "unpaired_bridge": "This iZone bridge is not paired with an air conditioner." + }, + "error": { + "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "no_devices_found": "[%key:common::config_flow::abort::no_devices_found%]", + "required": "Please enter a host.", + "unpaired_bridge": "This iZone bridge is not paired with an air conditioner." }, "flow_title": "{name}", "progress": { @@ -16,11 +24,23 @@ "confirm": { "description": "Do you want to set up iZone?\n\nController UID: {controller_uid}\nController IP: {host}" }, + "manual_host": { + "data": { + "host": "[%key:common::config_flow::data::host%]" + }, + "description": "Enter the IP address or hostname of your iZone controller." + }, "select_controller": { "data": { "selected_controller_uid": "Controller" }, "description": "Multiple unconfigured iZone controllers were found:\n{controllers}\n\nChoose the controller you want to set up now. Any controller you do not select will remain available as a discovered device you can set up later under **Settings** > **Devices & services**." + }, + "user": { + "menu_options": { + "discover": "Search for devices", + "manual_host": "Enter host" + } } } }, diff --git a/homeassistant/components/midea/climate.py b/homeassistant/components/midea/climate.py index f32ffcc16c6031..6bc24064e71d00 100644 --- a/homeassistant/components/midea/climate.py +++ b/homeassistant/components/midea/climate.py @@ -409,9 +409,7 @@ def swing_mode(self) -> str | None: def current_humidity(self) -> float | None: """Return the current indoor humidity, or None if unavailable.""" raw = self._device.get_attribute(ACAttributes.indoor_humidity) - # Some devices report invalid values (0 or 0xFF) for this sensor - # so filter those out and return None instead. - if isinstance(raw, (int, float)) and raw not in {0, 0xFF}: + if isinstance(raw, (int, float)): return float(raw) return None diff --git a/homeassistant/components/midea/icons.json b/homeassistant/components/midea/icons.json index 1d09a42c062e15..a80e1afe373565 100644 --- a/homeassistant/components/midea/icons.json +++ b/homeassistant/components/midea/icons.json @@ -73,6 +73,9 @@ }, "target_indoor_fan_speed": { "default": "mdi:fan-clock" + }, + "time_remaining": { + "default": "mdi:progress-clock" } }, "switch": { diff --git a/homeassistant/components/midea/number.py b/homeassistant/components/midea/number.py index 9104b7b27206e3..7524db1171dcc1 100644 --- a/homeassistant/components/midea/number.py +++ b/homeassistant/components/midea/number.py @@ -13,7 +13,7 @@ NumberEntity, NumberEntityDescription, ) -from homeassistant.const import UnitOfTime, UnitOfVolume +from homeassistant.const import UnitOfMass, UnitOfTime, UnitOfVolume from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback @@ -93,6 +93,16 @@ class MideaNumberEntityDescription(NumberEntityDescription): native_step=50, native_unit_of_measurement=UnitOfVolume.LITERS, ), + MideaNumberEntityDescription( + key="salt_setting", + translation_key="salt_setting", + models=[DeviceType.ED], + device_class=NumberDeviceClass.WEIGHT, + native_min_value=0, + native_max_value=255, + native_step=1, + native_unit_of_measurement=UnitOfMass.KILOGRAMS, + ), MideaNumberEntityDescription( key="heating_level", translation_key="heating_level", diff --git a/homeassistant/components/midea/sensor.py b/homeassistant/components/midea/sensor.py index 02793b4ff85630..d426cad11ff515 100644 --- a/homeassistant/components/midea/sensor.py +++ b/homeassistant/components/midea/sensor.py @@ -1,9 +1,12 @@ """Midea Sensor entities.""" from dataclasses import dataclass +from datetime import datetime, timedelta from typing import cast, override from midealocal.const import DeviceType +from midealocal.devices.ea import MideaEADevice +from midealocal.devices.ec import MideaECDevice from homeassistant.components.sensor import ( EntityCategory, @@ -29,6 +32,7 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.typing import StateType +from homeassistant.util import dt as dt_util from .entity import MideaConfigEntry, MideaEntity @@ -42,106 +46,6 @@ class MideaSensorEntityDescription(SensorEntityDescription): models: list[DeviceType] | None = None -COOKER_MODES: list[str] = [ - "smart", - "reserve", - "cook_rice", - "fast_cook_rice", - "standard_cook_rice", - "gruel", - "cook_congee", - "stew_soup", - "stewing", - "heat_rice", - "make_cake", - "yoghourt", - "soup_rice", - "coarse_rice", - "five_ceeals_rice", - "eight_treasures_rice", - "crispy_rice", - "shelled_rice", - "eight_treasures_congee", - "infant_congee", - "older_rice", - "rice_soup", - "rice_paste", - "egg_custard", - "warm_milk", - "hot_spring_egg", - "millet_congee", - "firewood_rice", - "few_rice", - "red_potato", - "corn", - "quick_freeze_bun", - "steam_ribs", - "steam_egg", - "coarse_congee", - "steep_rice", - "appetizing_congee", - "corn_congee", - "sprout_rice", - "luscious_rice", - "luscious_boiled", - "fast_rice", - "fast_boil", - "bean_rice_congee", - "fast_congee", - "baby_congee", - "cook_soup", - "congee_coup", - "steam_corn", - "steam_red_potato", - "boil_congee", - "delicious_steam", - "boil_egg", - "rice_wine", - "fruit_vegetable_paste", - "vegetable_porridge", - "pork_porridge", - "fragrant_rice", - "assorte_rice", - "steame_fish", - "baby_rice", - "essence_rice", - "fragrant_dense_congee", - "one_two_cook", - "original_steame", - "hot_fast_rice", - "online_celebrity_rice", - "sushi_rice", - "stone_bowl_rice", - "no_water_treat", - "keep_fresh", - "low_sugar_rice", - "black_buckwheat_rice", - "resveratrol_rice", - "yellow_wheat_rice", - "green_buckwheat_rice", - "roughage_rice", - "millet_mixed_rice", - "iron_pan_rice", - "olla_pan_rice", - "vegetable_rice", - "baby_side", - "regimen_congee", - "earthen_pot_congee", - "regimen_soup", - "pottery_jar_soup", - "canton_soup", - "nutrition_stew", - "northeast_stew", - "uncap_boil", - "trichromatic_coarse_grain", - "four_color_vegetables", - "egg", - "chop", - "clean", - "keep_warm", -] - - SENSOR_ENTITIES: list[MideaSensorEntityDescription] = [ MideaSensorEntityDescription( key="indoor_humidity", @@ -375,14 +279,14 @@ class MideaSensorEntityDescription(SensorEntityDescription): translation_key="mode", models=[DeviceType.EA], device_class=SensorDeviceClass.ENUM, - options=COOKER_MODES, + options=MideaEADevice.mode_options(), ), MideaSensorEntityDescription( key="mode", translation_key="mode", models=[DeviceType.EC], device_class=SensorDeviceClass.ENUM, - options=[*COOKER_MODES, "diy"], + options=MideaECDevice.mode_options(), ), MideaSensorEntityDescription( key="tank", @@ -567,6 +471,17 @@ class MideaSensorEntityDescription(SensorEntityDescription): "freeze_warm", ], ), + MideaSensorEntityDescription( + key="time_remaining", + translation_key="time_remaining", + device_class=SensorDeviceClass.TIMESTAMP, + ), + MideaSensorEntityDescription( + key="heating_time_remaining", + translation_key="time_remaining", + device_class=SensorDeviceClass.TIMESTAMP, + models=[DeviceType.E2], + ), MideaSensorEntityDescription( key="wash_time", translation_key="wash_time", @@ -808,15 +723,16 @@ class MideaSensor(MideaEntity, SensorEntity): @property @override - def native_value(self) -> StateType: + def native_value(self) -> StateType | datetime: """Native value of the sensor.""" value = self._device.get_attribute(self.entity_description.key) - if ( - self.entity_description.key == "indoor_humidity" - and isinstance(value, (int, float)) - and value in {0, 0xFF} - ): - return None if value == "unknown": return None + if self.entity_description.device_class == SensorDeviceClass.TIMESTAMP: + if not isinstance(value, (int, float)) or value <= 0: + return None + # round to the closest minute + return (dt_util.utcnow() + timedelta(seconds=30)).replace( + second=0, microsecond=0 + ) + timedelta(minutes=value) return cast("StateType", value) diff --git a/homeassistant/components/midea/strings.json b/homeassistant/components/midea/strings.json index 5a4fc1867b3dbb..4dd19acf76bcce 100644 --- a/homeassistant/components/midea/strings.json +++ b/homeassistant/components/midea/strings.json @@ -226,6 +226,9 @@ "leak_water_protection_value": { "name": "Leak water protection value" }, + "salt_setting": { + "name": "Salt setting" + }, "seat_temp_level": { "name": "Seat temperature level" }, @@ -626,6 +629,9 @@ "temperature_raw": { "name": "Raw temperature" }, + "time_remaining": { + "name": "Time remaining" + }, "top_compartment_temperature": { "name": "Top compartment temperature" }, @@ -674,6 +680,9 @@ } }, "switch": { + "ai_switch": { + "name": "AI switch" + }, "anion": { "name": "Anion" }, diff --git a/homeassistant/components/midea/switch.py b/homeassistant/components/midea/switch.py index e2ea81e11f3fe6..7ec118b048c6d4 100644 --- a/homeassistant/components/midea/switch.py +++ b/homeassistant/components/midea/switch.py @@ -109,6 +109,11 @@ class MideaSwitchEntityDescription(SwitchEntityDescription): translation_key="night_light", models=[DeviceType.CC], ), + MideaSwitchEntityDescription( + key="ai_switch", + translation_key="ai_switch", + models=[DeviceType.DC], + ), ] diff --git a/homeassistant/components/mikrotik/manifest.json b/homeassistant/components/mikrotik/manifest.json index 40575366121f17..3e1bcbe04a61be 100644 --- a/homeassistant/components/mikrotik/manifest.json +++ b/homeassistant/components/mikrotik/manifest.json @@ -8,5 +8,5 @@ "iot_class": "local_polling", "loggers": ["librouteros"], "quality_scale": "silver", - "requirements": ["librouteros==4.1.1"] + "requirements": ["librouteros==4.2.2"] } diff --git a/homeassistant/components/rest/__init__.py b/homeassistant/components/rest/__init__.py index 65ea0b913fb6eb..ef835e860a565a 100644 --- a/homeassistant/components/rest/__init__.py +++ b/homeassistant/components/rest/__init__.py @@ -38,7 +38,6 @@ async_reload_integration_platforms, ) from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType -from homeassistant.helpers.update_coordinator import DataUpdateCoordinator from homeassistant.util.async_ import create_eager_task from .const import ( @@ -49,10 +48,9 @@ DEFAULT_SSL_CIPHER_LIST, DOMAIN, PLATFORM_IDX, - REST, - REST_DATA, REST_IDX, ) +from .coordinator import RestCoordinator from .data import RestData from .schema import CONFIG_SCHEMA, RESOURCE_SCHEMA # noqa: F401 @@ -94,7 +92,7 @@ async def reload_service_handler(service: ServiceCall) -> None: @callback def _async_setup_shared_data(hass: HomeAssistant) -> None: """Create shared data for platform config and rest coordinators.""" - hass.data[DOMAIN] = {key: [] for key in (REST_DATA, *COORDINATOR_AWARE_PLATFORMS)} + hass.data[DOMAIN] = {key: [] for key in (COORDINATOR, *COORDINATOR_AWARE_PLATFORMS)} async def _async_process_config(hass: HomeAssistant, config: ConfigType) -> bool: @@ -110,11 +108,11 @@ async def _async_process_config(hass: HomeAssistant, config: ConfigType) -> bool resource_template: template.Template | None = conf.get(CONF_RESOURCE_TEMPLATE) payload_template: template.Template | None = conf.get(CONF_PAYLOAD_TEMPLATE) rest = create_rest_data_from_config(hass, conf) - coordinator = _rest_coordinator( - hass, rest, resource_template, payload_template, scan_interval + coordinator = RestCoordinator( + hass, rest, None, resource_template, payload_template, scan_interval ) refresh_coroutines.append(coordinator.async_refresh()) - hass.data[DOMAIN][REST_DATA].append({REST: rest, COORDINATOR: coordinator}) + hass.data[DOMAIN][COORDINATOR].append(coordinator) for platform_domain in COORDINATOR_AWARE_PLATFORMS: if platform_domain not in conf: @@ -144,46 +142,15 @@ async def _async_process_config(hass: HomeAssistant, config: ConfigType) -> bool async def async_get_config_and_coordinator( hass: HomeAssistant, platform_domain: str, discovery_info: DiscoveryInfoType -) -> tuple[ConfigType, DataUpdateCoordinator[None], RestData]: +) -> tuple[ConfigType, RestCoordinator, RestData]: """Get the config and coordinator for the platform from discovery.""" - shared_data = hass.data[DOMAIN][REST_DATA][discovery_info[REST_IDX]] + coordinator: RestCoordinator = hass.data[DOMAIN][COORDINATOR][ + discovery_info[REST_IDX] + ] conf: ConfigType = hass.data[DOMAIN][platform_domain][discovery_info[PLATFORM_IDX]] - coordinator: DataUpdateCoordinator[None] = shared_data[COORDINATOR] - rest: RestData = shared_data[REST] - if rest.data is None: + if coordinator.rest.data is None: await coordinator.async_request_refresh() - return conf, coordinator, rest - - -def _rest_coordinator( - hass: HomeAssistant, - rest: RestData, - resource_template: template.Template | None, - payload_template: template.Template | None, - update_interval: timedelta, -) -> DataUpdateCoordinator[None]: - """Wrap a DataUpdateCoordinator around the rest object.""" - if resource_template or payload_template: - - async def _async_refresh_with_templates() -> None: - if resource_template: - rest.set_url(resource_template.async_render(parse_result=False)) - if payload_template: - rest.set_payload(payload_template.async_render(parse_result=False)) - await rest.async_update() - - update_method = _async_refresh_with_templates - else: - update_method = rest.async_update - - return DataUpdateCoordinator( - hass, - _LOGGER, - config_entry=None, - name="rest data", - update_method=update_method, - update_interval=update_interval, - ) + return conf, coordinator, coordinator.rest def create_rest_data_from_config(hass: HomeAssistant, config: ConfigType) -> RestData: diff --git a/homeassistant/components/rest/binary_sensor.py b/homeassistant/components/rest/binary_sensor.py index 70d397d5ac3c1f..56b5511aba4e28 100644 --- a/homeassistant/components/rest/binary_sensor.py +++ b/homeassistant/components/rest/binary_sensor.py @@ -25,9 +25,9 @@ ValueTemplate, ) from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType -from homeassistant.helpers.update_coordinator import DataUpdateCoordinator from .const import DEFAULT_BINARY_SENSOR_NAME +from .coordinator import RestCoordinator from .data import RestData from .entity import ( RestEntity, @@ -78,7 +78,7 @@ class RestBinarySensor(ManualTriggerEntity, RestEntity, BinarySensorEntity): def __init__( self, hass: HomeAssistant, - coordinator: DataUpdateCoordinator[None] | None, + coordinator: RestCoordinator | None, rest: RestData, config: ConfigType, trigger_entity_config: ConfigType, diff --git a/homeassistant/components/rest/const.py b/homeassistant/components/rest/const.py index d10b3f3f74e5b8..ddc4f4a873f36b 100644 --- a/homeassistant/components/rest/const.py +++ b/homeassistant/components/rest/const.py @@ -21,9 +21,6 @@ PLATFORM_IDX = "platform_idx" COORDINATOR = "coordinator" -REST = "rest" - -REST_DATA = "rest_data" METHODS = ["POST", "GET"] diff --git a/homeassistant/components/rest/coordinator.py b/homeassistant/components/rest/coordinator.py new file mode 100644 index 00000000000000..6c22da365bfa37 --- /dev/null +++ b/homeassistant/components/rest/coordinator.py @@ -0,0 +1,54 @@ +"""RESTful Data Update Coordinator.""" + +from datetime import timedelta +import logging + +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.helpers import template +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator + +from .data import RestData + +_LOGGER = logging.getLogger(__name__) + +RestConfigEntry = ConfigEntry["RestCoordinator"] + + +class RestCoordinator(DataUpdateCoordinator[None]): + """Rest coordinator.""" + + def __init__( + self, + hass: HomeAssistant, + rest: RestData, + config_entry: RestConfigEntry | None, + resource_template: template.Template | None, + payload_template: template.Template | None, + update_interval: timedelta, + ) -> None: + """Initialize a data update coordinator.""" + + self.rest: RestData = rest + + if resource_template or payload_template: + + async def _async_refresh_with_templates() -> None: + if resource_template: + rest.set_url(resource_template.async_render(parse_result=False)) + if payload_template: + rest.set_payload(payload_template.async_render(parse_result=False)) + await rest.async_update() + + update_method = _async_refresh_with_templates + else: + update_method = rest.async_update + + super().__init__( + hass, + _LOGGER, + config_entry=config_entry, + name="rest data", + update_interval=update_interval, + update_method=update_method, + ) diff --git a/homeassistant/components/rest/entity.py b/homeassistant/components/rest/entity.py index 8a7f5112cc3789..424d2fe09dcdcf 100644 --- a/homeassistant/components/rest/entity.py +++ b/homeassistant/components/rest/entity.py @@ -22,9 +22,9 @@ CONF_PICTURE, ) from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType -from homeassistant.helpers.update_coordinator import DataUpdateCoordinator from . import async_get_config_and_coordinator, create_rest_data_from_config +from .coordinator import RestCoordinator from .data import RestData TRIGGER_ENTITY_OPTIONS = ( @@ -45,7 +45,7 @@ async def async_get_config_rest_data_and_coordinator( config: ConfigType, entity_domain: str, discovery_info: DiscoveryInfoType | None = None, -) -> tuple[ConfigType, RestData, DataUpdateCoordinator[None] | None]: +) -> tuple[ConfigType, RestData, RestCoordinator | None]: """Get the config, rest data +/- coordinator for sub entity.""" # Must update the sensor now (including fetching the rest resource) to # ensure it's updating its state. @@ -96,7 +96,7 @@ class RestEntity(Entity): def __init__( self, - coordinator: DataUpdateCoordinator[None] | None, + coordinator: RestCoordinator | None, rest: RestData, resource_template: Template | None, force_update: bool, diff --git a/homeassistant/components/rest/sensor.py b/homeassistant/components/rest/sensor.py index 6bf308e01c5f70..ea2f90b428f53d 100644 --- a/homeassistant/components/rest/sensor.py +++ b/homeassistant/components/rest/sensor.py @@ -24,9 +24,9 @@ ValueTemplate, ) from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType -from homeassistant.helpers.update_coordinator import DataUpdateCoordinator from .const import CONF_JSON_ATTRS, CONF_JSON_ATTRS_PATH, DEFAULT_SENSOR_NAME +from .coordinator import RestCoordinator from .data import RestData from .entity import ( RestEntity, @@ -76,7 +76,7 @@ class RestSensor(ManualTriggerSensorEntity, RestEntity): def __init__( self, hass: HomeAssistant, - coordinator: DataUpdateCoordinator[None] | None, + coordinator: RestCoordinator | None, rest: RestData, config: ConfigType, trigger_entity_config: ConfigType, diff --git a/homeassistant/components/samsungtv/manifest.json b/homeassistant/components/samsungtv/manifest.json index c2261e62c124a1..3b113bb57a76a4 100644 --- a/homeassistant/components/samsungtv/manifest.json +++ b/homeassistant/components/samsungtv/manifest.json @@ -38,7 +38,7 @@ "requirements": [ "getmac==0.9.5", "samsungctl[websocket]==0.7.1", - "samsungtvws[async,encrypted]==3.0.5", + "samsungtvws[async,encrypted]==3.0.6", "wakeonlan==3.3.0", "async-upnp-client==0.48.1" ], diff --git a/homeassistant/components/samsungtv/quality_scale.yaml b/homeassistant/components/samsungtv/quality_scale.yaml index eb2b1131c16f0f..4b48d3a42cf24f 100644 --- a/homeassistant/components/samsungtv/quality_scale.yaml +++ b/homeassistant/components/samsungtv/quality_scale.yaml @@ -85,4 +85,4 @@ rules: inject-websession: done strict-typing: status: todo - comment: Requirements 'getmac==0.9.5', 'samsungctl[websocket]==0.7.1' and 'wakeonlan==2.1.0' appear untyped + comment: Requirements 'getmac==0.9.5' and 'samsungctl[websocket]==0.7.1' appear untyped diff --git a/homeassistant/components/specialized_turbo/manifest.json b/homeassistant/components/specialized_turbo/manifest.json index 19b0e40c76f47a..2be0a4167ff99e 100644 --- a/homeassistant/components/specialized_turbo/manifest.json +++ b/homeassistant/components/specialized_turbo/manifest.json @@ -49,5 +49,5 @@ "iot_class": "local_push", "loggers": ["specialized_turbo"], "quality_scale": "silver", - "requirements": ["specialized-turbo[cloud]==0.8.3"] + "requirements": ["specialized-turbo[cloud]==0.8.4"] } diff --git a/homeassistant/components/telegram_bot/__init__.py b/homeassistant/components/telegram_bot/__init__.py index 54d567aa8a01ce..feeb9c6c2b8d3e 100644 --- a/homeassistant/components/telegram_bot/__init__.py +++ b/homeassistant/components/telegram_bot/__init__.py @@ -7,7 +7,7 @@ from telegram import Bot from telegram.error import InvalidToken, TelegramError -from homeassistant.const import CONF_PLATFORM, Platform +from homeassistant.const import CONF_API_KEY, CONF_PLATFORM, Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady from homeassistant.helpers import ( @@ -41,6 +41,7 @@ PLATFORM_POLLING, PLATFORM_WEBHOOKS, ) +from .log_filter import async_redact_token, async_unredact_token from .services import async_setup_services _LOGGER = logging.getLogger(__name__) @@ -157,6 +158,10 @@ def bot_device_info(config_entry: TelegramBotConfigEntry, bot_id: int) -> dr.Dev async def async_setup_entry(hass: HomeAssistant, entry: TelegramBotConfigEntry) -> bool: """Create the Telegram bot from config entry.""" + # Registered before the bot is built: the library logs the token as soon as + # it constructs the API URLs. + async_redact_token(entry.data[CONF_API_KEY]) + bot: Bot = await hass.async_add_executor_job(initialize_bot, hass, entry.data) try: await bot.get_me() @@ -164,7 +169,12 @@ async def async_setup_entry(hass: HomeAssistant, entry: TelegramBotConfigEntry) # pylint: disable-next=home-assistant-exception-not-translated raise ConfigEntryAuthFailed("Invalid API token for Telegram Bot.") from err except TelegramError as err: - raise ConfigEntryNotReady from err + # Do not let the message through: the Telegram API URL embeds the bot + # token, and library errors quote that URL. + raise ConfigEntryNotReady( + translation_domain=DOMAIN, + translation_key="cannot_connect", + ) from err p_type: str = entry.data[CONF_PLATFORM] @@ -212,6 +222,8 @@ async def async_unload_entry( hass: HomeAssistant, entry: TelegramBotConfigEntry ) -> bool: """Unload Telegram app.""" + async_unredact_token(entry.data[CONF_API_KEY]) + # broadcast platform has no app if entry.runtime_data.app: await entry.runtime_data.app.shutdown() diff --git a/homeassistant/components/telegram_bot/bot.py b/homeassistant/components/telegram_bot/bot.py index 75962d1afb45a0..e1d38b15a51462 100644 --- a/homeassistant/components/telegram_bot/bot.py +++ b/homeassistant/components/telegram_bot/bot.py @@ -1083,7 +1083,20 @@ async def download_file( translation_key="invalid_directory_path", translation_placeholders={"directory_path": directory_path}, ) from err + + # A caller supplied destination has to sit inside + # allowlist_external_dirs, the boundary load_data already applies to + # reads. is_allowed_path resolves the path first, so a symlink inside + # an allowed directory that points out of it is rejected too. + if not await self.hass.async_add_executor_job( + self.hass.config.is_allowed_path, directory_path + ): + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="allowlist_external_dirs_error", + ) else: + # The integration's own directory needs no allowlist entry. directory_path = self.hass.config.path(DOMAIN) if file_name: diff --git a/homeassistant/components/telegram_bot/log_filter.py b/homeassistant/components/telegram_bot/log_filter.py new file mode 100644 index 00000000000000..f834ec4a2a743e --- /dev/null +++ b/homeassistant/components/telegram_bot/log_filter.py @@ -0,0 +1,78 @@ +"""Keep Telegram bot tokens out of the logs. + +The Telegram API embeds the bot token in the URL path, so the library, the HTTP +stack underneath it and any traceback quoting that URL all carry the token. None +of that passes through this integration's own log calls, so the only way to keep +it out of `home-assistant.log` is to scrub the records on their way out. +""" + +import logging +from typing import override + +REDACTED = "**REDACTED**" + + +class TokenRedactingFilter(logging.Filter): + """Replace known bot tokens in log records with a placeholder.""" + + def __init__(self) -> None: + """Initialize the filter with no tokens to redact.""" + super().__init__() + # Replaced rather than mutated, so the logging thread always reads a + # consistent snapshot. + self._tokens: frozenset[str] = frozenset() + + def add_token(self, token: str) -> None: + """Start redacting a token.""" + self._tokens |= {token} + + def remove_token(self, token: str) -> None: + """Stop redacting a token.""" + self._tokens -= {token} + + @override + def filter(self, record: logging.LogRecord) -> bool: + """Redact any known token in the message and the traceback.""" + if not (tokens := self._tokens): + return True + + message = record.getMessage() + if any(token in message for token in tokens): + for token in tokens: + message = message.replace(token, REDACTED) + record.msg = message + record.args = None + + if record.exc_info: + traceback = logging.Formatter().formatException(record.exc_info) + if any(token in traceback for token in tokens): + for token in tokens: + traceback = traceback.replace(token, REDACTED) + # Hand the handler finished text so it cannot re-expand the + # original exception. + record.exc_text = traceback + record.exc_info = None + + return True + + +_FILTER = TokenRedactingFilter() + + +def async_redact_token(token: str) -> None: + """Redact a bot token from every log record from now on.""" + # The filter goes on the root handlers, not on a logger: a logger's filters + # only see records logged through that logger, so a filter on "telegram" + # would miss "telegram.Bot", which is where the library logs the token. + # Handler filters see every record that reaches them. + root = logging.getLogger() + for handler in root.handlers: + if _FILTER not in handler.filters: + handler.addFilter(_FILTER) + + _FILTER.add_token(token) + + +def async_unredact_token(token: str) -> None: + """Stop redacting a bot token.""" + _FILTER.remove_token(token) diff --git a/homeassistant/components/telegram_bot/strings.json b/homeassistant/components/telegram_bot/strings.json index 7a9428cabe5e52..439ee965ad090b 100644 --- a/homeassistant/components/telegram_bot/strings.json +++ b/homeassistant/components/telegram_bot/strings.json @@ -186,6 +186,9 @@ "allowlist_external_dirs_error": { "message": "File path has not been configured in allowlist_external_dirs." }, + "cannot_connect": { + "message": "Could not connect to Telegram." + }, "entry_not_loaded": { "message": "{telegram_bot} is not loaded" }, diff --git a/homeassistant/components/teslemetry/manifest.json b/homeassistant/components/teslemetry/manifest.json index 77ec1881f49a83..8a7b708df15e98 100644 --- a/homeassistant/components/teslemetry/manifest.json +++ b/homeassistant/components/teslemetry/manifest.json @@ -10,8 +10,8 @@ "loggers": ["aiopowerwall", "tesla_fleet_api", "teslemetry_stream"], "quality_scale": "platinum", "requirements": [ - "aiopowerwall==0.3.2", + "aiopowerwall==0.4.0", "tesla-fleet-api==1.13.0", - "teslemetry-stream==0.13.2" + "teslemetry-stream==1.0.1" ] } diff --git a/homeassistant/components/valve/__init__.py b/homeassistant/components/valve/__init__.py index 1f619ad2efee2e..b4fa62620c2e82 100644 --- a/homeassistant/components/valve/__init__.py +++ b/homeassistant/components/valve/__init__.py @@ -3,8 +3,6 @@ from datetime import timedelta import logging -import probatio - from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( # noqa: F401 SERVICE_CLOSE_VALVE, @@ -21,9 +19,10 @@ from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_component import EntityComponent from homeassistant.helpers.typing import ConfigType -from homeassistant.util.hass_dict import HassKey from .const import ( # noqa: F401 + ATTR_POSITION, + DATA_COMPONENT, DEVICE_CLASSES_SCHEMA, DOMAIN, ValveDeviceClass, @@ -37,19 +36,16 @@ ValveEntity, ValveEntityDescription, ) +from .services import async_setup_services _LOGGER = logging.getLogger(__name__) -DATA_COMPONENT: HassKey[EntityComponent[ValveEntity]] = HassKey(DOMAIN) ENTITY_ID_FORMAT = DOMAIN + ".{}" PLATFORM_SCHEMA = cv.PLATFORM_SCHEMA PLATFORM_SCHEMA_BASE = cv.PLATFORM_SCHEMA_BASE SCAN_INTERVAL = timedelta(seconds=15) -ATTR_POSITION = "position" - - async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Track states and offer events for valves.""" component = hass.data[DATA_COMPONENT] = EntityComponent[ValveEntity]( @@ -58,38 +54,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: await component.async_setup(config) - component.async_register_entity_service( - SERVICE_OPEN_VALVE, None, "async_handle_open_valve", [ValveEntityFeature.OPEN] - ) - - component.async_register_entity_service( - SERVICE_CLOSE_VALVE, - None, - "async_handle_close_valve", - [ValveEntityFeature.CLOSE], - ) - - component.async_register_entity_service( - SERVICE_SET_VALVE_POSITION, - { - probatio.Required(ATTR_POSITION): probatio.All( - probatio.Coerce(int), probatio.Range(min=0, max=100) - ) - }, - "async_set_valve_position", - [ValveEntityFeature.SET_POSITION], - ) - - component.async_register_entity_service( - SERVICE_STOP_VALVE, None, "async_stop_valve", [ValveEntityFeature.STOP] - ) - - component.async_register_entity_service( - SERVICE_TOGGLE, - None, - "async_toggle", - [ValveEntityFeature.OPEN | ValveEntityFeature.CLOSE], - ) + async_setup_services(hass) return True diff --git a/homeassistant/components/valve/const.py b/homeassistant/components/valve/const.py index 35afe1ac571050..72d128ba767632 100644 --- a/homeassistant/components/valve/const.py +++ b/homeassistant/components/valve/const.py @@ -1,11 +1,21 @@ """Constants for the Valve entity platform.""" from enum import IntFlag, StrEnum -from typing import Final +from typing import TYPE_CHECKING, Final import probatio +from homeassistant.util.hass_dict import HassKey + +if TYPE_CHECKING: + from homeassistant.helpers.entity_component import EntityComponent + + from .entity import ValveEntity + DOMAIN: Final = "valve" +DATA_COMPONENT: HassKey[EntityComponent[ValveEntity]] = HassKey(DOMAIN) + +ATTR_POSITION = "position" class ValveEntityStateAttribute(StrEnum): diff --git a/homeassistant/components/valve/services.py b/homeassistant/components/valve/services.py new file mode 100644 index 00000000000000..1c566f3bc4f4bc --- /dev/null +++ b/homeassistant/components/valve/services.py @@ -0,0 +1,53 @@ +"""Services for the Valve integration.""" + +import probatio + +from homeassistant.const import ( + SERVICE_CLOSE_VALVE, + SERVICE_OPEN_VALVE, + SERVICE_SET_VALVE_POSITION, + SERVICE_STOP_VALVE, + SERVICE_TOGGLE, +) +from homeassistant.core import HomeAssistant, callback + +from .const import ATTR_POSITION, DATA_COMPONENT, ValveEntityFeature + + +@callback +def async_setup_services(hass: HomeAssistant) -> None: + """Register the valve services.""" + component = hass.data[DATA_COMPONENT] + + component.async_register_entity_service( + SERVICE_OPEN_VALVE, None, "async_handle_open_valve", [ValveEntityFeature.OPEN] + ) + + component.async_register_entity_service( + SERVICE_CLOSE_VALVE, + None, + "async_handle_close_valve", + [ValveEntityFeature.CLOSE], + ) + + component.async_register_entity_service( + SERVICE_SET_VALVE_POSITION, + { + probatio.Required(ATTR_POSITION): probatio.All( + probatio.Coerce(int), probatio.Range(min=0, max=100) + ) + }, + "async_set_valve_position", + [ValveEntityFeature.SET_POSITION], + ) + + component.async_register_entity_service( + SERVICE_STOP_VALVE, None, "async_stop_valve", [ValveEntityFeature.STOP] + ) + + component.async_register_entity_service( + SERVICE_TOGGLE, + None, + "async_toggle", + [ValveEntityFeature.OPEN | ValveEntityFeature.CLOSE], + ) diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index fc8bc986acd672..b09efe44e6b180 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -310,6 +310,7 @@ "gpsd", "gpslogger", "gree", + "gree_infrared", "green_planet_energy", "greencell", "growatt_server", diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index 0dd5dfea3416ec..b0bd1e42431d52 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -2787,10 +2787,21 @@ "iot_class": "local_push" }, "gree": { - "name": "Gree Climate", - "integration_type": "hub", - "config_flow": true, - "iot_class": "local_polling" + "name": "Gree", + "integrations": { + "gree": { + "integration_type": "hub", + "config_flow": true, + "iot_class": "local_polling", + "name": "Gree Climate" + }, + "gree_infrared": { + "integration_type": "device", + "config_flow": true, + "iot_class": "assumed_state", + "name": "Gree Infrared" + } + } }, "green_planet_energy": { "name": "Green Planet Energy", diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index fba2da70527978..22604f319e745a 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -60,7 +60,7 @@ pymicro-vad==1.0.1 PyNaCl==1.6.2 pyopenssl==26.4.0 pyspeex-noise==1.0.2 -python-slugify==8.0.4 +python-slugify==9.0.0 PyTurboJPEG==1.8.3 PyYAML==6.0.3 requests==2.34.2 diff --git a/mypy.ini b/mypy.ini index b25989bc0da805..489d9261dafe19 100644 --- a/mypy.ini +++ b/mypy.ini @@ -2328,6 +2328,16 @@ disallow_untyped_defs = true warn_return_any = true warn_unreachable = true +[mypy-homeassistant.components.gree_infrared.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + [mypy-homeassistant.components.greeneye_monitor.*] check_untyped_defs = true disallow_incomplete_defs = true diff --git a/pyproject.toml b/pyproject.toml index 83cc5aa78381d2..ba8d9bd5b63042 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -67,7 +67,7 @@ dependencies = [ "orjson==3.11.9", "packaging>=23.1", "psutil-home-assistant==0.0.1", - "python-slugify==8.0.4", + "python-slugify==9.0.0", "PyYAML==6.0.3", "requests==2.34.2", "securetar==2026.4.1", diff --git a/requirements.txt b/requirements.txt index 737f3bb4c5384d..cf5b37df4461b6 100644 --- a/requirements.txt +++ b/requirements.txt @@ -45,7 +45,7 @@ PyJWT==2.13.0 pymicro-vad==1.0.1 pyopenssl==26.4.0 pyspeex-noise==1.0.2 -python-slugify==8.0.4 +python-slugify==9.0.0 PyTurboJPEG==1.8.3 PyYAML==6.0.3 requests==2.34.2 diff --git a/requirements_all.txt b/requirements_all.txt index a73f3adccb790e..60d1d591565f2a 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -193,7 +193,7 @@ aioairzone-cloud==0.7.2 aioairzone==1.0.5 # homeassistant.components.alexa_devices -aioamazondevices==15.2.0 +aioamazondevices==16.3.0 # homeassistant.components.ambient_network # homeassistant.components.ambient_station @@ -393,7 +393,7 @@ aiopegelonline==0.1.1 aiopnsense==1.0.10 # homeassistant.components.teslemetry -aiopowerwall==0.3.2 +aiopowerwall==0.4.0 # homeassistant.components.ptdevices aioptdevices==2026.03.2 @@ -1092,7 +1092,7 @@ freebox-api==1.3.1 freesms==0.2.0 # homeassistant.components.fressnapf_tracker -fressnapftracker==0.2.2 +fressnapftracker==0.3.1 # homeassistant.components.fritz # homeassistant.components.fritzbox_callmonitor @@ -1532,7 +1532,7 @@ libpyvivotek==0.6.1 librehardwaremonitor-api==1.11.1 # homeassistant.components.mikrotik -librouteros==4.1.1 +librouteros==4.2.2 # homeassistant.components.soundtouch libsoundtouch==0.8 @@ -2174,7 +2174,7 @@ pycync==0.5.0 pydaikin==2.19.1 # homeassistant.components.danfoss_air -pydanfossair==0.1.0 +pydanfossair==1.0.0 # homeassistant.components.deako pydeako==0.6.0 @@ -2229,7 +2229,7 @@ pyegps==0.2.5 pyemoncms==0.1.3 # homeassistant.components.enphase_envoy -pyenphase==4.0.3 +pyenphase==4.0.5 # homeassistant.components.envertech_evt800 pyenvertechevt800==0.2.4 @@ -3040,7 +3040,7 @@ samsung-exlink==1.1.1 samsungctl[websocket]==0.7.1 # homeassistant.components.samsungtv -samsungtvws[async,encrypted]==3.0.5 +samsungtvws[async,encrypted]==3.0.6 # homeassistant.components.sanix sanix==1.0.6 @@ -3162,7 +3162,7 @@ sonos-websocket==0.2.0 speak2mary==1.4.0 # homeassistant.components.specialized_turbo -specialized-turbo[cloud]==0.8.3 +specialized-turbo[cloud]==0.8.4 # homeassistant.components.speedtestdotnet speedtest-cli==2.1.3 @@ -3260,7 +3260,7 @@ tesla-powerwall==0.5.3 tesla-wall-connector==1.2.0 # homeassistant.components.teslemetry -teslemetry-stream==0.13.2 +teslemetry-stream==1.0.1 # homeassistant.components.tessie tessie-api==0.1.3 diff --git a/tests/components/alexa_devices/snapshots/test_diagnostics.ambr b/tests/components/alexa_devices/snapshots/test_diagnostics.ambr index 06073892f9eab2..ee115d6e6075f6 100644 --- a/tests/components/alexa_devices/snapshots/test_diagnostics.ambr +++ b/tests/components/alexa_devices/snapshots/test_diagnostics.ambr @@ -20,6 +20,7 @@ 'error_type': None, 'name': 'dnd', 'scale': None, + 'time_of_sample': None, 'value': False, }), 'temperature': dict({ @@ -28,6 +29,7 @@ 'error_type': None, 'name': 'temperature', 'scale': 'CELSIUS', + 'time_of_sample': None, 'value': '22.5', }), }), @@ -59,6 +61,7 @@ 'error_type': None, 'name': 'dnd', 'scale': None, + 'time_of_sample': None, 'value': False, }), 'temperature': dict({ @@ -67,6 +70,7 @@ 'error_type': None, 'name': 'temperature', 'scale': 'CELSIUS', + 'time_of_sample': None, 'value': '22.5', }), }), diff --git a/tests/components/alexa_devices/snapshots/test_services.ambr b/tests/components/alexa_devices/snapshots/test_services.ambr index d0d2764495b87d..c677a1acf424aa 100644 --- a/tests/components/alexa_devices/snapshots/test_services.ambr +++ b/tests/components/alexa_devices/snapshots/test_services.ambr @@ -58,6 +58,7 @@ 'error_type': None, 'name': 'dnd', 'scale': None, + 'time_of_sample': None, 'value': False, }), 'temperature': dict({ @@ -66,6 +67,7 @@ 'error_type': None, 'name': 'temperature', 'scale': 'CELSIUS', + 'time_of_sample': None, 'value': '22.5', }), }), @@ -138,6 +140,7 @@ 'error_type': None, 'name': 'dnd', 'scale': None, + 'time_of_sample': None, 'value': False, }), 'temperature': dict({ @@ -146,6 +149,7 @@ 'error_type': None, 'name': 'temperature', 'scale': 'CELSIUS', + 'time_of_sample': None, 'value': '22.5', }), }), @@ -218,6 +222,7 @@ 'error_type': None, 'name': 'dnd', 'scale': None, + 'time_of_sample': None, 'value': False, }), 'temperature': dict({ @@ -226,6 +231,7 @@ 'error_type': None, 'name': 'temperature', 'scale': 'CELSIUS', + 'time_of_sample': None, 'value': '22.5', }), }), diff --git a/tests/components/collection_image/test_image.py b/tests/components/collection_image/test_image.py index 05a3a6206059c8..09bd6efb380afe 100644 --- a/tests/components/collection_image/test_image.py +++ b/tests/components/collection_image/test_image.py @@ -270,6 +270,8 @@ async def test_media_error( for err in error_messages: assert err in caplog.text + assert "No image files were found in the configured media" in caplog.text + client = await hass_client() resp = await client.get(f"/api/image_proxy/{DEFAULT_ENTITY_ID}") assert resp.status == HTTPStatus.INTERNAL_SERVER_ERROR @@ -301,7 +303,15 @@ async def test_unresolvable( await hass.async_block_till_done(wait_background_tasks=True) - assert "image.random_image: Mock image failed to resolve" in caplog.text + assert "Mock image failed to resolve" in caplog.text + + with pytest.raises(HomeAssistantError, match="failed to resolve"): + await hass.services.async_call( + DOMAIN, + "shuffle", + {ATTR_ENTITY_ID: DEFAULT_ENTITY_ID}, + blocking=True, + ) # Test we can recover by calling shuffle again when the image is resolvable del media_source_state.resolve_exceptions[MOCK_MEDIA_IMAGE_URI_1] @@ -316,8 +326,8 @@ async def test_unresolvable( blocking=True, ) - assert mock_media_source.image_browse.call_count == 2 - assert mock_media_source.resolve.call_count == 2 + assert mock_media_source.image_browse.call_count == 3 + assert mock_media_source.resolve.call_count == 3 state = hass.states.get(DEFAULT_ENTITY_ID) diff --git a/tests/components/collection_image/test_services.py b/tests/components/collection_image/test_services.py index 97ff1ffd54fb4b..94e9ce7efb4b14 100644 --- a/tests/components/collection_image/test_services.py +++ b/tests/components/collection_image/test_services.py @@ -2,6 +2,8 @@ from unittest.mock import AsyncMock, patch +import pytest + from homeassistant.components.collection_image.const import DOMAIN from homeassistant.components.collection_image.image import CollectionImageImageEntity from homeassistant.components.collection_image.services import ( @@ -11,6 +13,7 @@ from homeassistant.components.media_source import PlayMedia from homeassistant.const import ATTR_ENTITY_ID, STATE_UNAVAILABLE from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError from .conftest import MediaSourceMocks, MediaSourceState from .const import DEFAULT_ENTITY_ID, MOCK_MEDIA_DIR_URI_1 @@ -176,12 +179,13 @@ def assert_resolve_index(idx: int): # Now there are no images, go to unavailable media_source_state.browse_results = {MOCK_MEDIA_DIR_URI_1: directory("My pictures")} - await hass.services.async_call( - DOMAIN, - CollectionImageService.SELECT_NEXT, - data, - blocking=True, - ) + with pytest.raises(HomeAssistantError, match="No image files were found"): + await hass.services.async_call( + DOMAIN, + CollectionImageService.SELECT_NEXT, + data, + blocking=True, + ) state = hass.states.get(DEFAULT_ENTITY_ID) assert state and state.state == STATE_UNAVAILABLE @@ -214,11 +218,12 @@ async def test_first_unavailable( assert state and state.state != STATE_UNAVAILABLE media_source_state.browse_results = {MOCK_MEDIA_DIR_URI_1: directory("My pictures")} - await hass.services.async_call( - DOMAIN, - CollectionImageService.SELECT_FIRST, - {ATTR_ENTITY_ID: DEFAULT_ENTITY_ID}, - blocking=True, - ) + with pytest.raises(HomeAssistantError, match="No image files were found"): + await hass.services.async_call( + DOMAIN, + CollectionImageService.SELECT_FIRST, + {ATTR_ENTITY_ID: DEFAULT_ENTITY_ID}, + blocking=True, + ) state = hass.states.get(DEFAULT_ENTITY_ID) assert state and state.state == STATE_UNAVAILABLE diff --git a/tests/components/command_line/__init__.py b/tests/components/command_line/__init__.py index dc96523450639d..159f0739cadb6a 100644 --- a/tests/components/command_line/__init__.py +++ b/tests/components/command_line/__init__.py @@ -16,7 +16,7 @@ class MockProcess(asyncio.subprocess.Process): def returncode(self): return returncode - async def communicate(self): + async def communicate(self, input=None): if exception: raise exception return response, b"" diff --git a/tests/components/command_line/test_cover.py b/tests/components/command_line/test_cover.py index 5e92ab3b19da82..bc3f39bd977c79 100644 --- a/tests/components/command_line/test_cover.py +++ b/tests/components/command_line/test_cover.py @@ -80,6 +80,7 @@ async def test_poll_when_cover_has_command_state( await hass.async_block_till_done() mock_subprocess_run.assert_called_once_with( "echo state", + stdin=None, close_fds=False, stdout=-1, ) diff --git a/tests/components/command_line/test_notify.py b/tests/components/command_line/test_notify.py index 571e7bfda85b6e..f003b52c0061d0 100644 --- a/tests/components/command_line/test_notify.py +++ b/tests/components/command_line/test_notify.py @@ -295,7 +295,7 @@ async def test_spawn_error( with ( patch( - "homeassistant.components.command_line.notify.asyncio.create_subprocess_shell", + "homeassistant.components.command_line.utils.asyncio.create_subprocess_shell", side_effect=OSError("exec failed"), ), pytest.raises(HomeAssistantError) as exc_info, @@ -356,7 +356,7 @@ async def test_timeout_cleanup( with ( patch( - "homeassistant.components.command_line.notify.asyncio.create_subprocess_shell", + "homeassistant.components.command_line.utils.asyncio.create_subprocess_shell", return_value=mock_proc, ), pytest.raises(HomeAssistantError) as exc_info, @@ -397,7 +397,7 @@ async def test_cancelled_kills_process( with ( patch( - "homeassistant.components.command_line.notify.asyncio.create_subprocess_shell", + "homeassistant.components.command_line.utils.asyncio.create_subprocess_shell", return_value=mock_proc, ), pytest.raises(asyncio.CancelledError), diff --git a/tests/components/command_line/test_sensor.py b/tests/components/command_line/test_sensor.py index a582a9426880b7..8f1f160cb41e59 100644 --- a/tests/components/command_line/test_sensor.py +++ b/tests/components/command_line/test_sensor.py @@ -145,6 +145,7 @@ async def test_template_render_with_quote(hass: HomeAssistant) -> None: assert len(mock_subprocess_run.mock_calls) == 1 mock_subprocess_run.assert_called_with( 'echo "sensor_value" "3 4"', + stdin=None, stdout=-1, close_fds=False, ) diff --git a/tests/components/gree_infrared/__init__.py b/tests/components/gree_infrared/__init__.py new file mode 100644 index 00000000000000..84ab15c1c479a8 --- /dev/null +++ b/tests/components/gree_infrared/__init__.py @@ -0,0 +1 @@ +"""Tests for the Gree Infrared integration.""" diff --git a/tests/components/gree_infrared/conftest.py b/tests/components/gree_infrared/conftest.py new file mode 100644 index 00000000000000..8d63bb44d12788 --- /dev/null +++ b/tests/components/gree_infrared/conftest.py @@ -0,0 +1,92 @@ +"""Common fixtures for the Gree Infrared tests.""" + +from typing import Any +from unittest.mock import patch + +import pytest + +from homeassistant.components.climate import HVACMode +from homeassistant.components.gree_infrared import PLATFORMS +from homeassistant.components.gree_infrared.const import ( + CONF_HVAC_MODES, + CONF_INFRARED_EMITTER_ENTITY_ID, + CONF_INFRARED_RECEIVER_ENTITY_ID, + DOMAIN, +) +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry +from tests.components.infrared import ( + EMITTER_ENTITY_ID as MOCK_INFRARED_EMITTER_ENTITY_ID, + RECEIVER_ENTITY_ID as MOCK_INFRARED_RECEIVER_ENTITY_ID, +) +from tests.components.infrared.common import ( + MockInfraredEmitterEntity, + MockInfraredReceiverEntity, +) + +ENTRY_ID = "01JTEST0000000000000000001" + + +@pytest.fixture +def hvac_modes() -> list[HVACMode]: + """Return the HVAC modes configured on the config entry.""" + return [HVACMode.COOL, HVACMode.DRY] + + +@pytest.fixture +def has_receiver() -> bool: + """Return whether the config entry has an infrared receiver configured.""" + return True + + +@pytest.fixture +def extra_entry_data(hvac_modes: list[HVACMode]) -> dict[str, Any]: + """Return the config entry data beyond the emitter/receiver ids.""" + return {CONF_HVAC_MODES: hvac_modes} + + +@pytest.fixture +def mock_config_entry( + extra_entry_data: dict[str, Any], + has_receiver: bool, +) -> MockConfigEntry: + """Return a mock config entry for the Gree AC.""" + data: dict[str, Any] = { + CONF_INFRARED_EMITTER_ENTITY_ID: MOCK_INFRARED_EMITTER_ENTITY_ID, + **extra_entry_data, + } + if has_receiver: + data[CONF_INFRARED_RECEIVER_ENTITY_ID] = MOCK_INFRARED_RECEIVER_ENTITY_ID + + return MockConfigEntry( + domain=DOMAIN, + entry_id=ENTRY_ID, + title="Gree AC via Test IR emitter", + data=data, + ) + + +@pytest.fixture +def platforms() -> list[Platform]: + """Return platforms to set up.""" + return PLATFORMS + + +@pytest.fixture +async def init_integration( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_infrared_emitter_entity: MockInfraredEmitterEntity, + mock_infrared_receiver_entity: MockInfraredReceiverEntity, + platforms: list[Platform], +) -> MockConfigEntry: + """Set up the Gree Infrared integration for testing.""" + mock_config_entry.add_to_hass(hass) + + with patch("homeassistant.components.gree_infrared.PLATFORMS", platforms): + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + return mock_config_entry diff --git a/tests/components/gree_infrared/snapshots/test_climate.ambr b/tests/components/gree_infrared/snapshots/test_climate.ambr new file mode 100644 index 00000000000000..3449e9616aee9f --- /dev/null +++ b/tests/components/gree_infrared/snapshots/test_climate.ambr @@ -0,0 +1,85 @@ +# serializer version: 1 +# name: test_entities[climate.gree_ac-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : list([ + 'auto', + 'low', + 'medium', + 'high', + ]), + : list([ + , + , + , + ]), + : 30.0, + : 16.0, + : 1.0, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'climate', + 'entity_category': None, + 'entity_id': 'climate.gree_ac', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': None, + 'platform': 'gree_infrared', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': , + 'translation_key': None, + 'unique_id': '01JTEST0000000000000000001', + 'unit_of_measurement': None, + }) +# --- +# name: test_entities[climate.gree_ac-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : True, + : None, + : 'auto', + : list([ + 'auto', + 'low', + 'medium', + 'high', + ]), + : 'Gree AC', + : list([ + , + , + , + ]), + : 30.0, + : 16.0, + : , + : 1.0, + : 16.0, + }), + 'context': , + 'entity_id': 'climate.gree_ac', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- diff --git a/tests/components/gree_infrared/test_climate.py b/tests/components/gree_infrared/test_climate.py new file mode 100644 index 00000000000000..6a03ed7bd79bbb --- /dev/null +++ b/tests/components/gree_infrared/test_climate.py @@ -0,0 +1,978 @@ +"""Tests for the Gree Infrared climate platform.""" + +from typing import Any +from unittest.mock import patch + +from infrared_protocols.commands.gree_ac import ( + MIN_TEMP, + GreeAcCommand, + GreeAcFanSpeed, + GreeAcMode, +) +import pytest +from syrupy.assertion import SnapshotAssertion + +from homeassistant.components.climate import ( + DOMAIN as CLIMATE_DOMAIN, + FAN_AUTO, + FAN_HIGH, + FAN_LOW, + FAN_MEDIUM, + SERVICE_SET_FAN_MODE, + SERVICE_SET_HVAC_MODE, + SERVICE_SET_TEMPERATURE, + ClimateEntityFeature, + HVACMode, +) +from homeassistant.components.infrared import InfraredReceivedSignal +from homeassistant.const import ( + ATTR_ENTITY_ID, + ATTR_TEMPERATURE, + STATE_UNAVAILABLE, + Platform, +) +from homeassistant.core import HomeAssistant, State +from homeassistant.exceptions import HomeAssistantError, ServiceValidationError +from homeassistant.helpers import entity_registry as er +from homeassistant.util.unit_system import US_CUSTOMARY_SYSTEM + +from tests.common import ( + MockConfigEntry, + mock_restore_cache, + mock_restore_cache_with_extra_data, + snapshot_platform, +) +from tests.components.common import assert_availability_follows_source_entity +from tests.components.infrared import EMITTER_ENTITY_ID +from tests.components.infrared.common import ( + MockInfraredEmitterEntity, + MockInfraredReceiverEntity, +) + +_CLIMATE_ENTITY_ID = "climate.gree_ac" + + +@pytest.fixture +def platforms() -> list[Platform]: + """Return platforms to set up.""" + return [Platform.CLIMATE] + + +@pytest.fixture +def has_receiver() -> bool: + """Return whether the config entry has an infrared receiver configured.""" + return False + + +@pytest.mark.usefixtures("init_integration") +async def test_entities( + hass: HomeAssistant, + snapshot: SnapshotAssertion, + entity_registry: er.EntityRegistry, + mock_config_entry: MockConfigEntry, +) -> None: + """Test entity state and registry snapshot.""" + await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) + + +@pytest.mark.usefixtures("init_integration", "mock_infrared_emitter_entity") +async def test_availability_follows_emitter( + hass: HomeAssistant, +) -> None: + """Test climate entity availability follows the infrared emitter.""" + await assert_availability_follows_source_entity( + hass, _CLIMATE_ENTITY_ID, EMITTER_ENTITY_ID + ) + + +@pytest.mark.usefixtures("init_integration") +async def test_set_hvac_mode_off( + hass: HomeAssistant, + mock_infrared_emitter_entity: MockInfraredEmitterEntity, +) -> None: + """Test setting HVAC mode to off sends a power-off frame with the default mode. + + The protocol has no dedicated off mode, so the frame still carries a mode; before + any mode has been active, that is the first configured mode. + """ + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_HVAC_MODE, + {ATTR_ENTITY_ID: _CLIMATE_ENTITY_ID, "hvac_mode": HVACMode.OFF}, + blocking=True, + ) + + assert len(mock_infrared_emitter_entity.send_command_calls) == 1 + timings = mock_infrared_emitter_entity.send_command_calls[0].get_raw_timings() + assert ( + timings + == GreeAcCommand( + power=False, + mode=GreeAcMode.COOL, + temperature=MIN_TEMP, + fan=GreeAcFanSpeed.AUTO, + ).get_raw_timings() + ) + + +@pytest.mark.usefixtures("init_integration") +async def test_failed_send_does_not_become_the_last_active_mode( + hass: HomeAssistant, + mock_infrared_emitter_entity: MockInfraredEmitterEntity, +) -> None: + """Test a mode whose frame never went out is not carried by a later off frame. + + The unit only reaches a mode if its frame was actually transmitted, so a send that + raised must leave the remembered mode alone; otherwise the next off frame carries a + mode the unit was never put into. + """ + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_HVAC_MODE, + {ATTR_ENTITY_ID: _CLIMATE_ENTITY_ID, "hvac_mode": HVACMode.DRY}, + blocking=True, + ) + mock_infrared_emitter_entity.send_command_calls.clear() + + with ( + patch.object( + mock_infrared_emitter_entity, + "async_send_command", + side_effect=HomeAssistantError, + ), + pytest.raises(HomeAssistantError), + ): + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_HVAC_MODE, + {ATTR_ENTITY_ID: _CLIMATE_ENTITY_ID, "hvac_mode": HVACMode.COOL}, + blocking=True, + ) + + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_HVAC_MODE, + {ATTR_ENTITY_ID: _CLIMATE_ENTITY_ID, "hvac_mode": HVACMode.OFF}, + blocking=True, + ) + + assert len(mock_infrared_emitter_entity.send_command_calls) == 1 + timings = mock_infrared_emitter_entity.send_command_calls[0].get_raw_timings() + assert ( + timings + == GreeAcCommand( + power=False, + mode=GreeAcMode.DRY, + temperature=MIN_TEMP, + fan=GreeAcFanSpeed.AUTO, + ).get_raw_timings() + ) + + +@pytest.mark.usefixtures("init_integration") +async def test_set_hvac_mode_off_keeps_the_last_active_mode( + hass: HomeAssistant, + mock_infrared_emitter_entity: MockInfraredEmitterEntity, +) -> None: + """Test a power-off frame carries the mode that was last active. + + The mode field is part of every frame and the entity's own mode is off by then, so + the last active mode has to be tracked separately; dry here is deliberately not the + first configured mode, which is what an untracked implementation would fall back to. + """ + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_HVAC_MODE, + {ATTR_ENTITY_ID: _CLIMATE_ENTITY_ID, "hvac_mode": HVACMode.DRY}, + blocking=True, + ) + mock_infrared_emitter_entity.send_command_calls.clear() + + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_HVAC_MODE, + {ATTR_ENTITY_ID: _CLIMATE_ENTITY_ID, "hvac_mode": HVACMode.OFF}, + blocking=True, + ) + + assert len(mock_infrared_emitter_entity.send_command_calls) == 1 + timings = mock_infrared_emitter_entity.send_command_calls[0].get_raw_timings() + assert ( + timings + == GreeAcCommand( + power=False, + mode=GreeAcMode.DRY, + temperature=MIN_TEMP, + fan=GreeAcFanSpeed.AUTO, + ).get_raw_timings() + ) + + +@pytest.mark.usefixtures("init_integration") +@pytest.mark.parametrize( + ("hvac_mode", "temp", "fan", "expected_cmd"), + [ + pytest.param( + HVACMode.COOL, + 24, + FAN_AUTO, + GreeAcCommand( + mode=GreeAcMode.COOL, temperature=24, fan=GreeAcFanSpeed.AUTO + ), + id="cool_24_auto", + ), + pytest.param( + HVACMode.COOL, + 18, + FAN_LOW, + GreeAcCommand(mode=GreeAcMode.COOL, temperature=18, fan=GreeAcFanSpeed.LOW), + id="cool_18_low", + ), + pytest.param( + HVACMode.COOL, + 30, + FAN_HIGH, + GreeAcCommand( + mode=GreeAcMode.COOL, temperature=30, fan=GreeAcFanSpeed.HIGH + ), + id="cool_30_high", + ), + pytest.param( + HVACMode.DRY, + 24, + FAN_MEDIUM, + GreeAcCommand( + mode=GreeAcMode.DRY, temperature=24, fan=GreeAcFanSpeed.MEDIUM + ), + id="dry_24_medium", + ), + ], +) +async def test_set_hvac_mode_encodes_correctly( + hass: HomeAssistant, + mock_infrared_emitter_entity: MockInfraredEmitterEntity, + hvac_mode: HVACMode, + temp: int, + fan: str, + expected_cmd: GreeAcCommand, +) -> None: + """Test that set_hvac_mode sends correctly encoded timings.""" + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_TEMPERATURE, + {ATTR_ENTITY_ID: _CLIMATE_ENTITY_ID, ATTR_TEMPERATURE: temp}, + blocking=True, + ) + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_FAN_MODE, + {ATTR_ENTITY_ID: _CLIMATE_ENTITY_ID, "fan_mode": fan}, + blocking=True, + ) + mock_infrared_emitter_entity.send_command_calls.clear() + + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_HVAC_MODE, + {ATTR_ENTITY_ID: _CLIMATE_ENTITY_ID, "hvac_mode": hvac_mode}, + blocking=True, + ) + + assert len(mock_infrared_emitter_entity.send_command_calls) == 1 + timings = mock_infrared_emitter_entity.send_command_calls[0].get_raw_timings() + assert timings == expected_cmd.get_raw_timings() + + +@pytest.mark.parametrize( + "hvac_modes", + [[HVACMode.COOL, HVACMode.HEAT, HVACMode.DRY, HVACMode.FAN_ONLY, HVACMode.AUTO]], +) +@pytest.mark.usefixtures("init_integration") +@pytest.mark.parametrize( + ("hvac_mode", "expected_cmd"), + [ + pytest.param( + HVACMode.HEAT, + GreeAcCommand( + mode=GreeAcMode.HEAT, temperature=MIN_TEMP, fan=GreeAcFanSpeed.AUTO + ), + id="heat", + ), + pytest.param( + HVACMode.FAN_ONLY, + GreeAcCommand( + mode=GreeAcMode.FAN_ONLY, + temperature=MIN_TEMP, + fan=GreeAcFanSpeed.AUTO, + ), + id="fan_only", + ), + pytest.param( + HVACMode.AUTO, + GreeAcCommand( + mode=GreeAcMode.AUTO, temperature=MIN_TEMP, fan=GreeAcFanSpeed.AUTO + ), + id="auto", + ), + ], +) +async def test_set_hvac_mode_from_off_uses_defaults( + hass: HomeAssistant, + mock_infrared_emitter_entity: MockInfraredEmitterEntity, + hvac_mode: HVACMode, + expected_cmd: GreeAcCommand, +) -> None: + """Test modes not reachable via the cool/dry default encode from entity defaults.""" + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_HVAC_MODE, + {ATTR_ENTITY_ID: _CLIMATE_ENTITY_ID, "hvac_mode": hvac_mode}, + blocking=True, + ) + + assert len(mock_infrared_emitter_entity.send_command_calls) == 1 + timings = mock_infrared_emitter_entity.send_command_calls[0].get_raw_timings() + assert timings == expected_cmd.get_raw_timings() + + +@pytest.mark.usefixtures("init_integration") +async def test_set_temperature_sends_command_when_active( + hass: HomeAssistant, + mock_infrared_emitter_entity: MockInfraredEmitterEntity, +) -> None: + """Test set_temperature sends IR when AC is on.""" + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_HVAC_MODE, + {ATTR_ENTITY_ID: _CLIMATE_ENTITY_ID, "hvac_mode": HVACMode.COOL}, + blocking=True, + ) + mock_infrared_emitter_entity.send_command_calls.clear() + + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_TEMPERATURE, + {ATTR_ENTITY_ID: _CLIMATE_ENTITY_ID, ATTR_TEMPERATURE: 26}, + blocking=True, + ) + + assert len(mock_infrared_emitter_entity.send_command_calls) == 1 + timings = mock_infrared_emitter_entity.send_command_calls[0].get_raw_timings() + assert ( + timings + == GreeAcCommand( + mode=GreeAcMode.COOL, temperature=26, fan=GreeAcFanSpeed.AUTO + ).get_raw_timings() + ) + + state = hass.states.get(_CLIMATE_ENTITY_ID) + assert state is not None + assert float(state.attributes["temperature"]) == 26.0 + + +@pytest.mark.parametrize("hvac_modes", [[HVACMode.DRY]]) +@pytest.mark.usefixtures("init_integration") +async def test_set_temperature_sends_command_in_dry_mode( + hass: HomeAssistant, + mock_infrared_emitter_entity: MockInfraredEmitterEntity, +) -> None: + """Test dry mode sends IR on temperature change. + + The temperature field is present in every mode's frame, so a temperature change + is always transmittable. + """ + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_HVAC_MODE, + {ATTR_ENTITY_ID: _CLIMATE_ENTITY_ID, "hvac_mode": HVACMode.DRY}, + blocking=True, + ) + mock_infrared_emitter_entity.send_command_calls.clear() + + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_TEMPERATURE, + {ATTR_ENTITY_ID: _CLIMATE_ENTITY_ID, ATTR_TEMPERATURE: 25}, + blocking=True, + ) + + assert len(mock_infrared_emitter_entity.send_command_calls) == 1 + timings = mock_infrared_emitter_entity.send_command_calls[0].get_raw_timings() + assert ( + timings + == GreeAcCommand( + mode=GreeAcMode.DRY, temperature=25, fan=GreeAcFanSpeed.AUTO + ).get_raw_timings() + ) + + +@pytest.mark.usefixtures("init_integration") +async def test_set_temperature_no_command_when_off( + hass: HomeAssistant, + mock_infrared_emitter_entity: MockInfraredEmitterEntity, +) -> None: + """Test set_temperature updates state but sends no IR when AC is off.""" + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_TEMPERATURE, + {ATTR_ENTITY_ID: _CLIMATE_ENTITY_ID, ATTR_TEMPERATURE: 22}, + blocking=True, + ) + + assert len(mock_infrared_emitter_entity.send_command_calls) == 0 + + state = hass.states.get(_CLIMATE_ENTITY_ID) + assert state is not None + assert float(state.attributes["temperature"]) == 22.0 + + +@pytest.mark.usefixtures("init_integration") +async def test_set_fan_mode_sends_command_when_active( + hass: HomeAssistant, + mock_infrared_emitter_entity: MockInfraredEmitterEntity, +) -> None: + """Test set_fan_mode sends IR when AC is on.""" + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_HVAC_MODE, + {ATTR_ENTITY_ID: _CLIMATE_ENTITY_ID, "hvac_mode": HVACMode.COOL}, + blocking=True, + ) + mock_infrared_emitter_entity.send_command_calls.clear() + + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_FAN_MODE, + {ATTR_ENTITY_ID: _CLIMATE_ENTITY_ID, "fan_mode": FAN_HIGH}, + blocking=True, + ) + + assert len(mock_infrared_emitter_entity.send_command_calls) == 1 + timings = mock_infrared_emitter_entity.send_command_calls[0].get_raw_timings() + assert ( + timings + == GreeAcCommand( + mode=GreeAcMode.COOL, temperature=MIN_TEMP, fan=GreeAcFanSpeed.HIGH + ).get_raw_timings() + ) + + +@pytest.mark.usefixtures("init_integration") +async def test_set_fan_mode_no_command_when_off( + hass: HomeAssistant, + mock_infrared_emitter_entity: MockInfraredEmitterEntity, +) -> None: + """Test set_fan_mode updates state but sends no IR when AC is off.""" + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_FAN_MODE, + {ATTR_ENTITY_ID: _CLIMATE_ENTITY_ID, "fan_mode": FAN_HIGH}, + blocking=True, + ) + + assert len(mock_infrared_emitter_entity.send_command_calls) == 0 + + state = hass.states.get(_CLIMATE_ENTITY_ID) + assert state is not None + assert state.attributes["fan_mode"] == FAN_HIGH + + +@pytest.mark.parametrize("has_receiver", [True]) +@pytest.mark.usefixtures("init_integration") +@pytest.mark.parametrize( + ("lib_fan", "expected_fan_mode"), + [ + pytest.param(GreeAcFanSpeed.AUTO, FAN_AUTO, id="auto"), + pytest.param(GreeAcFanSpeed.LOW, FAN_LOW, id="low"), + pytest.param(GreeAcFanSpeed.MEDIUM, FAN_MEDIUM, id="medium"), + pytest.param(GreeAcFanSpeed.HIGH, FAN_HIGH, id="high"), + ], +) +async def test_receiver_updates_state_on_cool_signal( + hass: HomeAssistant, + mock_infrared_receiver_entity: MockInfraredReceiverEntity, + lib_fan: GreeAcFanSpeed, + expected_fan_mode: str, +) -> None: + """Test that a received cool signal updates mode, temperature and every fan speed.""" + timings = GreeAcCommand( + mode=GreeAcMode.COOL, temperature=24, fan=lib_fan + ).get_raw_timings() + + signal = InfraredReceivedSignal(timings=timings) + mock_infrared_receiver_entity._handle_received_signal(signal) + await hass.async_block_till_done() + + state = hass.states.get(_CLIMATE_ENTITY_ID) + assert state is not None + assert state.state == HVACMode.COOL + assert state.attributes["fan_mode"] == expected_fan_mode + assert float(state.attributes["temperature"]) == 24.0 + + +@pytest.mark.parametrize("has_receiver", [True]) +@pytest.mark.usefixtures("init_integration") +async def test_receiver_updates_state_on_off_signal( + hass: HomeAssistant, + mock_infrared_receiver_entity: MockInfraredReceiverEntity, +) -> None: + """Test a received off signal sets mode to off, preserving temperature and fan.""" + mock_infrared_receiver_entity._handle_received_signal( + InfraredReceivedSignal( + timings=GreeAcCommand( + mode=GreeAcMode.COOL, temperature=24, fan=GreeAcFanSpeed.MEDIUM + ).get_raw_timings() + ) + ) + await hass.async_block_till_done() + + mock_infrared_receiver_entity._handle_received_signal( + InfraredReceivedSignal( + timings=GreeAcCommand( + power=False, + mode=GreeAcMode.COOL, + temperature=24, + fan=GreeAcFanSpeed.MEDIUM, + ).get_raw_timings() + ) + ) + await hass.async_block_till_done() + + state = hass.states.get(_CLIMATE_ENTITY_ID) + assert state is not None + assert state.state == HVACMode.OFF + assert state.attributes["fan_mode"] == FAN_MEDIUM + assert float(state.attributes["temperature"]) == 24.0 + + +@pytest.mark.parametrize("has_receiver", [True]) +@pytest.mark.usefixtures("init_integration") +async def test_set_hvac_mode_off_keeps_the_mode_seen_by_the_receiver( + hass: HomeAssistant, + mock_infrared_emitter_entity: MockInfraredEmitterEntity, + mock_infrared_receiver_entity: MockInfraredReceiverEntity, +) -> None: + """Test a power-off frame carries the last mode the physical remote selected.""" + mock_infrared_receiver_entity._handle_received_signal( + InfraredReceivedSignal( + timings=GreeAcCommand( + mode=GreeAcMode.DRY, temperature=24, fan=GreeAcFanSpeed.MEDIUM + ).get_raw_timings() + ) + ) + await hass.async_block_till_done() + + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_HVAC_MODE, + {ATTR_ENTITY_ID: _CLIMATE_ENTITY_ID, "hvac_mode": HVACMode.OFF}, + blocking=True, + ) + + assert len(mock_infrared_emitter_entity.send_command_calls) == 1 + timings = mock_infrared_emitter_entity.send_command_calls[0].get_raw_timings() + assert ( + timings + == GreeAcCommand( + power=False, + mode=GreeAcMode.DRY, + temperature=24, + fan=GreeAcFanSpeed.MEDIUM, + ).get_raw_timings() + ) + + +@pytest.mark.parametrize("has_receiver", [True]) +@pytest.mark.usefixtures("init_integration") +async def test_receiver_off_signal_records_the_mode_it_carries( + hass: HomeAssistant, + mock_infrared_emitter_entity: MockInfraredEmitterEntity, + mock_infrared_receiver_entity: MockInfraredReceiverEntity, +) -> None: + """Test an off frame seen before any on frame still records the mode it carries.""" + mock_infrared_receiver_entity._handle_received_signal( + InfraredReceivedSignal( + timings=GreeAcCommand( + power=False, + mode=GreeAcMode.DRY, + temperature=24, + fan=GreeAcFanSpeed.MEDIUM, + ).get_raw_timings() + ) + ) + await hass.async_block_till_done() + + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_HVAC_MODE, + {ATTR_ENTITY_ID: _CLIMATE_ENTITY_ID, "hvac_mode": HVACMode.OFF}, + blocking=True, + ) + + assert len(mock_infrared_emitter_entity.send_command_calls) == 1 + timings = mock_infrared_emitter_entity.send_command_calls[0].get_raw_timings() + assert ( + timings + == GreeAcCommand( + power=False, + mode=GreeAcMode.DRY, + temperature=24, + fan=GreeAcFanSpeed.MEDIUM, + ).get_raw_timings() + ) + + +@pytest.mark.usefixtures("mock_infrared_emitter_entity") +async def test_last_active_mode_restored_on_restart_while_off( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_infrared_emitter_entity: MockInfraredEmitterEntity, + platforms: list[Platform], +) -> None: + """Test an off frame after a restart carries the mode the unit was last in. + + The visible state only records off, so without the extra restore data the off + frame would fall back to the first configured mode instead of the real one. + """ + mock_restore_cache_with_extra_data( + hass, + [ + ( + State(_CLIMATE_ENTITY_ID, HVACMode.OFF, {ATTR_TEMPERATURE: 24.0}), + {"last_active_hvac_mode": HVACMode.DRY.value}, + ) + ], + ) + mock_config_entry.add_to_hass(hass) + + with patch("homeassistant.components.gree_infrared.PLATFORMS", platforms): + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_HVAC_MODE, + {ATTR_ENTITY_ID: _CLIMATE_ENTITY_ID, "hvac_mode": HVACMode.OFF}, + blocking=True, + ) + + assert len(mock_infrared_emitter_entity.send_command_calls) == 1 + timings = mock_infrared_emitter_entity.send_command_calls[0].get_raw_timings() + assert ( + timings + == GreeAcCommand( + power=False, + mode=GreeAcMode.DRY, + temperature=24, + fan=GreeAcFanSpeed.AUTO, + ).get_raw_timings() + ) + + +@pytest.mark.usefixtures("mock_infrared_emitter_entity") +async def test_last_active_mode_restored_from_unavailable_state( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_infrared_emitter_entity: MockInfraredEmitterEntity, + platforms: list[Platform], +) -> None: + """Test the last active mode survives a restart from an unavailable state. + + The visible state carries nothing usable once it is unavailable, so the extra + restore data has to be read regardless of what the visible state says. + """ + mock_restore_cache_with_extra_data( + hass, + [ + ( + State(_CLIMATE_ENTITY_ID, STATE_UNAVAILABLE), + {"last_active_hvac_mode": HVACMode.DRY.value}, + ) + ], + ) + mock_config_entry.add_to_hass(hass) + + with patch("homeassistant.components.gree_infrared.PLATFORMS", platforms): + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_HVAC_MODE, + {ATTR_ENTITY_ID: _CLIMATE_ENTITY_ID, "hvac_mode": HVACMode.OFF}, + blocking=True, + ) + + assert len(mock_infrared_emitter_entity.send_command_calls) == 1 + timings = mock_infrared_emitter_entity.send_command_calls[0].get_raw_timings() + assert ( + timings + == GreeAcCommand( + power=False, + mode=GreeAcMode.DRY, + temperature=MIN_TEMP, + fan=GreeAcFanSpeed.AUTO, + ).get_raw_timings() + ) + + +@pytest.mark.parametrize("has_receiver", [True]) +@pytest.mark.usefixtures("init_integration") +async def test_receiver_ignores_unconfigured_hvac_mode( + hass: HomeAssistant, + mock_infrared_receiver_entity: MockInfraredReceiverEntity, +) -> None: + """Test a signal for a mode the user did not configure does not change state.""" + mock_infrared_receiver_entity._handle_received_signal( + InfraredReceivedSignal( + timings=GreeAcCommand( + mode=GreeAcMode.HEAT, temperature=24, fan=GreeAcFanSpeed.HIGH + ).get_raw_timings() + ) + ) + await hass.async_block_till_done() + + state = hass.states.get(_CLIMATE_ENTITY_ID) + assert state is not None + assert state.state == HVACMode.OFF + assert state.attributes["fan_mode"] == FAN_AUTO + + +@pytest.mark.parametrize("has_receiver", [True]) +@pytest.mark.usefixtures("init_integration") +async def test_receiver_ignores_non_gree_ac_signal( + hass: HomeAssistant, + mock_infrared_receiver_entity: MockInfraredReceiverEntity, +) -> None: + """Test that an unrecognised IR signal does not change state.""" + mock_infrared_receiver_entity._handle_received_signal( + InfraredReceivedSignal(timings=[500, -500, 300, -300]) + ) + await hass.async_block_till_done() + + state = hass.states.get(_CLIMATE_ENTITY_ID) + assert state is not None + assert state.state == HVACMode.OFF + + +@pytest.mark.usefixtures("init_integration") +async def test_supported_features_always_include_target_temperature( + hass: HomeAssistant, +) -> None: + """Test target temperature is always offered, since every mode's frame carries it.""" + state = hass.states.get(_CLIMATE_ENTITY_ID) + assert state is not None + assert state.attributes["supported_features"] == ( + ClimateEntityFeature.TARGET_TEMPERATURE | ClimateEntityFeature.FAN_MODE + ) + + +@pytest.mark.parametrize( + ("restored_state", "restored_attributes", "expected"), + [ + pytest.param( + HVACMode.COOL, + {"fan_mode": FAN_HIGH, "temperature": 29.0}, + (HVACMode.COOL, FAN_HIGH, 29.0), + id="full_state", + ), + pytest.param( + STATE_UNAVAILABLE, + {}, + (HVACMode.OFF, FAN_AUTO, float(MIN_TEMP)), + id="unavailable_falls_back_to_defaults", + ), + pytest.param( + HVACMode.HEAT, + {"fan_mode": FAN_HIGH, "temperature": 29.0}, + (HVACMode.OFF, FAN_HIGH, 29.0), + id="mode_no_longer_configured_is_ignored", + ), + ], +) +@pytest.mark.usefixtures("mock_infrared_emitter_entity") +async def test_state_restored_on_restart( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + platforms: list[Platform], + restored_state: str, + restored_attributes: dict[str, Any], + expected: tuple[HVACMode, str, float], +) -> None: + """Test the assumed state is restored, since infrared cannot read it back.""" + mock_restore_cache( + hass, [State(_CLIMATE_ENTITY_ID, restored_state, restored_attributes)] + ) + mock_config_entry.add_to_hass(hass) + + with patch("homeassistant.components.gree_infrared.PLATFORMS", platforms): + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + state = hass.states.get(_CLIMATE_ENTITY_ID) + assert state is not None + expected_mode, expected_fan, expected_temp = expected + assert state.state == expected_mode + assert state.attributes["fan_mode"] == expected_fan + assert state.attributes["temperature"] == expected_temp + + +@pytest.mark.usefixtures("init_integration") +@pytest.mark.parametrize( + ("hvac_mode", "expected_cmd"), + [ + pytest.param( + HVACMode.COOL, + GreeAcCommand( + mode=GreeAcMode.COOL, temperature=24, fan=GreeAcFanSpeed.AUTO + ), + id="cool", + ), + pytest.param( + HVACMode.OFF, + GreeAcCommand( + power=False, + mode=GreeAcMode.COOL, + temperature=24, + fan=GreeAcFanSpeed.AUTO, + ), + id="off", + ), + ], +) +async def test_set_temperature_with_hvac_mode( + hass: HomeAssistant, + mock_infrared_emitter_entity: MockInfraredEmitterEntity, + hvac_mode: HVACMode, + expected_cmd: GreeAcCommand, +) -> None: + """Test set_temperature applies a given mode, off included, while off.""" + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_TEMPERATURE, + { + ATTR_ENTITY_ID: _CLIMATE_ENTITY_ID, + ATTR_TEMPERATURE: 24, + "hvac_mode": hvac_mode, + }, + blocking=True, + ) + + assert len(mock_infrared_emitter_entity.send_command_calls) == 1 + timings = mock_infrared_emitter_entity.send_command_calls[0].get_raw_timings() + assert timings == expected_cmd.get_raw_timings() + + state = hass.states.get(_CLIMATE_ENTITY_ID) + assert state is not None + assert state.state == hvac_mode + assert state.attributes["temperature"] == 24.0 + + +async def test_fahrenheit_temperatures_round_trip( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_infrared_emitter_entity: MockInfraredEmitterEntity, + platforms: list[Platform], +) -> None: + """Test temperatures convert to Celsius on a Fahrenheit installation.""" + hass.config.units = US_CUSTOMARY_SYSTEM + mock_restore_cache( + hass, + [ + State( + _CLIMATE_ENTITY_ID, + HVACMode.COOL, + {"fan_mode": FAN_AUTO, "temperature": 75}, + ) + ], + ) + mock_config_entry.add_to_hass(hass) + + with patch("homeassistant.components.gree_infrared.PLATFORMS", platforms): + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + state = hass.states.get(_CLIMATE_ENTITY_ID) + assert state is not None + assert state.attributes["temperature"] == 75 + + mock_infrared_emitter_entity.send_command_calls.clear() + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_TEMPERATURE, + {ATTR_ENTITY_ID: _CLIMATE_ENTITY_ID, ATTR_TEMPERATURE: 75}, + blocking=True, + ) + + assert len(mock_infrared_emitter_entity.send_command_calls) == 1 + timings = mock_infrared_emitter_entity.send_command_calls[0].get_raw_timings() + assert ( + timings + == GreeAcCommand( + mode=GreeAcMode.COOL, temperature=24, fan=GreeAcFanSpeed.AUTO + ).get_raw_timings() + ) + + +@pytest.mark.usefixtures("init_integration") +async def test_set_temperature_with_hvac_mode_off_while_active( + hass: HomeAssistant, + mock_infrared_emitter_entity: MockInfraredEmitterEntity, +) -> None: + """Test requesting off alongside a temperature turns an active AC off.""" + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_HVAC_MODE, + {ATTR_ENTITY_ID: _CLIMATE_ENTITY_ID, "hvac_mode": HVACMode.COOL}, + blocking=True, + ) + mock_infrared_emitter_entity.send_command_calls.clear() + + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_TEMPERATURE, + { + ATTR_ENTITY_ID: _CLIMATE_ENTITY_ID, + ATTR_TEMPERATURE: 26, + "hvac_mode": HVACMode.OFF, + }, + blocking=True, + ) + + assert len(mock_infrared_emitter_entity.send_command_calls) == 1 + timings = mock_infrared_emitter_entity.send_command_calls[0].get_raw_timings() + assert ( + timings + == GreeAcCommand( + power=False, + mode=GreeAcMode.COOL, + temperature=26, + fan=GreeAcFanSpeed.AUTO, + ).get_raw_timings() + ) + + state = hass.states.get(_CLIMATE_ENTITY_ID) + assert state is not None + assert state.state == HVACMode.OFF + assert state.attributes["temperature"] == 26.0 + + +@pytest.mark.usefixtures("init_integration") +async def test_set_temperature_with_unsupported_hvac_mode( + hass: HomeAssistant, + mock_infrared_emitter_entity: MockInfraredEmitterEntity, +) -> None: + """Test a mode the unit does not support is rejected instead of sent.""" + with pytest.raises(ServiceValidationError): + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_TEMPERATURE, + { + ATTR_ENTITY_ID: _CLIMATE_ENTITY_ID, + ATTR_TEMPERATURE: 24, + "hvac_mode": HVACMode.HEAT, + }, + blocking=True, + ) + + assert len(mock_infrared_emitter_entity.send_command_calls) == 0 diff --git a/tests/components/gree_infrared/test_config_flow.py b/tests/components/gree_infrared/test_config_flow.py new file mode 100644 index 00000000000000..9466329d333313 --- /dev/null +++ b/tests/components/gree_infrared/test_config_flow.py @@ -0,0 +1,202 @@ +"""Tests for the Gree Infrared config flow.""" + +import pytest + +from homeassistant.components.climate import HVACMode +from homeassistant.components.gree_infrared.const import ( + CONF_HVAC_MODES, + CONF_INFRARED_EMITTER_ENTITY_ID, + CONF_INFRARED_RECEIVER_ENTITY_ID, + DOMAIN, +) +from homeassistant.components.infrared import DATA_COMPONENT +from homeassistant.config_entries import SOURCE_USER +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType, InvalidData +from homeassistant.helpers import entity_registry as er + +from tests.common import MockConfigEntry +from tests.components.infrared import ( + EMITTER_ENTITY_ID as mock_infrared_emitter_entity_id, + RECEIVER_ENTITY_ID as mock_infrared_receiver_entity_id, +) +from tests.components.infrared.common import MockInfraredEmitterEntity + + +@pytest.mark.usefixtures("mock_infrared_emitter_entity") +async def test_user_flow_success(hass: HomeAssistant) -> None: + """Test successful config flow with default modes (cool + dry).""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ + CONF_INFRARED_EMITTER_ENTITY_ID: mock_infrared_emitter_entity_id, + CONF_HVAC_MODES: [HVACMode.COOL, HVACMode.DRY], + }, + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == "Gree AC via Test IR emitter" + assert result["data"] == { + CONF_INFRARED_EMITTER_ENTITY_ID: mock_infrared_emitter_entity_id, + CONF_HVAC_MODES: [HVACMode.COOL, HVACMode.DRY], + } + assert result["result"].unique_id is None + + +@pytest.mark.usefixtures( + "mock_infrared_emitter_entity", "mock_infrared_receiver_entity" +) +async def test_user_flow_with_heat_and_receiver(hass: HomeAssistant) -> None: + """Test config flow with heat mode and optional receiver.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ + CONF_INFRARED_EMITTER_ENTITY_ID: mock_infrared_emitter_entity_id, + CONF_INFRARED_RECEIVER_ENTITY_ID: mock_infrared_receiver_entity_id, + CONF_HVAC_MODES: [HVACMode.COOL, HVACMode.HEAT, HVACMode.DRY], + }, + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["data"][CONF_HVAC_MODES] == [ + HVACMode.COOL, + HVACMode.HEAT, + HVACMode.DRY, + ] + assert result["data"][CONF_INFRARED_RECEIVER_ENTITY_ID] == ( + mock_infrared_receiver_entity_id + ) + + +@pytest.mark.usefixtures("mock_infrared_emitter_entity") +async def test_user_flow_requires_hvac_mode(hass: HomeAssistant) -> None: + """Test the flow rejects an empty list of supported modes.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + + with pytest.raises(InvalidData) as err: + await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ + CONF_INFRARED_EMITTER_ENTITY_ID: mock_infrared_emitter_entity_id, + CONF_HVAC_MODES: [], + }, + ) + + assert err.value.schema_errors == {CONF_HVAC_MODES: "no_hvac_modes"} + + +@pytest.mark.usefixtures("mock_infrared_emitter_entity") +async def test_user_flow_already_configured( + hass: HomeAssistant, mock_config_entry: MockConfigEntry +) -> None: + """Test the flow aborts when the emitter is already configured.""" + 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"], + user_input={ + CONF_INFRARED_EMITTER_ENTITY_ID: mock_infrared_emitter_entity_id, + CONF_HVAC_MODES: [HVACMode.COOL, HVACMode.DRY], + }, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" + + +@pytest.mark.usefixtures( + "mock_infrared_emitter_entity", "mock_infrared_receiver_entity" +) +async def test_user_flow_receiver_already_configured( + hass: HomeAssistant, mock_config_entry: MockConfigEntry +) -> None: + """Test the flow aborts when the receiver is already configured.""" + mock_config_entry.add_to_hass(hass) + await hass.data[DATA_COMPONENT].async_add_entities( + [MockInfraredEmitterEntity("second_ir_emitter", "Second IR emitter")] + ) + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ + CONF_INFRARED_EMITTER_ENTITY_ID: "infrared.second_ir_emitter", + CONF_INFRARED_RECEIVER_ENTITY_ID: mock_infrared_receiver_entity_id, + CONF_HVAC_MODES: [HVACMode.COOL, HVACMode.DRY], + }, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" + + +@pytest.mark.usefixtures("init_infrared") +async def test_user_flow_no_emitters(hass: HomeAssistant) -> None: + """Test the flow aborts when no infrared emitters exist.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "no_infrared_emitters" + + +@pytest.mark.usefixtures("mock_infrared_receiver_entity") +async def test_user_flow_no_emitters_receiver_only(hass: HomeAssistant) -> None: + """Test the flow aborts when only a receiver is available, since AC needs an emitter.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "no_infrared_emitters" + + +@pytest.mark.usefixtures("mock_infrared_emitter_entity") +@pytest.mark.parametrize( + ("entity_name", "expected_title"), + [ + pytest.param(None, "Gree AC via Test IR emitter", id="original_name"), + pytest.param("AC IR emitter", "Gree AC via AC IR emitter", id="custom_name"), + ], +) +async def test_user_flow_title_from_entity_name( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + entity_name: str | None, + expected_title: str, +) -> None: + """Test config entry title uses the entity name.""" + entity_registry.async_update_entity( + mock_infrared_emitter_entity_id, name=entity_name + ) + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ + CONF_INFRARED_EMITTER_ENTITY_ID: mock_infrared_emitter_entity_id, + CONF_HVAC_MODES: [HVACMode.COOL, HVACMode.DRY], + }, + ) + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == expected_title diff --git a/tests/components/gree_infrared/test_init.py b/tests/components/gree_infrared/test_init.py new file mode 100644 index 00000000000000..34fa62d547d268 --- /dev/null +++ b/tests/components/gree_infrared/test_init.py @@ -0,0 +1,19 @@ +"""Tests for the Gree Infrared integration setup.""" + +from homeassistant.config_entries import ConfigEntryState +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry + + +async def test_setup_and_unload_entry( + hass: HomeAssistant, init_integration: MockConfigEntry +) -> None: + """Test setting up and unloading a config entry.""" + entry = init_integration + assert entry.state is ConfigEntryState.LOADED + + await hass.config_entries.async_unload(entry.entry_id) + await hass.async_block_till_done() + + assert entry.state is ConfigEntryState.NOT_LOADED diff --git a/tests/components/heos/test_config_flow.py b/tests/components/heos/test_config_flow.py index 9be5862d3f74ab..c9cad81fe4d871 100644 --- a/tests/components/heos/test_config_flow.py +++ b/tests/components/heos/test_config_flow.py @@ -62,7 +62,11 @@ async def test_cannot_connect_shows_error_form( """Test form is shown with error when cannot connect.""" controller.connect.side_effect = HeosError() result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": SOURCE_USER}, data={CONF_HOST: "127.0.0.1"} + DOMAIN, context={"source": SOURCE_USER} + ) + assert result["type"] is FlowResultType.FORM + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={CONF_HOST: "127.0.0.1"} ) assert result["type"] is FlowResultType.FORM assert result["step_id"] == "user" @@ -80,7 +84,11 @@ async def test_create_entry_when_host_valid( data = {CONF_HOST: "127.0.0.1"} result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": SOURCE_USER}, data=data + DOMAIN, context={"source": SOURCE_USER} + ) + assert result["type"] is FlowResultType.FORM + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input=data ) assert result["type"] is FlowResultType.CREATE_ENTRY assert result["result"].unique_id == DOMAIN diff --git a/tests/components/heos/test_media_player.py b/tests/components/heos/test_media_player.py index 2cf8492646cbd2..50e411dc59b8e8 100644 --- a/tests/components/heos/test_media_player.py +++ b/tests/components/heos/test_media_player.py @@ -1,5 +1,6 @@ """Tests for the Heos Media Player platform.""" +from collections.abc import Callable from datetime import timedelta import re from typing import Any @@ -10,6 +11,7 @@ BrowseResult, CommandFailedError, HeosError, + HeosPlayer, MediaItem, MediaMusicSource, MediaType as HeosMediaType, @@ -1566,7 +1568,7 @@ async def test_browse_media_invalid_content_id( [ (["media_player.test_player_2"], [1, 2]), (["media_player.test_player_2", "media_player.test_player"], [1, 2]), - (["media_player.test_player"], [1]), + (["media_player.test_player"], [1, 2]), ], ) async def test_media_player_join_group( @@ -1591,6 +1593,30 @@ async def test_media_player_join_group( controller.set_group.assert_called_once_with(expected) +async def test_media_player_join_group_keeps_existing_members( + hass: HomeAssistant, + config_entry: MockConfigEntry, + controller: MockHeos, + players: dict[int, HeosPlayer], + player_factory: Callable[[int, str, str], HeosPlayer], +) -> None: + """Test joining a player to a group retains the current members.""" + players[3] = player_factory(3, "Test Player 3", "Speaker") + controller.mock_set_players(players) + config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.services.async_call( + MEDIA_PLAYER_DOMAIN, + SERVICE_JOIN, + { + ATTR_ENTITY_ID: "media_player.test_player", + ATTR_GROUP_MEMBERS: ["media_player.test_player_3"], + }, + blocking=True, + ) + controller.set_group.assert_called_once_with([1, 2, 3]) + + async def test_media_player_join_group_error( hass: HomeAssistant, config_entry: MockConfigEntry, controller: MockHeos ) -> None: diff --git a/tests/components/izone/conftest.py b/tests/components/izone/conftest.py index 4f14d8af860ef1..9e117693a2e64e 100644 --- a/tests/components/izone/conftest.py +++ b/tests/components/izone/conftest.py @@ -212,9 +212,15 @@ async def _scan(hass: HomeAssistant) -> None: async def _discover_one(hass: HomeAssistant, uid: str) -> ControllerEndpoint | None: return endpoints.get(uid) + async def _discover_by_host( + hass: HomeAssistant, host: str + ) -> ControllerEndpoint | None: + return next((ep for ep in endpoints.values() if ep.host == host), None) + mock_discover_all = AsyncMock(side_effect=_discover_all) mock_scan = AsyncMock(side_effect=_scan) mock_discover_one = AsyncMock(side_effect=_discover_one) + mock_discover_by_host = AsyncMock(side_effect=_discover_by_host) with ( patch( "homeassistant.components.izone.discovery.async_discover_all_endpoints", @@ -228,20 +234,50 @@ async def _discover_one(hass: HomeAssistant, uid: str) -> ControllerEndpoint | N "homeassistant.components.izone.discovery.async_discover_endpoint", new=mock_discover_one, ), + patch( + "homeassistant.components.izone.discovery.async_discover_by_host", + new=mock_discover_by_host, + ), ): yield mock_discover_all, mock_discover_one, mock_scan -async def async_finish_user_discover( +async def async_start_user_discover( hass: HomeAssistant, result: FlowResult ) -> FlowResult: - """Advance a user Search flow past SHOW_PROGRESS discover.""" + """Select Search from the user menu and return the progress step.""" + if result["type"] is FlowResultType.MENU: + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {"next_step_id": "discover"} + ) assert result["type"] is FlowResultType.SHOW_PROGRESS assert result["progress_action"] == "discover" + return result + + +async def async_finish_user_discover( + hass: HomeAssistant, result: FlowResult +) -> FlowResult: + """Advance a user Search flow past the menu and SHOW_PROGRESS discover.""" + result = await async_start_user_discover(hass, result) await hass.async_block_till_done(wait_background_tasks=True) return await hass.config_entries.flow.async_configure(result["flow_id"]) +async def async_choose_manual_host( + hass: HomeAssistant, result: FlowResult +) -> FlowResult: + """Select Enter host from the user menu.""" + assert result["type"] is FlowResultType.MENU + assert result["step_id"] == "user" + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {"next_step_id": "manual_host"} + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "manual_host" + return result + + async def async_follow_user_handoff( hass: HomeAssistant, result: FlowResult ) -> FlowResult: diff --git a/tests/components/izone/test_config_flow.py b/tests/components/izone/test_config_flow.py index 2907de27ca8e9d..15b06613911775 100644 --- a/tests/components/izone/test_config_flow.py +++ b/tests/components/izone/test_config_flow.py @@ -5,6 +5,7 @@ from types import SimpleNamespace from unittest.mock import AsyncMock, Mock, patch +import pizone import pytest from homeassistant import config_entries @@ -16,9 +17,11 @@ from homeassistant.setup import async_setup_component from .conftest import ( + async_choose_manual_host, async_finish_user_discover, async_follow_user_handoff, async_load_yaml_exclude, + async_start_user_discover, create_mock_controller, endpoint_from_controller, patch_discovered_controllers, @@ -234,10 +237,10 @@ async def test_select_controller_rerender_hands_off_when_one_left( assert result["next_flow"] is not None -async def test_select_controller_rerender_aborts_when_shelf_empty( +async def test_select_controller_rerender_nudges_manual_host_when_shelf_empty( hass: HomeAssistant, ) -> None: - """Re-show after every shelf flow is gone aborts no_devices_found.""" + """Re-show after every shelf flow is gone opens Enter host with an error.""" first_controller = create_mock_controller("000000001", "192.0.2.1") second_controller = create_mock_controller("000000002", "192.0.2.2") @@ -257,8 +260,9 @@ async def test_select_controller_rerender_aborts_when_shelf_empty( result = await hass.config_entries.flow.async_configure(user_flow_id) - assert result["type"] is FlowResultType.ABORT - assert result["reason"] == "no_devices_found" + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "manual_host" + assert result["errors"] == {"base": "no_devices_found"} @pytest.mark.usefixtures("mock_entry_setup") @@ -378,10 +382,10 @@ async def test_select_controller_aborts_already_configured_when_uid_left_shelf( assert result["reason"] == "already_configured" -async def test_broadcast_aborts_when_all_discovered_are_configured( +async def test_broadcast_nudges_manual_host_when_all_discovered_are_configured( hass: HomeAssistant, ) -> None: - """Search aborts when every noted controller is already configured.""" + """Search opens Enter host when every noted controller is already configured.""" configured_controller = create_mock_controller("000000001", "192.0.2.1") MockConfigEntry( domain=DOMAIN, @@ -396,14 +400,15 @@ async def test_broadcast_aborts_when_all_discovered_are_configured( ) result = await async_finish_user_discover(hass, result) - assert result["type"] is FlowResultType.ABORT - assert result["reason"] == "no_devices_found" + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "manual_host" + assert result["errors"] == {"base": "no_devices_found"} -async def test_user_flow_aborts_when_all_discovered_are_ignored( +async def test_user_flow_nudges_manual_host_when_all_discovered_are_ignored( hass: HomeAssistant, ) -> None: - """Search aborts when every noted controller is ignored (no shelf flow).""" + """Search opens Enter host when every noted controller is ignored (no shelf).""" ignored_controller = create_mock_controller("000000001", "192.0.2.1") MockConfigEntry( domain=DOMAIN, @@ -418,8 +423,9 @@ async def test_user_flow_aborts_when_all_discovered_are_ignored( ) result = await async_finish_user_discover(hass, result) - assert result["type"] is FlowResultType.ABORT - assert result["reason"] == "no_devices_found" + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "manual_host" + assert result["errors"] == {"base": "no_devices_found"} async def test_import_aborts_when_another_izone_flow_in_progress( @@ -431,7 +437,7 @@ async def test_import_aborts_when_another_izone_flow_in_progress( user_flow = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} ) - assert user_flow["type"] is FlowResultType.SHOW_PROGRESS + assert user_flow["type"] is FlowResultType.MENU result = await hass.config_entries.flow.async_init( DOMAIN, @@ -509,6 +515,7 @@ async def _blocked_scan(_hass: HomeAssistant) -> None: result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} ) + result = await async_start_user_discover(hass, result) assert result["type"] is FlowResultType.SHOW_PROGRESS assert result["progress_action"] == "discover" @@ -522,8 +529,9 @@ async def _blocked_scan(_hass: HomeAssistant) -> None: await hass.async_block_till_done(wait_background_tasks=True) result = await hass.config_entries.flow.async_configure(result["flow_id"]) - assert result["type"] is FlowResultType.ABORT - assert result["reason"] == "no_devices_found" + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "manual_host" + assert result["errors"] == {"base": "no_devices_found"} async def test_user_search_skips_peer_user_flow_when_building_candidates( @@ -538,8 +546,8 @@ async def test_user_search_skips_peer_user_flow_when_building_candidates( second = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} ) - assert first["type"] is FlowResultType.SHOW_PROGRESS - assert second["type"] is FlowResultType.SHOW_PROGRESS + assert first["type"] is FlowResultType.MENU + assert second["type"] is FlowResultType.MENU first = await async_finish_user_discover(hass, first) @@ -743,7 +751,7 @@ async def test_user_search_allowed_while_homekit_flow_in_progress( DOMAIN, context={"source": config_entries.SOURCE_USER}, ) - assert result["type"] is FlowResultType.SHOW_PROGRESS + assert result["type"] is FlowResultType.MENU result = await async_finish_user_discover(hass, result) assert result["type"] is FlowResultType.FORM @@ -900,16 +908,17 @@ async def test_homekit_aborts_when_discovery_bind_fails(hass: HomeAssistant) -> assert result["reason"] == "discovery_failed" -async def test_user_flow_aborts_when_no_controllers_found(hass: HomeAssistant) -> None: - """User flow aborts when broadcast discovery returns no controllers.""" +async def test_user_search_empty_nudges_manual_host(hass: HomeAssistant) -> None: + """Empty Search shows Enter host with no_devices_found instead of aborting.""" with patch_discovered_controllers([]): result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} ) result = await async_finish_user_discover(hass, result) - assert result["type"] is FlowResultType.ABORT - assert result["reason"] == "no_devices_found" + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "manual_host" + assert result["errors"] == {"base": "no_devices_found"} async def test_homekit_without_model_aborts( @@ -1208,7 +1217,7 @@ async def test_confirm_asserts_when_controller_data_is_missing( # Corrupt flow-local state that the public path always sets before confirm. flow = hass.config_entries.flow._progress[result["flow_id"]] - flow._discovered_controller_ip = None + flow._discovered_controller_host = None with pytest.raises(AssertionError): await flow.async_step_confirm() @@ -1234,25 +1243,26 @@ async def test_confirm_asserts_when_unique_id_is_not_string( def test_async_fan_out_skips_uids_already_in_progress() -> None: - """Fan-out skips scheduling flows for UIDs already in progress.""" + """Fan-out skips noting discovery for UIDs already in progress.""" candidate = endpoint_from_controller( create_mock_controller("000000002", "192.0.2.2") ) # Drive the helper with a stub flow: happy-path fan-out tests only cover the - # "schedule missing UIDs" branch, not the already-in-progress skip. + # "note missing UIDs" branch, not the already-in-progress skip. fake_flow = SimpleNamespace( + hass=object(), _async_current_ids=Mock(return_value=set()), _async_in_progress=Mock(return_value=[{"context": {"unique_id": "000000002"}}]), - _async_schedule_integration_discovery_flow=Mock(), ) - config_flow.IZoneConfigFlow._async_fan_out_discovered_endpoints( - fake_flow, - [candidate], - selected_uid="000000001", - ) + with patch.object(izone_discovery, "async_note_integration_discovery") as mock_note: + config_flow.IZoneConfigFlow._async_fan_out_discovered_endpoints( + fake_flow, + [candidate], + selected_uid="000000001", + ) - fake_flow._async_schedule_integration_discovery_flow.assert_not_called() + mock_note.assert_not_called() async def test_homekit_aborts_for_yaml_excluded_uid_without_discovery( @@ -1339,3 +1349,403 @@ async def test_async_migrate_entry_clears_legacy_data( assert entry.version == 2 assert entry.data == {} + + +@pytest.mark.usefixtures("mock_entry_setup") +async def test_user_menu_always_offers_search_and_host( + hass: HomeAssistant, +) -> None: + """User start is always a menu, including when an entry is already loaded.""" + MockConfigEntry( + domain=DOMAIN, + unique_id="000000001", + data={CONF_HOST: "192.0.2.1"}, + version=2, + ).add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + assert result["type"] is FlowResultType.MENU + assert result["step_id"] == "user" + assert result["menu_options"] == ["discover", "manual_host"] + + +@pytest.mark.usefixtures("mock_entry_setup") +async def test_user_manual_host_success_shelves_and_handoff( + hass: HomeAssistant, +) -> None: + """Probe of an unknown host schedules a shelf flow and hands off.""" + controller = create_mock_controller("000000001", "192.0.2.55") + with patch_discovered_controllers(controller): + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + result = await async_choose_manual_host(hass, result) + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {CONF_HOST: "192.0.2.55"} + ) + result = await async_follow_user_handoff(hass, result) + result = await hass.config_entries.flow.async_configure(result["flow_id"], {}) + await hass.async_block_till_done() + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == "iZone 000000001" + assert result["data"] == {CONF_HOST: "192.0.2.55"} + assert result["result"].unique_id == "000000001" + + +@pytest.mark.usefixtures("mock_entry_setup") +async def test_user_manual_host_matching_shelf_skips_probe( + hass: HomeAssistant, +) -> None: + """Typing a host already on the Discovered shelf hands off without probing.""" + controller = create_mock_controller("000000001", "192.0.2.55") + with ( + patch_discovered_controllers(controller), + patch( + "homeassistant.components.izone.discovery.async_discover_by_host", + new=AsyncMock(), + ) as mock_probe, + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + result = await async_finish_user_discover(hass, result) + result = await async_follow_user_handoff(hass, result) + shelf_flow_id = result["flow_id"] + + menu = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + host_form = await async_choose_manual_host(hass, menu) + result = await hass.config_entries.flow.async_configure( + host_form["flow_id"], {CONF_HOST: "192.0.2.55"} + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "continue_setup" + assert result["next_flow"] == (config_entries.FlowType.CONFIG_FLOW, shelf_flow_id) + mock_probe.assert_not_called() + + +@pytest.mark.usefixtures("mock_entry_setup") +async def test_user_manual_host_handoff_by_uid_when_shelf_host_stale( + hass: HomeAssistant, +) -> None: + """Stale shelf host is replaced; handoff confirm/create use the probed host.""" + stale = create_mock_controller("000000001", "10.0.0.1") + current = create_mock_controller("000000001", "192.0.2.55") + with patch_discovered_controllers(stale): + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + result = await async_finish_user_discover(hass, result) + result = await async_follow_user_handoff(hass, result) + stale_shelf_flow_id = result["flow_id"] + + with patch_discovered_controllers(current): + menu = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + host_form = await async_choose_manual_host(hass, menu) + result = await hass.config_entries.flow.async_configure( + host_form["flow_id"], {CONF_HOST: "192.0.2.55"} + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "continue_setup" + next_flow = result["next_flow"] + assert next_flow is not None + _flow_type, shelf_flow_id = next_flow + assert shelf_flow_id != stale_shelf_flow_id + + result = await async_follow_user_handoff(hass, result) + assert result["description_placeholders"]["host"] == "192.0.2.55" + result = await hass.config_entries.flow.async_configure(result["flow_id"], {}) + await hass.async_block_till_done() + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["data"] == {CONF_HOST: "192.0.2.55"} + assert stale_shelf_flow_id not in hass.config_entries.flow._progress + + +@pytest.mark.usefixtures("mock_entry_setup") +async def test_user_manual_host_yaml_excluded_stays_on_form( + hass: HomeAssistant, +) -> None: + """Probed UID listed in YAML exclude redisplays Enter host.""" + await async_load_yaml_exclude(hass, "000000001") + controller = create_mock_controller("000000001", "192.0.2.55") + + with patch_discovered_controllers(controller): + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + result = await async_choose_manual_host(hass, result) + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {CONF_HOST: "192.0.2.55"} + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "manual_host" + assert result["errors"] == {"base": "no_devices_found"} + + +@pytest.mark.usefixtures("mock_entry_setup") +async def test_user_manual_host_yaml_excluded_ignored_uid_stays_on_form( + hass: HomeAssistant, +) -> None: + """YAML exclude wins over Ignore replacement, matching other discovery paths.""" + await async_load_yaml_exclude(hass, "000000001") + MockConfigEntry( + domain=DOMAIN, + unique_id="000000001", + source=config_entries.SOURCE_IGNORE, + data={}, + ).add_to_hass(hass) + controller = create_mock_controller("000000001", "192.0.2.55") + + with patch_discovered_controllers(controller): + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + result = await async_choose_manual_host(hass, result) + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {CONF_HOST: "192.0.2.55"} + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "manual_host" + assert result["errors"] == {"base": "no_devices_found"} + + +@pytest.mark.usefixtures("mock_entry_setup") +async def test_user_manual_host_ignored_uid_confirms_without_unique_id( + hass: HomeAssistant, +) -> None: + """Typed host of an ignored UID confirms in-flow and replaces Ignore on create.""" + ignored = MockConfigEntry( + domain=DOMAIN, + unique_id="000000001", + source=config_entries.SOURCE_IGNORE, + data={}, + ) + ignored.add_to_hass(hass) + controller = create_mock_controller("000000001", "192.0.2.55") + + with patch_discovered_controllers(controller): + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + result = await async_choose_manual_host(hass, result) + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {CONF_HOST: "192.0.2.55"} + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "confirm" + user_progress = hass.config_entries.flow.async_get(result["flow_id"]) + assert user_progress["context"].get("unique_id") is None + + retry = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + retry = await async_choose_manual_host(hass, retry) + retry = await hass.config_entries.flow.async_configure( + retry["flow_id"], {CONF_HOST: "192.0.2.55"} + ) + assert retry["type"] is FlowResultType.FORM + assert retry["step_id"] == "confirm" + + result = await hass.config_entries.flow.async_configure(result["flow_id"], {}) + await hass.async_block_till_done() + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["result"].unique_id == "000000001" + entries = hass.config_entries.async_entries(DOMAIN) + assert len(entries) == 1 + assert entries[0].source != config_entries.SOURCE_IGNORE + + +async def test_user_manual_host_empty_rejected_by_schema(hass: HomeAssistant) -> None: + """Whitespace-only host is a required-field error.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + result = await async_choose_manual_host(hass, result) + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {CONF_HOST: " "} + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "manual_host" + assert result["errors"] == {CONF_HOST: "required"} + + +async def test_user_manual_host_unreachable(hass: HomeAssistant) -> None: + """Unreachable host redisplays the form with cannot_connect.""" + with patch( + "homeassistant.components.izone.discovery.async_discover_by_host", + new=AsyncMock(return_value=None), + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + result = await async_choose_manual_host(hass, result) + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {CONF_HOST: "192.0.2.99"} + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "manual_host" + assert result["errors"] == {"base": "cannot_connect"} + + +async def test_user_manual_host_already_configured_stays_on_form( + hass: HomeAssistant, +) -> None: + """Loaded entry for the probed UID redisplays Enter host with an error.""" + MockConfigEntry( + domain=DOMAIN, + unique_id="000000001", + data={CONF_HOST: "10.0.0.90"}, + version=2, + ).add_to_hass(hass) + controller = create_mock_controller("000000001", "10.0.0.90") + + with patch_discovered_controllers(controller): + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + result = await async_choose_manual_host(hass, result) + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {CONF_HOST: "10.0.0.90"} + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "manual_host" + assert result["errors"] == {"base": "already_configured"} + + +async def test_user_manual_host_unpaired_stays_on_form(hass: HomeAssistant) -> None: + """Unpaired placeholder UID redisplays Enter host with an error.""" + with patch( + "homeassistant.components.izone.discovery.async_discover_by_host", + new=AsyncMock(side_effect=pizone.UnpairedBridgeError("unpaired")), + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + result = await async_choose_manual_host(hass, result) + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {CONF_HOST: "192.0.2.111"} + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "manual_host" + assert result["errors"] == {"base": "unpaired_bridge"} + + +async def test_user_manual_host_claimed_stays_on_form(hass: HomeAssistant) -> None: + """Claimed controller on the discovery service redisplays Enter host.""" + with patch( + "homeassistant.components.izone.discovery.async_discover_by_host", + new=AsyncMock(side_effect=pizone.ControllerAlreadyClaimedError("claimed")), + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + result = await async_choose_manual_host(hass, result) + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {CONF_HOST: "192.0.2.1"} + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "manual_host" + assert result["errors"] == {"base": "already_configured"} + + +async def test_user_manual_host_discovery_bind_fails(hass: HomeAssistant) -> None: + """UDP bind failure during Enter host probe aborts discovery_failed.""" + with patch( + "homeassistant.components.izone.discovery.async_discover_by_host", + new=AsyncMock(side_effect=OSError("bind failed")), + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + result = await async_choose_manual_host(hass, result) + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {CONF_HOST: "192.0.2.55"} + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "discovery_failed" + + +@pytest.mark.usefixtures("mock_entry_setup") +async def test_user_manual_host_handoff_by_uid_when_typed_host_differs( + hass: HomeAssistant, +) -> None: + """Typed alias that probes to the shelf host hands off without replacing the card.""" + controller = create_mock_controller("000000001", "192.0.2.55") + endpoint = endpoint_from_controller(controller) + with ( + patch_discovered_controllers(controller), + patch( + "homeassistant.components.izone.discovery.async_discover_by_host", + new=AsyncMock(return_value=endpoint), + ), + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + result = await async_finish_user_discover(hass, result) + result = await async_follow_user_handoff(hass, result) + shelf_flow_id = result["flow_id"] + + menu = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + host_form = await async_choose_manual_host(hass, menu) + result = await hass.config_entries.flow.async_configure( + host_form["flow_id"], {CONF_HOST: "izone.example"} + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "continue_setup" + assert result["next_flow"] == (config_entries.FlowType.CONFIG_FLOW, shelf_flow_id) + assert shelf_flow_id in hass.config_entries.flow._progress + + +async def test_user_manual_host_shelve_miss_stays_on_form( + hass: HomeAssistant, +) -> None: + """If shelving does not produce a shelf card, stay on Enter host.""" + endpoint = endpoint_from_controller( + create_mock_controller("000000001", "192.0.2.55") + ) + with ( + patch( + "homeassistant.components.izone.discovery.async_discover_by_host", + new=AsyncMock(return_value=endpoint), + ), + patch( + "homeassistant.components.izone.config_flow.IZoneConfigFlow." + "_async_shelve_integration_discovery_flow", + new=AsyncMock(), + ), + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + result = await async_choose_manual_host(hass, result) + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {CONF_HOST: "192.0.2.55"} + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "manual_host" + assert result["errors"] == {"base": "no_devices_found"} diff --git a/tests/components/izone/test_discovery.py b/tests/components/izone/test_discovery.py index 03227b999c1736..42c5454eaf9d26 100644 --- a/tests/components/izone/test_discovery.py +++ b/tests/components/izone/test_discovery.py @@ -28,6 +28,7 @@ def _mock_pizone_service() -> Mock: service.close = AsyncMock() service.discover_all = AsyncMock(return_value=[]) service.discover_by_uid = AsyncMock(return_value=None) + service.discover_by_host = AsyncMock(return_value=None) return service @@ -548,3 +549,18 @@ async def test_discover_endpoint_by_uid( assert result == endpoint mock_service.discover_by_uid.assert_awaited_once_with("000000001") + + +async def test_discover_by_host( + hass: HomeAssistant, + mock_pizone_create_discovery: tuple[AsyncMock, Mock], +) -> None: + """Manual host lookup returns a single endpoint from discover_by_host.""" + _, mock_service = mock_pizone_create_discovery + endpoint = create_mock_endpoint("000000001", "192.0.2.1") + mock_service.discover_by_host = AsyncMock(return_value=endpoint) + + result = await izone_discovery.async_discover_by_host(hass, "192.0.2.1") + + assert result == endpoint + mock_service.discover_by_host.assert_awaited_once_with("192.0.2.1") diff --git a/tests/components/midea/snapshots/test_number.ambr b/tests/components/midea/snapshots/test_number.ambr index ba4fba338d86fc..80601966d918fd 100644 --- a/tests/components/midea/snapshots/test_number.ambr +++ b/tests/components/midea/snapshots/test_number.ambr @@ -359,6 +359,67 @@ 'state': '500.0', }) # --- +# name: test_number_state_snapshot[ed][number.water_drinking_appliance_salt_setting-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : 255, + : 0, + : , + : 1, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': None, + 'entity_id': 'number.water_drinking_appliance_salt_setting', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Salt setting', + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Salt setting', + 'platform': 'midea', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'salt_setting', + 'unique_id': '12345678_salt_setting', + 'unit_of_measurement': , + }) +# --- +# name: test_number_state_snapshot[ed][number.water_drinking_appliance_salt_setting-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'weight', + : 'Water Drinking Appliance Salt setting', + : 255, + : 0, + : , + : 1, + : , + }), + 'context': , + 'entity_id': 'number.water_drinking_appliance_salt_setting', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '25.0', + }) +# --- # name: test_number_state_snapshot[ed][number.water_drinking_appliance_water_hardness-entry] EntityRegistryEntrySnapshot({ 'aliases': list([ diff --git a/tests/components/midea/snapshots/test_sensor.ambr b/tests/components/midea/snapshots/test_sensor.ambr index aa201b3c68e60a..8136f3cf1c023e 100644 --- a/tests/components/midea/snapshots/test_sensor.ambr +++ b/tests/components/midea/snapshots/test_sensor.ambr @@ -762,6 +762,57 @@ 'state': '21.0', }) # --- +# name: test_all_entities[e8][sensor.electric_slow_cooker_time_remaining-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + '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.electric_slow_cooker_time_remaining', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Time remaining', + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Time remaining', + 'platform': 'midea', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'time_remaining', + 'unique_id': '12345678_time_remaining', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[e8][sensor.electric_slow_cooker_time_remaining-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'timestamp', + : 'Electric Slow Cooker Time remaining', + }), + 'context': , + 'entity_id': 'sensor.electric_slow_cooker_time_remaining', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '2026-01-09T13:00:00+00:00', + }) +# --- # name: test_all_entities[ea][sensor.electric_rice_cooker_bottom_temperature-entry] EntityRegistryEntrySnapshot({ 'aliases': list([ @@ -1126,6 +1177,57 @@ 'state': 'heat_rice', }) # --- +# name: test_all_entities[ea][sensor.electric_rice_cooker_time_remaining-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + '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.electric_rice_cooker_time_remaining', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Time remaining', + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Time remaining', + 'platform': 'midea', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'time_remaining', + 'unique_id': '12345678_time_remaining', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[ea][sensor.electric_rice_cooker_time_remaining-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'timestamp', + : 'Electric Rice Cooker Time remaining', + }), + 'context': , + 'entity_id': 'sensor.electric_rice_cooker_time_remaining', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '2026-01-09T12:05:00+00:00', + }) +# --- # name: test_all_entities[ea][sensor.electric_rice_cooker_top_temperature-entry] EntityRegistryEntrySnapshot({ 'aliases': list([ @@ -1492,6 +1594,57 @@ 'state': 'diy', }) # --- +# name: test_all_entities[ec][sensor.electric_pressure_cooker_time_remaining-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + '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.electric_pressure_cooker_time_remaining', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Time remaining', + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Time remaining', + 'platform': 'midea', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'time_remaining', + 'unique_id': '12345678_time_remaining', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[ec][sensor.electric_pressure_cooker_time_remaining-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'timestamp', + : 'Electric Pressure Cooker Time remaining', + }), + 'context': , + 'entity_id': 'sensor.electric_pressure_cooker_time_remaining', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '2026-01-09T12:05:00+00:00', + }) +# --- # name: test_all_entities[ec][sensor.electric_pressure_cooker_top_temperature-entry] EntityRegistryEntrySnapshot({ 'aliases': list([ diff --git a/tests/components/midea/snapshots/test_switch.ambr b/tests/components/midea/snapshots/test_switch.ambr index 34df594b5eb2a4..1c358daa202687 100644 --- a/tests/components/midea/snapshots/test_switch.ambr +++ b/tests/components/midea/snapshots/test_switch.ambr @@ -849,3 +849,53 @@ 'state': 'on', }) # --- +# name: test_switch_state_snapshot[dc][switch.clothes_dryer_ai_switch-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + '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.clothes_dryer_ai_switch', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'AI switch', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'AI switch', + 'platform': 'midea', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'ai_switch', + 'unique_id': '12345678_ai_switch', + 'unit_of_measurement': None, + }) +# --- +# name: test_switch_state_snapshot[dc][switch.clothes_dryer_ai_switch-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'Clothes Dryer AI switch', + }), + 'context': , + 'entity_id': 'switch.clothes_dryer_ai_switch', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- diff --git a/tests/components/midea/test_climate.py b/tests/components/midea/test_climate.py index 12b97b552ca632..92247cad178c17 100644 --- a/tests/components/midea/test_climate.py +++ b/tests/components/midea/test_climate.py @@ -615,18 +615,17 @@ async def test_ac_set_temperature_without_hvac_mode( @pytest.mark.parametrize( ("humidity", "expected_humidity"), [ - pytest.param(50, 50.0, id="normal"), - pytest.param(0, None, id="invalid_zero"), - pytest.param(0xFF, None, id="invalid_ff"), + pytest.param(50, 50.0, id="numeric"), + pytest.param(None, None, id="unavailable"), ], ) -async def test_ac_humidity_filtering( +async def test_ac_current_humidity( hass: HomeAssistant, mock_config_entry: Callable[[DummyDevice], MockConfigEntry], - humidity: int, + humidity: int | None, expected_humidity: float | None, ) -> None: - """Test AC humidity filtering for invalid sensor values.""" + """Test AC current_humidity reflects the device's raw attribute value.""" device = DummyDevice( DeviceType.AC, attributes={ diff --git a/tests/components/midea/test_number.py b/tests/components/midea/test_number.py index d93fe47db76955..8044994964a241 100644 --- a/tests/components/midea/test_number.py +++ b/tests/components/midea/test_number.py @@ -62,6 +62,7 @@ def _ed_device() -> DummyDevice: EDAttributes.water_hardness: 120, EDAttributes.flushing_days: 14, EDAttributes.leak_water_protection_value: 500, + EDAttributes.salt_setting: 25, }, ) @@ -181,7 +182,7 @@ async def test_ed_number_entities( hass: HomeAssistant, mock_config_entry: Callable[[DummyDevice], MockConfigEntry], ) -> None: - """Test ED exposes water_hardness, flushing_days and leak_water_protection_value.""" + """Test ED exposes water_hardness, flushing_days, leak_water_protection_value and salt_setting.""" device = _ed_device() config_entry = mock_config_entry(device) with patch("homeassistant.components.midea._PLATFORMS", [Platform.NUMBER]): @@ -191,6 +192,7 @@ async def test_ed_number_entities( assert f"{TEST_DEVICE_ID}_water_hardness" in entities assert f"{TEST_DEVICE_ID}_flushing_days" in entities assert f"{TEST_DEVICE_ID}_leak_water_protection_value" in entities + assert f"{TEST_DEVICE_ID}_salt_setting" in entities leak_entry = entities[f"{TEST_DEVICE_ID}_leak_water_protection_value"] await _assert_service_call( @@ -201,6 +203,20 @@ async def test_ed_number_entities( device, ) + salt_entry = entities[f"{TEST_DEVICE_ID}_salt_setting"] + assert (salt_state := hass.states.get(salt_entry.entity_id)) is not None + assert float(salt_state.state) == 25 + assert salt_state.attributes[ATTR_MIN] == 0 + assert salt_state.attributes[ATTR_MAX] == 255 + + await _assert_service_call( + hass, + salt_entry.entity_id, + 30, + [("set_attribute", "salt_setting", 30)], + device, + ) + async def test_fb_heating_level( hass: HomeAssistant, diff --git a/tests/components/midea/test_sensor.py b/tests/components/midea/test_sensor.py index 0bf4143abdb2e1..a4e7da803bf012 100644 --- a/tests/components/midea/test_sensor.py +++ b/tests/components/midea/test_sensor.py @@ -3,17 +3,20 @@ from collections.abc import Callable from unittest.mock import patch +from freezegun.api import FrozenDateTimeFactory from midealocal.const import DeviceType from midealocal.devices.ac import DeviceAttributes as ACAttributes from midealocal.devices.c3 import DeviceAttributes as C3Attributes -from midealocal.devices.db import DeviceAttributes as DBAttributes +from midealocal.devices.ca import MideaCADevice +from midealocal.devices.db import DeviceAttributes as DBAttributes, MideaDBDevice from midealocal.devices.e8 import DeviceAttributes as E8Attributes -from midealocal.devices.ea import DeviceAttributes as EAAttributes -from midealocal.devices.ec import DeviceAttributes as ECAttributes +from midealocal.devices.ea import DeviceAttributes as EAAttributes, MideaEADevice +from midealocal.devices.ec import DeviceAttributes as ECAttributes, MideaECDevice from midealocal.devices.ed import DeviceAttributes as EDAttributes import pytest from syrupy.assertion import SnapshotAssertion +from homeassistant.components.midea.sensor import SENSOR_ENTITIES from homeassistant.const import Platform from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er @@ -70,7 +73,7 @@ DeviceType.E8, attributes={ E8Attributes.status: 1, - E8Attributes.time_remaining: 3600, + E8Attributes.time_remaining: 60, E8Attributes.keep_warm_remaining: 1800, E8Attributes.working_time: 7200, E8Attributes.target_temperature: 22.0, @@ -177,12 +180,14 @@ ) async def test_all_entities( hass: HomeAssistant, + freezer: FrozenDateTimeFactory, device: DummyDevice, mock_config_entry: Callable[[DummyDevice], MockConfigEntry], snapshot: SnapshotAssertion, entity_registry: er.EntityRegistry, ) -> None: """Test sensor entities are created.""" + freezer.move_to("2026-01-09 12:00:00+00:00") config_entry = mock_config_entry(device) with patch("homeassistant.components.midea._PLATFORMS", [Platform.SENSOR]): await setup_integration(hass, config_entry, device) @@ -203,7 +208,7 @@ async def test_sensor_state_update( ACAttributes.mode: 1, ACAttributes.target_temperature: 22.0, ACAttributes.indoor_temperature: 21.0, - ACAttributes.indoor_humidity: 0, + ACAttributes.indoor_humidity: 45, ACAttributes.full_dust: False, ACAttributes.outdoor_temperature: "unknown", }, @@ -239,9 +244,75 @@ async def test_sensor_state_update( ] state = hass.states.get(entity_entry.entity_id) assert state is not None + assert state.state == "45" + + await set_device_attribute(device, ACAttributes.indoor_humidity, None) + state = hass.states.get(entity_entry.entity_id) + assert state is not None assert state.state == "unknown" - await set_device_attribute(device, ACAttributes.indoor_humidity, 255) + +async def test_time_remaining_sensor_as_timestamp( + hass: HomeAssistant, + freezer: FrozenDateTimeFactory, + set_device_attribute: SetDeviceAttribute, + mock_config_entry: Callable[[DummyDevice], MockConfigEntry], +) -> None: + """Test time_remaining is reported as an absolute timestamp in minutes.""" + freezer.move_to("2026-01-09 12:00:00+00:00") + device = DummyDevice( + DeviceType.E8, + attributes={E8Attributes.time_remaining: 90}, + ) + config_entry = mock_config_entry(device) + with patch("homeassistant.components.midea._PLATFORMS", [Platform.SENSOR]): + await setup_integration(hass, config_entry, device) + + entity_entry = entity_entries(hass, config_entry)[ + f"{TEST_DEVICE_ID}_time_remaining" + ] + + state = hass.states.get(entity_entry.entity_id) + assert state is not None + assert state.state == "2026-01-09T13:30:00+00:00" + + await set_device_attribute(device, E8Attributes.time_remaining, 0) state = hass.states.get(entity_entry.entity_id) assert state is not None assert state.state == "unknown" + + await set_device_attribute(device, E8Attributes.time_remaining, None) + state = hass.states.get(entity_entry.entity_id) + assert state is not None + assert state.state == "unknown" + + +@pytest.mark.parametrize( + ("model", "expected_options"), + [ + pytest.param(DeviceType.DB, MideaDBDevice.mode_options(), id="db"), + pytest.param(DeviceType.EA, MideaEADevice.mode_options(), id="ea"), + pytest.param(DeviceType.EC, MideaECDevice.mode_options(), id="ec"), + ], +) +def test_mode_options_match_library( + model: DeviceType, + expected_options: list[str], +) -> None: + """Guard against drift between the hardcoded mode options and midealocal's tables.""" + description = next( + description + for description in SENSOR_ENTITIES + if description.key == "mode" and description.models == [model] + ) + assert description.options == expected_options + + +def test_variable_mode_options_match_library() -> None: + """Guard against drift between the hardcoded variable_mode options and midealocal's table.""" + description = next( + description + for description in SENSOR_ENTITIES + if description.key == "variable_mode" + ) + assert description.options == MideaCADevice.mode_options() diff --git a/tests/components/midea/test_switch.py b/tests/components/midea/test_switch.py index a34aeb2f708810..ef3851810c989a 100644 --- a/tests/components/midea/test_switch.py +++ b/tests/components/midea/test_switch.py @@ -9,6 +9,7 @@ from midealocal.devices.c3 import DeviceAttributes as C3Attributes from midealocal.devices.cc import DeviceAttributes as CCAttributes from midealocal.devices.cf import DeviceAttributes as CFAttributes +from midealocal.devices.dc import DeviceAttributes as DCAttributes from midealocal.exceptions import SocketException import pytest from syrupy.assertion import SnapshotAssertion @@ -134,6 +135,10 @@ async def _assert_service_call( DummyDevice(DeviceType.C2, attributes={"child_lock": True}), id="c2", ), + pytest.param( + DummyDevice(DeviceType.DC, attributes={DCAttributes.ai_switch: False}), + id="dc", + ), ], ) @pytest.mark.usefixtures("entity_registry_enabled_by_default") @@ -192,6 +197,40 @@ async def test_ac_switch_services( ) +async def test_dc_ai_switch_services( + hass: HomeAssistant, + mock_config_entry: Callable[[DummyDevice], MockConfigEntry], +) -> None: + """Test DC ai_switch service calls reach the device.""" + device = DummyDevice( + DeviceType.DC, + attributes={DCAttributes.ai_switch: False}, + ) + config_entry = mock_config_entry(device) + with patch("homeassistant.components.midea._PLATFORMS", [Platform.SWITCH]): + await setup_integration(hass, config_entry, device) + + entity_entry = entity_entries(hass, config_entry)[f"{TEST_DEVICE_ID}_ai_switch"] + + assert (state := hass.states.get(entity_entry.entity_id)) is not None + assert state.state == "off" + + await _assert_service_call( + hass, + entity_entry.entity_id, + SERVICE_TURN_ON, + [("set_attribute", DCAttributes.ai_switch, True)], + device, + ) + await _assert_service_call( + hass, + entity_entry.entity_id, + SERVICE_TURN_OFF, + [("set_attribute", DCAttributes.ai_switch, False)], + device, + ) + + CHILD_LOCK_DEVICE_TYPES = [ DeviceType.X34, DeviceType.A1, diff --git a/tests/components/telegram_bot/test_telegram_bot.py b/tests/components/telegram_bot/test_telegram_bot.py index c67cc719736752..0ecea74df54eec 100644 --- a/tests/components/telegram_bot/test_telegram_bot.py +++ b/tests/components/telegram_bot/test_telegram_bot.py @@ -145,6 +145,35 @@ async def test_polling_platform_init_failed( assert mock_polling_config_entry.state is ConfigEntryState.SETUP_RETRY +async def test_polling_platform_init_failed_does_not_log_token( + hass: HomeAssistant, + mock_polling_config_entry: MockConfigEntry, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test that a connection failure does not put the bot token in the log.""" + api_key = mock_polling_config_entry.data[CONF_API_KEY] + # The Telegram API URL embeds the bot token, and library errors quote it. + error = NetworkError( + "httpx.HTTPStatusError: Client error '401 Unauthorized' for url " + f"'https://api.telegram.org/bot{api_key}/getMe'" + ) + + with patch( + "homeassistant.components.telegram_bot.bot.Bot.get_me", side_effect=error + ): + mock_polling_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_polling_config_entry.entry_id) + await hass.async_block_till_done() + + assert mock_polling_config_entry.state is ConfigEntryState.SETUP_RETRY + # Home Assistant strips the trailing period from translated messages. + assert mock_polling_config_entry.reason == "Could not connect to Telegram" + + # Nothing at any level may carry the token: not the info line, not the + # traceback config entry setup logs, not the library's own debug output. + assert api_key not in caplog.text + + @pytest.mark.parametrize( ("service", "input"), [ @@ -447,6 +476,16 @@ def _read_file_as_bytesio_mock(file_path): return _file +@pytest.fixture +def allowlist_tmp_path(hass: HomeAssistant, tmp_path: Path) -> Path: + """Allow download_file to write into the temporary directory.""" + hass.config.allowlist_external_dirs = { + *hass.config.allowlist_external_dirs, + tmp_path.resolve().as_posix(), + } + return tmp_path + + async def _run_download_file_service_with_mocks( hass: HomeAssistant, schema_request: dict[str, Any], @@ -2317,6 +2356,7 @@ async def test_download_file_no_custom_dir( ), ], ) +@pytest.mark.usefixtures("allowlist_tmp_path") async def test_download_file_custom_dir( tmp_path: Path, hass: HomeAssistant, @@ -2364,6 +2404,7 @@ async def test_download_file_custom_dir( _assert_download_file_response(response, expected_path) +@pytest.mark.usefixtures("allowlist_tmp_path") async def test_download_file_directory_created_successfully( tmp_path: Path, hass: HomeAssistant, @@ -2411,6 +2452,7 @@ async def test_download_file_directory_created_successfully( _assert_download_file_response(response, expected_path) +@pytest.mark.usefixtures("allowlist_tmp_path") async def test_download_file_when_bot_failed_to_get_file( tmp_path: Path, hass: HomeAssistant, @@ -2448,6 +2490,7 @@ async def test_download_file_when_bot_failed_to_get_file( assert "failed to get file" in str(err.value) +@pytest.mark.usefixtures("allowlist_tmp_path") async def test_download_file_when_empty_file_path( tmp_path: Path, hass: HomeAssistant, @@ -2490,6 +2533,7 @@ async def test_download_file_when_empty_file_path( TelegramError, ], ) +@pytest.mark.usefixtures("allowlist_tmp_path") async def test_download_file_when_error_when_downloading( tmp_path: Path, hass: HomeAssistant, @@ -2593,6 +2637,7 @@ async def test_download_file_rejects_invalid_directory_path( "windows\\style.txt", ], ) +@pytest.mark.usefixtures("allowlist_tmp_path") async def test_download_file_rejects_invalid_file_name( tmp_path: Path, hass: HomeAssistant, @@ -2685,3 +2730,93 @@ async def test_send_media_group( } ] } + + +@pytest.mark.parametrize( + "subdirectory", + ["outside", "nested/deeper"], +) +async def test_download_file_rejects_directory_outside_allowlist( + tmp_path: Path, + hass: HomeAssistant, + mock_broadcast_config_entry: MockConfigEntry, + mock_external_calls: None, + subdirectory: str, +) -> None: + """Test download_file rejects an absolute path outside the allowlist.""" + 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() + + # tmp_path is deliberately not allowlisted here. + directory = tmp_path / subdirectory + target = directory / "payload.jpg" + + with ( + patch( + "homeassistant.components.telegram_bot.bot.Bot.get_file", + ) as get_file_mock, + pytest.raises(ServiceValidationError) as err, + ): + await hass.services.async_call( + DOMAIN, + "download_file", + { + ATTR_FILE_ID: "some-file-id", + ATTR_DIRECTORY_PATH: directory.as_posix(), + ATTR_FILE_NAME: "payload.jpg", + }, + blocking=True, + return_response=True, + ) + + assert err.value.translation_key == "allowlist_external_dirs_error" + get_file_mock.assert_not_called() + # Nothing reached the disk and no directory was created on the way. + assert not target.exists() + assert not directory.exists() + + +async def test_download_file_rejects_symlink_out_of_allowlist( + tmp_path: Path, + hass: HomeAssistant, + mock_broadcast_config_entry: MockConfigEntry, + mock_external_calls: None, +) -> None: + """Test download_file rejects a symlink leaving the allowlist.""" + 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() + + allowed = tmp_path / "allowed" + allowed.mkdir() + outside = tmp_path / "outside" + outside.mkdir() + hass.config.allowlist_external_dirs = {allowed.resolve().as_posix()} + + # A path that passes a plain string check but resolves out of the allowlist. + escape = allowed / "escape" + escape.symlink_to(outside, target_is_directory=True) + target = outside / "payload.jpg" + + with ( + patch( + "homeassistant.components.telegram_bot.bot.Bot.get_file", + ) as get_file_mock, + pytest.raises(ServiceValidationError) as err, + ): + await hass.services.async_call( + DOMAIN, + "download_file", + { + ATTR_FILE_ID: "some-file-id", + ATTR_DIRECTORY_PATH: escape.as_posix(), + ATTR_FILE_NAME: "payload.jpg", + }, + blocking=True, + return_response=True, + ) + + assert err.value.translation_key == "allowlist_external_dirs_error" + get_file_mock.assert_not_called() + assert not target.exists()