Compare commits

..

58 Commits

Author SHA1 Message Date
Franck Nijhof 28076bcad6 2026.6.3 (#173633) 2026-06-12 21:49:56 +02:00
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
Franck Nijhof 30d8bf4231 2026.6.2 (#173397) 2026-06-09 22:13:22 +02:00
Triggs 5436d8af9b Bump codecov/codecov-action from v6.0.1 to v7.0.0 (#173232)
Co-authored-by: Franck Nijhof <git@frenck.dev>
2026-06-09 19:26:01 +00:00
epenet 88adf39ef3 Use explicit DOMAIN import in mqtt tests (#173093) 2026-06-09 19:20:12 +00:00
Franck Nijhof 14b14bddf1 Bump version to 2026.6.2 2026-06-09 18:41:21 +00:00
Michael Hansen 3c4a30be6b Only allow specific protocols with ffmpeg in Wyoming satellite announce (#173381)
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
2026-06-09 18:34:34 +00:00
Joost Lekkerkerker 2988eb4b19 Set Zinvolt max output to 2kW if unlocked (#173367) 2026-06-09 18:34:32 +00:00
Nikolai Rahimi 00eef14558 Bump mitsubishi-comfort to 0.3.1 (#173362) 2026-06-09 18:34:30 +00:00
Joost Lekkerkerker d02516dd09 Handle unavailable Zinvolt devices better (#173359) 2026-06-09 18:34:28 +00:00
Jan Bouwhuis aabb6b3d04 Fix reload fails when MQTT entry is not set up (#173335)
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
Co-authored-by: Franck Nijhof <git@frenck.dev>
2026-06-09 18:34:26 +00:00
tronikos 50c2c7c4bc Bump opower to 0.18.4 (#173323) 2026-06-09 18:34:24 +00:00
Joakim Plate e81dd426bb Ensure we provide strings to vol.In for philips js (#173313) 2026-06-09 18:34:22 +00:00
Michael Hansen c4c569c181 Mitigate TTS ResultStream leak in pipeline (#173290) 2026-06-09 18:34:20 +00:00
Simone Chemelli 6182426132 Bump renault-api to 0.5.12 (#173289) 2026-06-09 18:34:18 +00:00
Martin Hjelmare a073cc4f7d Fix homeassistant hardware unique id migration (#173258) 2026-06-09 18:34:16 +00:00
Mark Purcell 07ddc08d84 Bump pydaikin to 2.18.1 (#173249) 2026-06-09 18:34:14 +00:00
Bram Kragten 17673dcf55 Update frontend to 20260527.5 (#173236) 2026-06-09 18:34:12 +00:00
starkillerOG a864bc1c80 Adjust ONVIF event fallbacks for battery cameras (#173214)
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
2026-06-09 18:34:10 +00:00
Shay Levy a15d80daa2 Fix Shelly virtual component unit retrieval (#173183) 2026-06-09 18:34:08 +00:00
Joost Lekkerkerker e123b29258 Have Plugwise handle unavailable temperature measurements (#173173) 2026-06-09 18:34:06 +00:00
J. Nick Koston 5669a7b602 Wait for Shelly bluetooth proxy connection at startup (#173165) 2026-06-09 18:34:05 +00:00
J. Nick Koston fe358a4a1f Wait for ESPHome bluetooth proxy connection at startup (#173164) 2026-06-09 18:34:03 +00:00
mvn23 3a93d6370b Ensure opentherm_gw boiler and thermostat manufacturers are strings (#173162) 2026-06-09 18:34:01 +00:00
Bouwe Westerdijk 89576f01e6 Bump plugwise to v1.11.4 (#173147) 2026-06-09 18:33:59 +00:00
tronikos f51895b0c9 Bump opower to 0.18.3 (#173141) 2026-06-09 18:30:18 +00:00
Joakim Plate d0dcbfadaa Switch to active scanner for gardena (#173062) 2026-06-09 18:30:16 +00:00
Simone Chemelli 5e0d3627c2 Improve and complete exception handling for Alexa Devices (#173053) 2026-06-09 18:30:14 +00:00
Diogo Gomes 80c90732a3 Bump pytrydan to v1.0.1 (#173047) 2026-06-09 18:30:12 +00:00
Yardian Support 16eca3909a Bump pyyardian to 1.4.0 (#173020)
Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com>
2026-06-09 18:30:10 +00:00
peteS-UK 1b471da31f Update PARALLEL_UPDATES to 0 for Squeezebox platforms (#172906) 2026-06-09 18:30:08 +00:00
165 changed files with 2732 additions and 1444 deletions
+3 -3
View File
@@ -1326,7 +1326,7 @@ jobs:
pattern: coverage-*
- name: Upload coverage to Codecov
if: needs.info.outputs.test_full_suite == 'true'
uses: codecov/codecov-action@e79a6962e0d4c0c17b229090214935d2e33f8354 # v6.0.1
uses: codecov/codecov-action@fb8b3582c8e4def4969c97caa2f19720cb33a72f # v7.0.0
with:
fail_ci_if_error: true
flags: full-suite
@@ -1485,7 +1485,7 @@ jobs:
pattern: coverage-*
- name: Upload coverage to Codecov
if: needs.info.outputs.test_full_suite == 'false'
uses: codecov/codecov-action@e79a6962e0d4c0c17b229090214935d2e33f8354 # v6.0.1
uses: codecov/codecov-action@fb8b3582c8e4def4969c97caa2f19720cb33a72f # v7.0.0
with:
fail_ci_if_error: true
token: ${{ secrets.CODECOV_TOKEN }} # zizmor: ignore[secrets-outside-env]
@@ -1513,7 +1513,7 @@ jobs:
with:
pattern: test-results-*
- name: Upload test results to Codecov
uses: codecov/codecov-action@e79a6962e0d4c0c17b229090214935d2e33f8354 # v6.0.1
uses: codecov/codecov-action@fb8b3582c8e4def4969c97caa2f19720cb33a72f # v7.0.0
with:
report_type: test_results
fail_ci_if_error: true
@@ -6,7 +6,7 @@ from homeassistant.helpers.entity import EntityDescription
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.util import slugify
from .coordinator import AmazonConfigEntry, AmazonDevicesCoordinator
from .coordinator import AmazonConfigEntry, AmazonDevicesCoordinator, alexa_api_call
from .entity import AmazonServiceEntity
# Coordinator is used to centralize the data updates
@@ -49,4 +49,5 @@ class AmazonRoutineButton(AmazonServiceEntity, ButtonEntity):
async def async_press(self) -> None:
"""Handle button press action."""
await self.coordinator.api.call_routine(self._routine)
async with alexa_api_call(self.coordinator):
await self.coordinator.api.call_routine(self._routine)
@@ -1,5 +1,7 @@
"""Support for Alexa Devices."""
from collections.abc import AsyncGenerator
from contextlib import asynccontextmanager
from datetime import timedelta
from aioamazondevices.api import AmazonEchoApi
@@ -19,7 +21,11 @@ from aiohttp import ClientSession
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_PASSWORD, CONF_USERNAME, Platform
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady
from homeassistant.exceptions import (
ConfigEntryAuthFailed,
ConfigEntryNotReady,
HomeAssistantError,
)
from homeassistant.helpers import device_registry as dr, entity_registry as er
from homeassistant.helpers.debounce import Debouncer
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
@@ -29,6 +35,65 @@ from .const import _LOGGER, CONF_LOGIN_DATA, DOMAIN
SCAN_INTERVAL = 300
@asynccontextmanager
async def alexa_api_call(
coordinator: DataUpdateCoordinator | None = None,
) -> AsyncGenerator[None]:
"""Handle common Alexa API exceptions as HomeAssistantError."""
try:
yield
except CannotAuthenticate as err:
if coordinator:
coordinator.last_update_success = False
raise HomeAssistantError(
translation_domain=DOMAIN,
translation_key="invalid_auth",
translation_placeholders={"error": repr(err)},
) from err
except CannotConnect as err:
if coordinator:
coordinator.last_update_success = False
raise HomeAssistantError(
translation_domain=DOMAIN,
translation_key="cannot_connect_with_error",
translation_placeholders={"error": repr(err)},
) from err
except (CannotRetrieveData, ValueError) as err:
if coordinator:
coordinator.last_update_success = False
raise HomeAssistantError(
translation_domain=DOMAIN,
translation_key="cannot_retrieve_data_with_error",
translation_placeholders={"error": repr(err)},
) from err
@asynccontextmanager
async def alexa_config_entry_errors() -> AsyncGenerator[None]:
"""Handle common Alexa API exceptions as ConfigEntry errors."""
try:
yield
except CannotAuthenticate as err:
raise ConfigEntryAuthFailed(
translation_domain=DOMAIN,
translation_key="invalid_auth",
translation_placeholders={"error": repr(err)},
) from err
except (CannotConnect, TimeoutError) as err:
raise ConfigEntryNotReady(
translation_domain=DOMAIN,
translation_key="cannot_connect_with_error",
translation_placeholders={"error": repr(err)},
) from err
except (CannotRetrieveData, ValueError, KeyError, StopIteration) as err:
raise ConfigEntryNotReady(
translation_domain=DOMAIN,
translation_key="cannot_retrieve_data_with_error",
translation_placeholders={"error": repr(err)},
) from err
type AmazonConfigEntry = ConfigEntry[AmazonDevicesCoordinator]
@@ -113,6 +178,12 @@ class AmazonDevicesCoordinator(DataUpdateCoordinator[dict[str, AmazonDevice]]):
translation_key="invalid_auth",
translation_placeholders={"error": repr(err)},
) from err
except ValueError as err:
raise UpdateFailed(
translation_domain=DOMAIN,
translation_key="cannot_retrieve_data_with_error",
translation_placeholders={"error": repr(err)},
) from err
else:
current_devices = set(data.keys())
if stale_devices := self.previous_devices - current_devices:
@@ -169,26 +240,8 @@ class AmazonDevicesCoordinator(DataUpdateCoordinator[dict[str, AmazonDevice]]):
async def sync_history_state(self) -> None:
"""Sync history state."""
try:
async with alexa_config_entry_errors():
self._vocal_records = await self.api.sync_history_state()
except CannotAuthenticate as e:
raise ConfigEntryAuthFailed(
translation_domain=DOMAIN,
translation_key="invalid_auth",
translation_placeholders={"error": repr(e)},
) from e
except CannotConnect as e:
raise ConfigEntryNotReady(
translation_domain=DOMAIN,
translation_key="cannot_connect_with_error",
translation_placeholders={"error": repr(e)},
) from e
except BaseException as e:
raise ConfigEntryNotReady(
translation_domain=DOMAIN,
translation_key="cannot_retrieve_data_with_error",
translation_placeholders={"error": repr(e)},
) from e
async def history_state_event_handler(
self, vocal_records: dict[str, AmazonVocalRecord]
@@ -204,26 +257,8 @@ class AmazonDevicesCoordinator(DataUpdateCoordinator[dict[str, AmazonDevice]]):
async def sync_media_state(self) -> None:
"""Sync media state."""
try:
async with alexa_config_entry_errors():
await self.api.sync_media_state()
except CannotAuthenticate as err:
raise ConfigEntryAuthFailed(
translation_domain=DOMAIN,
translation_key="invalid_auth",
translation_placeholders={"error": repr(err)},
) from err
except (CannotConnect, TimeoutError) as err:
raise ConfigEntryNotReady(
translation_domain=DOMAIN,
translation_key="cannot_connect_with_error",
translation_placeholders={"error": repr(err)},
) from err
except (CannotRetrieveData, ValueError) as err:
raise ConfigEntryNotReady(
translation_domain=DOMAIN,
translation_key="cannot_retrieve_data_with_error",
translation_placeholders={"error": repr(err)},
) from err
async def media_state_event_handler(
self, media_state: dict[str, AmazonMediaState]
@@ -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"]
}
@@ -22,9 +22,8 @@ from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import _LOGGER
from .coordinator import AmazonConfigEntry, AmazonDevicesCoordinator
from .coordinator import AmazonConfigEntry, AmazonDevicesCoordinator, alexa_api_call
from .entity import AmazonEntity
from .utils import alexa_api_call
PARALLEL_UPDATES = 1
@@ -216,16 +215,15 @@ class AlexaDevicesMediaPlayer(AmazonEntity, MediaPlayerEntity):
provider = media_type.value if isinstance(media_type, MediaType) else media_type
await self.async_call_alexa_music(media_id, provider)
@alexa_api_call
async def async_call_alexa_music(
self, search_phrase: str, provider_id: str
) -> None:
"""Call alexa music."""
await self.coordinator.api.call_alexa_music(
self.device, search_phrase, provider_id
)
async with alexa_api_call(self.coordinator):
await self.coordinator.api.call_alexa_music(
self.device, search_phrase, provider_id
)
@alexa_api_call
async def async_set_device_volume(self, volume: int) -> None:
"""Set the device volume."""
_LOGGER.debug(
@@ -233,7 +231,8 @@ class AlexaDevicesMediaPlayer(AmazonEntity, MediaPlayerEntity):
self.device.serial_number,
volume,
)
await self.coordinator.api.set_device_volume(self.device, volume)
async with alexa_api_call(self.coordinator):
await self.coordinator.api.set_device_volume(self.device, volume)
async def async_set_volume_level(self, volume: float) -> None:
"""Set the volume level (0.0 to 1.0)."""
@@ -263,12 +262,12 @@ class AlexaDevicesMediaPlayer(AmazonEntity, MediaPlayerEntity):
await self.async_set_volume_level(target_volume / 100)
self._prev_volume = None
@alexa_api_call
async def _send_media_command(self, command: AmazonMediaControls) -> None:
_LOGGER.debug(
"Sending media command '%s' to %s", command, self.device.serial_number
)
await self.coordinator.api.send_media_command(self.device, command)
async with alexa_api_call(self.coordinator):
await self.coordinator.api.send_media_command(self.device, command)
async def async_media_stop(self) -> None:
"""Send stop command."""
@@ -12,9 +12,8 @@ from homeassistant.components.notify import NotifyEntity, NotifyEntityDescriptio
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .coordinator import AmazonConfigEntry
from .coordinator import AmazonConfigEntry, alexa_api_call
from .entity import AmazonEntity
from .utils import alexa_api_call
PARALLEL_UPDATES = 1
@@ -80,10 +79,11 @@ class AmazonNotifyEntity(AmazonEntity, NotifyEntity):
entity_description: AmazonNotifyEntityDescription
@alexa_api_call
async def async_send_message(
self, message: str, title: str | None = None, **kwargs: Any
) -> None:
"""Send a message."""
await self.entity_description.method(self.coordinator.api, self.device, message)
async with alexa_api_call(self.coordinator):
await self.entity_description.method(
self.coordinator.api, self.device, message
)
@@ -11,7 +11,7 @@ from homeassistant.exceptions import ServiceValidationError
from homeassistant.helpers import config_validation as cv, device_registry as dr
from .const import DOMAIN, INFO_SKILLS_MAPPING
from .coordinator import AmazonConfigEntry
from .coordinator import AmazonConfigEntry, alexa_api_call
ATTR_TEXT_COMMAND = "text_command"
ATTR_SOUND = "sound"
@@ -85,13 +85,15 @@ async def _async_execute_action(call: ServiceCall, attribute: str) -> None:
translation_key="invalid_sound_value",
translation_placeholders={"sound": value},
)
await coordinator.api.call_alexa_sound(
coordinator.data[device.serial_number], value
)
async with alexa_api_call():
await coordinator.api.call_alexa_sound(
coordinator.data[device.serial_number], value
)
elif attribute == ATTR_TEXT_COMMAND:
await coordinator.api.call_alexa_text_command(
coordinator.data[device.serial_number], value
)
async with alexa_api_call():
await coordinator.api.call_alexa_text_command(
coordinator.data[device.serial_number], value
)
elif attribute == ATTR_INFO_SKILL:
info_skill = INFO_SKILLS_MAPPING.get(value)
if info_skill not in ALEXA_INFO_SKILLS:
@@ -100,9 +102,10 @@ async def _async_execute_action(call: ServiceCall, attribute: str) -> None:
translation_key="invalid_info_skill_value",
translation_placeholders={"info_skill": value},
)
await coordinator.api.call_alexa_info_skill(
coordinator.data[device.serial_number], info_skill
)
async with alexa_api_call():
await coordinator.api.call_alexa_info_skill(
coordinator.data[device.serial_number], info_skill
)
async def async_send_sound_notification(call: ServiceCall) -> None:
@@ -14,13 +14,9 @@ from homeassistant.components.switch import (
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .coordinator import AmazonConfigEntry
from .coordinator import AmazonConfigEntry, alexa_api_call
from .entity import AmazonEntity
from .utils import (
alexa_api_call,
async_remove_dnd_from_virtual_group,
async_update_unique_id,
)
from .utils import async_remove_dnd_from_virtual_group, async_update_unique_id
PARALLEL_UPDATES = 1
@@ -90,7 +86,6 @@ class AmazonSwitchEntity(AmazonEntity, SwitchEntity):
entity_description: AmazonSwitchEntityDescription
@alexa_api_call
async def _switch_set_state(self, state: bool) -> None:
"""Set desired switch state."""
method = getattr(self.coordinator.api, self.entity_description.method)
@@ -98,7 +93,8 @@ class AmazonSwitchEntity(AmazonEntity, SwitchEntity):
if TYPE_CHECKING:
assert method is not None
await method(self.device, state)
async with alexa_api_call(self.coordinator):
await method(self.device, state)
self.coordinator.data[self.device.serial_number].sensors[
self.entity_description.key
].value = state
@@ -1,54 +1,19 @@
"""Utils for Alexa Devices."""
from collections.abc import Awaitable, Callable, Coroutine
from functools import wraps
from typing import Any, Concatenate
from aioamazondevices.const.devices import SPEAKER_GROUP_FAMILY
from aioamazondevices.const.schedules import (
NOTIFICATION_ALARM,
NOTIFICATION_REMINDER,
NOTIFICATION_TIMER,
)
from aioamazondevices.exceptions import CannotConnect, CannotRetrieveData
from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN
from homeassistant.components.switch import DOMAIN as SWITCH_DOMAIN
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
import homeassistant.helpers.entity_registry as er
from .const import _LOGGER, DOMAIN
from .coordinator import AmazonDevicesCoordinator
from .entity import AmazonEntity
def alexa_api_call[_T: AmazonEntity, **_P](
func: Callable[Concatenate[_T, _P], Awaitable[None]],
) -> Callable[Concatenate[_T, _P], Coroutine[Any, Any, None]]:
"""Catch Alexa API call exceptions."""
@wraps(func)
async def cmd_wrapper(self: _T, *args: _P.args, **kwargs: _P.kwargs) -> None:
"""Wrap all command methods."""
try:
await func(self, *args, **kwargs)
except CannotConnect as err:
self.coordinator.last_update_success = False
raise HomeAssistantError(
translation_domain=DOMAIN,
translation_key="cannot_connect_with_error",
translation_placeholders={"error": repr(err)},
) from err
except CannotRetrieveData as err:
self.coordinator.last_update_success = False
raise HomeAssistantError(
translation_domain=DOMAIN,
translation_key="cannot_retrieve_data_with_error",
translation_placeholders={"error": repr(err)},
) from err
return cmd_wrapper
async def async_update_unique_id(
@@ -1816,6 +1816,11 @@ class PipelineInput:
await self.run.text_to_speech(tts_input)
except PipelineError as err:
if self.run.tts_stream:
# Clean up TTS stream
self.run.tts_stream.delete()
self.run.tts_stream = None
self.run.process_event(
PipelineEvent(
PipelineEventType.ERROR,
@@ -1885,15 +1890,17 @@ class PipelineInput:
):
prepare_tasks.append(self.run.prepare_recognize_intent(self.session))
if prepare_tasks:
await asyncio.gather(*prepare_tasks)
# Do TTS prepare separately so we don't create a ResultStream if the
# pipeline is invalid.
if (
start_stage_index
<= PIPELINE_STAGE_ORDER.index(PipelineStage.TTS)
<= end_stage_index
):
prepare_tasks.append(self.run.prepare_text_to_speech())
if prepare_tasks:
await asyncio.gather(*prepare_tasks)
await self.run.prepare_text_to_speech()
class PipelinePreferred(CollectionError):
@@ -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"]
}
@@ -7,6 +7,6 @@
"integration_type": "device",
"iot_class": "local_polling",
"loggers": ["pydaikin"],
"requirements": ["pydaikin==2.17.2"],
"requirements": ["pydaikin==2.18.1"],
"zeroconf": ["_dkapi._tcp.local."]
}
@@ -166,6 +166,8 @@ class RuntimeEntryData:
)
loaded_platforms: set[Platform] = field(default_factory=set)
platform_load_lock: asyncio.Lock = field(default_factory=asyncio.Lock)
# Set once the first connection has finished scanner setup or teardown.
first_connect_done: asyncio.Event = field(default_factory=asyncio.Event)
_storage_contents: StoreData | None = None
_pending_storage: Callable[[], StoreData] | None = None
assist_pipeline_update_callbacks: list[CALLBACK_TYPE] = field(default_factory=list)
+22 -1
View File
@@ -1,11 +1,12 @@
"""Manager for esphome devices."""
import asyncio
import base64
from functools import partial
import logging
import secrets
import struct
from typing import TYPE_CHECKING, Any, NamedTuple
from typing import TYPE_CHECKING, Any, Final, NamedTuple
from aioesphomeapi import (
APIClient,
@@ -106,6 +107,9 @@ if TYPE_CHECKING:
_LOGGER = logging.getLogger(__name__)
# Max time to wait at startup for a BLE proxy to register its scanner.
STARTUP_SCANNER_WAIT: Final = 3.0
LOG_LEVEL_TO_LOGGER = {
LogLevel.LOG_LEVEL_NONE: logging.DEBUG,
LogLevel.LOG_LEVEL_ERROR: logging.ERROR,
@@ -677,6 +681,8 @@ class ESPHomeManager:
hass, device_info.bluetooth_mac_address or device_info.mac_address
)
entry_data.first_connect_done.set()
if device_info.voice_assistant_feature_flags_compat(api_version) and (
Platform.ASSIST_SATELLITE not in entry_data.loaded_platforms
):
@@ -988,6 +994,21 @@ class ESPHomeManager:
await reconnect_logic.start()
# Wait for a cached BLE proxy to register its scanner before finishing setup.
if (
device_info := entry_data.device_info
) is not None and device_info.bluetooth_proxy_feature_flags_compat(
entry_data.api_version
):
try:
async with asyncio.timeout(STARTUP_SCANNER_WAIT):
await entry_data.first_connect_done.wait()
except TimeoutError:
_LOGGER.debug(
"%s: Timed out waiting for Bluetooth scanner to register",
self.host,
)
@callback
def _async_setup_device_registry(
@@ -21,5 +21,5 @@
"integration_type": "system",
"preview_features": { "winter_mode": {} },
"quality_scale": "internal",
"requirements": ["home-assistant-frontend==20260527.4"]
"requirements": ["home-assistant-frontend==20260527.6"]
}
@@ -1,11 +1,13 @@
"""The Gardena Bluetooth integration."""
from contextlib import suppress
import logging
from bleak.backends.device import BLEDevice
from gardena_bluetooth.client import CachedConnection, Client
from gardena_bluetooth.const import ProductType
from gardena_bluetooth.scan import async_get_manufacturer_data
from gardena_bluetooth.const import ScanService
from gardena_bluetooth.parse import ManufacturerData, ProductType
from habluetooth import BluetoothServiceInfoBleak
from homeassistant.components import bluetooth
from homeassistant.const import CONF_ADDRESS, Platform
@@ -30,6 +32,64 @@ PLATFORMS: list[Platform] = [
]
LOGGER = logging.getLogger(__name__)
DISCONNECT_DELAY = 5
PRODUCTS_SCAN_TIMEOUT = 10
PRODUCT_TYPE_TIMEOUT = 30
async def async_get_product_type(hass: HomeAssistant, address: str) -> ProductType:
"""Get a product type for the given address."""
data = ManufacturerData()
def _data_callback(info: BluetoothServiceInfoBleak) -> bool:
LOGGER.debug("Processing advertisement from %s: %s", info.address, info)
if info.device.address != address:
return False
data.update(info.manufacturer_data.get(ManufacturerData.company, b""))
return data.product_type is not ProductType.UNKNOWN
with suppress(TimeoutError):
await bluetooth.async_process_advertisements(
hass,
_data_callback,
bluetooth.BluetoothCallbackMatcher(
address=address, manufacturer_id=ManufacturerData.company
),
mode=bluetooth.BluetoothScanningMode.ACTIVE,
timeout=PRODUCT_TYPE_TIMEOUT,
)
return data.product_type
async def async_get_products(hass: HomeAssistant) -> dict[str, ManufacturerData]:
"""Get all products that are currently advertising."""
products: dict[str, ManufacturerData] = {}
def _data_callback(info: BluetoothServiceInfoBleak) -> bool:
LOGGER.debug("Processing advertisement from %s: %s", info.address, info)
if ScanService not in info.service_uuids:
return False
raw = info.manufacturer_data.get(ManufacturerData.company, b"")
if (data := products.get(info.device.address)) is None:
data = ManufacturerData()
products[info.device.address] = data
data.update(raw)
return False
with suppress(TimeoutError):
await bluetooth.async_process_advertisements(
hass,
_data_callback,
bluetooth.BluetoothCallbackMatcher(
manufacturer_id=ManufacturerData.company
),
mode=bluetooth.BluetoothScanningMode.ACTIVE,
timeout=PRODUCTS_SCAN_TIMEOUT,
)
return products
def get_connection(hass: HomeAssistant, address: str) -> CachedConnection:
@@ -53,12 +113,7 @@ async def async_setup_entry(
address = entry.data[CONF_ADDRESS]
try:
mfg_data = await async_get_manufacturer_data({address})
except TimeoutError as exc:
raise ConfigEntryNotReady("Unable to find product type") from exc
product_type = mfg_data[address].product_type
product_type = await async_get_product_type(hass, address)
if product_type is ProductType.UNKNOWN:
raise ConfigEntryNotReady("Unable to find product type")
@@ -4,21 +4,17 @@ import logging
from typing import Any
from gardena_bluetooth.client import Client
from gardena_bluetooth.const import PRODUCT_NAMES, DeviceInformation, ScanService
from gardena_bluetooth.const import PRODUCT_NAMES, DeviceInformation
from gardena_bluetooth.exceptions import CharacteristicNotFound, CommunicationFailure
from gardena_bluetooth.parse import ManufacturerData, ProductType
from gardena_bluetooth.scan import async_get_manufacturer_data
from gardena_bluetooth.parse import ProductType
import voluptuous as vol
from homeassistant.components.bluetooth import (
BluetoothServiceInfo,
async_discovered_service_info,
)
from homeassistant.components.bluetooth import BluetoothServiceInfo
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
from homeassistant.const import CONF_ADDRESS
from homeassistant.data_entry_flow import AbortFlow
from . import get_connection
from . import async_get_product_type, async_get_products, get_connection
from .const import DOMAIN
_LOGGER = logging.getLogger(__name__)
@@ -33,17 +29,6 @@ _SUPPORTED_PRODUCT_TYPES = {
}
def _is_supported(discovery_info: BluetoothServiceInfo):
"""Check if device is supported."""
if ScanService not in discovery_info.service_uuids:
return False
if discovery_info.manufacturer_data.get(ManufacturerData.company) is None:
_LOGGER.debug("Missing manufacturer data: %s", discovery_info)
return False
return True
class GardenaBluetoothConfigFlow(ConfigFlow, domain=DOMAIN):
"""Handle a config flow for Gardena Bluetooth."""
@@ -75,8 +60,7 @@ class GardenaBluetoothConfigFlow(ConfigFlow, domain=DOMAIN):
) -> ConfigFlowResult:
"""Handle the bluetooth discovery step."""
_LOGGER.debug("Discovered device: %s", discovery_info)
data = await async_get_manufacturer_data({discovery_info.address})
product_type = data[discovery_info.address].product_type
product_type = await async_get_product_type(self.hass, discovery_info.address)
if product_type not in _SUPPORTED_PRODUCT_TYPES:
return self.async_abort(reason="no_devices_found")
@@ -117,22 +101,16 @@ class GardenaBluetoothConfigFlow(ConfigFlow, domain=DOMAIN):
self._abort_if_unique_id_configured()
return await self.async_step_confirm()
current_addresses = self._async_current_ids(include_ignore=False)
candidates = set()
for discovery_info in async_discovered_service_info(self.hass):
address = discovery_info.address
if address in current_addresses or not _is_supported(discovery_info):
continue
candidates.add(address)
data = await async_get_manufacturer_data(candidates)
for address, mfg_data in data.items():
if mfg_data.product_type not in _SUPPORTED_PRODUCT_TYPES:
continue
self.devices[address] = PRODUCT_NAMES[mfg_data.product_type]
current = self._async_current_ids(include_ignore=False)
devices = await async_get_products(self.hass)
# Keep selection sorted by address to ensure stable tests
self.devices = dict(sorted(self.devices.items(), key=lambda x: x[0]))
self.devices = {
address: PRODUCT_NAMES[data.product_type]
for address in sorted(devices)
if address not in current
and (data := devices[address]).product_type in _SUPPORTED_PRODUCT_TYPES
}
if not self.devices:
return self.async_abort(reason="no_devices_found")
@@ -135,7 +135,24 @@ async def async_migrate_entry(
)
if canonical.entry_id != config_entry.entry_id:
# The canonical entry's migration will remove this duplicate.
if canonical.minor_version < 2:
# The canonical entry has not been migrated yet and its
# migration will remove this duplicate.
return False
# The canonical entry is already fully migrated and will not run
# a migration that removes this duplicate, so remove it here. The
# entry can't remove itself while its setup lock is held, so
# schedule the removal instead.
_LOGGER.debug(
"Removing duplicate config entry %s for serial %s in favor of %s",
config_entry.entry_id,
serial_number,
canonical.entry_id,
)
hass.async_create_task(
hass.config_entries.async_remove(config_entry.entry_id)
)
return False
for duplicate in duplicates:
@@ -239,7 +239,24 @@ async def async_migrate_entry(
)
if canonical.entry_id != config_entry.entry_id:
# The canonical entry's migration will remove this duplicate.
if canonical.minor_version < 5:
# The canonical entry has not been migrated yet and its
# migration will remove this duplicate.
return False
# The canonical entry is already fully migrated and will not run
# a migration that removes this duplicate, so remove it here. The
# entry can't remove itself while its setup lock is held, so
# schedule the removal instead.
_LOGGER.warning(
"Removing duplicate config entry %s for serial %s in favor of %s",
config_entry.entry_id,
serial_number,
canonical.entry_id,
)
hass.async_create_task(
hass.config_entries.async_remove(config_entry.entry_id)
)
return False
for duplicate in duplicates:
+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",
@@ -8,5 +8,5 @@
"integration_type": "hub",
"iot_class": "local_polling",
"quality_scale": "bronze",
"requirements": ["mitsubishi-comfort==0.3.0"]
"requirements": ["mitsubishi-comfort==0.3.1"]
}
@@ -412,6 +412,12 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
async def _reload_config(call: ServiceCall) -> None:
"""Reload the platforms."""
if not mqtt_config_entry_enabled(hass):
_LOGGER.debug(
"Skipped reloading MQTT integration, "
"the MQTT config entry is not enabled"
)
return
entry: ConfigEntry = next(iter(hass.config_entries.async_entries(DOMAIN)))
mqtt_data = hass.data[DATA_MQTT]
+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"]),
)
@@ -159,11 +159,14 @@ class OpenThermGatewayHub:
_LOGGER.debug("Received report: %s", status)
async_dispatcher_send(self.hass, self.update_signal, status)
boiler_manufacturer = status[OpenThermDataSource.BOILER].get(
gw_vars.DATA_SLAVE_MEMBERID
)
dev_reg.async_update_device(
boiler_device.id,
manufacturer=status[OpenThermDataSource.BOILER].get(
gw_vars.DATA_SLAVE_MEMBERID
),
manufacturer=str(boiler_manufacturer)
if boiler_manufacturer is not None
else None,
model_id=status[OpenThermDataSource.BOILER].get(
gw_vars.DATA_SLAVE_PRODUCT_TYPE
),
@@ -175,11 +178,14 @@ class OpenThermGatewayHub:
),
)
thermostat_manufacturer = status[OpenThermDataSource.THERMOSTAT].get(
gw_vars.DATA_MASTER_MEMBERID
)
dev_reg.async_update_device(
thermostat_device.id,
manufacturer=status[OpenThermDataSource.THERMOSTAT].get(
gw_vars.DATA_MASTER_MEMBERID
),
manufacturer=str(thermostat_manufacturer)
if thermostat_manufacturer is not None
else None,
model_id=status[OpenThermDataSource.THERMOSTAT].get(
gw_vars.DATA_MASTER_PRODUCT_TYPE
),
@@ -9,5 +9,5 @@
"iot_class": "cloud_polling",
"loggers": ["opower"],
"quality_scale": "platinum",
"requirements": ["opower==0.18.2"]
"requirements": ["opower==0.18.4"]
}
@@ -45,8 +45,8 @@ USER_SCHEMA = vol.Schema(
): str,
vol.Required(
CONF_API_VERSION,
default=1,
): vol.In([1, 5, 6]),
default="1",
): vol.In(["1", "5", "6"]),
}
)
@@ -223,7 +223,7 @@ class PhilipsJSConfigFlow(ConfigFlow, domain=DOMAIN):
self._current = user_input
try:
await self._async_attempt_prepare(
user_input[CONF_HOST], user_input[CONF_API_VERSION], False
user_input[CONF_HOST], int(user_input[CONF_API_VERSION]), False
)
except GeneralFailure as exc:
LOGGER.error(exc)
+2 -2
View File
@@ -155,9 +155,9 @@ class PlugwiseClimateEntity(PlugwiseEntity, ClimateEntity, RestoreEntity):
)
@property
def current_temperature(self) -> float:
def current_temperature(self) -> float | None:
"""Return the current temperature."""
return self.device["sensors"]["temperature"]
return self.device["sensors"].get("temperature")
@property
def target_temperature(self) -> float:
@@ -8,6 +8,6 @@
"iot_class": "local_polling",
"loggers": ["plugwise"],
"quality_scale": "platinum",
"requirements": ["plugwise==1.11.3"],
"requirements": ["plugwise==1.11.4"],
"zeroconf": ["_plugwise._tcp.local."]
}
@@ -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']})",
)
@@ -8,5 +8,5 @@
"iot_class": "cloud_polling",
"loggers": ["renault_api"],
"quality_scale": "silver",
"requirements": ["renault-api==0.5.11"]
"requirements": ["renault-api==0.5.12"]
}
+14 -9
View File
@@ -369,7 +369,11 @@ class ReolinkHost:
)
# start long polling if ONVIF push failed immediately
if not self._onvif_push_supported and not self._api.baichuan.privacy_mode():
if (
self._onvif_long_poll_supported
and not self._onvif_push_supported
and not self._api.baichuan.privacy_mode()
):
_LOGGER.debug(
"Camera model %s does not support ONVIF push,"
" using ONVIF long polling instead",
@@ -378,14 +382,8 @@ class ReolinkHost:
try:
await self._async_start_long_polling(initial=True)
except NotSupportedError:
_LOGGER.debug(
"Camera model %s does not support ONVIF long"
" polling, using fast polling instead",
self._api.model,
)
self._onvif_long_poll_supported = False
await self._api.unsubscribe()
await self._async_poll_all_motion()
else:
self._cancel_long_poll_check = async_call_later(
self._hass,
@@ -393,6 +391,13 @@ class ReolinkHost:
self._async_check_onvif_long_poll,
)
if not self._onvif_long_poll_supported:
_LOGGER.debug(
"Camera model %s does not support ONVIF push and long polling, using fast polling instead",
self._api.model,
)
await self._async_poll_all_motion()
self._cancel_tcp_push_check = None
async def _async_check_onvif(self, *_: Any) -> None:
@@ -822,7 +827,7 @@ class ReolinkHost:
return
try:
if self._api.session_active:
if self._api.session_active and not self._api.baichuan.privacy_mode():
await self._api.get_motion_state_all_ch()
except ReolinkError as err:
if not self._fast_poll_error:
@@ -834,7 +839,7 @@ class ReolinkHost:
)
self._fast_poll_error = True
else:
if self._api.session_active:
if self._api.session_active and not self._api.baichuan.privacy_mode():
self._fast_poll_error = False
finally:
# schedule next poll
@@ -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
@@ -1,5 +1,6 @@
"""The Shelly integration."""
import asyncio
from functools import partial
from typing import Final
@@ -73,6 +74,7 @@ from .utils import (
get_http_port,
get_rpc_scripts_event_types,
get_ws_context,
is_rpc_ble_scanner_supported,
remove_empty_sub_devices,
remove_stale_blu_trv_devices,
)
@@ -114,6 +116,9 @@ COAP_SCHEMA: Final = vol.Schema(
)
CONFIG_SCHEMA: Final = vol.Schema({DOMAIN: COAP_SCHEMA}, extra=vol.ALLOW_EXTRA)
# Max time to wait at startup for a BLE proxy to register its scanner.
STARTUP_SCANNER_WAIT: Final = 3.0
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
"""Set up the Shelly component."""
@@ -365,6 +370,21 @@ async def _async_setup_rpc_entry(hass: HomeAssistant, entry: ShellyConfigEntry)
runtime_data.rpc = ShellyRpcCoordinator(hass, entry, device)
runtime_data.rpc.async_setup()
if (
is_rpc_ble_scanner_supported(entry)
and entry.options.get(CONF_BLE_SCANNER_MODE, BLEScannerMode.DISABLED)
!= BLEScannerMode.DISABLED
):
# Wait for the proxy to register its scanner before finishing setup.
try:
async with asyncio.timeout(STARTUP_SCANNER_WAIT):
await runtime_data.rpc.ble_scanner_setup_done.wait()
except TimeoutError:
LOGGER.debug(
"%s: Timed out waiting for BLE scanner to register", entry.title
)
runtime_data.rpc_poll = ShellyRpcPollingCoordinator(hass, entry, device)
await hass.config_entries.async_forward_entry_setups(
entry, runtime_data.platforms
+24 -19
View File
@@ -521,6 +521,8 @@ class ShellyRpcCoordinator(ShellyCoordinatorBase[RpcDevice]):
super().__init__(hass, entry, device, update_interval)
self.connected = False
# Set once BLE scanner setup has been attempted after connecting.
self.ble_scanner_setup_done = asyncio.Event()
self._disconnected_callbacks: list[CALLBACK_TYPE] = []
self._connection_lock = asyncio.Lock()
self._event_listeners: list[Callable[[dict[str, Any]], None]] = []
@@ -759,27 +761,30 @@ class ShellyRpcCoordinator(ShellyCoordinatorBase[RpcDevice]):
async def _async_connect_ble_scanner(self) -> None:
"""Connect BLE scanner."""
ble_scanner_mode = self.config_entry.options.get(
CONF_BLE_SCANNER_MODE, BLEScannerMode.DISABLED
)
if ble_scanner_mode == BLEScannerMode.DISABLED and self.connected:
await async_stop_scanner(self.device)
async_remove_scanner(self.hass, self.bluetooth_source)
return
if await async_ensure_ble_enabled(self.device):
# BLE enable required a reboot, don't bother connecting
# the scanner since it will be disconnected anyway
LOGGER.debug(
"Device %s BLE enable required a reboot, skipping scanner connect",
self.name,
try:
ble_scanner_mode = self.config_entry.options.get(
CONF_BLE_SCANNER_MODE, BLEScannerMode.DISABLED
)
return
assert self.device_id is not None
self._disconnected_callbacks.append(
await async_connect_scanner(
self.hass, self, ble_scanner_mode, self.device_id
if ble_scanner_mode == BLEScannerMode.DISABLED and self.connected:
await async_stop_scanner(self.device)
async_remove_scanner(self.hass, self.bluetooth_source)
return
if await async_ensure_ble_enabled(self.device):
# BLE enable required a reboot, don't bother connecting
# the scanner since it will be disconnected anyway
LOGGER.debug(
"Device %s BLE enable required a reboot, skipping scanner connect",
self.name,
)
return
assert self.device_id is not None
self._disconnected_callbacks.append(
await async_connect_scanner(
self.hass, self, ble_scanner_mode, self.device_id
)
)
)
finally:
self.ble_scanner_setup_done.set()
@callback
def _async_handle_rpc_device_online(self) -> None:
+2 -2
View File
@@ -663,9 +663,9 @@ def is_view_for_platform(config: dict[str, Any], key: str, platform: str) -> boo
def get_virtual_component_unit(config: dict[str, Any]) -> str | None:
"""Return the unit of a virtual component.
If the unit is not set, the device sends an empty string
If the unit is not set, the device sends an empty string or the key may be absent.
"""
unit = config["meta"]["ui"]["unit"]
unit = config["meta"]["ui"].get("unit")
return DEVICE_UNIT_MAP.get(unit, unit) if unit else None
@@ -74,7 +74,7 @@ ATTR_QUERY_RESULT = "query_result"
_LOGGER = logging.getLogger(__name__)
PARALLEL_UPDATES = 1
PARALLEL_UPDATES = 0
ATTR_OTHER_PLAYER = "other_player"
@@ -22,7 +22,7 @@ from .entity import SqueezeboxEntity
_LOGGER = logging.getLogger(__name__)
PARALLEL_UPDATES = 1
PARALLEL_UPDATES = 0
async def async_setup_entry(
@@ -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
+9
View File
@@ -613,6 +613,10 @@ class ResultStream:
async for chunk in converted_audio:
yield chunk
def delete(self) -> None:
"""Remove the result stream from the manager."""
self._manager.async_delete_result_stream(self.token)
def _hash_options(options: dict) -> str:
"""Hashes an options dictionary."""
@@ -809,6 +813,11 @@ class SpeechManager:
stream.last_used = monotonic()
return stream
@callback
def async_delete_result_stream(self, token: str) -> None:
"""Delete a result stream given a token."""
self.token_to_stream.pop(token, None)
@callback
def async_create_result_stream(
self,
@@ -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]
+3 -3
View File
@@ -1,6 +1,6 @@
"""Diagnostics support for V2C."""
from typing import TYPE_CHECKING, Any
from typing import TYPE_CHECKING, Any, cast
from homeassistant.components.diagnostics import async_redact_data
from homeassistant.const import CONF_HOST
@@ -21,11 +21,11 @@ async def async_get_config_entry_diagnostics(
assert coordinator.evse
coordinator_data = coordinator.evse.data
evse_raw_data = coordinator.evse.raw_data
evse_raw_data = cast(dict[str, Any], coordinator.evse.raw_data)
return {
"config_entry": async_redact_data(entry.as_dict(), TO_REDACT),
"data": str(coordinator_data),
"raw_data": evse_raw_data["content"].decode("utf-8"), # type: ignore[attr-defined]
"raw_data": evse_raw_data["content"].decode("utf-8"),
"host_status": evse_raw_data["status_code"],
}
+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.0"]
"requirements": ["pytrydan==1.0.2"]
}
@@ -348,6 +348,8 @@ class WyomingAssistSatellite(WyomingSatelliteEntity, AssistSatelliteEntity):
# Use ffmpeg to convert to raw PCM audio with the appropriate format
proc = await asyncio.create_subprocess_exec(
self._ffmpeg_manager.binary,
"-protocol_whitelist",
"http,https,file,tcp,tls",
"-i",
announcement.media_id,
"-f",
@@ -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
@@ -6,5 +6,5 @@
"documentation": "https://www.home-assistant.io/integrations/yardian",
"integration_type": "device",
"iot_class": "local_polling",
"requirements": ["pyyardian==1.3.3"]
"requirements": ["pyyardian==1.4.0"]
}
@@ -30,7 +30,7 @@ POINT_ENTITIES = {
class ZinvoltBatteryStateDescription(BinarySensorEntityDescription):
"""Binary sensor description for Zinvolt battery state."""
is_on_fn: Callable[[ZinvoltData], bool]
is_on_fn: Callable[[ZinvoltData], bool | None]
SENSORS: tuple[ZinvoltBatteryStateDescription, ...] = (
@@ -84,7 +84,7 @@ class ZinvoltBatteryStateBinarySensor(ZinvoltEntity, BinarySensorEntity):
)
@property
def is_on(self) -> bool:
def is_on(self) -> bool | None:
"""Return the state of the binary sensor."""
return self.entity_description.is_on_fn(self.coordinator.data)
+10 -1
View File
@@ -1,6 +1,6 @@
"""Base entity for Zinvolt integration."""
from zinvolt.models import Unit
from zinvolt.models import OnlineStatus, Unit
from homeassistant.const import ATTR_VIA_DEVICE
from homeassistant.helpers.device_registry import DeviceInfo
@@ -25,6 +25,15 @@ class ZinvoltEntity(CoordinatorEntity[ZinvoltDeviceCoordinator]):
serial_number=coordinator.data.battery.serial_number,
)
@property
def available(self) -> bool:
"""Return if the entity is available."""
return (
super().available
and self.coordinator.data.battery.current_power.online_status
is OnlineStatus.ONLINE
)
class ZinvoltUnitEntity(ZinvoltEntity):
"""Base entity for Zinvolt units."""
@@ -8,5 +8,5 @@
"iot_class": "cloud_polling",
"loggers": ["zinvolt"],
"quality_scale": "bronze",
"requirements": ["zinvolt==0.4.3"]
"requirements": ["zinvolt==1.0.0"]
}
+5 -1
View File
@@ -34,7 +34,11 @@ NUMBERS: tuple[ZinvoltBatteryStateDescription, ...] = (
entity_category=EntityCategory.CONFIG,
device_class=NumberDeviceClass.POWER,
native_unit_of_measurement=UnitOfPower.WATT,
value_fn=lambda state: state.battery.global_settings.max_output,
value_fn=lambda state: (
2000
if state.battery.global_settings.max_output_unlocked
else state.battery.global_settings.max_output
),
set_value_fn=lambda client, battery_id, value: client.set_max_output(
battery_id, value
),
+9 -3
View File
@@ -21,7 +21,7 @@ from .entity import ZinvoltEntity
class ZinvoltBatteryStateDescription(SensorEntityDescription):
"""Sensor description for Zinvolt battery state."""
value_fn: Callable[[ZinvoltData], float]
value_fn: Callable[[ZinvoltData], float | None]
SENSORS: tuple[ZinvoltBatteryStateDescription, ...] = (
@@ -37,7 +37,13 @@ SENSORS: tuple[ZinvoltBatteryStateDescription, ...] = (
device_class=SensorDeviceClass.POWER,
state_class=SensorStateClass.MEASUREMENT,
native_unit_of_measurement=UnitOfPower.WATT,
value_fn=lambda state: 0 - state.battery.current_power.power_socket_output,
value_fn=(
lambda state: (
None
if state.battery.current_power.power_socket_output is None
else 0 - state.battery.current_power.power_socket_output
)
),
),
)
@@ -74,6 +80,6 @@ class ZinvoltBatteryStateSensor(ZinvoltEntity, SensorEntity):
)
@property
def native_value(self) -> float:
def native_value(self) -> float | None:
"""Return the state of the sensor."""
return self.entity_description.value_fn(self.coordinator.data)
+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 = "1"
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.4
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.4"
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.1"
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
+13 -13
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.4
home-assistant-frontend==20260527.6
# homeassistant.components.conversation
home-assistant-intents==2026.6.1
@@ -1577,7 +1577,7 @@ millheater==0.14.1
minio==7.1.12
# homeassistant.components.mitsubishi_comfort
mitsubishi-comfort==0.3.0
mitsubishi-comfort==0.3.1
# homeassistant.components.moat
moat-ble==0.1.1
@@ -1776,7 +1776,7 @@ openwrt-luci-rpc==1.1.17
openwrt-ubus-rpc==0.0.2
# homeassistant.components.opower
opower==0.18.2
opower==0.18.4
# homeassistant.components.oralb
oralb-ble==1.1.0
@@ -1843,7 +1843,7 @@ plexauth==0.0.6
plexwebsocket==0.0.14
# homeassistant.components.plugwise
plugwise==1.11.3
plugwise==1.11.4
# homeassistant.components.serial_pm
pmsensor==0.4
@@ -2081,7 +2081,7 @@ pycsspeechtts==1.0.8
pycync==0.5.0
# homeassistant.components.daikin
pydaikin==2.17.2
pydaikin==2.18.1
# homeassistant.components.danfoss_air
pydanfossair==0.1.0
@@ -2773,7 +2773,7 @@ pytradfri[async]==9.0.1
pytrafikverket==1.1.1
# homeassistant.components.v2c
pytrydan==1.0.0
pytrydan==1.0.2
# homeassistant.components.uptimerobot
pyuptimerobot==25.0.0
@@ -2824,7 +2824,7 @@ pyws66i==1.1
pyxeoma==1.4.2
# homeassistant.components.yardian
pyyardian==1.3.3
pyyardian==1.4.0
# homeassistant.components.qrcode
pyzbar==0.1.9
@@ -2872,13 +2872,13 @@ refoss-ha==1.2.5
regenmaschine==2024.03.0
# homeassistant.components.renault
renault-api==0.5.11
renault-api==0.5.12
# homeassistant.components.renson
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
@@ -3451,7 +3451,7 @@ zhong-hong-hvac==1.0.13
ziggo-mediabox-xl==1.1.0
# homeassistant.components.zinvolt
zinvolt==0.4.3
zinvolt==1.0.0
# homeassistant.components.zoneminder
zm-py==0.5.4
@@ -91,6 +91,48 @@ async def test_coordinator_load_previous_devices_from_registry(
assert coordinator.previous_devices == {TEST_DEVICE_1_SN}
@pytest.mark.parametrize(
("side_effect", "expected_state"),
[
pytest.param(
CannotConnect,
ConfigEntryState.SETUP_RETRY,
id="cannot_connect",
),
pytest.param(
CannotRetrieveData,
ConfigEntryState.SETUP_RETRY,
id="cannot_retrieve_data",
),
pytest.param(
CannotAuthenticate,
ConfigEntryState.SETUP_ERROR,
id="cannot_authenticate",
),
pytest.param(
ValueError,
ConfigEntryState.SETUP_RETRY,
id="value_error",
),
],
)
async def test_async_update_data_errors(
hass: HomeAssistant,
mock_amazon_devices_client: AsyncMock,
mock_config_entry: MockConfigEntry,
side_effect: type[Exception],
expected_state: ConfigEntryState,
) -> None:
"""Test _async_update_data error handling."""
mock_amazon_devices_client.get_devices_data.side_effect = side_effect
mock_config_entry.add_to_hass(hass)
await hass.config_entries.async_setup(mock_config_entry.entry_id)
await hass.async_block_till_done()
assert mock_config_entry.state is expected_state
@pytest.mark.parametrize(
("side_effect", "expected_state"),
[
+30 -4
View File
@@ -3,7 +3,11 @@
from unittest.mock import AsyncMock
from aioamazondevices.const.devices import SPEAKER_GROUP_FAMILY
from aioamazondevices.exceptions import CannotConnect, CannotRetrieveData
from aioamazondevices.exceptions import (
CannotAuthenticate,
CannotConnect,
CannotRetrieveData,
)
import pytest
from homeassistant.components.alexa_devices.const import DOMAIN
@@ -25,15 +29,37 @@ ENTITY_ID = "switch.echo_test_do_not_disturb"
@pytest.mark.parametrize(
("side_effect", "key", "error"),
[
(CannotConnect, "cannot_connect_with_error", "CannotConnect()"),
(CannotRetrieveData, "cannot_retrieve_data_with_error", "CannotRetrieveData()"),
pytest.param(
CannotAuthenticate,
"invalid_auth",
"CannotAuthenticate()",
id="cannot_authenticate",
),
pytest.param(
CannotConnect,
"cannot_connect_with_error",
"CannotConnect()",
id="cannot_connect",
),
pytest.param(
CannotRetrieveData,
"cannot_retrieve_data_with_error",
"CannotRetrieveData()",
id="cannot_retrieve_data",
),
pytest.param(
ValueError,
"cannot_retrieve_data_with_error",
"ValueError()",
id="value_error",
),
],
)
async def test_alexa_api_call_exceptions(
hass: HomeAssistant,
mock_amazon_devices_client: AsyncMock,
mock_config_entry: MockConfigEntry,
side_effect: Exception,
side_effect: type[Exception],
key: str,
error: str,
) -> None:
@@ -2274,3 +2274,86 @@ async def test_stt_vad_enabled_based_on_audio_processing(
# VAD should NOT be created when requires_external_vad is False
mock_vad.assert_not_called()
async def test_invalid_pipeline_does_not_create_tts_stream(
hass: HomeAssistant,
mock_wake_word_provider_entity: MockWakeWordEntity,
init_components,
) -> None:
"""Test that an invalid pipeline won't create a TTS ResultStream."""
pipeline = async_get_pipeline(hass, None)
await async_update_pipeline(hass, pipeline, stt_engine="does-not-exist")
async def audio_data() -> AsyncGenerator[bytes]:
yield make_10ms_chunk(b"not used")
with patch.object(
mock_wake_word_provider_entity,
"async_process_audio_stream",
side_effect=assist_pipeline.error.WakeWordTimeoutError(
code="timeout", message="timeout"
),
):
await assist_pipeline.async_pipeline_from_audio_stream(
hass,
context=Context(),
event_callback=lambda event: None,
stt_metadata=stt.SpeechMetadata(
language="",
format=stt.AudioFormats.WAV,
codec=stt.AudioCodecs.PCM,
bit_rate=stt.AudioBitRates.BITRATE_16,
sample_rate=stt.AudioSampleRates.SAMPLERATE_16000,
channel=stt.AudioChannels.CHANNEL_MONO,
),
stt_stream=audio_data(),
start_stage=assist_pipeline.PipelineStage.STT,
end_stage=assist_pipeline.PipelineStage.TTS,
audio_settings=assist_pipeline.AudioSettings(is_vad_enabled=False),
)
assert len(hass.data[tts.DATA_TTS_MANAGER].token_to_stream) == 0
async def test_pipeline_error_before_tts_does_not_leak_result_stream(
hass: HomeAssistant,
mock_wake_word_provider_entity: MockWakeWordEntity,
init_components,
) -> None:
"""Test that a pipeline error before TTS will not leak a ResultStream."""
async def audio_data() -> AsyncGenerator[bytes]:
yield make_10ms_chunk(b"not used")
with patch.object(
mock_wake_word_provider_entity,
"async_process_audio_stream",
side_effect=assist_pipeline.error.WakeWordTimeoutError(
code="timeout", message="timeout"
),
):
for i in range(10):
with patch("secrets.token_urlsafe", return_value=f"mocked-token-{i}"):
await assist_pipeline.async_pipeline_from_audio_stream(
hass,
context=Context(),
event_callback=lambda event: None,
stt_metadata=stt.SpeechMetadata(
language="",
format=stt.AudioFormats.WAV,
codec=stt.AudioCodecs.PCM,
bit_rate=stt.AudioBitRates.BITRATE_16,
sample_rate=stt.AudioSampleRates.SAMPLERATE_16000,
channel=stt.AudioChannels.CHANNEL_MONO,
),
stt_stream=audio_data(),
start_stage=assist_pipeline.PipelineStage.WAKE_WORD,
end_stage=assist_pipeline.PipelineStage.TTS,
wake_word_settings=assist_pipeline.WakeWordSettings(
audio_seconds_to_buffer=1.5
),
audio_settings=assist_pipeline.AudioSettings(is_vad_enabled=False),
)
assert len(hass.data[tts.DATA_TTS_MANAGER].token_to_stream) == 0
+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."""
+123
View File
@@ -9,7 +9,9 @@ from unittest.mock import AsyncMock, Mock, call, patch
from aioesphomeapi import (
APIClient,
APIConnectionError,
APIVersion,
AreaInfo,
BluetoothProxyFeature,
DeviceInfo,
EncryptionPlaintextAPIError,
ExecuteServiceResponse,
@@ -3274,3 +3276,124 @@ async def test_service_registration_response_types(
hass.services.supports_response(DOMAIN, "test_status_service")
== SupportsResponse.NONE
)
def _create_cached_bluetooth_proxy_entry(
hass: HomeAssistant,
hass_storage: dict[str, Any],
bluetooth_proxy_feature_flags: BluetoothProxyFeature,
) -> tuple[MockConfigEntry, DeviceInfo]:
"""Create an entry with cached device info so setup knows the proxy state."""
entry = MockConfigEntry(
domain=DOMAIN,
unique_id="11:22:33:44:55:aa",
data={
CONF_HOST: "test.local",
CONF_PORT: 6053,
CONF_PASSWORD: "",
CONF_BLUETOOTH_MAC_ADDRESS: "AA:BB:CC:DD:EE:FC",
},
)
entry.add_to_hass(hass)
device_info = DeviceInfo(
name="test",
mac_address="11:22:33:44:55:AA",
bluetooth_mac_address="AA:BB:CC:DD:EE:FC",
bluetooth_proxy_feature_flags=bluetooth_proxy_feature_flags,
)
storage_key = f"{DOMAIN}.{entry.entry_id}"
hass_storage[storage_key] = {
"version": 1,
"minor_version": 1,
"key": storage_key,
"data": {
"device_info": device_info.to_dict(),
"api_version": APIVersion(1, 9).to_dict(),
},
}
return entry, device_info
@pytest.mark.usefixtures("mock_zeroconf")
async def test_bluetooth_proxy_waits_for_scanner_at_startup(
hass: HomeAssistant,
mock_client: APIClient,
hass_storage: dict[str, Any],
) -> None:
"""Test setup waits for a cached bluetooth proxy to register its scanner."""
entry, device_info = _create_cached_bluetooth_proxy_entry(
hass, hass_storage, BluetoothProxyFeature.PASSIVE_SCAN
)
connect_event = asyncio.Event()
reached_connect = asyncio.Event()
async def _block_until_released() -> tuple[DeviceInfo, list[Any], list[Any]]:
reached_connect.set()
await connect_event.wait()
return (device_info, [], [])
mock_client.device_info_and_list_entities = _block_until_released
setup_task = hass.async_create_task(hass.config_entries.async_setup(entry.entry_id))
async with asyncio.timeout(2):
await reached_connect.wait()
# Setup must still be waiting for the scanner to be registered.
assert not setup_task.done()
connect_event.set()
async with asyncio.timeout(2):
assert await setup_task is True
assert entry.runtime_data.first_connect_done.is_set()
@pytest.mark.usefixtures("mock_zeroconf")
async def test_bluetooth_proxy_startup_wait_times_out(
hass: HomeAssistant,
mock_client: APIClient,
hass_storage: dict[str, Any],
) -> None:
"""Test setup finishes if a cached bluetooth proxy never connects."""
entry, device_info = _create_cached_bluetooth_proxy_entry(
hass, hass_storage, BluetoothProxyFeature.PASSIVE_SCAN
)
connect_event = asyncio.Event()
async def _never_returns() -> tuple[DeviceInfo, list[Any], list[Any]]:
await connect_event.wait()
return (device_info, [], [])
mock_client.device_info_and_list_entities = _never_returns
with patch("homeassistant.components.esphome.manager.STARTUP_SCANNER_WAIT", 0.05):
async with asyncio.timeout(2):
assert await hass.config_entries.async_setup(entry.entry_id) is True
connect_event.set()
await hass.async_block_till_done()
@pytest.mark.usefixtures("mock_zeroconf")
async def test_non_bluetooth_device_does_not_wait_at_startup(
hass: HomeAssistant,
mock_client: APIClient,
hass_storage: dict[str, Any],
) -> None:
"""Test setup does not wait for a device that is not a bluetooth proxy."""
entry, device_info = _create_cached_bluetooth_proxy_entry(
hass, hass_storage, BluetoothProxyFeature(0)
)
connect_event = asyncio.Event()
async def _never_returns() -> tuple[DeviceInfo, list[Any], list[Any]]:
await connect_event.wait()
return (device_info, [], [])
mock_client.device_info_and_list_entities = _never_returns
# The connection is blocked, but without proxy flags setup must not wait.
async with asyncio.timeout(2):
assert await hass.config_entries.async_setup(entry.entry_id) is True
connect_event.set()
await hass.async_block_till_done()
+33 -8
View File
@@ -11,11 +11,10 @@ from gardena_bluetooth.client import Client
from gardena_bluetooth.const import DeviceInformation
from gardena_bluetooth.exceptions import CharacteristicNotFound
from gardena_bluetooth.parse import Characteristic, Service
from gardena_bluetooth.scan import (
async_get_manufacturer_data as _async_get_manufacturer_data,
)
import pytest
from homeassistant.components import bluetooth
from homeassistant.components.gardena_bluetooth import async_get_product_type
from homeassistant.components.gardena_bluetooth.const import DOMAIN
from homeassistant.components.gardena_bluetooth.coordinator import SCAN_INTERVAL
from homeassistant.core import HomeAssistant
@@ -178,23 +177,49 @@ def enable_all_entities(entity_registry_enabled_by_default: None) -> None:
@pytest.fixture
def manufacturer_request_event() -> Generator[asyncio.Event]:
"""Track manufacturer data requests with an event."""
def get_product_type_event() -> Generator[asyncio.Event]:
"""Track product type data requests with an event."""
event = asyncio.Event()
async def _get(*args, **kwargs):
event.set()
return await _async_get_manufacturer_data(*args, **kwargs)
return await async_get_product_type(*args, **kwargs)
with (
patch(
"homeassistant.components.gardena_bluetooth.async_get_manufacturer_data",
"homeassistant.components.gardena_bluetooth.async_get_product_type",
wraps=_get,
),
patch(
"homeassistant.components.gardena_bluetooth.config_flow.async_get_manufacturer_data",
"homeassistant.components.gardena_bluetooth.config_flow.async_get_product_type",
wraps=_get,
),
):
yield event
@pytest.fixture
def constant_advertisements() -> Generator[None]:
"""Ensure async_process_advertisements only return a constant list."""
async def _advertisements(
hass: HomeAssistant,
callback: bluetooth.models.ProcessAdvertisementCallback,
match_dict: bluetooth.match.BluetoothCallbackMatcher,
mode: bluetooth.BluetoothScanningMode,
timeout: int,
) -> bluetooth.BluetoothServiceInfoBleak:
last = None
for advertisement in bluetooth.async_discovered_service_info(hass):
callback(advertisement)
last = advertisement
if not last:
raise TimeoutError
return last
with (
patch.object(bluetooth, "async_process_advertisements", new=_advertisements),
):
yield
@@ -1,7 +1,5 @@
"""Test the Gardena Bluetooth config flow."""
import asyncio
from collections.abc import Awaitable, Callable
from unittest.mock import Mock
from gardena_bluetooth.exceptions import CharacteristicNotFound
@@ -26,13 +24,10 @@ from . import (
from tests.common import MockConfigEntry
from tests.components.bluetooth import inject_bluetooth_service_info
pytestmark = pytest.mark.usefixtures("mock_setup_entry")
pytestmark = pytest.mark.usefixtures("mock_setup_entry", "constant_advertisements")
async def test_user_selection(
hass: HomeAssistant,
snapshot: SnapshotAssertion,
) -> None:
async def test_user_selection(hass: HomeAssistant, snapshot: SnapshotAssertion) -> None:
"""Test we can select a device."""
inject_bluetooth_service_info(hass, WATER_TIMER_SERVICE_INFO)
@@ -138,9 +133,6 @@ async def test_no_valid_devices(
async def test_timeout_manufacturer_data(
hass: HomeAssistant,
snapshot: SnapshotAssertion,
scan_step: Callable[[], Awaitable[None]],
manufacturer_request_event: asyncio.Event,
) -> None:
"""Test the flow aborts with no_devices_found.
@@ -149,24 +141,9 @@ async def test_timeout_manufacturer_data(
"""
inject_bluetooth_service_info(hass, MISSING_PRODUCT_SERVICE_INFO)
# The injected advertisement starts a bluetooth discovery flow which also
# calls async_get_manufacturer_data. Drain it first so it doesn't race
# with the user flow's own request.
await manufacturer_request_event.wait()
await scan_step()
await hass.async_block_till_done(wait_background_tasks=True)
manufacturer_request_event.clear()
async with asyncio.TaskGroup() as tg:
task = tg.create_task(
hass.config_entries.flow.async_init(
DOMAIN, context={"source": config_entries.SOURCE_USER}
)
)
await manufacturer_request_event.wait()
await scan_step()
result = await task
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": config_entries.SOURCE_USER}
)
assert result.get("type") == "abort"
assert result.get("reason") == "no_devices_found"
@@ -72,6 +72,7 @@ from tests.components.bluetooth import inject_bluetooth_service_info
),
],
)
@pytest.mark.usefixtures("constant_advertisements")
async def test_setup(
hass: HomeAssistant,
device_registry: dr.DeviceRegistry,
@@ -99,7 +100,7 @@ async def test_setup_delayed_product(
device_registry: dr.DeviceRegistry,
mock_entry: MockConfigEntry,
mock_read_char_raw: dict[str, bytes],
manufacturer_request_event: asyncio.Event,
get_product_type_event: asyncio.Event,
snapshot: SnapshotAssertion,
) -> None:
"""Test setup creates expected devices."""
@@ -107,15 +108,16 @@ async def test_setup_delayed_product(
mock_read_char_raw[Battery.battery_level.uuid] = Battery.battery_level.encode(100)
mock_entry.add_to_hass(hass)
await hass.async_block_till_done()
manufacturer_request_event.clear()
get_product_type_event.clear()
async with asyncio.TaskGroup() as tg:
setup_task = tg.create_task(
hass.config_entries.async_setup(mock_entry.entry_id)
)
await manufacturer_request_event.wait()
await get_product_type_event.wait()
assert mock_entry.state is ConfigEntryState.SETUP_IN_PROGRESS
inject_bluetooth_service_info(hass, MISSING_MANUFACTURER_DATA_SERVICE_INFO)
inject_bluetooth_service_info(hass, WATER_TIMER_SERVICE_INFO)
@@ -123,6 +125,7 @@ async def test_setup_delayed_product(
assert await setup_task is True
@pytest.mark.usefixtures("constant_advertisements")
async def test_setup_retry(
hass: HomeAssistant, mock_entry: MockConfigEntry, mock_client: Mock
) -> None:
@@ -26,6 +26,8 @@ from . import AQUA_CONTOUR_SERVICE_INFO, WATER_TIMER_SERVICE_INFO, setup_entry
from tests.common import MockConfigEntry
pytestmark = pytest.mark.usefixtures("constant_advertisements")
@pytest.mark.parametrize(
("service_info", "uuid", "raw", "entity_id"),
@@ -25,6 +25,8 @@ from . import AQUA_CONTOUR_SERVICE_INFO, setup_entry
from tests.common import MockConfigEntry, snapshot_platform
pytestmark = pytest.mark.usefixtures("constant_advertisements")
@pytest.fixture
def mock_chars(mock_read_char_raw):
@@ -27,6 +27,8 @@ from . import AQUA_CONTOUR_SERVICE_INFO, WATER_TIMER_SERVICE_INFO, setup_entry
from tests.common import MockConfigEntry, snapshot_platform
pytestmark = pytest.mark.usefixtures("constant_advertisements")
@pytest.mark.parametrize(
("service_info", "uuid", "raw", "entity_id"),
@@ -20,6 +20,8 @@ from . import setup_entry
from tests.common import MockConfigEntry
pytestmark = pytest.mark.usefixtures("constant_advertisements")
@pytest.fixture
def mock_switch_chars(mock_read_char_raw):
@@ -20,6 +20,8 @@ from . import AQUA_CONTOUR_SERVICE_INFO, setup_entry
from tests.common import snapshot_platform
pytestmark = pytest.mark.usefixtures("constant_advertisements")
@pytest.mark.parametrize(
("service_info", "raw"),
@@ -20,6 +20,8 @@ from . import setup_entry
from tests.common import MockConfigEntry
pytestmark = pytest.mark.usefixtures("constant_advertisements")
@pytest.fixture
def mock_switch_chars(mock_read_char_raw):
@@ -234,6 +234,75 @@ async def test_config_entry_migration_v2_prefers_active_entry(
assert active_entry.unique_id == serial_number
async def test_config_entry_migration_v2_removes_duplicates_of_migrated_entry(
hass: HomeAssistant,
) -> None:
"""Test v1.2 migration removes duplicates of an already migrated entry.
A migrated entry (minor version 2) never runs the migration again, so the
remaining minor version 1 duplicates have to remove themselves instead of
relying on the canonical entry's migration to remove them.
"""
serial_number = "E072A1D90104"
data = {
"device": "/dev/serial/by-id/usb-Nabu_Casa_ZBT-2_E072A1D90104-if00",
"firmware": "spinel",
"firmware_version": (
"SL-OPENTHREAD/2.7.2.0_GitHub-fb0446f53; EFR32; Feb 24 2026 00:58:55"
),
"manufacturer": "Nabu Casa",
"pid": "831A",
"product": "ZBT-2",
"serial_number": serial_number,
"vid": "303A",
}
older_entry = MockConfigEntry(
domain=DOMAIN,
unique_id="303A:831A_E072A1D90104_Nabu Casa_ZBT-2 - Nabu Casa ZBT-2",
source="usb",
data=dict(data),
version=1,
minor_version=1,
)
older_entry.add_to_hass(hass)
duplicate_entry = MockConfigEntry(
domain=DOMAIN,
unique_id="303A:831A_E072A1D90104_Nabu Casa_ZBT-2",
source="import",
data=dict(data),
version=1,
minor_version=1,
)
duplicate_entry.add_to_hass(hass)
migrated_entry = MockConfigEntry(
domain=DOMAIN,
unique_id=serial_number,
source="import",
data=dict(data),
version=1,
minor_version=2,
)
migrated_entry.add_to_hass(hass)
with patch(
"homeassistant.components.homeassistant_connect_zbt2.os.path.exists",
return_value=True,
):
await async_setup_component(hass, DOMAIN, {})
await hass.async_block_till_done()
remaining_entries = hass.config_entries.async_entries(DOMAIN)
assert len(remaining_entries) == 1
assert remaining_entries[0].entry_id == migrated_entry.entry_id
assert remaining_entries[0].minor_version == 2
assert remaining_entries[0].unique_id == serial_number
assert hass.config_entries.async_get_entry(older_entry.entry_id) is None
assert hass.config_entries.async_get_entry(duplicate_entry.entry_id) is None
async def test_setup_fails_on_missing_usb_port(hass: HomeAssistant) -> None:
"""Test setup failing when the USB port is missing."""
@@ -306,6 +306,79 @@ async def test_config_entry_migration_v5_prefers_active_entry(
assert active_entry.unique_id == serial_number
async def test_config_entry_migration_v5_removes_duplicates_of_migrated_entry(
hass: HomeAssistant,
) -> None:
"""Test v1.5 migration removes duplicates of an already migrated entry.
A migrated entry (minor version 5) never runs the migration again, so the
remaining minor version 4 duplicates have to remove themselves instead of
relying on the canonical entry's migration to remove them.
"""
serial_number = "9e2adbd75b8beb119fe564a0f320645d"
data = {
"description": "SkyConnect v1.0",
"device": (
"/dev/serial/by-id/"
"usb-Nabu_Casa_SkyConnect_v1.0_9e2adbd75b8beb119fe564a0f320645d-if00-port0"
),
"vid": "10C4",
"pid": "EA60",
"serial_number": serial_number,
"manufacturer": "Nabu Casa",
"product": "SkyConnect v1.0",
"firmware": "ezsp",
"firmware_version": "7.4.4.0",
}
older_entry = MockConfigEntry(
domain=DOMAIN,
unique_id=(
"10C4:EA60_9e2adbd75b8beb119fe564a0f320645d_Nabu Casa_SkyConnect v1.0"
),
source="usb",
data=dict(data),
version=1,
minor_version=4,
)
older_entry.add_to_hass(hass)
duplicate_entry = MockConfigEntry(
domain=DOMAIN,
unique_id="10C4:EA60_9e2adbd75b8beb119fe564a0f320645d_Nabu_Casa_SkyConnect",
source="import",
data=dict(data),
version=1,
minor_version=4,
)
duplicate_entry.add_to_hass(hass)
migrated_entry = MockConfigEntry(
domain=DOMAIN,
unique_id=serial_number,
source="import",
data=dict(data),
version=1,
minor_version=5,
)
migrated_entry.add_to_hass(hass)
with patch(
"homeassistant.components.homeassistant_sky_connect.os.path.exists",
return_value=True,
):
await async_setup_component(hass, DOMAIN, {})
await hass.async_block_till_done()
remaining = hass.config_entries.async_entries(DOMAIN)
assert len(remaining) == 1
assert remaining[0].entry_id == migrated_entry.entry_id
assert remaining[0].minor_version == 5
assert remaining[0].unique_id == serial_number
assert hass.config_entries.async_get_entry(older_entry.entry_id) is None
assert hass.config_entries.async_get_entry(duplicate_entry.entry_id) is None
async def test_setup_fails_on_missing_usb_port(hass: HomeAssistant) -> None:
"""Test setup failing when the USB port is missing."""
+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"

Some files were not shown because too many files have changed in this diff Show More