diff --git a/homeassistant/components/backup/http.py b/homeassistant/components/backup/http.py index b71859611b4287..b40ea76cd5924b 100644 --- a/homeassistant/components/backup/http.py +++ b/homeassistant/components/backup/http.py @@ -17,6 +17,7 @@ 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 @@ async def _send_backup_with_password( 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 @@ def on_done(error: Exception | None) -> None: """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, []], diff --git a/homeassistant/components/backup/manager.py b/homeassistant/components/backup/manager.py index 863775a32ed34b..cba09a078c1a5c 100644 --- a/homeassistant/components/backup/manager.py +++ b/homeassistant/components/backup/manager.py @@ -38,6 +38,7 @@ ) 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 .store import BackupStore from .util import ( - AsyncIteratorReader, DecryptedBackupStreamer, EncryptedBackupStreamer, make_backup_dir, @@ -1525,7 +1525,7 @@ async def async_can_decrypt_on_download( 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 diff --git a/homeassistant/components/backup/util.py b/homeassistant/components/backup/util.py index 1a32c938a54f6a..9dfcb36783d104 100644 --- a/homeassistant/components/backup/util.py +++ b/homeassistant/components/backup/util.py @@ -4,7 +4,6 @@ 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 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.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 @@ def on_done(error: Exception | None) -> None: 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=[ diff --git a/homeassistant/components/calendar/__init__.py b/homeassistant/components/calendar/__init__.py index 96bf717c3aced6..8f8d04a7c4cba1 100644 --- a/homeassistant/components/calendar/__init__.py +++ b/homeassistant/components/calendar/__init__.py @@ -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) diff --git a/homeassistant/components/config/__init__.py b/homeassistant/components/config/__init__.py index 1f6dc2c21229a7..ca4ddda2242885 100644 --- a/homeassistant/components/config/__init__.py +++ b/homeassistant/components/config/__init__.py @@ -49,7 +49,7 @@ 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: diff --git a/homeassistant/components/frontend/__init__.py b/homeassistant/components/frontend/__init__.py index 2d39726abbfec0..4bdaff92b0162c 100644 --- a/homeassistant/components/frontend/__init__.py +++ b/homeassistant/components/frontend/__init__.py @@ -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 diff --git a/homeassistant/components/history/__init__.py b/homeassistant/components/history/__init__.py index fd82b74b048a72..b948060fe24fb6 100644 --- a/homeassistant/components/history/__init__.py +++ b/homeassistant/components/history/__init__.py @@ -46,7 +46,7 @@ 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 diff --git a/homeassistant/components/homeassistant_connect_zbt2/strings.json b/homeassistant/components/homeassistant_connect_zbt2/strings.json index 20d340216e95d7..1fc7d4d70fbfac 100644 --- a/homeassistant/components/homeassistant_connect_zbt2/strings.json +++ b/homeassistant/components/homeassistant_connect_zbt2/strings.json @@ -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": { diff --git a/homeassistant/components/homeassistant_hardware/firmware_config_flow.py b/homeassistant/components/homeassistant_hardware/firmware_config_flow.py index 895c7e72618482..5e480f8440d2f4 100644 --- a/homeassistant/components/homeassistant_hardware/firmware_config_flow.py +++ b/homeassistant/components/homeassistant_hardware/firmware_config_flow.py @@ -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 @@ async def async_step_zigbee_intent_recommended( ) -> 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 @@ async def async_step_continue_zigbee( "flow_control": "hardware", }, "radio_type": "ezsp", + "flow_strategy": self._zigbee_flow_strategy, }, ) return self._continue_zha_flow(result) diff --git a/homeassistant/components/homeassistant_hardware/strings.json b/homeassistant/components/homeassistant_hardware/strings.json index a33dae15377981..07ed06761fe8f0 100644 --- a/homeassistant/components/homeassistant_hardware/strings.json +++ b/homeassistant/components/homeassistant_hardware/strings.json @@ -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" } } }, diff --git a/homeassistant/components/homeassistant_sky_connect/strings.json b/homeassistant/components/homeassistant_sky_connect/strings.json index 20d340216e95d7..c2f02897b459da 100644 --- a/homeassistant/components/homeassistant_sky_connect/strings.json +++ b/homeassistant/components/homeassistant_sky_connect/strings.json @@ -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": { diff --git a/homeassistant/components/homeassistant_yellow/strings.json b/homeassistant/components/homeassistant_yellow/strings.json index 3d5da55bb92bb6..f25e2b6d2bd887 100644 --- a/homeassistant/components/homeassistant_yellow/strings.json +++ b/homeassistant/components/homeassistant_yellow/strings.json @@ -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": { diff --git a/homeassistant/components/konnected/__init__.py b/homeassistant/components/konnected/__init__.py index dd4dbc7dbe5239..42cd39d1473f20 100644 --- a/homeassistant/components/konnected/__init__.py +++ b/homeassistant/components/konnected/__init__.py @@ -35,7 +35,7 @@ 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 @@ def import_validator(config): 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 = {} diff --git a/homeassistant/components/konnected/manifest.json b/homeassistant/components/konnected/manifest.json index 7aab6fcd176bfc..94b852476c1089 100644 --- a/homeassistant/components/konnected/manifest.json +++ b/homeassistant/components/konnected/manifest.json @@ -1,6 +1,6 @@ { "domain": "konnected", - "name": "Konnected.io", + "name": "Konnected.io (Legacy)", "codeowners": ["@heythisisnate"], "config_flow": true, "dependencies": ["http"], diff --git a/homeassistant/components/konnected/strings.json b/homeassistant/components/konnected/strings.json index df92e014f12146..4896e4fb767afa 100644 --- a/homeassistant/components/konnected/strings.json +++ b/homeassistant/components/konnected/strings.json @@ -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." + } } } diff --git a/homeassistant/components/logbook/__init__.py b/homeassistant/components/logbook/__init__.py index 2e2ffddac8833c..de2ff570f0c058 100644 --- a/homeassistant/components/logbook/__init__.py +++ b/homeassistant/components/logbook/__init__.py @@ -115,7 +115,7 @@ def log_message(service: ServiceCall) -> None: 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, {}) diff --git a/homeassistant/components/lovelace/const.py b/homeassistant/components/lovelace/const.py index 0450c62338d0c3..ac1c9c5abff24d 100644 --- a/homeassistant/components/lovelace/const.py +++ b/homeassistant/components/lovelace/const.py @@ -24,7 +24,7 @@ DOMAIN = "lovelace" LOVELACE_DATA: HassKey[LovelaceData] = HassKey(DOMAIN) -DEFAULT_ICON = "hass:view-dashboard" +DEFAULT_ICON = "mdi:view-dashboard" MODE_YAML = "yaml" MODE_STORAGE = "storage" diff --git a/homeassistant/components/matter/icons.json b/homeassistant/components/matter/icons.json index dc1fbc25181f3c..f21a7b7a931efb 100644 --- a/homeassistant/components/matter/icons.json +++ b/homeassistant/components/matter/icons.json @@ -148,6 +148,9 @@ }, "evse_charging_switch": { "default": "mdi:ev-station" + }, + "privacy_mode_button": { + "default": "mdi:shield-lock" } } } diff --git a/homeassistant/components/matter/number.py b/homeassistant/components/matter/number.py index d06a675ecc8d88..f9783127673a02 100644 --- a/homeassistant/components/matter/number.py +++ b/homeassistant/components/matter/number.py @@ -80,9 +80,7 @@ async def async_set_native_value(self, value: float) -> None: 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 @@ def _update_from_device(self) -> None: 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, + ), + ), ] diff --git a/homeassistant/components/matter/strings.json b/homeassistant/components/matter/strings.json index 85ad6527653da2..a46fbddd61244b 100644 --- a/homeassistant/components/matter/strings.json +++ b/homeassistant/components/matter/strings.json @@ -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": { diff --git a/homeassistant/components/matter/switch.py b/homeassistant/components/matter/switch.py index df8581c5c4f326..2c02522f0a1776 100644 --- a/homeassistant/components/matter/switch.py +++ b/homeassistant/components/matter/switch.py @@ -263,6 +263,18 @@ def _update_from_device(self) -> None: ), 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( diff --git a/homeassistant/components/media_source/http.py b/homeassistant/components/media_source/http.py index 3b9aaeea4ba482..3c6388db944921 100644 --- a/homeassistant/components/media_source/http.py +++ b/homeassistant/components/media_source/http.py @@ -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" ) diff --git a/homeassistant/components/mqtt/strings.json b/homeassistant/components/mqtt/strings.json index 7f14f26e879239..1f3892fb927de2 100644 --- a/homeassistant/components/mqtt/strings.json +++ b/homeassistant/components/mqtt/strings.json @@ -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%]", diff --git a/homeassistant/components/number/strings.json b/homeassistant/components/number/strings.json index 1e4290f1d75f62..8c94269f069b99 100644 --- a/homeassistant/components/number/strings.json +++ b/homeassistant/components/number/strings.json @@ -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%]" }, diff --git a/homeassistant/components/onkyo/media_player.py b/homeassistant/components/onkyo/media_player.py index 05374bfe6cfc6d..1b85e3627a2dcf 100644 --- a/homeassistant/components/onkyo/media_player.py +++ b/homeassistant/components/onkyo/media_player.py @@ -341,12 +341,12 @@ async def async_play_media( 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 @@ def process_update(self, message: status.Known) -> None: ) 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 @@ def process_update(self, message: status.Known) -> None: 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 @@ def process_update(self, message: status.Known) -> None: 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 @@ def process_update(self, message: status.Known) -> None: 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 diff --git a/homeassistant/components/random/strings.json b/homeassistant/components/random/strings.json index 450f78f9e83eeb..bf83da70de1061 100644 --- a/homeassistant/components/random/strings.json +++ b/homeassistant/components/random/strings.json @@ -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%]", diff --git a/homeassistant/components/reolink/icons.json b/homeassistant/components/reolink/icons.json index dfc7ca43608d8a..e4c270ae02b996 100644 --- a/homeassistant/components/reolink/icons.json +++ b/homeassistant/components/reolink/icons.json @@ -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" }, diff --git a/homeassistant/components/reolink/number.py b/homeassistant/components/reolink/number.py index aaf503d70f8a8f..6daea02529685f 100644 --- a/homeassistant/components/reolink/number.py +++ b/homeassistant/components/reolink/number.py @@ -255,6 +255,23 @@ class ReolinkChimeNumberEntityDescription( 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 @@ class ReolinkChimeNumberEntityDescription( 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", diff --git a/homeassistant/components/reolink/strings.json b/homeassistant/components/reolink/strings.json index 1449477716b0cb..89a62ad90b662b 100644 --- a/homeassistant/components/reolink/strings.json +++ b/homeassistant/components/reolink/strings.json @@ -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" }, diff --git a/homeassistant/components/scrape/strings.json b/homeassistant/components/scrape/strings.json index 91452287ce7ea1..7faa3ec91dbfed 100644 --- a/homeassistant/components/scrape/strings.json +++ b/homeassistant/components/scrape/strings.json @@ -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%]", diff --git a/homeassistant/components/sensor/strings.json b/homeassistant/components/sensor/strings.json index d721e20b244b04..81a67b78adad28 100644 --- a/homeassistant/components/sensor/strings.json +++ b/homeassistant/components/sensor/strings.json @@ -245,6 +245,9 @@ "pm1": { "name": "PM1" }, + "pm4": { + "name": "PM4" + }, "pm10": { "name": "PM10" }, diff --git a/homeassistant/components/smhi/manifest.json b/homeassistant/components/smhi/manifest.json index 0af692b800c009..391c1e02dd21bb 100644 --- a/homeassistant/components/smhi/manifest.json +++ b/homeassistant/components/smhi/manifest.json @@ -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"] } diff --git a/homeassistant/components/sql/strings.json b/homeassistant/components/sql/strings.json index a70a9812657fc1..7b4ad154981522 100644 --- a/homeassistant/components/sql/strings.json +++ b/homeassistant/components/sql/strings.json @@ -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%]", diff --git a/homeassistant/components/template/strings.json b/homeassistant/components/template/strings.json index 2f06abe9a22a0f..6ac73d43870c00 100644 --- a/homeassistant/components/template/strings.json +++ b/homeassistant/components/template/strings.json @@ -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%]", diff --git a/homeassistant/components/zha/config_flow.py b/homeassistant/components/zha/config_flow.py index 95c4593089b612..8ca270c0cc2bbf 100644 --- a/homeassistant/components/zha/config_flow.py +++ b/homeassistant/components/zha/config_flow.py @@ -20,6 +20,9 @@ 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 @@ async def async_step_choose_setup_strategy( 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 @@ async def async_step_choose_migration_strategy( 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 @@ async def async_step_hardware( 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}", diff --git a/homeassistant/components/zha/radio_manager.py b/homeassistant/components/zha/radio_manager.py index b2d515d785f2cf..1a2da153902bf8 100644 --- a/homeassistant/components/zha/radio_manager.py +++ b/homeassistant/components/zha/radio_manager.py @@ -28,6 +28,9 @@ 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 @@ vol.Required("name"): str, vol.Required("port"): DEVICE_SCHEMA, vol.Required("radio_type"): str, + vol.Optional("flow_strategy"): vol.All(str, vol.Coerce(ZigbeeFlowStrategy)), } ) diff --git a/homeassistant/components/zha/strings.json b/homeassistant/components/zha/strings.json index 4b28b1c426ed3f..91be9c3b3b483f 100644 --- a/homeassistant/components/zha/strings.json +++ b/homeassistant/components/zha/strings.json @@ -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%]", diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index 2ce0e314afb5c5..3289af99fe2d86 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -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" diff --git a/homeassistant/util/async_iterator.py b/homeassistant/util/async_iterator.py new file mode 100644 index 00000000000000..b59d8b474167e0 --- /dev/null +++ b/homeassistant/util/async_iterator.py @@ -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) diff --git a/requirements_all.txt b/requirements_all.txt index 1b8e5298621222..a935108cbb299d 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -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 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 68b31c316bed87..8cf0ad1f026c82 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -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 diff --git a/tests/components/airos/test_config_flow.py b/tests/components/airos/test_config_flow.py index 6b5c6f47716152..8f668166ea6676 100644 --- a/tests/components/airos/test_config_flow.py +++ b/tests/components/airos/test_config_flow.py @@ -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" diff --git a/tests/components/homeassistant_hardware/test_config_flow.py b/tests/components/homeassistant_hardware/test_config_flow.py index da81f2bff8838d..34c6cfb7f8048e 100644 --- a/tests/components/homeassistant_hardware/test_config_flow.py +++ b/tests/components/homeassistant_hardware/test_config_flow.py @@ -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( diff --git a/tests/components/matter/snapshots/test_number.ambr b/tests/components/matter/snapshots/test_number.ambr index 605ec6c1649d8b..bceec9def46559 100644 --- a/tests/components/matter/snapshots/test_number.ambr +++ b/tests/components/matter/snapshots/test_number.ambr @@ -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': , + 'step': 1, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': , + 'entity_id': 'number.mock_door_lock_user_code_temporary_disable_time', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + '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': , + }) +# --- +# 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': , + 'step': 1, + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'number.mock_door_lock_user_code_temporary_disable_time', + 'last_changed': , + 'last_reported': , + 'last_updated': , + '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': , + 'step': 1, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': , + 'entity_id': 'number.mock_door_lock_wrong_code_limit', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + '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': , + 'step': 1, + }), + 'context': , + 'entity_id': 'number.mock_door_lock_wrong_code_limit', + 'last_changed': , + 'last_reported': , + 'last_updated': , + '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': , + 'step': 1, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': , + 'entity_id': 'number.mock_door_lock_user_code_temporary_disable_time', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + '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': , + }) +# --- +# 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': , + 'step': 1, + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'number.mock_door_lock_user_code_temporary_disable_time', + 'last_changed': , + 'last_reported': , + 'last_updated': , + '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': , + 'step': 1, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': , + 'entity_id': 'number.mock_door_lock_wrong_code_limit', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + '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': , + 'step': 1, + }), + 'context': , + 'entity_id': 'number.mock_door_lock_wrong_code_limit', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '3', + }) +# --- # name: test_numbers[eve_thermo][number.eve_thermo_temperature_offset-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ diff --git a/tests/components/matter/snapshots/test_switch.ambr b/tests/components/matter/snapshots/test_switch.ambr index cdd2f65a61ed28..d7c2aba92a3980 100644 --- a/tests/components/matter/snapshots/test_switch.ambr +++ b/tests/components/matter/snapshots/test_switch.ambr @@ -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': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': , + 'entity_id': 'switch.mock_door_lock_privacy_mode_button', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + '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': , + 'entity_id': 'switch.mock_door_lock_privacy_mode_button', + 'last_changed': , + 'last_reported': , + 'last_updated': , + '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': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': , + 'entity_id': 'switch.mock_door_lock_privacy_mode_button', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + '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': , + 'entity_id': 'switch.mock_door_lock_privacy_mode_button', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- # name: test_switches[eve_energy_plug][switch.eve_energy_plug-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ diff --git a/tests/components/matter/test_number.py b/tests/components/matter/test_number.py index d544562afecec1..bca68179f40508 100644 --- a/tests/components/matter/test_number.py +++ b/tests/components/matter/test_number.py @@ -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!" diff --git a/tests/components/recorder/test_statistics.py b/tests/components/recorder/test_statistics.py index a8d8ed61020230..40baffa7b3ef4f 100644 --- a/tests/components/recorder/test_statistics.py +++ b/tests/components/recorder/test_statistics.py @@ -61,6 +61,7 @@ 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__}" + ) diff --git a/tests/components/reolink/snapshots/test_diagnostics.ambr b/tests/components/reolink/snapshots/test_diagnostics.ambr index 868a1d4ba9cdda..360816fc683aa5 100644 --- a/tests/components/reolink/snapshots/test_diagnostics.ambr +++ b/tests/components/reolink/snapshots/test_diagnostics.ambr @@ -90,8 +90,8 @@ 'null': 2, }), 'GetAiAlarm': dict({ - '0': 5, - 'null': 5, + '0': 6, + 'null': 6, }), 'GetAiCfg': dict({ '0': 2, diff --git a/tests/components/zha/test_config_flow.py b/tests/components/zha/test_config_flow.py index cb0ad5dc6d7ecc..581d49f7eec928 100644 --- a/tests/components/zha/test_config_flow.py +++ b/tests/components/zha/test_config_flow.py @@ -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,34 +1193,151 @@ 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 + ): + result_create = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_HARDWARE}, data=data + ) + 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_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 ) - if onboarded: - # Confirm discovery - assert result1["type"] is FlowResultType.FORM - assert result1["step_id"] == "confirm" + # 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={}, + 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 result2["type"] is FlowResultType.MENU - assert result2["step_id"] == "choose_setup_strategy" + assert result_hardware["type"] is FlowResultType.FORM + assert result_hardware["step_id"] == "confirm" - result_create = await hass.config_entries.flow.async_configure( - result2["flow_id"], - user_input={"next_step_id": config_flow.SETUP_STRATEGY_RECOMMENDED}, + 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 ) - await hass.async_block_till_done() - else: - # No need to confirm - result_create = result1 + 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: { @@ -1233,6 +1349,145 @@ async def test_hardware(onboarded, hass: HomeAssistant) -> None: } +@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"}] ) diff --git a/tests/util/test_async_iterator.py b/tests/util/test_async_iterator.py new file mode 100644 index 00000000000000..866b0c8c51c8d8 --- /dev/null +++ b/tests/util/test_async_iterator.py @@ -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