diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index 4242d428fa98b..7dfd7c71488b2 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -57,7 +57,8 @@ for before merging your code. AI tools are welcome, but contributors are responsible for *fully* - understanding the code before submitting a PR. + understanding the code before submitting a PR. Please follow our AI policy: + https://developers.home-assistant.io/docs/ai_policy --> - [ ] I understand the code I am submitting and can explain how it works. diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 5ec53f523ad43..a7fe2b04d4868 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -74,7 +74,8 @@ The PR description must follow this template (from `.github/PULL_REQUEST_TEMPLAT for before merging your code. AI tools are welcome, but contributors are responsible for *fully* - understanding the code before submitting a PR. + understanding the code before submitting a PR. Please follow our AI policy: + https://developers.home-assistant.io/docs/ai_policy --> - [ ] I understand the code I am submitting and can explain how it works. @@ -181,3 +182,11 @@ This repository contains the core of Home Assistant, a Python 3 based home autom - Do not add comments that just restate the code on the following line(s) (e.g. `# Check if initialized` above `if self.initialized:`). Comments should only explain why (non-obvious constraints, surprising behavior, or workarounds), never what. Never add comments that justify a change by referencing what the code looked like before. - Do not add section or divider comments (e.g. `# --- XYZ Triggers ---`) inside or outside of functions, since those can easily become stale and be misleading. - When catching exceptions, try-clauses should be as small as possible, i.e. avoid wrapping large blocks of code in a try-clause, and avoid catching exceptions from functions that are not expected to raise them. + +## AI policy + +This project follows the [Open Home Foundation AI Policy](AI_POLICY.md). +Autonomous contributions are not accepted: a human must review, understand, +and be able to explain every change before it is submitted. Do not open +issues or pull requests autonomously, and do not post comments on behalf of +a user without their review. diff --git a/AGENTS.md b/AGENTS.md index cd4be0a644e1b..ff6f455edee24 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -45,3 +45,11 @@ This repository contains the core of Home Assistant, a Python 3 based home autom - Do not add comments that just restate the code on the following line(s) (e.g. `# Check if initialized` above `if self.initialized:`). Comments should only explain why (non-obvious constraints, surprising behavior, or workarounds), never what. Never add comments that justify a change by referencing what the code looked like before. - Do not add section or divider comments (e.g. `# --- XYZ Triggers ---`) inside or outside of functions, since those can easily become stale and be misleading. - When catching exceptions, try-clauses should be as small as possible, i.e. avoid wrapping large blocks of code in a try-clause, and avoid catching exceptions from functions that are not expected to raise them. + +## AI policy + +This project follows the [Open Home Foundation AI Policy](AI_POLICY.md). +Autonomous contributions are not accepted: a human must review, understand, +and be able to explain every change before it is submitted. Do not open +issues or pull requests autonomously, and do not post comments on behalf of +a user without their review. diff --git a/AI_POLICY.md b/AI_POLICY.md new file mode 100644 index 0000000000000..8b247e95e758b --- /dev/null +++ b/AI_POLICY.md @@ -0,0 +1,45 @@ +# Open Home Foundation - AI Policy + +We support using AI (i.e., LLMs) as tools when contributing to Open Home Foundation projects. However, you are responsible for any contributions you submit, and we are responsible for any contributions we merge and release. We hold a high bar for all contributions to our projects. + +Our maintainers dedicate their time and expertise to reviewing contributions. Submitting AI-generated content that you have not personally reviewed and understood wastes that time and will not be accepted. + +## Autonomous agents + +**We do not allow autonomous agents to be used for contributing to our projects.** We will close any pull requests or issues that we believe were created autonomously, and may mark automated comments as spam. This includes contributions that bypass the provided issue or pull request templates. + +## Communication on issues, pull requests, and code reviews + +We don't mind if you use AI tools to help you write. However, do not have tools post unreviewed content on your behalf. Keep responses to the minimum needed to communicate your intent. We may hide any comments that we believe are unreviewed AI output. + +If you are opening a pull request, we expect you to be able to explain the proposed changes in your own words. This includes the pull request description and responses to questions. If you use AI to help generate the pull request summary, you must review it for technical accuracy. + +**Do not use AI to generate answers to questions from maintainers.** You should understand and be able to explain your own work. Using AI to improve grammar or clarity is fine, but the substance of your responses must be your own. + +If you wish to include context from an interaction with AI in your comments, it must be in a quote block (e.g., using `>`) and disclosed as such. It must be accompanied by your own commentary explaining the relevance and implications of the context. Do not share long snippets. + +## Non-native English speakers + +We understand that AI is useful when communicating as a non-native English speaker. Using AI to improve the grammar or clarity of text you have written yourself is fine. If you are using AI to translate your comments, please ensure the translation accurately reflects your intent. Including your original text in a details block shows the effort behind your contribution, helps maintainers verify the translation if needed, and keeps the conversation readable. + +## Code and documentation contributions + +AI can be a helpful tool for writing code and documentation. However, due to the foundational open source nature of our projects, we require a human in the loop who understands the work produced by AI. + +All contributions must be reviewed and understood by the contributor before submission. You should be able to explain every change in a pull request you submit. Pull requests that appear to be unreviewed AI output will be closed without review. + +## Our use of AI + +Some of our projects use AI tools to assist with code reviews, issue triaging, reporting, and other project management tasks. These tools may leave comments on pull requests or issues. As with any automated tooling, these comments are not always correct. + +If an AI tool leaves a comment on your contribution, treat it as you would any other review comment. If you believe it is incorrect, say so; a brief explanation is sufficient. Maintainers always have the final say. If in doubt, ask a maintainer. + +## Enforcement + +Contributions that do not follow this policy will be closed. Repeated violations may result in being blocked from contributing to OHF projects. If you believe your contribution was closed in error, you are welcome to reach out to a maintainer to discuss. + +--- + +The canonical version of this policy is published at +. In case of differences, +the published version applies. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index e7d8488048e58..1df57d84e5e75 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -19,3 +19,10 @@ If you want to suggest a new feature for Home Assistant (e.g. new integrations), ## Issue Tracker If you want to report an issue, please [create an issue](https://github.com/home-assistant/core/issues) on GitHub. + +## AI policy + +This project follows the [Open Home Foundation AI Policy](AI_POLICY.md). In +short: AI tools are welcome as an aid, but you must fully understand and be +able to explain every change you submit. Contributions made by autonomous +agents are not accepted. diff --git a/homeassistant/components/acmeda/entity.py b/homeassistant/components/acmeda/entity.py index e87e0a7c340a4..f10e3efca321d 100644 --- a/homeassistant/components/acmeda/entity.py +++ b/homeassistant/components/acmeda/entity.py @@ -29,16 +29,8 @@ async def async_remove_and_unregister(self) -> None: if self.entity_id in ent_registry.entities: ent_registry.async_remove(self.entity_id) - dev_registry = dr.async_get(self.hass) - device = dev_registry.async_get_device(identifiers={(DOMAIN, self.unique_id)}) - if ( - device is not None - and self.registry_entry is not None - and self.registry_entry.config_entry_id is not None - ): - dev_registry.async_update_device( - device.id, remove_config_entry_id=self.registry_entry.config_entry_id - ) + if self.device_entry: + dr.async_get(self.hass).async_remove_device(self.device_entry.id) await self.async_remove(force_remove=True) diff --git a/homeassistant/components/daikin/manifest.json b/homeassistant/components/daikin/manifest.json index ade738d2f7f93..792d404ad9ca0 100644 --- a/homeassistant/components/daikin/manifest.json +++ b/homeassistant/components/daikin/manifest.json @@ -7,6 +7,6 @@ "integration_type": "device", "iot_class": "local_polling", "loggers": ["pydaikin"], - "requirements": ["pydaikin==2.18.2"], + "requirements": ["pydaikin==2.18.3"], "zeroconf": ["_dkapi._tcp.local."] } diff --git a/homeassistant/components/devolo_home_control/entity.py b/homeassistant/components/devolo_home_control/entity.py index 70bfca92bceab..a47eb2c03ee98 100644 --- a/homeassistant/components/devolo_home_control/entity.py +++ b/homeassistant/components/devolo_home_control/entity.py @@ -103,16 +103,8 @@ def _generic_message(self, message: tuple) -> None: ].name, ) self._attr_available = state - elif message[1] == "del" and self.platform.config_entry: - device_registry = dr.async_get(self.hass) - device = device_registry.async_get_device( - identifiers={(DOMAIN, self._device_instance.uid)} - ) - if device: - device_registry.async_update_device( - device.id, - remove_config_entry_id=self.platform.config_entry.entry_id, - ) + elif message[1] == "del" and self.device_entry: + dr.async_get(self.hass).async_remove_device(self.device_entry.id) else: _LOGGER.debug("No valid message received: %s", message) diff --git a/homeassistant/components/earn_e_p1/manifest.json b/homeassistant/components/earn_e_p1/manifest.json index 39f9064f14e53..76f4ecb8d77c2 100644 --- a/homeassistant/components/earn_e_p1/manifest.json +++ b/homeassistant/components/earn_e_p1/manifest.json @@ -7,5 +7,5 @@ "integration_type": "device", "iot_class": "local_push", "quality_scale": "bronze", - "requirements": ["earn-e-p1==0.1.0"] + "requirements": ["earn-e-p1==0.2.0"] } diff --git a/homeassistant/components/edifier_infrared/__init__.py b/homeassistant/components/edifier_infrared/__init__.py index 251b388929cff..9c92c66d9ebf5 100644 --- a/homeassistant/components/edifier_infrared/__init__.py +++ b/homeassistant/components/edifier_infrared/__init__.py @@ -1,9 +1,13 @@ """Edifier infrared integration for Home Assistant.""" +from infrared_protocols.codes.edifier.models import MODEL_TO_COMMAND_SET, EdifierModel + from homeassistant.config_entries import ConfigEntry -from homeassistant.const import Platform +from homeassistant.const import CONF_MODEL, Platform from homeassistant.core import HomeAssistant +from .const import CONF_COMMAND_SET, CONF_INFRARED_ENTITY_ID + PLATFORMS = [Platform.BUTTON, Platform.MEDIA_PLAYER] @@ -13,6 +17,30 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: return True +async def async_migrate_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Migrate old config entries.""" + if entry.version > 2: + return False + + if entry.version == 1: + data = {**entry.data} + # The R1700BT model was renamed to R1700BT (pre-2017), and its + # command set was split from the one shared with the R1700BTs + # family, which it was incorrectly grouped with. + if data[CONF_MODEL] == "R1700BT": + data[CONF_MODEL] = EdifierModel.R1700BT_PRE_2017.value + command_set = MODEL_TO_COMMAND_SET[EdifierModel(data[CONF_MODEL])] + data[CONF_COMMAND_SET] = command_set.value + hass.config_entries.async_update_entry( + entry, + data=data, + unique_id=f"{command_set.value}_{data[CONF_INFRARED_ENTITY_ID]}", + version=2, + ) + + return True + + async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Unload an Edifier IR config entry.""" return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/homeassistant/components/edifier_infrared/button.py b/homeassistant/components/edifier_infrared/button.py index 10cd8d2e481e3..029487f6c393f 100644 --- a/homeassistant/components/edifier_infrared/button.py +++ b/homeassistant/components/edifier_infrared/button.py @@ -5,7 +5,9 @@ from infrared_protocols.codes.edifier.models import EdifierCommandSet, EdifierModel from infrared_protocols.codes.edifier.r1280db import EdifierR1280DBCode -from infrared_protocols.codes.edifier.r1700bt import EdifierR1700BTCode +from infrared_protocols.codes.edifier.r1700bt_2017 import EdifierR1700BT2017Code +from infrared_protocols.codes.edifier.r1700bt_pre_2017 import EdifierR1700BTPre2017Code +from infrared_protocols.codes.edifier.r1700bts import EdifierR1700BTsCode from infrared_protocols.codes.edifier.rc20g import EdifierRC20GCode from infrared_protocols.codes.edifier.s360db import EdifierS360DBCode from infrared_protocols.codes.edifier.s3000pro import EdifierS3000ProCode @@ -34,31 +36,55 @@ class EdifierIrButtonEntityDescription(ButtonEntityDescription): EdifierCommandSet, tuple[EdifierIrButtonEntityDescription, ...], ] = { - EdifierCommandSet.R1700BT: ( + EdifierCommandSet.R1700BT_PRE_2017: ( EdifierIrButtonEntityDescription( key="bluetooth", translation_key="bluetooth", - command_code=EdifierR1700BTCode.BLUETOOTH, + command_code=EdifierR1700BTPre2017Code.BLUETOOTH, + ), + EdifierIrButtonEntityDescription( + key="line", + translation_key="line", + command_code=EdifierR1700BTPre2017Code.LINE, + ), + ), + EdifierCommandSet.R1700BT_2017: ( + EdifierIrButtonEntityDescription( + key="bluetooth", + translation_key="bluetooth", + command_code=EdifierR1700BT2017Code.BLUETOOTH, + ), + EdifierIrButtonEntityDescription( + key="line", + translation_key="line", + command_code=EdifierR1700BT2017Code.LINE, + ), + ), + EdifierCommandSet.R1700BTS: ( + EdifierIrButtonEntityDescription( + key="bluetooth", + translation_key="bluetooth", + command_code=EdifierR1700BTsCode.BLUETOOTH, ), EdifierIrButtonEntityDescription( key="line_1", translation_key="line_1", - command_code=EdifierR1700BTCode.LINE_1, + command_code=EdifierR1700BTsCode.LINE_1, ), EdifierIrButtonEntityDescription( key="line_2", translation_key="line_2", - command_code=EdifierR1700BTCode.LINE_2, + command_code=EdifierR1700BTsCode.LINE_2, ), EdifierIrButtonEntityDescription( key="fx_on", translation_key="fx_on", - command_code=EdifierR1700BTCode.FX_ON, + command_code=EdifierR1700BTsCode.FX_ON, ), EdifierIrButtonEntityDescription( key="fx_off", translation_key="fx_off", - command_code=EdifierR1700BTCode.FX_OFF, + command_code=EdifierR1700BTsCode.FX_OFF, ), ), EdifierCommandSet.R1280DB: ( diff --git a/homeassistant/components/edifier_infrared/config_flow.py b/homeassistant/components/edifier_infrared/config_flow.py index 528a3b9630361..76fde9b7c5f4a 100644 --- a/homeassistant/components/edifier_infrared/config_flow.py +++ b/homeassistant/components/edifier_infrared/config_flow.py @@ -25,7 +25,7 @@ class EdifierIrConfigFlow(ConfigFlow, domain=DOMAIN): """Handle config flow for Edifier IR.""" - VERSION = 1 + VERSION = 2 MINOR_VERSION = 1 @override diff --git a/homeassistant/components/edifier_infrared/const.py b/homeassistant/components/edifier_infrared/const.py index 4fd4b959d7b24..9575ad58fdd99 100644 --- a/homeassistant/components/edifier_infrared/const.py +++ b/homeassistant/components/edifier_infrared/const.py @@ -2,7 +2,9 @@ from infrared_protocols.codes.edifier.r1280db import EdifierR1280DBCode from infrared_protocols.codes.edifier.r1280t import EdifierR1280TCode -from infrared_protocols.codes.edifier.r1700bt import EdifierR1700BTCode +from infrared_protocols.codes.edifier.r1700bt_2017 import EdifierR1700BT2017Code +from infrared_protocols.codes.edifier.r1700bt_pre_2017 import EdifierR1700BTPre2017Code +from infrared_protocols.codes.edifier.r1700bts import EdifierR1700BTsCode from infrared_protocols.codes.edifier.rc20g import EdifierRC20GCode from infrared_protocols.codes.edifier.s360db import EdifierS360DBCode from infrared_protocols.codes.edifier.s3000pro import EdifierS3000ProCode @@ -12,7 +14,9 @@ CONF_COMMAND_SET = "command_set" type EdifierCode = ( - EdifierR1700BTCode + EdifierR1700BTPre2017Code + | EdifierR1700BT2017Code + | EdifierR1700BTsCode | EdifierR1280DBCode | EdifierR1280TCode | EdifierS360DBCode diff --git a/homeassistant/components/edifier_infrared/media_player.py b/homeassistant/components/edifier_infrared/media_player.py index 8c9963e80e9d4..b591d9bb7b5d4 100644 --- a/homeassistant/components/edifier_infrared/media_player.py +++ b/homeassistant/components/edifier_infrared/media_player.py @@ -5,7 +5,9 @@ from infrared_protocols.codes.edifier.models import EdifierCommandSet, EdifierModel from infrared_protocols.codes.edifier.r1280db import EdifierR1280DBCode from infrared_protocols.codes.edifier.r1280t import EdifierR1280TCode -from infrared_protocols.codes.edifier.r1700bt import EdifierR1700BTCode +from infrared_protocols.codes.edifier.r1700bt_2017 import EdifierR1700BT2017Code +from infrared_protocols.codes.edifier.r1700bt_pre_2017 import EdifierR1700BTPre2017Code +from infrared_protocols.codes.edifier.r1700bts import EdifierR1700BTsCode from infrared_protocols.codes.edifier.rc20g import EdifierRC20GCode from infrared_protocols.codes.edifier.s360db import EdifierS360DBCode from infrared_protocols.codes.edifier.s3000pro import EdifierS3000ProCode @@ -35,18 +37,34 @@ tuple[EdifierCode | tuple[EdifierCode, ...], ...], ], ] = { - EdifierCommandSet.R1700BT: { - MediaPlayerEntityFeature.TURN_ON: (EdifierR1700BTCode.POWER,), - MediaPlayerEntityFeature.TURN_OFF: (EdifierR1700BTCode.POWER,), + EdifierCommandSet.R1700BT_PRE_2017: { MediaPlayerEntityFeature.VOLUME_STEP: ( - (EdifierR1700BTCode.VOLUME_UP,), - (EdifierR1700BTCode.VOLUME_DOWN,), + (EdifierR1700BTPre2017Code.VOLUME_UP,), + (EdifierR1700BTPre2017Code.VOLUME_DOWN,), ), - MediaPlayerEntityFeature.VOLUME_MUTE: (EdifierR1700BTCode.MUTE,), - MediaPlayerEntityFeature.PLAY: (EdifierR1700BTCode.PLAY_PAUSE,), - MediaPlayerEntityFeature.PAUSE: (EdifierR1700BTCode.PLAY_PAUSE,), - MediaPlayerEntityFeature.NEXT_TRACK: (EdifierR1700BTCode.FORWARD,), - MediaPlayerEntityFeature.PREVIOUS_TRACK: (EdifierR1700BTCode.BACK,), + MediaPlayerEntityFeature.VOLUME_MUTE: (EdifierR1700BTPre2017Code.MUTE,), + }, + EdifierCommandSet.R1700BT_2017: { + MediaPlayerEntityFeature.TURN_ON: (EdifierR1700BT2017Code.POWER,), + MediaPlayerEntityFeature.TURN_OFF: (EdifierR1700BT2017Code.POWER,), + MediaPlayerEntityFeature.VOLUME_STEP: ( + (EdifierR1700BT2017Code.VOLUME_UP,), + (EdifierR1700BT2017Code.VOLUME_DOWN,), + ), + MediaPlayerEntityFeature.VOLUME_MUTE: (EdifierR1700BT2017Code.MUTE,), + }, + EdifierCommandSet.R1700BTS: { + MediaPlayerEntityFeature.TURN_ON: (EdifierR1700BTsCode.POWER,), + MediaPlayerEntityFeature.TURN_OFF: (EdifierR1700BTsCode.POWER,), + MediaPlayerEntityFeature.VOLUME_STEP: ( + (EdifierR1700BTsCode.VOLUME_UP,), + (EdifierR1700BTsCode.VOLUME_DOWN,), + ), + MediaPlayerEntityFeature.VOLUME_MUTE: (EdifierR1700BTsCode.MUTE,), + MediaPlayerEntityFeature.PLAY: (EdifierR1700BTsCode.PLAY_PAUSE,), + MediaPlayerEntityFeature.PAUSE: (EdifierR1700BTsCode.PLAY_PAUSE,), + MediaPlayerEntityFeature.NEXT_TRACK: (EdifierR1700BTsCode.FORWARD,), + MediaPlayerEntityFeature.PREVIOUS_TRACK: (EdifierR1700BTsCode.BACK,), }, EdifierCommandSet.R1280DB: { MediaPlayerEntityFeature.TURN_ON: (EdifierR1280DBCode.POWER,), diff --git a/homeassistant/components/edifier_infrared/strings.json b/homeassistant/components/edifier_infrared/strings.json index 0ed3c9e2c2a87..65ba46dd839d0 100644 --- a/homeassistant/components/edifier_infrared/strings.json +++ b/homeassistant/components/edifier_infrared/strings.json @@ -48,6 +48,9 @@ "fx_on": { "name": "FX on" }, + "line": { + "name": "Line" + }, "line_1": { "name": "Line 1" }, diff --git a/homeassistant/components/eq3btsmart/climate.py b/homeassistant/components/eq3btsmart/climate.py index 0fed7cdd9bb6f..c108873748cdd 100644 --- a/homeassistant/components/eq3btsmart/climate.py +++ b/homeassistant/components/eq3btsmart/climate.py @@ -25,7 +25,6 @@ from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import ServiceValidationError from homeassistant.helpers import device_registry as dr -from homeassistant.helpers.device_registry import CONNECTION_BLUETOOTH from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback import homeassistant.util.dt as dt_util @@ -96,12 +95,9 @@ def _async_on_status_updated(self, data: Any) -> None: def _async_on_device_updated(self, data: Any) -> None: """Handle updated device data from the thermostat.""" - device_registry = dr.async_get(self.hass) - if device := device_registry.async_get_device( - connections={(CONNECTION_BLUETOOTH, self._eq3_config.mac_address)}, - ): - device_registry.async_update_device( - device.id, + if self.device_entry: + dr.async_get(self.hass).async_update_device( + self.device_entry.id, sw_version=str(self._thermostat.device_data.firmware_version), serial_number=self._thermostat.device_data.device_serial, ) diff --git a/homeassistant/components/freebox/entity.py b/homeassistant/components/freebox/entity.py index dc210b079657b..6b2b32a61b418 100644 --- a/homeassistant/components/freebox/entity.py +++ b/homeassistant/components/freebox/entity.py @@ -62,11 +62,10 @@ async def async_update_signal(self) -> None: self._node = self._router.home_devices[self._id] # Propagate Freebox device label changes to the device registry so # the entity stays in sync when users rename it on the Freebox app. - device_registry = dr.async_get(self.hass) - if device := device_registry.async_get_device(identifiers={(DOMAIN, self._id)}): + if device := self.device_entry: new_name = self._node["label"].strip() if device.name != new_name: - device_registry.async_update_device(device.id, name=new_name) + dr.async_get(self.hass).async_update_device(device.id, name=new_name) self.async_write_ha_state() async def set_home_endpoint_value( diff --git a/homeassistant/components/frontend/__init__.py b/homeassistant/components/frontend/__init__.py index 1971a9b7f1f56..c9d59ee46c815 100644 --- a/homeassistant/components/frontend/__init__.py +++ b/homeassistant/components/frontend/__init__.py @@ -558,19 +558,26 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: # Shopping list panel was replaced by todo panel in 2023.11 hass.http.register_redirect("/shopping-list", "/todo") - # Developer tools moved to config panel in 2026.2 - for url in ( - "/developer-tools", - "/developer-tools/yaml", - "/developer-tools/state", - "/developer-tools/action", - "/developer-tools/template", - "/developer-tools/event", - "/developer-tools/statistics", - "/developer-tools/assist", - "/developer-tools/debug", + # Developer tools moved to config in 2026.2 and was renamed to + # "Tools" (/config/tools) in 2026.8. Redirect both the original + # top-level URLs and the 2026.2 config URLs to the new location. + for suffix in ( + "", + "/yaml", + "/state", + "/action", + "/template", + "/event", + "/statistics", + "/assist", + "/debug", ): - hass.http.register_redirect(url, f"/config{url}") + hass.http.register_redirect( + f"/developer-tools{suffix}", f"/config/tools{suffix}" + ) + hass.http.register_redirect( + f"/config/developer-tools{suffix}", f"/config/tools{suffix}" + ) hass.http.app.router.register_resource(IndexView(repo_path, hass)) diff --git a/homeassistant/components/http/__init__.py b/homeassistant/components/http/__init__.py index 1c6c0c857ddc1..215976b5fbd2d 100644 --- a/homeassistant/components/http/__init__.py +++ b/homeassistant/components/http/__init__.py @@ -1,32 +1,12 @@ """Support to serve the Home Assistant API as WSGI application.""" -import asyncio -from collections.abc import Collection -from dataclasses import dataclass -import datetime -from functools import partial -from ipaddress import IPv4Network, IPv6Network, ip_network +from ipaddress import ip_network import logging import os from pathlib import Path -import socket -import ssl -from tempfile import NamedTemporaryFile -from typing import Any, Final, cast, override - -from aiohttp import web -from aiohttp.abc import AbstractStreamWriter -from aiohttp.http_parser import RawRequestMessage -from aiohttp.streams import StreamReader -from aiohttp.typedefs import JSONDecoder, StrOrURL -from aiohttp.web_exceptions import HTTPMovedPermanently, HTTPRedirection -from aiohttp.web_protocol import RequestHandler -from cryptography import x509 -from cryptography.hazmat.primitives import hashes, serialization -from cryptography.hazmat.primitives.asymmetric import rsa -from cryptography.x509.oid import NameOID +from typing import Any, Final + import voluptuous as vol -from yarl import URL from homeassistant.components.network import async_get_source_ip from homeassistant.const import ( @@ -38,27 +18,22 @@ from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import config_validation as cv, issue_registry as ir from homeassistant.helpers.hassio import is_hassio -from homeassistant.helpers.http import ( +from homeassistant.helpers.http import ( # noqa: F401 KEY_ALLOW_CONFIGURED_CORS, - KEY_AUTHENTICATED, # noqa: F401 + KEY_AUTHENTICATED, KEY_HASS, HomeAssistantView, current_request, ) from homeassistant.helpers.importlib import async_import_module -from homeassistant.helpers.network import NoURLAvailableError, get_url from homeassistant.helpers.typing import ConfigType from homeassistant.setup import ( SetupPhases, async_start_setup, async_when_setup_or_start, ) -from homeassistant.util import dt as dt_util, ssl as ssl_util from homeassistant.util.async_ import create_eager_task -from homeassistant.util.json import json_loads -from .auth import async_setup_auth -from .ban import setup_bans from .config import ( _DEFAULT_CONFIG, ConfData, @@ -89,25 +64,19 @@ SSL_INTERMEDIATE, SSL_MODERN, ) -from .cors import setup_cors from .decorators import require_admin # noqa: F401 -from .forwarded import async_setup_forwarded -from .headers import setup_headers -from .request_context import setup_request_context -from .security_filter import setup_security_filter -from .static import CACHE_HEADERS, CachingStaticResource -from .web_runner import HomeAssistantUnixSite +from .server import ( + DEFAULT_BIND, + HomeAssistantHTTP, # noqa: F401 + HomeAssistantRequest, # noqa: F401 + StaticPathConfig, # noqa: F401 + make_server, +) _LOGGER: Final = logging.getLogger(__name__) DEFAULT_DEVELOPMENT: Final = "0" -MAX_CLIENT_SIZE: Final = 1024**2 * 16 -MAX_LINE_SIZE: Final = 24570 - -_HAS_IPV6 = hasattr(socket, "AF_INET6") -_DEFAULT_BIND = ["0.0.0.0", "::"] if _HAS_IPV6 else ["0.0.0.0"] - HTTP_SCHEMA: Final = vol.All( cv.deprecated(CONF_BASE_URL), vol.Schema( @@ -142,21 +111,6 @@ CONFIG_SCHEMA: Final = vol.Schema({DOMAIN: HTTP_SCHEMA}, extra=vol.ALLOW_EXTRA) -@dataclass(slots=True) -class StaticPathConfig: - """Configuration for a static path.""" - - url_path: str - path: str - cache_headers: bool = True - - -_STATIC_CLASSES = { - True: CachingStaticResource, - False: web.StaticResource, -} - - class ApiConfig: """Configuration settings for API server.""" @@ -231,52 +185,6 @@ async def _async_fallback_config( return _DEFAULT_CONFIG -def _make_server( - hass: HomeAssistant, - conf: ConfData, - supervisor_unix_socket_path: Path | None = None, -) -> HomeAssistantHTTP: - """Create a server instance for the given config.""" - return HomeAssistantHTTP( - hass, - server_host=conf.get(CONF_SERVER_HOST, _DEFAULT_BIND), - server_port=conf[CONF_SERVER_PORT], - ssl_certificate=conf.get(CONF_SSL_CERTIFICATE), - ssl_peer_certificate=conf.get(CONF_SSL_PEER_CERTIFICATE), - ssl_key=conf.get(CONF_SSL_KEY), - # The loaded config stores trusted proxies as strings - # (JSON-serializable); the forwarded middleware needs - # IPv4Network/IPv6Network objects. - trusted_proxies=[ - ip_network(proxy) for proxy in conf.get(CONF_TRUSTED_PROXIES) or [] - ], - ssl_profile=conf[CONF_SSL_PROFILE], - supervisor_unix_socket_path=supervisor_unix_socket_path, - ) - - -async def async_verify_can_bind(hass: HomeAssistant, conf: ConfData) -> None: - """Verify a server for ``conf`` can be created and its address bound. - - Used to validate a new user-supplied config before it is stored and - applied via a restart; the sockets are released right away. Best effort: - the address can still be taken by another process before the restart, so - the setup fallback chain remains the safety net. - - Raises ``HomeAssistantError`` if the SSL configuration is unusable or the - configured address cannot be bound. - """ - server = _make_server(hass, conf) - try: - await server.async_bind() - except OSError as err: - raise HomeAssistantError( - f"Failed to create HTTP server at port {conf[CONF_SERVER_PORT]}: {err}" - ) from err - finally: - await server.stop() - - async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up the HTTP API and debug interface.""" # Late import to ensure isal is updated before @@ -308,7 +216,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: socket_env, ) - server = _make_server(hass, conf, supervisor_unix_socket_path) + server = make_server(hass, conf, supervisor_unix_socket_path) trial_reverted = False while True: try: @@ -317,7 +225,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: store = await async_get_and_load_store(hass) trial_reverted = store.revert_deadline is not None conf = await _async_fallback_config(hass, store, conf, err) - server = _make_server(hass, conf, supervisor_unix_socket_path) + server = make_server(hass, conf, supervisor_unix_socket_path) continue if trial_reverted: _LOGGER.warning( @@ -352,7 +260,7 @@ async def stop_server(event: Event) -> None: translation_key=issue_id, ) - server_host = conf.get(CONF_SERVER_HOST, _DEFAULT_BIND) + server_host = conf.get(CONF_SERVER_HOST, DEFAULT_BIND) server_port = conf[CONF_SERVER_PORT] ssl_certificate = conf.get(CONF_SSL_CERTIFICATE) @@ -425,426 +333,3 @@ def _async_check_ssl_issue(_: Event) -> None: hass.bus.async_listen_once(EVENT_HOMEASSISTANT_START, _async_check_ssl_issue) return True - - -class HomeAssistantRequest(web.Request): - """Home Assistant request object.""" - - @override - async def json(self, *, loads: JSONDecoder = json_loads) -> Any: - """Return body as JSON.""" - # json_loads is a wrapper around orjson.loads that handles - # bytes and str. We can pass the bytes directly to json_loads. - return json_loads(await self.read()) - - -class HomeAssistantApplication(web.Application): - """Home Assistant application.""" - - @override - def _make_request( - self, - message: RawRequestMessage, - payload: StreamReader, - protocol: RequestHandler, - writer: AbstractStreamWriter, - task: asyncio.Task[None], - _cls: type[web.Request] = HomeAssistantRequest, - ) -> web.Request: - """Create request instance.""" - return _cls( - message, - payload, - protocol, - writer, - task, - # loop will never be None when called from aiohttp - loop=self._loop, # type: ignore[arg-type] - client_max_size=self._client_max_size, - ) - - -async def _serve_file_with_cache_headers( - path: str, request: web.Request -) -> web.FileResponse: - return web.FileResponse(path, headers=CACHE_HEADERS) - - -async def _serve_file(path: str, request: web.Request) -> web.FileResponse: - return web.FileResponse(path) - - -class HomeAssistantHTTP: - """HTTP server for Home Assistant.""" - - def __init__( - self, - hass: HomeAssistant, - ssl_certificate: str | None, - ssl_peer_certificate: str | None, - ssl_key: str | None, - server_host: list[str] | None, - server_port: int, - trusted_proxies: list[IPv4Network | IPv6Network], - ssl_profile: str, - supervisor_unix_socket_path: Path | None = None, - ) -> None: - """Initialize the HTTP Home Assistant server.""" - self.app = HomeAssistantApplication( - middlewares=[], - client_max_size=MAX_CLIENT_SIZE, - handler_args={ - "max_line_size": MAX_LINE_SIZE, - "max_field_size": MAX_LINE_SIZE, - }, - ) - self.hass = hass - self.ssl_certificate = ssl_certificate - self.ssl_peer_certificate = ssl_peer_certificate - self.ssl_key = ssl_key - self.server_host = server_host - self.server_port = server_port - self.trusted_proxies = trusted_proxies - self.ssl_profile = ssl_profile - self.supervisor_unix_socket_path = supervisor_unix_socket_path - self.runner: web.AppRunner | None = None - self.supervisor_site: HomeAssistantUnixSite | None = None - self.context: ssl.SSLContext | None = None - self._server: asyncio.Server | None = None - - async def async_bind(self) -> None: - """Create the SSL context and the server, binding its sockets. - - Called during setup so that an unusable configuration surfaces before - it is applied; serving starts later in ``start()``. Raises - ``HomeAssistantError`` if the SSL configuration is unusable and - ``OSError`` if the configured address cannot be bound. - """ - if self.ssl_certificate: - self.context = await self.hass.async_add_executor_job( - self._create_ssl_context - ) - self._server = await self._async_create_server() - - async def _async_create_server(self) -> asyncio.Server: - """Create the (not yet serving) HTTP server, binding its sockets.""" - try: - return await self.hass.loop.create_server( - self._make_protocol, - self.server_host if self.server_host is not None else _DEFAULT_BIND, - self.server_port, - ssl=self.context, - backlog=128, - start_serving=False, - ) - except UnicodeError as err: - # create_server() raises UnicodeError for hosts the IDNA codec - # cannot encode (e.g. a label longer than 63 characters); - # normalize to OSError so callers only need to handle one error - # type. - raise OSError(f"error while resolving host: {err}") from err - - def _make_protocol(self) -> RequestHandler: - """Create a protocol instance for an accepted connection. - - Connections are only accepted once ``start()`` has run, so the - runner is set up by the time this is called. - """ - runner = self.runner - assert runner is not None and runner.server is not None - return runner.server() - - async def async_initialize( - self, - *, - cors_origins: list[str], - use_x_forwarded_for: bool, - login_threshold: int, - is_ban_enabled: bool, - use_x_frame_options: bool, - ) -> None: - """Initialize the server.""" - self.app[KEY_HASS] = self.hass - self.app["hass"] = self.hass # For backwards compatibility - - # Order matters, security filters middleware needs to go first, - # forwarded middleware needs to go second. - setup_security_filter(self.app) - - async_setup_forwarded(self.app, use_x_forwarded_for, self.trusted_proxies) - - setup_request_context(self.app, current_request) - - if is_ban_enabled: - setup_bans(self.hass, self.app, login_threshold) - - await async_setup_auth(self.hass, self.app) - - setup_headers(self.app, use_x_frame_options) - setup_cors(self.app, cors_origins) - - def register_view(self, view: HomeAssistantView | type[HomeAssistantView]) -> None: - """Register a view with the WSGI server. - - The view argument must be a class that inherits from HomeAssistantView. - It is optional to instantiate it before registering; this method will - handle it either way. - """ - if isinstance(view, type): - # Instantiate the view, if needed - view = view() - - if not hasattr(view, "url"): - class_name = view.__class__.__name__ - raise AttributeError(f'{class_name} missing required attribute "url"') - - if not hasattr(view, "name"): - class_name = view.__class__.__name__ - raise AttributeError(f'{class_name} missing required attribute "name"') - - view.register(self.hass, self.app, self.app.router) - - def register_redirect( - self, - url: str, - redirect_to: StrOrURL, - *, - redirect_exc: type[HTTPRedirection] = HTTPMovedPermanently, - ) -> None: - """Register a redirect with the server. - - If given this must be either a string or callable. In case of a - callable it's called with the url adapter that triggered the match and - the values of the URL as keyword arguments and has to return the target - for the redirect, otherwise it has to be a string with placeholders in - rule syntax. - """ - - async def redirect(request: web.Request) -> web.StreamResponse: - """Redirect to location.""" - # Should be instance of aiohttp.web_exceptions._HTTPMove. - raise redirect_exc(redirect_to) # type: ignore[arg-type,call-arg] - - self.app[KEY_ALLOW_CONFIGURED_CORS]( - self.app.router.add_route("GET", url, redirect) - ) - - def _make_static_resources( - self, configs: Collection[StaticPathConfig] - ) -> dict[str, CachingStaticResource | web.StaticResource | None]: - """Create a list of static resources.""" - return { - config.url_path: _STATIC_CLASSES[config.cache_headers]( - config.url_path, config.path - ) - if os.path.isdir(config.path) - else None - for config in configs - } - - async def async_register_static_paths( - self, configs: Collection[StaticPathConfig] - ) -> None: - """Register a folder or file to serve as a static path.""" - resources = await self.hass.async_add_executor_job( - self._make_static_resources, configs - ) - self._async_register_static_paths(configs, resources) - - @callback - def _async_register_static_paths( - self, - configs: Collection[StaticPathConfig], - resources: dict[str, CachingStaticResource | web.StaticResource | None], - ) -> None: - """Register a folders or files to serve as a static path.""" - app = self.app - allow_cors = app[KEY_ALLOW_CONFIGURED_CORS] - for config in configs: - if resource := resources[config.url_path]: - app.router.register_resource(resource) - allow_cors(resource) - - target = ( - _serve_file_with_cache_headers if config.cache_headers else _serve_file - ) - allow_cors( - self.app.router.add_route( - "GET", config.url_path, partial(target, config.path) - ) - ) - - def _create_ssl_context(self) -> ssl.SSLContext | None: - context: ssl.SSLContext | None = None - assert self.ssl_certificate is not None - try: - if self.ssl_profile == SSL_INTERMEDIATE: - context = ssl_util.server_context_intermediate() - else: - context = ssl_util.server_context_modern() - context.load_cert_chain(self.ssl_certificate, self.ssl_key) - except OSError as error: - if not self.hass.config.recovery_mode: - raise HomeAssistantError( - f"Could not use SSL certificate from {self.ssl_certificate}:" - f" {error}" - ) from error - _LOGGER.error( - "Could not read SSL certificate from %s: %s", - self.ssl_certificate, - error, - ) - try: - context = self._create_emergency_ssl_context() - except OSError as error2: - _LOGGER.error( - "Could not create an emergency self signed ssl certificate: %s", - error2, - ) - context = None - else: - # Fall through: a configured peer certificate must still be - # enforced on the emergency context. - _LOGGER.critical( - "Home Assistant is running in recovery mode with an emergency self" - " signed ssl certificate because the configured SSL certificate was" - " not usable" - ) - - if self.ssl_peer_certificate: - if context is None: - raise HomeAssistantError( - "Failed to create ssl context, no fallback available because a peer" - " certificate is required." - ) - - context.verify_mode = ssl.CERT_REQUIRED - try: - context.load_verify_locations(self.ssl_peer_certificate) - except OSError as error: - # Raise HomeAssistantError so the caller can tell an unusable - # SSL configuration apart from a socket bind failure (OSError). - raise HomeAssistantError( - f"Could not use SSL peer certificate from" - f" {self.ssl_peer_certificate}: {error}" - ) from error - - return context - - def _create_emergency_ssl_context(self) -> ssl.SSLContext: - """Create an emergency ssl certificate so we can still startup.""" - context = ssl_util.server_context_modern() - host: str - try: - host = cast(str, URL(get_url(self.hass, prefer_external=True)).host) - except NoURLAvailableError: - host = "homeassistant.local" - key = rsa.generate_private_key( - public_exponent=65537, - key_size=2048, - ) - subject = issuer = x509.Name( - [ - x509.NameAttribute( - NameOID.ORGANIZATION_NAME, "Home Assistant Emergency Certificate" - ), - x509.NameAttribute(NameOID.COMMON_NAME, host), - ] - ) - now = dt_util.utcnow() - cert = ( - x509.CertificateBuilder() - .subject_name(subject) - .issuer_name(issuer) - .public_key(key.public_key()) - .serial_number(x509.random_serial_number()) - .not_valid_before(now) - .not_valid_after(now + datetime.timedelta(days=30)) - .add_extension( - x509.SubjectAlternativeName([x509.DNSName(host)]), - critical=False, - ) - .sign(key, hashes.SHA256()) - ) - with NamedTemporaryFile() as cert_pem, NamedTemporaryFile() as key_pem: - cert_pem.write(cert.public_bytes(serialization.Encoding.PEM)) - key_pem.write( - key.private_bytes( - serialization.Encoding.PEM, - format=serialization.PrivateFormat.TraditionalOpenSSL, - encryption_algorithm=serialization.NoEncryption(), - ) - ) - cert_pem.flush() - key_pem.flush() - context.load_cert_chain(cert_pem.name, key_pem.name) - return context - - async def async_start_supervisor_unix_socket(self) -> None: - """Start listening on the Unix socket. - - This is called separately from start() to delay serving the Unix - socket until the Supervisor user exists (created by the hassio - integration). Without this delay, Supervisor could connect before - its user is available and receive 401 responses it won't retry. - """ - if self.supervisor_unix_socket_path is None or self.runner is None: - return - self.supervisor_site = HomeAssistantUnixSite( - self.runner, self.supervisor_unix_socket_path - ) - try: - await self.supervisor_site.start() - except OSError as error: - _LOGGER.error( - "Failed to create HTTP server on unix socket %s: %s", - self.supervisor_unix_socket_path, - error, - ) - self.supervisor_site = None - else: - _LOGGER.info( - "Now listening on unix socket %s", self.supervisor_unix_socket_path - ) - - async def start(self) -> None: - """Start the aiohttp server.""" - # Aiohttp freezes apps after start so that no changes can be made. - # However in Home Assistant components can be discovered after boot. - # This will now raise a RunTimeError. - # To work around this we now prevent the router from getting frozen - self.app._router.freeze = lambda: None # type: ignore[method-assign] # noqa: SLF001 - - self.runner = web.AppRunner( - self.app, handler_cancellation=True, shutdown_timeout=10 - ) - await self.runner.setup() - - # Setup either binds the server or fails, so it is always available - # here. - assert self._server is not None - await self._server.start_serving() - - _LOGGER.info("Now listening on port %d", self.server_port) - - async def stop(self) -> None: - """Stop the aiohttp server.""" - if self.supervisor_site is not None: - await self.supervisor_site.stop() - if self.supervisor_unix_socket_path is not None: - try: - await self.hass.async_add_executor_job( - self.supervisor_unix_socket_path.unlink, True - ) - except OSError as err: - _LOGGER.warning( - "Could not remove Supervisor unix socket %s: %s", - self.supervisor_unix_socket_path, - err, - ) - if self._server is not None: - # Only close (stop listening); do not await wait_closed() here. - # Let runner.cleanup() terminate active connections. - self._server.close() - if self.runner is not None: - await self.runner.cleanup() diff --git a/homeassistant/components/http/server.py b/homeassistant/components/http/server.py new file mode 100644 index 0000000000000..3017e818fff80 --- /dev/null +++ b/homeassistant/components/http/server.py @@ -0,0 +1,553 @@ +"""HTTP server implementation for the Home Assistant HTTP integration.""" + +import asyncio +from collections.abc import Collection +from dataclasses import dataclass +import datetime +from functools import partial +from ipaddress import IPv4Network, IPv6Network, ip_network +import logging +import os +from pathlib import Path +import socket +import ssl +from tempfile import NamedTemporaryFile +from typing import Any, Final, cast, override + +from aiohttp import web +from aiohttp.abc import AbstractStreamWriter +from aiohttp.http_parser import RawRequestMessage +from aiohttp.streams import StreamReader +from aiohttp.typedefs import JSONDecoder, StrOrURL +from aiohttp.web_exceptions import HTTPMovedPermanently, HTTPRedirection +from aiohttp.web_protocol import RequestHandler +from cryptography import x509 +from cryptography.hazmat.primitives import hashes, serialization +from cryptography.hazmat.primitives.asymmetric import rsa +from cryptography.x509.oid import NameOID +from yarl import URL + +from homeassistant.core import HomeAssistant, callback +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers.http import ( + KEY_ALLOW_CONFIGURED_CORS, + KEY_HASS, + HomeAssistantView, + current_request, +) +from homeassistant.helpers.network import NoURLAvailableError, get_url +from homeassistant.util import dt as dt_util, ssl as ssl_util +from homeassistant.util.json import json_loads + +from .auth import async_setup_auth +from .ban import setup_bans +from .config import ConfData +from .const import ( + CONF_SERVER_HOST, + CONF_SERVER_PORT, + CONF_SSL_CERTIFICATE, + CONF_SSL_KEY, + CONF_SSL_PEER_CERTIFICATE, + CONF_SSL_PROFILE, + CONF_TRUSTED_PROXIES, + SSL_INTERMEDIATE, +) +from .cors import setup_cors +from .forwarded import async_setup_forwarded +from .headers import setup_headers +from .request_context import setup_request_context +from .security_filter import setup_security_filter +from .static import CACHE_HEADERS, CachingStaticResource +from .web_runner import HomeAssistantUnixSite + +_LOGGER: Final = logging.getLogger(__name__) + +MAX_CLIENT_SIZE: Final = 1024**2 * 16 +MAX_LINE_SIZE: Final = 24570 + +_HAS_IPV6 = hasattr(socket, "AF_INET6") +DEFAULT_BIND = ["0.0.0.0", "::"] if _HAS_IPV6 else ["0.0.0.0"] + + +@dataclass(slots=True) +class StaticPathConfig: + """Configuration for a static path.""" + + url_path: str + path: str + cache_headers: bool = True + + +_STATIC_CLASSES = { + True: CachingStaticResource, + False: web.StaticResource, +} + + +def make_server( + hass: HomeAssistant, + conf: ConfData, + supervisor_unix_socket_path: Path | None = None, +) -> HomeAssistantHTTP: + """Create a server instance for the given config.""" + return HomeAssistantHTTP( + hass, + server_host=conf.get(CONF_SERVER_HOST, DEFAULT_BIND), + server_port=conf[CONF_SERVER_PORT], + ssl_certificate=conf.get(CONF_SSL_CERTIFICATE), + ssl_peer_certificate=conf.get(CONF_SSL_PEER_CERTIFICATE), + ssl_key=conf.get(CONF_SSL_KEY), + # The loaded config stores trusted proxies as strings + # (JSON-serializable); the forwarded middleware needs + # IPv4Network/IPv6Network objects. + trusted_proxies=[ + ip_network(proxy) for proxy in conf.get(CONF_TRUSTED_PROXIES) or [] + ], + ssl_profile=conf[CONF_SSL_PROFILE], + supervisor_unix_socket_path=supervisor_unix_socket_path, + ) + + +async def async_verify_can_bind(hass: HomeAssistant, conf: ConfData) -> None: + """Verify a server for ``conf`` can be created and its address bound. + + Used to validate a new user-supplied config before it is stored and + applied via a restart; the sockets are released right away. Best effort: + the address can still be taken by another process before the restart, so + the setup fallback chain remains the safety net. + + Raises ``HomeAssistantError`` if the SSL configuration is unusable or the + configured address cannot be bound. + """ + server = make_server(hass, conf) + try: + await server.async_bind() + except OSError as err: + raise HomeAssistantError( + f"Failed to create HTTP server at port {conf[CONF_SERVER_PORT]}: {err}" + ) from err + finally: + await server.stop() + + +class HomeAssistantRequest(web.Request): + """Home Assistant request object.""" + + @override + async def json(self, *, loads: JSONDecoder = json_loads) -> Any: + """Return body as JSON.""" + # json_loads is a wrapper around orjson.loads that handles + # bytes and str. We can pass the bytes directly to json_loads. + return json_loads(await self.read()) + + +class HomeAssistantApplication(web.Application): + """Home Assistant application.""" + + @override + def _make_request( + self, + message: RawRequestMessage, + payload: StreamReader, + protocol: RequestHandler, + writer: AbstractStreamWriter, + task: asyncio.Task[None], + _cls: type[web.Request] = HomeAssistantRequest, + ) -> web.Request: + """Create request instance.""" + return _cls( + message, + payload, + protocol, + writer, + task, + # loop will never be None when called from aiohttp + loop=self._loop, # type: ignore[arg-type] + client_max_size=self._client_max_size, + ) + + +async def _serve_file_with_cache_headers( + path: str, request: web.Request +) -> web.FileResponse: + return web.FileResponse(path, headers=CACHE_HEADERS) + + +async def _serve_file(path: str, request: web.Request) -> web.FileResponse: + return web.FileResponse(path) + + +class HomeAssistantHTTP: + """HTTP server for Home Assistant.""" + + def __init__( + self, + hass: HomeAssistant, + ssl_certificate: str | None, + ssl_peer_certificate: str | None, + ssl_key: str | None, + server_host: list[str] | None, + server_port: int, + trusted_proxies: list[IPv4Network | IPv6Network], + ssl_profile: str, + supervisor_unix_socket_path: Path | None = None, + ) -> None: + """Initialize the HTTP Home Assistant server.""" + self.app = HomeAssistantApplication( + middlewares=[], + client_max_size=MAX_CLIENT_SIZE, + handler_args={ + "max_line_size": MAX_LINE_SIZE, + "max_field_size": MAX_LINE_SIZE, + }, + ) + self.hass = hass + self.ssl_certificate = ssl_certificate + self.ssl_peer_certificate = ssl_peer_certificate + self.ssl_key = ssl_key + self.server_host = server_host + self.server_port = server_port + self.trusted_proxies = trusted_proxies + self.ssl_profile = ssl_profile + self.supervisor_unix_socket_path = supervisor_unix_socket_path + self.runner: web.AppRunner | None = None + self.supervisor_site: HomeAssistantUnixSite | None = None + self.context: ssl.SSLContext | None = None + self._server: asyncio.Server | None = None + + async def async_bind(self) -> None: + """Create the SSL context and the server, binding its sockets. + + Called during setup so that an unusable configuration surfaces before + it is applied; serving starts later in ``start()``. Raises + ``HomeAssistantError`` if the SSL configuration is unusable and + ``OSError`` if the configured address cannot be bound. + """ + if self.ssl_certificate: + self.context = await self.hass.async_add_executor_job( + self._create_ssl_context + ) + self._server = await self._async_create_server() + + async def _async_create_server(self) -> asyncio.Server: + """Create the (not yet serving) HTTP server, binding its sockets.""" + try: + return await self.hass.loop.create_server( + self._make_protocol, + self.server_host if self.server_host is not None else DEFAULT_BIND, + self.server_port, + ssl=self.context, + backlog=128, + start_serving=False, + ) + except UnicodeError as err: + # create_server() raises UnicodeError for hosts the IDNA codec + # cannot encode (e.g. a label longer than 63 characters); + # normalize to OSError so callers only need to handle one error + # type. + raise OSError(f"error while resolving host: {err}") from err + + def _make_protocol(self) -> RequestHandler: + """Create a protocol instance for an accepted connection. + + Connections are only accepted once ``start()`` has run, so the + runner is set up by the time this is called. + """ + runner = self.runner + assert runner is not None and runner.server is not None + return runner.server() + + async def async_initialize( + self, + *, + cors_origins: list[str], + use_x_forwarded_for: bool, + login_threshold: int, + is_ban_enabled: bool, + use_x_frame_options: bool, + ) -> None: + """Initialize the server.""" + self.app[KEY_HASS] = self.hass + self.app["hass"] = self.hass # For backwards compatibility + + # Order matters, security filters middleware needs to go first, + # forwarded middleware needs to go second. + setup_security_filter(self.app) + + async_setup_forwarded(self.app, use_x_forwarded_for, self.trusted_proxies) + + setup_request_context(self.app, current_request) + + if is_ban_enabled: + setup_bans(self.hass, self.app, login_threshold) + + await async_setup_auth(self.hass, self.app) + + setup_headers(self.app, use_x_frame_options) + setup_cors(self.app, cors_origins) + + def register_view(self, view: HomeAssistantView | type[HomeAssistantView]) -> None: + """Register a view with the WSGI server. + + The view argument must be a class that inherits from HomeAssistantView. + It is optional to instantiate it before registering; this method will + handle it either way. + """ + if isinstance(view, type): + # Instantiate the view, if needed + view = view() + + if not hasattr(view, "url"): + class_name = view.__class__.__name__ + raise AttributeError(f'{class_name} missing required attribute "url"') + + if not hasattr(view, "name"): + class_name = view.__class__.__name__ + raise AttributeError(f'{class_name} missing required attribute "name"') + + view.register(self.hass, self.app, self.app.router) + + def register_redirect( + self, + url: str, + redirect_to: StrOrURL, + *, + redirect_exc: type[HTTPRedirection] = HTTPMovedPermanently, + ) -> None: + """Register a redirect with the server. + + If given this must be either a string or callable. In case of a + callable it's called with the url adapter that triggered the match and + the values of the URL as keyword arguments and has to return the target + for the redirect, otherwise it has to be a string with placeholders in + rule syntax. + """ + + async def redirect(request: web.Request) -> web.StreamResponse: + """Redirect to location.""" + # Should be instance of aiohttp.web_exceptions._HTTPMove. + raise redirect_exc(redirect_to) # type: ignore[arg-type,call-arg] + + self.app[KEY_ALLOW_CONFIGURED_CORS]( + self.app.router.add_route("GET", url, redirect) + ) + + def _make_static_resources( + self, configs: Collection[StaticPathConfig] + ) -> dict[str, CachingStaticResource | web.StaticResource | None]: + """Create a list of static resources.""" + return { + config.url_path: _STATIC_CLASSES[config.cache_headers]( + config.url_path, config.path + ) + if os.path.isdir(config.path) + else None + for config in configs + } + + async def async_register_static_paths( + self, configs: Collection[StaticPathConfig] + ) -> None: + """Register a folder or file to serve as a static path.""" + resources = await self.hass.async_add_executor_job( + self._make_static_resources, configs + ) + self._async_register_static_paths(configs, resources) + + @callback + def _async_register_static_paths( + self, + configs: Collection[StaticPathConfig], + resources: dict[str, CachingStaticResource | web.StaticResource | None], + ) -> None: + """Register a folders or files to serve as a static path.""" + app = self.app + allow_cors = app[KEY_ALLOW_CONFIGURED_CORS] + for config in configs: + if resource := resources[config.url_path]: + app.router.register_resource(resource) + allow_cors(resource) + + target = ( + _serve_file_with_cache_headers if config.cache_headers else _serve_file + ) + allow_cors( + self.app.router.add_route( + "GET", config.url_path, partial(target, config.path) + ) + ) + + def _create_ssl_context(self) -> ssl.SSLContext | None: + context: ssl.SSLContext | None = None + assert self.ssl_certificate is not None + try: + if self.ssl_profile == SSL_INTERMEDIATE: + context = ssl_util.server_context_intermediate() + else: + context = ssl_util.server_context_modern() + context.load_cert_chain(self.ssl_certificate, self.ssl_key) + except OSError as error: + if not self.hass.config.recovery_mode: + raise HomeAssistantError( + f"Could not use SSL certificate from {self.ssl_certificate}:" + f" {error}" + ) from error + _LOGGER.error( + "Could not read SSL certificate from %s: %s", + self.ssl_certificate, + error, + ) + try: + context = self._create_emergency_ssl_context() + except OSError as error2: + _LOGGER.error( + "Could not create an emergency self signed ssl certificate: %s", + error2, + ) + context = None + else: + # Fall through: a configured peer certificate must still be + # enforced on the emergency context. + _LOGGER.critical( + "Home Assistant is running in recovery mode with an emergency self" + " signed ssl certificate because the configured SSL certificate was" + " not usable" + ) + + if self.ssl_peer_certificate: + if context is None: + raise HomeAssistantError( + "Failed to create ssl context, no fallback available because a peer" + " certificate is required." + ) + + context.verify_mode = ssl.CERT_REQUIRED + try: + context.load_verify_locations(self.ssl_peer_certificate) + except OSError as error: + # Raise HomeAssistantError so the caller can tell an unusable + # SSL configuration apart from a socket bind failure (OSError). + raise HomeAssistantError( + f"Could not use SSL peer certificate from" + f" {self.ssl_peer_certificate}: {error}" + ) from error + + return context + + def _create_emergency_ssl_context(self) -> ssl.SSLContext: + """Create an emergency ssl certificate so we can still startup.""" + context = ssl_util.server_context_modern() + host: str + try: + host = cast(str, URL(get_url(self.hass, prefer_external=True)).host) + except NoURLAvailableError: + host = "homeassistant.local" + key = rsa.generate_private_key( + public_exponent=65537, + key_size=2048, + ) + subject = issuer = x509.Name( + [ + x509.NameAttribute( + NameOID.ORGANIZATION_NAME, "Home Assistant Emergency Certificate" + ), + x509.NameAttribute(NameOID.COMMON_NAME, host), + ] + ) + now = dt_util.utcnow() + cert = ( + x509.CertificateBuilder() + .subject_name(subject) + .issuer_name(issuer) + .public_key(key.public_key()) + .serial_number(x509.random_serial_number()) + .not_valid_before(now) + .not_valid_after(now + datetime.timedelta(days=30)) + .add_extension( + x509.SubjectAlternativeName([x509.DNSName(host)]), + critical=False, + ) + .sign(key, hashes.SHA256()) + ) + with NamedTemporaryFile() as cert_pem, NamedTemporaryFile() as key_pem: + cert_pem.write(cert.public_bytes(serialization.Encoding.PEM)) + key_pem.write( + key.private_bytes( + serialization.Encoding.PEM, + format=serialization.PrivateFormat.TraditionalOpenSSL, + encryption_algorithm=serialization.NoEncryption(), + ) + ) + cert_pem.flush() + key_pem.flush() + context.load_cert_chain(cert_pem.name, key_pem.name) + return context + + async def async_start_supervisor_unix_socket(self) -> None: + """Start listening on the Unix socket. + + This is called separately from start() to delay serving the Unix + socket until the Supervisor user exists (created by the hassio + integration). Without this delay, Supervisor could connect before + its user is available and receive 401 responses it won't retry. + """ + if self.supervisor_unix_socket_path is None or self.runner is None: + return + self.supervisor_site = HomeAssistantUnixSite( + self.runner, self.supervisor_unix_socket_path + ) + try: + await self.supervisor_site.start() + except OSError as error: + _LOGGER.error( + "Failed to create HTTP server on unix socket %s: %s", + self.supervisor_unix_socket_path, + error, + ) + self.supervisor_site = None + else: + _LOGGER.info( + "Now listening on unix socket %s", self.supervisor_unix_socket_path + ) + + async def start(self) -> None: + """Start the aiohttp server.""" + # Aiohttp freezes apps after start so that no changes can be made. + # However in Home Assistant components can be discovered after boot. + # This will now raise a RunTimeError. + # To work around this we now prevent the router from getting frozen + self.app._router.freeze = lambda: None # type: ignore[method-assign] # noqa: SLF001 + + self.runner = web.AppRunner( + self.app, handler_cancellation=True, shutdown_timeout=10 + ) + await self.runner.setup() + + # Setup either binds the server or fails, so it is always available + # here. + assert self._server is not None + await self._server.start_serving() + + _LOGGER.info("Now listening on port %d", self.server_port) + + async def stop(self) -> None: + """Stop the aiohttp server.""" + if self.supervisor_site is not None: + await self.supervisor_site.stop() + if self.supervisor_unix_socket_path is not None: + try: + await self.hass.async_add_executor_job( + self.supervisor_unix_socket_path.unlink, True + ) + except OSError as err: + _LOGGER.warning( + "Could not remove Supervisor unix socket %s: %s", + self.supervisor_unix_socket_path, + err, + ) + if self._server is not None: + # Only close (stop listening); do not await wait_closed() here. + # Let runner.cleanup() terminate active connections. + self._server.close() + if self.runner is not None: + await self.runner.cleanup() diff --git a/homeassistant/components/http/websocket_api.py b/homeassistant/components/http/websocket_api.py index 343e1850d96df..9df63bbaf2ffe 100644 --- a/homeassistant/components/http/websocket_api.py +++ b/homeassistant/components/http/websocket_api.py @@ -12,9 +12,9 @@ from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError -from . import async_verify_can_bind from .config import HTTP_STORAGE_SCHEMA, ConfData, async_get_and_load_store from .const import ATTR_CONFIG, CONF_SERVER_PORT +from .server import async_verify_can_bind ERR_BIND_FAILED: Final = "bind_failed" diff --git a/homeassistant/components/husqvarna_automower/calendar.py b/homeassistant/components/husqvarna_automower/calendar.py index b439e066b9737..808c9ba82510b 100644 --- a/homeassistant/components/husqvarna_automower/calendar.py +++ b/homeassistant/components/husqvarna_automower/calendar.py @@ -8,12 +8,10 @@ from homeassistant.components.calendar import CalendarEntity, CalendarEvent from homeassistant.core import HomeAssistant -from homeassistant.helpers import device_registry as dr from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.util import dt as dt_util from . import AutomowerConfigEntry -from .const import DOMAIN from .coordinator import AutomowerDataUpdateCoordinator from .entity import AutomowerBaseEntity @@ -57,10 +55,7 @@ def __init__( @property def device_name(self) -> str: """Return the prefix for the event summary.""" - device_registry = dr.async_get(self.hass) - device_entry = device_registry.async_get_device( - identifiers={(DOMAIN, self.mower_id)} - ) + device_entry = self.device_entry if TYPE_CHECKING: assert device_entry is not None assert device_entry.name is not None diff --git a/homeassistant/components/imou/button.py b/homeassistant/components/imou/button.py index dd7242ae2bc7c..624f72dcacf8a 100644 --- a/homeassistant/components/imou/button.py +++ b/homeassistant/components/imou/button.py @@ -10,7 +10,7 @@ ButtonEntity, ButtonEntityDescription, ) -from homeassistant.core import HomeAssistant, callback +from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback @@ -90,14 +90,7 @@ def _add_buttons(new_devices: list[ImouHaDevice]) -> None: if imou_device_identifier(device) in device_keys ) - coordinator.new_device_callbacks.append(_add_buttons) - - @callback - def _remove_new_device_callback() -> None: - if _add_buttons in coordinator.new_device_callbacks: - coordinator.new_device_callbacks.remove(_add_buttons) - - entry.async_on_unload(_remove_new_device_callback) + entry.async_on_unload(coordinator.register_new_device_callback(_add_buttons)) _add_buttons(coordinator.devices) diff --git a/homeassistant/components/imou/camera.py b/homeassistant/components/imou/camera.py index 79acdedc9c441..52075d1349093 100644 --- a/homeassistant/components/imou/camera.py +++ b/homeassistant/components/imou/camera.py @@ -12,7 +12,7 @@ CameraEntityDescription, CameraEntityFeature, ) -from homeassistant.core import HomeAssistant, callback +from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback @@ -68,14 +68,7 @@ def _add_cameras(new_devices: list[ImouHaDevice]) -> None: for description in CAMERA_TYPES ) - coordinator.new_device_callbacks.append(_add_cameras) - - @callback - def _remove_new_device_callback() -> None: - if _add_cameras in coordinator.new_device_callbacks: - coordinator.new_device_callbacks.remove(_add_cameras) - - entry.async_on_unload(_remove_new_device_callback) + entry.async_on_unload(coordinator.register_new_device_callback(_add_cameras)) _add_cameras(coordinator.devices) diff --git a/homeassistant/components/imou/coordinator.py b/homeassistant/components/imou/coordinator.py index c6190e02b431a..594e35318a268 100644 --- a/homeassistant/components/imou/coordinator.py +++ b/homeassistant/components/imou/coordinator.py @@ -10,7 +10,7 @@ from pyimouapi.ha_device import ImouHaDevice, ImouHaDeviceManager from homeassistant.config_entries import ConfigEntry -from homeassistant.core import HomeAssistant +from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import device_registry as dr from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed @@ -58,6 +58,19 @@ def device_manager(self) -> ImouHaDeviceManager: """Return the device manager.""" return self._device_manager + @callback + def register_new_device_callback( + self, callback_func: Callable[[list[ImouHaDevice]], None] + ) -> Callable[[], None]: + """Register a callback for new devices and return an unregister callback.""" + self.new_device_callbacks.append(callback_func) + + @callback + def remove_callback() -> None: + self.new_device_callbacks.remove(callback_func) + + return remove_callback + def get_device(self, device_key: str) -> ImouHaDevice | None: """Return the current device for device_key, if still on the account.""" return self.devices_by_key.get(device_key) @@ -150,5 +163,5 @@ def _async_add_remove_devices(self, fresh_by_key: dict[str, ImouHaDevice]) -> No for device_key in new_keys: self.devices_by_key[device_key] = fresh_by_key[device_key] new_devices.append(fresh_by_key[device_key]) - for callback in self.new_device_callbacks: - callback(new_devices) + for new_device_callback in self.new_device_callbacks: + new_device_callback(new_devices) diff --git a/homeassistant/components/imou/sensor.py b/homeassistant/components/imou/sensor.py index baf82e933b5e4..539eb149adb75 100644 --- a/homeassistant/components/imou/sensor.py +++ b/homeassistant/components/imou/sensor.py @@ -22,7 +22,7 @@ UnitOfTemperature, UnitOfTime, ) -from homeassistant.core import HomeAssistant, callback +from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.typing import StateType @@ -146,14 +146,7 @@ def _add_sensors(new_devices: list[ImouHaDevice]) -> None: if imou_device_identifier(device) in device_keys ) - coordinator.new_device_callbacks.append(_add_sensors) - - @callback - def _remove_new_device_callback() -> None: - if _add_sensors in coordinator.new_device_callbacks: - coordinator.new_device_callbacks.remove(_add_sensors) - - entry.async_on_unload(_remove_new_device_callback) + entry.async_on_unload(coordinator.register_new_device_callback(_add_sensors)) _add_sensors(coordinator.devices) diff --git a/homeassistant/components/imou/switch.py b/homeassistant/components/imou/switch.py index be6b7764127d5..1f872e5c56c74 100644 --- a/homeassistant/components/imou/switch.py +++ b/homeassistant/components/imou/switch.py @@ -10,7 +10,7 @@ SwitchEntity, SwitchEntityDescription, ) -from homeassistant.core import HomeAssistant, callback +from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback @@ -97,14 +97,7 @@ def _add_switches(new_devices: list[ImouHaDevice]) -> None: if imou_device_identifier(device) in device_keys ) - coordinator.new_device_callbacks.append(_add_switches) - - @callback - def _remove_new_device_callback() -> None: - if _add_switches in coordinator.new_device_callbacks: - coordinator.new_device_callbacks.remove(_add_switches) - - entry.async_on_unload(_remove_new_device_callback) + entry.async_on_unload(coordinator.register_new_device_callback(_add_switches)) _add_switches(coordinator.devices) diff --git a/homeassistant/components/infrared/manifest.json b/homeassistant/components/infrared/manifest.json index 8958e81d9eba4..1c4681d5d6133 100644 --- a/homeassistant/components/infrared/manifest.json +++ b/homeassistant/components/infrared/manifest.json @@ -5,5 +5,5 @@ "documentation": "https://www.home-assistant.io/integrations/infrared", "integration_type": "entity", "quality_scale": "internal", - "requirements": ["infrared-protocols==7.5.0"] + "requirements": ["infrared-protocols==8.1.0"] } diff --git a/homeassistant/components/knx/manifest.json b/homeassistant/components/knx/manifest.json index af9d377e6c5bf..8b2110d160e7f 100644 --- a/homeassistant/components/knx/manifest.json +++ b/homeassistant/components/knx/manifest.json @@ -13,7 +13,7 @@ "requirements": [ "xknx==3.16.0", "xknxproject==3.9.0", - "knx-frontend==2026.6.23.203726", + "knx-frontend==2026.7.17.104339", "knx-telegram-store[sqlite,postgres]==0.10.2" ], "single_config_entry": true diff --git a/homeassistant/components/knx/storage/entity_store_schema.py b/homeassistant/components/knx/storage/entity_store_schema.py index 26d4c02869083..72a09557bdfb3 100644 --- a/homeassistant/components/knx/storage/entity_store_schema.py +++ b/homeassistant/components/knx/storage/entity_store_schema.py @@ -826,12 +826,15 @@ def _sensor_attribute_sub_validator(config: dict) -> dict: { platform: vol.Schema( { - vol.Required(CONF_DATA): { - vol.Required(CONF_ENTITY): BASE_ENTITY_SCHEMA, - vol.Required(DOMAIN): knx_schema, - }, + vol.Required(CONF_DATA): vol.Schema( + { + vol.Required(CONF_ENTITY): BASE_ENTITY_SCHEMA, + vol.Required(DOMAIN): knx_schema, + }, + extra=vol.PREVENT_EXTRA, # restrict in data key for yaml edit + ), }, - extra=vol.ALLOW_EXTRA, + extra=vol.ALLOW_EXTRA, # eg. "type" from WS-endpoint when validating directly ) for platform, knx_schema in KNX_SCHEMA_FOR_PLATFORM.items() }, diff --git a/homeassistant/components/knx/strings.json b/homeassistant/components/knx/strings.json index ddc1f06465c7b..5ecd0bdf1ff54 100644 --- a/homeassistant/components/knx/strings.json +++ b/homeassistant/components/knx/strings.json @@ -173,6 +173,10 @@ "description": "Import a KNX project file to help configure group addresses and datapoint types", "title": "[%key:component::knx::config_panel::dialogs::project_upload::title%]" }, + "send": { + "description": "Send an arbitrary telegram to the KNX bus.", + "title": "[%key:component::knx::config_panel::dialogs::send::title%]" + }, "time_server": { "description": "Broadcast the current local time and date to the KNX bus", "title": "[%key:component::knx::config_panel::dialogs::time_server::title%]" @@ -184,6 +188,20 @@ "file_upload_label": "ETS project file", "title": "Import ETS project" }, + "send": { + "data": { + "description": "The data to send in the telegram. The format depends on the selected datapoint type.", + "label": "Data" + }, + "description": "You can use the hotkey `s` to spawn this dialog from anywhere in the KNX config panel.", + "read_button": "Read", + "response": { + "description": "Send as GroupValueResponse telegram.", + "label": "Response" + }, + "title": "Send KNX telegram", + "write_button": "Write" + }, "time_server": { "date": { "label": "Date" @@ -375,6 +393,10 @@ "no_selection": "No DPT selected" } }, + "dpt_reference": { + "description": "Browse supported KNX datapoint types", + "title": "DPT reference" + }, "entities": { "create": { "_": { @@ -964,6 +986,10 @@ "title": "Create KNX entity", "type_selection": { "header": "Select entity type" + }, + "yaml": { + "mode_hint": "This is an internal representation of the entity configuration and may change in future Home Assistant versions. YAML mode is intended for debugging, and for sharing or copying configurations — prefer the visual editor for everyday use.", + "yaml_error": "There are YAML errors and it cannot be parsed. Switching to the visual editor may cause pending changes to be lost." } }, "description": "Add and manage KNX entities", @@ -1011,6 +1037,10 @@ "value_template": { "description": "Optionally transform the entity state or attribute value before sending it to KNX using a template. The template receives the entity state or attribute value as `value` variable.", "label": "Value template" + }, + "yaml": { + "mode_hint": "This is an internal representation of the expose configuration and may change in future Home Assistant versions. YAML mode is intended for debugging, and for sharing or copying configurations — prefer the visual editor for everyday use.", + "yaml_error": "[%key:component::knx::config_panel::entities::create::yaml::yaml_error%]" } }, "description": "Expose Home Assistant entity states to the KNX bus", diff --git a/homeassistant/components/kodi/media_player.py b/homeassistant/components/kodi/media_player.py index 7938fbd475734..3cc07c5621b47 100644 --- a/homeassistant/components/kodi/media_player.py +++ b/homeassistant/components/kodi/media_player.py @@ -281,10 +281,11 @@ async def _on_ws_connected(self): version = (await self._kodi.get_application_properties(["version"]))["version"] sw_version = f"{version['major']}.{version['minor']}" - dev_reg = dr.async_get(self.hass) - device = dev_reg.async_get_device(identifiers={(DOMAIN, self.unique_id)}) - dev_reg.async_update_device(device.id, sw_version=sw_version) - self._device_id = device.id + if self.device_entry: + dr.async_get(self.hass).async_update_device( + self.device_entry.id, sw_version=sw_version + ) + self._device_id = self.device_entry.id self.async_schedule_update_ha_state(True) diff --git a/homeassistant/components/mikrotik/__init__.py b/homeassistant/components/mikrotik/__init__.py index 43e32e55d5710..29ee9c3d0f083 100644 --- a/homeassistant/components/mikrotik/__init__.py +++ b/homeassistant/components/mikrotik/__init__.py @@ -17,6 +17,7 @@ ) PLATFORMS = [ + Platform.BUTTON, Platform.DEVICE_TRACKER, Platform.SENSOR, ] diff --git a/homeassistant/components/mikrotik/button.py b/homeassistant/components/mikrotik/button.py new file mode 100644 index 0000000000000..b01782f1c748e --- /dev/null +++ b/homeassistant/components/mikrotik/button.py @@ -0,0 +1,62 @@ +"""Support for buttons.""" + +from typing import override + +from homeassistant.components.button import ( + ButtonDeviceClass, + ButtonEntity, + ButtonEntityDescription, +) +from homeassistant.const import EntityCategory +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from .const import MIKROTIK_SERVICES +from .coordinator import MikrotikConfigEntry, mikrotik_config_entry_errors +from .entity import MikrotikEntity + +# Coordinator is used to centralize the data updates +PARALLEL_UPDATES = 0 + + +BUTTON_TYPES = { + ButtonEntityDescription( + key="reboot", + device_class=ButtonDeviceClass.RESTART, + entity_category=EntityCategory.CONFIG, + ), + ButtonEntityDescription( + key="shutdown", + translation_key="shutdown", + entity_category=EntityCategory.CONFIG, + ), +} + + +async def async_setup_entry( + hass: HomeAssistant, + entry: MikrotikConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up button entities for Mikrotik Devices.""" + + coordinator = entry.runtime_data + + sensors_list = [ + MikrotikButtonEntity(coordinator, sensor_desc) for sensor_desc in BUTTON_TYPES + ] + + async_add_entities(sensors_list) + + +class MikrotikButtonEntity(MikrotikEntity, ButtonEntity): + """Button entity for Mikrotik.""" + + @override + async def async_press(self) -> None: + """Handle button press action.""" + with mikrotik_config_entry_errors(): + await self.hass.async_add_executor_job( + self.coordinator.api.command, + MIKROTIK_SERVICES[self.entity_description.key], + ) diff --git a/homeassistant/components/mikrotik/const.py b/homeassistant/components/mikrotik/const.py index c710f66a14e39..399271d8179e2 100644 --- a/homeassistant/components/mikrotik/const.py +++ b/homeassistant/components/mikrotik/const.py @@ -27,6 +27,8 @@ IS_WIFI: Final = "is_wifi" IS_WIFIWAVE2: Final = "is_wifiwave2" IS_WIRELESS: Final = "is_wireless" +REBOOT: Final = "reboot" +SHUTDOWN: Final = "shutdown" SYSTEM: Final = "system" WIFI: Final = "wifi" WIFIWAVE2: Final = "wifiwave2" @@ -44,6 +46,8 @@ IS_WIFI: "/interface/wifi/print", IS_WIFIWAVE2: "/interface/wifiwave2/print", IS_WIRELESS: "/interface/wireless/print", + REBOOT: "/system/reboot", + SHUTDOWN: "/system/shutdown", SYSTEM: "/system/resource/print", WIFI: "/interface/wifi/registration-table/print", WIFIWAVE2: "/interface/wifiwave2/registration-table/print", diff --git a/homeassistant/components/mikrotik/icons.json b/homeassistant/components/mikrotik/icons.json index 73f0b262b4506..159f1891d5b8e 100644 --- a/homeassistant/components/mikrotik/icons.json +++ b/homeassistant/components/mikrotik/icons.json @@ -1,5 +1,10 @@ { "entity": { + "button": { + "shutdown": { + "default": "mdi:power" + } + }, "sensor": { "cpu_load": { "default": "mdi:chip" diff --git a/homeassistant/components/mikrotik/strings.json b/homeassistant/components/mikrotik/strings.json index 0f63b1e3ab70d..38e55a82d029c 100644 --- a/homeassistant/components/mikrotik/strings.json +++ b/homeassistant/components/mikrotik/strings.json @@ -31,6 +31,11 @@ } }, "entity": { + "button": { + "shutdown": { + "name": "Shutdown" + } + }, "sensor": { "cpu_load": { "name": "CPU usage" diff --git a/homeassistant/components/monzo/manifest.json b/homeassistant/components/monzo/manifest.json index fc6668efe18d5..6904b48afb9a6 100644 --- a/homeassistant/components/monzo/manifest.json +++ b/homeassistant/components/monzo/manifest.json @@ -7,5 +7,5 @@ "documentation": "https://www.home-assistant.io/integrations/monzo", "integration_type": "service", "iot_class": "cloud_polling", - "requirements": ["monzopy==1.5.1"] + "requirements": ["monzopy==1.6.0"] } diff --git a/homeassistant/components/mqtt/strings.json b/homeassistant/components/mqtt/strings.json index 530b81eab9dab..79000ba6e5669 100644 --- a/homeassistant/components/mqtt/strings.json +++ b/homeassistant/components/mqtt/strings.json @@ -1137,7 +1137,7 @@ }, "issues": { "invalid_platform_config": { - "description": "Home Assistant detected an invalid config for a manually configured item.\n\nPlatform domain: **{domain}**\nConfiguration file: **{config_file}**\nNear line: **{line}**\nConfiguration found:\n```yaml\n{config}\n```\nError: **{error}**.\n\nMake sure the configuration is valid and [reload](/config/developer-tools/yaml) the manually configured MQTT items or restart Home Assistant to fix this issue.", + "description": "Home Assistant detected an invalid config for a manually configured item.\n\nPlatform domain: **{domain}**\nConfiguration file: **{config_file}**\nNear line: **{line}**\nConfiguration found:\n```yaml\n{config}\n```\nError: **{error}**.\n\nMake sure the configuration is valid and [reload](/config/tools/yaml) the manually configured MQTT items or restart Home Assistant to fix this issue.", "title": "Invalid config found for MQTT {domain} item" }, "protocol_5_migration": { diff --git a/homeassistant/components/netatmo/entity.py b/homeassistant/components/netatmo/entity.py index ae301cb06fc98..87452bed4cc9e 100644 --- a/homeassistant/components/netatmo/entity.py +++ b/homeassistant/components/netatmo/entity.py @@ -9,7 +9,6 @@ from homeassistant.const import EntityStateAttribute from homeassistant.core import callback -from homeassistant.helpers import device_registry as dr from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity import Entity @@ -145,10 +144,7 @@ def __init__(self, room: NetatmoRoom) -> None: async def async_added_to_hass(self) -> None: """Entity created.""" await super().async_added_to_hass() - registry = dr.async_get(self.hass) - if device := registry.async_get_device( - identifiers={(DOMAIN, self.device.entity_id)} - ): + if device := self.device_entry: self.data_handler.device_ids[self.device.entity_id] = device.id @property diff --git a/homeassistant/components/nextdns/__init__.py b/homeassistant/components/nextdns/__init__.py index 221be4ffd8291..0e77c236bd9e9 100644 --- a/homeassistant/components/nextdns/__init__.py +++ b/homeassistant/components/nextdns/__init__.py @@ -2,6 +2,7 @@ import asyncio from dataclasses import dataclass +from types import MappingProxyType from aiohttp.client_exceptions import ClientConnectorError from nextdns import ( @@ -18,11 +19,17 @@ ) from tenacity import RetryError -from homeassistant.config_entries import ConfigEntry +from homeassistant.config_entries import ConfigEntry, ConfigSubentry from homeassistant.const import CONF_API_KEY, Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady +from homeassistant.helpers import ( + config_validation as cv, + device_registry as dr, + entity_registry as er, +) from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.helpers.typing import ConfigType from .const import ( ATTR_CONNECTION, @@ -34,6 +41,7 @@ ATTR_STATUS, CONF_PROFILE_ID, DOMAIN, + SUBENTRY_TYPE_PROFILE, ) from .coordinator import ( NextDnsConnectionUpdateCoordinator, @@ -50,8 +58,8 @@ @dataclass -class NextDnsData: - """Data for the NextDNS integration.""" +class NextDnsCoordinators: + """Coordinators for a NextDNS profile.""" connection: NextDnsUpdateCoordinator[ConnectionStatus] dnssec: NextDnsUpdateCoordinator[AnalyticsDnssec] @@ -62,6 +70,15 @@ class NextDnsData: status: NextDnsUpdateCoordinator[AnalyticsStatus] +@dataclass +class NextDnsData: + """Runtime data for the NextDNS integration.""" + + client: NextDns + profiles: dict[str, NextDnsCoordinators] + + +CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN) PLATFORMS = [Platform.BINARY_SENSOR, Platform.BUTTON, Platform.SENSOR, Platform.SWITCH] COORDINATORS: list[tuple[str, type[NextDnsUpdateCoordinator]]] = [ (ATTR_CONNECTION, NextDnsConnectionUpdateCoordinator), @@ -74,10 +91,117 @@ class NextDnsData: ] +async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: + """Set up NextDNS.""" + await async_migrate_integration(hass) + return True + + +async def async_migrate_integration(hass: HomeAssistant) -> None: + """Migrate integration entry structure.""" + # Make sure we get enabled config entries first + entries = sorted( + hass.config_entries.async_entries(DOMAIN), + key=lambda e: e.disabled_by is not None, + ) + if not any(entry.version == 1 for entry in entries): + return + + api_keys_entries: dict[str, tuple[NextDnsConfigEntry, bool]] = {} + device_registry = dr.async_get(hass) + entity_registry = er.async_get(hass) + + for entry in entries: + profile_id = entry.data[CONF_PROFILE_ID] + profile_name = entry.title + + subentry = ConfigSubentry( + data=MappingProxyType({CONF_PROFILE_ID: profile_id}), + subentry_type=SUBENTRY_TYPE_PROFILE, + title=profile_name, + unique_id=profile_id, + ) + + if entry.data[CONF_API_KEY] not in api_keys_entries: + all_disabled = all( + e.disabled_by is not None + for e in entries + if e.data[CONF_API_KEY] == entry.data[CONF_API_KEY] + ) + api_keys_entries[entry.data[CONF_API_KEY]] = (entry, all_disabled) + + parent_entry, all_disabled = api_keys_entries[entry.data[CONF_API_KEY]] + + hass.config_entries.async_add_subentry(parent_entry, subentry) + + entities = er.async_entries_for_config_entry(entity_registry, entry.entry_id) + device = device_registry.async_get_device(identifiers={(DOMAIN, profile_id)}) + + for entity_entry in entities: + entity_disabled_by = entity_entry.disabled_by + if ( + entity_disabled_by is er.RegistryEntryDisabler.CONFIG_ENTRY + and not all_disabled + ): + # Device and entity registries don't update the disabled_by flag + # when moving a device or entity from one config entry to another, + # so we need to do it manually. + entity_disabled_by = ( + er.RegistryEntryDisabler.DEVICE + if device + else er.RegistryEntryDisabler.USER + ) + entity_registry.async_update_entity( + entity_entry.entity_id, + config_entry_id=parent_entry.entry_id, + config_subentry_id=subentry.subentry_id, + disabled_by=entity_disabled_by, + ) + + if device is not None: + # Device and entity registries don't update the disabled_by flag when + # moving a device or entity from one config entry to another, so we + # need to do it manually. + device_disabled_by = device.disabled_by + if ( + device.disabled_by is dr.DeviceEntryDisabler.CONFIG_ENTRY + and not all_disabled + ): + device_disabled_by = dr.DeviceEntryDisabler.USER + device_registry.async_update_device( + device.id, + disabled_by=device_disabled_by, + new_identifiers={(DOMAIN, profile_id)}, + add_config_subentry_id=subentry.subentry_id, + add_config_entry_id=parent_entry.entry_id, + ) + if parent_entry.entry_id != entry.entry_id: + device_registry.async_update_device( + device.id, + remove_config_entry_id=entry.entry_id, + ) + else: + device_registry.async_update_device( + device.id, + remove_config_entry_id=entry.entry_id, + remove_config_subentry_id=None, + ) + + if parent_entry.entry_id != entry.entry_id: + await hass.config_entries.async_remove(entry.entry_id) + else: + hass.config_entries.async_update_entry( + entry, + data={CONF_API_KEY: entry.data[CONF_API_KEY]}, + title="NextDNS", + version=2, + unique_id=None, + ) + + async def async_setup_entry(hass: HomeAssistant, entry: NextDnsConfigEntry) -> bool: """Set up NextDNS as config entry.""" api_key = entry.data[CONF_API_KEY] - profile_id = entry.data[CONF_PROFILE_ID] websession = async_get_clientsession(hass) try: @@ -98,25 +222,43 @@ async def async_setup_entry(hass: HomeAssistant, entry: NextDnsConfigEntry) -> b translation_placeholders={"entry": entry.title}, ) from err - tasks = [] - coordinators = {} + profiles: dict[str, NextDnsCoordinators] = {} + + for subentry in entry.get_subentries_of_type(SUBENTRY_TYPE_PROFILE): + subentry_id = subentry.subentry_id + profile_id = subentry.data[CONF_PROFILE_ID] + tasks = [] + coordinators = {} + + # Independent DataUpdateCoordinator is used for each API endpoint to avoid + # unnecessary requests when entities using this endpoint are disabled. + for coordinator_name, coordinator_class in COORDINATORS: + coordinator = coordinator_class( + hass, entry, nextdns, profile_id, subentry_id + ) + tasks.append(coordinator.async_config_entry_first_refresh()) + coordinators[coordinator_name] = coordinator - # Independent DataUpdateCoordinator is used for each API endpoint to avoid - # unnecessary requests when entities using this endpoint are disabled. - for coordinator_name, coordinator_class in COORDINATORS: - coordinator = coordinator_class(hass, entry, nextdns, profile_id) - tasks.append(coordinator.async_config_entry_first_refresh()) - coordinators[coordinator_name] = coordinator + await asyncio.gather(*tasks) - await asyncio.gather(*tasks) + profiles[subentry_id] = NextDnsCoordinators(**coordinators) - entry.runtime_data = NextDnsData(**coordinators) + entry.runtime_data = NextDnsData(client=nextdns, profiles=profiles) + + entry.async_on_unload(entry.add_update_listener(_async_update_listener)) await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) return True +async def _async_update_listener( + hass: HomeAssistant, entry: NextDnsConfigEntry +) -> None: + """Reload the config entry when subentries change.""" + await hass.config_entries.async_reload(entry.entry_id) + + async def async_unload_entry(hass: HomeAssistant, entry: NextDnsConfigEntry) -> bool: """Unload a config entry.""" return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/homeassistant/components/nextdns/binary_sensor.py b/homeassistant/components/nextdns/binary_sensor.py index 1c713b8b7bea6..00849290eae89 100644 --- a/homeassistant/components/nextdns/binary_sensor.py +++ b/homeassistant/components/nextdns/binary_sensor.py @@ -52,11 +52,12 @@ async def async_setup_entry( async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Add NextDNS entities from a config_entry.""" - coordinator = entry.runtime_data.connection - - async_add_entities( - NextDnsBinarySensor(coordinator, description) for description in SENSORS - ) + for subentry_id, profile_data in entry.runtime_data.profiles.items(): + coordinator = profile_data.connection + async_add_entities( + (NextDnsBinarySensor(coordinator, description) for description in SENSORS), + config_subentry_id=subentry_id, + ) class NextDnsBinarySensor(NextDnsEntity, BinarySensorEntity): diff --git a/homeassistant/components/nextdns/button.py b/homeassistant/components/nextdns/button.py index e0e074dba931a..feee17e269ed1 100644 --- a/homeassistant/components/nextdns/button.py +++ b/homeassistant/components/nextdns/button.py @@ -31,10 +31,13 @@ async def async_setup_entry( entry: NextDnsConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: - """Add aNextDNS entities from a config_entry.""" - coordinator = entry.runtime_data.status - - async_add_entities([NextDnsButton(coordinator, CLEAR_LOGS_BUTTON)]) + """Add NextDNS entities from a config_entry.""" + for subentry_id, profile_data in entry.runtime_data.profiles.items(): + coordinator = profile_data.status + async_add_entities( + [NextDnsButton(coordinator, CLEAR_LOGS_BUTTON)], + config_subentry_id=subentry_id, + ) class NextDnsButton(NextDnsEntity, ButtonEntity): diff --git a/homeassistant/components/nextdns/config_flow.py b/homeassistant/components/nextdns/config_flow.py index 8cbde0b65323a..ced39ec9cc6c4 100644 --- a/homeassistant/components/nextdns/config_flow.py +++ b/homeassistant/components/nextdns/config_flow.py @@ -9,51 +9,58 @@ from tenacity import RetryError import voluptuous as vol -from homeassistant.config_entries import ConfigFlow, ConfigFlowResult -from homeassistant.const import CONF_API_KEY, CONF_PROFILE_NAME -from homeassistant.core import HomeAssistant -from homeassistant.exceptions import HomeAssistantError +from homeassistant.config_entries import ( + ConfigEntry, + ConfigEntryState, + ConfigFlow, + ConfigFlowResult, + ConfigSubentryFlow, + SubentryFlowResult, +) +from homeassistant.const import CONF_API_KEY +from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.helpers.selector import ( + SelectOptionDict, + SelectSelector, + SelectSelectorConfig, + SelectSelectorMode, +) -from .const import CONF_PROFILE_ID, DOMAIN +from .const import CONF_PROFILE_ID, DOMAIN, SUBENTRY_TYPE_PROFILE AUTH_SCHEMA = vol.Schema({vol.Required(CONF_API_KEY): str}) _LOGGER = logging.getLogger(__name__) -async def async_init_nextdns( - hass: HomeAssistant, api_key: str, profile_id: str | None = None -) -> NextDns: - """Check if credentials and profile_id are valid.""" +async def async_init_nextdns(hass: HomeAssistant, api_key: str) -> NextDns: + """Check if credentials are valid.""" websession = async_get_clientsession(hass) - nextdns = await NextDns.create(websession, api_key) - - if profile_id: - if not any(profile.id == profile_id for profile in nextdns.profiles): - raise ProfileNotAvailable - - return nextdns + return await NextDns.create(websession, api_key) async def async_validate_new_api_key( - hass: HomeAssistant, user_input: dict[str, Any], profile_id: str + hass: HomeAssistant, user_input: dict[str, Any], profile_ids: list[str] ) -> dict[str, str]: """Validate the new API key during reconfiguration or reauth.""" errors: dict[str, str] = {} try: - await async_init_nextdns(hass, user_input[CONF_API_KEY], profile_id) + nextdns = await async_init_nextdns(hass, user_input[CONF_API_KEY]) except InvalidApiKeyError: errors["base"] = "invalid_api_key" except ApiError, ClientConnectorError, RetryError, TimeoutError: errors["base"] = "cannot_connect" - except ProfileNotAvailable: - errors["base"] = "profile_not_available" except Exception: _LOGGER.exception("Unexpected exception") errors["base"] = "unknown" + else: + for profile_id in profile_ids: + if not any(profile.id == profile_id for profile in nextdns.profiles): + errors["base"] = "profile_not_available" + break return errors @@ -61,7 +68,7 @@ async def async_validate_new_api_key( class NextDnsFlowHandler(ConfigFlow, domain=DOMAIN): """Config flow for NextDNS.""" - VERSION = 1 + VERSION = 2 def __init__(self) -> None: """Initialize the config flow.""" @@ -77,6 +84,9 @@ async def async_step_user( if user_input is not None: self.api_key = user_input[CONF_API_KEY] + + self._async_abort_entries_match({CONF_API_KEY: self.api_key}) + try: self.nextdns = await async_init_nextdns(self.hass, self.api_key) except InvalidApiKeyError: @@ -102,23 +112,36 @@ async def async_step_profiles( errors: dict[str, str] = {} if user_input is not None: - profile_name = user_input[CONF_PROFILE_NAME] - profile_id = self.nextdns.get_profile_id(profile_name) - - await self.async_set_unique_id(profile_id) - self._abort_if_unique_id_configured() + profile_id = user_input[CONF_PROFILE_ID] return self.async_create_entry( - title=profile_name, - data={CONF_PROFILE_ID: profile_id, CONF_API_KEY: self.api_key}, + title="NextDNS", + data={CONF_API_KEY: self.api_key}, + subentries=[ + { + "subentry_type": SUBENTRY_TYPE_PROFILE, + "data": {CONF_PROFILE_ID: profile_id}, + "title": self.nextdns.get_profile_name(profile_id), + "unique_id": profile_id, + }, + ], ) return self.async_show_form( step_id="profiles", data_schema=vol.Schema( { - vol.Required(CONF_PROFILE_NAME): vol.In( - [profile.name for profile in self.nextdns.profiles] + vol.Required(CONF_PROFILE_ID): SelectSelector( + SelectSelectorConfig( + options=[ + SelectOptionDict( + value=profile.id, + label=profile.name, + ) + for profile in self.nextdns.profiles + ], + mode=SelectSelectorMode.LIST, + ) ) } ), @@ -139,8 +162,12 @@ async def async_step_reauth_confirm( entry = self._get_reauth_entry() if user_input is not None: + profile_ids = [ + subentry.data[CONF_PROFILE_ID] + for subentry in entry.get_subentries_of_type(SUBENTRY_TYPE_PROFILE) + ] errors = await async_validate_new_api_key( - self.hass, user_input, entry.data[CONF_PROFILE_ID] + self.hass, user_input, profile_ids ) if errors.get("base") == "profile_not_available": return self.async_abort(reason="profile_not_available") @@ -165,8 +192,12 @@ async def async_step_reconfigure( entry = self._get_reconfigure_entry() if user_input is not None: + profile_ids = [ + subentry.data[CONF_PROFILE_ID] + for subentry in entry.get_subentries_of_type(SUBENTRY_TYPE_PROFILE) + ] errors = await async_validate_new_api_key( - self.hass, user_input, entry.data[CONF_PROFILE_ID] + self.hass, user_input, profile_ids ) if errors.get("base") == "profile_not_available": return self.async_abort(reason="profile_not_available") @@ -183,6 +214,82 @@ async def async_step_reconfigure( errors=errors, ) + @classmethod + @callback + @override + def async_get_supported_subentry_types( + cls, config_entry: ConfigEntry + ) -> dict[str, type[ConfigSubentryFlow]]: + """Return subentries supported by this integration.""" + return {SUBENTRY_TYPE_PROFILE: ProfileSubentryFlowHandler} + -class ProfileNotAvailable(HomeAssistantError): - """Error to indicate that the profile is not available after reconfig/reauth.""" +class ProfileSubentryFlowHandler(ConfigSubentryFlow): + """Handle a subentry flow for profile.""" + + def __init__(self) -> None: + """Initialize the subentry flow.""" + self.nextdns: NextDns + + async def async_step_user( + self, + user_input: dict[str, Any] | None = None, + ) -> SubentryFlowResult: + """Handle the profile step.""" + entry = self._get_entry() + if entry.state is not ConfigEntryState.LOADED: + return self.async_abort(reason="entry_not_loaded") + + errors: dict[str, str] = {} + + self.nextdns = entry.runtime_data.client + + if user_input is not None: + profile_id = user_input[CONF_PROFILE_ID] + + if any( + subentry.unique_id == profile_id + for subentry in entry.get_subentries_of_type(SUBENTRY_TYPE_PROFILE) + ): + return self.async_abort(reason="already_configured") + + return self.async_create_entry( + title=self.nextdns.get_profile_name(profile_id), + data={CONF_PROFILE_ID: profile_id}, + unique_id=profile_id, + ) + + # Filter out already configured profiles + configured_profiles = { + subentry.data[CONF_PROFILE_ID] + for subentry in entry.get_subentries_of_type(SUBENTRY_TYPE_PROFILE) + } + available_profiles = [ + profile + for profile in self.nextdns.profiles + if profile.id not in configured_profiles + ] + + if not available_profiles: + return self.async_abort(reason="all_profiles_configured") + + return self.async_show_form( + step_id="user", + data_schema=vol.Schema( + { + vol.Required(CONF_PROFILE_ID): SelectSelector( + SelectSelectorConfig( + options=[ + SelectOptionDict( + value=profile.id, + label=profile.name, + ) + for profile in available_profiles + ], + mode=SelectSelectorMode.LIST, + ) + ) + } + ), + errors=errors, + ) diff --git a/homeassistant/components/nextdns/const.py b/homeassistant/components/nextdns/const.py index b8210c1939cae..53cae439b7f3b 100644 --- a/homeassistant/components/nextdns/const.py +++ b/homeassistant/components/nextdns/const.py @@ -12,6 +12,8 @@ CONF_PROFILE_ID = "profile_id" +SUBENTRY_TYPE_PROFILE = "profile" + UPDATE_INTERVAL_CONNECTION = timedelta(minutes=5) UPDATE_INTERVAL_ANALYTICS = timedelta(minutes=10) UPDATE_INTERVAL_SETTINGS = timedelta(minutes=1) diff --git a/homeassistant/components/nextdns/coordinator.py b/homeassistant/components/nextdns/coordinator.py index 3966e041f80a5..a2fceb9c14989 100644 --- a/homeassistant/components/nextdns/coordinator.py +++ b/homeassistant/components/nextdns/coordinator.py @@ -51,16 +51,18 @@ def __init__( config_entry: NextDnsConfigEntry, nextdns: NextDns, profile_id: str, + subentry_id: str, ) -> None: """Initialize.""" self.nextdns = nextdns self.profile_id = profile_id + self.subentry_id = subentry_id super().__init__( hass, _LOGGER, config_entry=config_entry, - name=DOMAIN, + name=f"{DOMAIN}_{subentry_id}", update_interval=self._update_interval, ) diff --git a/homeassistant/components/nextdns/diagnostics.py b/homeassistant/components/nextdns/diagnostics.py index 60f9d74e492a4..87efc4d069167 100644 --- a/homeassistant/components/nextdns/diagnostics.py +++ b/homeassistant/components/nextdns/diagnostics.py @@ -17,19 +17,22 @@ async def async_get_config_entry_diagnostics( hass: HomeAssistant, config_entry: NextDnsConfigEntry ) -> dict[str, Any]: """Return diagnostics for a config entry.""" - dnssec_coordinator = config_entry.runtime_data.dnssec - encryption_coordinator = config_entry.runtime_data.encryption - ip_versions_coordinator = config_entry.runtime_data.ip_versions - protocols_coordinator = config_entry.runtime_data.protocols - settings_coordinator = config_entry.runtime_data.settings - status_coordinator = config_entry.runtime_data.status + profiles_data: list[dict[str, Any]] = [] + for subentry_id, profile_data in config_entry.runtime_data.profiles.items(): + subentry = config_entry.subentries[subentry_id] + profiles_data.append( + { + "subentry_title": subentry.title, + "dnssec_coordinator_data": asdict(profile_data.dnssec.data), + "encryption_coordinator_data": asdict(profile_data.encryption.data), + "ip_versions_coordinator_data": asdict(profile_data.ip_versions.data), + "protocols_coordinator_data": asdict(profile_data.protocols.data), + "settings_coordinator_data": asdict(profile_data.settings.data), + "status_coordinator_data": asdict(profile_data.status.data), + } + ) return { "config_entry": async_redact_data(config_entry.as_dict(), TO_REDACT), - "dnssec_coordinator_data": asdict(dnssec_coordinator.data), - "encryption_coordinator_data": asdict(encryption_coordinator.data), - "ip_versions_coordinator_data": asdict(ip_versions_coordinator.data), - "protocols_coordinator_data": asdict(protocols_coordinator.data), - "settings_coordinator_data": asdict(settings_coordinator.data), - "status_coordinator_data": asdict(status_coordinator.data), + "profiles": profiles_data, } diff --git a/homeassistant/components/nextdns/entity.py b/homeassistant/components/nextdns/entity.py index 7e86d1d246ca2..6fb493990ab73 100644 --- a/homeassistant/components/nextdns/entity.py +++ b/homeassistant/components/nextdns/entity.py @@ -24,12 +24,13 @@ def __init__( ) -> None: """Initialize.""" super().__init__(coordinator) + subentry = coordinator.config_entry.subentries[coordinator.subentry_id] self._attr_device_info = DeviceInfo( configuration_url=f"https://my.nextdns.io/{coordinator.profile_id}/setup", entry_type=DeviceEntryType.SERVICE, - identifiers={(DOMAIN, str(coordinator.profile_id))}, + identifiers={(DOMAIN, coordinator.profile_id)}, manufacturer="NextDNS Inc.", - name=coordinator.nextdns.get_profile_name(coordinator.profile_id), + name=subentry.title, ) self._attr_unique_id = f"{coordinator.profile_id}_{description.key}" self.entity_description = description diff --git a/homeassistant/components/nextdns/sensor.py b/homeassistant/components/nextdns/sensor.py index 0cf52ef973a18..65d973d64279c 100644 --- a/homeassistant/components/nextdns/sensor.py +++ b/homeassistant/components/nextdns/sensor.py @@ -287,12 +287,16 @@ async def async_setup_entry( async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Add a NextDNS entities from a config_entry.""" - async_add_entities( - NextDnsSensor( - getattr(entry.runtime_data, description.coordinator_type), description + for subentry_id, profile_data in entry.runtime_data.profiles.items(): + async_add_entities( + ( + NextDnsSensor( + getattr(profile_data, description.coordinator_type), description + ) + for description in SENSORS + ), + config_subentry_id=subentry_id, ) - for description in SENSORS - ) class NextDnsSensor[CoordinatorDataT: NextDnsData]( diff --git a/homeassistant/components/nextdns/strings.json b/homeassistant/components/nextdns/strings.json index 18341c938a2b6..18aff8b73f6bc 100644 --- a/homeassistant/components/nextdns/strings.json +++ b/homeassistant/components/nextdns/strings.json @@ -1,7 +1,8 @@ { "config": { "abort": { - "already_configured": "This NextDNS profile is already configured.", + "all_profiles_configured": "All NextDNS profiles are already configured.", + "already_configured": "[%key:common::config_flow::abort::already_configured_account%]", "profile_not_available": "The configured NextDNS profile is no longer available in your account. Remove the configuration and configure the integration again.", "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]", "reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]" @@ -14,10 +15,10 @@ "step": { "profiles": { "data": { - "profile_name": "Profile" + "profile_id": "Profile" }, "data_description": { - "profile_name": "The NextDNS configuration profile you want to integrate" + "profile_id": "The NextDNS configuration profile you want to integrate" } }, "reauth_confirm": { @@ -46,6 +47,31 @@ } } }, + "config_subentries": { + "profile": { + "abort": { + "all_profiles_configured": "[%key:component::nextdns::config::abort::all_profiles_configured%]", + "already_configured": "This NextDNS profile is already configured.", + "entry_not_loaded": "The NextDNS configuration entry is not loaded. Please ensure it is set up correctly before adding a profile." + }, + "entry_type": "NextDNS profile", + "initiate_flow": { + "user": "Add profile" + }, + "step": { + "user": { + "data": { + "profile_id": "[%key:component::nextdns::config::step::profiles::data::profile_id%]" + }, + "data_description": { + "profile_id": "[%key:component::nextdns::config::step::profiles::data_description::profile_id%]" + }, + "description": "Select the NextDNS profile you want to add.", + "title": "Add NextDNS profile" + } + } + } + }, "entity": { "binary_sensor": { "device_connection_status": { diff --git a/homeassistant/components/nextdns/switch.py b/homeassistant/components/nextdns/switch.py index 5d8405afda84f..c207baa956be1 100644 --- a/homeassistant/components/nextdns/switch.py +++ b/homeassistant/components/nextdns/switch.py @@ -533,11 +533,12 @@ async def async_setup_entry( async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Add NextDNS entities from a config_entry.""" - coordinator = entry.runtime_data.settings - - async_add_entities( - NextDnsSwitch(coordinator, description) for description in SWITCHES - ) + for subentry_id, profile_data in entry.runtime_data.profiles.items(): + coordinator = profile_data.settings + async_add_entities( + (NextDnsSwitch(coordinator, description) for description in SWITCHES), + config_subentry_id=subentry_id, + ) class NextDnsSwitch(NextDnsEntity, SwitchEntity): diff --git a/homeassistant/components/notion/entity.py b/homeassistant/components/notion/entity.py index efd76d7f157a2..d8179aca51b87 100644 --- a/homeassistant/components/notion/entity.py +++ b/homeassistant/components/notion/entity.py @@ -100,19 +100,16 @@ def _async_update_bridge_id(self) -> None: self._bridge_id = sensor.bridge.id device_registry = dr.async_get(self.hass) - this_device = device_registry.async_get_device( - identifiers={(DOMAIN, sensor.hardware_id)} - ) bridge = self.coordinator.data.bridges[self._bridge_id] - bridge_device = device_registry.async_get_device( - identifiers={(DOMAIN, bridge.hardware_id)} + bridge_device = device_registry.async_get_device_by_identifier( + (DOMAIN, bridge.hardware_id), self.coordinator.config_entry.entry_id ) - if not bridge_device or not this_device: + if not bridge_device or not self.device_entry: return device_registry.async_update_device( - this_device.id, via_device_id=bridge_device.id + self.device_entry.id, via_device_id=bridge_device.id ) @callback diff --git a/homeassistant/components/search/__init__.py b/homeassistant/components/search/__init__.py index 5b6111e04b483..b9ab22a74b0bb 100644 --- a/homeassistant/components/search/__init__.py +++ b/homeassistant/components/search/__init__.py @@ -147,14 +147,8 @@ def _async_search_area(self, area_id: str, *, entry_point: bool = True) -> None: if device_entry := self._device_registry.async_get(device.id): self._add(ItemType.CONFIG_ENTRY, device_entry.config_entries) - # Automations referencing this device - self._add( - ItemType.AUTOMATION, - automation.automations_with_device(self.hass, device.id), - ) - - # Scripts referencing this device - self._add(ItemType.SCRIPT, script.scripts_with_device(self.hass, device.id)) + # Automations and scripts referencing this device + self._async_add_automations_and_scripts_for_device(device) # Entities of this device for entity_entry in er.async_entries_for_device( @@ -239,8 +233,7 @@ def _async_search_automation(self, automation_entity_id: str) -> None: # Devices referenced in this automation for device in automation.devices_in_automation(self.hass, automation_entity_id): - self._add(ItemType.DEVICE, device) - self._async_resolve_up_device(device) + self._async_search_referenced_device(device) # Entities referenced in this automation for entity_id in automation.entities_in_automation( @@ -319,14 +312,8 @@ def _async_search_device(self, device_id: str, *, entry_point: bool = True) -> N # Add labels of this device self._add(ItemType.LABEL, device_entry.labels) - # Automations referencing this device - self._add( - ItemType.AUTOMATION, - automation.automations_with_device(self.hass, device_id), - ) - - # Scripts referencing this device - self._add(ItemType.SCRIPT, script.scripts_with_device(self.hass, device_id)) + # Automations and scripts referencing this device + self._async_add_automations_and_scripts_for_device(device_entry) # Entities of this device for entity_entry in er.async_entries_for_device( @@ -336,6 +323,46 @@ def _async_search_device(self, device_id: str, *, entry_point: bool = True) -> N # Add all entity information as well self._async_search_entity(entity_entry.entity_id, entry_point=False) + @callback + def _async_add_automations_and_scripts_for_device( + self, device_entry: dr.DeviceEntry + ) -> None: + """Add automations and scripts referencing a device. + + A device produced by splitting a pre-migration composite device is also + matched by automations and scripts referencing the composite device id, so + the composite id is searched too. A sibling split's id is never searched, so + references to a sibling are not matched. + """ + device_ids = {device_entry.id} + if device_entry.composite_device_id is not None: + device_ids.add(device_entry.composite_device_id) + for device_id in device_ids: + self._add( + ItemType.AUTOMATION, + automation.automations_with_device(self.hass, device_id), + ) + self._add(ItemType.SCRIPT, script.scripts_with_device(self.hass, device_id)) + + @callback + def _async_search_referenced_device(self, device_id: str) -> None: + """Add a device referenced by an automation or script. + + An automation or script created before a composite device was split + references the composite device id, which is not a live device. It is + expanded to the live split device ids, so the real devices are returned. + Any other id (a live device, or a stale reference) is added unchanged. + """ + split_devices = self._device_registry.async_get_devices_for_composite_device_id( + device_id + ) + device_ids: Iterable[str] = ( + [device.id for device in split_devices] if split_devices else (device_id,) + ) + for resolved_device_id in device_ids: + self._add(ItemType.DEVICE, resolved_device_id) + self._async_resolve_up_device(resolved_device_id) + @callback def _async_search_entity(self, entity_id: str, *, entry_point: bool = True) -> None: """Find results for an entity.""" @@ -524,8 +551,7 @@ def _async_search_script( # Devices referenced in this script for device in script.devices_in_script(self.hass, script_entity_id): - self._add(ItemType.DEVICE, device) - self._async_resolve_up_device(device) + self._async_search_referenced_device(device) # Entities referenced in this script for entity_id in script.entities_in_script(self.hass, script_entity_id): diff --git a/homeassistant/components/tado/config_flow.py b/homeassistant/components/tado/config_flow.py index 6d22cb777f22b..a57b07f87b59e 100644 --- a/homeassistant/components/tado/config_flow.py +++ b/homeassistant/components/tado/config_flow.py @@ -167,7 +167,8 @@ async def async_step_timeout( return self.async_show_form( step_id="timeout", ) - del self.login_task + self.login_task = None + self.tado = None return await self.async_step_user() @override diff --git a/homeassistant/components/yoto/manifest.json b/homeassistant/components/yoto/manifest.json index 14d96dfbd4f30..0d41784390be4 100644 --- a/homeassistant/components/yoto/manifest.json +++ b/homeassistant/components/yoto/manifest.json @@ -10,5 +10,5 @@ "iot_class": "cloud_push", "loggers": ["yoto_api"], "quality_scale": "platinum", - "requirements": ["yoto-api==4.3.1"] + "requirements": ["yoto-api==4.3.2"] } diff --git a/homeassistant/components/zha/__init__.py b/homeassistant/components/zha/__init__.py index 952496bdce628..8afd72aa15f9b 100644 --- a/homeassistant/components/zha/__init__.py +++ b/homeassistant/components/zha/__init__.py @@ -169,9 +169,8 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> b connect=False, device_resolver=DEVICE_REGISTRY.resolve ) as app: for dev in app.devices.values(): - dev_entry = device_registry.async_get_device( - identifiers={(DOMAIN, str(dev.ieee))}, - connections={(dr.CONNECTION_ZIGBEE, str(dev.ieee))}, + dev_entry = device_registry.async_get_device_by_identifier( + (DOMAIN, str(dev.ieee)), config_entry.entry_id ) if dev_entry is None: diff --git a/homeassistant/helpers/device_registry.py b/homeassistant/helpers/device_registry.py index 07698e8a67c06..d6c9ed0be89f2 100644 --- a/homeassistant/helpers/device_registry.py +++ b/homeassistant/helpers/device_registry.py @@ -682,10 +682,10 @@ def to_device_entry( config_entry_id=config_entry.entry_id, config_subentry_id=config_subentry_id, # type ignores: likely https://github.com/python/mypy/issues/8625 - connections=self.connections & connections, # type: ignore[arg-type] + connections=connections, # type: ignore[arg-type] created_at=self.created_at, disabled_by=disabled_by, - identifiers=self.identifiers & identifiers, # type: ignore[arg-type] + identifiers=identifiers, # type: ignore[arg-type] id=self.id, labels=self.labels, # type: ignore[arg-type] name_by_user=self.name_by_user, diff --git a/homeassistant/helpers/entity_platform.py b/homeassistant/helpers/entity_platform.py index f4c4366d1b5a6..bad3be8b03deb 100644 --- a/homeassistant/helpers/entity_platform.py +++ b/homeassistant/helpers/entity_platform.py @@ -55,6 +55,10 @@ MAX_ENABLED_ENTITIES_PER_CONFIG_ENTRY = 10000 +# Protocol integrations act as bridges for entire networks and legitimately +# create large numbers of entities, so they are exempt from the entity limit. +ENTITY_LIMIT_EXEMPT_DOMAINS = {"hue", "matter", "mqtt", "zha", "zwave_js"} + PLATFORM_NOT_READY_RETRIES = 10 DATA_ENTITY_PLATFORM: HassKey[dict[str, list[EntityPlatform]]] = HassKey( "entity_platform" @@ -967,6 +971,7 @@ async def _async_add_entity( # noqa: C901 disabled_by is None and not registered_entity_id and self.config_entry is not None + and self.config_entry.domain not in ENTITY_LIMIT_EXEMPT_DOMAINS and entity_registry.entities.get_enabled_count_for_config_entry_id( self.config_entry.entry_id ) diff --git a/pylint/plugins/README.md b/pylint/plugins/README.md index 2646bae0b140c..950dc40c5bf48 100644 --- a/pylint/plugins/README.md +++ b/pylint/plugins/README.md @@ -76,64 +76,64 @@ Every check has a code following the |------|------|-------------| | `C7401` | [`home-assistant-logger-period`](#c7401-home-assistant-logger-period) | Logger messages must not end with a period | | `C7402` | [`home-assistant-logger-capital`](#c7402-home-assistant-logger-capital) | Logger messages must start with a capital letter or use debug level | -| `E7401` | [`home-assistant-invalid-inheritance`](#e7401-home-assistant-invalid-inheritance) | Invalid entity class inheritance chain | | `C7403` | [`home-assistant-relative-import`](#c7403-home-assistant-relative-import) | Use relative imports within an integration | -| `W7401` | [`home-assistant-deprecated-import`](#w7401-home-assistant-deprecated-import) | Import uses a deprecated path | | `C7404` | [`home-assistant-absolute-import`](#c7404-home-assistant-absolute-import) | Use absolute imports for cross-integration references | | `C7405` | [`home-assistant-component-root-import`](#c7405-home-assistant-component-root-import) | Do not import from another integration's internals | | `C7406` | [`home-assistant-helper-namespace-import`](#c7406-home-assistant-helper-namespace-import) | Use the helper namespace import pattern | | `C7407` | [`home-assistant-import-constant-alias`](#c7407-home-assistant-import-constant-alias) | Aliased DOMAIN import needs a descriptive alias | | `C7408` | [`home-assistant-import-constant-unnecessary-alias`](#c7408-home-assistant-import-constant-unnecessary-alias) | Unnecessary alias when importing DOMAIN within the same integration | -| `E7402` | [`home-assistant-argument-type`](#e7402-home-assistant-argument-type) | Function argument should have the specified type hint | -| `E7403` | [`home-assistant-return-type`](#e7403-home-assistant-return-type) | Function should have the specified return type hint | -| `R7401` | [`home-assistant-consider-usefixtures-decorator`](#r7401-home-assistant-consider-usefixtures-decorator) | Use `@pytest.mark.usefixtures` for unused fixtures | -| `E7404` | [`home-assistant-missing-super-call`](#e7404-home-assistant-missing-super-call) | Method must call its parent via `super()` | | `C7409` | [`home-assistant-enforce-sorted-platforms`](#c7409-home-assistant-enforce-sorted-platforms) | PLATFORMS list must be sorted alphabetically | | `C7410` | [`home-assistant-enforce-greek-micro-char`](#c7410-home-assistant-enforce-greek-micro-char) | Use Greek mu (U+03BC), not ANSI micro sign (U+00B5) | | `C7411` | [`home-assistant-enforce-class-module`](#c7411-home-assistant-enforce-class-module) | Entity class should be in the correct platform module | -| `W7402` | [`home-assistant-async-callback-decorator`](#w7402-home-assistant-async-callback-decorator) | Coroutine should not be decorated with `@callback` | -| `W7403` | [`home-assistant-pytest-fixture-decorator`](#w7403-home-assistant-pytest-fixture-decorator) | Pytest fixture has invalid scope or autouse config | -| `W7404` | [`home-assistant-async-load-fixtures`](#w7404-home-assistant-async-load-fixtures) | Test fixture files should be loaded asynchronously | -| `W7405` | [`home-assistant-use-runtime-data`](#w7405-home-assistant-use-runtime-data) | Use `entry.runtime_data` instead of `hass.data[DOMAIN]` | -| `W7406` | [`home-assistant-unique-id-ip-based`](#w7406-home-assistant-unique-id-ip-based) | Unique ID should not be based on IP/hostname | -| `W7407` | [`home-assistant-config-flow-polling-field`](#w7407-home-assistant-config-flow-polling-field) | Config flow should not include polling interval fields | -| `W7408` | [`home-assistant-config-flow-name-field`](#w7408-home-assistant-config-flow-name-field) | Config flow should not include name fields | -| `R7402` | [`home-assistant-unused-test-fixture-argument`](#r7402-home-assistant-unused-test-fixture-argument) | Unused test function argument should use `@pytest.mark.usefixtures` | -| `C7415` | [`home-assistant-domain-argument`](#c7415-home-assistant-domain-argument) | Domain argument in tests should be a domain constant or variable | -| `W7418` | [`home-assistant-tests-direct-async-setup-entry`](#w7418-home-assistant-tests-direct-async-setup-entry) | Tests should not call an integration's `async_setup_entry` directly | -| `W7420` | [`home-assistant-tests-direct-platform-async-setup-entry`](#w7420-home-assistant-tests-direct-platform-async-setup-entry) | Tests should not call a platform's `async_setup_entry` directly | -| `W7421` | [`home-assistant-tests-direct-async-migrate-entry`](#w7421-home-assistant-tests-direct-async-migrate-entry) | Tests should not call an integration's `async_migrate_entry` directly | -| `W7422` | [`home-assistant-tests-direct-async-setup`](#w7422-home-assistant-tests-direct-async-setup) | Tests should not call an integration's `async_setup` directly | -| `W7426` | [`home-assistant-tests-direct-async-unload-entry`](#w7426-home-assistant-tests-direct-async-unload-entry) | Tests should not call an integration's `async_unload_entry` directly | +| `C7412` | [`home-assistant-entity-description-redundant-default`](#c7412-home-assistant-entity-description-redundant-default) | Setting an EntityDescription field to its default value is redundant | +| `C7413` | [`home-assistant-duplicate-const`](#c7413-home-assistant-duplicate-const) | Constant duplicates one in `homeassistant.const` with the same value | | `C7414` | [`home-assistant-enforce-utcnow`](#c7414-home-assistant-enforce-utcnow) | Use `homeassistant.util.dt.utcnow` instead of `datetime.now(UTC)` | +| `C7415` | [`home-assistant-domain-argument`](#c7415-home-assistant-domain-argument) | Domain argument in tests should be a domain constant or variable | | `C7425` | [`home-assistant-enforce-now`](#c7425-home-assistant-enforce-now) | Use `homeassistant.util.dt.now` instead of `datetime.now()` | | `C7427` | [`home-assistant-enforce-naive-now`](#c7427-home-assistant-enforce-naive-now) | Use `homeassistant.util.dt.naive_now` instead of `datetime.now()` | -| `W7423` | [`home-assistant-missing-entity-unique-id`](#w7423-home-assistant-missing-entity-unique-id) | Entity class does not statically guarantee a non-None unique id | -| `W7424` | [`home-assistant-entity-unique-id-static`](#w7424-home-assistant-entity-unique-id-static) | Entity class sets `_attr_unique_id` to a static string at class level | -| `W7425` | [`home-assistant-entity-unique-id-redundant-domain`](#w7425-home-assistant-entity-unique-id-redundant-domain) | Entity unique ID references the `DOMAIN` constant or includes the integration's domain as a string-literal delimited segment | -| `W7427` | [`home-assistant-entity-unique-id-redundant-platform`](#w7427-home-assistant-entity-unique-id-redundant-platform) | Entity unique ID includes the entity platform name (e.g. `sensor`, `light`) as a delimited string-literal segment | -| `C7412` | [`home-assistant-entity-description-redundant-default`](#c7412-home-assistant-entity-description-redundant-default) | Setting an EntityDescription field to its default value is redundant | -| `C7413` | [`home-assistant-duplicate-const`](#c7413-home-assistant-duplicate-const) | Constant duplicates one in `homeassistant.const` with the same value | +| `E7401` | [`home-assistant-invalid-inheritance`](#e7401-home-assistant-invalid-inheritance) | Invalid entity class inheritance chain | +| `E7402` | [`home-assistant-argument-type`](#e7402-home-assistant-argument-type) | Function argument should have the specified type hint | +| `E7403` | [`home-assistant-return-type`](#e7403-home-assistant-return-type) | Function should have the specified return type hint | +| `E7404` | [`home-assistant-missing-super-call`](#e7404-home-assistant-missing-super-call) | Method must call its parent via `super()` | | `E7405` | [`home-assistant-action-swallowed-exception`](#e7405-home-assistant-action-swallowed-exception) | Action handler must not swallow exceptions | -| `W7414` | [`home-assistant-service-registered-in-setup-entry`](#w7414-home-assistant-service-registered-in-setup-entry) | Services should be registered in `async_setup`, not `async_setup_entry` | -| `W7417` | [`home-assistant-exception-not-translated`](#w7417-home-assistant-exception-not-translated) | `HomeAssistantError` should use `translation_key`/`translation_domain` | -| `W7419` | [`home-assistant-exception-message-with-translation`](#w7419-home-assistant-exception-message-with-translation) | Don't pass a positional message when `translation_key` is set | | `E7406` | [`home-assistant-exception-translation-key-missing`](#e7406-home-assistant-exception-translation-key-missing) | Translation key not found in `strings.json` exceptions section | | `E7408` | [`home-assistant-exception-translation-key-domain-mismatch`](#e7408-home-assistant-exception-translation-key-domain-mismatch) | Only one of `translation_key` / `translation_domain` is set | -| `E7418` | [`home-assistant-exception-placeholder-mismatch`](#e7418-home-assistant-exception-placeholder-mismatch) | Translation placeholders in code don't match `strings.json` | | `E7409` | [`home-assistant-mdi-icon-not-found`](#e7409-home-assistant-mdi-icon-not-found) | MDI icon string does not exist in the Material Design Icons set | | `E7410` | [`home-assistant-mdi-icon-json-not-found`](#e7410-home-assistant-mdi-icon-json-not-found) | MDI icon in `icons.json` does not exist in the Material Design Icons set | +| `E7418` | [`home-assistant-exception-placeholder-mismatch`](#e7418-home-assistant-exception-placeholder-mismatch) | Translation placeholders in code don't match `strings.json` | +| `R7401` | [`home-assistant-consider-usefixtures-decorator`](#r7401-home-assistant-consider-usefixtures-decorator) | Use `@pytest.mark.usefixtures` for unused fixtures | +| `R7402` | [`home-assistant-unused-test-fixture-argument`](#r7402-home-assistant-unused-test-fixture-argument) | Unused test function argument should use `@pytest.mark.usefixtures` | | `R7403` | [`home-assistant-tests-redundant-usefixtures`](#r7403-home-assistant-tests-redundant-usefixtures) | `@pytest.mark.usefixtures` redundant when `pytestmark` already applies it | +| `W7401` | [`home-assistant-deprecated-import`](#w7401-home-assistant-deprecated-import) | Import uses a deprecated path | +| `W7402` | [`home-assistant-async-callback-decorator`](#w7402-home-assistant-async-callback-decorator) | Coroutine should not be decorated with `@callback` | +| `W7403` | [`home-assistant-pytest-fixture-decorator`](#w7403-home-assistant-pytest-fixture-decorator) | Pytest fixture has invalid scope or autouse config | +| `W7404` | [`home-assistant-async-load-fixtures`](#w7404-home-assistant-async-load-fixtures) | Test fixture files should be loaded asynchronously | +| `W7405` | [`home-assistant-use-runtime-data`](#w7405-home-assistant-use-runtime-data) | Use `entry.runtime_data` instead of `hass.data[DOMAIN]` | +| `W7406` | [`home-assistant-unique-id-ip-based`](#w7406-home-assistant-unique-id-ip-based) | Unique ID should not be based on IP/hostname | +| `W7407` | [`home-assistant-config-flow-polling-field`](#w7407-home-assistant-config-flow-polling-field) | Config flow should not include polling interval fields | +| `W7408` | [`home-assistant-config-flow-name-field`](#w7408-home-assistant-config-flow-name-field) | Config flow should not include name fields | | `W7409` | [`home-assistant-test-non-deterministic`](#w7409-home-assistant-test-non-deterministic) | Test contains `if`/`match` creating non-deterministic execution | | `W7410` | [`home-assistant-missing-reauthentication-flow`](#w7410-home-assistant-missing-reauthentication-flow) | Config flow should implement `async_step_reauth` | | `W7411` | [`home-assistant-missing-parallel-updates`](#w7411-home-assistant-missing-parallel-updates) | Platform module should define `PARALLEL_UPDATES` | | `W7412` | [`home-assistant-missing-diagnostics`](#w7412-home-assistant-missing-diagnostics) | Integration diagnostics module should implement a diagnostics function | | `W7413` | [`home-assistant-missing-config-entry-unloading`](#w7413-home-assistant-missing-config-entry-unloading) | Integration should implement `async_unload_entry` | +| `W7414` | [`home-assistant-service-registered-in-setup-entry`](#w7414-home-assistant-service-registered-in-setup-entry) | Services should be registered in `async_setup`, not `async_setup_entry` | | `W7415` | [`home-assistant-sequential-executor-jobs`](#w7415-home-assistant-sequential-executor-jobs) | Sequential `async_add_executor_job` calls should be grouped | | `W7416` | [`home-assistant-missing-has-entity-name`](#w7416-home-assistant-missing-has-entity-name) | Entity class should set `_attr_has_entity_name = True` | -| `W7433` | [`home-assistant-missing-test-before-configure`](#w7433-home-assistant-missing-test-before-configure) | Config flow should test the connection before creating an entry | +| `W7417` | [`home-assistant-exception-not-translated`](#w7417-home-assistant-exception-not-translated) | `HomeAssistantError` should use `translation_key`/`translation_domain` | +| `W7418` | [`home-assistant-tests-direct-async-setup-entry`](#w7418-home-assistant-tests-direct-async-setup-entry) | Tests should not call an integration's `async_setup_entry` directly | +| `W7419` | [`home-assistant-exception-message-with-translation`](#w7419-home-assistant-exception-message-with-translation) | Don't pass a positional message when `translation_key` is set | +| `W7420` | [`home-assistant-tests-direct-platform-async-setup-entry`](#w7420-home-assistant-tests-direct-platform-async-setup-entry) | Tests should not call a platform's `async_setup_entry` directly | +| `W7421` | [`home-assistant-tests-direct-async-migrate-entry`](#w7421-home-assistant-tests-direct-async-migrate-entry) | Tests should not call an integration's `async_migrate_entry` directly | +| `W7422` | [`home-assistant-tests-direct-async-setup`](#w7422-home-assistant-tests-direct-async-setup) | Tests should not call an integration's `async_setup` directly | +| `W7423` | [`home-assistant-missing-entity-unique-id`](#w7423-home-assistant-missing-entity-unique-id) | Entity class does not statically guarantee a non-None unique id | +| `W7424` | [`home-assistant-entity-unique-id-static`](#w7424-home-assistant-entity-unique-id-static) | Entity class sets `_attr_unique_id` to a static string at class level | +| `W7425` | [`home-assistant-entity-unique-id-redundant-domain`](#w7425-home-assistant-entity-unique-id-redundant-domain) | Entity unique ID references the `DOMAIN` constant or includes the integration's domain as a string-literal delimited segment | +| `W7426` | [`home-assistant-tests-direct-async-unload-entry`](#w7426-home-assistant-tests-direct-async-unload-entry) | Tests should not call an integration's `async_unload_entry` directly | +| `W7427` | [`home-assistant-entity-unique-id-redundant-platform`](#w7427-home-assistant-entity-unique-id-redundant-platform) | Entity unique ID includes the entity platform name (e.g. `sensor`, `light`) as a delimited string-literal segment | | `W7429` | [`home-assistant-unnecessary-format-mac`](#w7429-home-assistant-unnecessary-format-mac) | `format_mac()` is unnecessary with `CONNECTION_NETWORK_MAC` | | `W7430` | [`home-assistant-serial-port-selector-usb-dependency`](#w7430-home-assistant-serial-port-selector-usb-dependency) | Config flow using `SerialPortSelector` must declare `usb` in `dependencies` | +| `W7433` | [`home-assistant-missing-test-before-configure`](#w7433-home-assistant-missing-test-before-configure) | Config flow should test the connection before creating an entry | ## `home_assistant_logger` checker diff --git a/requirements.txt b/requirements.txt index 6388e99a9bcef..9e785316ce562 100644 --- a/requirements.txt +++ b/requirements.txt @@ -30,7 +30,7 @@ home-assistant-bluetooth==2.0.0 home-assistant-intents==2026.6.24 httpx==0.28.1 ifaddr==0.2.0 -infrared-protocols==7.5.0 +infrared-protocols==8.1.0 Jinja2==3.1.6 lru-dict==1.4.1 mutagen==1.48.1 diff --git a/requirements_all.txt b/requirements_all.txt index ff15774467703..2bfede3967264 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -877,7 +877,7 @@ dynalite-panel==0.0.4 eagle100==0.1.1 # homeassistant.components.earn_e_p1 -earn-e-p1==0.1.0 +earn-e-p1==0.2.0 # homeassistant.components.easyenergy easyenergy==3.0.1 @@ -1374,7 +1374,7 @@ influxdb-client==1.50.0 influxdb==5.3.2 # homeassistant.components.infrared -infrared-protocols==7.5.0 +infrared-protocols==8.1.0 # homeassistant.components.inkbird inkbird-ble==1.4.4 @@ -1438,7 +1438,7 @@ kiwiki-client==0.1.2 knocki==0.4.2 # homeassistant.components.knx -knx-frontend==2026.6.23.203726 +knx-frontend==2026.7.17.104339 # homeassistant.components.knx knx-telegram-store[sqlite,postgres]==0.10.2 @@ -1616,7 +1616,7 @@ momonga==0.3.0 monarchmoneycommunity==1.5.1 # homeassistant.components.monzo -monzopy==1.5.1 +monzopy==1.6.0 # homeassistant.components.mopeka mopeka-iot-ble==0.8.0 @@ -2116,7 +2116,7 @@ pycsspeechtts==1.0.8 pycync==0.5.0 # homeassistant.components.daikin -pydaikin==2.18.2 +pydaikin==2.18.3 # homeassistant.components.danfoss_air pydanfossair==0.1.0 @@ -3446,7 +3446,7 @@ yeelightsunflower==0.0.10 yolink-api==0.6.5 # homeassistant.components.yoto -yoto-api==4.3.1 +yoto-api==4.3.2 # homeassistant.components.youless youless-api==2.2.0 diff --git a/tests/components/edifier_infrared/conftest.py b/tests/components/edifier_infrared/conftest.py index 7cc22ec7b2a29..147074c40cc60 100644 --- a/tests/components/edifier_infrared/conftest.py +++ b/tests/components/edifier_infrared/conftest.py @@ -28,13 +28,14 @@ def mock_config_entry() -> MockConfigEntry: return MockConfigEntry( domain=DOMAIN, entry_id="01JTEST0000000000000000000", - title="Edifier R1700BT via Test IR emitter", + title="Edifier R1700BTs via Test IR emitter", data={ CONF_INFRARED_ENTITY_ID: MOCK_INFRARED_EMITTER_ENTITY_ID, - CONF_MODEL: EdifierModel.R1700BT.value, - CONF_COMMAND_SET: EdifierCommandSet.R1700BT.value, + CONF_MODEL: EdifierModel.R1700BTS.value, + CONF_COMMAND_SET: EdifierCommandSet.R1700BTS.value, }, - unique_id=f"r1700bt_{MOCK_INFRARED_EMITTER_ENTITY_ID}", + unique_id=f"r1700bts_{MOCK_INFRARED_EMITTER_ENTITY_ID}", + version=2, ) @@ -53,7 +54,12 @@ def mock_edifier_code_to_command() -> Generator[None]: """ with ( patch( - "infrared_protocols.codes.edifier.r1700bt.EdifierR1700BTCode.to_command", + "infrared_protocols.codes.edifier.r1700bt_pre_2017.EdifierR1700BTPre2017Code.to_command", + autospec=True, + side_effect=lambda self: self, + ), + patch( + "infrared_protocols.codes.edifier.r1700bts.EdifierR1700BTsCode.to_command", autospec=True, side_effect=lambda self: self, ), diff --git a/tests/components/edifier_infrared/snapshots/test_button.ambr b/tests/components/edifier_infrared/snapshots/test_button.ambr index 06ff22cd26a31..a9e4b21733abf 100644 --- a/tests/components/edifier_infrared/snapshots/test_button.ambr +++ b/tests/components/edifier_infrared/snapshots/test_button.ambr @@ -1,5 +1,5 @@ # serializer version: 1 -# name: test_entities[button.edifier_r1700bt_bluetooth-entry] +# name: test_entities[button.edifier_r1700bts_bluetooth-entry] EntityRegistryEntrySnapshot({ 'aliases': list([ None, @@ -13,7 +13,7 @@ 'disabled_by': None, 'domain': 'button', 'entity_category': None, - 'entity_id': 'button.edifier_r1700bt_bluetooth', + 'entity_id': 'button.edifier_r1700bts_bluetooth', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -36,20 +36,20 @@ 'unit_of_measurement': None, }) # --- -# name: test_entities[button.edifier_r1700bt_bluetooth-state] +# name: test_entities[button.edifier_r1700bts_bluetooth-state] StateSnapshot({ 'attributes': ReadOnlyDict({ - : 'Edifier R1700BT Bluetooth', + : 'Edifier R1700BTs Bluetooth', }), 'context': , - 'entity_id': 'button.edifier_r1700bt_bluetooth', + 'entity_id': 'button.edifier_r1700bts_bluetooth', 'last_changed': , 'last_reported': , 'last_updated': , 'state': 'unknown', }) # --- -# name: test_entities[button.edifier_r1700bt_fx_off-entry] +# name: test_entities[button.edifier_r1700bts_fx_off-entry] EntityRegistryEntrySnapshot({ 'aliases': list([ None, @@ -63,7 +63,7 @@ 'disabled_by': None, 'domain': 'button', 'entity_category': None, - 'entity_id': 'button.edifier_r1700bt_fx_off', + 'entity_id': 'button.edifier_r1700bts_fx_off', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -86,20 +86,20 @@ 'unit_of_measurement': None, }) # --- -# name: test_entities[button.edifier_r1700bt_fx_off-state] +# name: test_entities[button.edifier_r1700bts_fx_off-state] StateSnapshot({ 'attributes': ReadOnlyDict({ - : 'Edifier R1700BT FX off', + : 'Edifier R1700BTs FX off', }), 'context': , - 'entity_id': 'button.edifier_r1700bt_fx_off', + 'entity_id': 'button.edifier_r1700bts_fx_off', 'last_changed': , 'last_reported': , 'last_updated': , 'state': 'unknown', }) # --- -# name: test_entities[button.edifier_r1700bt_fx_on-entry] +# name: test_entities[button.edifier_r1700bts_fx_on-entry] EntityRegistryEntrySnapshot({ 'aliases': list([ None, @@ -113,7 +113,7 @@ 'disabled_by': None, 'domain': 'button', 'entity_category': None, - 'entity_id': 'button.edifier_r1700bt_fx_on', + 'entity_id': 'button.edifier_r1700bts_fx_on', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -136,20 +136,20 @@ 'unit_of_measurement': None, }) # --- -# name: test_entities[button.edifier_r1700bt_fx_on-state] +# name: test_entities[button.edifier_r1700bts_fx_on-state] StateSnapshot({ 'attributes': ReadOnlyDict({ - : 'Edifier R1700BT FX on', + : 'Edifier R1700BTs FX on', }), 'context': , - 'entity_id': 'button.edifier_r1700bt_fx_on', + 'entity_id': 'button.edifier_r1700bts_fx_on', 'last_changed': , 'last_reported': , 'last_updated': , 'state': 'unknown', }) # --- -# name: test_entities[button.edifier_r1700bt_line_1-entry] +# name: test_entities[button.edifier_r1700bts_line_1-entry] EntityRegistryEntrySnapshot({ 'aliases': list([ None, @@ -163,7 +163,7 @@ 'disabled_by': None, 'domain': 'button', 'entity_category': None, - 'entity_id': 'button.edifier_r1700bt_line_1', + 'entity_id': 'button.edifier_r1700bts_line_1', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -186,20 +186,20 @@ 'unit_of_measurement': None, }) # --- -# name: test_entities[button.edifier_r1700bt_line_1-state] +# name: test_entities[button.edifier_r1700bts_line_1-state] StateSnapshot({ 'attributes': ReadOnlyDict({ - : 'Edifier R1700BT Line 1', + : 'Edifier R1700BTs Line 1', }), 'context': , - 'entity_id': 'button.edifier_r1700bt_line_1', + 'entity_id': 'button.edifier_r1700bts_line_1', 'last_changed': , 'last_reported': , 'last_updated': , 'state': 'unknown', }) # --- -# name: test_entities[button.edifier_r1700bt_line_2-entry] +# name: test_entities[button.edifier_r1700bts_line_2-entry] EntityRegistryEntrySnapshot({ 'aliases': list([ None, @@ -213,7 +213,7 @@ 'disabled_by': None, 'domain': 'button', 'entity_category': None, - 'entity_id': 'button.edifier_r1700bt_line_2', + 'entity_id': 'button.edifier_r1700bts_line_2', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -236,13 +236,13 @@ 'unit_of_measurement': None, }) # --- -# name: test_entities[button.edifier_r1700bt_line_2-state] +# name: test_entities[button.edifier_r1700bts_line_2-state] StateSnapshot({ 'attributes': ReadOnlyDict({ - : 'Edifier R1700BT Line 2', + : 'Edifier R1700BTs Line 2', }), 'context': , - 'entity_id': 'button.edifier_r1700bt_line_2', + 'entity_id': 'button.edifier_r1700bts_line_2', 'last_changed': , 'last_reported': , 'last_updated': , diff --git a/tests/components/edifier_infrared/snapshots/test_media_player.ambr b/tests/components/edifier_infrared/snapshots/test_media_player.ambr index e7c299a6a6706..10e7edc8c4c59 100644 --- a/tests/components/edifier_infrared/snapshots/test_media_player.ambr +++ b/tests/components/edifier_infrared/snapshots/test_media_player.ambr @@ -1,5 +1,5 @@ # serializer version: 1 -# name: test_entities[media_player.edifier_r1700bt-entry] +# name: test_entities[media_player.edifier_r1700bts-entry] EntityRegistryEntrySnapshot({ 'aliases': list([ None, @@ -14,7 +14,7 @@ 'disabled_by': None, 'domain': 'media_player', 'entity_category': None, - 'entity_id': 'media_player.edifier_r1700bt', + 'entity_id': 'media_player.edifier_r1700bts', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -37,16 +37,16 @@ 'unit_of_measurement': None, }) # --- -# name: test_entities[media_player.edifier_r1700bt-state] +# name: test_entities[media_player.edifier_r1700bts-state] StateSnapshot({ 'attributes': ReadOnlyDict({ : True, : 'speaker', - : 'Edifier R1700BT', + : 'Edifier R1700BTs', : , }), 'context': , - 'entity_id': 'media_player.edifier_r1700bt', + 'entity_id': 'media_player.edifier_r1700bts', 'last_changed': , 'last_reported': , 'last_updated': , diff --git a/tests/components/edifier_infrared/test_button.py b/tests/components/edifier_infrared/test_button.py index d97911aad4da1..35427e76969f3 100644 --- a/tests/components/edifier_infrared/test_button.py +++ b/tests/components/edifier_infrared/test_button.py @@ -1,6 +1,6 @@ """Tests for the Edifier Infrared button platform.""" -from infrared_protocols.codes.edifier.r1700bt import EdifierR1700BTCode +from infrared_protocols.codes.edifier.r1700bts import EdifierR1700BTsCode import pytest from syrupy.assertion import SnapshotAssertion @@ -14,7 +14,7 @@ from tests.components.infrared import EMITTER_ENTITY_ID from tests.components.infrared.common import MockInfraredEmitterEntity -BLUETOOTH_BUTTON_ENTITY_ID = "button.edifier_r1700bt_bluetooth" +BLUETOOTH_BUTTON_ENTITY_ID = "button.edifier_r1700bts_bluetooth" @pytest.fixture @@ -37,11 +37,11 @@ async def test_entities( @pytest.mark.parametrize( ("entity_id", "expected_code"), [ - ("button.edifier_r1700bt_bluetooth", EdifierR1700BTCode.BLUETOOTH), - ("button.edifier_r1700bt_line_1", EdifierR1700BTCode.LINE_1), - ("button.edifier_r1700bt_line_2", EdifierR1700BTCode.LINE_2), - ("button.edifier_r1700bt_fx_on", EdifierR1700BTCode.FX_ON), - ("button.edifier_r1700bt_fx_off", EdifierR1700BTCode.FX_OFF), + ("button.edifier_r1700bts_bluetooth", EdifierR1700BTsCode.BLUETOOTH), + ("button.edifier_r1700bts_line_1", EdifierR1700BTsCode.LINE_1), + ("button.edifier_r1700bts_line_2", EdifierR1700BTsCode.LINE_2), + ("button.edifier_r1700bts_fx_on", EdifierR1700BTsCode.FX_ON), + ("button.edifier_r1700bts_fx_off", EdifierR1700BTsCode.FX_OFF), ], ) @pytest.mark.usefixtures("init_integration") @@ -49,7 +49,7 @@ async def test_button_press_sends_correct_code( hass: HomeAssistant, mock_infrared_emitter_entity: MockInfraredEmitterEntity, entity_id: str, - expected_code: EdifierR1700BTCode, + expected_code: EdifierR1700BTsCode, ) -> None: """Test each button press sends the correct IR code.""" await hass.services.async_call( diff --git a/tests/components/edifier_infrared/test_config_flow.py b/tests/components/edifier_infrared/test_config_flow.py index 119cfa8ad0c18..afe7dc0fd7d82 100644 --- a/tests/components/edifier_infrared/test_config_flow.py +++ b/tests/components/edifier_infrared/test_config_flow.py @@ -21,7 +21,9 @@ @pytest.mark.parametrize( ("model", "expected_command_set"), [ - (EdifierModel.R1700BT, EdifierCommandSet.R1700BT), + (EdifierModel.R1700BT_PRE_2017, EdifierCommandSet.R1700BT_PRE_2017), + (EdifierModel.R1700BT_2017, EdifierCommandSet.R1700BT_2017), + (EdifierModel.R1700BTS, EdifierCommandSet.R1700BTS), (EdifierModel.R1280DB, EdifierCommandSet.R1280DB), (EdifierModel.R1280T, EdifierCommandSet.R1280T), (EdifierModel.S360DB, EdifierCommandSet.S360DB), @@ -81,7 +83,7 @@ async def test_user_flow_already_configured( result["flow_id"], user_input={ CONF_INFRARED_ENTITY_ID: EMITTER_ENTITY_ID, - CONF_MODEL: EdifierModel.R1700BT.value, + CONF_MODEL: EdifierModel.R1700BTS.value, }, ) @@ -104,8 +106,8 @@ async def test_user_flow_no_emitters(hass: HomeAssistant) -> None: @pytest.mark.parametrize( ("entity_name", "expected_title"), [ - (None, "Edifier R1700BT via Test IR emitter"), - ("Living room IR", "Edifier R1700BT via Living room IR"), + (None, "Edifier R1700BTs via Test IR emitter"), + ("Living room IR", "Edifier R1700BTs via Living room IR"), ], ) async def test_user_flow_title_from_entity_name( @@ -124,7 +126,7 @@ async def test_user_flow_title_from_entity_name( result["flow_id"], user_input={ CONF_INFRARED_ENTITY_ID: EMITTER_ENTITY_ID, - CONF_MODEL: EdifierModel.R1700BT.value, + CONF_MODEL: EdifierModel.R1700BTS.value, }, ) diff --git a/tests/components/edifier_infrared/test_init.py b/tests/components/edifier_infrared/test_init.py index 8e2770e8ca0d8..036727af2a11d 100644 --- a/tests/components/edifier_infrared/test_init.py +++ b/tests/components/edifier_infrared/test_init.py @@ -1,9 +1,18 @@ """Tests for the Edifier Infrared integration setup.""" +import pytest + +from homeassistant.components.edifier_infrared.const import ( + CONF_COMMAND_SET, + CONF_INFRARED_ENTITY_ID, + DOMAIN, +) from homeassistant.config_entries import ConfigEntryState +from homeassistant.const import CONF_MODEL from homeassistant.core import HomeAssistant from tests.common import MockConfigEntry +from tests.components.infrared import EMITTER_ENTITY_ID async def test_setup_and_unload_entry( @@ -17,3 +26,51 @@ async def test_setup_and_unload_entry( await hass.async_block_till_done() assert entry.state is ConfigEntryState.NOT_LOADED + + +@pytest.mark.parametrize( + ("old_model", "old_command_set", "expected_model", "expected_command_set"), + [ + pytest.param( + "R1700BT", + "r1700bt", + "R1700BT (pre-2017)", + "r1700bt_pre_2017", + id="r1700bt-to-pre-2017", + ), + pytest.param( + "R1700BTs", "r1700bt", "R1700BTs", "r1700bts", id="r1700bts-family" + ), + pytest.param("R1280DB", "r1280db", "R1280DB", "r1280db", id="unchanged-model"), + ], +) +@pytest.mark.usefixtures("mock_infrared_emitter_entity", "mock_edifier_code_to_command") +async def test_migrate_entry_v1_to_v2( + hass: HomeAssistant, + old_model: str, + old_command_set: str, + expected_model: str, + expected_command_set: str, +) -> None: + """Test v1 config entries are migrated to the split R1700BT command sets.""" + entry = MockConfigEntry( + domain=DOMAIN, + title=f"Edifier {old_model} via Test IR emitter", + data={ + CONF_INFRARED_ENTITY_ID: EMITTER_ENTITY_ID, + CONF_MODEL: old_model, + CONF_COMMAND_SET: old_command_set, + }, + unique_id=f"{old_command_set}_{EMITTER_ENTITY_ID}", + version=1, + ) + entry.add_to_hass(hass) + + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + assert entry.state is ConfigEntryState.LOADED + assert entry.version == 2 + assert entry.data[CONF_MODEL] == expected_model + assert entry.data[CONF_COMMAND_SET] == expected_command_set + assert entry.unique_id == f"{expected_command_set}_{EMITTER_ENTITY_ID}" diff --git a/tests/components/edifier_infrared/test_media_player.py b/tests/components/edifier_infrared/test_media_player.py index 5fb1779ebcb04..e737b117bcc31 100644 --- a/tests/components/edifier_infrared/test_media_player.py +++ b/tests/components/edifier_infrared/test_media_player.py @@ -1,7 +1,7 @@ """Tests for the Edifier Infrared media player platform.""" from infrared_protocols.codes.edifier.models import EdifierCommandSet, EdifierModel -from infrared_protocols.codes.edifier.r1700bt import EdifierR1700BTCode +from infrared_protocols.codes.edifier.r1700bts import EdifierR1700BTsCode from infrared_protocols.codes.edifier.rc20g import EdifierRC20GCode import pytest from syrupy.assertion import SnapshotAssertion @@ -32,7 +32,7 @@ from tests.components.infrared import EMITTER_ENTITY_ID from tests.components.infrared.common import MockInfraredEmitterEntity -MEDIA_PLAYER_ENTITY_ID = "media_player.edifier_r1700bt" +MEDIA_PLAYER_ENTITY_ID = "media_player.edifier_r1700bts" @pytest.fixture @@ -55,15 +55,15 @@ async def test_entities( @pytest.mark.parametrize( ("service", "service_data", "expected_code"), [ - (SERVICE_TURN_ON, {}, EdifierR1700BTCode.POWER), - (SERVICE_TURN_OFF, {}, EdifierR1700BTCode.POWER), - (SERVICE_VOLUME_UP, {}, EdifierR1700BTCode.VOLUME_UP), - (SERVICE_VOLUME_DOWN, {}, EdifierR1700BTCode.VOLUME_DOWN), - (SERVICE_VOLUME_MUTE, {"is_volume_muted": True}, EdifierR1700BTCode.MUTE), - (SERVICE_MEDIA_PLAY, {}, EdifierR1700BTCode.PLAY_PAUSE), - (SERVICE_MEDIA_PAUSE, {}, EdifierR1700BTCode.PLAY_PAUSE), - (SERVICE_MEDIA_NEXT_TRACK, {}, EdifierR1700BTCode.FORWARD), - (SERVICE_MEDIA_PREVIOUS_TRACK, {}, EdifierR1700BTCode.BACK), + (SERVICE_TURN_ON, {}, EdifierR1700BTsCode.POWER), + (SERVICE_TURN_OFF, {}, EdifierR1700BTsCode.POWER), + (SERVICE_VOLUME_UP, {}, EdifierR1700BTsCode.VOLUME_UP), + (SERVICE_VOLUME_DOWN, {}, EdifierR1700BTsCode.VOLUME_DOWN), + (SERVICE_VOLUME_MUTE, {"is_volume_muted": True}, EdifierR1700BTsCode.MUTE), + (SERVICE_MEDIA_PLAY, {}, EdifierR1700BTsCode.PLAY_PAUSE), + (SERVICE_MEDIA_PAUSE, {}, EdifierR1700BTsCode.PLAY_PAUSE), + (SERVICE_MEDIA_NEXT_TRACK, {}, EdifierR1700BTsCode.FORWARD), + (SERVICE_MEDIA_PREVIOUS_TRACK, {}, EdifierR1700BTsCode.BACK), ], ) @pytest.mark.usefixtures("init_integration") @@ -72,7 +72,7 @@ async def test_media_player_action_sends_correct_code( mock_infrared_emitter_entity: MockInfraredEmitterEntity, service: str, service_data: dict[str, bool], - expected_code: EdifierR1700BTCode, + expected_code: EdifierR1700BTsCode, ) -> None: """Test each media player action sends the correct IR code.""" await hass.services.async_call( diff --git a/tests/components/frontend/test_init.py b/tests/components/frontend/test_init.py index ebe0489424ff6..69ad5e048bf14 100644 --- a/tests/components/frontend/test_init.py +++ b/tests/components/frontend/test_init.py @@ -958,13 +958,24 @@ async def test_get_version( ("from_url", "to_url", "expected_status"), [ ("/.well-known/change-password", "/profile", 302), - ("/developer-tools", "/config/developer-tools", 301), - ("/developer-tools/yaml", "/config/developer-tools/yaml", 301), - ("/developer-tools/state", "/config/developer-tools/state", 301), - ("/developer-tools/action", "/config/developer-tools/action", 301), - ("/developer-tools/template", "/config/developer-tools/template", 301), - ("/developer-tools/event", "/config/developer-tools/event", 301), - ("/developer-tools/debug", "/config/developer-tools/debug", 301), + ("/developer-tools", "/config/tools", 301), + ("/developer-tools/yaml", "/config/tools/yaml", 301), + ("/developer-tools/state", "/config/tools/state", 301), + ("/developer-tools/action", "/config/tools/action", 301), + ("/developer-tools/template", "/config/tools/template", 301), + ("/developer-tools/event", "/config/tools/event", 301), + ("/developer-tools/statistics", "/config/tools/statistics", 301), + ("/developer-tools/assist", "/config/tools/assist", 301), + ("/developer-tools/debug", "/config/tools/debug", 301), + ("/config/developer-tools", "/config/tools", 301), + ("/config/developer-tools/yaml", "/config/tools/yaml", 301), + ("/config/developer-tools/state", "/config/tools/state", 301), + ("/config/developer-tools/action", "/config/tools/action", 301), + ("/config/developer-tools/template", "/config/tools/template", 301), + ("/config/developer-tools/event", "/config/tools/event", 301), + ("/config/developer-tools/statistics", "/config/tools/statistics", 301), + ("/config/developer-tools/assist", "/config/tools/assist", 301), + ("/config/developer-tools/debug", "/config/tools/debug", 301), ("/shopping-list", "/todo", 301), ], ) diff --git a/tests/components/http/test_auth.py b/tests/components/http/test_auth.py index a2f5e281e66f1..a5b7469128ce2 100644 --- a/tests/components/http/test_auth.py +++ b/tests/components/http/test_auth.py @@ -111,7 +111,7 @@ def trusted_networks_auth( async def test_auth_middleware_loaded_by_default(hass: HomeAssistant) -> None: """Test accessing to server from banned IP when feature is off.""" - with patch("homeassistant.components.http.async_setup_auth") as mock_setup: + with patch("homeassistant.components.http.server.async_setup_auth") as mock_setup: await async_setup_component(hass, DOMAIN, {"http": {}}) assert len(mock_setup.mock_calls) == 1 diff --git a/tests/components/http/test_ban.py b/tests/components/http/test_ban.py index 086adb99dca9e..5f5fd1660f4f1 100644 --- a/tests/components/http/test_ban.py +++ b/tests/components/http/test_ban.py @@ -310,7 +310,7 @@ async def unauth_handler(request): async def test_ban_middleware_not_loaded_by_config(hass: HomeAssistant) -> None: """Test accessing to server from banned IP when feature is off.""" - with patch("homeassistant.components.http.setup_bans") as mock_setup: + with patch("homeassistant.components.http.server.setup_bans") as mock_setup: await async_setup_component( hass, DOMAIN, {"http": {http.CONF_IP_BAN_ENABLED: False}} ) @@ -320,7 +320,7 @@ async def test_ban_middleware_not_loaded_by_config(hass: HomeAssistant) -> None: async def test_ban_middleware_loaded_by_default(hass: HomeAssistant) -> None: """Test accessing to server from banned IP when feature is off.""" - with patch("homeassistant.components.http.setup_bans") as mock_setup: + with patch("homeassistant.components.http.server.setup_bans") as mock_setup: await async_setup_component(hass, DOMAIN, {"http": {}}) assert len(mock_setup.mock_calls) == 1 diff --git a/tests/components/http/test_cors.py b/tests/components/http/test_cors.py index 3d3d6ba89e1fd..61b981528a472 100644 --- a/tests/components/http/test_cors.py +++ b/tests/components/http/test_cors.py @@ -31,7 +31,7 @@ async def test_cors_middleware_loaded_by_default(hass: HomeAssistant) -> None: """Test accessing to server from banned IP when feature is off.""" - with patch("homeassistant.components.http.setup_cors") as mock_setup: + with patch("homeassistant.components.http.server.setup_cors") as mock_setup: await async_setup_component(hass, DOMAIN, {"http": {}}) assert len(mock_setup.mock_calls) == 1 @@ -39,7 +39,7 @@ async def test_cors_middleware_loaded_by_default(hass: HomeAssistant) -> None: async def test_cors_middleware_loaded_from_config(hass: HomeAssistant) -> None: """Test accessing to server from banned IP when feature is off.""" - with patch("homeassistant.components.http.setup_cors") as mock_setup: + with patch("homeassistant.components.http.server.setup_cors") as mock_setup: await async_setup_component( hass, DOMAIN, diff --git a/tests/components/http/test_init.py b/tests/components/http/test_init.py index b28e57d015ae2..2dc1d7d4de337 100644 --- a/tests/components/http/test_init.py +++ b/tests/components/http/test_init.py @@ -492,7 +492,7 @@ async def test_emergency_ssl_certificate_when_invalid_get_url_fails( hass.config.recovery_mode = True with patch( - "homeassistant.components.http.get_url", side_effect=NoURLAvailableError + "homeassistant.components.http.server.get_url", side_effect=NoURLAvailableError ) as mock_get_url: assert await async_setup_component(hass, DOMAIN, {}) is True await hass.async_start() @@ -525,7 +525,8 @@ async def test_invalid_ssl_and_cannot_create_emergency_cert( hass.config.recovery_mode = True with patch( - "homeassistant.components.http.x509.CertificateBuilder", side_effect=OSError + "homeassistant.components.http.server.x509.CertificateBuilder", + side_effect=OSError, ) as mock_builder: assert await async_setup_component(hass, DOMAIN, {}) is True await hass.async_start() @@ -566,7 +567,8 @@ async def test_invalid_ssl_and_cannot_create_emergency_cert_with_ssl_peer_cert( hass.config.recovery_mode = True with patch( - "homeassistant.components.http.x509.CertificateBuilder", side_effect=OSError + "homeassistant.components.http.server.x509.CertificateBuilder", + side_effect=OSError, ) as mock_builder: assert await async_setup_component(hass, DOMAIN, {}) is False await hass.async_start() @@ -639,7 +641,7 @@ async def test_create_server_passes_configuration(hass: HomeAssistant) -> None: async def test_cors_defaults(hass: HomeAssistant) -> None: """Test the CORS default settings.""" - with patch("homeassistant.components.http.setup_cors") as mock_setup: + with patch("homeassistant.components.http.server.setup_cors") as mock_setup: assert await async_setup_component(hass, DOMAIN, {}) assert len(mock_setup.mock_calls) == 1 diff --git a/tests/components/mikrotik/__init__.py b/tests/components/mikrotik/__init__.py index 1291374206936..a0bd199ce510a 100644 --- a/tests/components/mikrotik/__init__.py +++ b/tests/components/mikrotik/__init__.py @@ -77,10 +77,7 @@ def mock_command( ) -> Any: return command_responses.get(cmd, {}) - with ( - patch("librouteros.connect"), - patch.object(mikrotik.coordinator.MikrotikData, "command", new=mock_command), - ): + with patch.object(mikrotik.coordinator.MikrotikData, "command", new=mock_command): assert await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() diff --git a/tests/components/mikrotik/conftest.py b/tests/components/mikrotik/conftest.py index e9dfe8fb3d7cf..6a8b54ec7d933 100644 --- a/tests/components/mikrotik/conftest.py +++ b/tests/components/mikrotik/conftest.py @@ -16,9 +16,8 @@ def mock_config_entry(): @pytest.fixture(autouse=True) def mock_api() -> Generator[MagicMock]: - """Mock api.""" - with ( - patch("librouteros.create_transport"), - patch("librouteros.Api.readResponse") as mock_api, - ): - yield mock_api + """Mock the librouteros API instance returned by librouteros.connect.""" + api_instance = MagicMock() + + with patch("librouteros.connect", return_value=api_instance): + yield api_instance diff --git a/tests/components/mikrotik/snapshots/test_button.ambr b/tests/components/mikrotik/snapshots/test_button.ambr new file mode 100644 index 0000000000000..37c86d9cde733 --- /dev/null +++ b/tests/components/mikrotik/snapshots/test_button.ambr @@ -0,0 +1,102 @@ +# serializer version: 1 +# name: test_button_entities_created[button.mikrotik_restart-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'button', + 'entity_category': , + 'entity_id': 'button.mikrotik_restart', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Restart', + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Restart', + 'platform': 'mikrotik', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'ABC123_reboot', + 'unit_of_measurement': None, + }) +# --- +# name: test_button_entities_created[button.mikrotik_restart-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'restart', + : 'Mikrotik Restart', + }), + 'context': , + 'entity_id': 'button.mikrotik_restart', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_button_entities_created[button.mikrotik_shutdown-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'button', + 'entity_category': , + 'entity_id': 'button.mikrotik_shutdown', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Shutdown', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Shutdown', + 'platform': 'mikrotik', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'shutdown', + 'unique_id': 'ABC123_shutdown', + 'unit_of_measurement': None, + }) +# --- +# name: test_button_entities_created[button.mikrotik_shutdown-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'Mikrotik Shutdown', + }), + 'context': , + 'entity_id': 'button.mikrotik_shutdown', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- diff --git a/tests/components/mikrotik/test_button.py b/tests/components/mikrotik/test_button.py new file mode 100644 index 0000000000000..a4dbc5827f1bb --- /dev/null +++ b/tests/components/mikrotik/test_button.py @@ -0,0 +1,57 @@ +"""Tests for the Mikrotik button platform.""" + +from unittest.mock import MagicMock, patch + +from syrupy.assertion import SnapshotAssertion + +from homeassistant.components.button import DOMAIN as BUTTON_DOMAIN, SERVICE_PRESS +from homeassistant.const import ATTR_ENTITY_ID, Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from . import setup_mikrotik_entry + +from tests.common import snapshot_platform + + +async def test_button_entities_created( + hass: HomeAssistant, + snapshot: SnapshotAssertion, + entity_registry: er.EntityRegistry, +) -> None: + """Test Mikrotik button entities are created with expected values.""" + with patch("homeassistant.components.mikrotik.PLATFORMS", [Platform.BUTTON]): + config_entry = await setup_mikrotik_entry(hass) + + await snapshot_platform(hass, entity_registry, snapshot, config_entry.entry_id) + + +async def test_button_press( + hass: HomeAssistant, mock_api: MagicMock, mock_config_entry +) -> None: + """Test Mikrotik button entities press.""" + await setup_mikrotik_entry( + hass, + health_data=[ + {"name": "voltage", "value": 24.2}, + ], + system_data=[ + { + "cpu-load": 15, + "total-memory": 0, + "free-memory": 200, + "total-hdd-space": 0, + "free-hdd-space": 25, + "uptime": None, + } + ], + ) + + await hass.services.async_call( + BUTTON_DOMAIN, + SERVICE_PRESS, + {ATTR_ENTITY_ID: "button.mikrotik_restart"}, + blocking=True, + ) + + mock_api.assert_called_with("/system/reboot") diff --git a/tests/components/nextdns/conftest.py b/tests/components/nextdns/conftest.py index 9699ffe2f3071..5081620e70290 100644 --- a/tests/components/nextdns/conftest.py +++ b/tests/components/nextdns/conftest.py @@ -15,7 +15,12 @@ ) import pytest -from homeassistant.components.nextdns.const import CONF_PROFILE_ID, DOMAIN +from homeassistant.components.nextdns.const import ( + CONF_PROFILE_ID, + DOMAIN, + SUBENTRY_TYPE_PROFILE, +) +from homeassistant.config_entries import ConfigSubentryData from homeassistant.const import CONF_API_KEY from tests.common import ( @@ -47,14 +52,41 @@ def mock_setup_entry() -> Generator[AsyncMock]: @pytest.fixture -def mock_config_entry() -> MockConfigEntry: +def mock_subentries() -> list[ConfigSubentryData]: + """Return a list of mock subentries.""" + return [ + ConfigSubentryData( + data={CONF_PROFILE_ID: "xyz12"}, + subentry_type=SUBENTRY_TYPE_PROFILE, + title="Fake Profile", + unique_id="xyz12", + ) + ] + + +@pytest.fixture +def mock_config_entry(mock_subentries: list[ConfigSubentryData]) -> MockConfigEntry: """Return the default mocked config entry.""" + return MockConfigEntry( + domain=DOMAIN, + title="NextDNS", + data={CONF_API_KEY: "fake_api_key"}, + entry_id="d9aa37407ddac7b964a99e86312288d6", + version=2, + subentries_data=mock_subentries, + ) + + +@pytest.fixture +def mock_config_entry_v1() -> MockConfigEntry: + """Return a v1 mocked config entry for migration testing.""" return MockConfigEntry( domain=DOMAIN, title="Fake Profile", unique_id="xyz12", data={CONF_API_KEY: "fake_api_key", CONF_PROFILE_ID: "xyz12"}, entry_id="d9aa37407ddac7b964a99e86312288d6", + version=1, ) @@ -80,7 +112,11 @@ def mock_nextdns_client(mock_nextdns: AsyncMock) -> AsyncMock: client.get_analytics_protocols.return_value = ANALYTICS_PROTOCOLS client.get_analytics_status.return_value = ANALYTICS_STATUS client.get_profile_id = Mock(return_value="xyz12") - client.get_profile_name = Mock(return_value="Fake Profile") + client.get_profile_name = Mock( + side_effect=lambda profile_id: next( + profile.name for profile in client.profiles if profile.id == profile_id + ) + ) client.get_profiles.return_value = PROFILES client.get_settings.return_value = SETTINGS client.set_setting.return_value = True diff --git a/tests/components/nextdns/snapshots/test_diagnostics.ambr b/tests/components/nextdns/snapshots/test_diagnostics.ambr index f55c381af4ec8..e585ad7696d38 100644 --- a/tests/components/nextdns/snapshots/test_diagnostics.ambr +++ b/tests/components/nextdns/snapshots/test_diagnostics.ambr @@ -4,13 +4,11 @@ 'config_entry': dict({ 'data': dict({ 'api_key': '**REDACTED**', - 'profile_id': '**REDACTED**', }), 'disabled_by': None, 'discovery_keys': dict({ }), 'domain': 'nextdns', - 'entry_id': 'd9aa37407ddac7b964a99e86312288d6', 'minor_version': 1, 'options': dict({ }), @@ -18,125 +16,138 @@ 'pref_disable_polling': False, 'source': 'user', 'subentries': list([ + dict({ + 'data': dict({ + 'profile_id': '**REDACTED**', + }), + 'subentry_type': 'profile', + 'title': 'Fake Profile', + 'unique_id': '**REDACTED**', + }), ]), - 'title': 'Fake Profile', - 'unique_id': '**REDACTED**', - 'version': 1, - }), - 'dnssec_coordinator_data': dict({ - 'not_validated_queries': 25, - 'validated_queries': 75, - 'validated_queries_ratio': 75.0, - }), - 'encryption_coordinator_data': dict({ - 'encrypted_queries': 60, - 'encrypted_queries_ratio': 60.0, - 'unencrypted_queries': 40, - }), - 'ip_versions_coordinator_data': dict({ - 'ipv4_queries': 90, - 'ipv6_queries': 10, - 'ipv6_queries_ratio': 10.0, - }), - 'protocols_coordinator_data': dict({ - 'doh3_queries': 15, - 'doh3_queries_ratio': 13.0, - 'doh_queries': 20, - 'doh_queries_ratio': 17.4, - 'doq_queries': 10, - 'doq_queries_ratio': 8.7, - 'dot_queries': 30, - 'dot_queries_ratio': 26.1, - 'tcp_queries': 0, - 'tcp_queries_ratio': 0.0, - 'udp_queries': 40, - 'udp_queries_ratio': 34.8, - }), - 'settings_coordinator_data': dict({ - 'ai_threat_detection': True, - 'allow_affiliate': True, - 'anonymized_ecs': True, - 'bav': True, - 'block_9gag': True, - 'block_amazon': True, - 'block_bereal': True, - 'block_blizzard': True, - 'block_bypass_methods': True, - 'block_chatgpt': True, - 'block_csam': True, - 'block_dailymotion': True, - 'block_dating': True, - 'block_ddns': True, - 'block_discord': True, - 'block_disguised_trackers': True, - 'block_disneyplus': True, - 'block_ebay': True, - 'block_facebook': True, - 'block_fortnite': True, - 'block_gambling': True, - 'block_google_chat': True, - 'block_hbomax': True, - 'block_hulu': True, - 'block_imgur': True, - 'block_instagram': True, - 'block_leagueoflegends': True, - 'block_mastodon': True, - 'block_messenger': True, - 'block_minecraft': True, - 'block_netflix': True, - 'block_nrd': True, - 'block_online_gaming': True, - 'block_page': False, - 'block_parked_domains': True, - 'block_pinterest': True, - 'block_piracy': True, - 'block_playstation_network': True, - 'block_porn': True, - 'block_primevideo': True, - 'block_reddit': True, - 'block_roblox': True, - 'block_signal': True, - 'block_skype': True, - 'block_snapchat': True, - 'block_social_networks': True, - 'block_spotify': True, - 'block_steam': True, - 'block_telegram': True, - 'block_tiktok': True, - 'block_tinder': True, - 'block_tumblr': True, - 'block_twitch': True, - 'block_twitter': True, - 'block_video_streaming': True, - 'block_vimeo': True, - 'block_vk': True, - 'block_whatsapp': True, - 'block_xboxlive': True, - 'block_youtube': True, - 'block_zoom': True, - 'cache_boost': True, - 'cname_flattening': True, - 'cryptojacking_protection': True, - 'dga_protection': True, - 'dns_rebinding_protection': True, - 'google_safe_browsing': False, - 'idn_homograph_attacks_protection': True, - 'logs': True, - 'logs_location': 'ch', - 'logs_retention': 720, - 'safesearch': False, - 'threat_intelligence_feeds': True, - 'typosquatting_protection': True, - 'web3': True, - 'youtube_restricted_mode': False, - }), - 'status_coordinator_data': dict({ - 'all_queries': 100, - 'allowed_queries': 30, - 'blocked_queries': 20, - 'blocked_queries_ratio': 20.0, - 'default_queries': 40, - 'relayed_queries': 10, + 'title': 'NextDNS', + 'unique_id': None, + 'version': 2, }), + 'profiles': list([ + dict({ + 'dnssec_coordinator_data': dict({ + 'not_validated_queries': 25, + 'validated_queries': 75, + 'validated_queries_ratio': 75.0, + }), + 'encryption_coordinator_data': dict({ + 'encrypted_queries': 60, + 'encrypted_queries_ratio': 60.0, + 'unencrypted_queries': 40, + }), + 'ip_versions_coordinator_data': dict({ + 'ipv4_queries': 90, + 'ipv6_queries': 10, + 'ipv6_queries_ratio': 10.0, + }), + 'protocols_coordinator_data': dict({ + 'doh3_queries': 15, + 'doh3_queries_ratio': 13.0, + 'doh_queries': 20, + 'doh_queries_ratio': 17.4, + 'doq_queries': 10, + 'doq_queries_ratio': 8.7, + 'dot_queries': 30, + 'dot_queries_ratio': 26.1, + 'tcp_queries': 0, + 'tcp_queries_ratio': 0.0, + 'udp_queries': 40, + 'udp_queries_ratio': 34.8, + }), + 'settings_coordinator_data': dict({ + 'ai_threat_detection': True, + 'allow_affiliate': True, + 'anonymized_ecs': True, + 'bav': True, + 'block_9gag': True, + 'block_amazon': True, + 'block_bereal': True, + 'block_blizzard': True, + 'block_bypass_methods': True, + 'block_chatgpt': True, + 'block_csam': True, + 'block_dailymotion': True, + 'block_dating': True, + 'block_ddns': True, + 'block_discord': True, + 'block_disguised_trackers': True, + 'block_disneyplus': True, + 'block_ebay': True, + 'block_facebook': True, + 'block_fortnite': True, + 'block_gambling': True, + 'block_google_chat': True, + 'block_hbomax': True, + 'block_hulu': True, + 'block_imgur': True, + 'block_instagram': True, + 'block_leagueoflegends': True, + 'block_mastodon': True, + 'block_messenger': True, + 'block_minecraft': True, + 'block_netflix': True, + 'block_nrd': True, + 'block_online_gaming': True, + 'block_page': False, + 'block_parked_domains': True, + 'block_pinterest': True, + 'block_piracy': True, + 'block_playstation_network': True, + 'block_porn': True, + 'block_primevideo': True, + 'block_reddit': True, + 'block_roblox': True, + 'block_signal': True, + 'block_skype': True, + 'block_snapchat': True, + 'block_social_networks': True, + 'block_spotify': True, + 'block_steam': True, + 'block_telegram': True, + 'block_tiktok': True, + 'block_tinder': True, + 'block_tumblr': True, + 'block_twitch': True, + 'block_twitter': True, + 'block_video_streaming': True, + 'block_vimeo': True, + 'block_vk': True, + 'block_whatsapp': True, + 'block_xboxlive': True, + 'block_youtube': True, + 'block_zoom': True, + 'cache_boost': True, + 'cname_flattening': True, + 'cryptojacking_protection': True, + 'dga_protection': True, + 'dns_rebinding_protection': True, + 'google_safe_browsing': False, + 'idn_homograph_attacks_protection': True, + 'logs': True, + 'logs_location': 'ch', + 'logs_retention': 720, + 'safesearch': False, + 'threat_intelligence_feeds': True, + 'typosquatting_protection': True, + 'web3': True, + 'youtube_restricted_mode': False, + }), + 'status_coordinator_data': dict({ + 'all_queries': 100, + 'allowed_queries': 30, + 'blocked_queries': 20, + 'blocked_queries_ratio': 20.0, + 'default_queries': 40, + 'relayed_queries': 10, + }), + 'subentry_title': 'Fake Profile', + }), + ]), }) # --- diff --git a/tests/components/nextdns/test_config_flow.py b/tests/components/nextdns/test_config_flow.py index 280dcc8336bd0..ace312728a71b 100644 --- a/tests/components/nextdns/test_config_flow.py +++ b/tests/components/nextdns/test_config_flow.py @@ -1,14 +1,19 @@ """Define tests for the NextDNS config flow.""" +from types import MappingProxyType from unittest.mock import AsyncMock from nextdns import ApiError, InvalidApiKeyError, ProfileInfo import pytest from tenacity import RetryError -from homeassistant.components.nextdns.const import CONF_PROFILE_ID, DOMAIN -from homeassistant.config_entries import SOURCE_USER -from homeassistant.const import CONF_API_KEY, CONF_PROFILE_NAME +from homeassistant.components.nextdns.const import ( + CONF_PROFILE_ID, + DOMAIN, + SUBENTRY_TYPE_PROFILE, +) +from homeassistant.config_entries import SOURCE_USER, ConfigSubentry +from homeassistant.const import CONF_API_KEY from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType @@ -40,14 +45,17 @@ async def test_form_create_entry( assert result["step_id"] == "profiles" result = await hass.config_entries.flow.async_configure( - result["flow_id"], {CONF_PROFILE_NAME: "Fake Profile"} + result["flow_id"], {CONF_PROFILE_ID: "xyz12"} ) assert result["type"] is FlowResultType.CREATE_ENTRY - assert result["title"] == "Fake Profile" + assert result["title"] == "NextDNS" assert result["data"][CONF_API_KEY] == "fake_api_key" - assert result["data"][CONF_PROFILE_ID] == "xyz12" - assert result["result"].unique_id == "xyz12" + assert len(result["subentries"]) == 1 + subentry = result["subentries"][0] + assert subentry["subentry_type"] == SUBENTRY_TYPE_PROFILE + assert subentry["title"] == "Fake Profile" + assert subentry["data"][CONF_PROFILE_ID] == "xyz12" assert len(mock_setup_entry.mock_calls) == 1 @@ -97,14 +105,13 @@ async def test_form_errors( assert result["step_id"] == "profiles" result = await hass.config_entries.flow.async_configure( - result["flow_id"], {CONF_PROFILE_NAME: "Fake Profile"} + result["flow_id"], {CONF_PROFILE_ID: "xyz12"} ) assert result["type"] is FlowResultType.CREATE_ENTRY - assert result["title"] == "Fake Profile" + assert result["title"] == "NextDNS" assert result["data"][CONF_API_KEY] == "fake_api_key" - assert result["data"][CONF_PROFILE_ID] == "xyz12" - assert result["result"].unique_id == "xyz12" + assert len(result["subentries"]) == 1 assert len(mock_setup_entry.mock_calls) == 1 @@ -114,22 +121,20 @@ async def test_form_already_configured( mock_nextdns_client: AsyncMock, mock_nextdns: AsyncMock, ) -> None: - """Test that errors are shown when duplicates are added.""" + """Test that the flow aborts when API key is already configured.""" await init_integration(hass, mock_config_entry) result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_USER} ) - await hass.config_entries.flow.async_configure( + result = await hass.config_entries.flow.async_configure( result["flow_id"], {CONF_API_KEY: "fake_api_key"}, ) - result = await hass.config_entries.flow.async_configure( - result["flow_id"], {CONF_PROFILE_NAME: "Fake Profile"} - ) - + # When a config entry with the same API key exists, the flow aborts + # Users should add profiles via the subentry flow assert result["type"] is FlowResultType.ABORT assert result["reason"] == "already_configured" @@ -324,3 +329,123 @@ async def test_reconfigure_flow_no_profile( assert result["type"] is FlowResultType.ABORT assert result["reason"] == "profile_not_available" + + +async def test_subentry_flow( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_nextdns_client: AsyncMock, +) -> None: + """Test creating a profile subentry.""" + # Add a second profile to the client + mock_nextdns_client.profiles = [ + ProfileInfo(id="xyz12", fingerprint="xyz12", name="Fake Profile"), + ProfileInfo(id="abc34", fingerprint="abc34", name="Second Profile"), + ] + + await init_integration(hass, mock_config_entry) + + result = await hass.config_entries.subentries.async_init( + (mock_config_entry.entry_id, SUBENTRY_TYPE_PROFILE), + context={"source": "user"}, + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + + result = await hass.config_entries.subentries.async_configure( + result["flow_id"], + {CONF_PROFILE_ID: "abc34"}, + ) + await hass.async_block_till_done() + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == "Second Profile" + assert result["data"][CONF_PROFILE_ID] == "abc34" + + entry = hass.config_entries.async_get_entry(mock_config_entry.entry_id) + assert len(entry.subentries) == 2 + + +async def test_subentry_flow_already_configured( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_nextdns_client: AsyncMock, +) -> None: + """Test subentry flow when the profile gets configured between form display and submit.""" + # Add a second and third profile so the flow doesn't abort immediately + second_profile = ProfileInfo( + id="abc34", fingerprint="xyz789", name="Second Profile" + ) + third_profile = ProfileInfo(id="def56", fingerprint="uvw456", name="Third Profile") + mock_nextdns_client.profiles = [ + *mock_nextdns_client.profiles, + second_profile, + third_profile, + ] + + await init_integration(hass, mock_config_entry) + + result = await hass.config_entries.subentries.async_init( + (mock_config_entry.entry_id, SUBENTRY_TYPE_PROFILE), + context={"source": "user"}, + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + + # Simulate a race condition where the second profile gets configured + # between showing the form and submitting it + hass.config_entries.async_add_subentry( + mock_config_entry, + ConfigSubentry( + data=MappingProxyType({CONF_PROFILE_ID: "abc34"}), + subentry_type=SUBENTRY_TYPE_PROFILE, + title="Second Profile", + unique_id="abc34", + ), + ) + await hass.async_block_till_done() + + result = await hass.config_entries.subentries.async_configure( + result["flow_id"], + {CONF_PROFILE_ID: "abc34"}, + ) + + # Abort flow when a profile is already configured between form display and submit + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" + + +async def test_subentry_flow_all_profiles_configured( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_nextdns_client: AsyncMock, +) -> None: + """Test subentry flow when all profiles are already configured.""" + await init_integration(hass, mock_config_entry) + + # Only one profile available and it's already configured + result = await hass.config_entries.subentries.async_init( + (mock_config_entry.entry_id, SUBENTRY_TYPE_PROFILE), + context={"source": "user"}, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "all_profiles_configured" + + +async def test_subentry_flow_entry_not_loaded( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, +) -> None: + """Test subentry flow when the entry is not loaded.""" + mock_config_entry.add_to_hass(hass) + + result = await hass.config_entries.subentries.async_init( + (mock_config_entry.entry_id, SUBENTRY_TYPE_PROFILE), + context={"source": SOURCE_USER}, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "entry_not_loaded" diff --git a/tests/components/nextdns/test_diagnostics.py b/tests/components/nextdns/test_diagnostics.py index 5d870c03471cb..e705b693e20d3 100644 --- a/tests/components/nextdns/test_diagnostics.py +++ b/tests/components/nextdns/test_diagnostics.py @@ -26,4 +26,4 @@ async def test_entry_diagnostics( assert await get_diagnostics_for_config_entry( hass, hass_client, mock_config_entry - ) == snapshot(exclude=props("created_at", "modified_at")) + ) == snapshot(exclude=props("created_at", "modified_at", "entry_id", "subentry_id")) diff --git a/tests/components/nextdns/test_init.py b/tests/components/nextdns/test_init.py index 1eaaeba3a52b2..55a0a525a4812 100644 --- a/tests/components/nextdns/test_init.py +++ b/tests/components/nextdns/test_init.py @@ -6,10 +6,19 @@ import pytest from tenacity import RetryError -from homeassistant.components.nextdns.const import DOMAIN -from homeassistant.config_entries import SOURCE_REAUTH, ConfigEntryState -from homeassistant.const import STATE_UNAVAILABLE +from homeassistant.components.nextdns.const import ( + CONF_PROFILE_ID, + DOMAIN, + SUBENTRY_TYPE_PROFILE, +) +from homeassistant.config_entries import ( + SOURCE_REAUTH, + ConfigEntryDisabler, + ConfigEntryState, +) +from homeassistant.const import CONF_API_KEY, STATE_UNAVAILABLE from homeassistant.core import HomeAssistant +from homeassistant.helpers import device_registry as dr, entity_registry as er from . import init_integration @@ -63,7 +72,6 @@ async def test_unload_entry( await hass.async_block_till_done() assert mock_config_entry.state is ConfigEntryState.NOT_LOADED - assert not hass.data.get(DOMAIN) async def test_config_auth_failed( @@ -88,3 +96,240 @@ async def test_config_auth_failed( assert "context" in flow assert flow["context"].get("source") == SOURCE_REAUTH assert flow["context"].get("entry_id") == mock_config_entry.entry_id + + +async def test_migrate_entry_v1_to_v2( + hass: HomeAssistant, + mock_config_entry_v1: MockConfigEntry, + mock_nextdns_client: AsyncMock, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, +) -> None: + """Test migration from version 1 to version 2.""" + # Create old device and entity with old-style identifiers before migration + mock_config_entry_v1.add_to_hass(hass) + device_registry.async_get_or_create( + config_entry_id=mock_config_entry_v1.entry_id, + identifiers={(DOMAIN, "xyz12")}, + manufacturer="NextDNS Inc.", + name="Fake Profile", + entry_type=dr.DeviceEntryType.SERVICE, + ) + entity_registry.async_get_or_create( + "sensor", + DOMAIN, + "xyz12_dns_queries", + config_entry=mock_config_entry_v1, + ) + + await hass.config_entries.async_setup(mock_config_entry_v1.entry_id) + await hass.async_block_till_done() + + # Verify migration was successful + assert mock_config_entry_v1.version == 2 + assert mock_config_entry_v1.title == "NextDNS" + assert mock_config_entry_v1.state is ConfigEntryState.LOADED + + # Verify data was migrated correctly + assert CONF_PROFILE_ID not in mock_config_entry_v1.data + assert mock_config_entry_v1.data[CONF_API_KEY] == "fake_api_key" + + # Verify subentry was created + assert len(mock_config_entry_v1.subentries) == 1 + subentry = list(mock_config_entry_v1.subentries.values())[0] + assert subentry.subentry_type == SUBENTRY_TYPE_PROFILE + assert subentry.title == "Fake Profile" + assert subentry.data[CONF_PROFILE_ID] == "xyz12" + assert subentry.unique_id == "xyz12" + + # Verify device was migrated and linked to subentry + device = device_registry.async_get_device(identifiers={(DOMAIN, "xyz12")}) + assert device is not None + assert device.config_entries_subentries == { + mock_config_entry_v1.entry_id: {subentry.subentry_id} + } + + # Verify entity was migrated and linked to subentry + entity_entry = entity_registry.async_get("sensor.nextdns_xyz12_dns_queries") + assert entity_entry is not None + assert entity_entry.config_entry_id == mock_config_entry_v1.entry_id + assert entity_entry.config_subentry_id == subentry.subentry_id + + +async def test_migrate_entry_v1_to_v2_merge_same_api_key( + hass: HomeAssistant, + mock_nextdns_client: AsyncMock, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, +) -> None: + """Test migration merges v1 entries with the same API key.""" + entry1 = MockConfigEntry( + domain=DOMAIN, + title="Profile One", + unique_id="abc11", + data={CONF_API_KEY: "fake_api_key", CONF_PROFILE_ID: "abc11"}, + entry_id="entry1_id", + version=1, + ) + entry2 = MockConfigEntry( + domain=DOMAIN, + title="Profile Two", + unique_id="def22", + data={CONF_API_KEY: "fake_api_key", CONF_PROFILE_ID: "def22"}, + entry_id="entry2_id", + version=1, + ) + entry1.add_to_hass(hass) + entry2.add_to_hass(hass) + + # Create old devices with old-style identifiers + device_registry.async_get_or_create( + config_entry_id=entry1.entry_id, + identifiers={(DOMAIN, "abc11")}, + manufacturer="NextDNS Inc.", + name="Profile One", + entry_type=dr.DeviceEntryType.SERVICE, + ) + device_registry.async_get_or_create( + config_entry_id=entry2.entry_id, + identifiers={(DOMAIN, "def22")}, + manufacturer="NextDNS Inc.", + name="Profile Two", + entry_type=dr.DeviceEntryType.SERVICE, + ) + + # Create old entities for both entries to verify they are migrated + entity_registry.async_get_or_create( + "sensor", + DOMAIN, + "profile_one_dns_queries", + config_entry=entry1, + ) + entity_registry.async_get_or_create( + "sensor", + DOMAIN, + "profile_two_dns_queries", + config_entry=entry2, + ) + + await hass.config_entries.async_setup(entry1.entry_id) + await hass.async_block_till_done() + + # Verify entry1 was migrated and is loaded + assert entry1.version == 2 + assert entry1.title == "NextDNS" + assert entry1.state is ConfigEntryState.LOADED + assert CONF_PROFILE_ID not in entry1.data + assert entry1.data[CONF_API_KEY] == "fake_api_key" + + # Verify entry2 was removed + assert hass.config_entries.async_get_entry(entry2.entry_id) is None + + # Verify entry1 has two subentries (both profiles merged) + assert len(entry1.subentries) == 2 + subentries = list(entry1.subentries.values()) + profile_ids = {s.data[CONF_PROFILE_ID] for s in subentries} + assert profile_ids == {"abc11", "def22"} + titles = {s.title for s in subentries} + assert titles == {"Profile One", "Profile Two"} + + # Verify devices were migrated to entry1 with existing identifiers + device_abc = device_registry.async_get_device(identifiers={(DOMAIN, "abc11")}) + assert device_abc is not None + assert entry1.entry_id in device_abc.config_entries + + device_def = device_registry.async_get_device(identifiers={(DOMAIN, "def22")}) + assert device_def is not None + assert entry1.entry_id in device_def.config_entries + + # Verify entities from both entries were migrated to entry1 + entity_entry_1 = entity_registry.async_get("sensor.nextdns_profile_one_dns_queries") + assert entity_entry_1 is not None + assert entity_entry_1.config_entry_id == entry1.entry_id + assert entity_entry_1.config_subentry_id is not None + + entity_entry_2 = entity_registry.async_get("sensor.nextdns_profile_two_dns_queries") + assert entity_entry_2 is not None + assert entity_entry_2.config_entry_id == entry1.entry_id + assert entity_entry_2.config_subentry_id is not None + + +async def test_migrate_entry_v1_to_v2_disabled_entry( + hass: HomeAssistant, + mock_nextdns_client: AsyncMock, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, +) -> None: + """Test migration updates disabled_by when merging disabled and enabled entries.""" + entry1 = MockConfigEntry( + domain=DOMAIN, + title="Profile One", + unique_id="abc11", + data={CONF_API_KEY: "fake_api_key", CONF_PROFILE_ID: "abc11"}, + entry_id="entry1_id", + version=1, + ) + entry2 = MockConfigEntry( + domain=DOMAIN, + title="Profile Two", + unique_id="def22", + data={CONF_API_KEY: "fake_api_key", CONF_PROFILE_ID: "def22"}, + entry_id="entry2_id", + version=1, + disabled_by=ConfigEntryDisabler.USER, + ) + entry1.add_to_hass(hass) + entry2.add_to_hass(hass) + + # Create device and entity for disabled entry2 with CONFIG_ENTRY disabled_by + device_registry.async_get_or_create( + config_entry_id=entry1.entry_id, + identifiers={(DOMAIN, "abc11")}, + manufacturer="NextDNS Inc.", + name="Profile One", + entry_type=dr.DeviceEntryType.SERVICE, + ) + + device2 = device_registry.async_get_or_create( + config_entry_id=entry2.entry_id, + identifiers={(DOMAIN, "def22")}, + manufacturer="NextDNS Inc.", + name="Profile Two", + entry_type=dr.DeviceEntryType.SERVICE, + disabled_by=dr.DeviceEntryDisabler.CONFIG_ENTRY, + ) + + entity_registry.async_get_or_create( + domain="sensor", + platform=DOMAIN, + unique_id="def22_all_queries", + suggested_object_id="profile_two_dns_queries", + config_entry=entry2, + device_id=device2.id, + disabled_by=er.RegistryEntryDisabler.CONFIG_ENTRY, + ) + + await hass.config_entries.async_setup(entry1.entry_id) + await hass.async_block_till_done() + + # Verify entry1 was migrated and entry2 was removed + assert entry1.version == 2 + assert entry1.state is ConfigEntryState.LOADED + assert hass.config_entries.async_get_entry(entry2.entry_id) is None + + # Find the subentry for the disabled profile + subentry2 = next( + s for s in entry1.subentries.values() if s.data[CONF_PROFILE_ID] == "def22" + ) + + # Verify device disabled_by was changed from CONFIG_ENTRY to USER + device = device_registry.async_get_device(identifiers={(DOMAIN, "def22")}) + assert device is not None + assert device.disabled_by is dr.DeviceEntryDisabler.USER + + # Verify entity disabled_by was changed from CONFIG_ENTRY to DEVICE + entity_entry = entity_registry.async_get("sensor.profile_two_dns_queries") + assert entity_entry is not None + assert entity_entry.config_entry_id == entry1.entry_id + assert entity_entry.config_subentry_id == subentry2.subentry_id + assert entity_entry.disabled_by is er.RegistryEntryDisabler.DEVICE diff --git a/tests/components/search/test_init.py b/tests/components/search/test_init.py index aeb51bad63030..a43c880a90f1e 100644 --- a/tests/components/search/test_init.py +++ b/tests/components/search/test_init.py @@ -1,5 +1,6 @@ """Tests for Search integration.""" +import attr from pytest_unordered import unordered from homeassistant.components.search import DOMAIN, ItemType, Searcher @@ -1107,3 +1108,112 @@ def search(item_type: ItemType, item_id: str) -> dict[str, set[str]]: ), ItemType.SCRIPT: unordered(["script.device", "script.hue"]), } + + +async def test_search_pre_migration_composite_device( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, +) -> None: + """Test search maps between a pre-migration composite device and its splits. + + When a composite device is split into one device per config entry, each split + device records the id of the pre-migration composite. Automations and scripts + created before the split still reference the composite id, so: + - searching a split device must return them, but not automations or scripts + referencing only a sibling split, and + - searching such an automation or script must return the live split devices, not + the virtual composite id. + """ + assert await async_setup_component(hass, DOMAIN, {}) + + entry_1 = MockConfigEntry(domain="test1") + entry_1.add_to_hass(hass) + entry_2 = MockConfigEntry(domain="test2") + entry_2.add_to_hass(hass) + + device_1 = device_registry.async_get_or_create( + config_entry_id=entry_1.entry_id, identifiers={("test1", "1")} + ) + device_2 = device_registry.async_get_or_create( + config_entry_id=entry_2.entry_id, identifiers={("test2", "2")} + ) + + # Simulate a migration split: both devices carry the pre-migration composite id + composite_device_id = "composite00000000000000000000ab" + device_registry.devices[device_1.id] = attr.evolve( + device_1, composite_device_id=composite_device_id + ) + device_registry.devices[device_2.id] = attr.evolve( + device_2, composite_device_id=composite_device_id + ) + + def device_action(device_id: str) -> dict[str, dict[str, str]]: + """Return a service call action targeting a device.""" + return {"service": "test.script", "target": {"device_id": device_id}} + + assert await async_setup_component( + hass, + "automation", + { + "automation": [ + { + "alias": "composite", + "trigger": {"platform": "template", "value_template": "true"}, + "action": [device_action(composite_device_id)], + }, + { + "alias": "split_1", + "trigger": {"platform": "template", "value_template": "true"}, + "action": [device_action(device_1.id)], + }, + { + "alias": "split_2", + "trigger": {"platform": "template", "value_template": "true"}, + "action": [device_action(device_2.id)], + }, + ] + }, + ) + + assert await async_setup_component( + hass, + "script", + { + "script": { + "composite": {"sequence": [device_action(composite_device_id)]}, + "split_2": {"sequence": [device_action(device_2.id)]}, + } + }, + ) + + def search(item_type: ItemType, item_id: str) -> dict[str, set[str]]: + """Search.""" + searcher = Searcher(hass, {}) + return searcher.async_search(item_type, item_id) + + # Forward: searching a split device returns automations and scripts referencing + # the pre-migration composite and the split itself, but not references to a + # sibling split. + assert search(ItemType.DEVICE, device_1.id) == { + ItemType.AUTOMATION: {"automation.composite", "automation.split_1"}, + ItemType.SCRIPT: {"script.composite"}, + ItemType.CONFIG_ENTRY: {entry_1.entry_id}, + ItemType.INTEGRATION: {"test1"}, + } + assert search(ItemType.DEVICE, device_2.id) == { + ItemType.AUTOMATION: {"automation.composite", "automation.split_2"}, + ItemType.SCRIPT: {"script.composite", "script.split_2"}, + ItemType.CONFIG_ENTRY: {entry_2.entry_id}, + ItemType.INTEGRATION: {"test2"}, + } + + # Reverse: searching an automation or script that references the composite returns + # the live split devices, not the virtual composite id. + expected_reverse = { + ItemType.DEVICE: {device_1.id, device_2.id}, + ItemType.CONFIG_ENTRY: {entry_1.entry_id, entry_2.entry_id}, + ItemType.INTEGRATION: {"test1", "test2"}, + } + assert search(ItemType.AUTOMATION, "automation.composite") == expected_reverse + assert search(ItemType.SCRIPT, "script.composite") == expected_reverse diff --git a/tests/components/tado/test_config_flow.py b/tests/components/tado/test_config_flow.py index 5367dcc437738..3e7cec5a17f51 100644 --- a/tests/components/tado/test_config_flow.py +++ b/tests/components/tado/test_config_flow.py @@ -110,26 +110,54 @@ async def test_auth_timeout( mock_tado_api: MagicMock, mock_setup_entry: AsyncMock, ) -> None: - """Test the auth timeout.""" - mock_tado_api.device_activation_status.return_value = DeviceActivationStatus.PENDING - - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": SOURCE_USER} + """Test that retrying after an auth timeout uses a fresh device code.""" + expired_client = MagicMock() + expired_client.device_verification_url.return_value = ( + "https://login.tado.com/oauth2/device?user_code=EXPIRED" ) - assert result["type"] is FlowResultType.SHOW_PROGRESS_DONE - assert result["step_id"] == "timeout" - - mock_tado_api.device_activation_status.return_value = ( - DeviceActivationStatus.COMPLETED + expired_client.device_activation_status.return_value = ( + DeviceActivationStatus.PENDING ) - result = await hass.config_entries.flow.async_configure(result["flow_id"]) - assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "timeout" + event = threading.Event() - result = await hass.config_entries.flow.async_configure( - result["flow_id"], user_input={} - ) + def mock_tado_api_device_activation() -> None: + # Simulate the device activation process + event.wait(timeout=5) + + mock_tado_api.device_activation = mock_tado_api_device_activation + + with patch( + "homeassistant.components.tado.config_flow.Tado", + side_effect=[expired_client, mock_tado_api], + ) as mock_tado_create: + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + assert result["type"] is FlowResultType.SHOW_PROGRESS_DONE + assert result["step_id"] == "timeout" + + result = await hass.config_entries.flow.async_configure(result["flow_id"]) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "timeout" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={} + ) + + # The retry must construct a new client and show its device code, + # not the expired one from the first attempt + assert mock_tado_create.call_count == 2 + assert result["type"] is FlowResultType.SHOW_PROGRESS + assert result["description_placeholders"] == { + "url": "https://login.tado.com/oauth2/device?user_code=TEST", + "code": "TEST", + } + + event.set() + await hass.async_block_till_done() + + result = await hass.config_entries.flow.async_configure(result["flow_id"]) assert result["type"] is FlowResultType.CREATE_ENTRY assert result["title"] == "home name" diff --git a/tests/helpers/test_device_registry.py b/tests/helpers/test_device_registry.py index 04f3497254521..050dc22a4f370 100644 --- a/tests/helpers/test_device_registry.py +++ b/tests/helpers/test_device_registry.py @@ -5729,6 +5729,110 @@ async def test_restore_device( } +@pytest.mark.parametrize( + ("stored_connections", "stored_identifiers", "new_connections", "new_identifiers"), + [ + pytest.param( + {(dr.CONNECTION_NETWORK_MAC, "aa:aa:aa:aa:aa:aa")}, + {("bridgeid", "0123")}, + { + (dr.CONNECTION_NETWORK_MAC, "aa:aa:aa:aa:aa:aa"), + (dr.CONNECTION_NETWORK_MAC, "bb:bb:bb:bb:bb:bb"), + }, + {("bridgeid", "0123"), ("bridgeid", "4567")}, + id="broader_reregistration", + ), + pytest.param( + {(dr.CONNECTION_NETWORK_MAC, "aa:aa:aa:aa:aa:aa")}, + {("bridgeid", "0123")}, + {(dr.CONNECTION_NETWORK_MAC, "bb:bb:bb:bb:bb:bb")}, + {("bridgeid", "0123")}, + id="disjoint_connection_matched_on_identifier", + ), + ], +) +async def test_restore_device_reflects_reregistered_identity( + device_registry: dr.DeviceRegistry, + mock_config_entry: MockConfigEntry, + stored_connections: set[tuple[str, str]], + stored_identifiers: set[tuple[str, str]], + new_connections: set[tuple[str, str]], + new_identifiers: set[tuple[str, str]], +) -> None: + """A restored device keeps the connections and identifiers it is re-registered with. + + The restored device must reflect the identity the integration reports on + re-registration, not its intersection with the stored (deleted) identity, so a + device that now reports a broader (or shifted) set is restored with all of it. + """ + entry = device_registry.async_get_or_create( + config_entry_id=mock_config_entry.entry_id, + connections=stored_connections, + identifiers=stored_identifiers, + ) + device_registry.async_remove_device(entry.id) + assert len(device_registry.deleted_devices) == 1 + + restored = device_registry.async_get_or_create( + config_entry_id=mock_config_entry.entry_id, + connections=new_connections, + identifiers=new_identifiers, + ) + + assert restored.id == entry.id + assert restored.connections == new_connections + assert restored.identifiers == new_identifiers + + +@pytest.mark.parametrize( + ("new_connections", "new_identifiers"), + [ + pytest.param( + { + (dr.CONNECTION_NETWORK_MAC, "aa:aa:aa:aa:aa:aa"), + (dr.CONNECTION_NETWORK_MAC, "bb:bb:bb:bb:bb:bb"), + }, + {("bridgeid", "0123"), ("bridgeid", "4567")}, + id="broader_reregistration", + ), + pytest.param( + {(dr.CONNECTION_NETWORK_MAC, "cc:cc:cc:cc:cc:cc")}, + {("bridgeid", "9999")}, + id="disjoint_reregistration", + ), + ], +) +async def test_deleted_device_to_device_entry_uses_reregistered_identity( + device_registry: dr.DeviceRegistry, + mock_config_entry: MockConfigEntry, + new_connections: set[tuple[str, str]], + new_identifiers: set[tuple[str, str]], +) -> None: + """DeletedDeviceEntry.to_device_entry keeps the passed connections and identifiers. + + Regression guard: it must not intersect them with the stored ones, which would drop + (or, for a disjoint re-registration, entirely empty) the device's identity. + """ + entry = device_registry.async_get_or_create( + config_entry_id=mock_config_entry.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, "aa:aa:aa:aa:aa:aa")}, + identifiers={("bridgeid", "0123")}, + ) + device_registry.async_remove_device(entry.id) + deleted_device = device_registry.deleted_devices[entry.id] + + restored = deleted_device.to_device_entry( + mock_config_entry, + None, + new_connections, + new_identifiers, + None, + ) + + assert restored.connections == new_connections + assert restored.identifiers == new_identifiers + + @pytest.mark.parametrize( ("device_disabled_by", "expected_disabled_by"), [ diff --git a/tests/helpers/test_entity_platform.py b/tests/helpers/test_entity_platform.py index 9cbe26135226c..83ca42a5e9595 100644 --- a/tests/helpers/test_entity_platform.py +++ b/tests/helpers/test_entity_platform.py @@ -1042,6 +1042,29 @@ async def test_entity_limit_not_applied_without_config_entry( assert hass.states.get("test_domain.ent2") is not None +@pytest.mark.parametrize("domain", sorted(entity_platform.ENTITY_LIMIT_EXEMPT_DOMAINS)) +async def test_entity_limit_not_applied_to_exempt_domains( + hass: HomeAssistant, + domain: str, +) -> None: + """Test protocol integrations are exempt from the entity limit.""" + config_entry = MockConfigEntry(domain=domain) + config_entry.add_to_hass(hass) + platform = MockEntityPlatform(hass) + platform.config_entry = config_entry + + with patch.object(entity_platform, "MAX_ENABLED_ENTITIES_PER_CONFIG_ENTRY", 1): + await platform.async_add_entities( + [ + MockEntity(unique_id="1", name="ent1"), + MockEntity(unique_id="2", name="ent2"), + ] + ) + + assert hass.states.get("test_domain.ent1") is not None + assert hass.states.get("test_domain.ent2") is not None + + async def test_unique_id_conflict_has_priority_over_disabled_entity( hass: HomeAssistant, entity_registry: er.EntityRegistry,