diff --git a/homeassistant/components/alexa_devices/binary_sensor.py b/homeassistant/components/alexa_devices/binary_sensor.py index 296f4c417f0209..010a561fa77ea8 100644 --- a/homeassistant/components/alexa_devices/binary_sensor.py +++ b/homeassistant/components/alexa_devices/binary_sensor.py @@ -75,13 +75,6 @@ async def async_setup_entry( "detectionState", ) - async_add_entities( - AmazonBinarySensorEntity(coordinator, serial_num, sensor_desc) - for sensor_desc in BINARY_SENSORS - for serial_num in coordinator.data - if sensor_desc.is_supported(coordinator.data[serial_num], sensor_desc.key) - ) - known_devices: set[str] = set() def _check_device() -> None: diff --git a/homeassistant/components/imap/__init__.py b/homeassistant/components/imap/__init__.py index 5349f249ab3caf..a60bc308410933 100644 --- a/homeassistant/components/imap/__init__.py +++ b/homeassistant/components/imap/__init__.py @@ -3,7 +3,9 @@ from __future__ import annotations import asyncio +from email.message import Message import logging +from typing import Any from aioimaplib import IMAP4_SSL, AioImapException, Response import voluptuous as vol @@ -33,6 +35,7 @@ ImapPollingDataUpdateCoordinator, ImapPushDataUpdateCoordinator, connect_to_server, + get_parts, ) from .errors import InvalidAuth, InvalidFolder @@ -40,6 +43,7 @@ CONF_ENTRY = "entry" CONF_SEEN = "seen" +CONF_PART = "part" CONF_UID = "uid" CONF_TARGET_FOLDER = "target_folder" @@ -64,6 +68,11 @@ ) SERVICE_DELETE_SCHEMA = _SERVICE_UID_SCHEMA SERVICE_FETCH_TEXT_SCHEMA = _SERVICE_UID_SCHEMA +SERVICE_FETCH_PART_SCHEMA = _SERVICE_UID_SCHEMA.extend( + { + vol.Required(CONF_PART): cv.string, + } +) type ImapConfigEntry = ConfigEntry[ImapDataUpdateCoordinator] @@ -216,12 +225,14 @@ async def async_fetch(call: ServiceCall) -> ServiceResponse: translation_placeholders={"error": str(exc)}, ) from exc raise_on_error(response, "fetch_failed") + # Index 1 of of the response lines contains the bytearray with the message data message = ImapMessage(response.lines[1]) await client.close() return { "text": message.text, "sender": message.sender, "subject": message.subject, + "parts": get_parts(message.email_message), "uid": uid, } @@ -233,6 +244,73 @@ async def async_fetch(call: ServiceCall) -> ServiceResponse: supports_response=SupportsResponse.ONLY, ) + async def async_fetch_part(call: ServiceCall) -> ServiceResponse: + """Process fetch email part service and return content.""" + + @callback + def get_message_part(message: Message, part_key: str) -> Message: + part: Message | Any = message + for index in part_key.split(","): + sub_parts = part.get_payload() + try: + assert isinstance(sub_parts, list) + part = sub_parts[int(index)] + except (AssertionError, ValueError, IndexError) as exc: + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="invalid_part_index", + ) from exc + + return part + + entry_id: str = call.data[CONF_ENTRY] + uid: str = call.data[CONF_UID] + part_key: str = call.data[CONF_PART] + _LOGGER.debug( + "Fetch part %s for message %s. Entry: %s", + part_key, + uid, + entry_id, + ) + client = await async_get_imap_client(hass, entry_id) + try: + response = await client.fetch(uid, "BODY.PEEK[]") + except (TimeoutError, AioImapException) as exc: + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="imap_server_fail", + translation_placeholders={"error": str(exc)}, + ) from exc + raise_on_error(response, "fetch_failed") + # Index 1 of of the response lines contains the bytearray with the message data + message = ImapMessage(response.lines[1]) + await client.close() + part_data = get_message_part(message.email_message, part_key) + part_data_content = part_data.get_payload(decode=False) + try: + assert isinstance(part_data_content, str) + except AssertionError as exc: + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="invalid_part_index", + ) from exc + return { + "part_data": part_data_content, + "content_type": part_data.get_content_type(), + "content_transfer_encoding": part_data.get("Content-Transfer-Encoding"), + "filename": part_data.get_filename(), + "part": part_key, + "uid": uid, + } + + hass.services.async_register( + DOMAIN, + "fetch_part", + async_fetch_part, + SERVICE_FETCH_PART_SCHEMA, + supports_response=SupportsResponse.ONLY, + ) + return True diff --git a/homeassistant/components/imap/coordinator.py b/homeassistant/components/imap/coordinator.py index 34d3f43eb697d9..af8fcc911554f5 100644 --- a/homeassistant/components/imap/coordinator.py +++ b/homeassistant/components/imap/coordinator.py @@ -21,7 +21,7 @@ CONF_VERIFY_SSL, CONTENT_TYPE_TEXT_PLAIN, ) -from homeassistant.core import HomeAssistant +from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import ( ConfigEntryAuthFailed, ConfigEntryError, @@ -209,6 +209,28 @@ def text(self) -> str: return str(self.email_message.get_payload()) +@callback +def get_parts(message: Message, prefix: str | None = None) -> dict[str, Any]: + """Return information about the parts of a multipart message.""" + parts: dict[str, Any] = {} + if not message.is_multipart(): + return {} + for index, part in enumerate(message.get_payload(), 0): + if TYPE_CHECKING: + assert isinstance(part, Message) + key = f"{prefix},{index}" if prefix else f"{index}" + if part.is_multipart(): + parts |= get_parts(part, key) + continue + parts[key] = {"content_type": part.get_content_type()} + if filename := part.get_filename(): + parts[key]["filename"] = filename + if content_transfer_encoding := part.get("Content-Transfer-Encoding"): + parts[key]["content_transfer_encoding"] = content_transfer_encoding + + return parts + + class ImapDataUpdateCoordinator(DataUpdateCoordinator[int | None]): """Base class for imap client.""" @@ -275,6 +297,7 @@ async def _async_process_event(self, last_message_uid: str) -> None: "sender": message.sender, "subject": message.subject, "uid": last_message_uid, + "parts": get_parts(message.email_message), } data.update({key: getattr(message, key) for key in self._event_data_keys}) if self.custom_event_template is not None: diff --git a/homeassistant/components/imap/icons.json b/homeassistant/components/imap/icons.json index 17a11d0fe22d28..5c134b8ef81d2b 100644 --- a/homeassistant/components/imap/icons.json +++ b/homeassistant/components/imap/icons.json @@ -21,6 +21,9 @@ }, "fetch": { "service": "mdi:email-sync-outline" + }, + "fetch_part": { + "service": "mdi:email-sync-outline" } } } diff --git a/homeassistant/components/imap/services.yaml b/homeassistant/components/imap/services.yaml index be56eb148daa9a..7854a6fd688e99 100644 --- a/homeassistant/components/imap/services.yaml +++ b/homeassistant/components/imap/services.yaml @@ -56,3 +56,22 @@ fetch: example: "12" selector: text: + +fetch_part: + fields: + entry: + required: true + selector: + config_entry: + integration: "imap" + uid: + required: true + example: "12" + selector: + text: + + part: + required: true + example: "0,1" + selector: + text: diff --git a/homeassistant/components/imap/strings.json b/homeassistant/components/imap/strings.json index 0f6f99dff65df6..417afcf17569f7 100644 --- a/homeassistant/components/imap/strings.json +++ b/homeassistant/components/imap/strings.json @@ -84,6 +84,9 @@ "imap_server_fail": { "message": "The IMAP server failed to connect: {error}." }, + "invalid_part_index": { + "message": "Invalid part index." + }, "seen_failed": { "message": "Marking message as seen failed with \"{error}\"." } @@ -148,6 +151,24 @@ } } }, + "fetch_part": { + "name": "Fetch message part", + "description": "Fetches a message part or attachment from an email message.", + "fields": { + "entry": { + "name": "[%key:component::imap::services::fetch::fields::entry::name%]", + "description": "[%key:component::imap::services::fetch::fields::entry::description%]" + }, + "uid": { + "name": "[%key:component::imap::services::fetch::fields::uid::name%]", + "description": "[%key:component::imap::services::fetch::fields::uid::description%]" + }, + "part": { + "name": "Part", + "description": "The message part index." + } + } + }, "seen": { "name": "Mark message as seen", "description": "Marks an email as seen.", diff --git a/homeassistant/components/portainer/__init__.py b/homeassistant/components/portainer/__init__.py index b945e60b545cab..ad57e66186d60b 100644 --- a/homeassistant/components/portainer/__init__.py +++ b/homeassistant/components/portainer/__init__.py @@ -5,7 +5,14 @@ from pyportainer import Portainer from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_API_KEY, CONF_HOST, CONF_VERIFY_SSL, Platform +from homeassistant.const import ( + CONF_API_KEY, + CONF_API_TOKEN, + CONF_HOST, + CONF_URL, + CONF_VERIFY_SSL, + Platform, +) from homeassistant.core import HomeAssistant from homeassistant.helpers.aiohttp_client import async_create_clientsession @@ -20,8 +27,8 @@ async def async_setup_entry(hass: HomeAssistant, entry: PortainerConfigEntry) -> """Set up Portainer from a config entry.""" client = Portainer( - api_url=entry.data[CONF_HOST], - api_key=entry.data[CONF_API_KEY], + api_url=entry.data[CONF_URL], + api_key=entry.data[CONF_API_TOKEN], session=async_create_clientsession( hass=hass, verify_ssl=entry.data[CONF_VERIFY_SSL] ), @@ -39,3 +46,15 @@ async def async_setup_entry(hass: HomeAssistant, entry: PortainerConfigEntry) -> async def async_unload_entry(hass: HomeAssistant, entry: PortainerConfigEntry) -> bool: """Unload a config entry.""" return await hass.config_entries.async_unload_platforms(entry, _PLATFORMS) + + +async def async_migrate_entry(hass: HomeAssistant, entry: PortainerConfigEntry) -> bool: + """Migrate old entry.""" + + if entry.version < 2: + data = dict(entry.data) + data[CONF_URL] = data.pop(CONF_HOST) + data[CONF_API_TOKEN] = data.pop(CONF_API_KEY) + hass.config_entries.async_update_entry(entry=entry, data=data, version=2) + + return True diff --git a/homeassistant/components/portainer/config_flow.py b/homeassistant/components/portainer/config_flow.py index 2fc4f3a722a2a7..b7cb0ba8b990ce 100644 --- a/homeassistant/components/portainer/config_flow.py +++ b/homeassistant/components/portainer/config_flow.py @@ -14,7 +14,7 @@ import voluptuous as vol from homeassistant.config_entries import ConfigFlow, ConfigFlowResult -from homeassistant.const import CONF_API_KEY, CONF_HOST, CONF_VERIFY_SSL +from homeassistant.const import CONF_API_TOKEN, CONF_URL, CONF_VERIFY_SSL from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.aiohttp_client import async_get_clientsession @@ -24,8 +24,8 @@ _LOGGER = logging.getLogger(__name__) STEP_USER_DATA_SCHEMA = vol.Schema( { - vol.Required(CONF_HOST): str, - vol.Required(CONF_API_KEY): str, + vol.Required(CONF_URL): str, + vol.Required(CONF_API_TOKEN): str, vol.Optional(CONF_VERIFY_SSL, default=True): bool, } ) @@ -35,9 +35,11 @@ async def _validate_input(hass: HomeAssistant, data: dict[str, Any]) -> None: """Validate the user input allows us to connect.""" client = Portainer( - api_url=data[CONF_HOST], - api_key=data[CONF_API_KEY], - session=async_get_clientsession(hass=hass, verify_ssl=data[CONF_VERIFY_SSL]), + api_url=data[CONF_URL], + api_key=data[CONF_API_TOKEN], + session=async_get_clientsession( + hass=hass, verify_ssl=data.get(CONF_VERIFY_SSL, True) + ), ) try: await client.get_endpoints() @@ -48,19 +50,21 @@ async def _validate_input(hass: HomeAssistant, data: dict[str, Any]) -> None: except PortainerTimeoutError as err: raise PortainerTimeout from err - _LOGGER.debug("Connected to Portainer API: %s", data[CONF_HOST]) + _LOGGER.debug("Connected to Portainer API: %s", data[CONF_URL]) class PortainerConfigFlow(ConfigFlow, domain=DOMAIN): """Handle a config flow for Portainer.""" + VERSION = 2 + async def async_step_user( self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: """Handle the initial step.""" errors: dict[str, str] = {} if user_input is not None: - self._async_abort_entries_match({CONF_HOST: user_input[CONF_HOST]}) + self._async_abort_entries_match({CONF_URL: user_input[CONF_URL]}) try: await _validate_input(self.hass, user_input) except CannotConnect: @@ -73,10 +77,10 @@ async def async_step_user( _LOGGER.exception("Unexpected exception") errors["base"] = "unknown" else: - await self.async_set_unique_id(user_input[CONF_API_KEY]) + await self.async_set_unique_id(user_input[CONF_API_TOKEN]) self._abort_if_unique_id_configured() return self.async_create_entry( - title=user_input[CONF_HOST], data=user_input + title=user_input[CONF_URL], data=user_input ) return self.async_show_form( diff --git a/homeassistant/components/portainer/coordinator.py b/homeassistant/components/portainer/coordinator.py index 988ae319bab146..378f5f342811ac 100644 --- a/homeassistant/components/portainer/coordinator.py +++ b/homeassistant/components/portainer/coordinator.py @@ -16,7 +16,7 @@ from pyportainer.models.portainer import Endpoint from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_HOST +from homeassistant.const import CONF_URL from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryError, ConfigEntryNotReady from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed @@ -87,7 +87,7 @@ async def _async_setup(self) -> None: async def _async_update_data(self) -> dict[int, PortainerCoordinatorData]: """Fetch data from Portainer API.""" _LOGGER.debug( - "Fetching data from Portainer API: %s", self.config_entry.data[CONF_HOST] + "Fetching data from Portainer API: %s", self.config_entry.data[CONF_URL] ) try: diff --git a/homeassistant/components/portainer/strings.json b/homeassistant/components/portainer/strings.json index acdd0d362a3bb2..083a6763b40fe1 100644 --- a/homeassistant/components/portainer/strings.json +++ b/homeassistant/components/portainer/strings.json @@ -3,16 +3,16 @@ "step": { "user": { "data": { - "host": "[%key:common::config_flow::data::host%]", - "api_key": "[%key:common::config_flow::data::api_key%]", + "url": "[%key:common::config_flow::data::url%]", + "api_token": "[%key:common::config_flow::data::api_token%]", "verify_ssl": "[%key:common::config_flow::data::verify_ssl%]" }, "data_description": { - "host": "The host/URL, including the port, of your Portainer instance", - "api_key": "The API key for authenticating with Portainer", + "url": "The URL, including the port, of your Portainer instance", + "api_token": "The API access token for authenticating with Portainer", "verify_ssl": "Whether to verify SSL certificates. Disable only if you have a self-signed certificate" }, - "description": "You can create an API key in the Portainer UI. Go to **My account > API keys** and select **Add API key**" + "description": "You can create an access token in the Portainer UI. Go to **My account > Access tokens** and select **Add access token**" } }, "error": { diff --git a/homeassistant/components/squeezebox/browse_media.py b/homeassistant/components/squeezebox/browse_media.py index 436308a8920d6b..2ca9d6f058c9db 100644 --- a/homeassistant/components/squeezebox/browse_media.py +++ b/homeassistant/components/squeezebox/browse_media.py @@ -5,7 +5,7 @@ import contextlib from dataclasses import dataclass, field import logging -from typing import Any +from typing import TYPE_CHECKING, Any, cast from pysqueezebox import Player @@ -14,7 +14,6 @@ BrowseError, BrowseMedia, MediaClass, - MediaPlayerEntity, MediaType, ) from homeassistant.core import HomeAssistant @@ -22,6 +21,9 @@ from .const import DOMAIN, UNPLAYABLE_TYPES +if TYPE_CHECKING: + from .media_player import SqueezeBoxMediaPlayerEntity + _LOGGER = logging.getLogger(__name__) LIBRARY = [ @@ -244,14 +246,13 @@ def _build_response_favorites(item: dict[str, Any]) -> BrowseMedia: def _get_item_thumbnail( item: dict[str, Any], player: Player, - entity: MediaPlayerEntity, + entity: SqueezeBoxMediaPlayerEntity, item_type: str | MediaType | None, search_type: str, internal_request: bool, known_apps_radios: set[str], ) -> str | None: """Construct path to thumbnail image.""" - item_thumbnail: str | None = None track_id = item.get("artwork_track_id") or ( item.get("id") @@ -262,21 +263,27 @@ def _get_item_thumbnail( if track_id: if internal_request: - item_thumbnail = player.generate_image_url_from_track_id(track_id) - elif item_type is not None: - item_thumbnail = entity.get_browse_image_url( - item_type, item["id"], track_id - ) + return cast(str, player.generate_image_url_from_track_id(track_id)) + if item_type is not None: + return entity.get_browse_image_url(item_type, item["id"], track_id) + + url = None + content_type = item_type or "unknown" + + if search_type in ["apps", "radios"]: + url = cast(str, player.generate_image_url(item["icon"])) + elif image_url := item.get("image_url"): + url = image_url + + if internal_request or not url: + return url - elif search_type in ["apps", "radios"]: - item_thumbnail = player.generate_image_url(item["icon"]) - if item_thumbnail is None: - item_thumbnail = item.get("image_url") # will not be proxied by HA - return item_thumbnail + synthetic_id = entity.get_synthetic_id_and_cache_url(url) + return entity.get_browse_image_url(content_type, "synthetic", synthetic_id) async def build_item_response( - entity: MediaPlayerEntity, + entity: SqueezeBoxMediaPlayerEntity, player: Player, payload: dict[str, str | None], browse_limit: int, diff --git a/homeassistant/components/squeezebox/media_player.py b/homeassistant/components/squeezebox/media_player.py index d1313eccc37bda..0b9b54a1dcddbf 100644 --- a/homeassistant/components/squeezebox/media_player.py +++ b/homeassistant/components/squeezebox/media_player.py @@ -8,6 +8,7 @@ import logging from typing import TYPE_CHECKING, Any, cast +from lru import LRU from pysqueezebox import Server, async_discover import voluptuous as vol @@ -43,6 +44,7 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.start import async_at_start from homeassistant.util.dt import utcnow +from homeassistant.util.ulid import ulid_now from . import SQUEEZEBOX_HASS_DATA from .browse_media import ( @@ -260,6 +262,7 @@ def __init__(self, coordinator: SqueezeBoxPlayerUpdateCoordinator) -> None: self._previous_media_position = 0 self._attr_unique_id = format_mac(self._player.player_id) self._browse_data = BrowseData() + self._synthetic_media_browser_thumbnail_items: LRU[str, str] = LRU(5000) @callback def _handle_coordinator_update(self) -> None: @@ -742,6 +745,17 @@ async def async_unjoin_player(self) -> None: await self._player.async_unsync() await self.coordinator.async_refresh() + def get_synthetic_id_and_cache_url(self, url: str) -> str: + """Cache a thumbnail URL and return a synthetic ID. + + This enables us to proxy thumbnails for apps and favorites, as those do not have IDs. + """ + synthetic_id = f"s_{ulid_now()}" + + self._synthetic_media_browser_thumbnail_items[synthetic_id] = url + + return synthetic_id + async def async_browse_media( self, media_content_type: MediaType | str | None = None, @@ -785,11 +799,21 @@ async def async_get_browse_image( media_image_id: str | None = None, ) -> tuple[bytes | None, str | None]: """Get album art from Squeezebox server.""" - if media_image_id: + if not media_image_id: + return (None, None) + + if media_content_id == "synthetic": + image_url = self._synthetic_media_browser_thumbnail_items.get( + media_image_id + ) + + if image_url is None: + _LOGGER.debug("Synthetic ID %s not found in cache", media_image_id) + return (None, None) + else: image_url = self._player.generate_image_url_from_track_id(media_image_id) - result = await self._async_fetch_image(image_url) - if result == (None, None): - _LOGGER.debug("Error retrieving proxied album art from %s", image_url) - return result - return (None, None) + result = await self._async_fetch_image(image_url) + if result == (None, None): + _LOGGER.debug("Error retrieving proxied album art from %s", image_url) + return result diff --git a/homeassistant/components/volvo/sensor.py b/homeassistant/components/volvo/sensor.py index 13614ff2830239..f104fabf83b75e 100644 --- a/homeassistant/components/volvo/sensor.py +++ b/homeassistant/components/volvo/sensor.py @@ -354,13 +354,19 @@ async def async_setup_entry( async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up sensors.""" + + entities: dict[str, VolvoSensor] = {} coordinators = entry.runtime_data.interval_coordinators - async_add_entities( - VolvoSensor(coordinator, description) - for coordinator in coordinators - for description in _DESCRIPTIONS - if description.api_field in coordinator.data - ) + + for coordinator in coordinators: + for description in _DESCRIPTIONS: + if description.key in entities: + continue + + if description.api_field in coordinator.data: + entities[description.key] = VolvoSensor(coordinator, description) + + async_add_entities(entities.values()) class VolvoSensor(VolvoEntity, SensorEntity): diff --git a/homeassistant/components/workday/config_flow.py b/homeassistant/components/workday/config_flow.py index 20d9040e527a43..f3b139b27c0729 100644 --- a/homeassistant/components/workday/config_flow.py +++ b/homeassistant/components/workday/config_flow.py @@ -155,6 +155,7 @@ def validate_custom_dates(user_input: dict[str, Any]) -> None: subdiv=province, years=year, language=language, + categories=[PUBLIC, *user_input.get(CONF_CATEGORY, [])], ) else: diff --git a/tests/components/imap/const.py b/tests/components/imap/const.py index 8f6761bd79511c..5ddf86153cb2d8 100644 --- a/tests/components/imap/const.py +++ b/tests/components/imap/const.py @@ -27,6 +27,9 @@ TEST_MULTIPART_HEADER = ( b'Content-Type: multipart/related;\r\n\tboundary="Mark=_100584970350292485166"' ) +TEST_MULTIPART_ATTACHMENT_HEADER = ( + b'Content-Type: multipart/mixed; boundary="------------qIuh0xG6dsImymfJo6f2M4Zv"' +) TEST_MESSAGE_HEADERS3 = b"" @@ -36,6 +39,13 @@ TEST_MESSAGE_HEADERS1 + DATE_HEADER1 + TEST_MESSAGE_HEADERS2 + TEST_MULTIPART_HEADER ) +TEST_MESSAGE_MULTIPART_ATTACHMENT = ( + TEST_MESSAGE_HEADERS1 + + DATE_HEADER1 + + TEST_MESSAGE_HEADERS2 + + TEST_MULTIPART_ATTACHMENT_HEADER +) + TEST_MESSAGE_NO_SUBJECT_TO_FROM = ( TEST_MESSAGE_HEADERS1 + DATE_HEADER1 + TEST_MESSAGE_HEADERS3 ) @@ -140,6 +150,45 @@ + b"\r\n--Mark=_100584970350292485166--\r\n" ) +TEST_CONTENT_MULTIPART_WITH_ATTACHMENT = b""" +\nThis is a multi-part message in MIME format. +--------------qIuh0xG6dsImymfJo6f2M4Zv +Content-Type: multipart/alternative; + boundary="------------N4zNjp2QWnOfrYQhtLL02Bk1" + +--------------N4zNjp2QWnOfrYQhtLL02Bk1 +Content-Type: text/plain; charset=UTF-8; format=flowed +Content-Transfer-Encoding: 7bit + +*Multi* part Test body + +--------------N4zNjp2QWnOfrYQhtLL02Bk1 +Content-Type: text/html; charset=UTF-8 +Content-Transfer-Encoding: 7bit + + + +
+ + + + +Multi part Test body
+ + + +--------------N4zNjp2QWnOfrYQhtLL02Bk1-- +--------------qIuh0xG6dsImymfJo6f2M4Zv +Content-Type: text/plain; charset=UTF-8; name="Text attachment content.txt" +Content-Disposition: attachment; filename="Text attachment content.txt" +Content-Transfer-Encoding: base64 + +VGV4dCBhdHRhY2htZW50IGNvbnRlbnQ= + +--------------qIuh0xG6dsImymfJo6f2M4Zv-- +""" + + EMPTY_SEARCH_RESPONSE = ("OK", [b"", b"Search completed (0.0001 + 0.000 secs)."]) EMPTY_SEARCH_RESPONSE_ALT = ("OK", [b"Search completed (0.0001 + 0.000 secs)."]) @@ -303,6 +352,24 @@ b"Fetch completed (0.0001 + 0.000 secs).", ], ) +TEST_FETCH_RESPONSE_MULTIPART_WITH_ATTACHMENT = ( + "OK", + [ + b"1 FETCH (BODY[] {" + + str( + len( + TEST_MESSAGE_MULTIPART_ATTACHMENT + + TEST_CONTENT_MULTIPART_WITH_ATTACHMENT + ) + ).encode("utf-8") + + b"}", + bytearray( + TEST_MESSAGE_MULTIPART_ATTACHMENT + TEST_CONTENT_MULTIPART_WITH_ATTACHMENT + ), + b")", + b"Fetch completed (0.0001 + 0.000 secs).", + ], +) TEST_FETCH_RESPONSE_MULTIPART_BASE64_INVALID = ( "OK", diff --git a/tests/components/imap/test_init.py b/tests/components/imap/test_init.py index bdd29f7442b004..dc5727991c18fc 100644 --- a/tests/components/imap/test_init.py +++ b/tests/components/imap/test_init.py @@ -1,6 +1,7 @@ """Test the imap entry initialization.""" import asyncio +from base64 import b64decode from datetime import datetime, timedelta, timezone from typing import Any from unittest.mock import AsyncMock, MagicMock, call, patch @@ -31,6 +32,7 @@ TEST_FETCH_RESPONSE_MULTIPART_BASE64, TEST_FETCH_RESPONSE_MULTIPART_BASE64_INVALID, TEST_FETCH_RESPONSE_MULTIPART_EMPTY_PLAIN, + TEST_FETCH_RESPONSE_MULTIPART_WITH_ATTACHMENT, TEST_FETCH_RESPONSE_NO_SUBJECT_TO_FROM, TEST_FETCH_RESPONSE_TEXT_BARE, TEST_FETCH_RESPONSE_TEXT_OTHER, @@ -107,20 +109,72 @@ async def test_entry_startup_fails( @pytest.mark.parametrize("imap_search", [TEST_SEARCH_RESPONSE]) @pytest.mark.parametrize( - ("imap_fetch", "valid_date"), + ("imap_fetch", "valid_date", "parts"), [ - (TEST_FETCH_RESPONSE_TEXT_BARE, True), - (TEST_FETCH_RESPONSE_TEXT_PLAIN, True), - (TEST_FETCH_RESPONSE_TEXT_PLAIN_ALT, True), - (TEST_FETCH_RESPONSE_INVALID_DATE1, False), - (TEST_FETCH_RESPONSE_INVALID_DATE2, False), - (TEST_FETCH_RESPONSE_INVALID_DATE3, False), - (TEST_FETCH_RESPONSE_TEXT_OTHER, True), - (TEST_FETCH_RESPONSE_HTML, True), - (TEST_FETCH_RESPONSE_MULTIPART, True), - (TEST_FETCH_RESPONSE_MULTIPART_EMPTY_PLAIN, True), - (TEST_FETCH_RESPONSE_MULTIPART_BASE64, True), - (TEST_FETCH_RESPONSE_BINARY, True), + (TEST_FETCH_RESPONSE_TEXT_BARE, True, {}), + (TEST_FETCH_RESPONSE_TEXT_PLAIN, True, {}), + (TEST_FETCH_RESPONSE_TEXT_PLAIN_ALT, True, {}), + (TEST_FETCH_RESPONSE_INVALID_DATE1, False, {}), + (TEST_FETCH_RESPONSE_INVALID_DATE2, False, {}), + (TEST_FETCH_RESPONSE_INVALID_DATE3, False, {}), + (TEST_FETCH_RESPONSE_TEXT_OTHER, True, {}), + (TEST_FETCH_RESPONSE_HTML, True, {}), + ( + TEST_FETCH_RESPONSE_MULTIPART, + True, + { + "0": { + "content_type": "text/plain", + "content_transfer_encoding": "7bit", + }, + "1": {"content_type": "text/html", "content_transfer_encoding": "7bit"}, + }, + ), + ( + TEST_FETCH_RESPONSE_MULTIPART_EMPTY_PLAIN, + True, + { + "0": { + "content_type": "text/plain", + "content_transfer_encoding": "7bit", + }, + "1": {"content_type": "text/html", "content_transfer_encoding": "7bit"}, + }, + ), + ( + TEST_FETCH_RESPONSE_MULTIPART_BASE64, + True, + { + "0": { + "content_type": "text/plain", + "content_transfer_encoding": "base64", + }, + "1": { + "content_type": "text/html", + "content_transfer_encoding": "base64", + }, + }, + ), + ( + TEST_FETCH_RESPONSE_MULTIPART_WITH_ATTACHMENT, + True, + { + "0,0": { + "content_type": "text/plain", + "content_transfer_encoding": "7bit", + }, + "0,1": { + "content_type": "text/html", + "content_transfer_encoding": "7bit", + }, + "1": { + "content_type": "text/plain", + "filename": "Text attachment content.txt", + "content_transfer_encoding": "base64", + }, + }, + ), + (TEST_FETCH_RESPONSE_BINARY, True, {}), ], ids=[ "bare", @@ -134,13 +188,18 @@ async def test_entry_startup_fails( "multipart", "multipart_empty_plain", "multipart_base64", + "multipart_attachment", "binary", ], ) @pytest.mark.parametrize("imap_has_capability", [True, False], ids=["push", "poll"]) @pytest.mark.parametrize("charset", ["utf-8", "us-ascii"], ids=["utf-8", "us-ascii"]) async def test_receiving_message_successfully( - hass: HomeAssistant, mock_imap_protocol: MagicMock, valid_date: bool, charset: str + hass: HomeAssistant, + mock_imap_protocol: MagicMock, + valid_date: bool, + charset: str, + parts: dict[str, Any], ) -> None: """Test receiving a message successfully.""" event_called = async_capture_events(hass, "imap_content") @@ -170,6 +229,7 @@ async def test_receiving_message_successfully( assert data["sender"] == "john.doe@example.com" assert data["subject"] == "Test subject" assert data["uid"] == "1" + assert data["parts"] == parts assert "Test body" in data["text"] assert (valid_date and isinstance(data["date"], datetime)) or ( not valid_date and data["date"] is None @@ -826,11 +886,33 @@ async def test_enforce_polling( @pytest.mark.parametrize( - ("imap_search", "imap_fetch"), - [(TEST_SEARCH_RESPONSE, TEST_FETCH_RESPONSE_TEXT_PLAIN)], + ("imap_search", "imap_fetch", "message_parts"), + [ + ( + TEST_SEARCH_RESPONSE, + TEST_FETCH_RESPONSE_MULTIPART_WITH_ATTACHMENT, + { + "0,0": { + "content_type": "text/plain", + "content_transfer_encoding": "7bit", + }, + "0,1": { + "content_type": "text/html", + "content_transfer_encoding": "7bit", + }, + "1": { + "content_type": "text/plain", + "filename": "Text attachment content.txt", + "content_transfer_encoding": "base64", + }, + }, + ) + ], ) @pytest.mark.parametrize("imap_has_capability", [True, False], ids=["push", "poll"]) -async def test_services(hass: HomeAssistant, mock_imap_protocol: MagicMock) -> None: +async def test_services( + hass: HomeAssistant, mock_imap_protocol: MagicMock, message_parts: dict[str, Any] +) -> None: """Test receiving a message successfully.""" event_called = async_capture_events(hass, "imap_content") @@ -859,6 +941,7 @@ async def test_services(hass: HomeAssistant, mock_imap_protocol: MagicMock) -> N assert data["subject"] == "Test subject" assert data["uid"] == "1" assert data["entry_id"] == config_entry.entry_id + assert data["parts"] == message_parts # Test seen service data = {"entry": config_entry.entry_id, "uid": "1"} @@ -889,16 +972,42 @@ async def test_services(hass: HomeAssistant, mock_imap_protocol: MagicMock) -> N mock_imap_protocol.store.assert_called_with("1", "+FLAGS (\\Deleted)") mock_imap_protocol.protocol.expunge.assert_called_once() - # Test fetch service + # Test fetch service with text response + mock_imap_protocol.reset_mock() data = {"entry": config_entry.entry_id, "uid": "1"} response = await hass.services.async_call( DOMAIN, "fetch", data, blocking=True, return_response=True ) mock_imap_protocol.fetch.assert_called_with("1", "BODY.PEEK[]") - assert response["text"] == "Test body\r\n" + assert response["text"] == "*Multi* part Test body\n" assert response["sender"] == "john.doe@example.com" assert response["subject"] == "Test subject" assert response["uid"] == "1" + assert response["parts"] == message_parts + + # Test fetch part service with attachment response + mock_imap_protocol.reset_mock() + data = {"entry": config_entry.entry_id, "uid": "1", "part": "1"} + response = await hass.services.async_call( + DOMAIN, "fetch_part", data, blocking=True, return_response=True + ) + mock_imap_protocol.fetch.assert_called_with("1", "BODY.PEEK[]") + assert response["part_data"] == "VGV4dCBhdHRhY2htZW50IGNvbnRlbnQ=\n" + assert response["content_type"] == "text/plain" + assert response["content_transfer_encoding"] == "base64" + assert response["filename"] == "Text attachment content.txt" + assert response["part"] == "1" + assert response["uid"] == "1" + assert b64decode(response["part_data"]) == b"Text attachment content" + + # Test fetch part service with invalid part index + for part in ("A", "2", "0"): + data = {"entry": config_entry.entry_id, "uid": "1", "part": part} + with pytest.raises(ServiceValidationError) as exc: + await hass.services.async_call( + DOMAIN, "fetch_part", data, blocking=True, return_response=True + ) + assert exc.value.translation_key == "invalid_part_index" # Test with invalid entry_id data = {"entry": "invalid", "uid": "1"} @@ -943,12 +1052,14 @@ async def test_services(hass: HomeAssistant, mock_imap_protocol: MagicMock) -> N ), "delete": ({"entry": config_entry.entry_id, "uid": "1"}, False), "fetch": ({"entry": config_entry.entry_id, "uid": "1"}, True), + "fetch_part": ({"entry": config_entry.entry_id, "uid": "1", "part": "1"}, True), } patch_error_translation_key = { "seen": ("store", "seen_failed"), "move": ("copy", "copy_failed"), "delete": ("store", "delete_failed"), "fetch": ("fetch", "fetch_failed"), + "fetch_part": ("fetch", "fetch_failed"), } for service, (data, response) in service_calls_response.items(): with ( diff --git a/tests/components/portainer/conftest.py b/tests/components/portainer/conftest.py index d6127c4344020b..21298da10484f1 100644 --- a/tests/components/portainer/conftest.py +++ b/tests/components/portainer/conftest.py @@ -8,13 +8,13 @@ import pytest from homeassistant.components.portainer.const import DOMAIN -from homeassistant.const import CONF_API_KEY, CONF_HOST, CONF_VERIFY_SSL +from homeassistant.const import CONF_API_TOKEN, CONF_URL, CONF_VERIFY_SSL from tests.common import MockConfigEntry, load_json_array_fixture MOCK_TEST_CONFIG = { - CONF_HOST: "https://127.0.0.1:9000/", - CONF_API_KEY: "test_api_key", + CONF_URL: "https://127.0.0.1:9000/", + CONF_API_TOKEN: "test_api_token", CONF_VERIFY_SSL: True, } @@ -61,4 +61,5 @@ def mock_config_entry() -> MockConfigEntry: title="Portainer test", data=MOCK_TEST_CONFIG, entry_id="portainer_test_entry_123", + version=2, ) diff --git a/tests/components/portainer/test_config_flow.py b/tests/components/portainer/test_config_flow.py index 50115398c79b84..a2806b53041818 100644 --- a/tests/components/portainer/test_config_flow.py +++ b/tests/components/portainer/test_config_flow.py @@ -11,7 +11,7 @@ from homeassistant.components.portainer.const import DOMAIN from homeassistant.config_entries import SOURCE_USER -from homeassistant.const import CONF_API_KEY, CONF_HOST +from homeassistant.const import CONF_API_TOKEN, CONF_URL, CONF_VERIFY_SSL from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType @@ -20,8 +20,9 @@ from tests.common import MockConfigEntry MOCK_USER_SETUP = { - CONF_HOST: "https://127.0.0.1:9000/", - CONF_API_KEY: "test_api_key", + CONF_URL: "https://127.0.0.1:9000/", + CONF_API_TOKEN: "test_api_token", + CONF_VERIFY_SSL: True, } diff --git a/tests/components/portainer/test_init.py b/tests/components/portainer/test_init.py index 8c82208752e6b0..00b4d5940e935e 100644 --- a/tests/components/portainer/test_init.py +++ b/tests/components/portainer/test_init.py @@ -9,7 +9,9 @@ ) import pytest +from homeassistant.components.portainer.const import DOMAIN from homeassistant.config_entries import ConfigEntryState +from homeassistant.const import CONF_API_KEY, CONF_API_TOKEN, CONF_HOST, CONF_URL from homeassistant.core import HomeAssistant from . import setup_integration @@ -36,3 +38,25 @@ async def test_setup_exceptions( mock_portainer_client.get_endpoints.side_effect = exception await setup_integration(hass, mock_config_entry) assert mock_config_entry.state == expected_state + + +async def test_v1_migration(hass: HomeAssistant) -> None: + """Test migration from v1 to v2 config entry.""" + entry = MockConfigEntry( + domain=DOMAIN, + data={ + CONF_HOST: "http://test_host", + CONF_API_KEY: "test_key", + }, + unique_id="1", + version=1, + ) + entry.add_to_hass(hass) + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + assert entry.version == 2 + assert CONF_HOST not in entry.data + assert CONF_API_KEY not in entry.data + assert entry.data[CONF_URL] == "http://test_host" + assert entry.data[CONF_API_TOKEN] == "test_key" diff --git a/tests/components/squeezebox/test_media_browser.py b/tests/components/squeezebox/test_media_browser.py index 093e4f186d406b..ee0cfaf501579c 100644 --- a/tests/components/squeezebox/test_media_browser.py +++ b/tests/components/squeezebox/test_media_browser.py @@ -428,3 +428,35 @@ async def test_play_browse_item_bad_category( }, blocking=True, ) + + +async def test_synthetic_thumbnail_item_ids( + hass: HomeAssistant, + config_entry: MockConfigEntry, + hass_ws_client: WebSocketGenerator, +) -> None: + """Test synthetic ID generation and url caching for items without stable IDs.""" + with patch( + "homeassistant.components.squeezebox.browse_media.is_internal_request", + return_value=False, + ): + client = await hass_ws_client() + + await client.send_json( + { + "id": 1, + "type": "media_player/browse_media", + "entity_id": "media_player.test_player", + "media_content_id": "", + "media_content_type": "apps", + } + ) + response = await client.receive_json() + assert response["success"] + + children = response["result"]["children"] + assert len(children) > 0 + for child in children: + if thumbnail := child.get("thumbnail"): + assert not thumbnail.startswith("http://lms.internal") + assert thumbnail.startswith("/api/media_player_proxy/") diff --git a/tests/components/volvo/__init__.py b/tests/components/volvo/__init__.py index acd608b8d2626f..39eba5c702c5bc 100644 --- a/tests/components/volvo/__init__.py +++ b/tests/components/volvo/__init__.py @@ -27,6 +27,12 @@ "vehicle", ], "xc90_petrol_2019": ["commands", "statistics", "vehicle"], + "xc90_phev_2024": [ + "energy_capabilities", + "energy_state", + "statistics", + "vehicle", + ], } diff --git a/tests/components/volvo/fixtures/xc90_phev_2024/energy_capabilities.json b/tests/components/volvo/fixtures/xc90_phev_2024/energy_capabilities.json new file mode 100644 index 00000000000000..c7a3cdea8c78e7 --- /dev/null +++ b/tests/components/volvo/fixtures/xc90_phev_2024/energy_capabilities.json @@ -0,0 +1,33 @@ +{ + "isSupported": true, + "batteryChargeLevel": { + "isSupported": true + }, + "electricRange": { + "isSupported": true + }, + "chargerConnectionStatus": { + "isSupported": true + }, + "chargingSystemStatus": { + "isSupported": true + }, + "chargingType": { + "isSupported": true + }, + "chargerPowerStatus": { + "isSupported": true + }, + "estimatedChargingTimeToTargetBatteryChargeLevel": { + "isSupported": true + }, + "targetBatteryChargeLevel": { + "isSupported": true + }, + "chargingCurrentLimit": { + "isSupported": false + }, + "chargingPower": { + "isSupported": false + } +} diff --git a/tests/components/volvo/fixtures/xc90_phev_2024/energy_state.json b/tests/components/volvo/fixtures/xc90_phev_2024/energy_state.json new file mode 100644 index 00000000000000..43cecce6c43a66 --- /dev/null +++ b/tests/components/volvo/fixtures/xc90_phev_2024/energy_state.json @@ -0,0 +1,55 @@ +{ + "batteryChargeLevel": { + "status": "OK", + "value": 87.3, + "unit": "percentage", + "updatedAt": "2025-09-05T07:58:14Z" + }, + "electricRange": { + "status": "OK", + "value": 26, + "unit": "miles", + "updatedAt": "2025-09-05T07:58:14Z" + }, + "chargerConnectionStatus": { + "status": "OK", + "value": "DISCONNECTED", + "updatedAt": "2025-09-05T07:58:14Z" + }, + "chargingStatus": { + "status": "OK", + "value": "IDLE", + "updatedAt": "2025-09-05T07:58:14Z" + }, + "chargingType": { + "status": "OK", + "value": "NONE", + "updatedAt": "2025-09-05T07:58:14Z" + }, + "chargerPowerStatus": { + "status": "OK", + "value": "NO_POWER_AVAILABLE", + "updatedAt": "2025-09-05T07:58:14Z" + }, + "estimatedChargingTimeToTargetBatteryChargeLevel": { + "status": "OK", + "value": 0, + "unit": "minutes", + "updatedAt": "2025-09-05T07:58:14Z" + }, + "chargingCurrentLimit": { + "status": "ERROR", + "code": "NOT_SUPPORTED", + "message": "Resource is not supported for this vehicle" + }, + "targetBatteryChargeLevel": { + "status": "ERROR", + "code": "ERROR_READING_PROPERTY", + "message": "Failed to retrieve property." + }, + "chargingPower": { + "status": "ERROR", + "code": "NOT_SUPPORTED", + "message": "Resource is not supported for this vehicle" + } +} diff --git a/tests/components/volvo/fixtures/xc90_phev_2024/statistics.json b/tests/components/volvo/fixtures/xc90_phev_2024/statistics.json new file mode 100644 index 00000000000000..41da31d0519973 --- /dev/null +++ b/tests/components/volvo/fixtures/xc90_phev_2024/statistics.json @@ -0,0 +1,47 @@ +{ + "averageFuelConsumption": { + "value": 2.0, + "unit": "l/100km", + "timestamp": "2025-09-04T18:03:57.437Z" + }, + "averageEnergyConsumption": { + "value": 19.9, + "unit": "kWh/100km", + "timestamp": "2025-09-04T18:03:57.437Z" + }, + "averageFuelConsumptionAutomatic": { + "value": 0.0, + "unit": "l/100km", + "timestamp": "2025-09-04T18:03:57.437Z" + }, + "averageSpeed": { + "value": 47, + "unit": "km/h", + "timestamp": "2025-09-04T18:03:57.437Z" + }, + "averageSpeedAutomatic": { + "value": 37, + "unit": "km/h", + "timestamp": "2025-09-04T18:03:57.437Z" + }, + "tripMeterManual": { + "value": 5935.8, + "unit": "km", + "timestamp": "2025-09-04T18:03:57.437Z" + }, + "tripMeterAutomatic": { + "value": 23.7, + "unit": "km", + "timestamp": "2025-09-04T18:03:57.437Z" + }, + "distanceToEmptyTank": { + "value": 804, + "unit": "km", + "timestamp": "2025-09-04T18:03:57.437Z" + }, + "distanceToEmptyBattery": { + "value": 43, + "unit": "km", + "timestamp": "2025-09-05T07:58:14.760Z" + } +} diff --git a/tests/components/volvo/fixtures/xc90_phev_2024/vehicle.json b/tests/components/volvo/fixtures/xc90_phev_2024/vehicle.json new file mode 100644 index 00000000000000..63ea7c965f5129 --- /dev/null +++ b/tests/components/volvo/fixtures/xc90_phev_2024/vehicle.json @@ -0,0 +1,17 @@ +{ + "vin": "YV1ABCDEFG1234567", + "modelYear": 2024, + "gearbox": "AUTOMATIC", + "fuelType": "PETROL/ELECTRIC", + "externalColour": "Crystal White Pearl", + "batteryCapacityKWH": 18.819, + "images": { + "exteriorImageUrl": "https://cas.volvocars.com/image/dynamic/MY24_0000/123/_/default.png?market=se&client=public-api-engineering&angle=1&bg=00000000&w=1920", + "internalImageUrl": "https://cas.volvocars.com/image/dynamic/MY24_0000/123/_/default.jpg?market=se&client=public-api-engineering&angle=0&w=1920" + }, + "descriptions": { + "model": "XC90", + "upholstery": "null", + "steering": "LEFT" + } +} diff --git a/tests/components/volvo/snapshots/test_sensor.ambr b/tests/components/volvo/snapshots/test_sensor.ambr index 9d709a27fc3d8c..a8c1f10357a7e4 100644 --- a/tests/components/volvo/snapshots/test_sensor.ambr +++ b/tests/components/volvo/snapshots/test_sensor.ambr @@ -4779,3 +4779,1313 @@ 'state': '178.9', }) # --- +# name: test_sensor[xc90_phev_2024][sensor.volvo_xc90_battery-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class':