mirror of
https://github.com/home-assistant/core.git
synced 2025-10-09 03:39:35 +00:00
Compare commits
21 Commits
triggers-y
...
target_web
Author | SHA1 | Date | |
---|---|---|---|
![]() |
5b76533098 | ||
![]() |
3914e41f3c | ||
![]() |
82bdfcb99b | ||
![]() |
976cea600f | ||
![]() |
8c8713c3f7 | ||
![]() |
2359ae6ce7 | ||
![]() |
b570fd35c8 | ||
![]() |
9d94e6b3b4 | ||
![]() |
cfab789823 | ||
![]() |
81917425dc | ||
![]() |
bfb62709d4 | ||
![]() |
ca3f2ee782 | ||
![]() |
fc8703a40f | ||
![]() |
80517c7ac1 | ||
![]() |
2b4b46eaf8 | ||
![]() |
40b9dae608 | ||
![]() |
a109f2dcdf | ||
![]() |
b61b9bb606 | ||
![]() |
86e7ca9790 | ||
![]() |
f8f4c7ddeb | ||
![]() |
8308be185e |
@@ -17,6 +17,7 @@ from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
from homeassistant.helpers import frame
|
||||
from homeassistant.util import slugify
|
||||
from homeassistant.util.async_iterator import AsyncIteratorReader, AsyncIteratorWriter
|
||||
|
||||
from . import util
|
||||
from .agent import BackupAgent
|
||||
@@ -144,7 +145,7 @@ class DownloadBackupView(HomeAssistantView):
|
||||
return Response(status=HTTPStatus.NOT_FOUND)
|
||||
else:
|
||||
stream = await agent.async_download_backup(backup_id)
|
||||
reader = cast(IO[bytes], util.AsyncIteratorReader(hass, stream))
|
||||
reader = cast(IO[bytes], AsyncIteratorReader(hass.loop, stream))
|
||||
|
||||
worker_done_event = asyncio.Event()
|
||||
|
||||
@@ -152,7 +153,7 @@ class DownloadBackupView(HomeAssistantView):
|
||||
"""Call by the worker thread when it's done."""
|
||||
hass.loop.call_soon_threadsafe(worker_done_event.set)
|
||||
|
||||
stream = util.AsyncIteratorWriter(hass)
|
||||
stream = AsyncIteratorWriter(hass.loop)
|
||||
worker = threading.Thread(
|
||||
target=util.decrypt_backup,
|
||||
args=[backup, reader, stream, password, on_done, 0, []],
|
||||
|
@@ -38,6 +38,7 @@ from homeassistant.helpers import (
|
||||
)
|
||||
from homeassistant.helpers.json import json_bytes
|
||||
from homeassistant.util import dt as dt_util, json as json_util
|
||||
from homeassistant.util.async_iterator import AsyncIteratorReader
|
||||
|
||||
from . import util as backup_util
|
||||
from .agent import (
|
||||
@@ -72,7 +73,6 @@ from .models import (
|
||||
)
|
||||
from .store import BackupStore
|
||||
from .util import (
|
||||
AsyncIteratorReader,
|
||||
DecryptedBackupStreamer,
|
||||
EncryptedBackupStreamer,
|
||||
make_backup_dir,
|
||||
@@ -1525,7 +1525,7 @@ class BackupManager:
|
||||
reader = await self.hass.async_add_executor_job(open, path.as_posix(), "rb")
|
||||
else:
|
||||
backup_stream = await agent.async_download_backup(backup_id)
|
||||
reader = cast(IO[bytes], AsyncIteratorReader(self.hass, backup_stream))
|
||||
reader = cast(IO[bytes], AsyncIteratorReader(self.hass.loop, backup_stream))
|
||||
try:
|
||||
await self.hass.async_add_executor_job(
|
||||
validate_password_stream, reader, password
|
||||
|
@@ -4,7 +4,6 @@ from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from collections.abc import AsyncIterator, Callable, Coroutine
|
||||
from concurrent.futures import CancelledError, Future
|
||||
import copy
|
||||
from dataclasses import dataclass, replace
|
||||
from io import BytesIO
|
||||
@@ -14,7 +13,7 @@ from pathlib import Path, PurePath
|
||||
from queue import SimpleQueue
|
||||
import tarfile
|
||||
import threading
|
||||
from typing import IO, Any, Self, cast
|
||||
from typing import IO, Any, cast
|
||||
|
||||
import aiohttp
|
||||
from securetar import SecureTarError, SecureTarFile, SecureTarReadError
|
||||
@@ -23,6 +22,11 @@ from homeassistant.backup_restore import password_to_key
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
from homeassistant.util import dt as dt_util
|
||||
from homeassistant.util.async_iterator import (
|
||||
Abort,
|
||||
AsyncIteratorReader,
|
||||
AsyncIteratorWriter,
|
||||
)
|
||||
from homeassistant.util.json import JsonObjectType, json_loads_object
|
||||
|
||||
from .const import BUF_SIZE, LOGGER
|
||||
@@ -59,12 +63,6 @@ class BackupEmpty(DecryptError):
|
||||
_message = "No tar files found in the backup."
|
||||
|
||||
|
||||
class AbortCipher(HomeAssistantError):
|
||||
"""Abort the cipher operation."""
|
||||
|
||||
_message = "Abort cipher operation."
|
||||
|
||||
|
||||
def make_backup_dir(path: Path) -> None:
|
||||
"""Create a backup directory if it does not exist."""
|
||||
path.mkdir(exist_ok=True)
|
||||
@@ -166,106 +164,6 @@ def validate_password(path: Path, password: str | None) -> bool:
|
||||
return False
|
||||
|
||||
|
||||
class AsyncIteratorReader:
|
||||
"""Wrap an AsyncIterator."""
|
||||
|
||||
def __init__(self, hass: HomeAssistant, stream: AsyncIterator[bytes]) -> None:
|
||||
"""Initialize the wrapper."""
|
||||
self._aborted = False
|
||||
self._hass = hass
|
||||
self._stream = stream
|
||||
self._buffer: bytes | None = None
|
||||
self._next_future: Future[bytes | None] | None = None
|
||||
self._pos: int = 0
|
||||
|
||||
async def _next(self) -> bytes | None:
|
||||
"""Get the next chunk from the iterator."""
|
||||
return await anext(self._stream, None)
|
||||
|
||||
def abort(self) -> None:
|
||||
"""Abort the reader."""
|
||||
self._aborted = True
|
||||
if self._next_future is not None:
|
||||
self._next_future.cancel()
|
||||
|
||||
def read(self, n: int = -1, /) -> bytes:
|
||||
"""Read data from the iterator."""
|
||||
result = bytearray()
|
||||
while n < 0 or len(result) < n:
|
||||
if not self._buffer:
|
||||
self._next_future = asyncio.run_coroutine_threadsafe(
|
||||
self._next(), self._hass.loop
|
||||
)
|
||||
if self._aborted:
|
||||
self._next_future.cancel()
|
||||
raise AbortCipher
|
||||
try:
|
||||
self._buffer = self._next_future.result()
|
||||
except CancelledError as err:
|
||||
raise AbortCipher from err
|
||||
self._pos = 0
|
||||
if not self._buffer:
|
||||
# The stream is exhausted
|
||||
break
|
||||
chunk = self._buffer[self._pos : self._pos + n]
|
||||
result.extend(chunk)
|
||||
n -= len(chunk)
|
||||
self._pos += len(chunk)
|
||||
if self._pos == len(self._buffer):
|
||||
self._buffer = None
|
||||
return bytes(result)
|
||||
|
||||
def close(self) -> None:
|
||||
"""Close the iterator."""
|
||||
|
||||
|
||||
class AsyncIteratorWriter:
|
||||
"""Wrap an AsyncIterator."""
|
||||
|
||||
def __init__(self, hass: HomeAssistant) -> None:
|
||||
"""Initialize the wrapper."""
|
||||
self._aborted = False
|
||||
self._hass = hass
|
||||
self._pos: int = 0
|
||||
self._queue: asyncio.Queue[bytes | None] = asyncio.Queue(maxsize=1)
|
||||
self._write_future: Future[bytes | None] | None = None
|
||||
|
||||
def __aiter__(self) -> Self:
|
||||
"""Return the iterator."""
|
||||
return self
|
||||
|
||||
async def __anext__(self) -> bytes:
|
||||
"""Get the next chunk from the iterator."""
|
||||
if data := await self._queue.get():
|
||||
return data
|
||||
raise StopAsyncIteration
|
||||
|
||||
def abort(self) -> None:
|
||||
"""Abort the writer."""
|
||||
self._aborted = True
|
||||
if self._write_future is not None:
|
||||
self._write_future.cancel()
|
||||
|
||||
def tell(self) -> int:
|
||||
"""Return the current position in the iterator."""
|
||||
return self._pos
|
||||
|
||||
def write(self, s: bytes, /) -> int:
|
||||
"""Write data to the iterator."""
|
||||
self._write_future = asyncio.run_coroutine_threadsafe(
|
||||
self._queue.put(s), self._hass.loop
|
||||
)
|
||||
if self._aborted:
|
||||
self._write_future.cancel()
|
||||
raise AbortCipher
|
||||
try:
|
||||
self._write_future.result()
|
||||
except CancelledError as err:
|
||||
raise AbortCipher from err
|
||||
self._pos += len(s)
|
||||
return len(s)
|
||||
|
||||
|
||||
def validate_password_stream(
|
||||
input_stream: IO[bytes],
|
||||
password: str | None,
|
||||
@@ -342,7 +240,7 @@ def decrypt_backup(
|
||||
finally:
|
||||
# Write an empty chunk to signal the end of the stream
|
||||
output_stream.write(b"")
|
||||
except AbortCipher:
|
||||
except Abort:
|
||||
LOGGER.debug("Cipher operation aborted")
|
||||
finally:
|
||||
on_done(error)
|
||||
@@ -430,7 +328,7 @@ def encrypt_backup(
|
||||
finally:
|
||||
# Write an empty chunk to signal the end of the stream
|
||||
output_stream.write(b"")
|
||||
except AbortCipher:
|
||||
except Abort:
|
||||
LOGGER.debug("Cipher operation aborted")
|
||||
finally:
|
||||
on_done(error)
|
||||
@@ -557,8 +455,8 @@ class _CipherBackupStreamer:
|
||||
self._hass.loop.call_soon_threadsafe(worker_status.done.set)
|
||||
|
||||
stream = await self._open_stream()
|
||||
reader = AsyncIteratorReader(self._hass, stream)
|
||||
writer = AsyncIteratorWriter(self._hass)
|
||||
reader = AsyncIteratorReader(self._hass.loop, stream)
|
||||
writer = AsyncIteratorWriter(self._hass.loop)
|
||||
worker = threading.Thread(
|
||||
target=self._cipher_func,
|
||||
args=[
|
||||
|
@@ -315,9 +315,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
|
||||
hass.http.register_view(CalendarListView(component))
|
||||
hass.http.register_view(CalendarEventView(component))
|
||||
|
||||
frontend.async_register_built_in_panel(
|
||||
hass, "calendar", "calendar", "hass:calendar"
|
||||
)
|
||||
frontend.async_register_built_in_panel(hass, "calendar", "calendar", "mdi:calendar")
|
||||
|
||||
websocket_api.async_register_command(hass, handle_calendar_event_create)
|
||||
websocket_api.async_register_command(hass, handle_calendar_event_delete)
|
||||
|
@@ -49,7 +49,7 @@ CONFIG_SCHEMA = cv.empty_config_schema(DOMAIN)
|
||||
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
|
||||
"""Set up the config component."""
|
||||
frontend.async_register_built_in_panel(
|
||||
hass, "config", "config", "hass:cog", require_admin=True
|
||||
hass, "config", "config", "mdi:cog", require_admin=True
|
||||
)
|
||||
|
||||
for panel in SECTIONS:
|
||||
|
@@ -56,16 +56,16 @@ async def async_setup_entry(
|
||||
hostname = entry.data[CONF_HOSTNAME]
|
||||
name = entry.data[CONF_NAME]
|
||||
|
||||
resolver_ipv4 = entry.options[CONF_RESOLVER]
|
||||
resolver_ipv6 = entry.options[CONF_RESOLVER_IPV6]
|
||||
nameserver_ipv4 = entry.options[CONF_RESOLVER]
|
||||
nameserver_ipv6 = entry.options[CONF_RESOLVER_IPV6]
|
||||
port_ipv4 = entry.options[CONF_PORT]
|
||||
port_ipv6 = entry.options[CONF_PORT_IPV6]
|
||||
|
||||
entities = []
|
||||
if entry.data[CONF_IPV4]:
|
||||
entities.append(WanIpSensor(name, hostname, resolver_ipv4, False, port_ipv4))
|
||||
entities.append(WanIpSensor(name, hostname, nameserver_ipv4, False, port_ipv4))
|
||||
if entry.data[CONF_IPV6]:
|
||||
entities.append(WanIpSensor(name, hostname, resolver_ipv6, True, port_ipv6))
|
||||
entities.append(WanIpSensor(name, hostname, nameserver_ipv6, True, port_ipv6))
|
||||
|
||||
async_add_entities(entities, update_before_add=True)
|
||||
|
||||
@@ -77,11 +77,13 @@ class WanIpSensor(SensorEntity):
|
||||
_attr_translation_key = "dnsip"
|
||||
_unrecorded_attributes = frozenset({"resolver", "querytype", "ip_addresses"})
|
||||
|
||||
resolver: aiodns.DNSResolver
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
name: str,
|
||||
hostname: str,
|
||||
resolver: str,
|
||||
nameserver: str,
|
||||
ipv6: bool,
|
||||
port: int,
|
||||
) -> None:
|
||||
@@ -90,11 +92,11 @@ class WanIpSensor(SensorEntity):
|
||||
self._attr_unique_id = f"{hostname}_{ipv6}"
|
||||
self.hostname = hostname
|
||||
self.port = port
|
||||
self._resolver = resolver
|
||||
self.nameserver = nameserver
|
||||
self.querytype: Literal["A", "AAAA"] = "AAAA" if ipv6 else "A"
|
||||
self._retries = DEFAULT_RETRIES
|
||||
self._attr_extra_state_attributes = {
|
||||
"resolver": resolver,
|
||||
"resolver": nameserver,
|
||||
"querytype": self.querytype,
|
||||
}
|
||||
self._attr_device_info = DeviceInfo(
|
||||
@@ -104,13 +106,13 @@ class WanIpSensor(SensorEntity):
|
||||
model=aiodns.__version__,
|
||||
name=name,
|
||||
)
|
||||
self.resolver: aiodns.DNSResolver
|
||||
self.create_dns_resolver()
|
||||
|
||||
def create_dns_resolver(self) -> None:
|
||||
"""Create the DNS resolver."""
|
||||
self.resolver = aiodns.DNSResolver(tcp_port=self.port, udp_port=self.port)
|
||||
self.resolver.nameservers = [self._resolver]
|
||||
self.resolver = aiodns.DNSResolver(
|
||||
nameservers=[self.nameserver], tcp_port=self.port, udp_port=self.port
|
||||
)
|
||||
|
||||
async def async_update(self) -> None:
|
||||
"""Get the current DNS IP address for hostname."""
|
||||
|
@@ -2,3 +2,4 @@ raw_get_positions:
|
||||
target:
|
||||
entity:
|
||||
domain: vacuum
|
||||
integration: ecovacs
|
||||
|
@@ -459,7 +459,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
|
||||
"developer-tools",
|
||||
require_admin=True,
|
||||
sidebar_title="developer_tools",
|
||||
sidebar_icon="hass:hammer",
|
||||
sidebar_icon="mdi:hammer",
|
||||
)
|
||||
|
||||
@callback
|
||||
@@ -740,9 +740,7 @@ class ManifestJSONView(HomeAssistantView):
|
||||
@websocket_api.websocket_command(
|
||||
{
|
||||
"type": "frontend/get_icons",
|
||||
vol.Required("category"): vol.In(
|
||||
{"entity", "entity_component", "services", "triggers"}
|
||||
),
|
||||
vol.Required("category"): vol.In({"entity", "entity_component", "services"}),
|
||||
vol.Optional("integration"): vol.All(cv.ensure_list, [str]),
|
||||
}
|
||||
)
|
||||
|
@@ -46,7 +46,7 @@ CONFIG_SCHEMA = vol.Schema(
|
||||
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
|
||||
"""Set up the history hooks."""
|
||||
hass.http.register_view(HistoryPeriodView())
|
||||
frontend.async_register_built_in_panel(hass, "history", "history", "hass:chart-box")
|
||||
frontend.async_register_built_in_panel(hass, "history", "history", "mdi:chart-box")
|
||||
websocket_api.async_setup(hass)
|
||||
return True
|
||||
|
||||
|
@@ -27,6 +27,12 @@
|
||||
"install_addon": {
|
||||
"title": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::step::install_addon::title%]"
|
||||
},
|
||||
"install_thread_firmware": {
|
||||
"title": "[%key:component::homeassistant_hardware::firmware_picker::options::step::install_thread_firmware::title%]"
|
||||
},
|
||||
"install_zigbee_firmware": {
|
||||
"title": "[%key:component::homeassistant_hardware::firmware_picker::options::step::install_zigbee_firmware::title%]"
|
||||
},
|
||||
"notify_channel_change": {
|
||||
"title": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::step::notify_channel_change::title%]",
|
||||
"description": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::step::notify_channel_change::description%]"
|
||||
@@ -69,12 +75,10 @@
|
||||
"description": "[%key:component::homeassistant_hardware::firmware_picker::options::step::confirm_zigbee::description%]"
|
||||
},
|
||||
"install_otbr_addon": {
|
||||
"title": "[%key:component::homeassistant_hardware::firmware_picker::options::step::install_otbr_addon::title%]",
|
||||
"description": "[%key:component::homeassistant_hardware::firmware_picker::options::step::install_otbr_addon::description%]"
|
||||
"title": "[%key:component::homeassistant_hardware::firmware_picker::options::step::install_otbr_addon::title%]"
|
||||
},
|
||||
"start_otbr_addon": {
|
||||
"title": "[%key:component::homeassistant_hardware::firmware_picker::options::step::start_otbr_addon::title%]",
|
||||
"description": "[%key:component::homeassistant_hardware::firmware_picker::options::step::start_otbr_addon::description%]"
|
||||
"title": "[%key:component::homeassistant_hardware::firmware_picker::options::step::start_otbr_addon::title%]"
|
||||
},
|
||||
"otbr_failed": {
|
||||
"title": "[%key:component::homeassistant_hardware::firmware_picker::options::step::otbr_failed::title%]",
|
||||
@@ -129,14 +133,21 @@
|
||||
},
|
||||
"progress": {
|
||||
"install_addon": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::progress::install_addon%]",
|
||||
"install_firmware": "[%key:component::homeassistant_hardware::firmware_picker::options::progress::install_firmware%]",
|
||||
"install_otbr_addon": "[%key:component::homeassistant_hardware::firmware_picker::options::progress::install_otbr_addon%]",
|
||||
"start_addon": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::progress::start_addon%]",
|
||||
"start_otbr_addon": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::progress::start_addon%]",
|
||||
"install_firmware": "[%key:component::homeassistant_hardware::firmware_picker::options::progress::install_firmware%]"
|
||||
"start_otbr_addon": "[%key:component::homeassistant_hardware::firmware_picker::options::progress::start_otbr_addon%]"
|
||||
}
|
||||
},
|
||||
"config": {
|
||||
"flow_title": "{model}",
|
||||
"step": {
|
||||
"install_thread_firmware": {
|
||||
"title": "[%key:component::homeassistant_hardware::firmware_picker::options::step::install_thread_firmware::title%]"
|
||||
},
|
||||
"install_zigbee_firmware": {
|
||||
"title": "[%key:component::homeassistant_hardware::firmware_picker::options::step::install_zigbee_firmware::title%]"
|
||||
},
|
||||
"pick_firmware": {
|
||||
"title": "[%key:component::homeassistant_hardware::firmware_picker::options::step::pick_firmware::title%]",
|
||||
"description": "[%key:component::homeassistant_hardware::firmware_picker::options::step::pick_firmware::description%]",
|
||||
@@ -158,12 +169,10 @@
|
||||
"description": "[%key:component::homeassistant_hardware::firmware_picker::options::step::confirm_zigbee::description%]"
|
||||
},
|
||||
"install_otbr_addon": {
|
||||
"title": "[%key:component::homeassistant_hardware::firmware_picker::options::step::install_otbr_addon::title%]",
|
||||
"description": "[%key:component::homeassistant_hardware::firmware_picker::options::step::install_otbr_addon::description%]"
|
||||
"title": "[%key:component::homeassistant_hardware::firmware_picker::options::step::install_otbr_addon::title%]"
|
||||
},
|
||||
"start_otbr_addon": {
|
||||
"title": "[%key:component::homeassistant_hardware::firmware_picker::options::step::start_otbr_addon::title%]",
|
||||
"description": "[%key:component::homeassistant_hardware::firmware_picker::options::step::start_otbr_addon::description%]"
|
||||
"title": "[%key:component::homeassistant_hardware::firmware_picker::options::step::start_otbr_addon::title%]"
|
||||
},
|
||||
"otbr_failed": {
|
||||
"title": "[%key:component::homeassistant_hardware::firmware_picker::options::step::otbr_failed::title%]",
|
||||
@@ -215,9 +224,10 @@
|
||||
},
|
||||
"progress": {
|
||||
"install_addon": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::progress::install_addon%]",
|
||||
"install_firmware": "[%key:component::homeassistant_hardware::firmware_picker::options::progress::install_firmware%]",
|
||||
"install_otbr_addon": "[%key:component::homeassistant_hardware::firmware_picker::options::progress::install_otbr_addon%]",
|
||||
"start_addon": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::progress::start_addon%]",
|
||||
"start_otbr_addon": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::progress::start_addon%]",
|
||||
"install_firmware": "[%key:component::homeassistant_hardware::firmware_picker::options::progress::install_firmware%]"
|
||||
"start_otbr_addon": "[%key:component::homeassistant_hardware::firmware_picker::options::progress::start_otbr_addon%]"
|
||||
}
|
||||
},
|
||||
"exceptions": {
|
||||
|
@@ -61,6 +61,13 @@ class PickedFirmwareType(StrEnum):
|
||||
ZIGBEE = "zigbee"
|
||||
|
||||
|
||||
class ZigbeeFlowStrategy(StrEnum):
|
||||
"""Zigbee setup strategies that can be picked."""
|
||||
|
||||
ADVANCED = "advanced"
|
||||
RECOMMENDED = "recommended"
|
||||
|
||||
|
||||
class ZigbeeIntegration(StrEnum):
|
||||
"""Zigbee integrations that can be picked."""
|
||||
|
||||
@@ -73,6 +80,7 @@ class BaseFirmwareInstallFlow(ConfigEntryBaseFlow, ABC):
|
||||
|
||||
ZIGBEE_BAUDRATE = 115200 # Default, subclasses may override
|
||||
_picked_firmware_type: PickedFirmwareType
|
||||
_zigbee_flow_strategy: ZigbeeFlowStrategy = ZigbeeFlowStrategy.RECOMMENDED
|
||||
|
||||
def __init__(self, *args: Any, **kwargs: Any) -> None:
|
||||
"""Instantiate base flow."""
|
||||
@@ -395,12 +403,14 @@ class BaseFirmwareInstallFlow(ConfigEntryBaseFlow, ABC):
|
||||
) -> ConfigFlowResult:
|
||||
"""Select recommended installation type."""
|
||||
self._zigbee_integration = ZigbeeIntegration.ZHA
|
||||
self._zigbee_flow_strategy = ZigbeeFlowStrategy.RECOMMENDED
|
||||
return await self._async_continue_picked_firmware()
|
||||
|
||||
async def async_step_zigbee_intent_custom(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> ConfigFlowResult:
|
||||
"""Select custom installation type."""
|
||||
self._zigbee_flow_strategy = ZigbeeFlowStrategy.ADVANCED
|
||||
return await self.async_step_zigbee_integration()
|
||||
|
||||
async def async_step_zigbee_integration(
|
||||
@@ -521,6 +531,7 @@ class BaseFirmwareInstallFlow(ConfigEntryBaseFlow, ABC):
|
||||
"flow_control": "hardware",
|
||||
},
|
||||
"radio_type": "ezsp",
|
||||
"flow_strategy": self._zigbee_flow_strategy,
|
||||
},
|
||||
)
|
||||
return self._continue_zha_flow(result)
|
||||
|
@@ -23,12 +23,16 @@
|
||||
"description": "Your {model} is now a Zigbee coordinator and will be shown as discovered by the Zigbee Home Automation integration."
|
||||
},
|
||||
"install_otbr_addon": {
|
||||
"title": "Installing OpenThread Border Router add-on",
|
||||
"description": "The OpenThread Border Router (OTBR) add-on is being installed."
|
||||
"title": "Configuring Thread"
|
||||
},
|
||||
"install_thread_firmware": {
|
||||
"title": "Updating adapter"
|
||||
},
|
||||
"install_zigbee_firmware": {
|
||||
"title": "Updating adapter"
|
||||
},
|
||||
"start_otbr_addon": {
|
||||
"title": "Starting OpenThread Border Router add-on",
|
||||
"description": "The OpenThread Border Router (OTBR) add-on is now starting."
|
||||
"title": "Configuring Thread"
|
||||
},
|
||||
"otbr_failed": {
|
||||
"title": "Failed to set up OpenThread Border Router",
|
||||
@@ -72,7 +76,9 @@
|
||||
"fw_install_failed": "{firmware_name} firmware failed to install, check Home Assistant logs for more information."
|
||||
},
|
||||
"progress": {
|
||||
"install_firmware": "Please wait while {firmware_name} firmware is installed to your {model}, this will take a few minutes. Do not make any changes to your hardware or software until this finishes."
|
||||
"install_firmware": "Installing {firmware_name} firmware.\n\nDo not make any changes to your hardware or software until this finishes.",
|
||||
"install_otbr_addon": "Installing add-on",
|
||||
"start_otbr_addon": "Starting add-on"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
@@ -27,6 +27,12 @@
|
||||
"install_addon": {
|
||||
"title": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::step::install_addon::title%]"
|
||||
},
|
||||
"install_thread_firmware": {
|
||||
"title": "[%key:component::homeassistant_hardware::firmware_picker::options::step::install_thread_firmware::title%]"
|
||||
},
|
||||
"install_zigbee_firmware": {
|
||||
"title": "[%key:component::homeassistant_hardware::firmware_picker::options::step::install_zigbee_firmware::title%]"
|
||||
},
|
||||
"notify_channel_change": {
|
||||
"title": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::step::notify_channel_change::title%]",
|
||||
"description": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::step::notify_channel_change::description%]"
|
||||
@@ -69,12 +75,10 @@
|
||||
"description": "[%key:component::homeassistant_hardware::firmware_picker::options::step::confirm_zigbee::description%]"
|
||||
},
|
||||
"install_otbr_addon": {
|
||||
"title": "[%key:component::homeassistant_hardware::firmware_picker::options::step::install_otbr_addon::title%]",
|
||||
"description": "[%key:component::homeassistant_hardware::firmware_picker::options::step::install_otbr_addon::description%]"
|
||||
"title": "[%key:component::homeassistant_hardware::firmware_picker::options::step::install_otbr_addon::title%]"
|
||||
},
|
||||
"start_otbr_addon": {
|
||||
"title": "[%key:component::homeassistant_hardware::firmware_picker::options::step::start_otbr_addon::title%]",
|
||||
"description": "[%key:component::homeassistant_hardware::firmware_picker::options::step::start_otbr_addon::description%]"
|
||||
"title": "[%key:component::homeassistant_hardware::firmware_picker::options::step::start_otbr_addon::title%]"
|
||||
},
|
||||
"otbr_failed": {
|
||||
"title": "[%key:component::homeassistant_hardware::firmware_picker::options::step::otbr_failed::title%]",
|
||||
@@ -129,9 +133,10 @@
|
||||
},
|
||||
"progress": {
|
||||
"install_addon": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::progress::install_addon%]",
|
||||
"install_firmware": "[%key:component::homeassistant_hardware::firmware_picker::options::progress::install_firmware%]",
|
||||
"install_otbr_addon": "[%key:component::homeassistant_hardware::firmware_picker::options::progress::install_otbr_addon%]",
|
||||
"start_addon": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::progress::start_addon%]",
|
||||
"start_otbr_addon": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::progress::start_addon%]",
|
||||
"install_firmware": "[%key:component::homeassistant_hardware::firmware_picker::options::progress::install_firmware%]"
|
||||
"start_otbr_addon": "[%key:component::homeassistant_hardware::firmware_picker::options::progress::start_otbr_addon%]"
|
||||
}
|
||||
},
|
||||
"config": {
|
||||
@@ -158,12 +163,16 @@
|
||||
"description": "[%key:component::homeassistant_hardware::firmware_picker::options::step::confirm_zigbee::description%]"
|
||||
},
|
||||
"install_otbr_addon": {
|
||||
"title": "[%key:component::homeassistant_hardware::firmware_picker::options::step::install_otbr_addon::title%]",
|
||||
"description": "[%key:component::homeassistant_hardware::firmware_picker::options::step::install_otbr_addon::description%]"
|
||||
"title": "[%key:component::homeassistant_hardware::firmware_picker::options::step::install_otbr_addon::title%]"
|
||||
},
|
||||
"install_thread_firmware": {
|
||||
"title": "[%key:component::homeassistant_hardware::firmware_picker::options::step::install_thread_firmware::title%]"
|
||||
},
|
||||
"install_zigbee_firmware": {
|
||||
"title": "[%key:component::homeassistant_hardware::firmware_picker::options::step::install_zigbee_firmware::title%]"
|
||||
},
|
||||
"start_otbr_addon": {
|
||||
"title": "[%key:component::homeassistant_hardware::firmware_picker::options::step::start_otbr_addon::title%]",
|
||||
"description": "[%key:component::homeassistant_hardware::firmware_picker::options::step::start_otbr_addon::description%]"
|
||||
"title": "[%key:component::homeassistant_hardware::firmware_picker::options::step::start_otbr_addon::title%]"
|
||||
},
|
||||
"otbr_failed": {
|
||||
"title": "[%key:component::homeassistant_hardware::firmware_picker::options::step::otbr_failed::title%]",
|
||||
@@ -215,9 +224,10 @@
|
||||
},
|
||||
"progress": {
|
||||
"install_addon": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::progress::install_addon%]",
|
||||
"install_firmware": "[%key:component::homeassistant_hardware::firmware_picker::options::progress::install_firmware%]",
|
||||
"install_otbr_addon": "[%key:component::homeassistant_hardware::firmware_picker::options::progress::install_otbr_addon%]",
|
||||
"start_addon": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::progress::start_addon%]",
|
||||
"start_otbr_addon": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::progress::start_addon%]",
|
||||
"install_firmware": "[%key:component::homeassistant_hardware::firmware_picker::options::progress::install_firmware%]"
|
||||
"start_otbr_addon": "[%key:component::homeassistant_hardware::firmware_picker::options::progress::start_otbr_addon%]"
|
||||
}
|
||||
},
|
||||
"exceptions": {
|
||||
|
@@ -35,6 +35,12 @@
|
||||
"install_addon": {
|
||||
"title": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::step::install_addon::title%]"
|
||||
},
|
||||
"install_thread_firmware": {
|
||||
"title": "[%key:component::homeassistant_hardware::firmware_picker::options::step::install_thread_firmware::title%]"
|
||||
},
|
||||
"install_zigbee_firmware": {
|
||||
"title": "[%key:component::homeassistant_hardware::firmware_picker::options::step::install_zigbee_firmware::title%]"
|
||||
},
|
||||
"notify_channel_change": {
|
||||
"title": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::step::notify_channel_change::title%]",
|
||||
"description": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::step::notify_channel_change::description%]"
|
||||
@@ -92,12 +98,10 @@
|
||||
"description": "[%key:component::homeassistant_hardware::firmware_picker::options::step::confirm_zigbee::description%]"
|
||||
},
|
||||
"install_otbr_addon": {
|
||||
"title": "[%key:component::homeassistant_hardware::firmware_picker::options::step::install_otbr_addon::title%]",
|
||||
"description": "[%key:component::homeassistant_hardware::firmware_picker::options::step::install_otbr_addon::description%]"
|
||||
"title": "[%key:component::homeassistant_hardware::firmware_picker::options::step::install_otbr_addon::title%]"
|
||||
},
|
||||
"start_otbr_addon": {
|
||||
"title": "[%key:component::homeassistant_hardware::firmware_picker::options::step::start_otbr_addon::title%]",
|
||||
"description": "[%key:component::homeassistant_hardware::firmware_picker::options::step::start_otbr_addon::description%]"
|
||||
"title": "[%key:component::homeassistant_hardware::firmware_picker::options::step::start_otbr_addon::title%]"
|
||||
},
|
||||
"otbr_failed": {
|
||||
"title": "[%key:component::homeassistant_hardware::firmware_picker::options::step::otbr_failed::title%]",
|
||||
@@ -154,9 +158,10 @@
|
||||
},
|
||||
"progress": {
|
||||
"install_addon": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::progress::install_addon%]",
|
||||
"install_firmware": "[%key:component::homeassistant_hardware::firmware_picker::options::progress::install_firmware%]",
|
||||
"install_otbr_addon": "[%key:component::homeassistant_hardware::firmware_picker::options::progress::install_otbr_addon%]",
|
||||
"start_addon": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::progress::start_addon%]",
|
||||
"start_otbr_addon": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::progress::start_addon%]",
|
||||
"install_firmware": "[%key:component::homeassistant_hardware::firmware_picker::options::progress::install_firmware%]"
|
||||
"start_otbr_addon": "[%key:component::homeassistant_hardware::firmware_picker::options::progress::start_otbr_addon%]"
|
||||
}
|
||||
},
|
||||
"entity": {
|
||||
|
@@ -35,7 +35,7 @@ from homeassistant.const import (
|
||||
Platform,
|
||||
)
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers import config_validation as cv
|
||||
from homeassistant.helpers import config_validation as cv, issue_registry as ir
|
||||
from homeassistant.helpers.typing import ConfigType
|
||||
|
||||
from .config_flow import ( # Loading the config flow file will register the flow
|
||||
@@ -221,6 +221,19 @@ PLATFORMS = [Platform.BINARY_SENSOR, Platform.SENSOR, Platform.SWITCH]
|
||||
|
||||
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
|
||||
"""Set up the Konnected platform."""
|
||||
ir.async_create_issue(
|
||||
hass,
|
||||
DOMAIN,
|
||||
"deprecated_firmware",
|
||||
breaks_in_ha_version="2026.4.0",
|
||||
is_fixable=False,
|
||||
issue_domain=DOMAIN,
|
||||
severity=ir.IssueSeverity.WARNING,
|
||||
translation_key="deprecated_firmware",
|
||||
translation_placeholders={
|
||||
"kb_page_url": "https://support.konnected.io/migrating-from-konnected-legacy-home-assistant-integration-to-esphome",
|
||||
},
|
||||
)
|
||||
if (cfg := config.get(DOMAIN)) is None:
|
||||
cfg = {}
|
||||
|
||||
|
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"domain": "konnected",
|
||||
"name": "Konnected.io",
|
||||
"name": "Konnected.io (Legacy)",
|
||||
"codeowners": ["@heythisisnate"],
|
||||
"config_flow": true,
|
||||
"dependencies": ["http"],
|
||||
|
@@ -105,5 +105,11 @@
|
||||
"abort": {
|
||||
"not_konn_panel": "[%key:component::konnected::config::abort::not_konn_panel%]"
|
||||
}
|
||||
},
|
||||
"issues": {
|
||||
"deprecated_firmware": {
|
||||
"title": "Konnected firmware is deprecated",
|
||||
"description": "Konnected's integration is deprecated and Konnected strongly recommends migrating to their ESPHome based firmware and integration by following the guide at {kb_page_url}. After this migration, make sure you don't have any Konnected YAML configuration left in your configuration.yaml file and remove this integration from Home Assistant."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@@ -25,10 +25,5 @@
|
||||
"turn_on": {
|
||||
"service": "mdi:lightbulb-on"
|
||||
}
|
||||
},
|
||||
"triggers": {
|
||||
"state": {
|
||||
"trigger": "mdi:state-machine"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@@ -133,13 +133,6 @@
|
||||
}
|
||||
},
|
||||
"selector": {
|
||||
"behavior": {
|
||||
"options": {
|
||||
"first": "First",
|
||||
"last": "Last",
|
||||
"any": "Any"
|
||||
}
|
||||
},
|
||||
"color_name": {
|
||||
"options": {
|
||||
"homeassistant": "Home Assistant",
|
||||
@@ -297,12 +290,6 @@
|
||||
"short": "Short",
|
||||
"long": "Long"
|
||||
}
|
||||
},
|
||||
"state": {
|
||||
"options": {
|
||||
"off": "[%key:common::state::off%]",
|
||||
"on": "[%key:common::state::on%]"
|
||||
}
|
||||
}
|
||||
},
|
||||
"services": {
|
||||
@@ -475,22 +462,5 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"triggers": {
|
||||
"state": {
|
||||
"name": "State",
|
||||
"description": "When the state of a light changes, such as turning on or off.",
|
||||
"description_configured": "When the state of a light changes",
|
||||
"fields": {
|
||||
"state": {
|
||||
"name": "State",
|
||||
"description": "The state to trigger on."
|
||||
},
|
||||
"behavior": {
|
||||
"name": "Behavior",
|
||||
"description": "The behavior of the targeted entities to trigger on."
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@@ -1,165 +0,0 @@
|
||||
"""Provides triggers for lights."""
|
||||
|
||||
from typing import Final, cast, override
|
||||
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.const import (
|
||||
ATTR_ENTITY_ID,
|
||||
CONF_PLATFORM,
|
||||
CONF_STATE,
|
||||
CONF_TARGET,
|
||||
STATE_OFF,
|
||||
STATE_ON,
|
||||
)
|
||||
from homeassistant.core import (
|
||||
CALLBACK_TYPE,
|
||||
HassJob,
|
||||
HomeAssistant,
|
||||
callback,
|
||||
split_entity_id,
|
||||
)
|
||||
from homeassistant.helpers import config_validation as cv
|
||||
from homeassistant.helpers.event import process_state_match
|
||||
from homeassistant.helpers.target import (
|
||||
TargetStateChangedData,
|
||||
async_track_target_selector_state_change_event,
|
||||
)
|
||||
from homeassistant.helpers.trigger import Trigger, TriggerActionType, TriggerInfo
|
||||
from homeassistant.helpers.typing import ConfigType
|
||||
|
||||
from .const import DOMAIN
|
||||
|
||||
# remove when #151314 is merged
|
||||
CONF_OPTIONS: Final = "options"
|
||||
|
||||
ATTR_BEHAVIOR: Final = "behavior"
|
||||
BEHAVIOR_FIRST: Final = "first"
|
||||
BEHAVIOR_LAST: Final = "last"
|
||||
BEHAVIOR_ANY: Final = "any"
|
||||
|
||||
STATE_PLATFORM_TYPE: Final = "state"
|
||||
STATE_TRIGGER_SCHEMA = vol.Schema(
|
||||
{
|
||||
vol.Required(CONF_OPTIONS): {
|
||||
vol.Required(CONF_STATE): vol.In([STATE_ON, STATE_OFF]),
|
||||
vol.Required(ATTR_BEHAVIOR, default=BEHAVIOR_ANY): vol.In(
|
||||
[BEHAVIOR_FIRST, BEHAVIOR_LAST, BEHAVIOR_ANY]
|
||||
),
|
||||
},
|
||||
vol.Required(CONF_TARGET): cv.TARGET_FIELDS,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
class StateTrigger(Trigger):
|
||||
"""Trigger for state changes."""
|
||||
|
||||
@override
|
||||
@classmethod
|
||||
async def async_validate_config(
|
||||
cls, hass: HomeAssistant, config: ConfigType
|
||||
) -> ConfigType:
|
||||
"""Validate config."""
|
||||
return cast(ConfigType, STATE_TRIGGER_SCHEMA(config))
|
||||
|
||||
def __init__(self, hass: HomeAssistant, config: dict) -> None:
|
||||
"""Initialize the state trigger."""
|
||||
self.hass = hass
|
||||
self.config = config
|
||||
|
||||
@override
|
||||
async def async_attach(
|
||||
self, action: TriggerActionType, trigger_info: TriggerInfo
|
||||
) -> CALLBACK_TYPE:
|
||||
"""Attach the trigger."""
|
||||
job = HassJob(action, f"light state trigger {trigger_info}")
|
||||
trigger_data = trigger_info["trigger_data"]
|
||||
config_options = self.config[CONF_OPTIONS]
|
||||
|
||||
match_config_state = process_state_match(config_options.get(CONF_STATE))
|
||||
|
||||
def check_all_match(entity_ids: set[str]) -> bool:
|
||||
"""Check if all entity states match."""
|
||||
return all(
|
||||
match_config_state(state.state)
|
||||
for entity_id in entity_ids
|
||||
if (state := self.hass.states.get(entity_id)) is not None
|
||||
)
|
||||
|
||||
def check_one_match(entity_ids: set[str]) -> bool:
|
||||
"""Check that only one entity state matches."""
|
||||
return (
|
||||
sum(
|
||||
match_config_state(state.state)
|
||||
for entity_id in entity_ids
|
||||
if (state := self.hass.states.get(entity_id)) is not None
|
||||
)
|
||||
== 1
|
||||
)
|
||||
|
||||
behavior = config_options.get(ATTR_BEHAVIOR)
|
||||
|
||||
@callback
|
||||
def state_change_listener(
|
||||
target_state_change_data: TargetStateChangedData,
|
||||
) -> None:
|
||||
"""Listen for state changes and call action."""
|
||||
event = target_state_change_data.state_change_event
|
||||
entity_id = event.data["entity_id"]
|
||||
from_state = event.data["old_state"]
|
||||
to_state = event.data["new_state"]
|
||||
|
||||
if to_state is None:
|
||||
return
|
||||
|
||||
# This check is required for "first" behavior, to check that it went from zero
|
||||
# entities matching the state to one. Otherwise, if previously there were two
|
||||
# entities on CONF_STATE and one changed, this would trigger.
|
||||
# For "last" behavior it is not required, but serves as a quicker fail check.
|
||||
if not match_config_state(to_state.state):
|
||||
return
|
||||
if behavior == BEHAVIOR_LAST:
|
||||
if not check_all_match(target_state_change_data.targeted_entity_ids):
|
||||
return
|
||||
elif behavior == BEHAVIOR_FIRST:
|
||||
if not check_one_match(target_state_change_data.targeted_entity_ids):
|
||||
return
|
||||
|
||||
self.hass.async_run_hass_job(
|
||||
job,
|
||||
{
|
||||
"trigger": {
|
||||
**trigger_data,
|
||||
CONF_PLATFORM: self.config[CONF_PLATFORM],
|
||||
ATTR_ENTITY_ID: entity_id,
|
||||
"from_state": from_state,
|
||||
"to_state": to_state,
|
||||
"description": f"state of {entity_id}",
|
||||
}
|
||||
},
|
||||
event.context,
|
||||
)
|
||||
|
||||
def entity_filter(entities: set[str]) -> set[str]:
|
||||
"""Filter entities of this domain."""
|
||||
return {
|
||||
entity_id
|
||||
for entity_id in entities
|
||||
if split_entity_id(entity_id)[0] == DOMAIN
|
||||
}
|
||||
|
||||
target_config = self.config[CONF_TARGET]
|
||||
return async_track_target_selector_state_change_event(
|
||||
self.hass, target_config, state_change_listener, entity_filter
|
||||
)
|
||||
|
||||
|
||||
TRIGGERS: dict[str, type[Trigger]] = {
|
||||
STATE_PLATFORM_TYPE: StateTrigger,
|
||||
}
|
||||
|
||||
|
||||
async def async_get_triggers(hass: HomeAssistant) -> dict[str, type[Trigger]]:
|
||||
"""Return the triggers for lights."""
|
||||
return TRIGGERS
|
@@ -1,24 +0,0 @@
|
||||
state:
|
||||
target:
|
||||
entity:
|
||||
domain: light
|
||||
fields:
|
||||
state:
|
||||
required: true
|
||||
default: "on"
|
||||
selector:
|
||||
select:
|
||||
options:
|
||||
- "off"
|
||||
- "on"
|
||||
translation_key: state
|
||||
behavior:
|
||||
required: true
|
||||
default: any
|
||||
selector:
|
||||
select:
|
||||
options:
|
||||
- first
|
||||
- last
|
||||
- any
|
||||
translation_key: behavior
|
@@ -115,7 +115,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
|
||||
async_log_entry(hass, name, message, domain, entity_id, service.context)
|
||||
|
||||
frontend.async_register_built_in_panel(
|
||||
hass, "logbook", "logbook", "hass:format-list-bulleted-type"
|
||||
hass, "logbook", "logbook", "mdi:format-list-bulleted-type"
|
||||
)
|
||||
|
||||
recorder_conf = config.get(RECORDER_DOMAIN, {})
|
||||
|
@@ -24,7 +24,7 @@ if TYPE_CHECKING:
|
||||
DOMAIN = "lovelace"
|
||||
LOVELACE_DATA: HassKey[LovelaceData] = HassKey(DOMAIN)
|
||||
|
||||
DEFAULT_ICON = "hass:view-dashboard"
|
||||
DEFAULT_ICON = "mdi:view-dashboard"
|
||||
|
||||
MODE_YAML = "yaml"
|
||||
MODE_STORAGE = "storage"
|
||||
|
@@ -148,6 +148,9 @@
|
||||
},
|
||||
"evse_charging_switch": {
|
||||
"default": "mdi:ev-station"
|
||||
},
|
||||
"privacy_mode_button": {
|
||||
"default": "mdi:shield-lock"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@@ -80,9 +80,7 @@ class MatterNumber(MatterEntity, NumberEntity):
|
||||
sendvalue = int(value)
|
||||
if value_convert := self.entity_description.ha_to_device:
|
||||
sendvalue = value_convert(value)
|
||||
await self.write_attribute(
|
||||
value=sendvalue,
|
||||
)
|
||||
await self.write_attribute(value=sendvalue)
|
||||
|
||||
@callback
|
||||
def _update_from_device(self) -> None:
|
||||
@@ -437,4 +435,35 @@ DISCOVERY_SCHEMAS = [
|
||||
custom_clusters.InovelliCluster.Attributes.LEDIndicatorIntensityOn,
|
||||
),
|
||||
),
|
||||
MatterDiscoverySchema(
|
||||
platform=Platform.NUMBER,
|
||||
entity_description=MatterNumberEntityDescription(
|
||||
key="DoorLockWrongCodeEntryLimit",
|
||||
entity_category=EntityCategory.CONFIG,
|
||||
translation_key="wrong_code_entry_limit",
|
||||
native_max_value=255,
|
||||
native_min_value=1,
|
||||
native_step=1,
|
||||
mode=NumberMode.BOX,
|
||||
),
|
||||
entity_class=MatterNumber,
|
||||
required_attributes=(clusters.DoorLock.Attributes.WrongCodeEntryLimit,),
|
||||
),
|
||||
MatterDiscoverySchema(
|
||||
platform=Platform.NUMBER,
|
||||
entity_description=MatterNumberEntityDescription(
|
||||
key="DoorLockUserCodeTemporaryDisableTime",
|
||||
entity_category=EntityCategory.CONFIG,
|
||||
translation_key="user_code_temporary_disable_time",
|
||||
native_max_value=255,
|
||||
native_min_value=1,
|
||||
native_step=1,
|
||||
native_unit_of_measurement=UnitOfTime.SECONDS,
|
||||
mode=NumberMode.BOX,
|
||||
),
|
||||
entity_class=MatterNumber,
|
||||
required_attributes=(
|
||||
clusters.DoorLock.Attributes.UserCodeTemporaryDisableTime,
|
||||
),
|
||||
),
|
||||
]
|
||||
|
@@ -198,6 +198,9 @@
|
||||
"pump_setpoint": {
|
||||
"name": "Setpoint"
|
||||
},
|
||||
"user_code_temporary_disable_time": {
|
||||
"name": "User code temporary disable time"
|
||||
},
|
||||
"temperature_offset": {
|
||||
"name": "Temperature offset"
|
||||
},
|
||||
@@ -218,6 +221,9 @@
|
||||
},
|
||||
"valve_configuration_and_control_default_open_duration": {
|
||||
"name": "Default open duration"
|
||||
},
|
||||
"wrong_code_entry_limit": {
|
||||
"name": "Wrong code limit"
|
||||
}
|
||||
},
|
||||
"light": {
|
||||
@@ -513,6 +519,9 @@
|
||||
},
|
||||
"evse_charging_switch": {
|
||||
"name": "Enable charging"
|
||||
},
|
||||
"privacy_mode_button": {
|
||||
"name": "Privacy mode button"
|
||||
}
|
||||
},
|
||||
"vacuum": {
|
||||
|
@@ -263,6 +263,18 @@ DISCOVERY_SCHEMAS = [
|
||||
),
|
||||
vendor_id=(4874,),
|
||||
),
|
||||
MatterDiscoverySchema(
|
||||
platform=Platform.SWITCH,
|
||||
entity_description=MatterNumericSwitchEntityDescription(
|
||||
key="DoorLockEnablePrivacyModeButton",
|
||||
entity_category=EntityCategory.CONFIG,
|
||||
translation_key="privacy_mode_button",
|
||||
device_to_ha=bool,
|
||||
ha_to_device=int,
|
||||
),
|
||||
entity_class=MatterNumericSwitch,
|
||||
required_attributes=(clusters.DoorLock.Attributes.EnablePrivacyModeButton,),
|
||||
),
|
||||
MatterDiscoverySchema(
|
||||
platform=Platform.SWITCH,
|
||||
entity_description=MatterGenericCommandSwitchEntityDescription(
|
||||
|
@@ -25,7 +25,7 @@ def async_setup(hass: HomeAssistant) -> None:
|
||||
websocket_api.async_register_command(hass, websocket_browse_media)
|
||||
websocket_api.async_register_command(hass, websocket_resolve_media)
|
||||
frontend.async_register_built_in_panel(
|
||||
hass, "media-browser", "media_browser", "hass:play-box-multiple"
|
||||
hass, "media-browser", "media_browser", "mdi:play-box-multiple"
|
||||
)
|
||||
|
||||
|
||||
|
@@ -1235,6 +1235,7 @@
|
||||
"ozone": "[%key:component::sensor::entity_component::ozone::name%]",
|
||||
"ph": "[%key:component::sensor::entity_component::ph::name%]",
|
||||
"pm1": "[%key:component::sensor::entity_component::pm1::name%]",
|
||||
"pm4": "[%key:component::sensor::entity_component::pm4::name%]",
|
||||
"pm10": "[%key:component::sensor::entity_component::pm10::name%]",
|
||||
"pm25": "[%key:component::sensor::entity_component::pm25::name%]",
|
||||
"power": "[%key:component::sensor::entity_component::power::name%]",
|
||||
@@ -1242,6 +1243,7 @@
|
||||
"precipitation": "[%key:component::sensor::entity_component::precipitation::name%]",
|
||||
"precipitation_intensity": "[%key:component::sensor::entity_component::precipitation_intensity::name%]",
|
||||
"pressure": "[%key:component::sensor::entity_component::pressure::name%]",
|
||||
"reactive_energy": "[%key:component::sensor::entity_component::reactive_energy::name%]",
|
||||
"reactive_power": "[%key:component::sensor::entity_component::reactive_power::name%]",
|
||||
"signal_strength": "[%key:component::sensor::entity_component::signal_strength::name%]",
|
||||
"sound_pressure": "[%key:component::sensor::entity_component::sound_pressure::name%]",
|
||||
|
@@ -112,6 +112,9 @@
|
||||
"pm1": {
|
||||
"name": "[%key:component::sensor::entity_component::pm1::name%]"
|
||||
},
|
||||
"pm4": {
|
||||
"name": "[%key:component::sensor::entity_component::pm4::name%]"
|
||||
},
|
||||
"pm10": {
|
||||
"name": "[%key:component::sensor::entity_component::pm10::name%]"
|
||||
},
|
||||
|
@@ -341,12 +341,12 @@ class OnkyoMediaPlayer(MediaPlayerEntity):
|
||||
def process_update(self, message: status.Known) -> None:
|
||||
"""Process update."""
|
||||
match message:
|
||||
case status.Power(status.Power.Param.ON):
|
||||
case status.Power(param=status.Power.Param.ON):
|
||||
self._attr_state = MediaPlayerState.ON
|
||||
case status.Power(status.Power.Param.STANDBY):
|
||||
case status.Power(param=status.Power.Param.STANDBY):
|
||||
self._attr_state = MediaPlayerState.OFF
|
||||
|
||||
case status.Volume(volume):
|
||||
case status.Volume(param=volume):
|
||||
if not self._supports_volume:
|
||||
self._attr_supported_features |= SUPPORTED_FEATURES_VOLUME
|
||||
self._supports_volume = True
|
||||
@@ -356,10 +356,10 @@ class OnkyoMediaPlayer(MediaPlayerEntity):
|
||||
)
|
||||
self._attr_volume_level = min(1, volume_level)
|
||||
|
||||
case status.Muting(muting):
|
||||
case status.Muting(param=muting):
|
||||
self._attr_is_volume_muted = bool(muting == status.Muting.Param.ON)
|
||||
|
||||
case status.InputSource(source):
|
||||
case status.InputSource(param=source):
|
||||
if source in self._source_mapping:
|
||||
self._attr_source = self._source_mapping[source]
|
||||
else:
|
||||
@@ -373,7 +373,7 @@ class OnkyoMediaPlayer(MediaPlayerEntity):
|
||||
|
||||
self._query_av_info_delayed()
|
||||
|
||||
case status.ListeningMode(sound_mode):
|
||||
case status.ListeningMode(param=sound_mode):
|
||||
if not self._supports_sound_mode:
|
||||
self._attr_supported_features |= (
|
||||
MediaPlayerEntityFeature.SELECT_SOUND_MODE
|
||||
@@ -393,13 +393,13 @@ class OnkyoMediaPlayer(MediaPlayerEntity):
|
||||
|
||||
self._query_av_info_delayed()
|
||||
|
||||
case status.HDMIOutput(hdmi_output):
|
||||
case status.HDMIOutput(param=hdmi_output):
|
||||
self._attr_extra_state_attributes[ATTR_VIDEO_OUT] = (
|
||||
self._hdmi_output_mapping[hdmi_output]
|
||||
)
|
||||
self._query_av_info_delayed()
|
||||
|
||||
case status.TunerPreset(preset):
|
||||
case status.TunerPreset(param=preset):
|
||||
self._attr_extra_state_attributes[ATTR_PRESET] = preset
|
||||
|
||||
case status.AudioInformation():
|
||||
@@ -427,11 +427,11 @@ class OnkyoMediaPlayer(MediaPlayerEntity):
|
||||
case status.FLDisplay():
|
||||
self._query_av_info_delayed()
|
||||
|
||||
case status.NotAvailable(Kind.AUDIO_INFORMATION):
|
||||
case status.NotAvailable(kind=Kind.AUDIO_INFORMATION):
|
||||
# Not available right now, but still supported
|
||||
self._supports_audio_info = True
|
||||
|
||||
case status.NotAvailable(Kind.VIDEO_INFORMATION):
|
||||
case status.NotAvailable(kind=Kind.VIDEO_INFORMATION):
|
||||
# Not available right now, but still supported
|
||||
self._supports_video_info = True
|
||||
|
||||
|
@@ -114,6 +114,7 @@
|
||||
"ozone": "[%key:component::sensor::entity_component::ozone::name%]",
|
||||
"ph": "[%key:component::sensor::entity_component::ph::name%]",
|
||||
"pm1": "[%key:component::sensor::entity_component::pm1::name%]",
|
||||
"pm4": "[%key:component::sensor::entity_component::pm4::name%]",
|
||||
"pm10": "[%key:component::sensor::entity_component::pm10::name%]",
|
||||
"pm25": "[%key:component::sensor::entity_component::pm25::name%]",
|
||||
"power": "[%key:component::sensor::entity_component::power::name%]",
|
||||
|
@@ -229,6 +229,9 @@
|
||||
"ai_vehicle_sensitivity": {
|
||||
"default": "mdi:car"
|
||||
},
|
||||
"ai_non_motor_vehicle_sensitivity": {
|
||||
"default": "mdi:bicycle"
|
||||
},
|
||||
"ai_package_sensitivity": {
|
||||
"default": "mdi:gift-outline"
|
||||
},
|
||||
@@ -265,6 +268,9 @@
|
||||
"ai_vehicle_delay": {
|
||||
"default": "mdi:car"
|
||||
},
|
||||
"ai_non_motor_vehicle_delay": {
|
||||
"default": "mdi:bicycle"
|
||||
},
|
||||
"ai_package_delay": {
|
||||
"default": "mdi:gift-outline"
|
||||
},
|
||||
|
@@ -255,6 +255,23 @@ NUMBER_ENTITIES = (
|
||||
value=lambda api, ch: api.ai_sensitivity(ch, "vehicle"),
|
||||
method=lambda api, ch, value: api.set_ai_sensitivity(ch, int(value), "vehicle"),
|
||||
),
|
||||
ReolinkNumberEntityDescription(
|
||||
key="ai_non_motor_vehicle_sensitivity",
|
||||
cmd_key="GetAiAlarm",
|
||||
translation_key="ai_non_motor_vehicle_sensitivity",
|
||||
entity_category=EntityCategory.CONFIG,
|
||||
native_step=1,
|
||||
native_min_value=0,
|
||||
native_max_value=100,
|
||||
supported=lambda api, ch: (
|
||||
api.supported(ch, "ai_sensitivity")
|
||||
and api.supported(ch, "ai_non-motor vehicle")
|
||||
),
|
||||
value=lambda api, ch: api.ai_sensitivity(ch, "non-motor vehicle"),
|
||||
method=lambda api, ch, value: (
|
||||
api.set_ai_sensitivity(ch, int(value), "non-motor vehicle")
|
||||
),
|
||||
),
|
||||
ReolinkNumberEntityDescription(
|
||||
key="ai_package_sensititvity",
|
||||
cmd_key="GetAiAlarm",
|
||||
@@ -345,6 +362,25 @@ NUMBER_ENTITIES = (
|
||||
value=lambda api, ch: api.ai_delay(ch, "people"),
|
||||
method=lambda api, ch, value: api.set_ai_delay(ch, int(value), "people"),
|
||||
),
|
||||
ReolinkNumberEntityDescription(
|
||||
key="ai_non_motor_vehicle_delay",
|
||||
cmd_key="GetAiAlarm",
|
||||
translation_key="ai_non_motor_vehicle_delay",
|
||||
entity_category=EntityCategory.CONFIG,
|
||||
device_class=NumberDeviceClass.DURATION,
|
||||
entity_registry_enabled_default=False,
|
||||
native_step=1,
|
||||
native_unit_of_measurement=UnitOfTime.SECONDS,
|
||||
native_min_value=0,
|
||||
native_max_value=8,
|
||||
supported=lambda api, ch: (
|
||||
api.supported(ch, "ai_delay") and api.supported(ch, "ai_non-motor vehicle")
|
||||
),
|
||||
value=lambda api, ch: api.ai_delay(ch, "non-motor vehicle"),
|
||||
method=lambda api, ch, value: (
|
||||
api.set_ai_delay(ch, int(value), "non-motor vehicle")
|
||||
),
|
||||
),
|
||||
ReolinkNumberEntityDescription(
|
||||
key="ai_vehicle_delay",
|
||||
cmd_key="GetAiAlarm",
|
||||
|
@@ -578,6 +578,9 @@
|
||||
"ai_vehicle_sensitivity": {
|
||||
"name": "AI vehicle sensitivity"
|
||||
},
|
||||
"ai_non_motor_vehicle_sensitivity": {
|
||||
"name": "AI bicycle sensitivity"
|
||||
},
|
||||
"ai_package_sensitivity": {
|
||||
"name": "AI package sensitivity"
|
||||
},
|
||||
@@ -614,6 +617,9 @@
|
||||
"ai_vehicle_delay": {
|
||||
"name": "AI vehicle delay"
|
||||
},
|
||||
"ai_non_motor_vehicle_delay": {
|
||||
"name": "AI bicycle delay"
|
||||
},
|
||||
"ai_package_delay": {
|
||||
"name": "AI package delay"
|
||||
},
|
||||
|
@@ -171,6 +171,7 @@
|
||||
"ozone": "[%key:component::sensor::entity_component::ozone::name%]",
|
||||
"ph": "[%key:component::sensor::entity_component::ph::name%]",
|
||||
"pm1": "[%key:component::sensor::entity_component::pm1::name%]",
|
||||
"pm4": "[%key:component::sensor::entity_component::pm4::name%]",
|
||||
"pm10": "[%key:component::sensor::entity_component::pm10::name%]",
|
||||
"pm25": "[%key:component::sensor::entity_component::pm25::name%]",
|
||||
"power": "[%key:component::sensor::entity_component::power::name%]",
|
||||
@@ -178,6 +179,7 @@
|
||||
"precipitation": "[%key:component::sensor::entity_component::precipitation::name%]",
|
||||
"precipitation_intensity": "[%key:component::sensor::entity_component::precipitation_intensity::name%]",
|
||||
"pressure": "[%key:component::sensor::entity_component::pressure::name%]",
|
||||
"reactive_energy": "[%key:component::sensor::entity_component::reactive_energy::name%]",
|
||||
"reactive_power": "[%key:component::sensor::entity_component::reactive_power::name%]",
|
||||
"signal_strength": "[%key:component::sensor::entity_component::signal_strength::name%]",
|
||||
"sound_pressure": "[%key:component::sensor::entity_component::sound_pressure::name%]",
|
||||
|
@@ -245,6 +245,9 @@
|
||||
"pm1": {
|
||||
"name": "PM1"
|
||||
},
|
||||
"pm4": {
|
||||
"name": "PM4"
|
||||
},
|
||||
"pm10": {
|
||||
"name": "PM10"
|
||||
},
|
||||
|
@@ -6,5 +6,5 @@
|
||||
"documentation": "https://www.home-assistant.io/integrations/smhi",
|
||||
"iot_class": "cloud_polling",
|
||||
"loggers": ["pysmhi"],
|
||||
"requirements": ["pysmhi==1.0.2"]
|
||||
"requirements": ["pysmhi==1.1.0"]
|
||||
}
|
||||
|
@@ -125,6 +125,7 @@
|
||||
"ozone": "[%key:component::sensor::entity_component::ozone::name%]",
|
||||
"ph": "[%key:component::sensor::entity_component::ph::name%]",
|
||||
"pm1": "[%key:component::sensor::entity_component::pm1::name%]",
|
||||
"pm4": "[%key:component::sensor::entity_component::pm4::name%]",
|
||||
"pm10": "[%key:component::sensor::entity_component::pm10::name%]",
|
||||
"pm25": "[%key:component::sensor::entity_component::pm25::name%]",
|
||||
"power": "[%key:component::sensor::entity_component::power::name%]",
|
||||
|
@@ -1083,6 +1083,7 @@
|
||||
"ozone": "[%key:component::sensor::entity_component::ozone::name%]",
|
||||
"ph": "[%key:component::sensor::entity_component::ph::name%]",
|
||||
"pm1": "[%key:component::sensor::entity_component::pm1::name%]",
|
||||
"pm4": "[%key:component::sensor::entity_component::pm4::name%]",
|
||||
"pm10": "[%key:component::sensor::entity_component::pm10::name%]",
|
||||
"pm25": "[%key:component::sensor::entity_component::pm25::name%]",
|
||||
"power": "[%key:component::sensor::entity_component::power::name%]",
|
||||
|
@@ -34,7 +34,12 @@ from homeassistant.exceptions import (
|
||||
TemplateError,
|
||||
Unauthorized,
|
||||
)
|
||||
from homeassistant.helpers import config_validation as cv, entity, template
|
||||
from homeassistant.helpers import (
|
||||
config_validation as cv,
|
||||
entity,
|
||||
target as target_helpers,
|
||||
template,
|
||||
)
|
||||
from homeassistant.helpers.condition import (
|
||||
async_get_all_descriptions as async_get_all_condition_descriptions,
|
||||
async_subscribe_platform_events as async_subscribe_condition_platform_events,
|
||||
@@ -96,6 +101,7 @@ def async_register_commands(
|
||||
async_reg(hass, handle_call_service)
|
||||
async_reg(hass, handle_entity_source)
|
||||
async_reg(hass, handle_execute_script)
|
||||
async_reg(hass, handle_extract_from_target)
|
||||
async_reg(hass, handle_fire_event)
|
||||
async_reg(hass, handle_get_config)
|
||||
async_reg(hass, handle_get_services)
|
||||
@@ -829,6 +835,39 @@ def handle_entity_source(
|
||||
connection.send_result(msg["id"], _serialize_entity_sources(entity_sources))
|
||||
|
||||
|
||||
@callback
|
||||
@decorators.websocket_command(
|
||||
{
|
||||
vol.Required("type"): "extract_from_target",
|
||||
vol.Required("target"): cv.TARGET_FIELDS,
|
||||
vol.Optional("expand_group", default=False): bool,
|
||||
}
|
||||
)
|
||||
def handle_extract_from_target(
|
||||
hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any]
|
||||
) -> None:
|
||||
"""Handle extract from target command."""
|
||||
|
||||
selector_data = target_helpers.TargetSelectorData(msg["target"])
|
||||
extracted = target_helpers.async_extract_referenced_entity_ids(
|
||||
hass, selector_data, expand_group=msg["expand_group"]
|
||||
)
|
||||
|
||||
extracted_dict = {
|
||||
"referenced_entities": extracted.referenced.union(
|
||||
extracted.indirectly_referenced
|
||||
),
|
||||
"referenced_devices": extracted.referenced_devices,
|
||||
"referenced_areas": extracted.referenced_areas,
|
||||
"missing_devices": extracted.missing_devices,
|
||||
"missing_areas": extracted.missing_areas,
|
||||
"missing_floors": extracted.missing_floors,
|
||||
"missing_labels": extracted.missing_labels,
|
||||
}
|
||||
|
||||
connection.send_result(msg["id"], extracted_dict)
|
||||
|
||||
|
||||
@decorators.websocket_command(
|
||||
{
|
||||
vol.Required("type"): "subscribe_trigger",
|
||||
|
@@ -20,6 +20,9 @@ from homeassistant.components import onboarding, usb
|
||||
from homeassistant.components.file_upload import process_uploaded_file
|
||||
from homeassistant.components.hassio import AddonError, AddonState
|
||||
from homeassistant.components.homeassistant_hardware import silabs_multiprotocol_addon
|
||||
from homeassistant.components.homeassistant_hardware.firmware_config_flow import (
|
||||
ZigbeeFlowStrategy,
|
||||
)
|
||||
from homeassistant.components.homeassistant_yellow import hardware as yellow_hardware
|
||||
from homeassistant.config_entries import (
|
||||
SOURCE_IGNORE,
|
||||
@@ -163,6 +166,7 @@ async def list_serial_ports(hass: HomeAssistant) -> list[ListPortInfo]:
|
||||
class BaseZhaFlow(ConfigEntryBaseFlow):
|
||||
"""Mixin for common ZHA flow steps and forms."""
|
||||
|
||||
_flow_strategy: ZigbeeFlowStrategy | None = None
|
||||
_hass: HomeAssistant
|
||||
_title: str
|
||||
|
||||
@@ -373,6 +377,12 @@ class BaseZhaFlow(ConfigEntryBaseFlow):
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> ConfigFlowResult:
|
||||
"""Choose how to set up the integration from scratch."""
|
||||
if self._flow_strategy == ZigbeeFlowStrategy.RECOMMENDED:
|
||||
# Fast path: automatically form a new network
|
||||
return await self.async_step_setup_strategy_recommended()
|
||||
if self._flow_strategy == ZigbeeFlowStrategy.ADVANCED:
|
||||
# Advanced path: let the user choose
|
||||
return await self.async_step_setup_strategy_advanced()
|
||||
|
||||
# Allow onboarding for new users to just create a new network automatically
|
||||
if (
|
||||
@@ -406,6 +416,12 @@ class BaseZhaFlow(ConfigEntryBaseFlow):
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> ConfigFlowResult:
|
||||
"""Choose how to deal with the current radio's settings during migration."""
|
||||
if self._flow_strategy == ZigbeeFlowStrategy.RECOMMENDED:
|
||||
# Fast path: automatically migrate everything
|
||||
return await self.async_step_migration_strategy_recommended()
|
||||
if self._flow_strategy == ZigbeeFlowStrategy.ADVANCED:
|
||||
# Advanced path: let the user choose
|
||||
return await self.async_step_migration_strategy_advanced()
|
||||
return self.async_show_menu(
|
||||
step_id="choose_migration_strategy",
|
||||
menu_options=[
|
||||
@@ -867,6 +883,7 @@ class ZhaConfigFlowHandler(BaseZhaFlow, ConfigFlow, domain=DOMAIN):
|
||||
radio_type = self._radio_mgr.parse_radio_type(discovery_data["radio_type"])
|
||||
device_settings = discovery_data["port"]
|
||||
device_path = device_settings[CONF_DEVICE_PATH]
|
||||
self._flow_strategy = discovery_data.get("flow_strategy")
|
||||
|
||||
await self._set_unique_id_and_update_ignored_flow(
|
||||
unique_id=f"{name}_{radio_type.name}_{device_path}",
|
||||
|
@@ -28,6 +28,9 @@ from zigpy.exceptions import NetworkNotFormed
|
||||
|
||||
from homeassistant import config_entries
|
||||
from homeassistant.components import usb
|
||||
from homeassistant.components.homeassistant_hardware.firmware_config_flow import (
|
||||
ZigbeeFlowStrategy,
|
||||
)
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
from homeassistant.helpers.service_info.usb import UsbServiceInfo
|
||||
@@ -74,6 +77,7 @@ HARDWARE_DISCOVERY_SCHEMA = vol.Schema(
|
||||
vol.Required("name"): str,
|
||||
vol.Required("port"): DEVICE_SCHEMA,
|
||||
vol.Required("radio_type"): str,
|
||||
vol.Optional("flow_strategy"): vol.All(str, vol.Coerce(ZigbeeFlowStrategy)),
|
||||
}
|
||||
)
|
||||
|
||||
|
@@ -65,8 +65,8 @@
|
||||
}
|
||||
},
|
||||
"maybe_reset_old_radio": {
|
||||
"title": "Resetting old radio",
|
||||
"description": "A backup was created earlier and your old radio is being reset as part of the migration."
|
||||
"title": "Resetting old adapter",
|
||||
"description": "A backup was created earlier and your old adapter is being reset as part of the migration."
|
||||
},
|
||||
"choose_formation_strategy": {
|
||||
"title": "Network formation",
|
||||
@@ -135,21 +135,21 @@
|
||||
"title": "Migrate or re-configure",
|
||||
"description": "Are you migrating to a new radio or re-configuring the current radio?",
|
||||
"menu_options": {
|
||||
"intent_migrate": "Migrate to a new radio",
|
||||
"intent_reconfigure": "Re-configure the current radio"
|
||||
"intent_migrate": "Migrate to a new adapter",
|
||||
"intent_reconfigure": "Re-configure the current adapter"
|
||||
},
|
||||
"menu_option_descriptions": {
|
||||
"intent_migrate": "This will help you migrate your Zigbee network from your old radio to a new one.",
|
||||
"intent_reconfigure": "This will let you change the serial port for your current Zigbee radio."
|
||||
"intent_migrate": "This will help you migrate your Zigbee network from your old adapter to a new one.",
|
||||
"intent_reconfigure": "This will let you change the serial port for your current Zigbee adapter."
|
||||
}
|
||||
},
|
||||
"intent_migrate": {
|
||||
"title": "[%key:component::zha::options::step::prompt_migrate_or_reconfigure::menu_options::intent_migrate%]",
|
||||
"description": "Before plugging in your new radio, your old radio needs to be reset. An automatic backup will be performed. If you are using a combined Z-Wave and Zigbee adapter like the HUSBZB-1, this will only reset the Zigbee portion.\n\n*Note: if you are migrating from a **ConBee/RaspBee**, make sure it is running firmware `0x26720700` or newer! Otherwise, some devices may not be controllable after migrating until they are power cycled.*\n\nDo you wish to continue?"
|
||||
"description": "Before plugging in your new adapter, your old adapter needs to be reset. An automatic backup will be performed. If you are using a combined Z-Wave and Zigbee adapter like the HUSBZB-1, this will only reset the Zigbee portion.\n\n*Note: if you are migrating from a **ConBee/RaspBee**, make sure it is running firmware `0x26720700` or newer! Otherwise, some devices may not be controllable after migrating until they are power cycled.*\n\nDo you wish to continue?"
|
||||
},
|
||||
"instruct_unplug": {
|
||||
"title": "Unplug your old radio",
|
||||
"description": "Your old radio has been reset. If the hardware is no longer needed, you can now unplug it.\n\nYou can now plug in your new radio."
|
||||
"title": "Unplug your old adapter",
|
||||
"description": "Your old adapter has been reset. If the hardware is no longer needed, you can now unplug it.\n\nYou can now plug in your new adapter."
|
||||
},
|
||||
"choose_serial_port": {
|
||||
"title": "[%key:component::zha::config::step::choose_serial_port::title%]",
|
||||
|
@@ -3346,7 +3346,7 @@
|
||||
"iot_class": "local_push"
|
||||
},
|
||||
"konnected": {
|
||||
"name": "Konnected.io",
|
||||
"name": "Konnected.io (Legacy)",
|
||||
"integration_type": "hub",
|
||||
"config_flow": true,
|
||||
"iot_class": "local_push"
|
||||
|
@@ -685,9 +685,6 @@ async def async_get_all_descriptions(
|
||||
|
||||
description = {"fields": yaml_description.get("fields", {})}
|
||||
|
||||
if (target := yaml_description.get("target")) is not None:
|
||||
description["target"] = target
|
||||
|
||||
new_descriptions_cache[missing_trigger] = description
|
||||
|
||||
hass.data[TRIGGER_DESCRIPTION_CACHE] = new_descriptions_cache
|
||||
|
134
homeassistant/util/async_iterator.py
Normal file
134
homeassistant/util/async_iterator.py
Normal file
@@ -0,0 +1,134 @@
|
||||
"""Async iterator utilities."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from collections.abc import AsyncIterator
|
||||
from concurrent.futures import CancelledError, Future
|
||||
from typing import Self
|
||||
|
||||
|
||||
class Abort(Exception):
|
||||
"""Raised when abort is requested."""
|
||||
|
||||
|
||||
class AsyncIteratorReader:
|
||||
"""Allow reading from an AsyncIterator using blocking I/O.
|
||||
|
||||
The class implements a blocking read method reading from the async iterator,
|
||||
and a close method.
|
||||
|
||||
In addition, the abort method can be used to abort any ongoing read operation.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
loop: asyncio.AbstractEventLoop,
|
||||
stream: AsyncIterator[bytes],
|
||||
) -> None:
|
||||
"""Initialize the wrapper."""
|
||||
self._aborted = False
|
||||
self._loop = loop
|
||||
self._stream = stream
|
||||
self._buffer: bytes | None = None
|
||||
self._next_future: Future[bytes | None] | None = None
|
||||
self._pos: int = 0
|
||||
|
||||
async def _next(self) -> bytes | None:
|
||||
"""Get the next chunk from the iterator."""
|
||||
return await anext(self._stream, None)
|
||||
|
||||
def abort(self) -> None:
|
||||
"""Abort the reader."""
|
||||
self._aborted = True
|
||||
if self._next_future is not None:
|
||||
self._next_future.cancel()
|
||||
|
||||
def read(self, n: int = -1, /) -> bytes:
|
||||
"""Read up to n bytes of data from the iterator.
|
||||
|
||||
The read method returns 0 bytes when the iterator is exhausted.
|
||||
"""
|
||||
result = bytearray()
|
||||
while n < 0 or len(result) < n:
|
||||
if not self._buffer:
|
||||
self._next_future = asyncio.run_coroutine_threadsafe(
|
||||
self._next(), self._loop
|
||||
)
|
||||
if self._aborted:
|
||||
self._next_future.cancel()
|
||||
raise Abort
|
||||
try:
|
||||
self._buffer = self._next_future.result()
|
||||
except CancelledError as err:
|
||||
raise Abort from err
|
||||
self._pos = 0
|
||||
if not self._buffer:
|
||||
# The stream is exhausted
|
||||
break
|
||||
chunk = self._buffer[self._pos : self._pos + n]
|
||||
result.extend(chunk)
|
||||
n -= len(chunk)
|
||||
self._pos += len(chunk)
|
||||
if self._pos == len(self._buffer):
|
||||
self._buffer = None
|
||||
return bytes(result)
|
||||
|
||||
def close(self) -> None:
|
||||
"""Close the iterator."""
|
||||
|
||||
|
||||
class AsyncIteratorWriter:
|
||||
"""Allow writing to an AsyncIterator using blocking I/O.
|
||||
|
||||
The class implements a blocking write method writing to the async iterator,
|
||||
as well as a close and tell methods.
|
||||
|
||||
In addition, the abort method can be used to abort any ongoing write operation.
|
||||
"""
|
||||
|
||||
def __init__(self, loop: asyncio.AbstractEventLoop) -> None:
|
||||
"""Initialize the wrapper."""
|
||||
self._aborted = False
|
||||
self._loop = loop
|
||||
self._pos: int = 0
|
||||
self._queue: asyncio.Queue[bytes | None] = asyncio.Queue(maxsize=1)
|
||||
self._write_future: Future[bytes | None] | None = None
|
||||
|
||||
def __aiter__(self) -> Self:
|
||||
"""Return the iterator."""
|
||||
return self
|
||||
|
||||
async def __anext__(self) -> bytes:
|
||||
"""Get the next chunk from the iterator."""
|
||||
if data := await self._queue.get():
|
||||
return data
|
||||
raise StopAsyncIteration
|
||||
|
||||
def abort(self) -> None:
|
||||
"""Abort the writer."""
|
||||
self._aborted = True
|
||||
if self._write_future is not None:
|
||||
self._write_future.cancel()
|
||||
|
||||
def tell(self) -> int:
|
||||
"""Return the current position in the iterator."""
|
||||
return self._pos
|
||||
|
||||
def write(self, s: bytes, /) -> int:
|
||||
"""Write data to the iterator.
|
||||
|
||||
To signal the end of the stream, write a zero-length bytes object.
|
||||
"""
|
||||
self._write_future = asyncio.run_coroutine_threadsafe(
|
||||
self._queue.put(s), self._loop
|
||||
)
|
||||
if self._aborted:
|
||||
self._write_future.cancel()
|
||||
raise Abort
|
||||
try:
|
||||
self._write_future.result()
|
||||
except CancelledError as err:
|
||||
raise Abort from err
|
||||
self._pos += len(s)
|
||||
return len(s)
|
2
requirements_all.txt
generated
2
requirements_all.txt
generated
@@ -2390,7 +2390,7 @@ pysmartthings==3.3.0
|
||||
pysmarty2==0.10.3
|
||||
|
||||
# homeassistant.components.smhi
|
||||
pysmhi==1.0.2
|
||||
pysmhi==1.1.0
|
||||
|
||||
# homeassistant.components.edl21
|
||||
pysml==0.1.5
|
||||
|
2
requirements_test_all.txt
generated
2
requirements_test_all.txt
generated
@@ -1993,7 +1993,7 @@ pysmartthings==3.3.0
|
||||
pysmarty2==0.10.3
|
||||
|
||||
# homeassistant.components.smhi
|
||||
pysmhi==1.0.2
|
||||
pysmhi==1.1.0
|
||||
|
||||
# homeassistant.components.edl21
|
||||
pysml==0.1.5
|
||||
|
@@ -137,17 +137,46 @@ async def test_form_exception_handling(
|
||||
assert len(mock_setup_entry.mock_calls) == 1
|
||||
|
||||
|
||||
async def test_reauth_flow_scenario(
|
||||
hass: HomeAssistant,
|
||||
mock_airos_client: AsyncMock,
|
||||
mock_config_entry: MockConfigEntry,
|
||||
) -> None:
|
||||
"""Test successful reauthentication."""
|
||||
mock_config_entry.add_to_hass(hass)
|
||||
|
||||
mock_airos_client.login.side_effect = AirOSConnectionAuthenticationError
|
||||
await hass.config_entries.async_setup(mock_config_entry.entry_id)
|
||||
|
||||
flows = hass.config_entries.flow.async_progress()
|
||||
assert len(flows) == 1
|
||||
|
||||
flow = flows[0]
|
||||
assert flow["step_id"] == REAUTH_STEP
|
||||
|
||||
mock_airos_client.login.side_effect = None
|
||||
result = await hass.config_entries.flow.async_configure(
|
||||
flow["flow_id"],
|
||||
user_input={CONF_PASSWORD: NEW_PASSWORD},
|
||||
)
|
||||
|
||||
# Always test resolution
|
||||
assert result["type"] is FlowResultType.ABORT
|
||||
assert result["reason"] == "reauth_successful"
|
||||
|
||||
updated_entry = hass.config_entries.async_get_entry(mock_config_entry.entry_id)
|
||||
assert updated_entry.data[CONF_PASSWORD] == NEW_PASSWORD
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("reauth_exception", "expected_error"),
|
||||
[
|
||||
(None, None),
|
||||
(AirOSConnectionAuthenticationError, "invalid_auth"),
|
||||
(AirOSDeviceConnectionError, "cannot_connect"),
|
||||
(AirOSKeyDataMissingError, "key_data_missing"),
|
||||
(Exception, "unknown"),
|
||||
],
|
||||
ids=[
|
||||
"reauth_succes",
|
||||
"invalid_auth",
|
||||
"cannot_connect",
|
||||
"key_data_missing",
|
||||
@@ -180,19 +209,16 @@ async def test_reauth_flow_scenarios(
|
||||
user_input={CONF_PASSWORD: NEW_PASSWORD},
|
||||
)
|
||||
|
||||
if expected_error:
|
||||
assert result["type"] is FlowResultType.FORM
|
||||
assert result["step_id"] == REAUTH_STEP
|
||||
assert result["errors"] == {"base": expected_error}
|
||||
assert result["type"] is FlowResultType.FORM
|
||||
assert result["step_id"] == REAUTH_STEP
|
||||
assert result["errors"] == {"base": expected_error}
|
||||
|
||||
# Retry
|
||||
mock_airos_client.login.side_effect = None
|
||||
result = await hass.config_entries.flow.async_configure(
|
||||
flow["flow_id"],
|
||||
user_input={CONF_PASSWORD: NEW_PASSWORD},
|
||||
)
|
||||
mock_airos_client.login.side_effect = None
|
||||
result = await hass.config_entries.flow.async_configure(
|
||||
flow["flow_id"],
|
||||
user_input={CONF_PASSWORD: NEW_PASSWORD},
|
||||
)
|
||||
|
||||
# Always test resolution
|
||||
assert result["type"] is FlowResultType.ABORT
|
||||
assert result["reason"] == "reauth_successful"
|
||||
|
||||
|
@@ -364,8 +364,8 @@ async def consume_progress_flow(
|
||||
return result
|
||||
|
||||
|
||||
async def test_config_flow_recommended(hass: HomeAssistant) -> None:
|
||||
"""Test the config flow with recommended installation type for Zigbee."""
|
||||
async def test_config_flow_zigbee_recommended(hass: HomeAssistant) -> None:
|
||||
"""Test flow with recommended Zigbee installation type."""
|
||||
init_result = await hass.config_entries.flow.async_init(
|
||||
TEST_DOMAIN, context={"source": "hardware"}
|
||||
)
|
||||
@@ -418,37 +418,28 @@ async def test_config_flow_recommended(hass: HomeAssistant) -> None:
|
||||
assert zha_flow["context"]["source"] == "hardware"
|
||||
assert zha_flow["step_id"] == "confirm"
|
||||
|
||||
progress_zha_flows = hass.config_entries.flow._async_progress_by_handler(
|
||||
handler="zha",
|
||||
match_context=None,
|
||||
)
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("zigbee_integration", "zha_flows"),
|
||||
[
|
||||
(
|
||||
"zigbee_integration_zha",
|
||||
[
|
||||
{
|
||||
"context": {
|
||||
"confirm_only": True,
|
||||
"source": "hardware",
|
||||
"title_placeholders": {
|
||||
"name": "Some Hardware Name",
|
||||
},
|
||||
"unique_id": "Some Hardware Name_ezsp_/dev/SomeDevice123",
|
||||
},
|
||||
"flow_id": ANY,
|
||||
"handler": "zha",
|
||||
"step_id": "confirm",
|
||||
}
|
||||
],
|
||||
),
|
||||
("zigbee_integration_other", []),
|
||||
],
|
||||
)
|
||||
async def test_config_flow_zigbee_custom(
|
||||
hass: HomeAssistant,
|
||||
zigbee_integration: str,
|
||||
zha_flows: list[ConfigFlowResult],
|
||||
) -> None:
|
||||
"""Test the config flow with custom installation type selected for Zigbee."""
|
||||
assert len(progress_zha_flows) == 1
|
||||
|
||||
progress_zha_flow = progress_zha_flows[0]
|
||||
assert progress_zha_flow.init_data == {
|
||||
"name": "Some Hardware Name",
|
||||
"port": {
|
||||
"path": "/dev/SomeDevice123",
|
||||
"baudrate": 115200,
|
||||
"flow_control": "hardware",
|
||||
},
|
||||
"radio_type": "ezsp",
|
||||
"flow_strategy": "recommended",
|
||||
}
|
||||
|
||||
|
||||
async def test_config_flow_zigbee_custom_zha(hass: HomeAssistant) -> None:
|
||||
"""Test flow with custom Zigbee installation type and ZHA selected."""
|
||||
init_result = await hass.config_entries.flow.async_init(
|
||||
TEST_DOMAIN, context={"source": "hardware"}
|
||||
)
|
||||
@@ -479,7 +470,7 @@ async def test_config_flow_zigbee_custom(
|
||||
|
||||
pick_result = await hass.config_entries.flow.async_configure(
|
||||
pick_result["flow_id"],
|
||||
user_input={"next_step_id": zigbee_integration},
|
||||
user_input={"next_step_id": "zigbee_integration_zha"},
|
||||
)
|
||||
|
||||
assert pick_result["type"] is FlowResultType.SHOW_PROGRESS
|
||||
@@ -503,7 +494,98 @@ async def test_config_flow_zigbee_custom(
|
||||
|
||||
# Ensure a ZHA discovery flow has been created
|
||||
flows = hass.config_entries.flow.async_progress()
|
||||
assert flows == zha_flows
|
||||
assert flows == [
|
||||
{
|
||||
"context": {
|
||||
"confirm_only": True,
|
||||
"source": "hardware",
|
||||
"title_placeholders": {
|
||||
"name": "Some Hardware Name",
|
||||
},
|
||||
"unique_id": "Some Hardware Name_ezsp_/dev/SomeDevice123",
|
||||
},
|
||||
"flow_id": ANY,
|
||||
"handler": "zha",
|
||||
"step_id": "confirm",
|
||||
}
|
||||
]
|
||||
|
||||
progress_zha_flows = hass.config_entries.flow._async_progress_by_handler(
|
||||
handler="zha",
|
||||
match_context=None,
|
||||
)
|
||||
|
||||
assert len(progress_zha_flows) == 1
|
||||
|
||||
progress_zha_flow = progress_zha_flows[0]
|
||||
assert progress_zha_flow.init_data == {
|
||||
"name": "Some Hardware Name",
|
||||
"port": {
|
||||
"path": "/dev/SomeDevice123",
|
||||
"baudrate": 115200,
|
||||
"flow_control": "hardware",
|
||||
},
|
||||
"radio_type": "ezsp",
|
||||
"flow_strategy": "advanced",
|
||||
}
|
||||
|
||||
|
||||
async def test_config_flow_zigbee_custom_other(hass: HomeAssistant) -> None:
|
||||
"""Test flow with custom Zigbee installation type and Other selected."""
|
||||
init_result = await hass.config_entries.flow.async_init(
|
||||
TEST_DOMAIN, context={"source": "hardware"}
|
||||
)
|
||||
|
||||
assert init_result["type"] is FlowResultType.MENU
|
||||
assert init_result["step_id"] == "pick_firmware"
|
||||
|
||||
with mock_firmware_info(
|
||||
probe_app_type=ApplicationType.SPINEL,
|
||||
flash_app_type=ApplicationType.EZSP,
|
||||
):
|
||||
# Pick the menu option: we are flashing the firmware
|
||||
pick_result = await hass.config_entries.flow.async_configure(
|
||||
init_result["flow_id"],
|
||||
user_input={"next_step_id": STEP_PICK_FIRMWARE_ZIGBEE},
|
||||
)
|
||||
|
||||
assert pick_result["type"] is FlowResultType.MENU
|
||||
assert pick_result["step_id"] == "zigbee_installation_type"
|
||||
|
||||
pick_result = await hass.config_entries.flow.async_configure(
|
||||
pick_result["flow_id"],
|
||||
user_input={"next_step_id": "zigbee_intent_custom"},
|
||||
)
|
||||
|
||||
assert pick_result["type"] is FlowResultType.MENU
|
||||
assert pick_result["step_id"] == "zigbee_integration"
|
||||
|
||||
pick_result = await hass.config_entries.flow.async_configure(
|
||||
pick_result["flow_id"],
|
||||
user_input={"next_step_id": "zigbee_integration_other"},
|
||||
)
|
||||
|
||||
assert pick_result["type"] is FlowResultType.SHOW_PROGRESS
|
||||
assert pick_result["progress_action"] == "install_firmware"
|
||||
assert pick_result["step_id"] == "install_zigbee_firmware"
|
||||
|
||||
create_result = await consume_progress_flow(
|
||||
hass,
|
||||
flow_id=pick_result["flow_id"],
|
||||
valid_step_ids=("install_zigbee_firmware",),
|
||||
)
|
||||
|
||||
assert create_result["type"] is FlowResultType.CREATE_ENTRY
|
||||
|
||||
config_entry = create_result["result"]
|
||||
assert config_entry.data == {
|
||||
"firmware": "ezsp",
|
||||
"device": TEST_DEVICE,
|
||||
"hardware": TEST_HARDWARE_NAME,
|
||||
}
|
||||
|
||||
flows = hass.config_entries.flow.async_progress()
|
||||
assert flows == []
|
||||
|
||||
|
||||
async def test_config_flow_firmware_index_download_fails_but_not_required(
|
||||
|
@@ -1,283 +0,0 @@
|
||||
"""Test light trigger."""
|
||||
|
||||
import pytest
|
||||
|
||||
from homeassistant.components import automation
|
||||
from homeassistant.const import (
|
||||
ATTR_AREA_ID,
|
||||
ATTR_DEVICE_ID,
|
||||
ATTR_FLOOR_ID,
|
||||
ATTR_LABEL_ID,
|
||||
CONF_ENTITY_ID,
|
||||
CONF_PLATFORM,
|
||||
CONF_STATE,
|
||||
CONF_TARGET,
|
||||
STATE_OFF,
|
||||
STATE_ON,
|
||||
)
|
||||
from homeassistant.core import HomeAssistant, ServiceCall
|
||||
from homeassistant.helpers import (
|
||||
area_registry as ar,
|
||||
device_registry as dr,
|
||||
entity_registry as er,
|
||||
floor_registry as fr,
|
||||
label_registry as lr,
|
||||
)
|
||||
from homeassistant.setup import async_setup_component
|
||||
|
||||
from tests.common import MockConfigEntry, mock_device_registry
|
||||
|
||||
# remove when #151314 is merged
|
||||
CONF_OPTIONS = "options"
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True, name="stub_blueprint_populate")
|
||||
def stub_blueprint_populate_autouse(stub_blueprint_populate: None) -> None:
|
||||
"""Stub copying the blueprints to the config folder."""
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def target_lights(hass: HomeAssistant) -> None:
|
||||
"""Create multiple light entities associated with different targets."""
|
||||
await async_setup_component(hass, "light", {})
|
||||
|
||||
config_entry = MockConfigEntry(domain="test")
|
||||
config_entry.add_to_hass(hass)
|
||||
|
||||
floor_reg = fr.async_get(hass)
|
||||
floor = floor_reg.async_create("Test Floor")
|
||||
|
||||
area_reg = ar.async_get(hass)
|
||||
area = area_reg.async_create("Test Area", floor_id=floor.floor_id)
|
||||
|
||||
label_reg = lr.async_get(hass)
|
||||
label = label_reg.async_create("Test Label")
|
||||
|
||||
device = dr.DeviceEntry(id="test_device", area_id=area.id, labels={label.label_id})
|
||||
mock_device_registry(hass, {device.id: device})
|
||||
|
||||
entity_reg = er.async_get(hass)
|
||||
# Light associated with area
|
||||
light_area = entity_reg.async_get_or_create(
|
||||
domain="light",
|
||||
platform="test",
|
||||
unique_id="light_area",
|
||||
suggested_object_id="area_light",
|
||||
)
|
||||
entity_reg.async_update_entity(light_area.entity_id, area_id=area.id)
|
||||
|
||||
# Light associated with device
|
||||
entity_reg.async_get_or_create(
|
||||
domain="light",
|
||||
platform="test",
|
||||
unique_id="light_device",
|
||||
suggested_object_id="device_light",
|
||||
device_id=device.id,
|
||||
)
|
||||
|
||||
# Light associated with label
|
||||
light_label = entity_reg.async_get_or_create(
|
||||
domain="light",
|
||||
platform="test",
|
||||
unique_id="light_label",
|
||||
suggested_object_id="label_light",
|
||||
)
|
||||
entity_reg.async_update_entity(light_label.entity_id, labels={label.label_id})
|
||||
|
||||
# Return all available light entities
|
||||
return [
|
||||
"light.standalone_light",
|
||||
"light.label_light",
|
||||
"light.area_light",
|
||||
"light.device_light",
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("target_lights")
|
||||
@pytest.mark.parametrize(
|
||||
("trigger_target_config", "entity_id"),
|
||||
[
|
||||
({CONF_ENTITY_ID: "light.standalone_light"}, "light.standalone_light"),
|
||||
({ATTR_LABEL_ID: "test_label"}, "light.label_light"),
|
||||
({ATTR_AREA_ID: "test_area"}, "light.area_light"),
|
||||
({ATTR_FLOOR_ID: "test_floor"}, "light.area_light"),
|
||||
({ATTR_LABEL_ID: "test_label"}, "light.device_light"),
|
||||
({ATTR_AREA_ID: "test_area"}, "light.device_light"),
|
||||
({ATTR_FLOOR_ID: "test_floor"}, "light.device_light"),
|
||||
({ATTR_DEVICE_ID: "test_device"}, "light.device_light"),
|
||||
],
|
||||
)
|
||||
@pytest.mark.parametrize(
|
||||
("state", "reverse_state"), [(STATE_ON, STATE_OFF), (STATE_OFF, STATE_ON)]
|
||||
)
|
||||
async def test_light_state_trigger_behavior_any(
|
||||
hass: HomeAssistant,
|
||||
service_calls: list[ServiceCall],
|
||||
trigger_target_config: dict,
|
||||
entity_id: str,
|
||||
state: str,
|
||||
reverse_state: str,
|
||||
) -> None:
|
||||
"""Test that the light state trigger fires when any light state changes to a specific state."""
|
||||
await async_setup_component(hass, "light", {})
|
||||
|
||||
hass.states.async_set(entity_id, reverse_state)
|
||||
|
||||
await async_setup_component(
|
||||
hass,
|
||||
automation.DOMAIN,
|
||||
{
|
||||
automation.DOMAIN: {
|
||||
"trigger": {
|
||||
CONF_PLATFORM: "light.state",
|
||||
CONF_TARGET: {**trigger_target_config},
|
||||
CONF_OPTIONS: {CONF_STATE: state},
|
||||
},
|
||||
"action": {
|
||||
"service": "test.automation",
|
||||
"data_template": {CONF_ENTITY_ID: f"{entity_id}"},
|
||||
},
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
hass.states.async_set(entity_id, state)
|
||||
await hass.async_block_till_done()
|
||||
assert len(service_calls) == 1
|
||||
assert service_calls[0].data[CONF_ENTITY_ID] == entity_id
|
||||
service_calls.clear()
|
||||
|
||||
hass.states.async_set(entity_id, reverse_state)
|
||||
await hass.async_block_till_done()
|
||||
assert len(service_calls) == 0
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("trigger_target_config", "entity_id"),
|
||||
[
|
||||
({CONF_ENTITY_ID: "light.standalone_light"}, "light.standalone_light"),
|
||||
({ATTR_LABEL_ID: "test_label"}, "light.label_light"),
|
||||
({ATTR_AREA_ID: "test_area"}, "light.area_light"),
|
||||
({ATTR_FLOOR_ID: "test_floor"}, "light.area_light"),
|
||||
({ATTR_LABEL_ID: "test_label"}, "light.device_light"),
|
||||
({ATTR_AREA_ID: "test_area"}, "light.device_light"),
|
||||
({ATTR_FLOOR_ID: "test_floor"}, "light.device_light"),
|
||||
({ATTR_DEVICE_ID: "test_device"}, "light.device_light"),
|
||||
],
|
||||
)
|
||||
@pytest.mark.parametrize(
|
||||
("state", "reverse_state"), [(STATE_ON, STATE_OFF), (STATE_OFF, STATE_ON)]
|
||||
)
|
||||
async def test_light_state_trigger_behavior_first(
|
||||
hass: HomeAssistant,
|
||||
service_calls: list[ServiceCall],
|
||||
target_lights: list[str],
|
||||
trigger_target_config: dict,
|
||||
entity_id: str,
|
||||
state: str,
|
||||
reverse_state: str,
|
||||
) -> None:
|
||||
"""Test that the light state trigger fires when the first light changes to a specific state."""
|
||||
await async_setup_component(hass, "light", {})
|
||||
|
||||
for other_entity_id in target_lights:
|
||||
hass.states.async_set(other_entity_id, reverse_state)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
await async_setup_component(
|
||||
hass,
|
||||
automation.DOMAIN,
|
||||
{
|
||||
automation.DOMAIN: {
|
||||
"trigger": {
|
||||
CONF_PLATFORM: "light.state",
|
||||
CONF_TARGET: {**trigger_target_config},
|
||||
CONF_OPTIONS: {CONF_STATE: state, "behavior": "first"},
|
||||
},
|
||||
"action": {
|
||||
"service": "test.automation",
|
||||
"data_template": {CONF_ENTITY_ID: f"{entity_id}"},
|
||||
},
|
||||
}
|
||||
},
|
||||
)
|
||||
hass.states.async_set(entity_id, state)
|
||||
await hass.async_block_till_done()
|
||||
assert len(service_calls) == 1
|
||||
assert service_calls[0].data[CONF_ENTITY_ID] == entity_id
|
||||
service_calls.clear()
|
||||
|
||||
# Triggering other lights should not cause any service calls after the first one
|
||||
for other_entity_id in target_lights:
|
||||
hass.states.async_set(other_entity_id, state)
|
||||
await hass.async_block_till_done()
|
||||
for other_entity_id in target_lights:
|
||||
hass.states.async_set(other_entity_id, reverse_state)
|
||||
await hass.async_block_till_done()
|
||||
assert len(service_calls) == 0
|
||||
|
||||
hass.states.async_set(entity_id, state)
|
||||
await hass.async_block_till_done()
|
||||
assert len(service_calls) == 1
|
||||
assert service_calls[0].data[CONF_ENTITY_ID] == entity_id
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("trigger_target_config", "entity_id"),
|
||||
[
|
||||
({CONF_ENTITY_ID: "light.standalone_light"}, "light.standalone_light"),
|
||||
({ATTR_LABEL_ID: "test_label"}, "light.label_light"),
|
||||
({ATTR_AREA_ID: "test_area"}, "light.area_light"),
|
||||
({ATTR_FLOOR_ID: "test_floor"}, "light.area_light"),
|
||||
({ATTR_LABEL_ID: "test_label"}, "light.device_light"),
|
||||
({ATTR_AREA_ID: "test_area"}, "light.device_light"),
|
||||
({ATTR_FLOOR_ID: "test_floor"}, "light.device_light"),
|
||||
({ATTR_DEVICE_ID: "test_device"}, "light.device_light"),
|
||||
],
|
||||
)
|
||||
@pytest.mark.parametrize(
|
||||
("state", "reverse_state"), [(STATE_ON, STATE_OFF), (STATE_OFF, STATE_ON)]
|
||||
)
|
||||
async def test_light_state_trigger_behavior_last(
|
||||
hass: HomeAssistant,
|
||||
service_calls: list[ServiceCall],
|
||||
target_lights: list[str],
|
||||
trigger_target_config: dict,
|
||||
entity_id: str,
|
||||
state: str,
|
||||
reverse_state: str,
|
||||
) -> None:
|
||||
"""Test that the light state trigger fires when the last light changes to a specific state."""
|
||||
await async_setup_component(hass, "light", {})
|
||||
|
||||
for other_entity_id in target_lights:
|
||||
hass.states.async_set(other_entity_id, reverse_state)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
await async_setup_component(
|
||||
hass,
|
||||
automation.DOMAIN,
|
||||
{
|
||||
automation.DOMAIN: {
|
||||
"trigger": {
|
||||
CONF_PLATFORM: "light.state",
|
||||
CONF_TARGET: {**trigger_target_config},
|
||||
CONF_OPTIONS: {CONF_STATE: state, "behavior": "last"},
|
||||
},
|
||||
"action": {
|
||||
"service": "test.automation",
|
||||
"data_template": {CONF_ENTITY_ID: f"{entity_id}"},
|
||||
},
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
target_lights.remove(entity_id)
|
||||
for other_entity_id in target_lights:
|
||||
hass.states.async_set(other_entity_id, state)
|
||||
await hass.async_block_till_done()
|
||||
assert len(service_calls) == 0
|
||||
|
||||
hass.states.async_set(entity_id, state)
|
||||
await hass.async_block_till_done()
|
||||
assert len(service_calls) == 1
|
@@ -518,6 +518,121 @@
|
||||
'state': '60',
|
||||
})
|
||||
# ---
|
||||
# name: test_numbers[door_lock][number.mock_door_lock_user_code_temporary_disable_time-entry]
|
||||
EntityRegistryEntrySnapshot({
|
||||
'aliases': set({
|
||||
}),
|
||||
'area_id': None,
|
||||
'capabilities': dict({
|
||||
'max': 255,
|
||||
'min': 1,
|
||||
'mode': <NumberMode.BOX: 'box'>,
|
||||
'step': 1,
|
||||
}),
|
||||
'config_entry_id': <ANY>,
|
||||
'config_subentry_id': <ANY>,
|
||||
'device_class': None,
|
||||
'device_id': <ANY>,
|
||||
'disabled_by': None,
|
||||
'domain': 'number',
|
||||
'entity_category': <EntityCategory.CONFIG: 'config'>,
|
||||
'entity_id': 'number.mock_door_lock_user_code_temporary_disable_time',
|
||||
'has_entity_name': True,
|
||||
'hidden_by': None,
|
||||
'icon': None,
|
||||
'id': <ANY>,
|
||||
'labels': set({
|
||||
}),
|
||||
'name': None,
|
||||
'options': dict({
|
||||
}),
|
||||
'original_device_class': None,
|
||||
'original_icon': None,
|
||||
'original_name': 'User code temporary disable time',
|
||||
'platform': 'matter',
|
||||
'previous_unique_id': None,
|
||||
'suggested_object_id': None,
|
||||
'supported_features': 0,
|
||||
'translation_key': 'user_code_temporary_disable_time',
|
||||
'unique_id': '00000000000004D2-0000000000000001-MatterNodeDevice-1-DoorLockUserCodeTemporaryDisableTime-257-49',
|
||||
'unit_of_measurement': <UnitOfTime.SECONDS: 's'>,
|
||||
})
|
||||
# ---
|
||||
# name: test_numbers[door_lock][number.mock_door_lock_user_code_temporary_disable_time-state]
|
||||
StateSnapshot({
|
||||
'attributes': ReadOnlyDict({
|
||||
'friendly_name': 'Mock Door Lock User code temporary disable time',
|
||||
'max': 255,
|
||||
'min': 1,
|
||||
'mode': <NumberMode.BOX: 'box'>,
|
||||
'step': 1,
|
||||
'unit_of_measurement': <UnitOfTime.SECONDS: 's'>,
|
||||
}),
|
||||
'context': <ANY>,
|
||||
'entity_id': 'number.mock_door_lock_user_code_temporary_disable_time',
|
||||
'last_changed': <ANY>,
|
||||
'last_reported': <ANY>,
|
||||
'last_updated': <ANY>,
|
||||
'state': '10',
|
||||
})
|
||||
# ---
|
||||
# name: test_numbers[door_lock][number.mock_door_lock_wrong_code_limit-entry]
|
||||
EntityRegistryEntrySnapshot({
|
||||
'aliases': set({
|
||||
}),
|
||||
'area_id': None,
|
||||
'capabilities': dict({
|
||||
'max': 255,
|
||||
'min': 1,
|
||||
'mode': <NumberMode.BOX: 'box'>,
|
||||
'step': 1,
|
||||
}),
|
||||
'config_entry_id': <ANY>,
|
||||
'config_subentry_id': <ANY>,
|
||||
'device_class': None,
|
||||
'device_id': <ANY>,
|
||||
'disabled_by': None,
|
||||
'domain': 'number',
|
||||
'entity_category': <EntityCategory.CONFIG: 'config'>,
|
||||
'entity_id': 'number.mock_door_lock_wrong_code_limit',
|
||||
'has_entity_name': True,
|
||||
'hidden_by': None,
|
||||
'icon': None,
|
||||
'id': <ANY>,
|
||||
'labels': set({
|
||||
}),
|
||||
'name': None,
|
||||
'options': dict({
|
||||
}),
|
||||
'original_device_class': None,
|
||||
'original_icon': None,
|
||||
'original_name': 'Wrong code limit',
|
||||
'platform': 'matter',
|
||||
'previous_unique_id': None,
|
||||
'suggested_object_id': None,
|
||||
'supported_features': 0,
|
||||
'translation_key': 'wrong_code_entry_limit',
|
||||
'unique_id': '00000000000004D2-0000000000000001-MatterNodeDevice-1-DoorLockWrongCodeEntryLimit-257-48',
|
||||
'unit_of_measurement': None,
|
||||
})
|
||||
# ---
|
||||
# name: test_numbers[door_lock][number.mock_door_lock_wrong_code_limit-state]
|
||||
StateSnapshot({
|
||||
'attributes': ReadOnlyDict({
|
||||
'friendly_name': 'Mock Door Lock Wrong code limit',
|
||||
'max': 255,
|
||||
'min': 1,
|
||||
'mode': <NumberMode.BOX: 'box'>,
|
||||
'step': 1,
|
||||
}),
|
||||
'context': <ANY>,
|
||||
'entity_id': 'number.mock_door_lock_wrong_code_limit',
|
||||
'last_changed': <ANY>,
|
||||
'last_reported': <ANY>,
|
||||
'last_updated': <ANY>,
|
||||
'state': '3',
|
||||
})
|
||||
# ---
|
||||
# name: test_numbers[door_lock_with_unbolt][number.mock_door_lock_autorelock_time-entry]
|
||||
EntityRegistryEntrySnapshot({
|
||||
'aliases': set({
|
||||
@@ -576,6 +691,121 @@
|
||||
'state': '60',
|
||||
})
|
||||
# ---
|
||||
# name: test_numbers[door_lock_with_unbolt][number.mock_door_lock_user_code_temporary_disable_time-entry]
|
||||
EntityRegistryEntrySnapshot({
|
||||
'aliases': set({
|
||||
}),
|
||||
'area_id': None,
|
||||
'capabilities': dict({
|
||||
'max': 255,
|
||||
'min': 1,
|
||||
'mode': <NumberMode.BOX: 'box'>,
|
||||
'step': 1,
|
||||
}),
|
||||
'config_entry_id': <ANY>,
|
||||
'config_subentry_id': <ANY>,
|
||||
'device_class': None,
|
||||
'device_id': <ANY>,
|
||||
'disabled_by': None,
|
||||
'domain': 'number',
|
||||
'entity_category': <EntityCategory.CONFIG: 'config'>,
|
||||
'entity_id': 'number.mock_door_lock_user_code_temporary_disable_time',
|
||||
'has_entity_name': True,
|
||||
'hidden_by': None,
|
||||
'icon': None,
|
||||
'id': <ANY>,
|
||||
'labels': set({
|
||||
}),
|
||||
'name': None,
|
||||
'options': dict({
|
||||
}),
|
||||
'original_device_class': None,
|
||||
'original_icon': None,
|
||||
'original_name': 'User code temporary disable time',
|
||||
'platform': 'matter',
|
||||
'previous_unique_id': None,
|
||||
'suggested_object_id': None,
|
||||
'supported_features': 0,
|
||||
'translation_key': 'user_code_temporary_disable_time',
|
||||
'unique_id': '00000000000004D2-0000000000000001-MatterNodeDevice-1-DoorLockUserCodeTemporaryDisableTime-257-49',
|
||||
'unit_of_measurement': <UnitOfTime.SECONDS: 's'>,
|
||||
})
|
||||
# ---
|
||||
# name: test_numbers[door_lock_with_unbolt][number.mock_door_lock_user_code_temporary_disable_time-state]
|
||||
StateSnapshot({
|
||||
'attributes': ReadOnlyDict({
|
||||
'friendly_name': 'Mock Door Lock User code temporary disable time',
|
||||
'max': 255,
|
||||
'min': 1,
|
||||
'mode': <NumberMode.BOX: 'box'>,
|
||||
'step': 1,
|
||||
'unit_of_measurement': <UnitOfTime.SECONDS: 's'>,
|
||||
}),
|
||||
'context': <ANY>,
|
||||
'entity_id': 'number.mock_door_lock_user_code_temporary_disable_time',
|
||||
'last_changed': <ANY>,
|
||||
'last_reported': <ANY>,
|
||||
'last_updated': <ANY>,
|
||||
'state': '10',
|
||||
})
|
||||
# ---
|
||||
# name: test_numbers[door_lock_with_unbolt][number.mock_door_lock_wrong_code_limit-entry]
|
||||
EntityRegistryEntrySnapshot({
|
||||
'aliases': set({
|
||||
}),
|
||||
'area_id': None,
|
||||
'capabilities': dict({
|
||||
'max': 255,
|
||||
'min': 1,
|
||||
'mode': <NumberMode.BOX: 'box'>,
|
||||
'step': 1,
|
||||
}),
|
||||
'config_entry_id': <ANY>,
|
||||
'config_subentry_id': <ANY>,
|
||||
'device_class': None,
|
||||
'device_id': <ANY>,
|
||||
'disabled_by': None,
|
||||
'domain': 'number',
|
||||
'entity_category': <EntityCategory.CONFIG: 'config'>,
|
||||
'entity_id': 'number.mock_door_lock_wrong_code_limit',
|
||||
'has_entity_name': True,
|
||||
'hidden_by': None,
|
||||
'icon': None,
|
||||
'id': <ANY>,
|
||||
'labels': set({
|
||||
}),
|
||||
'name': None,
|
||||
'options': dict({
|
||||
}),
|
||||
'original_device_class': None,
|
||||
'original_icon': None,
|
||||
'original_name': 'Wrong code limit',
|
||||
'platform': 'matter',
|
||||
'previous_unique_id': None,
|
||||
'suggested_object_id': None,
|
||||
'supported_features': 0,
|
||||
'translation_key': 'wrong_code_entry_limit',
|
||||
'unique_id': '00000000000004D2-0000000000000001-MatterNodeDevice-1-DoorLockWrongCodeEntryLimit-257-48',
|
||||
'unit_of_measurement': None,
|
||||
})
|
||||
# ---
|
||||
# name: test_numbers[door_lock_with_unbolt][number.mock_door_lock_wrong_code_limit-state]
|
||||
StateSnapshot({
|
||||
'attributes': ReadOnlyDict({
|
||||
'friendly_name': 'Mock Door Lock Wrong code limit',
|
||||
'max': 255,
|
||||
'min': 1,
|
||||
'mode': <NumberMode.BOX: 'box'>,
|
||||
'step': 1,
|
||||
}),
|
||||
'context': <ANY>,
|
||||
'entity_id': 'number.mock_door_lock_wrong_code_limit',
|
||||
'last_changed': <ANY>,
|
||||
'last_reported': <ANY>,
|
||||
'last_updated': <ANY>,
|
||||
'state': '3',
|
||||
})
|
||||
# ---
|
||||
# name: test_numbers[eve_thermo][number.eve_thermo_temperature_offset-entry]
|
||||
EntityRegistryEntrySnapshot({
|
||||
'aliases': set({
|
||||
|
@@ -146,6 +146,54 @@
|
||||
'state': 'off',
|
||||
})
|
||||
# ---
|
||||
# name: test_switches[door_lock][switch.mock_door_lock_privacy_mode_button-entry]
|
||||
EntityRegistryEntrySnapshot({
|
||||
'aliases': set({
|
||||
}),
|
||||
'area_id': None,
|
||||
'capabilities': None,
|
||||
'config_entry_id': <ANY>,
|
||||
'config_subentry_id': <ANY>,
|
||||
'device_class': None,
|
||||
'device_id': <ANY>,
|
||||
'disabled_by': None,
|
||||
'domain': 'switch',
|
||||
'entity_category': <EntityCategory.CONFIG: 'config'>,
|
||||
'entity_id': 'switch.mock_door_lock_privacy_mode_button',
|
||||
'has_entity_name': True,
|
||||
'hidden_by': None,
|
||||
'icon': None,
|
||||
'id': <ANY>,
|
||||
'labels': set({
|
||||
}),
|
||||
'name': None,
|
||||
'options': dict({
|
||||
}),
|
||||
'original_device_class': None,
|
||||
'original_icon': None,
|
||||
'original_name': 'Privacy mode button',
|
||||
'platform': 'matter',
|
||||
'previous_unique_id': None,
|
||||
'suggested_object_id': None,
|
||||
'supported_features': 0,
|
||||
'translation_key': 'privacy_mode_button',
|
||||
'unique_id': '00000000000004D2-0000000000000001-MatterNodeDevice-1-DoorLockEnablePrivacyModeButton-257-43',
|
||||
'unit_of_measurement': None,
|
||||
})
|
||||
# ---
|
||||
# name: test_switches[door_lock][switch.mock_door_lock_privacy_mode_button-state]
|
||||
StateSnapshot({
|
||||
'attributes': ReadOnlyDict({
|
||||
'friendly_name': 'Mock Door Lock Privacy mode button',
|
||||
}),
|
||||
'context': <ANY>,
|
||||
'entity_id': 'switch.mock_door_lock_privacy_mode_button',
|
||||
'last_changed': <ANY>,
|
||||
'last_reported': <ANY>,
|
||||
'last_updated': <ANY>,
|
||||
'state': 'off',
|
||||
})
|
||||
# ---
|
||||
# name: test_switches[door_lock_with_unbolt][switch.mock_door_lock-entry]
|
||||
EntityRegistryEntrySnapshot({
|
||||
'aliases': set({
|
||||
@@ -195,6 +243,54 @@
|
||||
'state': 'off',
|
||||
})
|
||||
# ---
|
||||
# name: test_switches[door_lock_with_unbolt][switch.mock_door_lock_privacy_mode_button-entry]
|
||||
EntityRegistryEntrySnapshot({
|
||||
'aliases': set({
|
||||
}),
|
||||
'area_id': None,
|
||||
'capabilities': None,
|
||||
'config_entry_id': <ANY>,
|
||||
'config_subentry_id': <ANY>,
|
||||
'device_class': None,
|
||||
'device_id': <ANY>,
|
||||
'disabled_by': None,
|
||||
'domain': 'switch',
|
||||
'entity_category': <EntityCategory.CONFIG: 'config'>,
|
||||
'entity_id': 'switch.mock_door_lock_privacy_mode_button',
|
||||
'has_entity_name': True,
|
||||
'hidden_by': None,
|
||||
'icon': None,
|
||||
'id': <ANY>,
|
||||
'labels': set({
|
||||
}),
|
||||
'name': None,
|
||||
'options': dict({
|
||||
}),
|
||||
'original_device_class': None,
|
||||
'original_icon': None,
|
||||
'original_name': 'Privacy mode button',
|
||||
'platform': 'matter',
|
||||
'previous_unique_id': None,
|
||||
'suggested_object_id': None,
|
||||
'supported_features': 0,
|
||||
'translation_key': 'privacy_mode_button',
|
||||
'unique_id': '00000000000004D2-0000000000000001-MatterNodeDevice-1-DoorLockEnablePrivacyModeButton-257-43',
|
||||
'unit_of_measurement': None,
|
||||
})
|
||||
# ---
|
||||
# name: test_switches[door_lock_with_unbolt][switch.mock_door_lock_privacy_mode_button-state]
|
||||
StateSnapshot({
|
||||
'attributes': ReadOnlyDict({
|
||||
'friendly_name': 'Mock Door Lock Privacy mode button',
|
||||
}),
|
||||
'context': <ANY>,
|
||||
'entity_id': 'switch.mock_door_lock_privacy_mode_button',
|
||||
'last_changed': <ANY>,
|
||||
'last_reported': <ANY>,
|
||||
'last_updated': <ANY>,
|
||||
'state': 'off',
|
||||
})
|
||||
# ---
|
||||
# name: test_switches[eve_energy_plug][switch.eve_energy_plug-entry]
|
||||
EntityRegistryEntrySnapshot({
|
||||
'aliases': set({
|
||||
|
@@ -234,3 +234,58 @@ async def test_microwave_oven(
|
||||
cookTime=60, # 60 seconds
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("node_fixture", ["door_lock"])
|
||||
async def test_lock_attributes(
|
||||
hass: HomeAssistant,
|
||||
matter_client: MagicMock,
|
||||
matter_node: MatterNode,
|
||||
) -> None:
|
||||
"""Test door lock attributes."""
|
||||
# WrongCodeEntryLimit for door lock
|
||||
state = hass.states.get("number.mock_door_lock_wrong_code_limit")
|
||||
assert state
|
||||
assert state.state == "3"
|
||||
|
||||
set_node_attribute(matter_node, 1, 257, 48, 10)
|
||||
await trigger_subscription_callback(hass, matter_client)
|
||||
state = hass.states.get("number.mock_door_lock_wrong_code_limit")
|
||||
assert state
|
||||
assert state.state == "10"
|
||||
|
||||
# UserCodeTemporaryDisableTime for door lock
|
||||
state = hass.states.get("number.mock_door_lock_user_code_temporary_disable_time")
|
||||
assert state
|
||||
assert state.state == "10"
|
||||
|
||||
set_node_attribute(matter_node, 1, 257, 49, 30)
|
||||
await trigger_subscription_callback(hass, matter_client)
|
||||
state = hass.states.get("number.mock_door_lock_user_code_temporary_disable_time")
|
||||
assert state
|
||||
assert state.state == "30"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("node_fixture", ["door_lock"])
|
||||
async def test_matter_exception_on_door_lock_write_attribute(
|
||||
hass: HomeAssistant,
|
||||
matter_client: MagicMock,
|
||||
matter_node: MatterNode,
|
||||
) -> None:
|
||||
"""Test that MatterError is handled for write_attribute call."""
|
||||
entity_id = "number.mock_door_lock_wrong_code_limit"
|
||||
state = hass.states.get(entity_id)
|
||||
assert state
|
||||
matter_client.write_attribute.side_effect = MatterError("Boom!")
|
||||
with pytest.raises(HomeAssistantError) as exc_info:
|
||||
await hass.services.async_call(
|
||||
"number",
|
||||
"set_value",
|
||||
{
|
||||
"entity_id": entity_id,
|
||||
"value": 1,
|
||||
},
|
||||
blocking=True,
|
||||
)
|
||||
|
||||
assert str(exc_info.value) == "Boom!"
|
||||
|
@@ -61,6 +61,7 @@ from .common import (
|
||||
|
||||
from tests.common import MockPlatform, MockUser, mock_platform
|
||||
from tests.typing import RecorderInstanceContextManager, WebSocketGenerator
|
||||
from tests.util.test_unit_conversion import _ALL_CONVERTERS
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
@@ -3740,3 +3741,24 @@ async def test_get_statistics_service_missing_mandatory_keys(
|
||||
return_response=True,
|
||||
blocking=True,
|
||||
)
|
||||
|
||||
|
||||
# The STATISTIC_UNIT_TO_UNIT_CONVERTER keys are sorted to ensure that pytest runs are
|
||||
# consistent and avoid `different tests were collected between gw0 and gw1`
|
||||
@pytest.mark.parametrize(
|
||||
"uom", sorted(STATISTIC_UNIT_TO_UNIT_CONVERTER, key=lambda x: (x is None, x))
|
||||
)
|
||||
def test_STATISTIC_UNIT_TO_UNIT_CONVERTER(uom: str) -> None:
|
||||
"""Ensure unit does not belong to multiple converters."""
|
||||
unit_converter = STATISTIC_UNIT_TO_UNIT_CONVERTER[uom]
|
||||
if other := next(
|
||||
(
|
||||
c
|
||||
for c in _ALL_CONVERTERS
|
||||
if unit_converter is not c and uom in c.VALID_UNITS
|
||||
),
|
||||
None,
|
||||
):
|
||||
pytest.fail(
|
||||
f"{uom} is present in both {other.__name__} and {unit_converter.__name__}"
|
||||
)
|
||||
|
@@ -90,8 +90,8 @@
|
||||
'null': 2,
|
||||
}),
|
||||
'GetAiAlarm': dict({
|
||||
'0': 5,
|
||||
'null': 5,
|
||||
'0': 6,
|
||||
'null': 6,
|
||||
}),
|
||||
'GetAiCfg': dict({
|
||||
'0': 2,
|
||||
|
@@ -31,7 +31,12 @@ from homeassistant.config_entries import ConfigEntryState
|
||||
from homeassistant.const import SIGNAL_BOOTSTRAP_INTEGRATIONS
|
||||
from homeassistant.core import Context, HomeAssistant, State, SupportsResponse, callback
|
||||
from homeassistant.exceptions import HomeAssistantError, ServiceValidationError
|
||||
from homeassistant.helpers import device_registry as dr
|
||||
from homeassistant.helpers import (
|
||||
area_registry as ar,
|
||||
device_registry as dr,
|
||||
entity_registry as er,
|
||||
label_registry as lr,
|
||||
)
|
||||
from homeassistant.helpers.dispatcher import async_dispatcher_send
|
||||
from homeassistant.helpers.event import async_track_state_change_event
|
||||
from homeassistant.loader import Integration, async_get_integration
|
||||
@@ -107,6 +112,29 @@ def _apply_entities_changes(state_dict: dict, change_dict: dict) -> None:
|
||||
del state_dict[STATE_KEY_LONG_NAMES[key]][item]
|
||||
|
||||
|
||||
def _assert_extract_from_target_command_result(
|
||||
msg: dict[str, Any],
|
||||
entities: set[str] | None = None,
|
||||
devices: set[str] | None = None,
|
||||
areas: set[str] | None = None,
|
||||
missing_devices: set[str] | None = None,
|
||||
missing_areas: set[str] | None = None,
|
||||
missing_labels: set[str] | None = None,
|
||||
missing_floors: set[str] | None = None,
|
||||
) -> None:
|
||||
assert msg["type"] == const.TYPE_RESULT
|
||||
assert msg["success"]
|
||||
|
||||
result = msg["result"]
|
||||
assert set(result["referenced_entities"]) == (entities or set())
|
||||
assert set(result["referenced_devices"]) == (devices or set())
|
||||
assert set(result["referenced_areas"]) == (areas or set())
|
||||
assert set(result["missing_devices"]) == (missing_devices or set())
|
||||
assert set(result["missing_areas"]) == (missing_areas or set())
|
||||
assert set(result["missing_floors"]) == (missing_floors or set())
|
||||
assert set(result["missing_labels"]) == (missing_labels or set())
|
||||
|
||||
|
||||
async def test_fire_event(
|
||||
hass: HomeAssistant, websocket_client: MockHAClientWebSocket
|
||||
) -> None:
|
||||
@@ -3177,3 +3205,236 @@ async def test_wait_integration_startup(
|
||||
|
||||
# The component has been loaded
|
||||
assert "test" in hass.config.components
|
||||
|
||||
|
||||
async def test_extract_from_target(
|
||||
hass: HomeAssistant,
|
||||
websocket_client: MockHAClientWebSocket,
|
||||
area_registry: ar.AreaRegistry,
|
||||
device_registry: dr.DeviceRegistry,
|
||||
entity_registry: er.EntityRegistry,
|
||||
label_registry: lr.LabelRegistry,
|
||||
) -> None:
|
||||
"""Test extract_from_target command with mixed target types including entities, devices, areas, and labels."""
|
||||
|
||||
async def call_command(target: dict[str, str]) -> Any:
|
||||
await websocket_client.send_json_auto_id(
|
||||
{"type": "extract_from_target", "target": target}
|
||||
)
|
||||
return await websocket_client.receive_json()
|
||||
|
||||
config_entry = MockConfigEntry(domain="test")
|
||||
config_entry.add_to_hass(hass)
|
||||
|
||||
device1 = device_registry.async_get_or_create(
|
||||
config_entry_id=config_entry.entry_id,
|
||||
identifiers={("test", "device1")},
|
||||
)
|
||||
|
||||
device2 = device_registry.async_get_or_create(
|
||||
config_entry_id=config_entry.entry_id,
|
||||
identifiers={("test", "device2")},
|
||||
)
|
||||
|
||||
area_device = device_registry.async_get_or_create(
|
||||
config_entry_id=config_entry.entry_id,
|
||||
identifiers={("test", "device3")},
|
||||
)
|
||||
|
||||
label2_device = device_registry.async_get_or_create(
|
||||
config_entry_id=config_entry.entry_id,
|
||||
identifiers={("test", "device4")},
|
||||
)
|
||||
|
||||
kitchen_area = area_registry.async_create("Kitchen")
|
||||
living_room_area = area_registry.async_create("Living Room")
|
||||
label_area = area_registry.async_create("Bathroom")
|
||||
label1 = label_registry.async_create("Test Label 1")
|
||||
label2 = label_registry.async_create("Test Label 2")
|
||||
|
||||
# Associate devices with areas and labels
|
||||
device_registry.async_update_device(area_device.id, area_id=kitchen_area.id)
|
||||
device_registry.async_update_device(label2_device.id, labels={label2.label_id})
|
||||
area_registry.async_update(label_area.id, labels={label1.label_id})
|
||||
|
||||
# Setup entities with targets
|
||||
device1_entity1 = entity_registry.async_get_or_create(
|
||||
"light", "test", "unique1", device_id=device1.id
|
||||
)
|
||||
device1_entity2 = entity_registry.async_get_or_create(
|
||||
"switch", "test", "unique2", device_id=device1.id
|
||||
)
|
||||
device2_entity = entity_registry.async_get_or_create(
|
||||
"sensor", "test", "unique3", device_id=device2.id
|
||||
)
|
||||
area_device_entity = entity_registry.async_get_or_create(
|
||||
"light", "test", "unique4", device_id=area_device.id
|
||||
)
|
||||
area_entity = entity_registry.async_get_or_create("switch", "test", "unique5")
|
||||
label_device_entity = entity_registry.async_get_or_create(
|
||||
"light", "test", "unique6", device_id=label2_device.id
|
||||
)
|
||||
label_entity = entity_registry.async_get_or_create("switch", "test", "unique7")
|
||||
|
||||
# Associate entities with areas and labels
|
||||
entity_registry.async_update_entity(
|
||||
area_entity.entity_id, area_id=living_room_area.id
|
||||
)
|
||||
entity_registry.async_update_entity(
|
||||
label_entity.entity_id, labels={label1.label_id}
|
||||
)
|
||||
|
||||
msg = await call_command({"entity_id": ["light.unknown_entity"]})
|
||||
_assert_extract_from_target_command_result(msg, entities={"light.unknown_entity"})
|
||||
|
||||
msg = await call_command({"device_id": [device1.id, device2.id]})
|
||||
_assert_extract_from_target_command_result(
|
||||
msg,
|
||||
entities={
|
||||
device1_entity1.entity_id,
|
||||
device1_entity2.entity_id,
|
||||
device2_entity.entity_id,
|
||||
},
|
||||
devices={device1.id, device2.id},
|
||||
)
|
||||
|
||||
msg = await call_command({"area_id": [kitchen_area.id, living_room_area.id]})
|
||||
_assert_extract_from_target_command_result(
|
||||
msg,
|
||||
entities={area_device_entity.entity_id, area_entity.entity_id},
|
||||
areas={kitchen_area.id, living_room_area.id},
|
||||
devices={area_device.id},
|
||||
)
|
||||
|
||||
msg = await call_command({"label_id": [label1.label_id, label2.label_id]})
|
||||
_assert_extract_from_target_command_result(
|
||||
msg,
|
||||
entities={label_device_entity.entity_id, label_entity.entity_id},
|
||||
devices={label2_device.id},
|
||||
areas={label_area.id},
|
||||
)
|
||||
|
||||
# Test multiple mixed targets
|
||||
msg = await call_command(
|
||||
{
|
||||
"entity_id": ["light.direct"],
|
||||
"device_id": [device1.id],
|
||||
"area_id": [kitchen_area.id],
|
||||
"label_id": [label1.label_id],
|
||||
},
|
||||
)
|
||||
_assert_extract_from_target_command_result(
|
||||
msg,
|
||||
entities={
|
||||
"light.direct",
|
||||
device1_entity1.entity_id,
|
||||
device1_entity2.entity_id,
|
||||
area_device_entity.entity_id,
|
||||
label_entity.entity_id,
|
||||
},
|
||||
devices={device1.id, area_device.id},
|
||||
areas={kitchen_area.id, label_area.id},
|
||||
)
|
||||
|
||||
|
||||
async def test_extract_from_target_expand_group(
|
||||
hass: HomeAssistant, websocket_client: MockHAClientWebSocket
|
||||
) -> None:
|
||||
"""Test extract_from_target command with expand_group parameter."""
|
||||
await async_setup_component(
|
||||
hass,
|
||||
"group",
|
||||
{
|
||||
"group": {
|
||||
"test_group": {
|
||||
"name": "Test Group",
|
||||
"entities": ["light.kitchen", "light.living_room"],
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
hass.states.async_set("light.kitchen", "on")
|
||||
hass.states.async_set("light.living_room", "off")
|
||||
|
||||
# Test without expand_group (default False)
|
||||
await websocket_client.send_json_auto_id(
|
||||
{
|
||||
"type": "extract_from_target",
|
||||
"target": {"entity_id": ["group.test_group"]},
|
||||
}
|
||||
)
|
||||
msg = await websocket_client.receive_json()
|
||||
_assert_extract_from_target_command_result(msg, entities={"group.test_group"})
|
||||
|
||||
# Test with expand_group=True
|
||||
await websocket_client.send_json_auto_id(
|
||||
{
|
||||
"type": "extract_from_target",
|
||||
"target": {"entity_id": ["group.test_group"]},
|
||||
"expand_group": True,
|
||||
}
|
||||
)
|
||||
msg = await websocket_client.receive_json()
|
||||
_assert_extract_from_target_command_result(
|
||||
msg,
|
||||
entities={"light.kitchen", "light.living_room"},
|
||||
)
|
||||
|
||||
|
||||
async def test_extract_from_target_missing_entities(
|
||||
hass: HomeAssistant, websocket_client: MockHAClientWebSocket
|
||||
) -> None:
|
||||
"""Test extract_from_target command with missing device IDs, area IDs, etc."""
|
||||
await websocket_client.send_json_auto_id(
|
||||
{
|
||||
"type": "extract_from_target",
|
||||
"target": {
|
||||
"device_id": ["non_existent_device"],
|
||||
"area_id": ["non_existent_area"],
|
||||
"label_id": ["non_existent_label"],
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
msg = await websocket_client.receive_json()
|
||||
# Non-existent devices/areas are still referenced but reported as missing
|
||||
_assert_extract_from_target_command_result(
|
||||
msg,
|
||||
devices={"non_existent_device"},
|
||||
areas={"non_existent_area"},
|
||||
missing_areas={"non_existent_area"},
|
||||
missing_devices={"non_existent_device"},
|
||||
missing_labels={"non_existent_label"},
|
||||
)
|
||||
|
||||
|
||||
async def test_extract_from_target_empty_target(
|
||||
hass: HomeAssistant, websocket_client: MockHAClientWebSocket
|
||||
) -> None:
|
||||
"""Test extract_from_target command with empty target."""
|
||||
await websocket_client.send_json_auto_id(
|
||||
{
|
||||
"type": "extract_from_target",
|
||||
"target": {},
|
||||
}
|
||||
)
|
||||
|
||||
msg = await websocket_client.receive_json()
|
||||
_assert_extract_from_target_command_result(msg)
|
||||
|
||||
|
||||
async def test_extract_from_target_validation_error(
|
||||
hass: HomeAssistant, websocket_client: MockHAClientWebSocket
|
||||
) -> None:
|
||||
"""Test extract_from_target command with invalid target data."""
|
||||
await websocket_client.send_json_auto_id(
|
||||
{
|
||||
"type": "extract_from_target",
|
||||
"target": "invalid", # Should be a dict, not string
|
||||
}
|
||||
)
|
||||
msg = await websocket_client.receive_json()
|
||||
assert msg["type"] == const.TYPE_RESULT
|
||||
assert not msg["success"]
|
||||
assert "error" in msg
|
||||
|
@@ -1180,9 +1180,8 @@ async def test_user_port_config(probe_mock, hass: HomeAssistant) -> None:
|
||||
assert probe_mock.await_count == 1
|
||||
|
||||
|
||||
@pytest.mark.parametrize("onboarded", [True, False])
|
||||
@patch("homeassistant.components.zha.async_setup_entry", AsyncMock(return_value=True))
|
||||
async def test_hardware(onboarded, hass: HomeAssistant) -> None:
|
||||
async def test_hardware_not_onboarded(hass: HomeAssistant) -> None:
|
||||
"""Test hardware flow."""
|
||||
data = {
|
||||
"name": "Yellow",
|
||||
@@ -1194,33 +1193,12 @@ async def test_hardware(onboarded, hass: HomeAssistant) -> None:
|
||||
},
|
||||
}
|
||||
with patch(
|
||||
"homeassistant.components.onboarding.async_is_onboarded", return_value=onboarded
|
||||
"homeassistant.components.onboarding.async_is_onboarded", return_value=False
|
||||
):
|
||||
result1 = await hass.config_entries.flow.async_init(
|
||||
result_create = await hass.config_entries.flow.async_init(
|
||||
DOMAIN, context={"source": config_entries.SOURCE_HARDWARE}, data=data
|
||||
)
|
||||
|
||||
if onboarded:
|
||||
# Confirm discovery
|
||||
assert result1["type"] is FlowResultType.FORM
|
||||
assert result1["step_id"] == "confirm"
|
||||
|
||||
result2 = await hass.config_entries.flow.async_configure(
|
||||
result1["flow_id"],
|
||||
user_input={},
|
||||
)
|
||||
|
||||
assert result2["type"] is FlowResultType.MENU
|
||||
assert result2["step_id"] == "choose_setup_strategy"
|
||||
|
||||
result_create = await hass.config_entries.flow.async_configure(
|
||||
result2["flow_id"],
|
||||
user_input={"next_step_id": config_flow.SETUP_STRATEGY_RECOMMENDED},
|
||||
)
|
||||
await hass.async_block_till_done()
|
||||
else:
|
||||
# No need to confirm
|
||||
result_create = result1
|
||||
|
||||
assert result_create["title"] == "Yellow"
|
||||
assert result_create["data"] == {
|
||||
@@ -1233,6 +1211,283 @@ async def test_hardware(onboarded, hass: HomeAssistant) -> None:
|
||||
}
|
||||
|
||||
|
||||
@patch("homeassistant.components.zha.async_setup_entry", AsyncMock(return_value=True))
|
||||
async def test_hardware_no_flow_strategy(hass: HomeAssistant) -> None:
|
||||
"""Test hardware flow."""
|
||||
data = {
|
||||
"name": "Yellow",
|
||||
"radio_type": "efr32",
|
||||
"port": {
|
||||
"path": "/dev/ttyAMA1",
|
||||
"baudrate": 115200,
|
||||
"flow_control": "hardware",
|
||||
},
|
||||
}
|
||||
with patch(
|
||||
"homeassistant.components.onboarding.async_is_onboarded", return_value=True
|
||||
):
|
||||
result1 = await hass.config_entries.flow.async_init(
|
||||
DOMAIN, context={"source": config_entries.SOURCE_HARDWARE}, data=data
|
||||
)
|
||||
|
||||
# Confirm discovery
|
||||
assert result1["type"] is FlowResultType.FORM
|
||||
assert result1["step_id"] == "confirm"
|
||||
|
||||
result2 = await hass.config_entries.flow.async_configure(
|
||||
result1["flow_id"],
|
||||
user_input={},
|
||||
)
|
||||
|
||||
assert result2["type"] is FlowResultType.MENU
|
||||
assert result2["step_id"] == "choose_setup_strategy"
|
||||
|
||||
result_create = await hass.config_entries.flow.async_configure(
|
||||
result2["flow_id"],
|
||||
user_input={"next_step_id": config_flow.SETUP_STRATEGY_RECOMMENDED},
|
||||
)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
assert result_create["title"] == "Yellow"
|
||||
assert result_create["data"] == {
|
||||
CONF_DEVICE: {
|
||||
CONF_BAUDRATE: 115200,
|
||||
CONF_FLOW_CONTROL: "hardware",
|
||||
CONF_DEVICE_PATH: "/dev/ttyAMA1",
|
||||
},
|
||||
CONF_RADIO_TYPE: "ezsp",
|
||||
}
|
||||
|
||||
|
||||
@patch("homeassistant.components.zha.async_setup_entry", AsyncMock(return_value=True))
|
||||
async def test_hardware_flow_strategy_advanced(hass: HomeAssistant) -> None:
|
||||
"""Test advanced flow strategy for hardware flow."""
|
||||
data = {
|
||||
"name": "Yellow",
|
||||
"radio_type": "efr32",
|
||||
"port": {
|
||||
"path": "/dev/ttyAMA1",
|
||||
"baudrate": 115200,
|
||||
"flow_control": "hardware",
|
||||
},
|
||||
"flow_strategy": "advanced",
|
||||
}
|
||||
with patch(
|
||||
"homeassistant.components.onboarding.async_is_onboarded", return_value=True
|
||||
):
|
||||
result_hardware = await hass.config_entries.flow.async_init(
|
||||
DOMAIN, context={"source": config_entries.SOURCE_HARDWARE}, data=data
|
||||
)
|
||||
|
||||
assert result_hardware["type"] is FlowResultType.FORM
|
||||
assert result_hardware["step_id"] == "confirm"
|
||||
|
||||
confirm_result = await hass.config_entries.flow.async_configure(
|
||||
result_hardware["flow_id"],
|
||||
user_input={},
|
||||
)
|
||||
|
||||
assert confirm_result["type"] is FlowResultType.MENU
|
||||
assert confirm_result["step_id"] == "choose_formation_strategy"
|
||||
|
||||
result_create = await hass.config_entries.flow.async_configure(
|
||||
confirm_result["flow_id"],
|
||||
user_input={"next_step_id": "form_new_network"},
|
||||
)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
assert result_create["type"] is FlowResultType.CREATE_ENTRY
|
||||
assert result_create["title"] == "Yellow"
|
||||
assert result_create["data"] == {
|
||||
CONF_DEVICE: {
|
||||
CONF_BAUDRATE: 115200,
|
||||
CONF_FLOW_CONTROL: "hardware",
|
||||
CONF_DEVICE_PATH: "/dev/ttyAMA1",
|
||||
},
|
||||
CONF_RADIO_TYPE: "ezsp",
|
||||
}
|
||||
|
||||
|
||||
@patch("homeassistant.components.zha.async_setup_entry", AsyncMock(return_value=True))
|
||||
async def test_hardware_flow_strategy_recommended(hass: HomeAssistant) -> None:
|
||||
"""Test recommended flow strategy for hardware flow."""
|
||||
data = {
|
||||
"name": "Yellow",
|
||||
"radio_type": "efr32",
|
||||
"port": {
|
||||
"path": "/dev/ttyAMA1",
|
||||
"baudrate": 115200,
|
||||
"flow_control": "hardware",
|
||||
},
|
||||
"flow_strategy": "recommended",
|
||||
}
|
||||
with patch(
|
||||
"homeassistant.components.onboarding.async_is_onboarded", return_value=True
|
||||
):
|
||||
result_hardware = await hass.config_entries.flow.async_init(
|
||||
DOMAIN, context={"source": config_entries.SOURCE_HARDWARE}, data=data
|
||||
)
|
||||
|
||||
assert result_hardware["type"] is FlowResultType.FORM
|
||||
assert result_hardware["step_id"] == "confirm"
|
||||
|
||||
result_create = await hass.config_entries.flow.async_configure(
|
||||
result_hardware["flow_id"],
|
||||
user_input={},
|
||||
)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
assert result_create["type"] is FlowResultType.CREATE_ENTRY
|
||||
assert result_create["title"] == "Yellow"
|
||||
assert result_create["data"] == {
|
||||
CONF_DEVICE: {
|
||||
CONF_BAUDRATE: 115200,
|
||||
CONF_FLOW_CONTROL: "hardware",
|
||||
CONF_DEVICE_PATH: "/dev/ttyAMA1",
|
||||
},
|
||||
CONF_RADIO_TYPE: "ezsp",
|
||||
}
|
||||
|
||||
|
||||
@patch(f"zigpy_znp.{PROBE_FUNCTION_PATH}", AsyncMock(return_value=True))
|
||||
@patch("homeassistant.components.zha.async_setup_entry", AsyncMock(return_value=True))
|
||||
async def test_hardware_migration_flow_strategy_advanced(
|
||||
hass: HomeAssistant,
|
||||
backup: zigpy.backups.NetworkBackup,
|
||||
mock_app: AsyncMock,
|
||||
) -> None:
|
||||
"""Test advanced flow strategy for hardware migration flow."""
|
||||
entry = MockConfigEntry(
|
||||
version=config_flow.ZhaConfigFlowHandler.VERSION,
|
||||
domain=DOMAIN,
|
||||
data={
|
||||
CONF_DEVICE: {
|
||||
CONF_DEVICE_PATH: "/dev/ttyUSB0",
|
||||
CONF_BAUDRATE: 115200,
|
||||
CONF_FLOW_CONTROL: None,
|
||||
},
|
||||
CONF_RADIO_TYPE: "znp",
|
||||
},
|
||||
)
|
||||
entry.add_to_hass(hass)
|
||||
|
||||
data = {
|
||||
"name": "Yellow",
|
||||
"radio_type": "efr32",
|
||||
"port": {
|
||||
"path": "/dev/ttyAMA1",
|
||||
"baudrate": 115200,
|
||||
"flow_control": "hardware",
|
||||
},
|
||||
"flow_strategy": "advanced",
|
||||
}
|
||||
with (
|
||||
patch(
|
||||
"homeassistant.components.onboarding.async_is_onboarded", return_value=True
|
||||
),
|
||||
patch(
|
||||
"homeassistant.components.zha.radio_manager.ZhaRadioManager._async_read_backups_from_database",
|
||||
return_value=[backup],
|
||||
),
|
||||
patch(
|
||||
"homeassistant.components.zha.radio_manager.ZhaRadioManager.restore_backup",
|
||||
) as mock_restore_backup,
|
||||
patch(
|
||||
"homeassistant.config_entries.ConfigEntries.async_unload",
|
||||
return_value=True,
|
||||
) as mock_async_unload,
|
||||
):
|
||||
result_hardware = await hass.config_entries.flow.async_init(
|
||||
DOMAIN, context={"source": config_entries.SOURCE_HARDWARE}, data=data
|
||||
)
|
||||
|
||||
assert result_hardware["type"] is FlowResultType.FORM
|
||||
assert result_hardware["step_id"] == "confirm"
|
||||
|
||||
result_confirm = await hass.config_entries.flow.async_configure(
|
||||
result_hardware["flow_id"], user_input={}
|
||||
)
|
||||
|
||||
assert result_confirm["type"] is FlowResultType.MENU
|
||||
assert result_confirm["step_id"] == "choose_formation_strategy"
|
||||
|
||||
result_formation_strategy = await hass.config_entries.flow.async_configure(
|
||||
result_confirm["flow_id"],
|
||||
user_input={"next_step_id": "form_new_network"},
|
||||
)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
assert result_formation_strategy["type"] is FlowResultType.ABORT
|
||||
assert result_formation_strategy["reason"] == "reconfigure_successful"
|
||||
assert mock_async_unload.call_count == 0
|
||||
assert mock_restore_backup.call_count == 0
|
||||
|
||||
|
||||
@patch(f"zigpy_znp.{PROBE_FUNCTION_PATH}", AsyncMock(return_value=True))
|
||||
@patch("homeassistant.components.zha.async_setup_entry", AsyncMock(return_value=True))
|
||||
async def test_hardware_migration_flow_strategy_recommended(
|
||||
hass: HomeAssistant,
|
||||
backup: zigpy.backups.NetworkBackup,
|
||||
mock_app: AsyncMock,
|
||||
) -> None:
|
||||
"""Test recommended flow strategy for hardware migration flow."""
|
||||
entry = MockConfigEntry(
|
||||
version=config_flow.ZhaConfigFlowHandler.VERSION,
|
||||
domain=DOMAIN,
|
||||
data={
|
||||
CONF_DEVICE: {
|
||||
CONF_DEVICE_PATH: "/dev/ttyUSB0",
|
||||
CONF_BAUDRATE: 115200,
|
||||
CONF_FLOW_CONTROL: None,
|
||||
},
|
||||
CONF_RADIO_TYPE: "znp",
|
||||
},
|
||||
)
|
||||
entry.add_to_hass(hass)
|
||||
|
||||
data = {
|
||||
"name": "Yellow",
|
||||
"radio_type": "efr32",
|
||||
"port": {
|
||||
"path": "/dev/ttyAMA1",
|
||||
"baudrate": 115200,
|
||||
"flow_control": "hardware",
|
||||
},
|
||||
"flow_strategy": "recommended",
|
||||
}
|
||||
with (
|
||||
patch(
|
||||
"homeassistant.components.onboarding.async_is_onboarded", return_value=True
|
||||
),
|
||||
patch(
|
||||
"homeassistant.components.zha.radio_manager.ZhaRadioManager._async_read_backups_from_database",
|
||||
return_value=[backup],
|
||||
),
|
||||
patch(
|
||||
"homeassistant.components.zha.radio_manager.ZhaRadioManager.restore_backup",
|
||||
) as mock_restore_backup,
|
||||
patch(
|
||||
"homeassistant.config_entries.ConfigEntries.async_unload",
|
||||
return_value=True,
|
||||
) as mock_async_unload,
|
||||
):
|
||||
result_hardware = await hass.config_entries.flow.async_init(
|
||||
DOMAIN, context={"source": config_entries.SOURCE_HARDWARE}, data=data
|
||||
)
|
||||
|
||||
assert result_hardware["type"] is FlowResultType.FORM
|
||||
assert result_hardware["step_id"] == "confirm"
|
||||
|
||||
result_confirm = await hass.config_entries.flow.async_configure(
|
||||
result_hardware["flow_id"], user_input={}
|
||||
)
|
||||
|
||||
assert result_confirm["type"] is FlowResultType.ABORT
|
||||
assert result_confirm["reason"] == "reconfigure_successful"
|
||||
assert mock_async_unload.mock_calls == [call(entry.entry_id)]
|
||||
assert mock_restore_backup.call_count == 1
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"data", [None, {}, {"radio_type": "best_radio"}, {"radio_type": "efr32"}]
|
||||
)
|
||||
|
@@ -450,10 +450,10 @@ async def test_caching(hass: HomeAssistant) -> None:
|
||||
side_effect=translation.build_resources,
|
||||
) as mock_build_resources:
|
||||
load1 = await translation.async_get_translations(hass, "en", "entity_component")
|
||||
assert len(mock_build_resources.mock_calls) == 8
|
||||
assert len(mock_build_resources.mock_calls) == 7
|
||||
|
||||
load2 = await translation.async_get_translations(hass, "en", "entity_component")
|
||||
assert len(mock_build_resources.mock_calls) == 8
|
||||
assert len(mock_build_resources.mock_calls) == 7
|
||||
|
||||
assert load1 == load2
|
||||
|
||||
|
116
tests/util/test_async_iterator.py
Normal file
116
tests/util/test_async_iterator.py
Normal file
@@ -0,0 +1,116 @@
|
||||
"""Tests for async iterator utility functions."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from collections.abc import AsyncIterator
|
||||
|
||||
import pytest
|
||||
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.util.async_iterator import (
|
||||
Abort,
|
||||
AsyncIteratorReader,
|
||||
AsyncIteratorWriter,
|
||||
)
|
||||
|
||||
|
||||
def _read_all(reader: AsyncIteratorReader) -> bytes:
|
||||
output = b""
|
||||
while chunk := reader.read(500):
|
||||
output += chunk
|
||||
return output
|
||||
|
||||
|
||||
async def test_async_iterator_reader(hass: HomeAssistant) -> None:
|
||||
"""Test the async iterator reader."""
|
||||
data = b"hello world" * 1000
|
||||
|
||||
async def async_gen() -> AsyncIterator[bytes]:
|
||||
for _ in range(10):
|
||||
yield data
|
||||
|
||||
reader = AsyncIteratorReader(hass.loop, async_gen())
|
||||
assert await hass.async_add_executor_job(_read_all, reader) == data * 10
|
||||
|
||||
|
||||
async def test_async_iterator_reader_abort_early(hass: HomeAssistant) -> None:
|
||||
"""Test abort the async iterator reader."""
|
||||
evt = asyncio.Event()
|
||||
|
||||
async def async_gen() -> AsyncIterator[bytes]:
|
||||
await evt.wait()
|
||||
yield b""
|
||||
|
||||
reader = AsyncIteratorReader(hass.loop, async_gen())
|
||||
reader.abort()
|
||||
fut = hass.async_add_executor_job(_read_all, reader)
|
||||
with pytest.raises(Abort):
|
||||
await fut
|
||||
|
||||
|
||||
async def test_async_iterator_reader_abort_late(hass: HomeAssistant) -> None:
|
||||
"""Test abort the async iterator reader."""
|
||||
evt = asyncio.Event()
|
||||
|
||||
async def async_gen() -> AsyncIterator[bytes]:
|
||||
await evt.wait()
|
||||
yield b""
|
||||
|
||||
reader = AsyncIteratorReader(hass.loop, async_gen())
|
||||
fut = hass.async_add_executor_job(_read_all, reader)
|
||||
await asyncio.sleep(0.1)
|
||||
reader.abort()
|
||||
with pytest.raises(Abort):
|
||||
await fut
|
||||
|
||||
|
||||
def _write_all(writer: AsyncIteratorWriter, data: list[bytes]) -> bytes:
|
||||
for chunk in data:
|
||||
assert writer.write(chunk) == len(chunk)
|
||||
assert writer.write(b"") == 0
|
||||
|
||||
|
||||
async def test_async_iterator_writer(hass: HomeAssistant) -> None:
|
||||
"""Test the async iterator writer."""
|
||||
chunk = b"hello world" * 1000
|
||||
chunks = [chunk] * 10
|
||||
writer = AsyncIteratorWriter(hass.loop)
|
||||
|
||||
fut = hass.async_add_executor_job(_write_all, writer, chunks)
|
||||
|
||||
read = b""
|
||||
async for data in writer:
|
||||
read += data
|
||||
|
||||
await fut
|
||||
|
||||
assert read == chunk * 10
|
||||
assert writer.tell() == len(read)
|
||||
|
||||
|
||||
async def test_async_iterator_writer_abort_early(hass: HomeAssistant) -> None:
|
||||
"""Test the async iterator writer."""
|
||||
chunk = b"hello world" * 1000
|
||||
chunks = [chunk] * 10
|
||||
writer = AsyncIteratorWriter(hass.loop)
|
||||
writer.abort()
|
||||
|
||||
fut = hass.async_add_executor_job(_write_all, writer, chunks)
|
||||
|
||||
with pytest.raises(Abort):
|
||||
await fut
|
||||
|
||||
|
||||
async def test_async_iterator_writer_abort_late(hass: HomeAssistant) -> None:
|
||||
"""Test the async iterator writer."""
|
||||
chunk = b"hello world" * 1000
|
||||
chunks = [chunk] * 10
|
||||
writer = AsyncIteratorWriter(hass.loop)
|
||||
|
||||
fut = hass.async_add_executor_job(_write_all, writer, chunks)
|
||||
await asyncio.sleep(0.1)
|
||||
writer.abort()
|
||||
|
||||
with pytest.raises(Abort):
|
||||
await fut
|
Reference in New Issue
Block a user