mirror of
https://github.com/home-assistant/core.git
synced 2026-06-11 11:41:42 +02:00
Compare commits
55 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 30d8bf4231 | |||
| 5436d8af9b | |||
| 88adf39ef3 | |||
| 14b14bddf1 | |||
| 3c4a30be6b | |||
| 2988eb4b19 | |||
| 00eef14558 | |||
| d02516dd09 | |||
| aabb6b3d04 | |||
| 50c2c7c4bc | |||
| e81dd426bb | |||
| c4c569c181 | |||
| 6182426132 | |||
| a073cc4f7d | |||
| 07ddc08d84 | |||
| 17673dcf55 | |||
| a864bc1c80 | |||
| a15d80daa2 | |||
| e123b29258 | |||
| 5669a7b602 | |||
| fe358a4a1f | |||
| 3a93d6370b | |||
| 89576f01e6 | |||
| f51895b0c9 | |||
| d0dcbfadaa | |||
| 5e0d3627c2 | |||
| 80c90732a3 | |||
| 16eca3909a | |||
| 1b471da31f | |||
| 7391209f48 | |||
| 0683344079 | |||
| 0b77cf9e4b | |||
| e0a87d966d | |||
| af53d2d082 | |||
| da7fa80e75 | |||
| 6cf1e7fb48 | |||
| 18fa0ac47d | |||
| 4afced1a49 | |||
| 74a4471160 | |||
| 857a3de066 | |||
| 06bf2ff6de | |||
| 6a5dae9cc3 | |||
| 475ebbc028 | |||
| 6e7643e997 | |||
| 1f954cda0d | |||
| 2961fca1b1 | |||
| 106b189206 | |||
| 0387034f4e | |||
| f81b6abca9 | |||
| 43f6e7977e | |||
| 706fea4ec5 | |||
| 74d23503e7 | |||
| 4ca5da2365 | |||
| 53c77ae2ef | |||
| 14968f9d67 |
@@ -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
|
||||
|
||||
@@ -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
@@ -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):
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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."]
|
||||
}
|
||||
|
||||
@@ -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."]
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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"]
|
||||
}
|
||||
|
||||
@@ -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"]
|
||||
}
|
||||
|
||||
@@ -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)},
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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"]
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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"],
|
||||
}
|
||||
|
||||
@@ -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"]
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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"]
|
||||
}
|
||||
|
||||
@@ -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
|
||||
),
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
Generated
+4
@@ -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,
|
||||
|
||||
@@ -4351,7 +4351,7 @@
|
||||
"mitsubishi_comfort": {
|
||||
"integration_type": "hub",
|
||||
"config_flow": true,
|
||||
"iot_class": "cloud_polling",
|
||||
"iot_class": "local_polling",
|
||||
"name": "Mitsubishi Comfort"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 ()
|
||||
|
||||
@@ -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
@@ -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
@@ -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."
|
||||
|
||||
Generated
+18
-18
@@ -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
|
||||
|
||||
@@ -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"),
|
||||
[
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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>,
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user