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': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.volvo_xc90_battery', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Battery', + 'platform': 'volvo', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'yv1abcdefg1234567_battery_charge_level', + 'unit_of_measurement': '%', + }) +# --- +# name: test_sensor[xc90_phev_2024][sensor.volvo_xc90_battery-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'battery', + 'friendly_name': 'Volvo XC90 Battery', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.volvo_xc90_battery', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '87.3', + }) +# --- +# name: test_sensor[xc90_phev_2024][sensor.volvo_xc90_battery_capacity-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.volvo_xc90_battery_capacity', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Battery capacity', + 'platform': 'volvo', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'battery_capacity', + 'unique_id': 'yv1abcdefg1234567_battery_capacity', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[xc90_phev_2024][sensor.volvo_xc90_battery_capacity-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy_storage', + 'friendly_name': 'Volvo XC90 Battery capacity', + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.volvo_xc90_battery_capacity', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '18.819', + }) +# --- +# name: test_sensor[xc90_phev_2024][sensor.volvo_xc90_car_connection-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'available', + 'car_in_use', + 'no_internet', + 'ota_installation_in_progress', + 'power_saving_mode', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.volvo_xc90_car_connection', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Car connection', + 'platform': 'volvo', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'availability', + 'unique_id': 'yv1abcdefg1234567_availability', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor[xc90_phev_2024][sensor.volvo_xc90_car_connection-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'enum', + 'friendly_name': 'Volvo XC90 Car connection', + 'options': list([ + 'available', + 'car_in_use', + 'no_internet', + 'ota_installation_in_progress', + 'power_saving_mode', + ]), + }), + 'context': , + 'entity_id': 'sensor.volvo_xc90_car_connection', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'available', + }) +# --- +# name: test_sensor[xc90_phev_2024][sensor.volvo_xc90_charging_connection_status-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'connected', + 'disconnected', + 'fault', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.volvo_xc90_charging_connection_status', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Charging connection status', + 'platform': 'volvo', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'charger_connection_status', + 'unique_id': 'yv1abcdefg1234567_charger_connection_status', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor[xc90_phev_2024][sensor.volvo_xc90_charging_connection_status-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'enum', + 'friendly_name': 'Volvo XC90 Charging connection status', + 'options': list([ + 'connected', + 'disconnected', + 'fault', + ]), + }), + 'context': , + 'entity_id': 'sensor.volvo_xc90_charging_connection_status', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'disconnected', + }) +# --- +# name: test_sensor[xc90_phev_2024][sensor.volvo_xc90_charging_power_status-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'fault', + 'power_available_but_not_activated', + 'providing_power', + 'no_power_available', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.volvo_xc90_charging_power_status', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Charging power status', + 'platform': 'volvo', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'charging_power_status', + 'unique_id': 'yv1abcdefg1234567_charging_power_status', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor[xc90_phev_2024][sensor.volvo_xc90_charging_power_status-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'enum', + 'friendly_name': 'Volvo XC90 Charging power status', + 'options': list([ + 'fault', + 'power_available_but_not_activated', + 'providing_power', + 'no_power_available', + ]), + }), + 'context': , + 'entity_id': 'sensor.volvo_xc90_charging_power_status', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'no_power_available', + }) +# --- +# name: test_sensor[xc90_phev_2024][sensor.volvo_xc90_charging_status-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'charging', + 'discharging', + 'done', + 'error', + 'idle', + 'scheduled', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.volvo_xc90_charging_status', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Charging status', + 'platform': 'volvo', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'charging_status', + 'unique_id': 'yv1abcdefg1234567_charging_status', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor[xc90_phev_2024][sensor.volvo_xc90_charging_status-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'enum', + 'friendly_name': 'Volvo XC90 Charging status', + 'options': list([ + 'charging', + 'discharging', + 'done', + 'error', + 'idle', + 'scheduled', + ]), + }), + 'context': , + 'entity_id': 'sensor.volvo_xc90_charging_status', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'idle', + }) +# --- +# name: test_sensor[xc90_phev_2024][sensor.volvo_xc90_charging_type-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'ac', + 'dc', + 'none', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.volvo_xc90_charging_type', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Charging type', + 'platform': 'volvo', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'charging_type', + 'unique_id': 'yv1abcdefg1234567_charging_type', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor[xc90_phev_2024][sensor.volvo_xc90_charging_type-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'enum', + 'friendly_name': 'Volvo XC90 Charging type', + 'options': list([ + 'ac', + 'dc', + 'none', + ]), + }), + 'context': , + 'entity_id': 'sensor.volvo_xc90_charging_type', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'none', + }) +# --- +# name: test_sensor[xc90_phev_2024][sensor.volvo_xc90_distance_to_empty_battery-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.volvo_xc90_distance_to_empty_battery', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Distance to empty battery', + 'platform': 'volvo', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'distance_to_empty_battery', + 'unique_id': 'yv1abcdefg1234567_distance_to_empty_battery', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[xc90_phev_2024][sensor.volvo_xc90_distance_to_empty_battery-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'distance', + 'friendly_name': 'Volvo XC90 Distance to empty battery', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.volvo_xc90_distance_to_empty_battery', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '43', + }) +# --- +# name: test_sensor[xc90_phev_2024][sensor.volvo_xc90_distance_to_empty_tank-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.volvo_xc90_distance_to_empty_tank', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Distance to empty tank', + 'platform': 'volvo', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'distance_to_empty_tank', + 'unique_id': 'yv1abcdefg1234567_distance_to_empty_tank', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[xc90_phev_2024][sensor.volvo_xc90_distance_to_empty_tank-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'distance', + 'friendly_name': 'Volvo XC90 Distance to empty tank', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.volvo_xc90_distance_to_empty_tank', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '804', + }) +# --- +# name: test_sensor[xc90_phev_2024][sensor.volvo_xc90_distance_to_service-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.volvo_xc90_distance_to_service', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Distance to service', + 'platform': 'volvo', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'distance_to_service', + 'unique_id': 'yv1abcdefg1234567_distance_to_service', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[xc90_phev_2024][sensor.volvo_xc90_distance_to_service-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'distance', + 'friendly_name': 'Volvo XC90 Distance to service', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.volvo_xc90_distance_to_service', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '29000', + }) +# --- +# name: test_sensor[xc90_phev_2024][sensor.volvo_xc90_estimated_charging_time-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.volvo_xc90_estimated_charging_time', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Estimated charging time', + 'platform': 'volvo', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'estimated_charging_time', + 'unique_id': 'yv1abcdefg1234567_estimated_charging_time', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[xc90_phev_2024][sensor.volvo_xc90_estimated_charging_time-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'duration', + 'friendly_name': 'Volvo XC90 Estimated charging time', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.volvo_xc90_estimated_charging_time', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0', + }) +# --- +# name: test_sensor[xc90_phev_2024][sensor.volvo_xc90_fuel_amount-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.volvo_xc90_fuel_amount', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Fuel amount', + 'platform': 'volvo', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'fuel_amount', + 'unique_id': 'yv1abcdefg1234567_fuel_amount', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[xc90_phev_2024][sensor.volvo_xc90_fuel_amount-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'volume_storage', + 'friendly_name': 'Volvo XC90 Fuel amount', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.volvo_xc90_fuel_amount', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '47.3', + }) +# --- +# name: test_sensor[xc90_phev_2024][sensor.volvo_xc90_odometer-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.volvo_xc90_odometer', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Odometer', + 'platform': 'volvo', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'odometer', + 'unique_id': 'yv1abcdefg1234567_odometer', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[xc90_phev_2024][sensor.volvo_xc90_odometer-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'distance', + 'friendly_name': 'Volvo XC90 Odometer', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.volvo_xc90_odometer', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '30000', + }) +# --- +# name: test_sensor[xc90_phev_2024][sensor.volvo_xc90_target_battery_charge_level-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.volvo_xc90_target_battery_charge_level', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Target battery charge level', + 'platform': 'volvo', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'target_battery_charge_level', + 'unique_id': 'yv1abcdefg1234567_target_battery_charge_level', + 'unit_of_measurement': '%', + }) +# --- +# name: test_sensor[xc90_phev_2024][sensor.volvo_xc90_target_battery_charge_level-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Volvo XC90 Target battery charge level', + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.volvo_xc90_target_battery_charge_level', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_sensor[xc90_phev_2024][sensor.volvo_xc90_time_to_engine_service-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.volvo_xc90_time_to_engine_service', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Time to engine service', + 'platform': 'volvo', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'engine_time_to_service', + 'unique_id': 'yv1abcdefg1234567_engine_time_to_service', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[xc90_phev_2024][sensor.volvo_xc90_time_to_engine_service-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'duration', + 'friendly_name': 'Volvo XC90 Time to engine service', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.volvo_xc90_time_to_engine_service', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '1266', + }) +# --- +# name: test_sensor[xc90_phev_2024][sensor.volvo_xc90_time_to_service-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.volvo_xc90_time_to_service', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Time to service', + 'platform': 'volvo', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'time_to_service', + 'unique_id': 'yv1abcdefg1234567_time_to_service', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[xc90_phev_2024][sensor.volvo_xc90_time_to_service-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'duration', + 'friendly_name': 'Volvo XC90 Time to service', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.volvo_xc90_time_to_service', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '690', + }) +# --- +# name: test_sensor[xc90_phev_2024][sensor.volvo_xc90_trip_automatic_average_fuel_consumption-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.volvo_xc90_trip_automatic_average_fuel_consumption', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Trip automatic average fuel consumption', + 'platform': 'volvo', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'average_fuel_consumption_automatic', + 'unique_id': 'yv1abcdefg1234567_average_fuel_consumption_automatic', + 'unit_of_measurement': 'L/100 km', + }) +# --- +# name: test_sensor[xc90_phev_2024][sensor.volvo_xc90_trip_automatic_average_fuel_consumption-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Volvo XC90 Trip automatic average fuel consumption', + 'state_class': , + 'unit_of_measurement': 'L/100 km', + }), + 'context': , + 'entity_id': 'sensor.volvo_xc90_trip_automatic_average_fuel_consumption', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0', + }) +# --- +# name: test_sensor[xc90_phev_2024][sensor.volvo_xc90_trip_automatic_average_speed-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.volvo_xc90_trip_automatic_average_speed', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Trip automatic average speed', + 'platform': 'volvo', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'average_speed_automatic', + 'unique_id': 'yv1abcdefg1234567_average_speed_automatic', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[xc90_phev_2024][sensor.volvo_xc90_trip_automatic_average_speed-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'speed', + 'friendly_name': 'Volvo XC90 Trip automatic average speed', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.volvo_xc90_trip_automatic_average_speed', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '37', + }) +# --- +# name: test_sensor[xc90_phev_2024][sensor.volvo_xc90_trip_automatic_distance-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.volvo_xc90_trip_automatic_distance', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Trip automatic distance', + 'platform': 'volvo', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'trip_meter_automatic', + 'unique_id': 'yv1abcdefg1234567_trip_meter_automatic', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[xc90_phev_2024][sensor.volvo_xc90_trip_automatic_distance-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'distance', + 'friendly_name': 'Volvo XC90 Trip automatic distance', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.volvo_xc90_trip_automatic_distance', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '23.7', + }) +# --- +# name: test_sensor[xc90_phev_2024][sensor.volvo_xc90_trip_manual_average_energy_consumption-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.volvo_xc90_trip_manual_average_energy_consumption', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Trip manual average energy consumption', + 'platform': 'volvo', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'average_energy_consumption', + 'unique_id': 'yv1abcdefg1234567_average_energy_consumption', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[xc90_phev_2024][sensor.volvo_xc90_trip_manual_average_energy_consumption-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Volvo XC90 Trip manual average energy consumption', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.volvo_xc90_trip_manual_average_energy_consumption', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '19.9', + }) +# --- +# name: test_sensor[xc90_phev_2024][sensor.volvo_xc90_trip_manual_average_fuel_consumption-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.volvo_xc90_trip_manual_average_fuel_consumption', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Trip manual average fuel consumption', + 'platform': 'volvo', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'average_fuel_consumption', + 'unique_id': 'yv1abcdefg1234567_average_fuel_consumption', + 'unit_of_measurement': 'L/100 km', + }) +# --- +# name: test_sensor[xc90_phev_2024][sensor.volvo_xc90_trip_manual_average_fuel_consumption-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Volvo XC90 Trip manual average fuel consumption', + 'state_class': , + 'unit_of_measurement': 'L/100 km', + }), + 'context': , + 'entity_id': 'sensor.volvo_xc90_trip_manual_average_fuel_consumption', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '2.0', + }) +# --- +# name: test_sensor[xc90_phev_2024][sensor.volvo_xc90_trip_manual_average_speed-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.volvo_xc90_trip_manual_average_speed', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Trip manual average speed', + 'platform': 'volvo', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'average_speed', + 'unique_id': 'yv1abcdefg1234567_average_speed', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[xc90_phev_2024][sensor.volvo_xc90_trip_manual_average_speed-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'speed', + 'friendly_name': 'Volvo XC90 Trip manual average speed', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.volvo_xc90_trip_manual_average_speed', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '47', + }) +# --- +# name: test_sensor[xc90_phev_2024][sensor.volvo_xc90_trip_manual_distance-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.volvo_xc90_trip_manual_distance', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Trip manual distance', + 'platform': 'volvo', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'trip_meter_manual', + 'unique_id': 'yv1abcdefg1234567_trip_meter_manual', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[xc90_phev_2024][sensor.volvo_xc90_trip_manual_distance-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'distance', + 'friendly_name': 'Volvo XC90 Trip manual distance', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.volvo_xc90_trip_manual_distance', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '5935.8', + }) +# --- diff --git a/tests/components/volvo/test_binary_sensor.py b/tests/components/volvo/test_binary_sensor.py index e581b00595c6bf..3d88b32f7985f0 100644 --- a/tests/components/volvo/test_binary_sensor.py +++ b/tests/components/volvo/test_binary_sensor.py @@ -6,6 +6,7 @@ import pytest from syrupy.assertion import SnapshotAssertion +from homeassistant.components.volvo.const import DOMAIN from homeassistant.const import Platform from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er @@ -31,3 +32,28 @@ async def test_binary_sensor( assert await setup_integration() await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) + + +@pytest.mark.usefixtures("mock_api", "full_model") +@pytest.mark.parametrize( + "full_model", + [ + "ex30_2024", + "s90_diesel_2018", + "xc40_electric_2024", + "xc60_phev_2020", + "xc90_petrol_2019", + "xc90_phev_2024", + ], +) +async def test_unique_ids( + hass: HomeAssistant, + setup_integration: Callable[[], Awaitable[bool]], + caplog: pytest.LogCaptureFixture, +) -> None: + """Test binary sensor for unique id's.""" + + with patch("homeassistant.components.volvo.PLATFORMS", [Platform.BINARY_SENSOR]): + assert await setup_integration() + + assert f"Platform {DOMAIN} does not generate unique IDs" not in caplog.text diff --git a/tests/components/volvo/test_sensor.py b/tests/components/volvo/test_sensor.py index 988777cd7739ab..05571ff8cac18f 100644 --- a/tests/components/volvo/test_sensor.py +++ b/tests/components/volvo/test_sensor.py @@ -6,6 +6,7 @@ import pytest from syrupy.assertion import SnapshotAssertion +from homeassistant.components.volvo.const import DOMAIN from homeassistant.const import Platform from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er @@ -22,6 +23,7 @@ "xc40_electric_2024", "xc60_phev_2020", "xc90_petrol_2019", + "xc90_phev_2024", ], ) async def test_sensor( @@ -89,3 +91,28 @@ async def test_charging_power_value( assert await setup_integration() assert hass.states.get("sensor.volvo_ex30_charging_power").state == "0" + + +@pytest.mark.usefixtures("mock_api", "full_model") +@pytest.mark.parametrize( + "full_model", + [ + "ex30_2024", + "s90_diesel_2018", + "xc40_electric_2024", + "xc60_phev_2020", + "xc90_petrol_2019", + "xc90_phev_2024", + ], +) +async def test_unique_ids( + hass: HomeAssistant, + setup_integration: Callable[[], Awaitable[bool]], + caplog: pytest.LogCaptureFixture, +) -> None: + """Test sensor for unique id's.""" + + with patch("homeassistant.components.volvo.PLATFORMS", [Platform.SENSOR]): + assert await setup_integration() + + assert f"Platform {DOMAIN} does not generate unique IDs" not in caplog.text diff --git a/tests/components/workday/test_config_flow.py b/tests/components/workday/test_config_flow.py index c618c5fd83038d..b9cbde31e54e59 100644 --- a/tests/components/workday/test_config_flow.py +++ b/tests/components/workday/test_config_flow.py @@ -14,6 +14,7 @@ CONF_CATEGORY, CONF_EXCLUDES, CONF_OFFSET, + CONF_PROVINCE, CONF_REMOVE_HOLIDAYS, CONF_WORKDAYS, DEFAULT_EXCLUDES, @@ -702,6 +703,53 @@ async def test_form_with_categories(hass: HomeAssistant) -> None: } +async def test_form_with_categories_can_remove_day(hass: HomeAssistant) -> None: + """Test optional categories, days can be removed.""" + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] is FlowResultType.FORM + + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_NAME: "Workday Sensor", + CONF_COUNTRY: "CH", + }, + ) + await hass.async_block_till_done() + result3 = await hass.config_entries.flow.async_configure( + result2["flow_id"], + { + CONF_PROVINCE: "FR", + CONF_EXCLUDES: DEFAULT_EXCLUDES, + CONF_OFFSET: DEFAULT_OFFSET, + CONF_WORKDAYS: DEFAULT_WORKDAYS, + CONF_ADD_HOLIDAYS: [], + CONF_REMOVE_HOLIDAYS: ["Berchtoldstag"], + CONF_LANGUAGE: "de", + CONF_CATEGORY: [OPTIONAL], + }, + ) + await hass.async_block_till_done() + + assert result3["type"] is FlowResultType.CREATE_ENTRY + assert result3["title"] == "Workday Sensor" + assert result3["options"] == { + "name": "Workday Sensor", + "country": "CH", + "excludes": ["sat", "sun", "holiday"], + "days_offset": 0, + "workdays": ["mon", "tue", "wed", "thu", "fri"], + "add_holidays": [], + "province": "FR", + "remove_holidays": ["Berchtoldstag"], + "language": "de", + "category": ["optional"], + } + + async def test_options_form_removes_subdiv(hass: HomeAssistant) -> None: """Test we get the form in options when removing a configured subdivision."""