mirror of
https://github.com/home-assistant/core.git
synced 2026-02-25 11:41:30 +01:00
Compare commits
6 Commits
dev
...
frenck-202
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e3351970be | ||
|
|
faff4f8e40 | ||
|
|
a2d996d3c1 | ||
|
|
71e7efe94d | ||
|
|
37ca3984f9 | ||
|
|
5b40c34848 |
2
CODEOWNERS
generated
2
CODEOWNERS
generated
@@ -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
|
||||
|
||||
@@ -210,6 +210,7 @@ DEFAULT_INTEGRATIONS = {
|
||||
"analytics", # Needed for onboarding
|
||||
"application_credentials",
|
||||
"backup",
|
||||
"brands",
|
||||
"frontend",
|
||||
"hardware",
|
||||
"labs",
|
||||
|
||||
291
homeassistant/components/brands/__init__.py
Normal file
291
homeassistant/components/brands/__init__.py
Normal 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,
|
||||
)
|
||||
57
homeassistant/components/brands/const.py
Normal file
57
homeassistant/components/brands/const.py
Normal 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",
|
||||
],
|
||||
}
|
||||
10
homeassistant/components/brands/manifest.json
Normal file
10
homeassistant/components/brands/manifest.json
Normal 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"
|
||||
}
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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."""
|
||||
|
||||
@@ -62,6 +62,7 @@ NO_IOT_CLASS = [
|
||||
"auth",
|
||||
"automation",
|
||||
"blueprint",
|
||||
"brands",
|
||||
"color_extractor",
|
||||
"config",
|
||||
"configurator",
|
||||
|
||||
@@ -2106,6 +2106,7 @@ NO_QUALITY_SCALE = [
|
||||
"auth",
|
||||
"automation",
|
||||
"blueprint",
|
||||
"brands",
|
||||
"config",
|
||||
"configurator",
|
||||
"counter",
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
1
tests/components/brands/__init__.py
Normal file
1
tests/components/brands/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
"""Tests for the Brands integration."""
|
||||
20
tests/components/brands/conftest.py
Normal file
20
tests/components/brands/conftest.py
Normal 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
|
||||
903
tests/components/brands/test_init.py
Normal file
903
tests/components/brands/test_init.py
Normal 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]
|
||||
@@ -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',
|
||||
}),
|
||||
])
|
||||
|
||||
@@ -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"]
|
||||
|
||||
@@ -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"
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
}),
|
||||
])
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
}),
|
||||
])
|
||||
|
||||
@@ -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',
|
||||
})
|
||||
# ---
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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"
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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, {})
|
||||
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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,
|
||||
}),
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
Reference in New Issue
Block a user