Compare commits

...

53 Commits

Author SHA1 Message Date
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
Franck Nijhof 0683344079 Bump version to 2026.6.1 2026-06-05 18:02:28 +00:00
Joakim Plate 0b77cf9e4b Fix process advertisement for active scans (#173116) 2026-06-05 18:02:10 +00:00
Noah Husby e0a87d966d Bump aiostreammagic to 2.13.2 (#173114) 2026-06-05 18:02:08 +00:00
Paul Bottein af53d2d082 Bump yoto-api to 3.1.6 (#173104) 2026-06-05 18:02:06 +00:00
Joost Lekkerkerker da7fa80e75 Bump pySmartThings to 4.0.1 (#173092) 2026-06-05 18:02:04 +00:00
Robert Resch 6cf1e7fb48 Bump wheels to 2026.06.0 (#173089) 2026-06-05 18:02:02 +00:00
Jan Bouwhuis 18fa0ac47d Create certificate files before trying to migrate the MQTT config entry (#173087)
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
2026-06-05 18:01:59 +00:00
Robert Resch 4afced1a49 Unify query token auth in http views (#173082) 2026-06-05 18:01:57 +00:00
Ronald van der Meer 74a4471160 Fix Duco mode end time sensor name (#173045) 2026-06-05 18:01:55 +00:00
Franck Nijhof 857a3de066 Convert LinkPlay configuration_url to string for device registry (#173034) 2026-06-05 18:01:53 +00:00
Erwin Douna 06bf2ff6de Portainer extend timeout for disk space coordinator (#173032) 2026-06-05 18:01:51 +00:00
G Johansson 6a5dae9cc3 Bump holidays to 0.98 (#173029) 2026-06-05 18:01:49 +00:00
Erik Montnemery 475ebbc028 Fix person in_zones propagation from scanner in home zone (#173007) 2026-06-05 18:01:47 +00:00
Maciej Bieniek 6e7643e997 Bump imgw_pib to 2.2.2 (#172999) 2026-06-05 18:01:45 +00:00
Erik Montnemery 1f954cda0d Improve person tests (#172997) 2026-06-05 18:01:43 +00:00
Jan Bouwhuis 2961fca1b1 Fix value template in MQTT Fan and Siren subentry setup (#172980) 2026-06-05 18:01:41 +00:00
Abílio Costa 106b189206 Bump idasen-ha to 2.7.0 (#172962) 2026-06-05 18:01:39 +00:00
Nikolai Rahimi 0387034f4e Fix Mitsubishi Comfort devices skipped due to unresolved local address (#172959) 2026-06-05 18:01:37 +00:00
starkillerOG f81b6abca9 Add more Reolink diagnostic info (#172945) 2026-06-05 18:01:35 +00:00
Thomas55555 43f6e7977e Bump aioautomower to 2.7.6 (#172937) 2026-06-05 18:01:33 +00:00
Samuel Xiao 706fea4ec5 Switchbot Cloud: Fixed an issue where condition filtering for enabled Webhooks was abnormal (#172903) 2026-06-05 18:01:32 +00:00
Kurt Chrisford 74d23503e7 Bump actron-neo-api to 0.5.12 (#172902) 2026-06-05 18:01:30 +00:00
rjones-gentex 4ca5da2365 Upgrade HomeLink package, set integration type (#172371) 2026-06-05 17:50:58 +00:00
Eric Stern 53c77ae2ef Fix SleepIQ 401 storm by isolating client session cookies (#172276) 2026-06-05 17:50:56 +00:00
bk86a 14968f9d67 Fix Lyric sensor crash when next_period_time is None (#167831)
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-06-05 17:50:54 +00:00
162 changed files with 3103 additions and 1448 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
+2 -2
View File
@@ -137,7 +137,7 @@ jobs:
sed -i "/uv/d" requirements_diff.txt
- name: Build wheels
uses: home-assistant/wheels@e5742a69d69f0e274e2689c998900c7d19652c21 # 2025.12.0
uses: home-assistant/wheels@34957438948e0b3dcde73c77750643dadae594f5 # 2026.06.0
with:
abi: ${{ matrix.abi }}
tag: musllinux_1_2
@@ -195,7 +195,7 @@ jobs:
sed -i "/uv/d" requirements_diff.txt
- name: Build wheels
uses: home-assistant/wheels@e5742a69d69f0e274e2689c998900c7d19652c21 # 2025.12.0
uses: home-assistant/wheels@34957438948e0b3dcde73c77750643dadae594f5 # 2026.06.0
with:
abi: ${{ matrix.abi }}
tag: musllinux_1_2
Generated
+2 -2
View File
@@ -623,8 +623,8 @@ CLAUDE.md @home-assistant/core
/tests/components/generic_hygrostat/ @Shulyaka
/homeassistant/components/geniushub/ @manzanotti
/tests/components/geniushub/ @manzanotti
/homeassistant/components/gentex_homelink/ @niaexa @ryanjones-gentex
/tests/components/gentex_homelink/ @niaexa @ryanjones-gentex
/homeassistant/components/gentex_homelink/ @Gentex-Corporation/Homelink @rjones-gentex
/tests/components/gentex_homelink/ @Gentex-Corporation/Homelink @rjones-gentex
/homeassistant/components/geo_json_events/ @exxamalte
/tests/components/geo_json_events/ @exxamalte
/homeassistant/components/geo_location/ @home-assistant/core
@@ -13,5 +13,5 @@
"integration_type": "hub",
"iot_class": "cloud_polling",
"quality_scale": "silver",
"requirements": ["actron-neo-api==0.5.6"]
"requirements": ["actron-neo-api==0.5.12"]
}
@@ -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]
@@ -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):
+12 -6
View File
@@ -6,6 +6,7 @@ These APIs are the only documented way to interact with the bluetooth integratio
import asyncio
from asyncio import Future
from collections.abc import Callable, Iterable
from contextlib import ExitStack
from typing import TYPE_CHECKING, cast
from bleak import BleakScanner
@@ -178,15 +179,20 @@ async def async_process_advertisements(
if not done.done() and callback(service_info):
done.set_result(service_info)
unload = _get_manager(hass).async_register_callback(
_async_discovered_device, match_dict, mode, scan_duration=timeout
)
manager = _get_manager(hass)
with ExitStack() as stack:
unload = manager.async_register_callback(
_async_discovered_device, match_dict, mode
)
stack.callback(unload)
if mode == BluetoothScanningMode.ACTIVE:
task = hass.async_create_task(manager.async_request_active_scan(timeout))
stack.callback(task.cancel)
try:
async with asyncio.timeout(timeout):
return await done
finally:
unload()
@hass_callback
+10 -18
View File
@@ -1,18 +1,19 @@
"""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
from typing import Any, Final, override
from aiohttp import ClientError, hdrs, web
from aiohttp import ClientError, web
import voluptuous as vol
from homeassistant.components import websocket_api
from homeassistant.components.http import KEY_AUTHENTICATED, HomeAssistantView
from homeassistant.components.http import 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
@@ -108,23 +109,18 @@ def _read_brand_file(brand_dir: Path, image: str) -> bytes | None:
class _BrandsBaseView(HomeAssistantView):
"""Base view for serving brand images."""
requires_auth = False
use_query_token_for_auth = True
def __init__(self, hass: HomeAssistant) -> None:
"""Initialize the view."""
self._hass = hass
self._cache_dir = Path(hass.config.cache_path(DOMAIN))
def _authenticate(self, request: web.Request) -> None:
"""Authenticate the request using Bearer token or query token."""
access_tokens: deque[str] = self._hass.data[DOMAIN]
authenticated = (
request[KEY_AUTHENTICATED] or request.query.get("token") in access_tokens
)
if not authenticated:
if hdrs.AUTHORIZATION in request.headers:
raise web.HTTPUnauthorized
raise web.HTTPForbidden
@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]
async def _serve_from_custom_integration(
self,
@@ -240,8 +236,6 @@ 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)
@@ -274,8 +268,6 @@ 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"
@@ -8,6 +8,6 @@
"iot_class": "local_push",
"loggers": ["aiostreammagic"],
"quality_scale": "platinum",
"requirements": ["aiostreammagic==2.13.1"],
"requirements": ["aiostreammagic==2.13.2"],
"zeroconf": ["_stream-magic._tcp.local.", "_smoip._tcp.local."]
}
+14 -18
View File
@@ -2,7 +2,7 @@
import asyncio
import collections
from collections.abc import Awaitable, Callable, Coroutine
from collections.abc import Awaitable, Callable, Container, Coroutine, Mapping
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
from typing import Any, Final, final, override
from aiohttp import hdrs, web
from aiohttp import 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 KEY_AUTHENTICATED, HomeAssistantView
from homeassistant.components.http import HomeAssistantView
from homeassistant.components.media_player import (
ATTR_MEDIA_CONTENT_ID,
ATTR_MEDIA_CONTENT_TYPE,
@@ -776,30 +776,26 @@ class Camera(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_):
class CameraView(HomeAssistantView):
"""Base CameraView."""
requires_auth = False
use_query_token_for_auth = True
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
@@ -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."]
}
+1 -1
View File
@@ -59,7 +59,7 @@
"name": "Target flow level"
},
"time_state_end": {
"name": "Mode end time"
"name": "State end time"
},
"ventilation_state": {
"name": "Ventilation state",
@@ -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.5"]
}
@@ -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")
@@ -1,11 +1,12 @@
{
"domain": "gentex_homelink",
"name": "HomeLink",
"codeowners": ["@niaexa", "@ryanjones-gentex"],
"codeowners": ["@Gentex-Corporation/Homelink", "@rjones-gentex"],
"config_flow": true,
"dependencies": ["application_credentials"],
"documentation": "https://www.home-assistant.io/integrations/gentex_homelink",
"integration_type": "hub",
"iot_class": "cloud_push",
"quality_scale": "bronze",
"requirements": ["homelink-integration-api==0.0.1"]
"requirements": ["homelink-integration-api==0.0.5"]
}
@@ -5,5 +5,5 @@
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/holiday",
"iot_class": "local_polling",
"requirements": ["holidays==0.97", "babel==2.15.0"]
"requirements": ["holidays==0.98", "babel==2.15.0"]
}
@@ -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:
@@ -9,5 +9,5 @@
"iot_class": "cloud_push",
"loggers": ["aioautomower"],
"quality_scale": "silver",
"requirements": ["aioautomower==2.7.5"]
"requirements": ["aioautomower==2.7.6"]
}
@@ -13,5 +13,5 @@
"integration_type": "device",
"iot_class": "local_push",
"quality_scale": "bronze",
"requirements": ["idasen-ha==2.6.5"]
"requirements": ["idasen-ha==2.7.0"]
}
+19 -23
View File
@@ -2,20 +2,21 @@
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
from typing import Final, final, override
from aiohttp import hdrs, web
from aiohttp import web
import httpx
from propcache.api import cached_property
import voluptuous as vol
from homeassistant.components.http import KEY_AUTHENTICATED, KEY_HASS, HomeAssistantView
from homeassistant.components.http import KEY_HASS, HomeAssistantView
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONTENT_TYPE_MULTIPART, EVENT_HOMEASSISTANT_STOP
from homeassistant.core import (
@@ -314,33 +315,28 @@ class ImageView(HomeAssistantView):
"""View to serve an image."""
name = "api:image:image"
requires_auth = False
use_query_token_for_auth = True
url = "/api/image_proxy/{entity_id}"
def __init__(self, component: EntityComponent[ImageEntity]) -> None:
"""Initialize an image view."""
self.component = component
async def _authenticate_request(
self, request: web.Request, entity_id: str
) -> ImageEntity:
"""Authenticate request and return image entity."""
@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."""
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:
@@ -349,7 +345,7 @@ class ImageView(HomeAssistantView):
This is sent by some DLNA renderers, like Samsung ones, prior to sending
the GET request.
"""
image_entity = await self._authenticate_request(request, entity_id)
image_entity = self._get_image_entity(entity_id)
# Don't use `handle` as we don't care about the stream case, we only want
# to verify that the image exists.
@@ -365,7 +361,7 @@ class ImageView(HomeAssistantView):
async def get(self, request: web.Request, entity_id: str) -> web.StreamResponse:
"""Start a GET request."""
image_entity = await self._authenticate_request(request, entity_id)
image_entity = self._get_image_entity(entity_id)
return await self.handle(request, image_entity)
async def handle(
@@ -7,5 +7,5 @@
"integration_type": "service",
"iot_class": "cloud_polling",
"quality_scale": "platinum",
"requirements": ["imgw_pib==2.2.0"]
"requirements": ["imgw_pib==2.2.2"]
}
+1 -1
View File
@@ -51,7 +51,7 @@ class LinkPlayBaseEntity(Entity):
)
self._attr_device_info = dr.DeviceInfo(
configuration_url=bridge.endpoint,
configuration_url=str(bridge.endpoint),
connections=connections,
hw_version=bridge.device.properties["hardware"],
identifiers={(DOMAIN, bridge.device.uuid)},
+4 -2
View File
@@ -142,11 +142,13 @@ def get_setpoint_status(status: str, time: str) -> str | None:
return LYRIC_SETPOINT_STATUS_NAMES.get(status)
def get_datetime_from_future_time(time_str: str) -> datetime:
def get_datetime_from_future_time(time_str: str | None) -> datetime | None:
"""Get datetime from future time provided."""
if time_str is None:
return None
time = dt_util.parse_time(time_str)
if time is None:
raise ValueError(f"Unable to parse time {time_str}")
return None
now = dt_util.utcnow()
if time <= now.time():
now = now + timedelta(days=1)
@@ -2,7 +2,7 @@
import asyncio
import collections
from collections.abc import Callable
from collections.abc import Callable, Container, Mapping
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
from typing import Any, Final, Required, TypedDict, final, override
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 KEY_AUTHENTICATED, HomeAssistantView
from homeassistant.components.http import 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
from homeassistant.core import HomeAssistant, SupportsResponse, callback
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."""
requires_auth = False
use_query_token_for_auth = True
url = "/api/media_player_proxy/{entity_id}"
name = "api:media_player:image"
extra_urls = [
@@ -1261,6 +1261,15 @@ 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,
@@ -1270,21 +1279,9 @@ class MediaPlayerImageView(HomeAssistantView):
) -> web.Response:
"""Start a get request."""
if (player := self.component.get_entity(entity_id)) is None:
status = (
HTTPStatus.NOT_FOUND
if request[KEY_AUTHENTICATED]
else HTTPStatus.UNAUTHORIZED
)
return web.Response(status=status)
return web.Response(status=HTTPStatus.NOT_FOUND)
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")
@@ -14,9 +14,16 @@ from mitsubishi_comfort.exceptions import AuthenticationError, DeviceConnectionE
from homeassistant.const import CONF_PASSWORD, CONF_USERNAME
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryError, ConfigEntryNotReady
from homeassistant.helpers import device_registry as dr
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from .const import DEFAULT_CONNECT_TIMEOUT, DEFAULT_RESPONSE_TIMEOUT, DOMAIN, PLATFORMS
from .const import (
CONF_ADDRESSES,
DEFAULT_CONNECT_TIMEOUT,
DEFAULT_RESPONSE_TIMEOUT,
DOMAIN,
PLATFORMS,
)
from .coordinator import MitsubishiComfortConfigEntry, MitsubishiComfortCoordinator
_LOGGER = logging.getLogger(__name__)
@@ -25,13 +32,14 @@ _LOGGER = logging.getLogger(__name__)
def _make_device(
info: DeviceInfo,
serial: str,
address: str,
session,
) -> IndoorUnit | KumoStation:
"""Create the appropriate device instance from DeviceInfo."""
cls = IndoorUnit if info.is_indoor_unit else KumoStation
return cls(
name=info.label,
address=info.address,
address=address,
password_b64=info.password,
crypto_serial_hex=info.crypto_serial,
serial=serial,
@@ -64,12 +72,39 @@ async def async_setup_entry(
translation_key="no_devices",
)
# The cloud provides each device's MAC but never its LAN IP. Register every
# device with its MAC so the manifest's "registered_devices" DHCP matcher
# tracks it; DHCP discovery then supplies the IP via async_step_dhcp.
device_registry = dr.async_get(hass)
owned_macs = {dr.format_mac(info.mac) for info in devices.values()}
for serial, info in devices.items():
device_registry.async_get_or_create(
config_entry_id=entry.entry_id,
identifiers={(DOMAIN, serial)},
connections={(dr.CONNECTION_NETWORK_MAC, dr.format_mac(info.mac))},
manufacturer="Mitsubishi",
name=info.label,
serial_number=serial,
)
# Resolved IPs are stored keyed by MAC. Drop any for devices that are no
# longer on the account.
stored: dict[str, str] = entry.data.get(CONF_ADDRESSES, {})
addresses = {mac: ip for mac, ip in stored.items() if mac in owned_macs}
if addresses != stored:
hass.config_entries.async_update_entry(
entry, data={**entry.data, CONF_ADDRESSES: addresses}
)
coordinators: dict[str, MitsubishiComfortCoordinator] = {}
for serial, info in devices.items():
if not info.address or not info.password or not info.crypto_serial:
_LOGGER.warning("Device %s missing credentials, skipping", info.label)
address = addresses.get(dr.format_mac(info.mac))
if not address or not info.password or not info.crypto_serial:
# No LAN address yet: the device is registered, so DHCP discovery
# supplies its IP and reloads the entry to add it.
_LOGGER.debug("Device %s has no known LAN address yet", info.label)
continue
device = _make_device(info, serial, session)
device = _make_device(info, serial, address, session)
coordinators[serial] = MitsubishiComfortCoordinator(
hass, entry, device, info.mac
)
@@ -9,9 +9,11 @@ import voluptuous as vol
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
from homeassistant.const import CONF_PASSWORD, CONF_USERNAME
from homeassistant.helpers import device_registry as dr
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo
from .const import DOMAIN
from .const import CONF_ADDRESSES, DOMAIN
_LOGGER = logging.getLogger(__name__)
@@ -71,3 +73,41 @@ class MitsubishiComfortConfigFlow(ConfigFlow, domain=DOMAIN):
return self.async_show_form(
step_id="user", data_schema=USER_SCHEMA, errors=errors
)
async def async_step_dhcp(
self, discovery_info: DhcpServiceInfo
) -> ConfigFlowResult:
"""Handle a registered device discovered on the local network via DHCP.
The cloud API never returns a device's LAN IP, so DHCP discovery is the
source of addresses. Each device is registered with its MAC during setup,
so "registered_devices" discovery only fires for our own devices: record
the IP on the owning entry and reload to set the device up or recover a
changed IP.
"""
mac = dr.format_mac(discovery_info.macaddress)
device = dr.async_get(self.hass).async_get_device(
connections={(dr.CONNECTION_NETWORK_MAC, mac)}
)
entry = next(
(
entry
for entry in self._async_current_entries(include_ignore=False)
if device is not None and entry.entry_id in device.config_entries
),
None,
)
if entry is None:
return self.async_abort(reason="already_configured")
addresses = entry.data.get(CONF_ADDRESSES, {})
if addresses.get(mac) != discovery_info.ip:
self.hass.config_entries.async_update_entry(
entry,
data={
**entry.data,
CONF_ADDRESSES: {**addresses, mac: discovery_info.ip},
},
)
self.hass.config_entries.async_schedule_reload(entry.entry_id)
return self.async_abort(reason="already_configured")
@@ -7,6 +7,13 @@ from homeassistant.const import Platform
DOMAIN: Final = "mitsubishi_comfort"
PLATFORMS: Final = [Platform.CLIMATE]
# Config entry data key holding the per-device LAN address cache, keyed by the
# device's formatted MAC. The cloud API only returns each device's MAC, never
# its LAN IP, so addresses are resolved from DHCP discovery and persisted here
# to survive restarts without re-discovery.
CONF_ADDRESSES: Final = "addresses"
DEFAULT_SCAN_INTERVAL = timedelta(seconds=60)
DEFAULT_CONNECT_TIMEOUT: Final = 1.2
DEFAULT_RESPONSE_TIMEOUT: Final = 8.0
@@ -3,9 +3,10 @@
"name": "Mitsubishi Comfort",
"codeowners": ["@nikolairahimi"],
"config_flow": true,
"dhcp": [{ "registered_devices": true }],
"documentation": "https://www.home-assistant.io/integrations/mitsubishi_comfort",
"integration_type": "hub",
"iot_class": "cloud_polling",
"iot_class": "local_polling",
"quality_scale": "bronze",
"requirements": ["mitsubishi-comfort==0.3.0"]
"requirements": ["mitsubishi-comfort==0.3.1"]
}
@@ -56,7 +56,7 @@ rules:
icon-translations: todo
reconfiguration-flow: todo
dynamic-devices: todo
discovery-update-info: todo
discovery-update-info: done
repair-issues: todo
docs-use-cases: done
docs-supported-devices: done
@@ -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]
@@ -504,6 +510,8 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
# Can be removed with HA Core 2027.1
new_entry_data = entry.data.copy()
new_entry_data[CONF_PROTOCOL] = PROTOCOL_5
# Create temporary certificate files from entry
await async_create_certificate_temp_files(hass, new_entry_data)
# Try the connection with protocol version 5
# And update the protocol if successful
if await hass.async_add_executor_job(
+2 -2
View File
@@ -2451,7 +2451,7 @@ PLATFORM_MQTT_FIELDS: dict[Platform, dict[str, PlatformField]] = {
validator=valid_subscribe_topic,
error="invalid_subscribe_topic",
),
CONF_VALUE_TEMPLATE: PlatformField(
CONF_STATE_VALUE_TEMPLATE: PlatformField(
selector=TEMPLATE_SELECTOR,
required=False,
validator=validate(cv.template),
@@ -3395,7 +3395,7 @@ PLATFORM_MQTT_FIELDS: dict[Platform, dict[str, PlatformField]] = {
validator=valid_subscribe_topic,
error="invalid_subscribe_topic",
),
CONF_VALUE_TEMPLATE: PlatformField(
CONF_STATE_VALUE_TEMPLATE: PlatformField(
selector=TEMPLATE_SELECTOR,
required=False,
validator=validate(cv.template),
@@ -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"]
}
+1 -1
View File
@@ -565,7 +565,7 @@ class Person(
self._latitude = coordinates.attributes.get(ATTR_LATITUDE)
self._longitude = coordinates.attributes.get(ATTR_LONGITUDE)
self._gps_accuracy = coordinates.attributes.get(ATTR_GPS_ACCURACY)
self._in_zones = coordinates.attributes.get(ATTR_IN_ZONES, [])
self._in_zones = state.attributes.get(ATTR_IN_ZONES, [])
@callback
def _update_extra_state_attributes(self) -> None:
@@ -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."]
}
@@ -63,7 +63,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: PortainerConfigEntry) ->
api_url=entry.data[CONF_URL],
api_key=entry.data[CONF_API_TOKEN],
session=session,
request_timeout=60,
request_timeout=120,
max_retries=API_MAX_RETRIES,
)
@@ -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"]
}
@@ -41,6 +41,7 @@ async def async_get_config_entry_diagnostics(
"HTTP(S) port": api.port,
"Baichuan port": api.baichuan.port,
"Baichuan only": api.baichuan_only,
"Baichuan connection": api.baichuan.connection_type.value,
"WiFi connection": api.wifi_connection(),
"WiFi signal": api.wifi_signal(),
"RTMP enabled": api.rtmp_enabled,
@@ -48,10 +49,15 @@ async def async_get_config_entry_diagnostics(
"ONVIF enabled": api.onvif_enabled,
"event connection": host.event_connection,
"stream protocol": api.protocol,
"is NVR": api.is_nvr,
"is Hub": api.is_hub,
"is Battery": api.is_battery,
"channels": api.channels,
"stream channels": api.stream_channels,
"IPC cams": ipc_cam,
"Chimes": chimes,
"Broken cmds": api.broken_cmds,
"Baichuan fallbacks": api.baichuan_cmds,
"capabilities": api.capabilities,
"cmd list": host.update_cmd,
"firmware ch list": host.firmware_ch_list,
+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
@@ -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
+2 -2
View File
@@ -16,7 +16,7 @@ from homeassistant.const import CONF_PASSWORD, CONF_USERNAME, PRESSURE, Platform
from homeassistant.core import HomeAssistant, callback
from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady
from homeassistant.helpers import config_validation as cv, entity_registry as er
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers.aiohttp_client import async_create_clientsession
from homeassistant.helpers.typing import ConfigType
from .const import DOMAIN, IS_IN_BED, SLEEP_NUMBER
@@ -69,7 +69,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: SleepIQConfigEntry) -> b
email = conf[CONF_USERNAME]
password = conf[CONF_PASSWORD]
client_session = async_get_clientsession(hass)
client_session = async_create_clientsession(hass)
gateway = AsyncSleepIQ(client_session=client_session)
@@ -47,11 +47,16 @@ class SleepIQDataUpdateCoordinator(DataUpdateCoordinator[None]):
bed.foundation.update_foundation_status()
for bed in self.client.beds.values()
]
await asyncio.gather(*tasks)
try:
await asyncio.gather(*tasks)
except SleepIQTimeoutException as err:
raise UpdateFailed(f"Timed out fetching SleepIQ data: {err}") from err
except SleepIQAPIException as err:
raise UpdateFailed(f"Failed to fetch SleepIQ data: {err}") from err
class SleepIQPauseUpdateCoordinator(DataUpdateCoordinator[None]):
"""SleepIQ data update coordinator."""
"""SleepIQ pause update coordinator."""
config_entry: SleepIQConfigEntry
@@ -72,9 +77,14 @@ class SleepIQPauseUpdateCoordinator(DataUpdateCoordinator[None]):
self.client = client
async def _async_update_data(self) -> None:
await asyncio.gather(
*[bed.fetch_pause_mode() for bed in self.client.beds.values()]
)
try:
await asyncio.gather(
*[bed.fetch_pause_mode() for bed in self.client.beds.values()]
)
except SleepIQTimeoutException as err:
raise UpdateFailed(f"Timed out fetching SleepIQ pause data: {err}") from err
except SleepIQAPIException as err:
raise UpdateFailed(f"Failed to fetch SleepIQ pause data: {err}") from err
class SleepIQSleepDataCoordinator(DataUpdateCoordinator[None]):
@@ -38,5 +38,5 @@
"iot_class": "cloud_push",
"loggers": ["pysmartthings"],
"quality_scale": "bronze",
"requirements": ["pysmartthings==4.0.0"]
"requirements": ["pysmartthings==4.0.1"]
}
@@ -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(
@@ -504,9 +504,15 @@ def _create_handle_webhook(
_LOGGER.debug("Received data from switchbot webhook: %s", repr(data))
device_mac = data["context"]["deviceMac"]
if device_mac not in coordinators_by_id:
_LOGGER.error(
"Received data for unknown entity from switchbot webhook: %s", data
registered_device_macs = [
coordinator.data.get("deviceMac") or coordinator.data.get("deviceId")
for coordinator in coordinators_by_id.values()
if coordinator.manageable_by_webhook() and coordinator.data is not None
]
if device_mac not in registered_device_macs:
_LOGGER.debug(
"Received data for an unregistered webhook entity from SwitchBot Webhook: %s",
data,
)
return
+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,
+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.1"]
}
@@ -8,5 +8,5 @@
"iot_class": "local_polling",
"loggers": ["holidays"],
"quality_scale": "internal",
"requirements": ["holidays==0.97"]
"requirements": ["holidays==0.98"]
}
@@ -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",
@@ -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"]
}
+1 -1
View File
@@ -10,5 +10,5 @@
"iot_class": "cloud_push",
"loggers": ["yoto_api"],
"quality_scale": "bronze",
"requirements": ["yoto-api==3.1.5"]
"requirements": ["yoto-api==3.1.6"]
}
@@ -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 = "0"
PATCH_VERSION: Final = "2"
__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)
+4
View File
@@ -443,6 +443,10 @@ DHCP: Final[list[dict[str, str | bool]]] = [
"hostname": "lyric-*",
"macaddress": "00D02D*",
},
{
"domain": "mitsubishi_comfort",
"registered_devices": True,
},
{
"domain": "motion_blinds",
"registered_devices": True,
+1 -1
View File
@@ -4351,7 +4351,7 @@
"mitsubishi_comfort": {
"integration_type": "hub",
"config_flow": true,
"iot_class": "cloud_polling",
"iot_class": "local_polling",
"name": "Mitsubishi Comfort"
}
}
+15 -3
View File
@@ -1,6 +1,6 @@
"""Helper to track the current http request."""
from collections.abc import Awaitable, Callable
from collections.abc import Awaitable, Callable, Container, Mapping
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, is_callback
from homeassistant.core import Context, HomeAssistant, callback, 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,7 +55,13 @@ def request_handler_factory(
authenticated = request.get(KEY_AUTHENTICATED, False)
if view.requires_auth and not authenticated:
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:
# Import here to avoid circular dependency with network.py
from .network import NoURLAvailableError, get_url # noqa: PLC0415
@@ -129,6 +135,7 @@ 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
@@ -204,3 +211,8 @@ 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 ()
+1 -1
View File
@@ -39,7 +39,7 @@ habluetooth==6.8.1
hass-nabucasa==2.2.0
hassil==3.5.0
home-assistant-bluetooth==2.0.0
home-assistant-frontend==20260527.4
home-assistant-frontend==20260527.5
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.5"
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.0"
version = "2026.6.2"
license = "Apache-2.0"
license-files = ["LICENSE*", "homeassistant/backports/LICENSE*"]
description = "Open-source home automation platform running on Python 3."
+18 -18
View File
@@ -133,7 +133,7 @@ WSDiscovery==2.1.2
accuweather==5.1.0
# homeassistant.components.actron_air
actron-neo-api==0.5.6
actron-neo-api==0.5.12
# homeassistant.components.adax
adax==0.4.0
@@ -212,7 +212,7 @@ aioaseko==1.0.0
aioasuswrt==1.5.4
# homeassistant.components.husqvarna_automower
aioautomower==2.7.5
aioautomower==2.7.6
# homeassistant.components.azure_devops
aioazuredevops==2.2.2
@@ -420,7 +420,7 @@ aiosolaredge==1.0.2
aiosteamist==1.0.1
# homeassistant.components.cambridge_audio
aiostreammagic==2.13.1
aiostreammagic==2.13.2
# homeassistant.components.switcher_kis
aioswitcher==6.1.1
@@ -1263,10 +1263,10 @@ hole==0.9.0
# homeassistant.components.holiday
# homeassistant.components.workday
holidays==0.97
holidays==0.98
# homeassistant.components.frontend
home-assistant-frontend==20260527.4
home-assistant-frontend==20260527.5
# homeassistant.components.conversation
home-assistant-intents==2026.6.1
@@ -1275,7 +1275,7 @@ home-assistant-intents==2026.6.1
homekit-audio-proxy==1.2.1
# homeassistant.components.gentex_homelink
homelink-integration-api==0.0.1
homelink-integration-api==0.0.5
# homeassistant.components.homematicip_cloud
homematicip==2.12.0
@@ -1323,7 +1323,7 @@ icalendar==6.3.1
icmplib==3.0.4
# homeassistant.components.idasen_desk
idasen-ha==2.6.5
idasen-ha==2.7.0
# homeassistant.components.idrive_e2
idrive-e2-client==0.1.1
@@ -1344,7 +1344,7 @@ ihcsdk==2.8.12
imeon_inverter_api==0.4.0
# homeassistant.components.imgw_pib
imgw_pib==2.2.0
imgw_pib==2.2.2
# homeassistant.components.incomfort
incomfort-client==0.7.0
@@ -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
@@ -2540,7 +2540,7 @@ pysmappee==0.2.29
pysmarlaapi==1.0.2
# homeassistant.components.smartthings
pysmartthings==4.0.0
pysmartthings==4.0.1
# homeassistant.components.smarty
pysmarty2==0.10.3
@@ -2773,7 +2773,7 @@ pytradfri[async]==9.0.1
pytrafikverket==1.1.1
# homeassistant.components.v2c
pytrydan==1.0.0
pytrydan==1.0.1
# 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,7 +2872,7 @@ 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
@@ -3415,7 +3415,7 @@ yeelightsunflower==0.0.10
yolink-api==0.6.5
# homeassistant.components.yoto
yoto-api==3.1.5
yoto-api==3.1.6
# homeassistant.components.youless
youless-api==2.2.0
@@ -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
-1
View File
@@ -27,7 +27,6 @@ MISSING_INTEGRATION_TYPE = {
"folder_watcher",
"forked_daapd",
"geniushub",
"gentex_homelink",
"geofency",
"govee_light_local",
"gpsd",
@@ -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
+10 -4
View File
@@ -2666,21 +2666,26 @@ async def test_process_advertisements_timeout(
@pytest.mark.usefixtures("enable_bluetooth", "mock_bleak_scanner_start")
async def test_process_advertisements_wires_timeout_as_scan_duration(
async def test_process_advertisements_triggers_active_scan_of_correct_duration(
hass: HomeAssistant,
) -> None:
"""async_process_advertisements forwards its timeout as scan_duration."""
"""async_process_advertisements triggers active scan now."""
def _callback(service_info: BluetoothServiceInfo) -> bool:
return False
timeout = 0.001
mock_cancel = Mock()
with (
patch.object(
HomeAssistantBluetoothManager,
"async_register_active_scan",
return_value=mock_cancel,
) as mock_register,
patch.object(
HomeAssistantBluetoothManager, "async_request_active_scan"
) as mock_request_active_scan,
pytest.raises(TimeoutError),
):
await async_process_advertisements(
@@ -2688,9 +2693,10 @@ async def test_process_advertisements_wires_timeout_as_scan_duration(
_callback,
{"address": "aa:44:33:11:23:45"},
BluetoothScanningMode.ACTIVE,
0,
timeout,
)
mock_register.assert_called_once_with("aa:44:33:11:23:45", None, 0)
mock_register.assert_called_once_with("aa:44:33:11:23:45", None, None)
mock_request_active_scan.assert_called_once_with(timeout)
mock_cancel.assert_called_once()
+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_forbidden(
async def test_unauthenticated_request_unauthorized(
hass: HomeAssistant,
hass_client_no_auth: ClientSessionGenerator,
aioclient_mock: AiohttpClientMocker,
) -> None:
"""Test that unauthenticated requests are forbidden."""
"""Test that unauthenticated requests are unauthorized."""
client = await hass_client_no_auth()
resp = await client.get("/api/brands/integration/hue/icon.png")
assert resp.status == HTTPStatus.FORBIDDEN
assert resp.status == HTTPStatus.UNAUTHORIZED
resp = await client.get("/api/brands/hardware/boards/green.png")
assert resp.status == HTTPStatus.FORBIDDEN
assert resp.status == HTTPStatus.UNAUTHORIZED
async def test_invalid_token_forbidden(
async def test_invalid_token_unauthorized(
hass: HomeAssistant,
hass_client_no_auth: ClientSessionGenerator,
) -> None:
"""Test that an invalid access token in query param is forbidden."""
"""Test that an invalid access token in query param is unauthorized."""
client = await hass_client_no_auth()
resp = await client.get("/api/brands/integration/hue/icon.png?token=invalid_token")
assert resp.status == HTTPStatus.FORBIDDEN
assert resp.status == HTTPStatus.UNAUTHORIZED
async def test_invalid_bearer_token_unauthorized(
+24
View File
@@ -693,6 +693,30 @@ 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."""
@@ -435,7 +435,7 @@
'state': '90',
})
# ---
# name: test_sensor_entities_state[sensor.living_mode_end_time-entry]
# name: test_sensor_entities_state[sensor.living_state_end_time-entry]
EntityRegistryEntrySnapshot({
'aliases': list([
None,
@@ -449,7 +449,7 @@
'disabled_by': None,
'domain': 'sensor',
'entity_category': None,
'entity_id': 'sensor.living_mode_end_time',
'entity_id': 'sensor.living_state_end_time',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
@@ -457,12 +457,12 @@
'labels': set({
}),
'name': None,
'object_id_base': 'Mode end time',
'object_id_base': 'State end time',
'options': dict({
}),
'original_device_class': <SensorDeviceClass.TIMESTAMP: 'timestamp'>,
'original_icon': None,
'original_name': 'Mode end time',
'original_name': 'State end time',
'platform': 'duco',
'previous_unique_id': None,
'suggested_object_id': None,
@@ -472,14 +472,14 @@
'unit_of_measurement': None,
})
# ---
# name: test_sensor_entities_state[sensor.living_mode_end_time-state]
# name: test_sensor_entities_state[sensor.living_state_end_time-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'device_class': 'timestamp',
'friendly_name': 'Living Mode end time',
'friendly_name': 'Living State end time',
}),
'context': <ANY>,
'entity_id': 'sensor.living_mode_end_time',
'entity_id': 'sensor.living_state_end_time',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
+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."""

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