Compare commits

..

21 Commits

Author SHA1 Message Date
Abílio Costa
5b76533098 Merge branch 'dev' into target_websocket_api 2025-09-30 10:09:35 +01:00
G Johansson
3914e41f3c Rename resolver to nameserver in dnsip (#153223) 2025-09-30 10:46:59 +02:00
Erik Montnemery
82bdfcb99b Correct target filter in ecovacs services (#153241) 2025-09-30 10:39:18 +03:00
Marc Mueller
976cea600f Use attribute names for match class (#153191) 2025-09-29 23:12:54 +02:00
Tom
8c8713c3f7 Rework test split for airOS reauthentication flow (#153221) 2025-09-29 22:07:18 +02:00
G Johansson
2359ae6ce7 Bump pysmhi to 1.1.0 (#153222) 2025-09-29 22:04:59 +02:00
Paul Bottein
b570fd35c8 Replace legacy hass icons to mdi icons (#153204) 2025-09-29 20:04:21 +01:00
starkillerOG
9d94e6b3b4 Add Reolink bicycle sensitivity and delay (#153217) 2025-09-29 20:44:13 +02:00
Martin Hjelmare
cfab789823 Add hardware Zigbee flow strategy (#153190) 2025-09-29 20:08:43 +02:00
Erik Montnemery
81917425dc Add test which fails on duplicated statistics units (#153202)
Co-authored-by: Abílio Costa <abmantis@users.noreply.github.com>
Co-authored-by: epenet <6771947+epenet@users.noreply.github.com>
Co-authored-by: jbouwh <jan@jbsoft.nl>
2025-09-29 20:07:59 +02:00
Jan Bouwhuis
bfb62709d4 Add missing translation strings for added sensor device classes pm4 and reactive energy (#153215) 2025-09-29 19:55:09 +02:00
Joost Lekkerkerker
ca3f2ee782 Mark Konnected as Legacy (#153193) 2025-09-29 18:22:29 +01:00
Ludovic BOUÉ
fc8703a40f Matter DoorLock attributes (#151418)
Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com>
Co-authored-by: Martin Hjelmare <marhje52@gmail.com>
2025-09-29 18:20:22 +01:00
c0ffeeca7
80517c7ac1 ZHA: rename radio to adapter (#153206) 2025-09-29 18:17:44 +01:00
Erik Montnemery
2b4b46eaf8 Add async_iterator util (#153194) 2025-09-29 18:54:23 +02:00
Martin Hjelmare
40b9dae608 Improve hardware flow strings (#153034) 2025-09-29 18:29:58 +02:00
Abílio Costa
a109f2dcdf Update homeassistant/components/websocket_api/commands.py
Co-authored-by: Artur Pragacz <49985303+arturpragacz@users.noreply.github.com>
2025-09-15 14:20:21 +01:00
Abílio Costa
b61b9bb606 Merge branch 'dev' into target_websocket_api 2025-09-08 16:11:44 +01:00
abmantis
86e7ca9790 Rename to extract_from_target 2025-08-07 22:02:41 +01:00
Abílio Costa
f8f4c7ddeb copilot: Update homeassistant/components/websocket_api/commands.py
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2025-08-06 15:57:16 +01:00
abmantis
8308be185e Add expand_target websocket command 2025-08-06 15:50:16 +01:00
61 changed files with 1689 additions and 789 deletions

View File

@@ -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, []],

View File

@@ -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

View File

@@ -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=[

View File

@@ -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)

View File

@@ -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:

View File

@@ -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."""

View File

@@ -2,3 +2,4 @@ raw_get_positions:
target:
entity:
domain: vacuum
integration: ecovacs

View File

@@ -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]),
}
)

View File

@@ -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

View File

@@ -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": {

View File

@@ -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)

View File

@@ -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"
}
}
},

View File

@@ -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": {

View File

@@ -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": {

View File

@@ -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 = {}

View File

@@ -1,6 +1,6 @@
{
"domain": "konnected",
"name": "Konnected.io",
"name": "Konnected.io (Legacy)",
"codeowners": ["@heythisisnate"],
"config_flow": true,
"dependencies": ["http"],

View File

@@ -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."
}
}
}

View File

@@ -25,10 +25,5 @@
"turn_on": {
"service": "mdi:lightbulb-on"
}
},
"triggers": {
"state": {
"trigger": "mdi:state-machine"
}
}
}

View File

@@ -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."
}
}
}
}
}

View File

@@ -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

View File

@@ -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

View File

@@ -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, {})

View File

@@ -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"

View File

@@ -148,6 +148,9 @@
},
"evse_charging_switch": {
"default": "mdi:ev-station"
},
"privacy_mode_button": {
"default": "mdi:shield-lock"
}
}
}

View File

@@ -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,
),
),
]

View File

@@ -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": {

View File

@@ -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(

View File

@@ -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"
)

View File

@@ -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%]",

View File

@@ -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%]"
},

View File

@@ -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

View File

@@ -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%]",

View File

@@ -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"
},

View File

@@ -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",

View File

@@ -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"
},

View File

@@ -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%]",

View File

@@ -245,6 +245,9 @@
"pm1": {
"name": "PM1"
},
"pm4": {
"name": "PM4"
},
"pm10": {
"name": "PM10"
},

View File

@@ -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"]
}

View File

@@ -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%]",

View File

@@ -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%]",

View File

@@ -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",

View File

@@ -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}",

View File

@@ -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)),
}
)

View File

@@ -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%]",

View File

@@ -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"

View File

@@ -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

View 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
View File

@@ -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

View File

@@ -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

View File

@@ -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"

View File

@@ -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(

View File

@@ -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

View File

@@ -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({

View File

@@ -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({

View File

@@ -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!"

View File

@@ -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__}"
)

View File

@@ -90,8 +90,8 @@
'null': 2,
}),
'GetAiAlarm': dict({
'0': 5,
'null': 5,
'0': 6,
'null': 6,
}),
'GetAiCfg': dict({
'0': 2,

View File

@@ -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

View File

@@ -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"}]
)

View File

@@ -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

View 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