Compare commits

..

28 Commits

Author SHA1 Message Date
Franck Nijhof ff25428e56 Fix undefined DOMAIN in image upload tests 2026-06-12 19:12:56 +00:00
Franck Nijhof 608acd422f Bump version to 2026.6.3 2026-06-12 18:33:03 +00:00
Franck Nijhof c860e83ec9 Disambiguate duplicate channel names in LG Netcast source list (#173560) 2026-06-12 18:32:03 +00:00
Franck Nijhof c9f3f4a265 Sort aliases in LLM prompts for stable prefix caching (#173558) 2026-06-12 18:32:01 +00:00
Franck Nijhof e346a801d1 Return enum values from config_entry_attr template function (#173554) 2026-06-12 18:31:59 +00:00
Franck Nijhof a5c193931f Fix Rituals Perfume Genie sw_version dict passed to device registry (#173552) 2026-06-12 18:31:57 +00:00
Franck Nijhof d273350db1 Suppress InsecureKeyLengthWarning in HTML5 push notifications (#173551) 2026-06-12 18:31:55 +00:00
Franck Nijhof 45f27b8b6e Fix Yale Smart Living panic button unique_id for multiple hubs (#173547) 2026-06-12 18:31:53 +00:00
Franck Nijhof d3208a420f Convert OpenGarage sw_version to string for device registry (#173546) 2026-06-12 18:31:51 +00:00
Franck Nijhof d0d35e380f Convert RainMachine hw_version to string for device registry (#173545) 2026-06-12 18:31:49 +00:00
Franck Nijhof 2735e58d7f Convert JPEG-incompatible image modes to RGB in image upload thumbnail generation (#173538) 2026-06-12 18:31:47 +00:00
Franck Nijhof ad3eab80c3 Fix iCloud RuntimeError on unload by running cancel in executor (#173537) 2026-06-12 18:31:45 +00:00
Franck Nijhof 18e5d284b4 Fix Hue grouped light icon by adding translation_key (#173536) 2026-06-12 18:31:43 +00:00
Franck Nijhof e5052eaf44 Fix Hue light level sensor crash on None value (#173532) 2026-06-12 18:31:41 +00:00
Ernst Klamer 62c2e8d2fd Bump bthome-ble to 3.23.4 (#173526) 2026-06-12 18:31:39 +00:00
Bram Kragten 1f505067dd Update frontend to 20260527.6 (#173522) 2026-06-12 18:31:37 +00:00
Stefan Agner 72875b3b5e Refresh preferred Thread border agent address on OTBR reconnect (#173508)
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-authored-by: Martin Hjelmare <marhje52@gmail.com>
Co-authored-by: puddly <32534428+puddly@users.noreply.github.com>
2026-06-12 18:31:35 +00:00
renovate[bot] 3be755e496 Update hassil to 3.7.0 (#173484)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-06-12 18:31:33 +00:00
Michael Hansen 5285798052 Bump hassil to 3.6.0 (#173031) 2026-06-12 18:31:31 +00:00
Diogo Gomes da49e37946 Bump pytrydan to 1.0.2 (#173479) 2026-06-12 18:28:13 +00:00
Simone Chemelli 2f9de98f2d Bump aioamazondevices to 14.0.3 (#173478) 2026-06-12 18:28:09 +00:00
starkillerOG 383a6426fc Bump reolink_aio to 0.21.0 (#173477) 2026-06-12 18:28:06 +00:00
Robert Resch 5ed60cd057 Revert "Unify query token auth in http views" (#173466) 2026-06-12 18:28:04 +00:00
Tom Cassady a1250b7bfb Fix UniFi Protect ufp_set debug log printing UndefinedType for translation-key entities (#173460) 2026-06-12 18:28:02 +00:00
Simone Chemelli 240e5219ad Redact more fields in diagnostics for Alexa devices (#173446) 2026-06-12 18:28:00 +00:00
Simone Chemelli 418f352ce7 Change update interval for UptimeRobot (#173435) 2026-06-12 18:27:58 +00:00
Jan Bouwhuis 599967b1d8 Do not enable MQTT entities though discovery that were disabled by user (#173404) 2026-06-12 18:27:56 +00:00
Nikolai Rahimi ad82729357 Add debug logging for Mitsubishi Comfort polling failures (#173364) 2026-06-12 18:27:54 +00:00
61 changed files with 653 additions and 298 deletions
@@ -12,7 +12,18 @@ from homeassistant.helpers.device_registry import DeviceEntry
from .coordinator import AmazonConfigEntry
TO_REDACT = {CONF_PASSWORD, CONF_USERNAME, CONF_NAME, "title"}
TO_REDACT = {
CONF_NAME,
CONF_PASSWORD,
CONF_USERNAME,
"access_token",
"adp_token",
"device_private_key",
"refresh_token",
"store_authentication_cookie",
"title",
"website_cookies",
}
async def async_get_config_entry_diagnostics(
@@ -8,5 +8,5 @@
"iot_class": "cloud_polling",
"loggers": ["aioamazondevices"],
"quality_scale": "platinum",
"requirements": ["aioamazondevices==14.0.0"]
"requirements": ["aioamazondevices==14.0.3"]
}
@@ -6,5 +6,5 @@
"documentation": "https://www.home-assistant.io/integrations/assist_satellite",
"integration_type": "entity",
"quality_scale": "internal",
"requirements": ["hassil==3.5.0"]
"requirements": ["hassil==3.7.0"]
}
+18 -10
View File
@@ -1,19 +1,18 @@
"""The Brands integration."""
from collections import deque
from collections.abc import Container, Mapping
from http import HTTPStatus
import logging
from pathlib import Path
from random import SystemRandom
import time
from typing import Any, Final, override
from typing import Any, Final
from aiohttp import ClientError, web
from aiohttp import ClientError, hdrs, web
import voluptuous as vol
from homeassistant.components import websocket_api
from homeassistant.components.http import HomeAssistantView
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
@@ -109,18 +108,23 @@ def _read_brand_file(brand_dir: Path, image: str) -> bytes | None:
class _BrandsBaseView(HomeAssistantView):
"""Base view for serving brand images."""
use_query_token_for_auth = True
requires_auth = False
def __init__(self, hass: HomeAssistant) -> None:
"""Initialize the view."""
self._hass = hass
self._cache_dir = Path(hass.config.cache_path(DOMAIN))
@callback
@override
def get_valid_auth_tokens(self, match_info: Mapping[str, str]) -> Container[str]:
"""Return valid auth tokens, which can be used for query token authentication."""
return self._hass.data[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,
@@ -236,6 +240,8 @@ class BrandsIntegrationView(_BrandsBaseView):
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)
@@ -268,6 +274,8 @@ class BrandsHardwareView(_BrandsBaseView):
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"
@@ -20,5 +20,5 @@
"dependencies": ["bluetooth_adapters"],
"documentation": "https://www.home-assistant.io/integrations/bthome",
"iot_class": "local_push",
"requirements": ["bthome-ble==3.23.2"]
"requirements": ["bthome-ble==3.23.4"]
}
+18 -14
View File
@@ -2,7 +2,7 @@
import asyncio
import collections
from collections.abc import Awaitable, Callable, Container, Coroutine, Mapping
from collections.abc import Awaitable, Callable, Coroutine
from contextlib import suppress
from dataclasses import asdict, dataclass
from datetime import datetime, timedelta
@@ -12,16 +12,16 @@ import logging
import os
from random import SystemRandom
import time
from typing import Any, Final, final, override
from typing import Any, Final, final
from aiohttp import web
from aiohttp import hdrs, web
import attr
from propcache.api import cached_property, under_cached_property
import voluptuous as vol
from webrtc_models import RTCIceCandidateInit
from homeassistant.components import websocket_api
from homeassistant.components.http import HomeAssistantView
from homeassistant.components.http import KEY_AUTHENTICATED, HomeAssistantView
from homeassistant.components.media_player import (
ATTR_MEDIA_CONTENT_ID,
ATTR_MEDIA_CONTENT_TYPE,
@@ -776,26 +776,30 @@ class Camera(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_):
class CameraView(HomeAssistantView):
"""Base CameraView."""
use_query_token_for_auth = True
requires_auth = False
def __init__(self, component: EntityComponent[Camera]) -> None:
"""Initialize a basic camera view."""
self.component = component
@callback
@override
def get_valid_auth_tokens(self, match_info: Mapping[str, str]) -> Container[str]:
"""Return valid auth tokens, which can be used for query token authentication."""
if (camera := self.component.get_entity(match_info["entity_id"])) is None:
return ()
return camera.access_tokens
async def get(self, request: web.Request, entity_id: str) -> web.StreamResponse:
"""Start a GET request."""
if (camera := self.component.get_entity(entity_id)) is None:
raise web.HTTPNotFound
authenticated = (
request[KEY_AUTHENTICATED]
or request.query.get("token") in camera.access_tokens
)
if not authenticated:
# Attempt with invalid bearer token, raise unauthorized
# so ban middleware can handle it.
if hdrs.AUTHORIZATION in request.headers:
raise web.HTTPUnauthorized
# Invalid sigAuth or camera access token
raise web.HTTPForbidden
if not camera.is_on:
_LOGGER.debug("Camera is off")
raise web.HTTPServiceUnavailable
@@ -6,5 +6,5 @@
"documentation": "https://www.home-assistant.io/integrations/conversation",
"integration_type": "entity",
"quality_scale": "internal",
"requirements": ["hassil==3.5.0", "home-assistant-intents==2026.6.1"]
"requirements": ["hassil==3.7.0", "home-assistant-intents==2026.6.1"]
}
@@ -21,5 +21,5 @@
"integration_type": "system",
"preview_features": { "winter_mode": {} },
"quality_scale": "internal",
"requirements": ["home-assistant-frontend==20260527.5"]
"requirements": ["home-assistant-frontend==20260527.6"]
}
+7 -2
View File
@@ -9,10 +9,12 @@ import time
from typing import TYPE_CHECKING, Any, cast
from urllib.parse import urlparse
import uuid
import warnings
from aiohttp import ClientError, ClientResponse, ClientSession, web
from aiohttp.hdrs import AUTHORIZATION
import jwt
from jwt.warnings import InsecureKeyLengthWarning
from py_vapid import Vapid
from pywebpush import WebPusher, WebPushException, webpush_async
import voluptuous as vol
@@ -325,7 +327,8 @@ class HTML5PushCallbackView(HomeAssistantView):
if target_check.get(ATTR_TARGET) in self.registrations:
possible_target = self.registrations[target_check[ATTR_TARGET]]
key = possible_target["subscription"]["keys"]["auth"]
with suppress(jwt.exceptions.DecodeError):
with suppress(jwt.exceptions.DecodeError), warnings.catch_warnings():
warnings.simplefilter("ignore", InsecureKeyLengthWarning)
return jwt.decode(token, key, algorithms=["ES256", "HS256"])
return self.json_message(
@@ -585,7 +588,9 @@ def add_jwt(timestamp: int, target: str, tag: str, jwt_secret: str) -> str:
ATTR_TARGET: target,
ATTR_TAG: tag,
}
return jwt.encode(jwt_claims, jwt_secret)
with warnings.catch_warnings():
warnings.simplefilter("ignore", InsecureKeyLengthWarning)
return jwt.encode(jwt_claims, jwt_secret)
async def async_setup_entry(
+1
View File
@@ -85,6 +85,7 @@ class GroupedHueLight(HueBaseEntity, LightEntity):
entity_description = LightEntityDescription(
key="hue_grouped_light",
translation_key="hue_grouped_light",
has_entity_name=True,
name=None,
)
+3 -1
View File
@@ -166,8 +166,10 @@ class HueLightLevelSensor(HueSensorBase):
)
@property
def native_value(self) -> int:
def native_value(self) -> int | None:
"""Return the value reported by the sensor."""
if self.resource.light.value is None:
return None
# Light level in 10000 log10 (lux) +1 measured by sensor. Logarithm
# scale used because the human eye adjusts to light levels and small
# changes at low lux levels are more noticeable than at high lux
+3 -2
View File
@@ -59,7 +59,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: IcloudConfigEntry) -> bo
await hass.async_add_executor_job(account.setup)
entry.runtime_data = account
entry.async_on_unload(account.cancel_fetch)
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
@@ -68,4 +67,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: IcloudConfigEntry) -> bo
async def async_unload_entry(hass: HomeAssistant, entry: IcloudConfigEntry) -> bool:
"""Unload a config entry."""
return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS):
await hass.async_add_executor_job(entry.runtime_data.cancel_fetch)
return unload_ok
+23 -19
View File
@@ -2,21 +2,20 @@
import asyncio
import collections
from collections.abc import Container, Mapping
from contextlib import suppress
from dataclasses import dataclass
from datetime import datetime, timedelta
import logging
import os
from random import SystemRandom
from typing import Final, final, override
from typing import Final, final
from aiohttp import web
from aiohttp import hdrs, web
import httpx
from propcache.api import cached_property
import voluptuous as vol
from homeassistant.components.http import KEY_HASS, HomeAssistantView
from homeassistant.components.http import KEY_AUTHENTICATED, KEY_HASS, HomeAssistantView
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONTENT_TYPE_MULTIPART, EVENT_HOMEASSISTANT_STOP
from homeassistant.core import (
@@ -315,28 +314,33 @@ class ImageView(HomeAssistantView):
"""View to serve an image."""
name = "api:image:image"
use_query_token_for_auth = True
requires_auth = False
url = "/api/image_proxy/{entity_id}"
def __init__(self, component: EntityComponent[ImageEntity]) -> None:
"""Initialize an image view."""
self.component = component
@callback
@override
def get_valid_auth_tokens(self, match_info: Mapping[str, str]) -> Container[str]:
"""Return valid auth tokens, which can be used for query token authentication."""
if (image_entity := self.component.get_entity(match_info["entity_id"])) is None:
return ()
return image_entity.access_tokens
@callback
def _get_image_entity(self, entity_id: str) -> ImageEntity:
"""Get image entity from request."""
async def _authenticate_request(
self, request: web.Request, entity_id: str
) -> ImageEntity:
"""Authenticate request and return image entity."""
if (image_entity := self.component.get_entity(entity_id)) is None:
raise web.HTTPNotFound
authenticated = (
request[KEY_AUTHENTICATED]
or request.query.get("token") in image_entity.access_tokens
)
if not authenticated:
# Attempt with invalid bearer token, raise unauthorized
# so ban middleware can handle it.
if hdrs.AUTHORIZATION in request.headers:
raise web.HTTPUnauthorized
# Invalid sigAuth or image entity access token
raise web.HTTPForbidden
return image_entity
async def head(self, request: web.Request, entity_id: str) -> web.Response:
@@ -345,7 +349,7 @@ class ImageView(HomeAssistantView):
This is sent by some DLNA renderers, like Samsung ones, prior to sending
the GET request.
"""
image_entity = self._get_image_entity(entity_id)
image_entity = await self._authenticate_request(request, entity_id)
# Don't use `handle` as we don't care about the stream case, we only want
# to verify that the image exists.
@@ -361,7 +365,7 @@ class ImageView(HomeAssistantView):
async def get(self, request: web.Request, entity_id: str) -> web.StreamResponse:
"""Start a GET request."""
image_entity = self._get_image_entity(entity_id)
image_entity = await self._authenticate_request(request, entity_id)
return await self.handle(request, image_entity)
async def handle(
@@ -248,7 +248,10 @@ def _generate_thumbnail_if_file_does_not_exist(
if not target_file.is_file():
image = ImageOps.exif_transpose(Image.open(original_path))
image.thumbnail(target_size)
image.save(target_path, format=content_type.partition("/")[-1])
save_format = content_type.partition("/")[-1]
if save_format == "jpeg" and image.mode not in ("RGB", "L", "CMYK"):
image = image.convert("RGB")
image.save(target_path, format=save_format)
def _validate_size_from_filename(filename: str) -> tuple[int, int]:
@@ -1,5 +1,6 @@
"""Support for LG TV running on NetCast 3 or 4."""
from collections import Counter
from datetime import datetime
from typing import TYPE_CHECKING, Any
@@ -133,13 +134,22 @@ class LgTVDevice(MediaPlayerEntity):
channel_list = client.query_data("channel_list")
if channel_list:
channel_names = []
channel_pairs = []
for channel in channel_list:
channel_name = channel.find("chname")
if channel_name is not None:
channel_names.append(str(channel_name.text))
self._sources = dict(zip(channel_names, channel_list, strict=False))
# sort source names by the major channel number
channel_pairs.append((str(channel_name.text), channel))
name_count = Counter(name for name, _ in channel_pairs)
self._sources = {}
for name, channel in channel_pairs:
if name_count[name] > 1:
major = channel.find("major")
if major is not None:
name = f"{name} ({major.text})"
self._sources[name] = channel
source_tuples = [
(k, source.find("major").text)
for k, source in self._sources.items()
@@ -2,7 +2,7 @@
import asyncio
import collections
from collections.abc import Callable, Container, Mapping
from collections.abc import Callable
from contextlib import suppress
import datetime as dt
from enum import StrEnum
@@ -12,7 +12,7 @@ import hashlib
from http import HTTPStatus
import logging
import secrets
from typing import Any, Final, Required, TypedDict, final, override
from typing import Any, Final, Required, TypedDict, final
from urllib.parse import quote, urlparse
import aiohttp
@@ -24,7 +24,7 @@ import voluptuous as vol
from yarl import URL
from homeassistant.components import websocket_api
from homeassistant.components.http import HomeAssistantView
from homeassistant.components.http import KEY_AUTHENTICATED, HomeAssistantView
from homeassistant.components.websocket_api import ERR_NOT_SUPPORTED, ERR_UNKNOWN_ERROR
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import ( # noqa: F401
@@ -50,7 +50,7 @@ from homeassistant.const import ( # noqa: F401
STATE_PLAYING,
STATE_STANDBY,
)
from homeassistant.core import HomeAssistant, SupportsResponse, callback
from homeassistant.core import HomeAssistant, SupportsResponse
from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers.entity import Entity, EntityDescription
@@ -1248,7 +1248,7 @@ class MediaPlayerEntity(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_):
class MediaPlayerImageView(HomeAssistantView):
"""Media player view to serve an image."""
use_query_token_for_auth = True
requires_auth = False
url = "/api/media_player_proxy/{entity_id}"
name = "api:media_player:image"
extra_urls = [
@@ -1261,15 +1261,6 @@ class MediaPlayerImageView(HomeAssistantView):
"""Initialize a media player view."""
self.component = component
@callback
@override
def get_valid_auth_tokens(self, match_info: Mapping[str, str]) -> Container[str]:
"""Return valid auth tokens, which can be used for query token authentication."""
if (player := self.component.get_entity(match_info["entity_id"])) is None:
return ()
return (player.access_token,)
async def get(
self,
request: web.Request,
@@ -1279,9 +1270,21 @@ class MediaPlayerImageView(HomeAssistantView):
) -> web.Response:
"""Start a get request."""
if (player := self.component.get_entity(entity_id)) is None:
return web.Response(status=HTTPStatus.NOT_FOUND)
status = (
HTTPStatus.NOT_FOUND
if request[KEY_AUTHENTICATED]
else HTTPStatus.UNAUTHORIZED
)
return web.Response(status=status)
assert isinstance(player, MediaPlayerEntity)
authenticated = (
request[KEY_AUTHENTICATED]
or request.query.get("token") == player.access_token
)
if not authenticated:
return web.Response(status=HTTPStatus.UNAUTHORIZED)
if media_content_type and media_content_id:
media_image_id = request.query.get("media_image_id")
@@ -42,12 +42,23 @@ class MitsubishiComfortCoordinator(DataUpdateCoordinator[IndoorUnit | KumoStatio
try:
success = await self.device.update_status()
except Exception as err:
# The user-facing UpdateFailed message is translated and omits the IP;
# log it here so the failing address is visible in debug logs.
_LOGGER.debug(
"Error polling %s at %s: %s",
self.device.name,
self.device.address,
err,
)
raise UpdateFailed(
translation_domain=DOMAIN,
translation_key="communication_error",
translation_placeholders={"device_name": self.device.name},
) from err
if not success:
_LOGGER.debug(
"%s at %s returned no data", self.device.name, self.device.address
)
raise UpdateFailed(
translation_domain=DOMAIN,
translation_key="update_failed",
+3 -2
View File
@@ -1432,9 +1432,10 @@ class MqttEntity(
if (
self._config[CONF_ENABLED_BY_DEFAULT]
and deleted_entry
and deleted_entry.disabled_by is not None
and deleted_entry.disabled_by is er.RegistryEntryDisabler.INTEGRATION
):
# Enable previous deleted entity and enable it
# Enable previous deleted entity and enable it,
# if it was not disabled by the user
recreated_entry = entity_registry.async_get_or_create(
entity_platform, DOMAIN, self.unique_id
)
@@ -52,5 +52,5 @@ class OpenGarageEntity(CoordinatorEntity[OpenGarageDataUpdateCoordinator]):
manufacturer="Open Garage",
name=self.coordinator.data["name"],
suggested_area="Garage",
sw_version=self.coordinator.data["fwv"],
sw_version=str(self.coordinator.data["fwv"]),
)
@@ -54,7 +54,7 @@ class RainMachineEntity(CoordinatorEntity[RainMachineDataUpdateCoordinator]):
connections={(dr.CONNECTION_NETWORK_MAC, self._data.controller.mac)},
name=self._data.controller.name.capitalize(),
manufacturer="RainMachine",
hw_version=self._version_coordinator.data["hwVer"],
hw_version=str(self._version_coordinator.data["hwVer"]),
sw_version=f"{self._version_coordinator.data['swVer']} "
f"(API: {self._version_coordinator.data['apiVer']})",
)
@@ -20,5 +20,5 @@
"iot_class": "local_push",
"loggers": ["reolink_aio"],
"quality_scale": "platinum",
"requirements": ["reolink-aio==0.20.1"]
"requirements": ["reolink-aio==0.21.0"]
}
@@ -1,5 +1,7 @@
"""Base class for Rituals Perfume Genie diffuser entity."""
from typing import Any
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.entity import EntityDescription
from homeassistant.helpers.update_coordinator import CoordinatorEntity
@@ -12,6 +14,12 @@ MODEL = "The Perfume Genie"
MODEL2 = "The Perfume Genie 2.0"
def _version_string(version: Any) -> str:
if isinstance(version, dict):
return str(version.get("title", version))
return str(version)
class DiffuserEntity(CoordinatorEntity[RitualsDataUpdateCoordinator]):
"""Representation of a diffuser entity."""
@@ -31,7 +39,7 @@ class DiffuserEntity(CoordinatorEntity[RitualsDataUpdateCoordinator]):
manufacturer=MANUFACTURER,
model=MODEL if coordinator.diffuser.has_battery else MODEL2,
name=coordinator.diffuser.name,
sw_version=coordinator.diffuser.version,
sw_version=_version_string(coordinator.diffuser.version),
)
@property
@@ -250,13 +250,9 @@ class DatasetStore:
entry: DatasetEntry | None
for entry in self.datasets.values():
if entry.dataset == dataset:
if (
preferred_extended_address
and entry.preferred_extended_address is None
):
self.async_set_preferred_border_agent(
entry.id, preferred_border_agent_id, preferred_extended_address
)
self._async_maybe_update_preferred_border_agent(
entry, preferred_border_agent_id, preferred_extended_address
)
return
# Update if dataset with same extended pan id exists and the timestamp
@@ -307,10 +303,9 @@ class DatasetStore:
self.datasets[entry.id], tlv=tlv
)
self.async_schedule_save()
if preferred_extended_address and entry.preferred_extended_address is None:
self.async_set_preferred_border_agent(
entry.id, preferred_border_agent_id, preferred_extended_address
)
self._async_maybe_update_preferred_border_agent(
entry, preferred_border_agent_id, preferred_extended_address
)
return
entry = DatasetEntry(
@@ -348,6 +343,37 @@ class DatasetStore:
"""Get dataset by id."""
return self.datasets.get(dataset_id)
@callback
def _async_maybe_update_preferred_border_agent(
self,
entry: DatasetEntry,
preferred_border_agent_id: str | None,
preferred_extended_address: str | None,
) -> None:
"""Update the preferred border agent of an existing dataset if appropriate.
Sets the preferred border agent if it was not set yet, or refreshes the
stored extended address when the border agent ID still matches but the
extended address changed. The latter happens e.g. after an OTBR upgrade
regenerates the extended address while keeping the same border agent ID.
"""
if not preferred_extended_address:
return
if entry.preferred_extended_address is None or (
preferred_border_agent_id is not None
and preferred_border_agent_id == entry.preferred_border_agent_id
and preferred_extended_address != entry.preferred_extended_address
):
_LOGGER.info(
"Updating extended address of preferred border agent %s from %s to %s",
preferred_border_agent_id,
entry.preferred_extended_address,
preferred_extended_address,
)
self.async_set_preferred_border_agent(
entry.id, preferred_border_agent_id, preferred_extended_address
)
@callback
def async_set_preferred_border_agent(
self, dataset_id: str, border_agent_id: str | None, extended_address: str
@@ -440,7 +440,7 @@ class ProtectSettableKeysMixin(ProtectEntityDescription[T]):
async def ufp_set(self, obj: T, value: Any) -> None:
"""Set value for UniFi Protect device."""
_LOGGER.debug("Setting %s to %s for %s", self.name, value, obj.display_name)
_LOGGER.debug("Setting %s to %s for %s", self.key, value, obj.display_name)
if self.ufp_set_method is not None:
await getattr(obj, self.ufp_set_method)(value)
elif self.ufp_set_method_fn is not None:
@@ -8,8 +8,10 @@ from homeassistant.const import Platform
LOGGER: Logger = getLogger(__package__)
# The free plan is limited to 10 requests/minute
COORDINATOR_UPDATE_INTERVAL: timedelta = timedelta(seconds=10)
# The free plan is formally limited to 10 requests/minute
# But real world says 5 requests/minute is the real limit
# Opened a ticket with support with no response for 2 months
COORDINATOR_UPDATE_INTERVAL: timedelta = timedelta(seconds=15)
DOMAIN: Final = "uptimerobot"
PLATFORMS: Final = [Platform.BINARY_SENSOR, Platform.SENSOR, Platform.SWITCH]
+1 -1
View File
@@ -6,5 +6,5 @@
"documentation": "https://www.home-assistant.io/integrations/v2c",
"integration_type": "device",
"iot_class": "local_polling",
"requirements": ["pytrydan==1.0.1"]
"requirements": ["pytrydan==1.0.2"]
}
@@ -31,7 +31,7 @@ async def async_unload_entry(hass: HomeAssistant, entry: YaleConfigEntry) -> boo
async def async_migrate_entry(hass: HomeAssistant, entry: YaleConfigEntry) -> bool:
"""Migrate old entry."""
LOGGER.debug("Migrating from version %s", entry.version)
LOGGER.debug("Migrating from version %s.%s", entry.version, entry.minor_version)
if entry.version == 1:
new_options = entry.options.copy()
@@ -55,6 +55,19 @@ async def async_migrate_entry(hass: HomeAssistant, entry: YaleConfigEntry) -> bo
del new_data[CONF_NAME]
hass.config_entries.async_update_entry(entry, data=new_data, minor_version=2)
LOGGER.debug("Migration to version %s successful", entry.version)
if entry.version == 2 and entry.minor_version == 2:
entity_reg = er.async_get(hass)
entries = er.async_entries_for_config_entry(entity_reg, entry.entry_id)
for entity in entries:
if entity.unique_id == "yale_smart_alarm-panic":
entity_reg.async_update_entity(
entity.entity_id,
new_unique_id=f"{entry.entry_id}-panic",
)
hass.config_entries.async_update_entry(entry, minor_version=3)
LOGGER.debug(
"Migration to version %s.%s successful", entry.version, entry.minor_version
)
return True
@@ -47,7 +47,7 @@ class YalePanicButton(YaleAlarmEntity, ButtonEntity):
"""Initialize the plug switch."""
super().__init__(coordinator)
self.entity_description = description
self._attr_unique_id = f"yale_smart_alarm-{description.key}"
self._attr_unique_id = f"{coordinator.config_entry.entry_id}-{description.key}"
async def async_press(self) -> None:
"""Press the button."""
@@ -64,7 +64,7 @@ class YaleConfigFlow(ConfigFlow, domain=DOMAIN):
"""Handle a config flow for Yale integration."""
VERSION = 2
MINOR_VERSION = 2
MINOR_VERSION = 3
@staticmethod
@callback
+1 -1
View File
@@ -15,7 +15,7 @@ if TYPE_CHECKING:
APPLICATION_NAME: Final = "HomeAssistant"
MAJOR_VERSION: Final = 2026
MINOR_VERSION: Final = 6
PATCH_VERSION: Final = "2"
PATCH_VERSION: Final = "3"
__short_version__: Final = f"{MAJOR_VERSION}.{MINOR_VERSION}"
__version__: Final = f"{__short_version__}.{PATCH_VERSION}"
REQUIRED_PYTHON_VER: Final[tuple[int, int, int]] = (3, 14, 2)
+3 -15
View File
@@ -1,6 +1,6 @@
"""Helper to track the current http request."""
from collections.abc import Awaitable, Callable, Container, Mapping
from collections.abc import Awaitable, Callable
from contextvars import ContextVar
from http import HTTPStatus
import inspect
@@ -20,7 +20,7 @@ import voluptuous as vol
from homeassistant import exceptions
from homeassistant.const import CONTENT_TYPE_JSON
from homeassistant.core import Context, HomeAssistant, callback, is_callback
from homeassistant.core import Context, HomeAssistant, is_callback
from homeassistant.util.json import JSON_ENCODE_EXCEPTIONS, format_unserializable_data
from .json import find_paths_unserializable_data, json_bytes, json_dumps
@@ -55,13 +55,7 @@ def request_handler_factory(
authenticated = request.get(KEY_AUTHENTICATED, False)
if view.use_query_token_for_auth and not authenticated:
token = request.query.get("token")
if token and token in view.get_valid_auth_tokens(request.match_info):
_LOGGER.debug("Authenticated request with query token")
authenticated = True
if (view.requires_auth or view.use_query_token_for_auth) and not authenticated:
if view.requires_auth and not authenticated:
# Import here to avoid circular dependency with network.py
from .network import NoURLAvailableError, get_url # noqa: PLC0415
@@ -135,7 +129,6 @@ class HomeAssistantView:
extra_urls: list[str] = []
# Views inheriting from this class can override this
requires_auth = True
use_query_token_for_auth = False
cors_allowed = False
@staticmethod
@@ -211,8 +204,3 @@ class HomeAssistantView:
if allow_cors:
for route in routes:
allow_cors(route)
@callback
def get_valid_auth_tokens(self, match_info: Mapping[str, str]) -> Container[str]:
"""Return valid auth tokens, which can be used for query token authentication."""
return ()
+4 -4
View File
@@ -699,7 +699,7 @@ def _get_exposed_entities(
):
# Entity is in area
area_names.append(area_entry.name)
area_names.extend(area_entry.aliases)
area_names.extend(sorted(area_entry.aliases))
elif device_entry is not None:
# Check device area
if (
@@ -710,7 +710,7 @@ def _get_exposed_entities(
is not None
):
area_names.append(area_entry.name)
area_names.extend(area_entry.aliases)
area_names.extend(sorted(area_entry.aliases))
info: dict[str, Any] = {
"names": ", ".join(names),
@@ -957,9 +957,9 @@ def _get_cached_action_parameters(
aliases = er.async_get_entity_aliases(hass, entity_entry)
if aliases:
if description:
description = description + ". Aliases: " + str(list(aliases))
description = description + ". Aliases: " + str(sorted(aliases))
else:
description = "Aliases: " + str(list(aliases))
description = "Aliases: " + str(sorted(aliases))
parameters_cache.setdefault(domain, {})[action] = (description, parameters)
@@ -1,6 +1,7 @@
"""Config entry functions for Home Assistant templates."""
from collections.abc import Iterable
from enum import Enum
from typing import TYPE_CHECKING, Any
from homeassistant.exceptions import TemplateError
@@ -104,4 +105,6 @@ class ConfigEntryExtension(BaseTemplateExtension):
if config_entry is None:
return None
return getattr(config_entry, attr_name)
if isinstance(result := getattr(config_entry, attr_name), Enum):
return result.value
return result
+2 -2
View File
@@ -37,9 +37,9 @@ go2rtc-client==0.4.0
ha-ffmpeg==3.2.2
habluetooth==6.8.1
hass-nabucasa==2.2.0
hassil==3.5.0
hassil==3.7.0
home-assistant-bluetooth==2.0.0
home-assistant-frontend==20260527.5
home-assistant-frontend==20260527.6
home-assistant-intents==2026.6.1
httpx==0.28.1
ifaddr==0.2.0
+1 -1
View File
@@ -5,7 +5,7 @@ To update, run python3 -m script.hassfest
from typing import Final
FRONTEND_VERSION: Final[str] = "20260527.5"
FRONTEND_VERSION: Final[str] = "20260527.6"
MDI_ICONS: Final[set[str]] = {
"ab-testing",
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "homeassistant"
version = "2026.6.2"
version = "2026.6.3"
license = "Apache-2.0"
license-files = ["LICENSE*", "homeassistant/backports/LICENSE*"]
description = "Open-source home automation platform running on Python 3."
+1 -1
View File
@@ -25,7 +25,7 @@ cryptography==48.0.0
fnv-hash-fast==2.0.3
ha-ffmpeg==3.2.2
hass-nabucasa==2.2.0
hassil==3.5.0
hassil==3.7.0
home-assistant-bluetooth==2.0.0
home-assistant-intents==2026.6.1
httpx==0.28.1
+6 -6
View File
@@ -190,7 +190,7 @@ aioairzone-cloud==0.7.2
aioairzone==1.0.5
# homeassistant.components.alexa_devices
aioamazondevices==14.0.0
aioamazondevices==14.0.3
# homeassistant.components.ambient_network
# homeassistant.components.ambient_station
@@ -718,7 +718,7 @@ brottsplatskartan==1.0.5
brunt==1.2.0
# homeassistant.components.bthome
bthome-ble==3.23.2
bthome-ble==3.23.4
# homeassistant.components.bt_home_hub_5
bthomehub5-devicelist==0.1.1
@@ -1226,7 +1226,7 @@ hass-splunk==0.1.4
# homeassistant.components.assist_satellite
# homeassistant.components.conversation
hassil==3.5.0
hassil==3.7.0
# homeassistant.components.jewish_calendar
hdate[astral]==1.2.1
@@ -1266,7 +1266,7 @@ hole==0.9.0
holidays==0.98
# homeassistant.components.frontend
home-assistant-frontend==20260527.5
home-assistant-frontend==20260527.6
# homeassistant.components.conversation
home-assistant-intents==2026.6.1
@@ -2773,7 +2773,7 @@ pytradfri[async]==9.0.1
pytrafikverket==1.1.1
# homeassistant.components.v2c
pytrydan==1.0.1
pytrydan==1.0.2
# homeassistant.components.uptimerobot
pyuptimerobot==25.0.0
@@ -2878,7 +2878,7 @@ renault-api==0.5.12
renson-endura-delta==1.7.2
# homeassistant.components.reolink
reolink-aio==0.20.1
reolink-aio==0.21.0
# homeassistant.components.radio_frequency
rf-protocols==4.0.1
+7 -7
View File
@@ -809,30 +809,30 @@ async def test_token_query_param_authentication(
assert await resp.read() == FAKE_PNG
async def test_unauthenticated_request_unauthorized(
async def test_unauthenticated_request_forbidden(
hass: HomeAssistant,
hass_client_no_auth: ClientSessionGenerator,
aioclient_mock: AiohttpClientMocker,
) -> None:
"""Test that unauthenticated requests are unauthorized."""
"""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.UNAUTHORIZED
assert resp.status == HTTPStatus.FORBIDDEN
resp = await client.get("/api/brands/hardware/boards/green.png")
assert resp.status == HTTPStatus.UNAUTHORIZED
assert resp.status == HTTPStatus.FORBIDDEN
async def test_invalid_token_unauthorized(
async def test_invalid_token_forbidden(
hass: HomeAssistant,
hass_client_no_auth: ClientSessionGenerator,
) -> None:
"""Test that an invalid access token in query param is unauthorized."""
"""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.UNAUTHORIZED
assert resp.status == HTTPStatus.FORBIDDEN
async def test_invalid_bearer_token_unauthorized(
-24
View File
@@ -693,30 +693,6 @@ async def test_camera_proxy_stream(hass_client: ClientSessionGenerator) -> None:
assert response.status == HTTPStatus.BAD_GATEWAY
@pytest.mark.usefixtures("mock_camera")
async def test_camera_proxy_query_token_auth(
hass: HomeAssistant, hass_client_no_auth: ClientSessionGenerator
) -> None:
"""Test the camera proxy authenticates via the access token query param."""
client = await hass_client_no_auth()
state = hass.states.get("camera.demo_camera")
assert state is not None
# A valid access token in the query param authenticates the request
resp = await client.get(state.attributes["entity_picture"])
assert resp.status == HTTPStatus.OK
assert await resp.read() == b"Test"
# Without a token the request is unauthorized
resp = await client.get("/api/camera_proxy/camera.demo_camera")
assert resp.status == HTTPStatus.UNAUTHORIZED
# An invalid token is also unauthorized
resp = await client.get("/api/camera_proxy/camera.demo_camera?token=invalid")
assert resp.status == HTTPStatus.UNAUTHORIZED
@pytest.mark.usefixtures("mock_camera")
async def test_state_streaming(hass: HomeAssistant) -> None:
"""Camera state."""
+10
View File
@@ -5,9 +5,11 @@ from http import HTTPStatus
import json
from typing import Any
from unittest.mock import AsyncMock, MagicMock, Mock, mock_open, patch
import warnings
from aiohttp import ClientError
from aiohttp.hdrs import AUTHORIZATION
import jwt.warnings
import pytest
from pywebpush import WebPushException
from syrupy.assertion import SnapshotAssertion
@@ -1289,3 +1291,11 @@ async def test_html5_dismiss_message(
"data": {"jwt": "JWT"},
**expected_payload,
}
def test_add_jwt_no_insecure_key_warning() -> None:
"""Test that add_jwt does not emit InsecureKeyLengthWarning for short keys."""
short_key = "c2hvcnRfa2V5X2hlcmU="
with warnings.catch_warnings():
warnings.simplefilter("error", jwt.warnings.InsecureKeyLengthWarning)
html5.add_jwt(1234567890, "device", "tag", short_key)
+3 -16
View File
@@ -404,27 +404,14 @@ async def test_failed_login_attempts_counter(
app.router.add_get(
"/auth_true",
request_handler_factory(
hass,
Mock(requires_auth=True, use_query_token_for_auth=False),
auth_true_handler,
),
request_handler_factory(hass, Mock(requires_auth=True), auth_true_handler),
)
app.router.add_get(
"/auth_false",
request_handler_factory(
hass,
Mock(requires_auth=True, use_query_token_for_auth=False),
auth_handler,
),
request_handler_factory(hass, Mock(requires_auth=True), auth_handler),
)
app.router.add_get(
"/",
request_handler_factory(
hass,
Mock(requires_auth=False, use_query_token_for_auth=False),
auth_handler,
),
"/", request_handler_factory(hass, Mock(requires_auth=False), auth_handler)
)
setup_bans(hass, app, 5)
+8 -61
View File
@@ -61,7 +61,7 @@ async def test_handling_unauthorized(mock_request: Mock) -> None:
with pytest.raises(HTTPUnauthorized):
await request_handler_factory(
mock_request.app[KEY_HASS],
Mock(requires_auth=False, use_query_token_for_auth=False),
Mock(requires_auth=False),
AsyncMock(side_effect=Unauthorized),
)(mock_request)
@@ -71,7 +71,7 @@ async def test_handling_invalid_data(mock_request: Mock) -> None:
with pytest.raises(HTTPBadRequest):
await request_handler_factory(
mock_request.app[KEY_HASS],
Mock(requires_auth=False, use_query_token_for_auth=False),
Mock(requires_auth=False),
AsyncMock(side_effect=vol.Invalid("yo")),
)(mock_request)
@@ -81,7 +81,7 @@ async def test_handling_service_not_found(mock_request: Mock) -> None:
with pytest.raises(HTTPInternalServerError):
await request_handler_factory(
mock_request.app[KEY_HASS],
Mock(requires_auth=False, use_query_token_for_auth=False),
Mock(requires_auth=False),
AsyncMock(side_effect=ServiceNotFound("test", "test")),
)(mock_request)
@@ -90,7 +90,7 @@ async def test_not_running(mock_request_with_stopping: Mock) -> None:
"""Test we get a 503 when not running."""
response = await request_handler_factory(
mock_request_with_stopping.app[KEY_HASS],
Mock(requires_auth=False, use_query_token_for_auth=False),
Mock(requires_auth=False),
AsyncMock(side_effect=Unauthorized),
)(mock_request_with_stopping)
assert response.status == HTTPStatus.SERVICE_UNAVAILABLE
@@ -101,64 +101,11 @@ async def test_invalid_handler(mock_request: Mock) -> None:
with pytest.raises(TypeError):
await request_handler_factory(
mock_request.app[KEY_HASS],
Mock(requires_auth=False, use_query_token_for_auth=False),
Mock(requires_auth=False),
AsyncMock(return_value=["not valid"]),
)(mock_request)
async def test_query_token_auth_valid(mock_request: Mock) -> None:
"""Test authentication with a valid query token."""
mock_request.get = Mock(return_value=False)
mock_request.query = {"token": "valid-token"}
handler = AsyncMock(return_value=None)
response = await request_handler_factory(
mock_request.app[KEY_HASS],
Mock(
requires_auth=False,
use_query_token_for_auth=True,
get_valid_auth_tokens=Mock(return_value={"valid-token"}),
),
handler,
)(mock_request)
assert response.status == HTTPStatus.OK
handler.assert_awaited_once()
@pytest.mark.parametrize(
"query",
[{"token": "wrong-token"}, {}],
ids=["invalid_token", "missing_token"],
)
async def test_query_token_auth_unauthorized(
mock_request: Mock, query: dict[str, str]
) -> None:
"""Test an invalid or missing query token is rejected."""
mock_request.get = Mock(return_value=False)
mock_request.query = query
handler = AsyncMock()
with (
patch(
"homeassistant.helpers.network.get_url",
return_value="https://example.com",
),
pytest.raises(HTTPUnauthorized),
):
await request_handler_factory(
mock_request.app[KEY_HASS],
Mock(
requires_auth=False,
use_query_token_for_auth=True,
get_valid_auth_tokens=Mock(return_value={"valid-token"}),
),
handler,
)(mock_request)
handler.assert_not_awaited()
async def test_requires_auth_includes_www_authenticate(
mock_request: Mock,
) -> None:
@@ -173,7 +120,7 @@ async def test_requires_auth_includes_www_authenticate(
):
await request_handler_factory(
mock_request.app[KEY_HASS],
Mock(requires_auth=True, use_query_token_for_auth=False),
Mock(requires_auth=True),
AsyncMock(),
)(mock_request)
assert exc_info.value.headers["WWW-Authenticate"] == (
@@ -196,7 +143,7 @@ async def test_requires_auth_omits_www_authenticate_without_url(
):
await request_handler_factory(
mock_request.app[KEY_HASS],
Mock(requires_auth=True, use_query_token_for_auth=False),
Mock(requires_auth=True),
AsyncMock(),
)(mock_request)
assert "WWW-Authenticate" not in exc_info.value.headers
@@ -265,7 +212,7 @@ async def test_requires_auth_www_authenticate_prefer_external(
with pytest.raises(HTTPUnauthorized) as exc_info:
await request_handler_factory(
hass,
Mock(requires_auth=True, use_query_token_for_auth=False),
Mock(requires_auth=True),
AsyncMock(),
)(mock_current_request)
+20 -1
View File
@@ -3,7 +3,7 @@
from unittest.mock import Mock
from homeassistant.components import hue
from homeassistant.const import Platform
from homeassistant.const import STATE_UNKNOWN, Platform
from homeassistant.core import HomeAssistant
from homeassistant.helpers import entity_registry as er
from homeassistant.setup import async_setup_component
@@ -185,3 +185,22 @@ async def test_grouped_light_level_sensor(
assert (
sensor.state == "999"
) # Light level 30000 translates to 10^((30000-1)/10000) ≈ 999 lux
async def test_light_level_sensor_none_value(
hass: HomeAssistant, mock_bridge_v2: Mock, v2_resources_test_data: JsonArrayType
) -> None:
"""Test that light level sensor handles None value without crashing."""
# Modify the light_level sensor to have None value (simulates sensor unavailability)
for resource in v2_resources_test_data:
if resource.get("id") == "d504e7a4-9a18-4854-90fd-c5b6ac102c40":
resource["light"]["light_level"] = None
resource["light"]["light_level_valid"] = False
break
await mock_bridge_v2.api.load_test_data(v2_resources_test_data)
await setup_platform(hass, mock_bridge_v2, Platform.SENSOR)
sensor = hass.states.get("sensor.hue_motion_sensor_illuminance")
assert sensor is not None
assert sensor.state == STATE_UNKNOWN
+3 -9
View File
@@ -234,30 +234,24 @@ async def test_fetch_image_unauthenticated(
client = await hass_client_no_auth()
resp = await client.get("/api/image_proxy/image.test")
assert resp.status == HTTPStatus.UNAUTHORIZED
assert resp.status == HTTPStatus.FORBIDDEN
resp = await client.get("/api/image_proxy/image.test")
assert resp.status == HTTPStatus.UNAUTHORIZED
assert resp.status == HTTPStatus.FORBIDDEN
resp = await client.get(
"/api/image_proxy/image.test", headers={hdrs.AUTHORIZATION: "blabla"}
)
assert resp.status == HTTPStatus.UNAUTHORIZED
# An invalid token is also unauthorized
resp = await client.get("/api/image_proxy/image.test?token=invalid")
assert resp.status == HTTPStatus.UNAUTHORIZED
state = hass.states.get("image.test")
resp = await client.get(state.attributes["entity_picture"])
assert resp.status == HTTPStatus.OK
body = await resp.read()
assert body == b"Test"
# Unknown entities are also unauthorized for an unauthenticated client, so
# their existence is not leaked
resp = await client.get("/api/image_proxy/image.unknown")
assert resp.status == HTTPStatus.UNAUTHORIZED
assert resp.status == HTTPStatus.NOT_FOUND
@respx.mock
@@ -6,7 +6,10 @@ from unittest.mock import patch
from aiohttp import ClientSession, ClientWebSocketResponse
from freezegun.api import FrozenDateTimeFactory
from PIL import Image
import pytest
from homeassistant.components.image_upload import DOMAIN
from homeassistant.components.websocket_api import TYPE_RESULT
from homeassistant.core import HomeAssistant
from homeassistant.setup import async_setup_component
@@ -93,3 +96,57 @@ async def test_upload_image(
# Ensure removed from disk
assert not item_folder.is_dir()
@pytest.mark.parametrize(
("image_mode", "content_type"),
[
("RGBA", "image/jpeg"),
("LA", "image/jpeg"),
("P", "image/jpeg"),
],
)
async def test_upload_image_thumbnail_rgba_as_jpeg(
hass: HomeAssistant,
freezer: FrozenDateTimeFactory,
hass_client: ClientSessionGenerator,
image_mode: str,
content_type: str,
) -> None:
"""Test thumbnail generation when image mode is incompatible with JPEG."""
now = dt_util.utcnow()
freezer.move_to(now)
with (
tempfile.TemporaryDirectory() as tempdir,
patch.object(hass.config, "path", return_value=tempdir),
):
assert await async_setup_component(hass, DOMAIN, {})
client: ClientSession = await hass_client()
with TEST_IMAGE.open("rb") as fp:
res = await client.post("/api/image/upload", data={"file": fp})
assert res.status == 200
item = await res.json()
image_id = item["id"]
tempdir = pathlib.Path(tempdir)
item_folder = tempdir / image_id
# Create an image file with the given mode to simulate the mismatch
original_path = item_folder / "original"
img = Image.new(image_mode, (300, 300))
img.save(original_path, format="png")
# Change the stored content_type to simulate the mismatch
hass.data[DOMAIN].data[image_id]["content_type"] = content_type
# Fetch the thumbnail; this should not raise an OSError
res = await client.get(f"/api/image/serve/{image_id}/256x256")
assert res.status == 200
assert (item_folder / "256x256").is_file()
# Verify the generated thumbnail is a valid JPEG
thumbnail = Image.open(item_folder / "256x256")
assert thumbnail.mode == "RGB"
@@ -0,0 +1,70 @@
"""Tests for LG Netcast media player platform."""
from collections.abc import Generator
from datetime import timedelta
from unittest.mock import MagicMock, patch
import xml.etree.ElementTree as ET
import pytest
from homeassistant.components.media_player import DOMAIN as MP_DOMAIN
from homeassistant.core import HomeAssistant
from homeassistant.util import dt as dt_util
from . import MODEL_NAME, setup_lgnetcast
from tests.common import async_fire_time_changed
ENTITY_ID = f"{MP_DOMAIN}.{MODEL_NAME.lower()}"
def _make_channel(name: str, major: str) -> ET.Element:
"""Create a fake channel XML element."""
channel = ET.Element("data")
chname = ET.SubElement(channel, "chname")
chname.text = name
major_el = ET.SubElement(channel, "major")
major_el.text = major
return channel
@pytest.fixture(autouse=True)
def mock_lg_netcast() -> Generator[MagicMock]:
"""Mock LG Netcast library."""
with patch(
"homeassistant.components.lg_netcast.LgNetCastClient"
) as mock_client_class:
yield mock_client_class
async def test_source_list_duplicate_channel_names(
hass: HomeAssistant,
mock_lg_netcast: MagicMock,
) -> None:
"""Test that duplicate channel names are disambiguated in source list."""
client = mock_lg_netcast.return_value
client.get_volume.return_value = (20, False)
context_client = client.__enter__.return_value
channel_data = {
"cur_channel": None,
"channel_list": [
_make_channel("BBC One", "1"),
_make_channel("ITV", "3"),
_make_channel("BBC One", "101"),
],
}
context_client.query_data.side_effect = channel_data.get
await setup_lgnetcast(hass)
async_fire_time_changed(hass, dt_util.utcnow() + timedelta(seconds=60))
await hass.async_block_till_done(wait_background_tasks=True)
entity = hass.states.get(ENTITY_ID)
assert entity is not None
source_list = entity.attributes.get("source_list")
assert source_list is not None
assert len(source_list) == 3
assert "ITV" in source_list
assert "BBC One (1)" in source_list
assert "BBC One (101)" in source_list
@@ -113,28 +113,6 @@ async def test_get_image_http(
assert content == b"image"
async def test_get_image_http_unauthenticated(
hass: HomeAssistant, hass_client_no_auth: ClientSessionGenerator
) -> None:
"""Test get image via http command without a valid token is unauthorized."""
await async_setup_component(
hass, "media_player", {"media_player": {"platform": "demo"}}
)
await hass.async_block_till_done()
client = await hass_client_no_auth()
# Without a token the request is unauthorized
resp = await client.get("/api/media_player_proxy/media_player.bedroom")
assert resp.status == HTTPStatus.UNAUTHORIZED
# An invalid token is also unauthorized
resp = await client.get(
"/api/media_player_proxy/media_player.bedroom?token=invalid"
)
assert resp.status == HTTPStatus.UNAUTHORIZED
async def test_get_image_http_remote(
hass: HomeAssistant, hass_client_no_auth: ClientSessionGenerator
) -> None:
+21
View File
@@ -626,6 +626,27 @@ async def test_registry_enable_not_enabled_by_default_entity(
assert not entry.disabled
assert device_registry.async_get(device_id) is not None
# Mock the entry is disabled by the user
entity_registry.async_update_entity(
"sensor.test_new", disabled_by=er.RegistryEntryDisabler.USER
)
await hass.async_block_till_done(wait_background_tasks=True)
state = hass.states.get("sensor.test_new")
assert state is None
# Remove the disabled entry
entity_registry.async_remove("sensor.test_new")
await hass.async_block_till_done(wait_background_tasks=True)
entry = entity_registry.async_get("sensor.test_new")
assert entry is None
# Repeat the re-discovery, and assert the entity remains disabled
async_fire_mqtt_message(hass, discovery_topic, config_enabled_new_entity_name)
await hass.async_block_till_done()
entry = entity_registry.async_get("sensor.test_new")
assert entry is not None
assert entry.disabled
assert device_registry.async_get(device_id) is not None
@pytest.mark.parametrize(
"mqtt_config_subentries_data",
+1 -1
View File
@@ -40,7 +40,7 @@ def mock_opengarage() -> Generator[MagicMock]:
client.update_state.return_value = {
"name": "abcdef",
"mac": "aa:bb:cc:dd:ee:ff",
"fwv": "1.2.0",
"fwv": 120,
}
yield client
@@ -1,7 +1,10 @@
"""Test the OpenGarage Browser buttons."""
import logging
from unittest.mock import MagicMock
import pytest
from homeassistant.components import button
from homeassistant.const import ATTR_ENTITY_ID
from homeassistant.core import HomeAssistant
@@ -32,3 +35,24 @@ async def test_buttons(
assert entry.device_id
device_entry = device_registry.async_get(entry.device_id)
assert device_entry
async def test_device_info_sw_version_is_string(
hass: HomeAssistant,
mock_opengarage: MagicMock,
mock_config_entry: MockConfigEntry,
device_registry: dr.DeviceRegistry,
caplog: pytest.LogCaptureFixture,
) -> None:
"""Test that sw_version is a string even when API returns int."""
mock_config_entry.add_to_hass(hass)
with caplog.at_level(logging.WARNING, logger="homeassistant.helpers.frame"):
await hass.config_entries.async_setup(mock_config_entry.entry_id)
await hass.async_block_till_done()
device_entry = device_registry.async_get_device(
connections={(dr.CONNECTION_NETWORK_MAC, "aa:bb:cc:dd:ee:ff")}
)
assert device_entry
assert device_entry.sw_version == "120"
assert "non-string value" not in caplog.text
@@ -1,5 +1,5 @@
{
"apiVer": "4.6.1",
"hwVer": "3",
"hwVer": 3,
"swVer": "4.0.1144"
}
@@ -26,7 +26,7 @@
'coordinator': dict({
'api.versions': dict({
'apiVer': '4.6.1',
'hwVer': '3',
'hwVer': 3,
'swVer': '4.0.1144',
}),
'machine.firmware_update_status': dict({
@@ -1159,7 +1159,7 @@
'coordinator': dict({
'api.versions': dict({
'apiVer': '4.6.1',
'hwVer': '3',
'hwVer': 3,
'swVer': '4.0.1144',
}),
'machine.firmware_update_status': dict({
+27 -1
View File
@@ -1,5 +1,6 @@
"""Test RainMachine sensors."""
import logging
from typing import Any
from unittest.mock import AsyncMock, patch
@@ -9,7 +10,7 @@ from syrupy.assertion import SnapshotAssertion
from homeassistant.components.rainmachine import DOMAIN
from homeassistant.const import Platform
from homeassistant.core import HomeAssistant
from homeassistant.helpers import entity_registry as er
from homeassistant.helpers import device_registry as dr, entity_registry as er
from homeassistant.setup import async_setup_component
from tests.common import MockConfigEntry, snapshot_platform
@@ -32,3 +33,28 @@ async def test_sensors(
assert await async_setup_component(hass, DOMAIN, config)
await hass.async_block_till_done()
await snapshot_platform(hass, entity_registry, snapshot, config_entry.entry_id)
async def test_device_info_hw_version_is_string(
hass: HomeAssistant,
device_registry: dr.DeviceRegistry,
config: dict[str, Any],
config_entry: MockConfigEntry,
client: AsyncMock,
caplog: pytest.LogCaptureFixture,
) -> None:
"""Test that hw_version is a string even when API returns int."""
with (
caplog.at_level(logging.WARNING, logger="homeassistant.helpers.frame"),
patch("homeassistant.components.rainmachine.Client", return_value=client),
patch("homeassistant.components.rainmachine.PLATFORMS", [Platform.SENSOR]),
):
assert await async_setup_component(hass, DOMAIN, config)
await hass.async_block_till_done()
device_entry = device_registry.async_get_device(
connections={(dr.CONNECTION_NETWORK_MAC, "aa:bb:cc:dd:ee:ff")}
)
assert device_entry
assert device_entry.hw_version == "3"
assert "non-string value" not in caplog.text
@@ -1,5 +1,9 @@
"""Tests for the Rituals Perfume Genie switch platform."""
import logging
import pytest
from homeassistant.components.homeassistant import (
DOMAIN as HOMEASSISTANT_DOMAIN,
SERVICE_UPDATE_ENTITY,
@@ -13,12 +17,13 @@ from homeassistant.const import (
STATE_ON,
)
from homeassistant.core import HomeAssistant
from homeassistant.helpers import entity_registry as er
from homeassistant.helpers import device_registry as dr, entity_registry as er
from homeassistant.setup import async_setup_component
from .common import (
init_integration,
mock_config_entry,
mock_diffuser,
mock_diffuser_v1_battery_cartridge,
)
@@ -71,6 +76,28 @@ async def test_switch_handle_coordinator_update(hass: HomeAssistant) -> None:
assert diffuser.update_data.call_count == call_count_before_update + 1
async def test_device_info_sw_version_dict(
hass: HomeAssistant,
device_registry: dr.DeviceRegistry,
caplog: pytest.LogCaptureFixture,
) -> None:
"""Test that sw_version is a string even when the library returns a dict."""
diffuser = mock_diffuser(
hublot="lot123dict",
version={"id": 1, "title": "5.2-rc15", "icon": ""},
)
config_entry = mock_config_entry(unique_id="id_123_version_dict_test")
with caplog.at_level(logging.WARNING, logger="homeassistant.helpers.frame"):
await init_integration(hass, config_entry, [diffuser])
device_entry = device_registry.async_get_device(
identifiers={("rituals_perfume_genie", "lot123dict")}
)
assert device_entry
assert device_entry.sw_version == "5.2-rc15"
assert "non-string value" not in caplog.text
async def test_set_switch_state(hass: HomeAssistant) -> None:
"""Test changing the diffuser switch entity state."""
config_entry = mock_config_entry(unique_id="id_123_switch_set_state_test")
@@ -730,6 +730,64 @@ async def test_set_preferred_extended_address(hass: HomeAssistant) -> None:
assert list(store.datasets.values())[2].preferred_extended_address == "blah"
async def test_refresh_preferred_extended_address(hass: HomeAssistant) -> None:
"""Test the preferred extended address is refreshed when the border agent ID matches.
An OTBR upgrade can regenerate the extended address while keeping the same
border agent ID. In that case the stored extended address should be updated
so the preferred border agent keeps being recognized.
"""
await dataset_store.async_add_dataset(
hass,
"source",
DATASET_1,
preferred_border_agent_id="baid",
preferred_extended_address="old_address",
)
store = await dataset_store.async_get_store(hass)
assert len(store.datasets) == 1
entry = list(store.datasets.values())[0]
assert entry.preferred_border_agent_id == "baid"
assert entry.preferred_extended_address == "old_address"
# Same dataset and border agent ID, new extended address: refresh the address
await dataset_store.async_add_dataset(
hass,
"source",
DATASET_1,
preferred_border_agent_id="baid",
preferred_extended_address="new_address",
)
entry = list(store.datasets.values())[0]
assert entry.preferred_border_agent_id == "baid"
assert entry.preferred_extended_address == "new_address"
# Different border agent ID: leave the preferred border agent untouched
await dataset_store.async_add_dataset(
hass,
"source",
DATASET_1,
preferred_border_agent_id="other_baid",
preferred_extended_address="other_address",
)
entry = list(store.datasets.values())[0]
assert entry.preferred_border_agent_id == "baid"
assert entry.preferred_extended_address == "new_address"
# Newer dataset (same extended PAN ID) with matching border agent ID: refresh
await dataset_store.async_add_dataset(
hass,
"source",
DATASET_1_LARGER_TIMESTAMP,
preferred_border_agent_id="baid",
preferred_extended_address="newest_address",
)
entry = list(store.datasets.values())[0]
assert entry.preferred_border_agent_id == "baid"
assert entry.preferred_extended_address == "newest_address"
async def test_automatically_set_preferred_dataset(
hass: HomeAssistant, mock_async_zeroconf: MagicMock
) -> None:
@@ -46,7 +46,7 @@ async def load_config_entry(
entry_id="1",
unique_id="username",
version=2,
minor_version=2,
minor_version=3,
)
config_entry.add_to_hass(hass)
@@ -32,7 +32,7 @@
'suggested_object_id': None,
'supported_features': 0,
'translation_key': 'panic',
'unique_id': 'yale_smart_alarm-panic',
'unique_id': '1-panic',
'unit_of_measurement': None,
})
# ---
+46 -2
View File
@@ -27,7 +27,7 @@ async def test_setup_entry(
entry_id="1",
unique_id="username",
version=2,
minor_version=2,
minor_version=3,
)
entry.add_to_hass(hass)
@@ -87,7 +87,7 @@ async def test_migrate_entry(
assert entry.state is ConfigEntryState.LOADED
assert entry.version == 2
assert entry.minor_version == 2
assert entry.minor_version == 3
assert entry.data == ENTRY_CONFIG
assert entry.options == OPTIONS_CONFIG
@@ -95,3 +95,47 @@ async def test_migrate_entry(
lock = entity_registry.async_get(lock_entity_id)
assert lock.options == {"lock": {"default_code": "123456"}}
async def test_migrate_panic_button_unique_id(
hass: HomeAssistant,
get_client: Mock,
entity_registry: er.EntityRegistry,
) -> None:
"""Test migration of panic button unique_id from v2.2 to v2.3."""
entry = MockConfigEntry(
title=ENTRY_CONFIG["username"],
domain=DOMAIN,
source=SOURCE_USER,
data=ENTRY_CONFIG,
options=OPTIONS_CONFIG,
entry_id="1",
unique_id="username",
version=2,
minor_version=2,
)
entry.add_to_hass(hass)
entity_registry.async_get_or_create(
"button",
DOMAIN,
"yale_smart_alarm-panic",
config_entry=entry,
)
with patch(
"homeassistant.components.yale_smart_alarm.coordinator.YaleSmartAlarmClient",
return_value=get_client,
):
await hass.config_entries.async_setup(entry.entry_id)
await hass.async_block_till_done()
assert entry.state is ConfigEntryState.LOADED
assert entry.version == 2
assert entry.minor_version == 3
migrated = entity_registry.async_get_entity_id("button", DOMAIN, "1-panic")
assert migrated is not None
old = entity_registry.async_get_entity_id(
"button", DOMAIN, "yale_smart_alarm-panic"
)
assert old is None
@@ -110,24 +110,37 @@ async def test_config_entry_id(
async def test_config_entry_attr(hass: HomeAssistant) -> None:
"""Test config entry attr."""
info = {
config_entry = MockConfigEntry(
domain="mock_light",
title="mock title",
source=config_entries.SOURCE_BLUETOOTH,
disabled_by=config_entries.ConfigEntryDisabler.USER,
pref_disable_polling=True,
)
config_entry.add_to_hass(hass)
expected = {
"domain": "mock_light",
"title": "mock title",
"source": config_entries.SOURCE_BLUETOOTH,
"disabled_by": config_entries.ConfigEntryDisabler.USER,
"pref_disable_polling": True,
"disabled_by": "user",
"pref_disable_polling": "True",
"state": "not_loaded",
}
config_entry = MockConfigEntry(**info)
config_entry.add_to_hass(hass)
info["state"] = config_entries.ConfigEntryState.NOT_LOADED
for key, value in info.items():
assert render(
hass,
"{{ config_entry_attr('" + config_entry.entry_id + "', '" + key + "') }}",
parse_result=False,
) == str(value)
for key, value in expected.items():
assert (
render(
hass,
"{{ config_entry_attr('"
+ config_entry.entry_id
+ "', '"
+ key
+ "') }}",
parse_result=False,
)
== value
)
for config_entry_id, key in (
(config_entry.entry_id, "invalid_key"),
+4 -4
View File
@@ -1134,7 +1134,7 @@ async def test_script_tool(
assert tool.name == "test_script"
assert (
tool.description
== "This is a test script. Aliases: ['script name', 'script alias']"
== "This is a test script. Aliases: ['script alias', 'script name']"
)
schema = {
vol.Required("beer", description="Number of beers"): cv.string,
@@ -1149,7 +1149,7 @@ async def test_script_tool(
assert hass.data[llm.ACTION_PARAMETERS_CACHE]["script"] == {
"test_script": (
"This is a test script. Aliases: ['script name', 'script alias']",
"This is a test script. Aliases: ['script alias', 'script name']",
vol.Schema(schema),
),
"script_with_no_fields": (
@@ -1258,14 +1258,14 @@ async def test_script_tool(
assert tool.name == "test_script"
assert (
tool.description
== "This is a new test script. Aliases: ['script name', 'script alias']"
== "This is a new test script. Aliases: ['script alias', 'script name']"
)
schema = {vol.Required("beer", description="Number of beers"): cv.string}
assert tool.parameters.schema == schema
assert hass.data[llm.ACTION_PARAMETERS_CACHE]["script"] == {
"test_script": (
"This is a new test script. Aliases: ['script name', 'script alias']",
"This is a new test script. Aliases: ['script alias', 'script name']",
vol.Schema(schema),
),
"script_with_no_fields": (