Compare commits

...

6 Commits

Author SHA1 Message Date
Franck Nijhof
e3351970be Update homeassistant/components/brands/const.py
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2026-02-25 11:26:03 +01:00
Franck Nijhof
faff4f8e40 Update homeassistant/components/hassio/update.py
Co-authored-by: Robert Resch <robert@resch.dev>
2026-02-25 11:18:18 +01:00
Franck Nijhof
a2d996d3c1 Reverse placeholder logic 2026-02-25 09:20:36 +00:00
Franck Nijhof
71e7efe94d Clean up caching headers 2026-02-24 21:33:50 +00:00
Franck Nijhof
37ca3984f9 Remove old cors leftover 2026-02-24 21:28:33 +00:00
Franck Nijhof
5b40c34848 Add brands system integration to proxy brand images through local API 2026-02-24 20:47:01 +00:00
64 changed files with 1426 additions and 149 deletions

2
CODEOWNERS generated
View File

@@ -242,6 +242,8 @@ build.json @home-assistant/supervisor
/tests/components/bosch_alarm/ @mag1024 @sanjay900
/homeassistant/components/bosch_shc/ @tschamm
/tests/components/bosch_shc/ @tschamm
/homeassistant/components/brands/ @home-assistant/core
/tests/components/brands/ @home-assistant/core
/homeassistant/components/braviatv/ @bieniu @Drafteed
/tests/components/braviatv/ @bieniu @Drafteed
/homeassistant/components/bring/ @miaucl @tr4nt0r

View File

@@ -210,6 +210,7 @@ DEFAULT_INTEGRATIONS = {
"analytics", # Needed for onboarding
"application_credentials",
"backup",
"brands",
"frontend",
"hardware",
"labs",

View File

@@ -0,0 +1,291 @@
"""The Brands integration."""
from __future__ import annotations
from collections import deque
from http import HTTPStatus
import logging
from pathlib import Path
from random import SystemRandom
import time
from typing import Any, Final
from aiohttp import ClientError, hdrs, web
import voluptuous as vol
from homeassistant.components import websocket_api
from homeassistant.components.http import KEY_AUTHENTICATED, HomeAssistantView
from homeassistant.core import HomeAssistant, callback, valid_domain
from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers.event import async_track_time_interval
from homeassistant.helpers.typing import ConfigType
from homeassistant.loader import async_get_custom_components
from .const import (
ALLOWED_IMAGES,
BRANDS_CDN_URL,
CACHE_TTL,
CATEGORY_RE,
CDN_TIMEOUT,
DOMAIN,
HARDWARE_IMAGE_RE,
IMAGE_FALLBACKS,
PLACEHOLDER,
TOKEN_CHANGE_INTERVAL,
)
_LOGGER = logging.getLogger(__name__)
_RND: Final = SystemRandom()
CONFIG_SCHEMA = cv.empty_config_schema(DOMAIN)
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
"""Set up the Brands integration."""
access_tokens: deque[str] = deque([], 2)
access_tokens.append(hex(_RND.getrandbits(256))[2:])
hass.data[DOMAIN] = access_tokens
@callback
def _rotate_token(_now: Any) -> None:
"""Rotate the access token."""
access_tokens.append(hex(_RND.getrandbits(256))[2:])
async_track_time_interval(hass, _rotate_token, TOKEN_CHANGE_INTERVAL)
hass.http.register_view(BrandsIntegrationView(hass))
hass.http.register_view(BrandsHardwareView(hass))
websocket_api.async_register_command(hass, ws_access_token)
return True
@callback
@websocket_api.websocket_command({vol.Required("type"): "brands/access_token"})
def ws_access_token(
hass: HomeAssistant,
connection: websocket_api.ActiveConnection,
msg: dict[str, Any],
) -> None:
"""Return the current brands access token."""
access_tokens: deque[str] = hass.data[DOMAIN]
connection.send_result(msg["id"], {"token": access_tokens[-1]})
def _read_cached_file_with_marker(
cache_path: Path,
) -> tuple[bytes | None, float] | None:
"""Read a cached file, distinguishing between content and 404 markers.
Returns (content, mtime) where content is None for 404 markers (empty files).
Returns None if the file does not exist at all.
"""
if not cache_path.is_file():
return None
mtime = cache_path.stat().st_mtime
data = cache_path.read_bytes()
if not data:
# Empty file is a 404 marker
return (None, mtime)
return (data, mtime)
def _write_cache_file(cache_path: Path, data: bytes) -> None:
"""Write data to cache file, creating directories as needed."""
cache_path.parent.mkdir(parents=True, exist_ok=True)
cache_path.write_bytes(data)
def _read_brand_file(brand_dir: Path, image: str) -> bytes | None:
"""Read a brand image, trying fallbacks in a single I/O pass."""
for candidate in (image, *IMAGE_FALLBACKS.get(image, ())):
file_path = brand_dir / candidate
if file_path.is_file():
return file_path.read_bytes()
return None
class _BrandsBaseView(HomeAssistantView):
"""Base view for serving brand images."""
requires_auth = False
def __init__(self, hass: HomeAssistant) -> None:
"""Initialize the view."""
self._hass = hass
self._cache_dir = Path(hass.config.cache_path(DOMAIN))
def _authenticate(self, request: web.Request) -> None:
"""Authenticate the request using Bearer token or query token."""
access_tokens: deque[str] = self._hass.data[DOMAIN]
authenticated = (
request[KEY_AUTHENTICATED] or request.query.get("token") in access_tokens
)
if not authenticated:
if hdrs.AUTHORIZATION in request.headers:
raise web.HTTPUnauthorized
raise web.HTTPForbidden
async def _serve_from_custom_integration(
self,
domain: str,
image: str,
) -> web.Response | None:
"""Try to serve a brand image from a custom integration."""
custom_components = await async_get_custom_components(self._hass)
if (integration := custom_components.get(domain)) is None:
return None
if not integration.has_branding:
return None
brand_dir = Path(integration.file_path) / "brand"
data = await self._hass.async_add_executor_job(
_read_brand_file, brand_dir, image
)
if data is not None:
return self._build_response(data)
return None
async def _serve_from_cache_or_cdn(
self,
cdn_path: str,
cache_subpath: str,
*,
fallback_placeholder: bool = True,
) -> web.Response:
"""Serve from disk cache, fetching from CDN if needed."""
cache_path = self._cache_dir / cache_subpath
now = time.time()
# Try disk cache
result = await self._hass.async_add_executor_job(
_read_cached_file_with_marker, cache_path
)
if result is not None:
data, mtime = result
# Schedule background refresh if stale
if now - mtime > CACHE_TTL:
self._hass.async_create_background_task(
self._fetch_and_cache(cdn_path, cache_path),
f"brands_refresh_{cache_subpath}",
)
else:
# Cache miss - fetch from CDN
data = await self._fetch_and_cache(cdn_path, cache_path)
if data is None:
if fallback_placeholder:
return await self._serve_placeholder(
image=cache_subpath.rsplit("/", 1)[-1]
)
return web.Response(status=HTTPStatus.NOT_FOUND)
return self._build_response(data)
async def _fetch_and_cache(
self,
cdn_path: str,
cache_path: Path,
) -> bytes | None:
"""Fetch from CDN and write to cache. Returns data or None on 404."""
url = f"{BRANDS_CDN_URL}/{cdn_path}"
session = async_get_clientsession(self._hass)
try:
resp = await session.get(url, timeout=CDN_TIMEOUT)
except ClientError, TimeoutError:
_LOGGER.debug("Failed to fetch brand from CDN: %s", cdn_path)
return None
if resp.status == HTTPStatus.NOT_FOUND:
# Cache the 404 as empty file
await self._hass.async_add_executor_job(_write_cache_file, cache_path, b"")
return None
if resp.status != HTTPStatus.OK:
_LOGGER.debug("Unexpected CDN response %s for %s", resp.status, cdn_path)
return None
data = await resp.read()
await self._hass.async_add_executor_job(_write_cache_file, cache_path, data)
return data
async def _serve_placeholder(self, image: str) -> web.Response:
"""Serve a placeholder image."""
return await self._serve_from_cache_or_cdn(
cdn_path=f"_/{PLACEHOLDER}/{image}",
cache_subpath=f"integrations/{PLACEHOLDER}/{image}",
fallback_placeholder=False,
)
@staticmethod
def _build_response(data: bytes) -> web.Response:
"""Build a response with proper headers."""
return web.Response(
body=data,
content_type="image/png",
)
class BrandsIntegrationView(_BrandsBaseView):
"""Serve integration brand images."""
name = "api:brands:integration"
url = "/api/brands/integration/{domain}/{image}"
async def get(
self,
request: web.Request,
domain: str,
image: str,
) -> web.Response:
"""Handle GET request for an integration brand image."""
self._authenticate(request)
if not valid_domain(domain) or image not in ALLOWED_IMAGES:
return web.Response(status=HTTPStatus.NOT_FOUND)
use_placeholder = request.query.get("placeholder") != "no"
# 1. Try custom integration local files
if (
response := await self._serve_from_custom_integration(domain, image)
) is not None:
return response
# 2. Try cache / CDN (always use direct path for proper 404 caching)
return await self._serve_from_cache_or_cdn(
cdn_path=f"brands/{domain}/{image}",
cache_subpath=f"integrations/{domain}/{image}",
fallback_placeholder=use_placeholder,
)
class BrandsHardwareView(_BrandsBaseView):
"""Serve hardware brand images."""
name = "api:brands:hardware"
url = "/api/brands/hardware/{category}/{image:.+}"
async def get(
self,
request: web.Request,
category: str,
image: str,
) -> web.Response:
"""Handle GET request for a hardware brand image."""
self._authenticate(request)
if not CATEGORY_RE.match(category):
return web.Response(status=HTTPStatus.NOT_FOUND)
# Hardware images have dynamic names like "manufacturer_model.png"
# Validate it ends with .png and contains only safe characters
if not HARDWARE_IMAGE_RE.match(image):
return web.Response(status=HTTPStatus.NOT_FOUND)
cache_subpath = f"hardware/{category}/{image}"
return await self._serve_from_cache_or_cdn(
cdn_path=cache_subpath,
cache_subpath=cache_subpath,
)

View File

@@ -0,0 +1,57 @@
"""Constants for the Brands integration."""
from __future__ import annotations
from datetime import timedelta
import re
from typing import Final
from aiohttp import ClientTimeout
DOMAIN: Final = "brands"
# CDN
BRANDS_CDN_URL: Final = "https://brands.home-assistant.io"
CDN_TIMEOUT: Final = ClientTimeout(total=10)
PLACEHOLDER: Final = "_placeholder"
# Caching
CACHE_TTL: Final = 30 * 24 * 60 * 60 # 30 days in seconds
# Access token
TOKEN_CHANGE_INTERVAL: Final = timedelta(minutes=30)
# Validation
CATEGORY_RE: Final = re.compile(r"^[a-z0-9_]+$")
HARDWARE_IMAGE_RE: Final = re.compile(r"^[a-z0-9_-]+\.png$")
# Images and fallback chains
ALLOWED_IMAGES: Final = frozenset(
{
"icon.png",
"logo.png",
"icon@2x.png",
"logo@2x.png",
"dark_icon.png",
"dark_logo.png",
"dark_icon@2x.png",
"dark_logo@2x.png",
}
)
# Fallback chains for image resolution, mirroring the brands CDN build logic.
# When a requested image is not found, we try each fallback in order.
IMAGE_FALLBACKS: Final[dict[str, list[str]]] = {
"logo.png": ["icon.png"],
"icon@2x.png": ["icon.png"],
"logo@2x.png": ["logo.png", "icon.png"],
"dark_icon.png": ["icon.png"],
"dark_logo.png": ["dark_icon.png", "logo.png", "icon.png"],
"dark_icon@2x.png": ["icon@2x.png", "icon.png"],
"dark_logo@2x.png": [
"dark_icon@2x.png",
"logo@2x.png",
"logo.png",
"icon.png",
],
}

View File

@@ -0,0 +1,10 @@
{
"domain": "brands",
"name": "Brands",
"codeowners": ["@home-assistant/core"],
"config_flow": false,
"dependencies": ["http", "websocket_api"],
"documentation": "https://www.home-assistant.io/integrations/brands",
"integration_type": "system",
"quality_scale": "internal"
}

View File

@@ -38,7 +38,7 @@ async def _root_payload(
media_class=MediaClass.DIRECTORY,
media_content_id="",
media_content_type="presets",
thumbnail="https://brands.home-assistant.io/_/cambridge_audio/logo.png",
thumbnail="/api/brands/integration/cambridge_audio/logo.png",
can_play=False,
can_expand=True,
)

View File

@@ -304,7 +304,7 @@ def base_owntone_library() -> BrowseMedia:
can_play=False,
can_expand=True,
children=children,
thumbnail="https://brands.home-assistant.io/_/forked_daapd/logo.png",
thumbnail="/api/brands/integration/forked_daapd/logo.png",
)
@@ -321,7 +321,7 @@ def library(other: Sequence[BrowseMedia] | None) -> BrowseMedia:
media_content_type=MediaType.APP,
can_play=False,
can_expand=True,
thumbnail="https://brands.home-assistant.io/_/forked_daapd/logo.png",
thumbnail="/api/brands/integration/forked_daapd/logo.png",
)
]
if other:

View File

@@ -207,7 +207,7 @@ class SupervisorOSUpdateEntity(HassioOSEntity, UpdateEntity):
@property
def entity_picture(self) -> str | None:
"""Return the icon of the entity."""
return "https://brands.home-assistant.io/homeassistant/icon.png"
return "/api/brands/integration/homeassistant/icon.png?placeholder=no"
@property
def release_url(self) -> str | None:
@@ -258,7 +258,7 @@ class SupervisorSupervisorUpdateEntity(HassioSupervisorEntity, UpdateEntity):
@property
def entity_picture(self) -> str | None:
"""Return the icon of the entity."""
return "https://brands.home-assistant.io/hassio/icon.png"
return "/api/brands/integration/hassio/icon.png?placeholder=no"
async def async_install(
self, version: str | None, backup: bool, **kwargs: Any
@@ -296,7 +296,7 @@ class SupervisorCoreUpdateEntity(HassioCoreEntity, UpdateEntity):
@property
def entity_picture(self) -> str | None:
"""Return the icon of the entity."""
return "https://brands.home-assistant.io/homeassistant/icon.png"
return "/api/brands/integration/homeassistant/icon.png?placeholder=no"
@property
def release_url(self) -> str | None:

View File

@@ -219,7 +219,7 @@ async def library_payload(hass):
)
for child in library_info.children:
child.thumbnail = "https://brands.home-assistant.io/_/kodi/logo.png"
child.thumbnail = "/api/brands/integration/kodi/logo.png"
with contextlib.suppress(BrowseError):
item = await media_source.async_browse_media(

View File

@@ -42,7 +42,7 @@ async def async_get_media_browser_root_object(
media_class=MediaClass.APP,
media_content_id="",
media_content_type=DOMAIN,
thumbnail="https://brands.home-assistant.io/_/lovelace/logo.png",
thumbnail="/api/brands/integration/lovelace/logo.png",
can_play=False,
can_expand=True,
)
@@ -72,7 +72,7 @@ async def async_browse_media(
media_class=MediaClass.APP,
media_content_id=DEFAULT_DASHBOARD,
media_content_type=DOMAIN,
thumbnail="https://brands.home-assistant.io/_/lovelace/logo.png",
thumbnail="/api/brands/integration/lovelace/logo.png",
can_play=True,
can_expand=False,
)
@@ -104,7 +104,7 @@ async def async_browse_media(
media_class=MediaClass.APP,
media_content_id=f"{info['url_path']}/{view['path']}",
media_content_type=DOMAIN,
thumbnail="https://brands.home-assistant.io/_/lovelace/logo.png",
thumbnail="/api/brands/integration/lovelace/logo.png",
can_play=True,
can_expand=False,
)
@@ -213,7 +213,7 @@ def _item_from_info(info: dict) -> BrowseMedia:
media_class=MediaClass.APP,
media_content_id=info["url_path"],
media_content_type=DOMAIN,
thumbnail="https://brands.home-assistant.io/_/lovelace/logo.png",
thumbnail="/api/brands/integration/lovelace/logo.png",
can_play=True,
can_expand=len(info["views"]) > 1,
)

View File

@@ -83,7 +83,7 @@ class MediaSourceItem:
identifier=None,
media_class=MediaClass.APP,
media_content_type=MediaType.APP,
thumbnail=f"https://brands.home-assistant.io/_/{source.domain}/logo.png",
thumbnail=f"/api/brands/integration/{source.domain}/logo.png",
title=source.name,
can_play=False,
can_expand=True,

View File

@@ -23,7 +23,7 @@ async def async_get_media_browser_root_object(
media_class=MediaClass.APP,
media_content_id="",
media_content_type="plex",
thumbnail="https://brands.home-assistant.io/_/plex/logo.png",
thumbnail="/api/brands/integration/plex/logo.png",
can_play=False,
can_expand=True,
)

View File

@@ -94,7 +94,7 @@ def browse_media( # noqa: C901
can_expand=True,
children=[],
children_media_class=MediaClass.DIRECTORY,
thumbnail="https://brands.home-assistant.io/_/plex/logo.png",
thumbnail="/api/brands/integration/plex/logo.png",
)
if platform != "sonos":
server_info.children.append(

View File

@@ -131,7 +131,7 @@ async def root_payload(
)
for child in children:
child.thumbnail = "https://brands.home-assistant.io/_/roku/logo.png"
child.thumbnail = "/api/brands/integration/roku/logo.png"
try:
browse_item = await media_source.async_browse_media(hass, None)

View File

@@ -35,7 +35,7 @@ async def _root_payload(
media_class=MediaClass.DIRECTORY,
media_content_id="",
media_content_type="presets",
thumbnail="https://brands.home-assistant.io/_/russound_rio/logo.png",
thumbnail="/api/brands/integration/russound_rio/logo.png",
can_play=False,
can_expand=True,
)

View File

@@ -330,7 +330,7 @@ async def root_payload(
media_class=MediaClass.DIRECTORY,
media_content_id="",
media_content_type="favorites",
thumbnail="https://brands.home-assistant.io/_/sonos/logo.png",
thumbnail="/api/brands/integration/sonos/logo.png",
can_play=False,
can_expand=True,
)
@@ -345,7 +345,7 @@ async def root_payload(
media_class=MediaClass.DIRECTORY,
media_content_id="",
media_content_type="library",
thumbnail="https://brands.home-assistant.io/_/sonos/logo.png",
thumbnail="/api/brands/integration/sonos/logo.png",
can_play=False,
can_expand=True,
)
@@ -358,7 +358,7 @@ async def root_payload(
media_class=MediaClass.APP,
media_content_id="",
media_content_type="plex",
thumbnail="https://brands.home-assistant.io/_/plex/logo.png",
thumbnail="/api/brands/integration/plex/logo.png",
can_play=False,
can_expand=True,
)

View File

@@ -212,7 +212,7 @@ async def async_browse_media(
media_class=MediaClass.APP,
media_content_id=f"{MEDIA_PLAYER_PREFIX}{config_entry.entry_id}",
media_content_type=f"{MEDIA_PLAYER_PREFIX}library",
thumbnail="https://brands.home-assistant.io/_/spotify/logo.png",
thumbnail="/api/brands/integration/spotify/logo.png",
can_play=False,
can_expand=True,
)
@@ -223,7 +223,7 @@ async def async_browse_media(
media_class=MediaClass.APP,
media_content_id=MEDIA_PLAYER_PREFIX,
media_content_type="spotify",
thumbnail="https://brands.home-assistant.io/_/spotify/logo.png",
thumbnail="/api/brands/integration/spotify/logo.png",
can_play=False,
can_expand=True,
children=children,

View File

@@ -266,7 +266,7 @@ class StateUpdateEntity(TemplateEntity, AbstractTemplateUpdate):
# The default picture for update entities would use `self.platform.platform_name` in
# place of `template`. This does not work when creating an entity preview because
# the platform does not exist for that entity, therefore this is hardcoded as `template`.
return "https://brands.home-assistant.io/_/template/icon.png"
return "/api/brands/integration/template/icon.png"
return self._attr_entity_picture

View File

@@ -214,7 +214,7 @@ class TTSMediaSource(MediaSource):
media_class=MediaClass.APP,
media_content_type="provider",
title=engine_instance.name,
thumbnail=f"https://brands.home-assistant.io/_/{engine_domain}/logo.png",
thumbnail=f"/api/brands/integration/{engine_domain}/logo.png",
can_play=False,
can_expand=True,
)

View File

@@ -290,9 +290,7 @@ class UpdateEntity(
Update entities return the brand icon based on the integration
domain by default.
"""
return (
f"https://brands.home-assistant.io/_/{self.platform.platform_name}/icon.png"
)
return f"/api/brands/integration/{self.platform.platform_name}/icon.png"
@cached_property
def in_progress(self) -> bool | None:

View File

@@ -882,6 +882,11 @@ class Integration:
"""Return if the integration has translations."""
return "translations" in self._top_level_files
@cached_property
def has_branding(self) -> bool:
"""Return if the integration has brand assets."""
return "brand" in self._top_level_files
@cached_property
def has_triggers(self) -> bool:
"""Return if the integration has triggers."""

View File

@@ -62,6 +62,7 @@ NO_IOT_CLASS = [
"auth",
"automation",
"blueprint",
"brands",
"color_extractor",
"config",
"configurator",

View File

@@ -2106,6 +2106,7 @@ NO_QUALITY_SCALE = [
"auth",
"automation",
"blueprint",
"brands",
"config",
"configurator",
"counter",

View File

@@ -40,7 +40,7 @@
'attributes': ReadOnlyDict({
'auto_update': False,
'display_precision': 0,
'entity_picture': 'https://brands.home-assistant.io/_/adguard/icon.png',
'entity_picture': '/api/brands/integration/adguard/icon.png',
'friendly_name': 'AdGuard Home',
'in_progress': False,
'installed_version': 'v0.107.50',

View File

@@ -41,7 +41,7 @@
'auto_update': False,
'device_class': 'firmware',
'display_precision': 0,
'entity_picture': 'https://brands.home-assistant.io/_/airgradient/icon.png',
'entity_picture': '/api/brands/integration/airgradient/icon.png',
'friendly_name': 'Airgradient Firmware',
'in_progress': False,
'installed_version': '3.1.1',

View File

@@ -0,0 +1 @@
"""Tests for the Brands integration."""

View File

@@ -0,0 +1,20 @@
"""Test configuration for the Brands integration."""
import pytest
from tests.typing import ClientSessionGenerator
@pytest.fixture
def hass_config_dir(hass_tmp_config_dir: str) -> str:
"""Use temporary config directory for brands tests."""
return hass_tmp_config_dir
@pytest.fixture
def aiohttp_client(
aiohttp_client: ClientSessionGenerator,
socket_enabled: None,
) -> ClientSessionGenerator:
"""Return aiohttp_client and allow opening sockets."""
return aiohttp_client

View File

@@ -0,0 +1,903 @@
"""Tests for the Brands integration."""
from datetime import timedelta
from http import HTTPStatus
import os
from pathlib import Path
import time
from unittest.mock import patch
from aiohttp import ClientError
from freezegun.api import FrozenDateTimeFactory
import pytest
from homeassistant.components.brands.const import (
BRANDS_CDN_URL,
CACHE_TTL,
DOMAIN,
TOKEN_CHANGE_INTERVAL,
)
from homeassistant.core import HomeAssistant
from homeassistant.loader import Integration
from homeassistant.setup import async_setup_component
from tests.common import async_fire_time_changed
from tests.test_util.aiohttp import AiohttpClientMocker
from tests.typing import ClientSessionGenerator, WebSocketGenerator
FAKE_PNG = b"\x89PNG\r\n\x1a\nfakeimagedata"
@pytest.fixture(autouse=True)
async def setup_brands(hass: HomeAssistant) -> None:
"""Set up the brands integration for all tests."""
assert await async_setup_component(hass, "http", {"http": {}})
assert await async_setup_component(hass, DOMAIN, {})
def _create_custom_integration(
hass: HomeAssistant,
domain: str,
*,
has_branding: bool = False,
) -> Integration:
"""Create a mock custom integration."""
top_level = {"__init__.py", "manifest.json"}
if has_branding:
top_level.add("brand")
return Integration(
hass,
f"custom_components.{domain}",
Path(hass.config.config_dir) / "custom_components" / domain,
{
"name": domain,
"domain": domain,
"config_flow": False,
"dependencies": [],
"requirements": [],
"version": "1.0.0",
},
top_level,
)
# ------------------------------------------------------------------
# Integration view: /api/brands/integration/{domain}/{image}
# ------------------------------------------------------------------
async def test_integration_view_serves_from_cdn(
hass: HomeAssistant,
hass_client: ClientSessionGenerator,
aioclient_mock: AiohttpClientMocker,
) -> None:
"""Test serving an integration brand image from the CDN."""
aioclient_mock.get(
f"{BRANDS_CDN_URL}/brands/hue/icon.png",
content=FAKE_PNG,
)
client = await hass_client()
resp = await client.get("/api/brands/integration/hue/icon.png")
assert resp.status == HTTPStatus.OK
assert resp.content_type == "image/png"
assert await resp.read() == FAKE_PNG
async def test_integration_view_default_placeholder_fallback(
hass: HomeAssistant,
hass_client: ClientSessionGenerator,
aioclient_mock: AiohttpClientMocker,
) -> None:
"""Test that CDN 404 serves placeholder by default."""
aioclient_mock.get(
f"{BRANDS_CDN_URL}/brands/nonexistent/icon.png",
status=HTTPStatus.NOT_FOUND,
)
aioclient_mock.get(
f"{BRANDS_CDN_URL}/_/_placeholder/icon.png",
content=FAKE_PNG,
)
client = await hass_client()
resp = await client.get("/api/brands/integration/nonexistent/icon.png")
assert resp.status == HTTPStatus.OK
assert await resp.read() == FAKE_PNG
async def test_integration_view_no_placeholder(
hass: HomeAssistant,
hass_client: ClientSessionGenerator,
aioclient_mock: AiohttpClientMocker,
) -> None:
"""Test that CDN 404 returns 404 when placeholder=no is set."""
aioclient_mock.get(
f"{BRANDS_CDN_URL}/brands/nonexistent/icon.png",
status=HTTPStatus.NOT_FOUND,
)
client = await hass_client()
resp = await client.get(
"/api/brands/integration/nonexistent/icon.png?placeholder=no"
)
assert resp.status == HTTPStatus.NOT_FOUND
async def test_integration_view_invalid_domain(
hass: HomeAssistant,
hass_client: ClientSessionGenerator,
) -> None:
"""Test that invalid domain names return 404."""
client = await hass_client()
resp = await client.get("/api/brands/integration/INVALID/icon.png")
assert resp.status == HTTPStatus.NOT_FOUND
resp = await client.get("/api/brands/integration/../etc/icon.png")
assert resp.status == HTTPStatus.NOT_FOUND
resp = await client.get("/api/brands/integration/has spaces/icon.png")
assert resp.status == HTTPStatus.NOT_FOUND
resp = await client.get("/api/brands/integration/_leading/icon.png")
assert resp.status == HTTPStatus.NOT_FOUND
resp = await client.get("/api/brands/integration/trailing_/icon.png")
assert resp.status == HTTPStatus.NOT_FOUND
resp = await client.get("/api/brands/integration/double__under/icon.png")
assert resp.status == HTTPStatus.NOT_FOUND
async def test_integration_view_invalid_image(
hass: HomeAssistant,
hass_client: ClientSessionGenerator,
) -> None:
"""Test that invalid image filenames return 404."""
client = await hass_client()
resp = await client.get("/api/brands/integration/hue/malicious.jpg")
assert resp.status == HTTPStatus.NOT_FOUND
resp = await client.get("/api/brands/integration/hue/../../etc/passwd")
assert resp.status == HTTPStatus.NOT_FOUND
resp = await client.get("/api/brands/integration/hue/notallowed.png")
assert resp.status == HTTPStatus.NOT_FOUND
async def test_integration_view_all_allowed_images(
hass: HomeAssistant,
hass_client: ClientSessionGenerator,
aioclient_mock: AiohttpClientMocker,
) -> None:
"""Test that all allowed image filenames are accepted."""
allowed = [
"icon.png",
"logo.png",
"icon@2x.png",
"logo@2x.png",
"dark_icon.png",
"dark_logo.png",
"dark_icon@2x.png",
"dark_logo@2x.png",
]
for image in allowed:
aioclient_mock.get(
f"{BRANDS_CDN_URL}/brands/hue/{image}",
content=FAKE_PNG,
)
client = await hass_client()
for image in allowed:
resp = await client.get(f"/api/brands/integration/hue/{image}")
assert resp.status == HTTPStatus.OK, f"Failed for {image}"
async def test_integration_view_cdn_error_returns_none(
hass: HomeAssistant,
hass_client: ClientSessionGenerator,
aioclient_mock: AiohttpClientMocker,
) -> None:
"""Test that CDN connection errors result in 404 with placeholder=no."""
aioclient_mock.get(
f"{BRANDS_CDN_URL}/brands/broken/icon.png",
exc=ClientError(),
)
client = await hass_client()
resp = await client.get("/api/brands/integration/broken/icon.png?placeholder=no")
assert resp.status == HTTPStatus.NOT_FOUND
async def test_integration_view_cdn_unexpected_status(
hass: HomeAssistant,
hass_client: ClientSessionGenerator,
aioclient_mock: AiohttpClientMocker,
) -> None:
"""Test that unexpected CDN status codes result in 404 with placeholder=no."""
aioclient_mock.get(
f"{BRANDS_CDN_URL}/brands/broken/icon.png",
status=HTTPStatus.INTERNAL_SERVER_ERROR,
)
client = await hass_client()
resp = await client.get("/api/brands/integration/broken/icon.png?placeholder=no")
assert resp.status == HTTPStatus.NOT_FOUND
# ------------------------------------------------------------------
# Disk caching
# ------------------------------------------------------------------
async def test_disk_cache_hit(
hass: HomeAssistant,
hass_client: ClientSessionGenerator,
aioclient_mock: AiohttpClientMocker,
) -> None:
"""Test that a second request is served from disk cache."""
aioclient_mock.get(
f"{BRANDS_CDN_URL}/brands/hue/icon.png",
content=FAKE_PNG,
)
client = await hass_client()
# First request: fetches from CDN
resp = await client.get("/api/brands/integration/hue/icon.png")
assert resp.status == HTTPStatus.OK
assert aioclient_mock.call_count == 1
# Second request: served from disk cache
resp = await client.get("/api/brands/integration/hue/icon.png")
assert resp.status == HTTPStatus.OK
assert await resp.read() == FAKE_PNG
assert aioclient_mock.call_count == 1 # No additional CDN call
async def test_disk_cache_404_marker(
hass: HomeAssistant,
hass_client: ClientSessionGenerator,
aioclient_mock: AiohttpClientMocker,
) -> None:
"""Test that 404s are cached as empty files."""
aioclient_mock.get(
f"{BRANDS_CDN_URL}/brands/nothing/icon.png",
status=HTTPStatus.NOT_FOUND,
)
client = await hass_client()
# First request: CDN returns 404, cached as empty file
resp = await client.get("/api/brands/integration/nothing/icon.png?placeholder=no")
assert resp.status == HTTPStatus.NOT_FOUND
assert aioclient_mock.call_count == 1
# Second request: served from cached 404 marker
resp = await client.get("/api/brands/integration/nothing/icon.png?placeholder=no")
assert resp.status == HTTPStatus.NOT_FOUND
assert aioclient_mock.call_count == 1 # No additional CDN call
async def test_stale_cache_triggers_background_refresh(
hass: HomeAssistant,
hass_client: ClientSessionGenerator,
aioclient_mock: AiohttpClientMocker,
) -> None:
"""Test that stale cache entries trigger background refresh."""
aioclient_mock.get(
f"{BRANDS_CDN_URL}/brands/hue/icon.png",
content=FAKE_PNG,
)
client = await hass_client()
# Prime the cache
resp = await client.get("/api/brands/integration/hue/icon.png")
assert resp.status == HTTPStatus.OK
assert aioclient_mock.call_count == 1
# Make the cache stale by backdating the file mtime
cache_path = (
Path(hass.config.cache_path(DOMAIN)) / "integrations" / "hue" / "icon.png"
)
assert cache_path.is_file()
stale_time = time.time() - CACHE_TTL - 1
os.utime(cache_path, (stale_time, stale_time))
# Request with stale cache should still return cached data
# but trigger a background refresh
resp = await client.get("/api/brands/integration/hue/icon.png")
assert resp.status == HTTPStatus.OK
assert await resp.read() == FAKE_PNG
# Wait for the background task to complete
await hass.async_block_till_done()
# Background refresh should have fetched from CDN again
assert aioclient_mock.call_count == 2
async def test_stale_cache_404_marker_with_placeholder(
hass: HomeAssistant,
hass_client: ClientSessionGenerator,
aioclient_mock: AiohttpClientMocker,
) -> None:
"""Test that stale cached 404 serves placeholder by default."""
aioclient_mock.get(
f"{BRANDS_CDN_URL}/brands/gone/icon.png",
status=HTTPStatus.NOT_FOUND,
)
aioclient_mock.get(
f"{BRANDS_CDN_URL}/_/_placeholder/icon.png",
content=FAKE_PNG,
)
client = await hass_client()
# First request caches the 404 (with placeholder=no)
resp = await client.get("/api/brands/integration/gone/icon.png?placeholder=no")
assert resp.status == HTTPStatus.NOT_FOUND
assert aioclient_mock.call_count == 1
# Make the cache stale
cache_path = (
Path(hass.config.cache_path(DOMAIN)) / "integrations" / "gone" / "icon.png"
)
assert cache_path.is_file()
stale_time = time.time() - CACHE_TTL - 1
os.utime(cache_path, (stale_time, stale_time))
# Stale 404 with default placeholder serves the placeholder
resp = await client.get("/api/brands/integration/gone/icon.png")
assert resp.status == HTTPStatus.OK
assert await resp.read() == FAKE_PNG
async def test_stale_cache_404_marker_no_placeholder(
hass: HomeAssistant,
hass_client: ClientSessionGenerator,
aioclient_mock: AiohttpClientMocker,
) -> None:
"""Test that stale cached 404 with placeholder=no returns 404."""
aioclient_mock.get(
f"{BRANDS_CDN_URL}/brands/gone/icon.png",
status=HTTPStatus.NOT_FOUND,
)
client = await hass_client()
# First request caches the 404
resp = await client.get("/api/brands/integration/gone/icon.png?placeholder=no")
assert resp.status == HTTPStatus.NOT_FOUND
assert aioclient_mock.call_count == 1
# Make the cache stale
cache_path = (
Path(hass.config.cache_path(DOMAIN)) / "integrations" / "gone" / "icon.png"
)
assert cache_path.is_file()
stale_time = time.time() - CACHE_TTL - 1
os.utime(cache_path, (stale_time, stale_time))
# Stale 404 with placeholder=no still returns 404
resp = await client.get("/api/brands/integration/gone/icon.png?placeholder=no")
assert resp.status == HTTPStatus.NOT_FOUND
# Background refresh should have been triggered
await hass.async_block_till_done()
assert aioclient_mock.call_count == 2
# ------------------------------------------------------------------
# Custom integration brand files
# ------------------------------------------------------------------
async def test_custom_integration_brand_served(
hass: HomeAssistant,
hass_client: ClientSessionGenerator,
aioclient_mock: AiohttpClientMocker,
) -> None:
"""Test that custom integration brand files are served."""
custom = _create_custom_integration(hass, "my_custom", has_branding=True)
# Create the brand file on disk
brand_dir = Path(custom.file_path) / "brand"
brand_dir.mkdir(parents=True, exist_ok=True)
(brand_dir / "icon.png").write_bytes(FAKE_PNG)
with patch(
"homeassistant.components.brands.async_get_custom_components",
return_value={"my_custom": custom},
):
client = await hass_client()
resp = await client.get("/api/brands/integration/my_custom/icon.png")
assert resp.status == HTTPStatus.OK
assert await resp.read() == FAKE_PNG
# Should not have called CDN
assert aioclient_mock.call_count == 0
async def test_custom_integration_no_brand_falls_through(
hass: HomeAssistant,
hass_client: ClientSessionGenerator,
aioclient_mock: AiohttpClientMocker,
) -> None:
"""Test that custom integration without brand falls through to CDN."""
custom = _create_custom_integration(hass, "my_custom", has_branding=False)
aioclient_mock.get(
f"{BRANDS_CDN_URL}/brands/my_custom/icon.png",
content=FAKE_PNG,
)
with patch(
"homeassistant.components.brands.async_get_custom_components",
return_value={"my_custom": custom},
):
client = await hass_client()
resp = await client.get("/api/brands/integration/my_custom/icon.png")
assert resp.status == HTTPStatus.OK
assert aioclient_mock.call_count == 1
async def test_custom_integration_brand_missing_file_falls_through(
hass: HomeAssistant,
hass_client: ClientSessionGenerator,
aioclient_mock: AiohttpClientMocker,
) -> None:
"""Test that custom integration with brand dir but missing file falls through."""
custom = _create_custom_integration(hass, "my_custom", has_branding=True)
# Create the brand directory but NOT the requested file
brand_dir = Path(custom.file_path) / "brand"
brand_dir.mkdir(parents=True, exist_ok=True)
aioclient_mock.get(
f"{BRANDS_CDN_URL}/brands/my_custom/icon.png",
content=FAKE_PNG,
)
with patch(
"homeassistant.components.brands.async_get_custom_components",
return_value={"my_custom": custom},
):
client = await hass_client()
resp = await client.get("/api/brands/integration/my_custom/icon.png")
assert resp.status == HTTPStatus.OK
assert aioclient_mock.call_count == 1
async def test_custom_integration_takes_priority_over_cache(
hass: HomeAssistant,
hass_client: ClientSessionGenerator,
aioclient_mock: AiohttpClientMocker,
) -> None:
"""Test that custom integration brand takes priority over disk cache."""
custom_png = b"\x89PNGcustom"
# Prime the CDN cache first
aioclient_mock.get(
f"{BRANDS_CDN_URL}/brands/my_custom/icon.png",
content=FAKE_PNG,
)
client = await hass_client()
resp = await client.get("/api/brands/integration/my_custom/icon.png")
assert resp.status == HTTPStatus.OK
assert await resp.read() == FAKE_PNG
# Now create a custom integration with brand
custom = _create_custom_integration(hass, "my_custom", has_branding=True)
brand_dir = Path(custom.file_path) / "brand"
brand_dir.mkdir(parents=True, exist_ok=True)
(brand_dir / "icon.png").write_bytes(custom_png)
with patch(
"homeassistant.components.brands.async_get_custom_components",
return_value={"my_custom": custom},
):
resp = await client.get("/api/brands/integration/my_custom/icon.png")
# Custom integration brand takes priority
assert resp.status == HTTPStatus.OK
assert await resp.read() == custom_png
# ------------------------------------------------------------------
# Custom integration image fallback chains
# ------------------------------------------------------------------
async def test_custom_integration_logo_falls_back_to_icon(
hass: HomeAssistant,
hass_client: ClientSessionGenerator,
aioclient_mock: AiohttpClientMocker,
) -> None:
"""Test that requesting logo.png falls back to icon.png for custom integrations."""
custom = _create_custom_integration(hass, "my_custom", has_branding=True)
brand_dir = Path(custom.file_path) / "brand"
brand_dir.mkdir(parents=True, exist_ok=True)
(brand_dir / "icon.png").write_bytes(FAKE_PNG)
with patch(
"homeassistant.components.brands.async_get_custom_components",
return_value={"my_custom": custom},
):
client = await hass_client()
resp = await client.get("/api/brands/integration/my_custom/logo.png")
assert resp.status == HTTPStatus.OK
assert await resp.read() == FAKE_PNG
assert aioclient_mock.call_count == 0
async def test_custom_integration_dark_icon_falls_back_to_icon(
hass: HomeAssistant,
hass_client: ClientSessionGenerator,
aioclient_mock: AiohttpClientMocker,
) -> None:
"""Test that dark_icon.png falls back to icon.png for custom integrations."""
custom = _create_custom_integration(hass, "my_custom", has_branding=True)
brand_dir = Path(custom.file_path) / "brand"
brand_dir.mkdir(parents=True, exist_ok=True)
(brand_dir / "icon.png").write_bytes(FAKE_PNG)
with patch(
"homeassistant.components.brands.async_get_custom_components",
return_value={"my_custom": custom},
):
client = await hass_client()
resp = await client.get("/api/brands/integration/my_custom/dark_icon.png")
assert resp.status == HTTPStatus.OK
assert await resp.read() == FAKE_PNG
assert aioclient_mock.call_count == 0
async def test_custom_integration_dark_logo_falls_back_through_chain(
hass: HomeAssistant,
hass_client: ClientSessionGenerator,
aioclient_mock: AiohttpClientMocker,
) -> None:
"""Test that dark_logo.png walks the full fallback chain."""
custom = _create_custom_integration(hass, "my_custom", has_branding=True)
brand_dir = Path(custom.file_path) / "brand"
brand_dir.mkdir(parents=True, exist_ok=True)
# Only icon.png exists; dark_logo → dark_icon → logo → icon
(brand_dir / "icon.png").write_bytes(FAKE_PNG)
with patch(
"homeassistant.components.brands.async_get_custom_components",
return_value={"my_custom": custom},
):
client = await hass_client()
resp = await client.get("/api/brands/integration/my_custom/dark_logo.png")
assert resp.status == HTTPStatus.OK
assert await resp.read() == FAKE_PNG
assert aioclient_mock.call_count == 0
async def test_custom_integration_dark_logo_prefers_dark_icon(
hass: HomeAssistant,
hass_client: ClientSessionGenerator,
aioclient_mock: AiohttpClientMocker,
) -> None:
"""Test that dark_logo.png prefers dark_icon.png over icon.png."""
dark_icon_data = b"\x89PNGdarkicon"
custom = _create_custom_integration(hass, "my_custom", has_branding=True)
brand_dir = Path(custom.file_path) / "brand"
brand_dir.mkdir(parents=True, exist_ok=True)
(brand_dir / "icon.png").write_bytes(FAKE_PNG)
(brand_dir / "dark_icon.png").write_bytes(dark_icon_data)
with patch(
"homeassistant.components.brands.async_get_custom_components",
return_value={"my_custom": custom},
):
client = await hass_client()
resp = await client.get("/api/brands/integration/my_custom/dark_logo.png")
assert resp.status == HTTPStatus.OK
assert await resp.read() == dark_icon_data
async def test_custom_integration_icon2x_falls_back_to_icon(
hass: HomeAssistant,
hass_client: ClientSessionGenerator,
aioclient_mock: AiohttpClientMocker,
) -> None:
"""Test that icon@2x.png falls back to icon.png."""
custom = _create_custom_integration(hass, "my_custom", has_branding=True)
brand_dir = Path(custom.file_path) / "brand"
brand_dir.mkdir(parents=True, exist_ok=True)
(brand_dir / "icon.png").write_bytes(FAKE_PNG)
with patch(
"homeassistant.components.brands.async_get_custom_components",
return_value={"my_custom": custom},
):
client = await hass_client()
resp = await client.get("/api/brands/integration/my_custom/icon@2x.png")
assert resp.status == HTTPStatus.OK
assert await resp.read() == FAKE_PNG
assert aioclient_mock.call_count == 0
async def test_custom_integration_logo2x_falls_back_to_logo_then_icon(
hass: HomeAssistant,
hass_client: ClientSessionGenerator,
aioclient_mock: AiohttpClientMocker,
) -> None:
"""Test that logo@2x.png falls back to logo.png then icon.png."""
logo_data = b"\x89PNGlogodata"
custom = _create_custom_integration(hass, "my_custom", has_branding=True)
brand_dir = Path(custom.file_path) / "brand"
brand_dir.mkdir(parents=True, exist_ok=True)
(brand_dir / "icon.png").write_bytes(FAKE_PNG)
(brand_dir / "logo.png").write_bytes(logo_data)
with patch(
"homeassistant.components.brands.async_get_custom_components",
return_value={"my_custom": custom},
):
client = await hass_client()
resp = await client.get("/api/brands/integration/my_custom/logo@2x.png")
assert resp.status == HTTPStatus.OK
assert await resp.read() == logo_data
async def test_custom_integration_no_fallback_match_falls_through_to_cdn(
hass: HomeAssistant,
hass_client: ClientSessionGenerator,
aioclient_mock: AiohttpClientMocker,
) -> None:
"""Test that if no fallback image exists locally, we fall through to CDN."""
custom = _create_custom_integration(hass, "my_custom", has_branding=True)
brand_dir = Path(custom.file_path) / "brand"
brand_dir.mkdir(parents=True, exist_ok=True)
# brand dir exists but is empty - no icon.png either
aioclient_mock.get(
f"{BRANDS_CDN_URL}/brands/my_custom/icon.png",
content=FAKE_PNG,
)
with patch(
"homeassistant.components.brands.async_get_custom_components",
return_value={"my_custom": custom},
):
client = await hass_client()
resp = await client.get("/api/brands/integration/my_custom/icon.png")
assert resp.status == HTTPStatus.OK
assert aioclient_mock.call_count == 1
# ------------------------------------------------------------------
# Hardware view: /api/brands/hardware/{category}/{image:.+}
# ------------------------------------------------------------------
async def test_hardware_view_serves_from_cdn(
hass: HomeAssistant,
hass_client: ClientSessionGenerator,
aioclient_mock: AiohttpClientMocker,
) -> None:
"""Test serving a hardware brand image from CDN."""
aioclient_mock.get(
f"{BRANDS_CDN_URL}/hardware/boards/green.png",
content=FAKE_PNG,
)
client = await hass_client()
resp = await client.get("/api/brands/hardware/boards/green.png")
assert resp.status == HTTPStatus.OK
assert await resp.read() == FAKE_PNG
async def test_hardware_view_invalid_category(
hass: HomeAssistant,
hass_client: ClientSessionGenerator,
) -> None:
"""Test that invalid category names return 404."""
client = await hass_client()
resp = await client.get("/api/brands/hardware/INVALID/board.png")
assert resp.status == HTTPStatus.NOT_FOUND
async def test_hardware_view_invalid_image_extension(
hass: HomeAssistant,
hass_client: ClientSessionGenerator,
) -> None:
"""Test that non-png image names return 404."""
client = await hass_client()
resp = await client.get("/api/brands/hardware/boards/image.jpg")
assert resp.status == HTTPStatus.NOT_FOUND
async def test_hardware_view_invalid_image_characters(
hass: HomeAssistant,
hass_client: ClientSessionGenerator,
) -> None:
"""Test that image names with invalid characters return 404."""
client = await hass_client()
resp = await client.get("/api/brands/hardware/boards/Bad-Name.png")
assert resp.status == HTTPStatus.NOT_FOUND
resp = await client.get("/api/brands/hardware/boards/../etc.png")
assert resp.status == HTTPStatus.NOT_FOUND
# ------------------------------------------------------------------
# CDN timeout handling
# ------------------------------------------------------------------
async def test_cdn_timeout_returns_404(
hass: HomeAssistant,
hass_client: ClientSessionGenerator,
aioclient_mock: AiohttpClientMocker,
) -> None:
"""Test that CDN timeout results in 404 with placeholder=no."""
aioclient_mock.get(
f"{BRANDS_CDN_URL}/brands/slow/icon.png",
exc=TimeoutError(),
)
client = await hass_client()
resp = await client.get("/api/brands/integration/slow/icon.png?placeholder=no")
assert resp.status == HTTPStatus.NOT_FOUND
# ------------------------------------------------------------------
# Authentication
# ------------------------------------------------------------------
async def test_authenticated_request(
hass: HomeAssistant,
hass_client: ClientSessionGenerator,
aioclient_mock: AiohttpClientMocker,
) -> None:
"""Test that authenticated requests succeed."""
aioclient_mock.get(
f"{BRANDS_CDN_URL}/brands/hue/icon.png",
content=FAKE_PNG,
)
client = await hass_client()
resp = await client.get("/api/brands/integration/hue/icon.png")
assert resp.status == HTTPStatus.OK
async def test_token_query_param_authentication(
hass: HomeAssistant,
hass_client_no_auth: ClientSessionGenerator,
aioclient_mock: AiohttpClientMocker,
) -> None:
"""Test that a valid access token in query param authenticates."""
aioclient_mock.get(
f"{BRANDS_CDN_URL}/brands/hue/icon.png",
content=FAKE_PNG,
)
token = hass.data[DOMAIN][-1]
client = await hass_client_no_auth()
resp = await client.get(f"/api/brands/integration/hue/icon.png?token={token}")
assert resp.status == HTTPStatus.OK
assert await resp.read() == FAKE_PNG
async def test_unauthenticated_request_forbidden(
hass: HomeAssistant,
hass_client_no_auth: ClientSessionGenerator,
aioclient_mock: AiohttpClientMocker,
) -> None:
"""Test that unauthenticated requests are forbidden."""
client = await hass_client_no_auth()
resp = await client.get("/api/brands/integration/hue/icon.png")
assert resp.status == HTTPStatus.FORBIDDEN
resp = await client.get("/api/brands/hardware/boards/green.png")
assert resp.status == HTTPStatus.FORBIDDEN
async def test_invalid_token_forbidden(
hass: HomeAssistant,
hass_client_no_auth: ClientSessionGenerator,
) -> None:
"""Test that an invalid access token in query param is forbidden."""
client = await hass_client_no_auth()
resp = await client.get("/api/brands/integration/hue/icon.png?token=invalid_token")
assert resp.status == HTTPStatus.FORBIDDEN
async def test_invalid_bearer_token_unauthorized(
hass: HomeAssistant,
hass_client_no_auth: ClientSessionGenerator,
) -> None:
"""Test that an invalid Bearer token returns unauthorized."""
client = await hass_client_no_auth()
resp = await client.get(
"/api/brands/integration/hue/icon.png",
headers={"Authorization": "Bearer invalid_token"},
)
assert resp.status == HTTPStatus.UNAUTHORIZED
async def test_token_rotation(
hass: HomeAssistant,
hass_client_no_auth: ClientSessionGenerator,
aioclient_mock: AiohttpClientMocker,
freezer: FrozenDateTimeFactory,
) -> None:
"""Test that access tokens rotate over time."""
aioclient_mock.get(
f"{BRANDS_CDN_URL}/brands/hue/icon.png",
content=FAKE_PNG,
)
original_token = hass.data[DOMAIN][-1]
client = await hass_client_no_auth()
# Original token works
resp = await client.get(
f"/api/brands/integration/hue/icon.png?token={original_token}"
)
assert resp.status == HTTPStatus.OK
# Trigger token rotation
freezer.tick(TOKEN_CHANGE_INTERVAL + timedelta(seconds=1))
async_fire_time_changed(hass)
await hass.async_block_till_done()
# Deque now contains a different newest token
new_token = hass.data[DOMAIN][-1]
assert new_token != original_token
# New token works
resp = await client.get(f"/api/brands/integration/hue/icon.png?token={new_token}")
assert resp.status == HTTPStatus.OK
# ------------------------------------------------------------------
# WebSocket API
# ------------------------------------------------------------------
async def test_ws_access_token(
hass: HomeAssistant,
hass_ws_client: WebSocketGenerator,
) -> None:
"""Test the brands/access_token WebSocket command."""
client = await hass_ws_client(hass)
await client.send_json({"id": 1, "type": "brands/access_token"})
resp = await client.receive_json()
assert resp["success"]
assert resp["result"]["token"] == hass.data[DOMAIN][-1]

View File

@@ -9,7 +9,7 @@
'media_class': 'directory',
'media_content_id': '',
'media_content_type': 'presets',
'thumbnail': 'https://brands.home-assistant.io/_/cambridge_audio/logo.png',
'thumbnail': '/api/brands/integration/cambridge_audio/logo.png',
'title': 'Presets',
}),
])

View File

@@ -2167,7 +2167,7 @@ async def test_cast_platform_browse_media(
media_class=MediaClass.APP,
media_content_id="",
media_content_type="spotify",
thumbnail="https://brands.home-assistant.io/_/spotify/logo.png",
thumbnail="/api/brands/integration/spotify/logo.png",
can_play=False,
can_expand=True,
)
@@ -2219,7 +2219,7 @@ async def test_cast_platform_browse_media(
"can_play": False,
"can_expand": True,
"can_search": False,
"thumbnail": "https://brands.home-assistant.io/_/spotify/logo.png",
"thumbnail": "/api/brands/integration/spotify/logo.png",
"children_media_class": None,
}
assert expected_child in response["result"]["children"]

View File

@@ -61,8 +61,7 @@ def test_setup_params(hass: HomeAssistant) -> None:
)
assert state.attributes[ATTR_RELEASE_URL] == "https://www.example.com/release/1.0.1"
assert (
state.attributes[ATTR_ENTITY_PICTURE]
== "https://brands.home-assistant.io/_/demo/icon.png"
state.attributes[ATTR_ENTITY_PICTURE] == "/api/brands/integration/demo/icon.png"
)
state = hass.states.get("update.demo_no_update")
@@ -74,8 +73,7 @@ def test_setup_params(hass: HomeAssistant) -> None:
assert state.attributes[ATTR_RELEASE_SUMMARY] is None
assert state.attributes[ATTR_RELEASE_URL] is None
assert (
state.attributes[ATTR_ENTITY_PICTURE]
== "https://brands.home-assistant.io/_/demo/icon.png"
state.attributes[ATTR_ENTITY_PICTURE] == "/api/brands/integration/demo/icon.png"
)
state = hass.states.get("update.demo_add_on")
@@ -89,8 +87,7 @@ def test_setup_params(hass: HomeAssistant) -> None:
)
assert state.attributes[ATTR_RELEASE_URL] == "https://www.example.com/release/1.0.1"
assert (
state.attributes[ATTR_ENTITY_PICTURE]
== "https://brands.home-assistant.io/_/demo/icon.png"
state.attributes[ATTR_ENTITY_PICTURE] == "/api/brands/integration/demo/icon.png"
)
state = hass.states.get("update.demo_living_room_bulb_update")
@@ -105,8 +102,7 @@ def test_setup_params(hass: HomeAssistant) -> None:
)
assert state.attributes[ATTR_DEVICE_CLASS] == UpdateDeviceClass.FIRMWARE
assert (
state.attributes[ATTR_ENTITY_PICTURE]
== "https://brands.home-assistant.io/_/demo/icon.png"
state.attributes[ATTR_ENTITY_PICTURE] == "/api/brands/integration/demo/icon.png"
)
state = hass.states.get("update.demo_update_with_progress")
@@ -121,8 +117,7 @@ def test_setup_params(hass: HomeAssistant) -> None:
)
assert state.attributes[ATTR_DEVICE_CLASS] == UpdateDeviceClass.FIRMWARE
assert (
state.attributes[ATTR_ENTITY_PICTURE]
== "https://brands.home-assistant.io/_/demo/icon.png"
state.attributes[ATTR_ENTITY_PICTURE] == "/api/brands/integration/demo/icon.png"
)

View File

@@ -5,7 +5,7 @@
'auto_update': False,
'device_class': 'firmware',
'display_precision': 0,
'entity_picture': 'https://brands.home-assistant.io/_/devolo_home_network/icon.png',
'entity_picture': '/api/brands/integration/devolo_home_network/icon.png',
'friendly_name': 'Mock Title Firmware',
'in_progress': False,
'installed_version': '5.6.1',

View File

@@ -285,7 +285,7 @@ async def test_media_player_entity_with_source(
media_class=MediaClass.APP,
media_content_id="",
media_content_type="spotify",
thumbnail="https://brands.home-assistant.io/_/spotify/logo.png",
thumbnail="/api/brands/integration/spotify/logo.png",
can_play=False,
can_expand=True,
)

View File

@@ -284,7 +284,7 @@ async def test_async_browse_spotify(
media_class=MediaClass.APP,
media_content_id=f"{SPOTIFY_MEDIA_PLAYER_PREFIX}some_id",
media_content_type=f"{SPOTIFY_MEDIA_PLAYER_PREFIX}track",
thumbnail="https://brands.home-assistant.io/_/spotify/logo.png",
thumbnail="/api/brands/integration/spotify/logo.png",
can_play=False,
can_expand=True,
)
@@ -294,7 +294,7 @@ async def test_async_browse_spotify(
media_class=MediaClass.APP,
media_content_id=SPOTIFY_MEDIA_PLAYER_PREFIX,
media_content_type=f"{SPOTIFY_MEDIA_PLAYER_PREFIX}library",
thumbnail="https://brands.home-assistant.io/_/spotify/logo.png",
thumbnail="/api/brands/integration/spotify/logo.png",
can_play=False,
can_expand=True,
children=children,

View File

@@ -40,7 +40,7 @@
'attributes': ReadOnlyDict({
'auto_update': False,
'display_precision': 0,
'entity_picture': 'https://brands.home-assistant.io/_/fritz/icon.png',
'entity_picture': '/api/brands/integration/fritz/icon.png',
'friendly_name': 'Mock Title FRITZ!OS',
'in_progress': False,
'installed_version': '7.29',
@@ -101,7 +101,7 @@
'attributes': ReadOnlyDict({
'auto_update': False,
'display_precision': 0,
'entity_picture': 'https://brands.home-assistant.io/_/fritz/icon.png',
'entity_picture': '/api/brands/integration/fritz/icon.png',
'friendly_name': 'Mock Title FRITZ!OS',
'in_progress': False,
'installed_version': '7.29',
@@ -162,7 +162,7 @@
'attributes': ReadOnlyDict({
'auto_update': False,
'display_precision': 0,
'entity_picture': 'https://brands.home-assistant.io/_/fritz/icon.png',
'entity_picture': '/api/brands/integration/fritz/icon.png',
'friendly_name': 'Mock Title FRITZ!OS',
'in_progress': False,
'installed_version': '7.29',

View File

@@ -40,7 +40,7 @@
'attributes': ReadOnlyDict({
'auto_update': False,
'display_precision': 0,
'entity_picture': 'https://brands.home-assistant.io/_/immich/icon.png',
'entity_picture': '/api/brands/integration/immich/icon.png',
'friendly_name': 'Someone Version',
'in_progress': False,
'installed_version': 'v1.134.0',

View File

@@ -44,7 +44,7 @@
'auto_update': False,
'device_class': 'firmware',
'display_precision': 0,
'entity_picture': 'https://brands.home-assistant.io/_/iron_os/icon.png',
'entity_picture': '/api/brands/integration/iron_os/icon.png',
'friendly_name': 'Pinecil Firmware',
'in_progress': False,
'installed_version': 'v2.23',

View File

@@ -41,7 +41,7 @@
'auto_update': False,
'device_class': 'firmware',
'display_precision': 0,
'entity_picture': 'https://brands.home-assistant.io/_/lamarzocco/icon.png',
'entity_picture': '/api/brands/integration/lamarzocco/icon.png',
'friendly_name': 'GS012345 Gateway firmware',
'in_progress': False,
'installed_version': 'v5.0.9',
@@ -103,7 +103,7 @@
'auto_update': False,
'device_class': 'firmware',
'display_precision': 0,
'entity_picture': 'https://brands.home-assistant.io/_/lamarzocco/icon.png',
'entity_picture': '/api/brands/integration/lamarzocco/icon.png',
'friendly_name': 'GS012345 Machine firmware',
'in_progress': False,
'installed_version': 'v1.17',

View File

@@ -41,7 +41,7 @@
'auto_update': False,
'device_class': 'firmware',
'display_precision': 0,
'entity_picture': 'https://brands.home-assistant.io/_/lametric/icon.png',
'entity_picture': '/api/brands/integration/lametric/icon.png',
'friendly_name': "spyfly's LaMetric SKY Firmware",
'in_progress': False,
'installed_version': '3.0.13',

View File

@@ -94,7 +94,7 @@ async def test_root_object(hass: HomeAssistant) -> None:
assert item.media_class == MediaClass.APP
assert item.media_content_id == ""
assert item.media_content_type == lovelace_cast.DOMAIN
assert item.thumbnail == "https://brands.home-assistant.io/_/lovelace/logo.png"
assert item.thumbnail == "/api/brands/integration/lovelace/logo.png"
assert item.can_play is False
assert item.can_expand is True
@@ -130,7 +130,7 @@ async def test_browse_media(hass: HomeAssistant) -> None:
assert child_1.media_class == MediaClass.APP
assert child_1.media_content_id == lovelace_cast.DEFAULT_DASHBOARD
assert child_1.media_content_type == lovelace_cast.DOMAIN
assert child_1.thumbnail == "https://brands.home-assistant.io/_/lovelace/logo.png"
assert child_1.thumbnail == "/api/brands/integration/lovelace/logo.png"
assert child_1.can_play is True
assert child_1.can_expand is False
@@ -139,7 +139,7 @@ async def test_browse_media(hass: HomeAssistant) -> None:
assert child_2.media_class == MediaClass.APP
assert child_2.media_content_id == "yaml-with-views"
assert child_2.media_content_type == lovelace_cast.DOMAIN
assert child_2.thumbnail == "https://brands.home-assistant.io/_/lovelace/logo.png"
assert child_2.thumbnail == "/api/brands/integration/lovelace/logo.png"
assert child_2.can_play is True
assert child_2.can_expand is True
@@ -154,9 +154,7 @@ async def test_browse_media(hass: HomeAssistant) -> None:
assert grandchild_1.media_class == MediaClass.APP
assert grandchild_1.media_content_id == "yaml-with-views/0"
assert grandchild_1.media_content_type == lovelace_cast.DOMAIN
assert (
grandchild_1.thumbnail == "https://brands.home-assistant.io/_/lovelace/logo.png"
)
assert grandchild_1.thumbnail == "/api/brands/integration/lovelace/logo.png"
assert grandchild_1.can_play is True
assert grandchild_1.can_expand is False
@@ -165,9 +163,7 @@ async def test_browse_media(hass: HomeAssistant) -> None:
assert grandchild_2.media_class == MediaClass.APP
assert grandchild_2.media_content_id == "yaml-with-views/second-view"
assert grandchild_2.media_content_type == lovelace_cast.DOMAIN
assert (
grandchild_2.thumbnail == "https://brands.home-assistant.io/_/lovelace/logo.png"
)
assert grandchild_2.thumbnail == "/api/brands/integration/lovelace/logo.png"
assert grandchild_2.can_play is True
assert grandchild_2.can_expand is False

View File

@@ -40,7 +40,7 @@
'attributes': ReadOnlyDict({
'auto_update': False,
'display_precision': 0,
'entity_picture': 'https://brands.home-assistant.io/_/nextcloud/icon.png',
'entity_picture': '/api/brands/integration/nextcloud/icon.png',
'friendly_name': 'my.nc_url.local',
'in_progress': False,
'installed_version': '28.0.4.1',

View File

@@ -41,7 +41,7 @@
'auto_update': False,
'device_class': 'firmware',
'display_precision': 0,
'entity_picture': 'https://brands.home-assistant.io/_/paperless_ngx/icon.png',
'entity_picture': '/api/brands/integration/paperless_ngx/icon.png',
'friendly_name': 'Paperless-ngx Software',
'in_progress': False,
'installed_version': '2.3.0',

View File

@@ -40,7 +40,7 @@
'attributes': ReadOnlyDict({
'auto_update': False,
'display_precision': 0,
'entity_picture': 'https://brands.home-assistant.io/_/peblar/icon.png',
'entity_picture': '/api/brands/integration/peblar/icon.png',
'friendly_name': 'Peblar EV Charger Customization',
'in_progress': False,
'installed_version': 'Peblar-1.9',
@@ -102,7 +102,7 @@
'auto_update': False,
'device_class': 'firmware',
'display_precision': 0,
'entity_picture': 'https://brands.home-assistant.io/_/peblar/icon.png',
'entity_picture': '/api/brands/integration/peblar/icon.png',
'friendly_name': 'Peblar EV Charger Firmware',
'in_progress': False,
'installed_version': '1.6.1+1+WL-1',

View File

@@ -9,7 +9,7 @@
'media_class': 'directory',
'media_content_id': '',
'media_content_type': 'presets',
'thumbnail': 'https://brands.home-assistant.io/_/russound_rio/logo.png',
'thumbnail': '/api/brands/integration/russound_rio/logo.png',
'title': 'Presets',
}),
])

View File

@@ -41,7 +41,7 @@
'auto_update': False,
'device_class': 'firmware',
'display_precision': 0,
'entity_picture': 'https://brands.home-assistant.io/_/sensibo/icon.png',
'entity_picture': '/api/brands/integration/sensibo/icon.png',
'friendly_name': 'Bedroom Firmware',
'in_progress': False,
'installed_version': 'PUR00111',
@@ -103,7 +103,7 @@
'auto_update': False,
'device_class': 'firmware',
'display_precision': 0,
'entity_picture': 'https://brands.home-assistant.io/_/sensibo/icon.png',
'entity_picture': '/api/brands/integration/sensibo/icon.png',
'friendly_name': 'Hallway Firmware',
'in_progress': False,
'installed_version': 'SKY30046',
@@ -165,7 +165,7 @@
'auto_update': False,
'device_class': 'firmware',
'display_precision': 0,
'entity_picture': 'https://brands.home-assistant.io/_/sensibo/icon.png',
'entity_picture': '/api/brands/integration/sensibo/icon.png',
'friendly_name': 'Kitchen Firmware',
'in_progress': False,
'installed_version': 'PUR00111',

View File

@@ -930,7 +930,7 @@
'auto_update': False,
'device_class': 'firmware',
'display_precision': 0,
'entity_picture': 'https://brands.home-assistant.io/_/shelly/icon.png',
'entity_picture': '/api/brands/integration/shelly/icon.png',
'friendly_name': 'Test name Beta firmware',
'in_progress': False,
'installed_version': '1.8.99-dev144746',
@@ -992,7 +992,7 @@
'auto_update': False,
'device_class': 'firmware',
'display_precision': 0,
'entity_picture': 'https://brands.home-assistant.io/_/shelly/icon.png',
'entity_picture': '/api/brands/integration/shelly/icon.png',
'friendly_name': 'Test name Firmware',
'in_progress': False,
'installed_version': '1.8.99-dev144746',
@@ -1492,7 +1492,7 @@
'auto_update': False,
'device_class': 'firmware',
'display_precision': 0,
'entity_picture': 'https://brands.home-assistant.io/_/shelly/icon.png',
'entity_picture': '/api/brands/integration/shelly/icon.png',
'friendly_name': 'Test name Beta firmware',
'in_progress': False,
'installed_version': '1.8.99-dev144333',
@@ -1554,7 +1554,7 @@
'auto_update': False,
'device_class': 'firmware',
'display_precision': 0,
'entity_picture': 'https://brands.home-assistant.io/_/shelly/icon.png',
'entity_picture': '/api/brands/integration/shelly/icon.png',
'friendly_name': 'Test name Firmware',
'in_progress': False,
'installed_version': '1.8.99-dev144333',
@@ -4507,7 +4507,7 @@
'auto_update': False,
'device_class': 'firmware',
'display_precision': 0,
'entity_picture': 'https://brands.home-assistant.io/_/shelly/icon.png',
'entity_picture': '/api/brands/integration/shelly/icon.png',
'friendly_name': 'Test name Beta firmware',
'in_progress': False,
'installed_version': '1.8.99-dev134818',
@@ -4569,7 +4569,7 @@
'auto_update': False,
'device_class': 'firmware',
'display_precision': 0,
'entity_picture': 'https://brands.home-assistant.io/_/shelly/icon.png',
'entity_picture': '/api/brands/integration/shelly/icon.png',
'friendly_name': 'Test name Firmware',
'in_progress': False,
'installed_version': '1.8.99-dev134818',
@@ -5255,7 +5255,7 @@
'auto_update': False,
'device_class': 'firmware',
'display_precision': 0,
'entity_picture': 'https://brands.home-assistant.io/_/shelly/icon.png',
'entity_picture': '/api/brands/integration/shelly/icon.png',
'friendly_name': 'Test name Beta firmware',
'in_progress': False,
'installed_version': '1.8.99',
@@ -5317,7 +5317,7 @@
'auto_update': False,
'device_class': 'firmware',
'display_precision': 0,
'entity_picture': 'https://brands.home-assistant.io/_/shelly/icon.png',
'entity_picture': '/api/brands/integration/shelly/icon.png',
'friendly_name': 'Test name Firmware',
'in_progress': False,
'installed_version': '1.8.99',
@@ -6289,7 +6289,7 @@
'auto_update': False,
'device_class': 'firmware',
'display_precision': 0,
'entity_picture': 'https://brands.home-assistant.io/_/shelly/icon.png',
'entity_picture': '/api/brands/integration/shelly/icon.png',
'friendly_name': 'Test name Beta firmware',
'in_progress': False,
'installed_version': '2.4.4',
@@ -6351,7 +6351,7 @@
'auto_update': False,
'device_class': 'firmware',
'display_precision': 0,
'entity_picture': 'https://brands.home-assistant.io/_/shelly/icon.png',
'entity_picture': '/api/brands/integration/shelly/icon.png',
'friendly_name': 'Test name Firmware',
'in_progress': False,
'installed_version': '2.4.4',
@@ -7363,7 +7363,7 @@
'auto_update': False,
'device_class': 'firmware',
'display_precision': 0,
'entity_picture': 'https://brands.home-assistant.io/_/shelly/icon.png',
'entity_picture': '/api/brands/integration/shelly/icon.png',
'friendly_name': 'Test name Beta firmware',
'in_progress': False,
'installed_version': '1.6.1',
@@ -7425,7 +7425,7 @@
'auto_update': False,
'device_class': 'firmware',
'display_precision': 0,
'entity_picture': 'https://brands.home-assistant.io/_/shelly/icon.png',
'entity_picture': '/api/brands/integration/shelly/icon.png',
'friendly_name': 'Test name Firmware',
'in_progress': False,
'installed_version': '1.6.1',
@@ -9269,7 +9269,7 @@
'auto_update': False,
'device_class': 'firmware',
'display_precision': 0,
'entity_picture': 'https://brands.home-assistant.io/_/shelly/icon.png',
'entity_picture': '/api/brands/integration/shelly/icon.png',
'friendly_name': 'Test name Beta firmware',
'in_progress': False,
'installed_version': '1.6.1',
@@ -9331,7 +9331,7 @@
'auto_update': False,
'device_class': 'firmware',
'display_precision': 0,
'entity_picture': 'https://brands.home-assistant.io/_/shelly/icon.png',
'entity_picture': '/api/brands/integration/shelly/icon.png',
'friendly_name': 'Test name Firmware',
'in_progress': False,
'installed_version': '1.6.1',
@@ -11426,7 +11426,7 @@
'auto_update': False,
'device_class': 'firmware',
'display_precision': 0,
'entity_picture': 'https://brands.home-assistant.io/_/shelly/icon.png',
'entity_picture': '/api/brands/integration/shelly/icon.png',
'friendly_name': 'Test name Beta firmware',
'in_progress': False,
'installed_version': '1.6.1',
@@ -11488,7 +11488,7 @@
'auto_update': False,
'device_class': 'firmware',
'display_precision': 0,
'entity_picture': 'https://brands.home-assistant.io/_/shelly/icon.png',
'entity_picture': '/api/brands/integration/shelly/icon.png',
'friendly_name': 'Test name Firmware',
'in_progress': False,
'installed_version': '1.6.1',

View File

@@ -41,7 +41,7 @@
'auto_update': False,
'device_class': 'firmware',
'display_precision': 0,
'entity_picture': 'https://brands.home-assistant.io/_/smartthings/icon.png',
'entity_picture': '/api/brands/integration/smartthings/icon.png',
'friendly_name': 'aq-sensor-3-ikea Firmware',
'in_progress': False,
'installed_version': '00010010',
@@ -103,7 +103,7 @@
'auto_update': False,
'device_class': 'firmware',
'display_precision': 0,
'entity_picture': 'https://brands.home-assistant.io/_/smartthings/icon.png',
'entity_picture': '/api/brands/integration/smartthings/icon.png',
'friendly_name': 'Radiator Thermostat II [+M] Wohnzimmer Firmware',
'in_progress': False,
'installed_version': '2.00.09 (20009)',
@@ -165,7 +165,7 @@
'auto_update': False,
'device_class': 'firmware',
'display_precision': 0,
'entity_picture': 'https://brands.home-assistant.io/_/smartthings/icon.png',
'entity_picture': '/api/brands/integration/smartthings/icon.png',
'friendly_name': 'Dimmer Debian Firmware',
'in_progress': False,
'installed_version': '16015010',
@@ -227,7 +227,7 @@
'auto_update': False,
'device_class': 'firmware',
'display_precision': 0,
'entity_picture': 'https://brands.home-assistant.io/_/smartthings/icon.png',
'entity_picture': '/api/brands/integration/smartthings/icon.png',
'friendly_name': '.Front Door Open/Closed Sensor Firmware',
'in_progress': False,
'installed_version': '00000103',
@@ -289,7 +289,7 @@
'auto_update': False,
'device_class': 'firmware',
'display_precision': 0,
'entity_picture': 'https://brands.home-assistant.io/_/smartthings/icon.png',
'entity_picture': '/api/brands/integration/smartthings/icon.png',
'friendly_name': 'Kitchen IKEA KADRILJ Window blind Firmware',
'in_progress': False,
'installed_version': '22007631',
@@ -351,7 +351,7 @@
'auto_update': False,
'device_class': 'firmware',
'display_precision': 0,
'entity_picture': 'https://brands.home-assistant.io/_/smartthings/icon.png',
'entity_picture': '/api/brands/integration/smartthings/icon.png',
'friendly_name': 'Deck Door Firmware',
'in_progress': False,
'installed_version': '0000001B',
@@ -413,7 +413,7 @@
'auto_update': False,
'device_class': 'firmware',
'display_precision': 0,
'entity_picture': 'https://brands.home-assistant.io/_/smartthings/icon.png',
'entity_picture': '/api/brands/integration/smartthings/icon.png',
'friendly_name': 'Arlo Beta Basestation Firmware',
'in_progress': False,
'installed_version': '00102101',
@@ -475,7 +475,7 @@
'auto_update': False,
'device_class': 'firmware',
'display_precision': 0,
'entity_picture': 'https://brands.home-assistant.io/_/smartthings/icon.png',
'entity_picture': '/api/brands/integration/smartthings/icon.png',
'friendly_name': 'Basement Door Lock Firmware',
'in_progress': False,
'installed_version': '00840847',

View File

@@ -41,7 +41,7 @@
'auto_update': False,
'device_class': 'firmware',
'display_precision': 0,
'entity_picture': 'https://brands.home-assistant.io/_/smlight/icon.png',
'entity_picture': '/api/brands/integration/smlight/icon.png',
'friendly_name': 'Mock Title Core firmware',
'in_progress': False,
'installed_version': 'v2.3.6',
@@ -103,7 +103,7 @@
'auto_update': False,
'device_class': 'firmware',
'display_precision': 0,
'entity_picture': 'https://brands.home-assistant.io/_/smlight/icon.png',
'entity_picture': '/api/brands/integration/smlight/icon.png',
'friendly_name': 'Mock Title Zigbee firmware',
'in_progress': False,
'installed_version': '20240314',

View File

@@ -366,7 +366,7 @@
'media_class': 'directory',
'media_content_id': '',
'media_content_type': 'favorites',
'thumbnail': 'https://brands.home-assistant.io/_/sonos/logo.png',
'thumbnail': '/api/brands/integration/sonos/logo.png',
'title': 'Favorites',
}),
dict({
@@ -377,7 +377,7 @@
'media_class': 'directory',
'media_content_id': '',
'media_content_type': 'library',
'thumbnail': 'https://brands.home-assistant.io/_/sonos/logo.png',
'thumbnail': '/api/brands/integration/sonos/logo.png',
'title': 'Music Library',
}),
])

View File

@@ -204,7 +204,7 @@
'media_class': <MediaClass.APP: 'app'>,
'media_content_id': 'spotify://01J5TX5A0FF6G5V0QJX6HBC94T',
'media_content_type': 'spotify://library',
'thumbnail': 'https://brands.home-assistant.io/_/spotify/logo.png',
'thumbnail': '/api/brands/integration/spotify/logo.png',
'title': 'spotify_1',
}),
dict({
@@ -215,7 +215,7 @@
'media_class': <MediaClass.APP: 'app'>,
'media_content_id': 'spotify://32oesphrnacjcf7vw5bf6odx3',
'media_content_type': 'spotify://library',
'thumbnail': 'https://brands.home-assistant.io/_/spotify/logo.png',
'thumbnail': '/api/brands/integration/spotify/logo.png',
'title': 'spotify_2',
}),
]),
@@ -224,7 +224,7 @@
'media_content_id': 'spotify://',
'media_content_type': 'spotify',
'not_shown': 0,
'thumbnail': 'https://brands.home-assistant.io/_/spotify/logo.png',
'thumbnail': '/api/brands/integration/spotify/logo.png',
'title': 'Spotify',
})
# ---

View File

@@ -4,7 +4,7 @@
'attributes': ReadOnlyDict({
'auto_update': False,
'display_precision': 0,
'entity_picture': 'https://brands.home-assistant.io/_/template/icon.png',
'entity_picture': '/api/brands/integration/template/icon.png',
'friendly_name': 'template_update',
'in_progress': False,
'installed_version': '1.0',

View File

@@ -272,7 +272,7 @@ async def test_update_templates(
# ensure that the entity picture exists when not provided.
assert (
state.attributes["entity_picture"]
== "https://brands.home-assistant.io/_/template/icon.png"
== "/api/brands/integration/template/icon.png"
)
@@ -524,7 +524,7 @@ async def test_entity_picture_uses_default(hass: HomeAssistant) -> None:
assert (
state.attributes[ATTR_ENTITY_PICTURE]
== "https://brands.home-assistant.io/_/template/icon.png"
== "/api/brands/integration/template/icon.png"
)

View File

@@ -40,7 +40,7 @@
'attributes': ReadOnlyDict({
'auto_update': False,
'display_precision': 0,
'entity_picture': 'https://brands.home-assistant.io/_/tesla_fleet/icon.png',
'entity_picture': '/api/brands/integration/tesla_fleet/icon.png',
'friendly_name': 'Test Update',
'in_progress': False,
'installed_version': '2023.44.30.8',
@@ -101,7 +101,7 @@
'attributes': ReadOnlyDict({
'auto_update': False,
'display_precision': 0,
'entity_picture': 'https://brands.home-assistant.io/_/tesla_fleet/icon.png',
'entity_picture': '/api/brands/integration/tesla_fleet/icon.png',
'friendly_name': 'Test Update',
'in_progress': False,
'installed_version': '2023.44.30.8',

View File

@@ -40,7 +40,7 @@
'attributes': ReadOnlyDict({
'auto_update': False,
'display_precision': 0,
'entity_picture': 'https://brands.home-assistant.io/_/teslemetry/icon.png',
'entity_picture': '/api/brands/integration/teslemetry/icon.png',
'friendly_name': 'Test Update',
'in_progress': False,
'installed_version': '2026.0.0',
@@ -101,7 +101,7 @@
'attributes': ReadOnlyDict({
'auto_update': False,
'display_precision': 0,
'entity_picture': 'https://brands.home-assistant.io/_/teslemetry/icon.png',
'entity_picture': '/api/brands/integration/teslemetry/icon.png',
'friendly_name': 'Test Update',
'in_progress': False,
'installed_version': '2024.44.25',
@@ -126,7 +126,7 @@
'attributes': ReadOnlyDict({
'auto_update': False,
'display_precision': 0,
'entity_picture': 'https://brands.home-assistant.io/_/teslemetry/icon.png',
'entity_picture': '/api/brands/integration/teslemetry/icon.png',
'friendly_name': 'Test Update',
'in_progress': False,
'installed_version': '2025.1.1',
@@ -151,7 +151,7 @@
'attributes': ReadOnlyDict({
'auto_update': False,
'display_precision': 0,
'entity_picture': 'https://brands.home-assistant.io/_/teslemetry/icon.png',
'entity_picture': '/api/brands/integration/teslemetry/icon.png',
'friendly_name': 'Test Update',
'in_progress': False,
'installed_version': '2025.1.1',
@@ -176,7 +176,7 @@
'attributes': ReadOnlyDict({
'auto_update': False,
'display_precision': 0,
'entity_picture': 'https://brands.home-assistant.io/_/teslemetry/icon.png',
'entity_picture': '/api/brands/integration/teslemetry/icon.png',
'friendly_name': 'Test Update',
'in_progress': False,
'installed_version': '2025.1.1',
@@ -201,7 +201,7 @@
'attributes': ReadOnlyDict({
'auto_update': False,
'display_precision': 0,
'entity_picture': 'https://brands.home-assistant.io/_/teslemetry/icon.png',
'entity_picture': '/api/brands/integration/teslemetry/icon.png',
'friendly_name': 'Test Update',
'in_progress': False,
'installed_version': '2025.2.1',
@@ -226,7 +226,7 @@
'attributes': ReadOnlyDict({
'auto_update': False,
'display_precision': 0,
'entity_picture': 'https://brands.home-assistant.io/_/teslemetry/icon.png',
'entity_picture': '/api/brands/integration/teslemetry/icon.png',
'friendly_name': 'Test Update',
'in_progress': False,
'installed_version': '2025.2.1',

View File

@@ -40,7 +40,7 @@
'attributes': ReadOnlyDict({
'auto_update': False,
'display_precision': 0,
'entity_picture': 'https://brands.home-assistant.io/_/tessie/icon.png',
'entity_picture': '/api/brands/integration/tessie/icon.png',
'friendly_name': 'Test Update',
'in_progress': False,
'installed_version': '2023.38.6',

View File

@@ -41,7 +41,7 @@
'auto_update': False,
'device_class': 'firmware',
'display_precision': 0,
'entity_picture': 'https://brands.home-assistant.io/_/tplink_omada/icon.png',
'entity_picture': '/api/brands/integration/tplink_omada/icon.png',
'friendly_name': 'Test PoE Switch Firmware',
'in_progress': False,
'installed_version': '1.0.12 Build 20230203 Rel.36545',
@@ -103,7 +103,7 @@
'auto_update': False,
'device_class': 'firmware',
'display_precision': 0,
'entity_picture': 'https://brands.home-assistant.io/_/tplink_omada/icon.png',
'entity_picture': '/api/brands/integration/tplink_omada/icon.png',
'friendly_name': 'Test Router Firmware',
'in_progress': False,
'installed_version': '1.1.1 Build 20230901 Rel.55651',

View File

@@ -79,7 +79,7 @@ async def test_browsing(hass: HomeAssistant, setup: str) -> None:
assert item_child.children is None
assert item_child.can_play is False
assert item_child.can_expand is True
assert item_child.thumbnail == "https://brands.home-assistant.io/_/test/logo.png"
assert item_child.thumbnail == "/api/brands/integration/test/logo.png"
item_child = await media_source.async_browse_media(
hass, item.children[0].media_content_id + "?message=bla"

View File

@@ -41,7 +41,7 @@
'auto_update': False,
'device_class': 'firmware',
'display_precision': 0,
'entity_picture': 'https://brands.home-assistant.io/_/unifi/icon.png',
'entity_picture': '/api/brands/integration/unifi/icon.png',
'friendly_name': 'Device 1',
'in_progress': False,
'installed_version': '4.0.42.10433',
@@ -103,7 +103,7 @@
'auto_update': False,
'device_class': 'firmware',
'display_precision': 0,
'entity_picture': 'https://brands.home-assistant.io/_/unifi/icon.png',
'entity_picture': '/api/brands/integration/unifi/icon.png',
'friendly_name': 'Device 2',
'in_progress': False,
'installed_version': '4.0.42.10433',
@@ -165,7 +165,7 @@
'auto_update': False,
'device_class': 'firmware',
'display_precision': 0,
'entity_picture': 'https://brands.home-assistant.io/_/unifi/icon.png',
'entity_picture': '/api/brands/integration/unifi/icon.png',
'friendly_name': 'Device 1',
'in_progress': False,
'installed_version': '4.0.42.10433',
@@ -227,7 +227,7 @@
'auto_update': False,
'device_class': 'firmware',
'display_precision': 0,
'entity_picture': 'https://brands.home-assistant.io/_/unifi/icon.png',
'entity_picture': '/api/brands/integration/unifi/icon.png',
'friendly_name': 'Device 2',
'in_progress': False,
'installed_version': '4.0.42.10433',

View File

@@ -81,10 +81,7 @@ async def test_update(hass: HomeAssistant) -> None:
update._attr_title = "Title"
assert update.entity_category is EntityCategory.DIAGNOSTIC
assert (
update.entity_picture
== "https://brands.home-assistant.io/_/test_platform/icon.png"
)
assert update.entity_picture == "/api/brands/integration/test_platform/icon.png"
assert update.installed_version == "1.0.0"
assert update.latest_version == "1.0.1"
assert update.release_summary == "Summary"
@@ -991,7 +988,7 @@ async def test_update_percentage_backwards_compatibility(
expected_attributes = {
ATTR_AUTO_UPDATE: False,
ATTR_DISPLAY_PRECISION: 0,
ATTR_ENTITY_PICTURE: "https://brands.home-assistant.io/_/test/icon.png",
ATTR_ENTITY_PICTURE: "/api/brands/integration/test/icon.png",
ATTR_FRIENDLY_NAME: "legacy",
ATTR_INSTALLED_VERSION: "1.0.0",
ATTR_IN_PROGRESS: False,

View File

@@ -40,8 +40,7 @@ async def test_exclude_attributes(
assert state.attributes[ATTR_IN_PROGRESS] is True
assert state.attributes[ATTR_UPDATE_PERCENTAGE] == 50
assert (
state.attributes[ATTR_ENTITY_PICTURE]
== "https://brands.home-assistant.io/_/test/icon.png"
state.attributes[ATTR_ENTITY_PICTURE] == "/api/brands/integration/test/icon.png"
)
await async_setup_component(hass, DOMAIN, {})

View File

@@ -40,7 +40,7 @@
'attributes': ReadOnlyDict({
'auto_update': False,
'display_precision': 0,
'entity_picture': 'https://brands.home-assistant.io/_/uptime_kuma/icon.png',
'entity_picture': '/api/brands/integration/uptime_kuma/icon.png',
'friendly_name': 'uptime.example.org Uptime Kuma version',
'in_progress': False,
'installed_version': '2.0.0',

View File

@@ -485,7 +485,7 @@
'state': dict({
'attributes': dict({
'device_class': 'firmware',
'entity_picture': 'https://brands.home-assistant.io/_/vesync/icon.png',
'entity_picture': '/api/brands/integration/vesync/icon.png',
'friendly_name': 'Test Fan Firmware',
'supported_features': 0,
}),

View File

@@ -76,7 +76,7 @@
'auto_update': False,
'device_class': 'firmware',
'display_precision': 0,
'entity_picture': 'https://brands.home-assistant.io/_/vesync/icon.png',
'entity_picture': '/api/brands/integration/vesync/icon.png',
'friendly_name': 'Air Purifier 131s Firmware',
'in_progress': False,
'installed_version': '1.0.0',
@@ -173,7 +173,7 @@
'auto_update': False,
'device_class': 'firmware',
'display_precision': 0,
'entity_picture': 'https://brands.home-assistant.io/_/vesync/icon.png',
'entity_picture': '/api/brands/integration/vesync/icon.png',
'friendly_name': 'Air Purifier 200s Firmware',
'in_progress': False,
'installed_version': '1.0.0',
@@ -270,7 +270,7 @@
'auto_update': False,
'device_class': 'firmware',
'display_precision': 0,
'entity_picture': 'https://brands.home-assistant.io/_/vesync/icon.png',
'entity_picture': '/api/brands/integration/vesync/icon.png',
'friendly_name': 'Air Purifier 400s Firmware',
'in_progress': False,
'installed_version': '1.0.0',
@@ -367,7 +367,7 @@
'auto_update': False,
'device_class': 'firmware',
'display_precision': 0,
'entity_picture': 'https://brands.home-assistant.io/_/vesync/icon.png',
'entity_picture': '/api/brands/integration/vesync/icon.png',
'friendly_name': 'Air Purifier 600s Firmware',
'in_progress': False,
'installed_version': '1.0.0',
@@ -464,7 +464,7 @@
'auto_update': False,
'device_class': 'firmware',
'display_precision': 0,
'entity_picture': 'https://brands.home-assistant.io/_/vesync/icon.png',
'entity_picture': '/api/brands/integration/vesync/icon.png',
'friendly_name': 'CS158-AF Air Fryer Cooking Firmware',
'in_progress': False,
'installed_version': None,
@@ -561,7 +561,7 @@
'auto_update': False,
'device_class': 'firmware',
'display_precision': 0,
'entity_picture': 'https://brands.home-assistant.io/_/vesync/icon.png',
'entity_picture': '/api/brands/integration/vesync/icon.png',
'friendly_name': 'CS158-AF Air Fryer Standby Firmware',
'in_progress': False,
'installed_version': None,
@@ -656,7 +656,7 @@
StateSnapshot({
'attributes': ReadOnlyDict({
'device_class': 'firmware',
'entity_picture': 'https://brands.home-assistant.io/_/vesync/icon.png',
'entity_picture': '/api/brands/integration/vesync/icon.png',
'friendly_name': 'Dimmable Light Firmware',
'supported_features': <UpdateEntityFeature: 0>,
}),
@@ -745,7 +745,7 @@
'auto_update': False,
'device_class': 'firmware',
'display_precision': 0,
'entity_picture': 'https://brands.home-assistant.io/_/vesync/icon.png',
'entity_picture': '/api/brands/integration/vesync/icon.png',
'friendly_name': 'Dimmer Switch Firmware',
'in_progress': False,
'installed_version': '1.0.0',
@@ -842,7 +842,7 @@
'auto_update': False,
'device_class': 'firmware',
'display_precision': 0,
'entity_picture': 'https://brands.home-assistant.io/_/vesync/icon.png',
'entity_picture': '/api/brands/integration/vesync/icon.png',
'friendly_name': 'Humidifier 200s Firmware',
'in_progress': False,
'installed_version': '1.0.0',
@@ -939,7 +939,7 @@
'auto_update': False,
'device_class': 'firmware',
'display_precision': 0,
'entity_picture': 'https://brands.home-assistant.io/_/vesync/icon.png',
'entity_picture': '/api/brands/integration/vesync/icon.png',
'friendly_name': 'Humidifier 6000s Firmware',
'in_progress': False,
'installed_version': None,
@@ -1036,7 +1036,7 @@
'auto_update': False,
'device_class': 'firmware',
'display_precision': 0,
'entity_picture': 'https://brands.home-assistant.io/_/vesync/icon.png',
'entity_picture': '/api/brands/integration/vesync/icon.png',
'friendly_name': 'Humidifier 600S Firmware',
'in_progress': False,
'installed_version': '1.0.0',
@@ -1133,7 +1133,7 @@
'auto_update': False,
'device_class': 'firmware',
'display_precision': 0,
'entity_picture': 'https://brands.home-assistant.io/_/vesync/icon.png',
'entity_picture': '/api/brands/integration/vesync/icon.png',
'friendly_name': 'Outlet Firmware',
'in_progress': False,
'installed_version': '1.0.0',
@@ -1230,7 +1230,7 @@
'auto_update': False,
'device_class': 'firmware',
'display_precision': 0,
'entity_picture': 'https://brands.home-assistant.io/_/vesync/icon.png',
'entity_picture': '/api/brands/integration/vesync/icon.png',
'friendly_name': 'SmartTowerFan Firmware',
'in_progress': False,
'installed_version': '1.0.0',
@@ -1327,7 +1327,7 @@
'auto_update': False,
'device_class': 'firmware',
'display_precision': 0,
'entity_picture': 'https://brands.home-assistant.io/_/vesync/icon.png',
'entity_picture': '/api/brands/integration/vesync/icon.png',
'friendly_name': 'Temperature Light Firmware',
'in_progress': False,
'installed_version': '1.0.0',
@@ -1424,7 +1424,7 @@
'auto_update': False,
'device_class': 'firmware',
'display_precision': 0,
'entity_picture': 'https://brands.home-assistant.io/_/vesync/icon.png',
'entity_picture': '/api/brands/integration/vesync/icon.png',
'friendly_name': 'Wall Switch Firmware',
'in_progress': False,
'installed_version': '1.0.0',

View File

@@ -5,7 +5,7 @@
'auto_update': False,
'device_class': 'firmware',
'display_precision': 0,
'entity_picture': 'https://brands.home-assistant.io/_/wled/icon.png',
'entity_picture': '/api/brands/integration/wled/icon.png',
'friendly_name': 'WLED WebSocket Firmware',
'in_progress': False,
'installed_version': '0.99.0',
@@ -31,7 +31,7 @@
'auto_update': False,
'device_class': 'firmware',
'display_precision': 0,
'entity_picture': 'https://brands.home-assistant.io/_/wled/icon.png',
'entity_picture': '/api/brands/integration/wled/icon.png',
'friendly_name': 'WLED RGB Light Firmware',
'in_progress': False,
'installed_version': '0.14.4',
@@ -93,7 +93,7 @@
'auto_update': False,
'device_class': 'firmware',
'display_precision': 0,
'entity_picture': 'https://brands.home-assistant.io/_/wled/icon.png',
'entity_picture': '/api/brands/integration/wled/icon.png',
'friendly_name': 'WLED RGB Light Firmware',
'in_progress': False,
'installed_version': '0.14.4',
@@ -119,7 +119,7 @@
'auto_update': False,
'device_class': 'firmware',
'display_precision': 0,
'entity_picture': 'https://brands.home-assistant.io/_/wled/icon.png',
'entity_picture': '/api/brands/integration/wled/icon.png',
'friendly_name': 'WLED RGB Light Firmware',
'in_progress': False,
'installed_version': '0.14.4',