mirror of
https://github.com/home-assistant/core.git
synced 2026-06-14 21:22:13 +02:00
Compare commits
28 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| ff25428e56 | |||
| 608acd422f | |||
| c860e83ec9 | |||
| c9f3f4a265 | |||
| e346a801d1 | |||
| a5c193931f | |||
| d273350db1 | |||
| 45f27b8b6e | |||
| d3208a420f | |||
| d0d35e380f | |||
| 2735e58d7f | |||
| ad3eab80c3 | |||
| 18e5d284b4 | |||
| e5052eaf44 | |||
| 62c2e8d2fd | |||
| 1f505067dd | |||
| 72875b3b5e | |||
| 3be755e496 | |||
| 5285798052 | |||
| da49e37946 | |||
| 2f9de98f2d | |||
| 383a6426fc | |||
| 5ed60cd057 | |||
| a1250b7bfb | |||
| 240e5219ad | |||
| 418f352ce7 | |||
| 599967b1d8 | |||
| ad82729357 |
@@ -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"]
|
||||
}
|
||||
|
||||
@@ -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"]
|
||||
}
|
||||
|
||||
@@ -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"]
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 ()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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."
|
||||
|
||||
Generated
+1
-1
@@ -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
|
||||
|
||||
Generated
+6
-6
@@ -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
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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."""
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
# ---
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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": (
|
||||
|
||||
Reference in New Issue
Block a user