diff --git a/.github/workflows/builder.yml b/.github/workflows/builder.yml index ce89d8c2b10..27c208d57c5 100644 --- a/.github/workflows/builder.yml +++ b/.github/workflows/builder.yml @@ -324,7 +324,7 @@ jobs: uses: actions/checkout@v4.2.2 - name: Install Cosign - uses: sigstore/cosign-installer@v3.8.1 + uses: sigstore/cosign-installer@v3.8.2 with: cosign-release: "v2.2.3" diff --git a/.strict-typing b/.strict-typing index 69d46958882..be6f540e633 100644 --- a/.strict-typing +++ b/.strict-typing @@ -363,6 +363,7 @@ homeassistant.components.no_ip.* homeassistant.components.nordpool.* homeassistant.components.notify.* homeassistant.components.notion.* +homeassistant.components.ntfy.* homeassistant.components.number.* homeassistant.components.nut.* homeassistant.components.ohme.* diff --git a/CODEOWNERS b/CODEOWNERS index 1ac564a6991..5896972959e 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -1051,6 +1051,8 @@ build.json @home-assistant/supervisor /tests/components/nsw_fuel_station/ @nickw444 /homeassistant/components/nsw_rural_fire_service_feed/ @exxamalte /tests/components/nsw_rural_fire_service_feed/ @exxamalte +/homeassistant/components/ntfy/ @tr4nt0r +/tests/components/ntfy/ @tr4nt0r /homeassistant/components/nuheat/ @tstabrawa /tests/components/nuheat/ @tstabrawa /homeassistant/components/nuki/ @pschmitt @pvizeli @pree diff --git a/homeassistant/components/airzone_cloud/manifest.json b/homeassistant/components/airzone_cloud/manifest.json index 3b6f94df57c..ecc9634f36a 100644 --- a/homeassistant/components/airzone_cloud/manifest.json +++ b/homeassistant/components/airzone_cloud/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/airzone_cloud", "iot_class": "cloud_push", "loggers": ["aioairzone_cloud"], - "requirements": ["aioairzone-cloud==0.6.11"] + "requirements": ["aioairzone-cloud==0.6.12"] } diff --git a/homeassistant/components/alexa/state_report.py b/homeassistant/components/alexa/state_report.py index 20e3ef1d7c7..e3181ee1405 100644 --- a/homeassistant/components/alexa/state_report.py +++ b/homeassistant/components/alexa/state_report.py @@ -3,10 +3,10 @@ from __future__ import annotations from asyncio import timeout +from collections.abc import Mapping from http import HTTPStatus import json import logging -from types import MappingProxyType from typing import TYPE_CHECKING, Any, cast from uuid import uuid4 @@ -260,10 +260,10 @@ async def async_enable_proactive_mode( def extra_significant_check( hass: HomeAssistant, old_state: str, - old_attrs: dict[Any, Any] | MappingProxyType[Any, Any], + old_attrs: Mapping[Any, Any], old_extra_arg: Any, new_state: str, - new_attrs: dict[str, Any] | MappingProxyType[Any, Any], + new_attrs: Mapping[Any, Any], new_extra_arg: Any, ) -> bool: """Check if the serialized data has changed.""" diff --git a/homeassistant/components/analytics_insights/strings.json b/homeassistant/components/analytics_insights/strings.json index 10d3c19a2f6..222906efa0b 100644 --- a/homeassistant/components/analytics_insights/strings.json +++ b/homeassistant/components/analytics_insights/strings.json @@ -3,12 +3,12 @@ "step": { "user": { "data": { - "tracked_addons": "Addons", + "tracked_addons": "Add-ons", "tracked_integrations": "Integrations", "tracked_custom_integrations": "Custom integrations" }, "data_description": { - "tracked_addons": "Select the addons you want to track", + "tracked_addons": "Select the add-ons you want to track", "tracked_integrations": "Select the integrations you want to track", "tracked_custom_integrations": "Select the custom integrations you want to track" } diff --git a/homeassistant/components/anthropic/config_flow.py b/homeassistant/components/anthropic/config_flow.py index 1b6289efe7c..ebad206af61 100644 --- a/homeassistant/components/anthropic/config_flow.py +++ b/homeassistant/components/anthropic/config_flow.py @@ -2,6 +2,7 @@ from __future__ import annotations +from collections.abc import Mapping from functools import partial import logging from types import MappingProxyType @@ -175,7 +176,7 @@ class AnthropicOptionsFlow(OptionsFlow): def anthropic_config_option_schema( hass: HomeAssistant, - options: dict[str, Any] | MappingProxyType[str, Any], + options: Mapping[str, Any], ) -> dict: """Return a schema for Anthropic completion options.""" hass_apis: list[SelectOptionDict] = [ diff --git a/homeassistant/components/anthropic/conversation.py b/homeassistant/components/anthropic/conversation.py index 288ec63509e..56b8031417b 100644 --- a/homeassistant/components/anthropic/conversation.py +++ b/homeassistant/components/anthropic/conversation.py @@ -265,21 +265,21 @@ async def _transform_stream( if current_block is None: raise ValueError("Unexpected stop event without a current block") if current_block["type"] == "tool_use": - tool_block = cast(ToolUseBlockParam, current_block) + # tool block tool_args = json.loads(current_tool_args) if current_tool_args else {} - tool_block["input"] = tool_args + current_block["input"] = tool_args yield { "tool_calls": [ llm.ToolInput( - id=tool_block["id"], - tool_name=tool_block["name"], + id=current_block["id"], + tool_name=current_block["name"], tool_args=tool_args, ) ] } elif current_block["type"] == "thinking": - thinking_block = cast(ThinkingBlockParam, current_block) - LOGGER.debug("Thinking: %s", thinking_block["thinking"]) + # thinking block + LOGGER.debug("Thinking: %s", current_block["thinking"]) if current_message is None: raise ValueError("Unexpected stop event without a current message") diff --git a/homeassistant/components/apsystems/strings.json b/homeassistant/components/apsystems/strings.json index b3a10ca49a7..bdcd464ee9c 100644 --- a/homeassistant/components/apsystems/strings.json +++ b/homeassistant/components/apsystems/strings.json @@ -21,7 +21,7 @@ "entity": { "binary_sensor": { "off_grid_status": { - "name": "Off grid status" + "name": "Off-grid status" }, "dc_1_short_circuit_error_status": { "name": "DC 1 short circuit error status" diff --git a/homeassistant/components/aranet/strings.json b/homeassistant/components/aranet/strings.json index f786f4b2d4d..bb2ea3b2887 100644 --- a/homeassistant/components/aranet/strings.json +++ b/homeassistant/components/aranet/strings.json @@ -26,7 +26,7 @@ "sensor": { "threshold": { "state": { - "error": "Error", + "error": "[%key:common::state::error%]", "green": "Green", "yellow": "Yellow", "red": "Red" diff --git a/homeassistant/components/asuswrt/router.py b/homeassistant/components/asuswrt/router.py index 330c4bcfb67..a34f191b7a7 100644 --- a/homeassistant/components/asuswrt/router.py +++ b/homeassistant/components/asuswrt/router.py @@ -2,10 +2,9 @@ from __future__ import annotations -from collections.abc import Callable +from collections.abc import Callable, Mapping from datetime import datetime, timedelta import logging -from types import MappingProxyType from typing import Any from pyasuswrt import AsusWrtError @@ -363,7 +362,7 @@ class AsusWrtRouter: """Add a function to call when router is closed.""" self._on_close.append(func) - def update_options(self, new_options: MappingProxyType[str, Any]) -> bool: + def update_options(self, new_options: Mapping[str, Any]) -> bool: """Update router options.""" req_reload = False for name, new_opt in new_options.items(): diff --git a/homeassistant/components/august/manifest.json b/homeassistant/components/august/manifest.json index 5e16a22af76..a6b2961c2a0 100644 --- a/homeassistant/components/august/manifest.json +++ b/homeassistant/components/august/manifest.json @@ -28,5 +28,5 @@ "documentation": "https://www.home-assistant.io/integrations/august", "iot_class": "cloud_push", "loggers": ["pubnub", "yalexs"], - "requirements": ["yalexs==8.10.0", "yalexs-ble==2.5.7"] + "requirements": ["yalexs==8.10.0", "yalexs-ble==2.6.0"] } diff --git a/homeassistant/components/axis/config_flow.py b/homeassistant/components/axis/config_flow.py index 9f801882387..8b4a1d4f5f5 100644 --- a/homeassistant/components/axis/config_flow.py +++ b/homeassistant/components/axis/config_flow.py @@ -4,7 +4,6 @@ from __future__ import annotations from collections.abc import Mapping from ipaddress import ip_address -from types import MappingProxyType from typing import Any from urllib.parse import urlsplit @@ -88,7 +87,7 @@ class AxisFlowHandler(ConfigFlow, domain=AXIS_DOMAIN): if user_input is not None: try: - api = await get_axis_api(self.hass, MappingProxyType(user_input)) + api = await get_axis_api(self.hass, user_input) except AuthenticationRequired: errors["base"] = "invalid_auth" diff --git a/homeassistant/components/axis/hub/api.py b/homeassistant/components/axis/hub/api.py index 8e5d7533631..f33e925929c 100644 --- a/homeassistant/components/axis/hub/api.py +++ b/homeassistant/components/axis/hub/api.py @@ -1,7 +1,7 @@ """Axis network device abstraction.""" from asyncio import timeout -from types import MappingProxyType +from collections.abc import Mapping from typing import Any import axis @@ -23,7 +23,7 @@ from ..errors import AuthenticationRequired, CannotConnect async def get_axis_api( hass: HomeAssistant, - config: MappingProxyType[str, Any], + config: Mapping[str, Any], ) -> axis.AxisDevice: """Create a Axis device API.""" session = get_async_client(hass, verify_ssl=False) diff --git a/homeassistant/components/azure_event_hub/__init__.py b/homeassistant/components/azure_event_hub/__init__.py index abe6cdfe15f..6a035e664d4 100644 --- a/homeassistant/components/azure_event_hub/__init__.py +++ b/homeassistant/components/azure_event_hub/__init__.py @@ -3,11 +3,10 @@ from __future__ import annotations import asyncio -from collections.abc import Callable +from collections.abc import Callable, Mapping from datetime import datetime import json import logging -from types import MappingProxyType from typing import Any from azure.eventhub import EventData, EventDataBatch @@ -179,7 +178,7 @@ class AzureEventHub: await self.async_send(None) await self._queue.join() - def update_options(self, new_options: MappingProxyType[str, Any]) -> None: + def update_options(self, new_options: Mapping[str, Any]) -> None: """Update options.""" self._send_interval = new_options[CONF_SEND_INTERVAL] diff --git a/homeassistant/components/blue_current/strings.json b/homeassistant/components/blue_current/strings.json index b90a4792f65..a8a9aff7f08 100644 --- a/homeassistant/components/blue_current/strings.json +++ b/homeassistant/components/blue_current/strings.json @@ -30,7 +30,7 @@ "available": "Available", "charging": "[%key:common::state::charging%]", "unavailable": "Unavailable", - "error": "Error", + "error": "[%key:common::state::error%]", "offline": "Offline" } }, @@ -41,7 +41,7 @@ "vehicle_detected": "Detected", "ready": "Ready", "no_power": "No power", - "vehicle_error": "Error" + "vehicle_error": "[%key:common::state::error%]" } }, "actual_v1": { diff --git a/homeassistant/components/bmw_connected_drive/strings.json b/homeassistant/components/bmw_connected_drive/strings.json index bd9814476f5..d094116725f 100644 --- a/homeassistant/components/bmw_connected_drive/strings.json +++ b/homeassistant/components/bmw_connected_drive/strings.json @@ -139,7 +139,7 @@ "state": { "default": "Default", "charging": "[%key:common::state::charging%]", - "error": "Error", + "error": "[%key:common::state::error%]", "complete": "Complete", "fully_charged": "Fully charged", "finished_fully_charged": "Finished, fully charged", diff --git a/homeassistant/components/canary/__init__.py b/homeassistant/components/canary/__init__.py index b0e59e49a6f..4ea1bf48cf0 100644 --- a/homeassistant/components/canary/__init__.py +++ b/homeassistant/components/canary/__init__.py @@ -8,46 +8,18 @@ from typing import Final from canary.api import Api from requests.exceptions import ConnectTimeout, HTTPError -import voluptuous as vol -from homeassistant.components.camera import DOMAIN as CAMERA_DOMAIN -from homeassistant.config_entries import SOURCE_IMPORT from homeassistant.const import CONF_PASSWORD, CONF_TIMEOUT, CONF_USERNAME, Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady -from homeassistant.helpers import config_validation as cv -from homeassistant.helpers.typing import ConfigType -from .const import ( - CONF_FFMPEG_ARGUMENTS, - DEFAULT_FFMPEG_ARGUMENTS, - DEFAULT_TIMEOUT, - DOMAIN, -) +from .const import CONF_FFMPEG_ARGUMENTS, DEFAULT_FFMPEG_ARGUMENTS, DEFAULT_TIMEOUT from .coordinator import CanaryConfigEntry, CanaryDataUpdateCoordinator _LOGGER: Final = logging.getLogger(__name__) MIN_TIME_BETWEEN_UPDATES: Final = timedelta(seconds=30) -CONFIG_SCHEMA: Final = vol.Schema( - vol.All( - cv.deprecated(DOMAIN), - { - DOMAIN: vol.Schema( - { - vol.Required(CONF_USERNAME): cv.string, - vol.Required(CONF_PASSWORD): cv.string, - vol.Optional( - CONF_TIMEOUT, default=DEFAULT_TIMEOUT - ): cv.positive_int, - } - ) - }, - ), - extra=vol.ALLOW_EXTRA, -) - PLATFORMS: Final[list[Platform]] = [ Platform.ALARM_CONTROL_PANEL, Platform.CAMERA, @@ -55,37 +27,6 @@ PLATFORMS: Final[list[Platform]] = [ ] -async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: - """Set up the Canary integration.""" - if hass.config_entries.async_entries(DOMAIN): - return True - - ffmpeg_arguments = DEFAULT_FFMPEG_ARGUMENTS - if CAMERA_DOMAIN in config: - camera_config = next( - (item for item in config[CAMERA_DOMAIN] if item["platform"] == DOMAIN), - None, - ) - - if camera_config: - ffmpeg_arguments = camera_config.get( - CONF_FFMPEG_ARGUMENTS, DEFAULT_FFMPEG_ARGUMENTS - ) - - if DOMAIN in config: - if ffmpeg_arguments != DEFAULT_FFMPEG_ARGUMENTS: - config[DOMAIN][CONF_FFMPEG_ARGUMENTS] = ffmpeg_arguments - - hass.async_create_task( - hass.config_entries.flow.async_init( - DOMAIN, - context={"source": SOURCE_IMPORT}, - data=config[DOMAIN], - ) - ) - return True - - async def async_setup_entry(hass: HomeAssistant, entry: CanaryConfigEntry) -> bool: """Set up Canary from a config entry.""" if not entry.options: diff --git a/homeassistant/components/canary/config_flow.py b/homeassistant/components/canary/config_flow.py index 17e660e96ac..390f65904fe 100644 --- a/homeassistant/components/canary/config_flow.py +++ b/homeassistant/components/canary/config_flow.py @@ -54,10 +54,6 @@ class CanaryConfigFlow(ConfigFlow, domain=DOMAIN): """Get the options flow for this handler.""" return CanaryOptionsFlowHandler() - async def async_step_import(self, import_data: dict[str, Any]) -> ConfigFlowResult: - """Handle a flow initiated by configuration file.""" - return await self.async_step_user(import_data) - async def async_step_user( self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: diff --git a/homeassistant/components/chacon_dio/manifest.json b/homeassistant/components/chacon_dio/manifest.json index edee24444f7..117982a7ab8 100644 --- a/homeassistant/components/chacon_dio/manifest.json +++ b/homeassistant/components/chacon_dio/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/chacon_dio", "iot_class": "cloud_push", "loggers": ["dio_chacon_api"], - "requirements": ["dio-chacon-wifi-api==1.2.1"] + "requirements": ["dio-chacon-wifi-api==1.2.2"] } diff --git a/homeassistant/components/devolo_home_control/__init__.py b/homeassistant/components/devolo_home_control/__init__.py index e86b7b753c8..b8dc948913f 100644 --- a/homeassistant/components/devolo_home_control/__init__.py +++ b/homeassistant/components/devolo_home_control/__init__.py @@ -3,8 +3,8 @@ from __future__ import annotations import asyncio +from collections.abc import Mapping from functools import partial -from types import MappingProxyType from typing import Any from devolo_home_control_api.exceptions.gateway import GatewayOfflineError @@ -97,7 +97,7 @@ async def async_remove_config_entry_device( return True -def configure_mydevolo(conf: dict[str, Any] | MappingProxyType[str, Any]) -> Mydevolo: +def configure_mydevolo(conf: Mapping[str, Any]) -> Mydevolo: """Configure mydevolo.""" mydevolo = Mydevolo() mydevolo.user = conf[CONF_USERNAME] diff --git a/homeassistant/components/dhcp/__init__.py b/homeassistant/components/dhcp/__init__.py index a11a0b262b0..76d11f22424 100644 --- a/homeassistant/components/dhcp/__init__.py +++ b/homeassistant/components/dhcp/__init__.py @@ -4,7 +4,6 @@ from __future__ import annotations import asyncio from collections.abc import Callable -from dataclasses import dataclass from datetime import timedelta from fnmatch import translate from functools import lru_cache, partial @@ -66,13 +65,12 @@ from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo as _DhcpServ from homeassistant.helpers.typing import ConfigType from homeassistant.loader import DHCPMatcher, async_get_dhcp -from .const import DOMAIN +from . import websocket_api +from .const import DOMAIN, HOSTNAME, IP_ADDRESS, MAC_ADDRESS +from .models import DATA_DHCP, DHCPAddressData, DHCPData, DhcpMatchers CONFIG_SCHEMA = cv.empty_config_schema(DOMAIN) -HOSTNAME: Final = "hostname" -MAC_ADDRESS: Final = "macaddress" -IP_ADDRESS: Final = "ip" REGISTERED_DEVICES: Final = "registered_devices" SCAN_INTERVAL = timedelta(minutes=60) @@ -87,15 +85,6 @@ _DEPRECATED_DhcpServiceInfo = DeprecatedConstant( ) -@dataclass(slots=True) -class DhcpMatchers: - """Prepared info from dhcp entries.""" - - registered_devices_domains: set[str] - no_oui_matchers: dict[str, list[DHCPMatcher]] - oui_matchers: dict[str, list[DHCPMatcher]] - - def async_index_integration_matchers( integration_matchers: list[DHCPMatcher], ) -> DhcpMatchers: @@ -133,36 +122,34 @@ def async_index_integration_matchers( async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up the dhcp component.""" - watchers: list[WatcherBase] = [] - address_data: dict[str, dict[str, str]] = {} integration_matchers = async_index_integration_matchers(await async_get_dhcp(hass)) + dhcp_data = DHCPData(integration_matchers=integration_matchers) + hass.data[DATA_DHCP] = dhcp_data + websocket_api.async_setup(hass) + watchers: list[WatcherBase] = [] # For the passive classes we need to start listening # for state changes and connect the dispatchers before # everything else starts up or we will miss events - device_watcher = DeviceTrackerWatcher(hass, address_data, integration_matchers) + device_watcher = DeviceTrackerWatcher(hass, dhcp_data) device_watcher.async_start() watchers.append(device_watcher) - device_tracker_registered_watcher = DeviceTrackerRegisteredWatcher( - hass, address_data, integration_matchers - ) + device_tracker_registered_watcher = DeviceTrackerRegisteredWatcher(hass, dhcp_data) device_tracker_registered_watcher.async_start() watchers.append(device_tracker_registered_watcher) async def _async_initialize(event: Event) -> None: await aiodhcpwatcher.async_init() - network_watcher = NetworkWatcher(hass, address_data, integration_matchers) + network_watcher = NetworkWatcher(hass, dhcp_data) network_watcher.async_start() watchers.append(network_watcher) - dhcp_watcher = DHCPWatcher(hass, address_data, integration_matchers) + dhcp_watcher = DHCPWatcher(hass, dhcp_data) await dhcp_watcher.async_start() watchers.append(dhcp_watcher) - rediscovery_watcher = RediscoveryWatcher( - hass, address_data, integration_matchers - ) + rediscovery_watcher = RediscoveryWatcher(hass, dhcp_data) rediscovery_watcher.async_start() watchers.append(rediscovery_watcher) @@ -180,18 +167,13 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: class WatcherBase: """Base class for dhcp and device tracker watching.""" - def __init__( - self, - hass: HomeAssistant, - address_data: dict[str, dict[str, str]], - integration_matchers: DhcpMatchers, - ) -> None: + def __init__(self, hass: HomeAssistant, dhcp_data: DHCPData) -> None: """Initialize class.""" super().__init__() - self.hass = hass - self._integration_matchers = integration_matchers - self._address_data = address_data + self._callbacks = dhcp_data.callbacks + self._integration_matchers = dhcp_data.integration_matchers + self._address_data = dhcp_data.address_data self._unsub: Callable[[], None] | None = None @callback @@ -230,18 +212,18 @@ class WatcherBase: mac_address = formatted_mac.replace(":", "") compressed_ip_address = made_ip_address.compressed - data = self._address_data.get(mac_address) + current_data = self._address_data.get(mac_address) if ( not force - and data - and data[IP_ADDRESS] == compressed_ip_address - and data[HOSTNAME].startswith(hostname) + and current_data + and current_data[IP_ADDRESS] == compressed_ip_address + and current_data[HOSTNAME].startswith(hostname) ): # If the address data is the same no need # to process it return - data = {IP_ADDRESS: compressed_ip_address, HOSTNAME: hostname} + data: DHCPAddressData = {IP_ADDRESS: compressed_ip_address, HOSTNAME: hostname} self._address_data[mac_address] = data lowercase_hostname = hostname.lower() @@ -287,9 +269,19 @@ class WatcherBase: _LOGGER.debug("Matched %s against %s", data, matcher) matched_domains.add(domain) - if not matched_domains: - return # avoid creating DiscoveryKey if there are no matches + if self._callbacks: + address_data = {mac_address: data} + for callback_ in self._callbacks: + callback_(address_data) + service_info: _DhcpServiceInfo | None = None + if not matched_domains: + return + service_info = _DhcpServiceInfo( + ip=ip_address, + hostname=lowercase_hostname, + macaddress=mac_address, + ) discovery_key = DiscoveryKey( domain=DOMAIN, key=mac_address, @@ -300,11 +292,7 @@ class WatcherBase: self.hass, domain, {"source": config_entries.SOURCE_DHCP}, - _DhcpServiceInfo( - ip=ip_address, - hostname=lowercase_hostname, - macaddress=mac_address, - ), + service_info, discovery_key=discovery_key, ) @@ -315,11 +303,10 @@ class NetworkWatcher(WatcherBase): def __init__( self, hass: HomeAssistant, - address_data: dict[str, dict[str, str]], - integration_matchers: DhcpMatchers, + dhcp_data: DHCPData, ) -> None: """Initialize class.""" - super().__init__(hass, address_data, integration_matchers) + super().__init__(hass, dhcp_data) self._discover_hosts: DiscoverHosts | None = None self._discover_task: asyncio.Task | None = None diff --git a/homeassistant/components/dhcp/const.py b/homeassistant/components/dhcp/const.py index c28a699c64c..c3bf8c512db 100644 --- a/homeassistant/components/dhcp/const.py +++ b/homeassistant/components/dhcp/const.py @@ -1,3 +1,8 @@ """Constants for the dhcp integration.""" +from typing import Final + DOMAIN = "dhcp" +HOSTNAME: Final = "hostname" +MAC_ADDRESS: Final = "macaddress" +IP_ADDRESS: Final = "ip" diff --git a/homeassistant/components/dhcp/helpers.py b/homeassistant/components/dhcp/helpers.py new file mode 100644 index 00000000000..e5ab767ee71 --- /dev/null +++ b/homeassistant/components/dhcp/helpers.py @@ -0,0 +1,37 @@ +"""The dhcp integration.""" + +from __future__ import annotations + +from collections.abc import Callable +from functools import partial + +from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback + +from .models import DATA_DHCP, DHCPAddressData + + +@callback +def async_register_dhcp_callback_internal( + hass: HomeAssistant, + callback_: Callable[[dict[str, DHCPAddressData]], None], +) -> CALLBACK_TYPE: + """Register a dhcp callback. + + For internal use only. + This is not intended for use by integrations. + """ + callbacks = hass.data[DATA_DHCP].callbacks + callbacks.add(callback_) + return partial(callbacks.remove, callback_) + + +@callback +def async_get_address_data_internal( + hass: HomeAssistant, +) -> dict[str, DHCPAddressData]: + """Get the address data. + + For internal use only. + This is not intended for use by integrations. + """ + return hass.data[DATA_DHCP].address_data diff --git a/homeassistant/components/dhcp/models.py b/homeassistant/components/dhcp/models.py new file mode 100644 index 00000000000..d26993e7f0f --- /dev/null +++ b/homeassistant/components/dhcp/models.py @@ -0,0 +1,43 @@ +"""The dhcp integration.""" + +from __future__ import annotations + +from collections.abc import Callable +import dataclasses +from dataclasses import dataclass +from typing import TypedDict + +from homeassistant.loader import DHCPMatcher +from homeassistant.util.hass_dict import HassKey + +from .const import DOMAIN + + +@dataclass(slots=True) +class DhcpMatchers: + """Prepared info from dhcp entries.""" + + registered_devices_domains: set[str] + no_oui_matchers: dict[str, list[DHCPMatcher]] + oui_matchers: dict[str, list[DHCPMatcher]] + + +class DHCPAddressData(TypedDict): + """Typed dict for DHCP address data.""" + + hostname: str + ip: str + + +@dataclasses.dataclass(slots=True) +class DHCPData: + """Data for the dhcp component.""" + + integration_matchers: DhcpMatchers + callbacks: set[Callable[[dict[str, DHCPAddressData]], None]] = dataclasses.field( + default_factory=set + ) + address_data: dict[str, DHCPAddressData] = dataclasses.field(default_factory=dict) + + +DATA_DHCP: HassKey[DHCPData] = HassKey(DOMAIN) diff --git a/homeassistant/components/dhcp/websocket_api.py b/homeassistant/components/dhcp/websocket_api.py new file mode 100644 index 00000000000..e6682de2158 --- /dev/null +++ b/homeassistant/components/dhcp/websocket_api.py @@ -0,0 +1,63 @@ +"""The dhcp integration websocket apis.""" + +from __future__ import annotations + +from typing import Any + +import voluptuous as vol + +from homeassistant.components import websocket_api +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers import device_registry as dr +from homeassistant.helpers.json import json_bytes + +from .const import HOSTNAME, IP_ADDRESS +from .helpers import ( + async_get_address_data_internal, + async_register_dhcp_callback_internal, +) +from .models import DHCPAddressData + + +@callback +def async_setup(hass: HomeAssistant) -> None: + """Set up the DHCP websocket API.""" + websocket_api.async_register_command(hass, ws_subscribe_discovery) + + +@websocket_api.require_admin +@websocket_api.websocket_command( + { + vol.Required("type"): "dhcp/subscribe_discovery", + } +) +@websocket_api.async_response +async def ws_subscribe_discovery( + hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg: dict[str, Any] +) -> None: + """Handle subscribe discovery websocket command.""" + ws_msg_id: int = msg["id"] + + def _async_send(address_data: dict[str, DHCPAddressData]) -> None: + connection.send_message( + json_bytes( + websocket_api.event_message( + ws_msg_id, + { + "add": [ + { + "mac_address": dr.format_mac(mac_address).upper(), + "hostname": data[HOSTNAME], + "ip_address": data[IP_ADDRESS], + } + for mac_address, data in address_data.items() + ] + }, + ) + ) + ) + + unsub = async_register_dhcp_callback_internal(hass, _async_send) + connection.subscriptions[ws_msg_id] = unsub + connection.send_message(json_bytes(websocket_api.result_message(ws_msg_id))) + _async_send(async_get_address_data_internal(hass)) diff --git a/homeassistant/components/dynalite/bridge.py b/homeassistant/components/dynalite/bridge.py index 0e491281619..162d1167e81 100644 --- a/homeassistant/components/dynalite/bridge.py +++ b/homeassistant/components/dynalite/bridge.py @@ -2,8 +2,7 @@ from __future__ import annotations -from collections.abc import Callable -from types import MappingProxyType +from collections.abc import Callable, Mapping from typing import Any from dynalite_devices_lib.dynalite_devices import ( @@ -50,7 +49,7 @@ class DynaliteBridge: LOGGER.debug("Setting up bridge - host %s", self.host) return await self.dynalite_devices.async_setup() - def reload_config(self, config: MappingProxyType[str, Any]) -> None: + def reload_config(self, config: Mapping[str, Any]) -> None: """Reconfigure a bridge when config changes.""" LOGGER.debug("Reloading bridge - host %s, config %s", self.host, config) self.dynalite_devices.configure(convert_config(config)) diff --git a/homeassistant/components/dynalite/convert_config.py b/homeassistant/components/dynalite/convert_config.py index 00edc26f1ab..e37ce93ece4 100644 --- a/homeassistant/components/dynalite/convert_config.py +++ b/homeassistant/components/dynalite/convert_config.py @@ -2,7 +2,7 @@ from __future__ import annotations -from types import MappingProxyType +from collections.abc import Mapping from typing import Any from dynalite_devices_lib import const as dyn_const @@ -138,9 +138,7 @@ def convert_template(config: dict[str, Any]) -> dict[str, Any]: return convert_with_map(config, my_map) -def convert_config( - config: dict[str, Any] | MappingProxyType[str, Any], -) -> dict[str, Any]: +def convert_config(config: Mapping[str, Any]) -> dict[str, Any]: """Convert a config dict by replacing component consts with library consts.""" my_map = { CONF_NAME: dyn_const.CONF_NAME, diff --git a/homeassistant/components/elevenlabs/tts.py b/homeassistant/components/elevenlabs/tts.py index efcadb3f440..61850837075 100644 --- a/homeassistant/components/elevenlabs/tts.py +++ b/homeassistant/components/elevenlabs/tts.py @@ -2,8 +2,8 @@ from __future__ import annotations +from collections.abc import Mapping import logging -from types import MappingProxyType from typing import Any from elevenlabs import AsyncElevenLabs @@ -43,7 +43,7 @@ _LOGGER = logging.getLogger(__name__) PARALLEL_UPDATES = 0 -def to_voice_settings(options: MappingProxyType[str, Any]) -> VoiceSettings: +def to_voice_settings(options: Mapping[str, Any]) -> VoiceSettings: """Return voice settings.""" return VoiceSettings( stability=options.get(CONF_STABILITY, DEFAULT_STABILITY), diff --git a/homeassistant/components/elkm1/__init__.py b/homeassistant/components/elkm1/__init__.py index 4bf51b99de1..0fe2df09bc5 100644 --- a/homeassistant/components/elkm1/__init__.py +++ b/homeassistant/components/elkm1/__init__.py @@ -5,7 +5,6 @@ from __future__ import annotations import asyncio import logging import re -from types import MappingProxyType from typing import Any from elkm1_lib.elements import Element @@ -235,7 +234,7 @@ def _async_find_matching_config_entry( async def async_setup_entry(hass: HomeAssistant, entry: ElkM1ConfigEntry) -> bool: """Set up Elk-M1 Control from a config entry.""" - conf: MappingProxyType[str, Any] = entry.data + conf = entry.data host = hostname_from_url(entry.data[CONF_HOST]) diff --git a/homeassistant/components/energy/websocket_api.py b/homeassistant/components/energy/websocket_api.py index 5f48a99133d..d9d36deb03e 100644 --- a/homeassistant/components/energy/websocket_api.py +++ b/homeassistant/components/energy/websocket_api.py @@ -293,9 +293,9 @@ async def ws_get_fossil_energy_consumption( if statistics_id not in statistic_ids: continue for period in stat: - if period["change"] is None: + if (change := period.get("change")) is None: continue - result[period["start"]] += period["change"] + result[period["start"]] += change return {key: result[key] for key in sorted(result)} diff --git a/homeassistant/components/enphase_envoy/manifest.json b/homeassistant/components/enphase_envoy/manifest.json index 88183fe4cfd..17bad6be92d 100644 --- a/homeassistant/components/enphase_envoy/manifest.json +++ b/homeassistant/components/enphase_envoy/manifest.json @@ -6,6 +6,7 @@ "documentation": "https://www.home-assistant.io/integrations/enphase_envoy", "iot_class": "local_polling", "loggers": ["pyenphase"], + "quality_scale": "bronze", "requirements": ["pyenphase==1.25.5"], "zeroconf": [ { diff --git a/homeassistant/components/enphase_envoy/quality_scale.yaml b/homeassistant/components/enphase_envoy/quality_scale.yaml index 4431a298c8c..78ff6de4297 100644 --- a/homeassistant/components/enphase_envoy/quality_scale.yaml +++ b/homeassistant/components/enphase_envoy/quality_scale.yaml @@ -1,31 +1,19 @@ rules: # Bronze action-setup: - status: done + status: exempt comment: only actions implemented are platform native ones. - appropriate-polling: - status: done - comment: fixed 1 minute cycle based on Enphase Envoy device characteristics + appropriate-polling: done brands: done common-modules: done config-flow-test-coverage: done config-flow: done dependency-transparency: done - docs-actions: - status: done - comment: https://www.home-assistant.io/integrations/enphase_envoy/#actions - docs-high-level-description: - status: done - comment: https://www.home-assistant.io/integrations/enphase_envoy - docs-installation-instructions: - status: done - comment: https://www.home-assistant.io/integrations/enphase_envoy#prerequisites - docs-removal-instructions: - status: done - comment: https://www.home-assistant.io/integrations/enphase_envoy#removing-the-integration - entity-event-setup: - status: done - comment: no events used. + docs-actions: done + docs-high-level-description: done + docs-installation-instructions: done + docs-removal-instructions: done + entity-event-setup: done entity-unique-id: done has-entity-name: done runtime-data: done @@ -34,24 +22,14 @@ rules: unique-config-entry: done # Silver - action-exceptions: - status: todo - comment: | - needs to raise appropriate error when exception occurs. - Pending https://github.com/pyenphase/pyenphase/pull/194 + action-exceptions: done config-entry-unloading: done - docs-configuration-parameters: - status: done - comment: https://www.home-assistant.io/integrations/enphase_envoy#configuration - docs-installation-parameters: - status: done - comment: https://www.home-assistant.io/integrations/enphase_envoy#required-manual-input + docs-configuration-parameters: done + docs-installation-parameters: done entity-unavailable: done integration-owner: done log-when-unavailable: done - parallel-updates: - status: done - comment: pending https://github.com/home-assistant/core/pull/132373 + parallel-updates: done reauthentication-flow: done test-coverage: done @@ -60,22 +38,14 @@ rules: diagnostics: done discovery-update-info: done discovery: done - docs-data-update: - status: done - comment: https://www.home-assistant.io/integrations/enphase_envoy#data-updates - docs-examples: - status: todo - comment: add blue-print examples, if any - docs-known-limitations: todo - docs-supported-devices: - status: done - comment: https://www.home-assistant.io/integrations/enphase_envoy#supported-devices - docs-supported-functions: todo - docs-troubleshooting: - status: done - comment: https://www.home-assistant.io/integrations/enphase_envoy#troubleshooting - docs-use-cases: todo - dynamic-devices: todo + docs-data-update: done + docs-examples: done + docs-known-limitations: done + docs-supported-devices: done + docs-supported-functions: done + docs-troubleshooting: done + docs-use-cases: done + dynamic-devices: done entity-category: done entity-device-class: done entity-disabled-by-default: done @@ -86,7 +56,7 @@ rules: repair-issues: status: exempt comment: no general issues or repair.py - stale-devices: todo + stale-devices: done # Platinum async-dependency: done diff --git a/homeassistant/components/esphome/assist_satellite.py b/homeassistant/components/esphome/assist_satellite.py index cf1e299a6f0..9b5d4e74c70 100644 --- a/homeassistant/components/esphome/assist_satellite.py +++ b/homeassistant/components/esphome/assist_satellite.py @@ -336,6 +336,12 @@ class EsphomeAssistSatellite( "code": event.data["code"], "message": event.data["message"], } + elif event_type == VoiceAssistantEventType.VOICE_ASSISTANT_RUN_START: + assert event.data is not None + if tts_output := event.data["tts_output"]: + path = tts_output["url"] + url = async_process_play_media_url(self.hass, path) + data_to_send = {"url": url} elif event_type == VoiceAssistantEventType.VOICE_ASSISTANT_RUN_END: if self._tts_streaming_task is None: # No TTS diff --git a/homeassistant/components/esphome/config_flow.py b/homeassistant/components/esphome/config_flow.py index 2b1babfc0ba..d9c8381e4ff 100644 --- a/homeassistant/components/esphome/config_flow.py +++ b/homeassistant/components/esphome/config_flow.py @@ -57,6 +57,7 @@ ERROR_INVALID_ENCRYPTION_KEY = "invalid_psk" _LOGGER = logging.getLogger(__name__) ZERO_NOISE_PSK = "MDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDA=" +DEFAULT_NAME = "ESPHome" class EsphomeFlowHandler(ConfigFlow, domain=DOMAIN): @@ -117,8 +118,8 @@ class EsphomeFlowHandler(ConfigFlow, domain=DOMAIN): self._host = entry_data[CONF_HOST] self._port = entry_data[CONF_PORT] self._password = entry_data[CONF_PASSWORD] - self._name = self._reauth_entry.title self._device_name = entry_data.get(CONF_DEVICE_NAME) + self._name = self._reauth_entry.title # Device without encryption allows fetching device info. We can then check # if the device is no longer using a password. If we did try with a password, @@ -147,7 +148,7 @@ class EsphomeFlowHandler(ConfigFlow, domain=DOMAIN): return self.async_show_form( step_id="reauth_encryption_removed_confirm", - description_placeholders={"name": self._name}, + description_placeholders={"name": self._async_get_human_readable_name()}, ) async def async_step_reauth_confirm( @@ -172,7 +173,7 @@ class EsphomeFlowHandler(ConfigFlow, domain=DOMAIN): step_id="reauth_confirm", data_schema=vol.Schema({vol.Required(CONF_NOISE_PSK): str}), errors=errors, - description_placeholders={"name": self._name}, + description_placeholders={"name": self._async_get_human_readable_name()}, ) async def async_step_reconfigure( @@ -189,12 +190,14 @@ class EsphomeFlowHandler(ConfigFlow, domain=DOMAIN): @property def _name(self) -> str: - return self.__name or "ESPHome" + return self.__name or DEFAULT_NAME @_name.setter def _name(self, value: str) -> None: self.__name = value - self.context["title_placeholders"] = {"name": self._name} + self.context["title_placeholders"] = { + "name": self._async_get_human_readable_name() + } async def _async_try_fetch_device_info(self) -> ConfigFlowResult: """Try to fetch device info and return any errors.""" @@ -254,7 +257,8 @@ class EsphomeFlowHandler(ConfigFlow, domain=DOMAIN): if user_input is not None: return await self._async_try_fetch_device_info() return self.async_show_form( - step_id="discovery_confirm", description_placeholders={"name": self._name} + step_id="discovery_confirm", + description_placeholders={"name": self._async_get_human_readable_name()}, ) async def async_step_zeroconf( @@ -274,8 +278,8 @@ class EsphomeFlowHandler(ConfigFlow, domain=DOMAIN): # Hostname is format: livingroom.local. device_name = discovery_info.hostname.removesuffix(".local.") - self._name = discovery_info.properties.get("friendly_name", device_name) self._device_name = device_name + self._name = discovery_info.properties.get("friendly_name", device_name) self._host = discovery_info.host self._port = discovery_info.port self._noise_required = bool(discovery_info.properties.get("api_encryption")) @@ -306,7 +310,32 @@ class EsphomeFlowHandler(ConfigFlow, domain=DOMAIN): updates[CONF_HOST] = host if port is not None: updates[CONF_PORT] = port - self._abort_if_unique_id_configured(updates=updates) + self._abort_unique_id_configured_with_details(updates=updates) + + @callback + def _abort_unique_id_configured_with_details(self, updates: dict[str, Any]) -> None: + """Abort if unique_id is already configured with details.""" + assert self.unique_id is not None + if not ( + conflict_entry := self.hass.config_entries.async_entry_for_domain_unique_id( + self.handler, self.unique_id + ) + ): + return + assert conflict_entry.unique_id is not None + if updates: + error = "already_configured_updates" + else: + error = "already_configured_detailed" + self._abort_if_unique_id_configured( + updates=updates, + error=error, + description_placeholders={ + "title": conflict_entry.title, + "name": conflict_entry.data.get(CONF_DEVICE_NAME, "unknown"), + "mac": format_mac(conflict_entry.unique_id), + }, + ) async def async_step_mqtt( self, discovery_info: MqttServiceInfo @@ -341,7 +370,7 @@ class EsphomeFlowHandler(ConfigFlow, domain=DOMAIN): # Check if already configured await self.async_set_unique_id(mac_address) - self._abort_if_unique_id_configured( + self._abort_unique_id_configured_with_details( updates={CONF_HOST: self._host, CONF_PORT: self._port} ) @@ -479,7 +508,7 @@ class EsphomeFlowHandler(ConfigFlow, domain=DOMAIN): data=self._reauth_entry.data | self._async_make_config_data(), ) assert self._host is not None - self._abort_if_unique_id_configured( + self._abort_unique_id_configured_with_details( updates={ CONF_HOST: self._host, CONF_PORT: self._port, @@ -510,7 +539,7 @@ class EsphomeFlowHandler(ConfigFlow, domain=DOMAIN): if not ( unique_id_matches := (self.unique_id == self._reconfig_entry.unique_id) ): - self._abort_if_unique_id_configured( + self._abort_unique_id_configured_with_details( updates={ CONF_HOST: self._host, CONF_PORT: self._port, @@ -568,9 +597,30 @@ class EsphomeFlowHandler(ConfigFlow, domain=DOMAIN): step_id="encryption_key", data_schema=vol.Schema({vol.Required(CONF_NOISE_PSK): str}), errors=errors, - description_placeholders={"name": self._name}, + description_placeholders={"name": self._async_get_human_readable_name()}, ) + @callback + def _async_get_human_readable_name(self) -> str: + """Return a human readable name for the entry.""" + entry: ConfigEntry | None = None + if self.source == SOURCE_REAUTH: + entry = self._reauth_entry + elif self.source == SOURCE_RECONFIGURE: + entry = self._reconfig_entry + friendly_name = self._name + device_name = self._device_name + if ( + device_name + and friendly_name in (DEFAULT_NAME, device_name) + and entry + and entry.title != friendly_name + ): + friendly_name = entry.title + if not device_name or friendly_name == device_name: + return friendly_name + return f"{friendly_name} ({device_name})" + async def async_step_authenticate( self, user_input: dict[str, Any] | None = None, error: str | None = None ) -> ConfigFlowResult: @@ -589,7 +639,7 @@ class EsphomeFlowHandler(ConfigFlow, domain=DOMAIN): return self.async_show_form( step_id="authenticate", data_schema=vol.Schema({vol.Required("password"): str}), - description_placeholders={"name": self._name}, + description_placeholders={"name": self._async_get_human_readable_name()}, errors=errors, ) @@ -612,10 +662,12 @@ class EsphomeFlowHandler(ConfigFlow, domain=DOMAIN): return ERROR_REQUIRES_ENCRYPTION_KEY except InvalidEncryptionKeyAPIError as ex: if ex.received_name: + device_name_changed = self._device_name != ex.received_name self._device_name = ex.received_name if ex.received_mac: self._device_mac = format_mac(ex.received_mac) - self._name = ex.received_name + if not self._name or device_name_changed: + self._name = ex.received_name return ERROR_INVALID_ENCRYPTION_KEY except ResolveAPIError: return "resolve_error" @@ -623,9 +675,9 @@ class EsphomeFlowHandler(ConfigFlow, domain=DOMAIN): return "connection_error" finally: await cli.disconnect(force=True) - self._name = self._device_info.friendly_name or self._device_info.name - self._device_name = self._device_info.name self._device_mac = format_mac(self._device_info.mac_address) + self._device_name = self._device_info.name + self._name = self._device_info.friendly_name or self._device_info.name return None async def fetch_device_info(self) -> str | None: @@ -640,7 +692,7 @@ class EsphomeFlowHandler(ConfigFlow, domain=DOMAIN): mac_address = format_mac(self._device_info.mac_address) await self.async_set_unique_id(mac_address, raise_on_progress=False) if self.source not in (SOURCE_REAUTH, SOURCE_RECONFIGURE): - self._abort_if_unique_id_configured( + self._abort_unique_id_configured_with_details( updates={ CONF_HOST: self._host, CONF_PORT: self._port, diff --git a/homeassistant/components/esphome/entity.py b/homeassistant/components/esphome/entity.py index 313785fd2df..b442eaebb65 100644 --- a/homeassistant/components/esphome/entity.py +++ b/homeassistant/components/esphome/entity.py @@ -224,7 +224,16 @@ class EsphomeEntity(Entity, Generic[_InfoT, _StateT]): self._attr_device_info = DeviceInfo( connections={(dr.CONNECTION_NETWORK_MAC, device_info.mac_address)} ) - self.entity_id = f"{domain}.{device_info.name}_{entity_info.object_id}" + if entity_info.name: + self.entity_id = f"{domain}.{device_info.name}_{entity_info.object_id}" + else: + # https://github.com/home-assistant/core/issues/132532 + # If name is not set, ESPHome will use the sanitized friendly name + # as the name, however we want to use the original object_id + # as the entity_id before it is sanitized since the sanitizer + # is not utf-8 aware. In this case, its always going to be + # an empty string so we drop the object_id. + self.entity_id = f"{domain}.{device_info.name}" async def async_added_to_hass(self) -> None: """Register callbacks.""" @@ -260,7 +269,12 @@ class EsphomeEntity(Entity, Generic[_InfoT, _StateT]): self._static_info = static_info self._attr_unique_id = build_unique_id(device_info.mac_address, static_info) self._attr_entity_registry_enabled_default = not static_info.disabled_by_default - self._attr_name = static_info.name + # https://github.com/home-assistant/core/issues/132532 + # If the name is "", we need to set it to None since otherwise + # the friendly_name will be "{friendly_name} " with a trailing + # space. ESPHome uses protobuf under the hood, and an empty field + # gets a default value of "". + self._attr_name = static_info.name if static_info.name else None if entity_category := static_info.entity_category: self._attr_entity_category = ENTITY_CATEGORIES.from_esphome(entity_category) else: diff --git a/homeassistant/components/esphome/strings.json b/homeassistant/components/esphome/strings.json index 6c10a2e5fe8..fa4cc549250 100644 --- a/homeassistant/components/esphome/strings.json +++ b/homeassistant/components/esphome/strings.json @@ -2,6 +2,8 @@ "config": { "abort": { "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", + "already_configured_detailed": "A device `{name}`, with MAC address `{mac}` is already configured as `{title}`.", + "already_configured_updates": "A device `{name}`, with MAC address `{mac}` is already configured as `{title}`; the existing configuration will be updated with the validated data.", "already_in_progress": "[%key:common::config_flow::abort::already_in_progress%]", "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]", "mdns_missing_mac": "Missing MAC address in mDNS properties.", @@ -17,10 +19,11 @@ "reconfigure_unique_id_changed": "**Reconfiguration of `{name}` was aborted** because the address `{host}` points to a different device: `{unexpected_device_name}` (MAC: `{unexpected_mac}`) instead of the expected one (MAC: `{expected_mac}`)." }, "error": { - "resolve_error": "Can't resolve address of the ESP. If this error persists, please set a static IP address", - "connection_error": "Can't connect to ESP. Please make sure your YAML file contains an 'api:' line.", + "resolve_error": "Unable to resolve the address of the ESPHome device. If this issue continues, consider setting a static IP address.", + "connection_error": "Unable to connect to the ESPHome device. Make sure the device’s YAML configuration includes an `api` section.", + "requires_encryption_key": "The ESPHome device requires an encryption key. Enter the key defined in the device’s YAML configuration under `api -> encryption -> key`.", "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", - "invalid_psk": "The transport encryption key is invalid. Please ensure it matches what you have in your configuration" + "invalid_psk": "The encryption key is invalid. Make sure it matches the value in the device’s YAML configuration under `api -> encryption -> key`." }, "step": { "user": { @@ -41,7 +44,7 @@ "data_description": { "password": "Passwords are deprecated and will be removed in a future version. Please update your ESPHome device YAML configuration to use an encryption key instead." }, - "description": "Please enter the password you set in your ESPHome device YAML configuration for {name}." + "description": "Please enter the password you set in your ESPHome device YAML configuration for `{name}`." }, "encryption_key": { "data": { @@ -50,7 +53,7 @@ "data_description": { "noise_psk": "The encryption key is used to encrypt the connection between Home Assistant and the ESPHome device. You can find this in the api: section of your ESPHome device YAML configuration." }, - "description": "Please enter the encryption key for {name}. You can find it in the ESPHome Dashboard or in your ESPHome device YAML configuration." + "description": "Please enter the encryption key for `{name}`. You can find it in the ESPHome Dashboard or in your ESPHome device YAML configuration." }, "reauth_confirm": { "data": { @@ -59,10 +62,10 @@ "data_description": { "noise_psk": "[%key:component::esphome::config::step::encryption_key::data_description::noise_psk%]" }, - "description": "The ESPHome device {name} enabled transport encryption or changed the encryption key. Please enter the updated key. You can find it in the ESPHome Dashboard or in your ESPHome device YAML configuration." + "description": "The ESPHome device `{name}` enabled transport encryption or changed the encryption key. Please enter the updated key. You can find it in the ESPHome Dashboard or in your ESPHome device YAML configuration." }, "reauth_encryption_removed_confirm": { - "description": "The ESPHome device {name} disabled transport encryption. Please confirm that you want to remove the encryption key and allow unencrypted connections." + "description": "The ESPHome device `{name}` disabled transport encryption. Please confirm that you want to remove the encryption key and allow unencrypted connections." }, "discovery_confirm": { "description": "Do you want to add the device `{name}` to Home Assistant?", diff --git a/homeassistant/components/freebox/router.py b/homeassistant/components/freebox/router.py index efa96eca5a7..753bdff8cec 100644 --- a/homeassistant/components/freebox/router.py +++ b/homeassistant/components/freebox/router.py @@ -72,7 +72,11 @@ async def get_hosts_list_if_supported( supports_hosts: bool = True fbx_devices: list[dict[str, Any]] = [] try: - fbx_devices = await fbx_api.lan.get_hosts_list() or [] + fbx_interfaces = await fbx_api.lan.get_interfaces() or [] + for interface in fbx_interfaces: + fbx_devices.extend( + await fbx_api.lan.get_hosts_list(interface["name"]) or [] + ) except HttpRequestError as err: if ( (matcher := re.search(r"Request failed \(APIResponse: (.+)\)", str(err))) diff --git a/homeassistant/components/fritz/coordinator.py b/homeassistant/components/fritz/coordinator.py index c0121ed9aa1..9199692f564 100644 --- a/homeassistant/components/fritz/coordinator.py +++ b/homeassistant/components/fritz/coordinator.py @@ -2,13 +2,12 @@ from __future__ import annotations -from collections.abc import Callable, ValuesView +from collections.abc import Callable, Mapping, ValuesView from dataclasses import dataclass, field from datetime import datetime, timedelta from functools import partial import logging import re -from types import MappingProxyType from typing import Any, TypedDict, cast from fritzconnection import FritzConnection @@ -187,7 +186,7 @@ class FritzBoxTools(DataUpdateCoordinator[UpdateCoordinatorDataType]): ) self._devices: dict[str, FritzDevice] = {} - self._options: MappingProxyType[str, Any] | None = None + self._options: Mapping[str, Any] | None = None self._unique_id: str | None = None self.connection: FritzConnection = None self.fritz_guest_wifi: FritzGuestWLAN = None @@ -213,9 +212,7 @@ class FritzBoxTools(DataUpdateCoordinator[UpdateCoordinatorDataType]): str, Callable[[FritzStatus, StateType], Any] ] = {} - async def async_setup( - self, options: MappingProxyType[str, Any] | None = None - ) -> None: + async def async_setup(self, options: Mapping[str, Any] | None = None) -> None: """Wrap up FritzboxTools class setup.""" self._options = options await self.hass.async_add_executor_job(self.setup) diff --git a/homeassistant/components/fritzbox/climate.py b/homeassistant/components/fritzbox/climate.py index 57c7e2a696f..0c6c2141c12 100644 --- a/homeassistant/components/fritzbox/climate.py +++ b/homeassistant/components/fritzbox/climate.py @@ -53,8 +53,11 @@ MAX_TEMPERATURE = 28 # special temperatures for on/off in Fritz!Box API (modified by pyfritzhome) ON_API_TEMPERATURE = 127.0 OFF_API_TEMPERATURE = 126.5 -ON_REPORT_SET_TEMPERATURE = 30.0 -OFF_REPORT_SET_TEMPERATURE = 0.0 +PRESET_API_HKR_STATE_MAPPING = { + PRESET_COMFORT: "comfort", + PRESET_BOOST: "on", + PRESET_ECO: "eco", +} async def async_setup_entry( @@ -128,29 +131,28 @@ class FritzboxThermostat(FritzBoxDeviceEntity, ClimateEntity): return self.data.actual_temperature # type: ignore [no-any-return] @property - def target_temperature(self) -> float: + def target_temperature(self) -> float | None: """Return the temperature we try to reach.""" - if self.data.target_temperature == ON_API_TEMPERATURE: - return ON_REPORT_SET_TEMPERATURE - if self.data.target_temperature == OFF_API_TEMPERATURE: - return OFF_REPORT_SET_TEMPERATURE + if self.data.target_temperature in [ON_API_TEMPERATURE, OFF_API_TEMPERATURE]: + return None return self.data.target_temperature # type: ignore [no-any-return] + async def async_set_hkr_state(self, hkr_state: str) -> None: + """Set the state of the climate.""" + await self.hass.async_add_executor_job(self.data.set_hkr_state, hkr_state, True) + await self.coordinator.async_refresh() + async def async_set_temperature(self, **kwargs: Any) -> None: """Set new target temperature.""" - if (hvac_mode := kwargs.get(ATTR_HVAC_MODE)) is HVACMode.OFF: - await self.async_set_hvac_mode(hvac_mode) + if kwargs.get(ATTR_HVAC_MODE) is HVACMode.OFF: + await self.async_set_hkr_state("off") elif (target_temp := kwargs.get(ATTR_TEMPERATURE)) is not None: - if target_temp == OFF_API_TEMPERATURE: - target_temp = OFF_REPORT_SET_TEMPERATURE - elif target_temp == ON_API_TEMPERATURE: - target_temp = ON_REPORT_SET_TEMPERATURE await self.hass.async_add_executor_job( self.data.set_target_temperature, target_temp, True ) + await self.coordinator.async_refresh() else: return - await self.coordinator.async_refresh() @property def hvac_mode(self) -> HVACMode: @@ -159,10 +161,7 @@ class FritzboxThermostat(FritzBoxDeviceEntity, ClimateEntity): return HVACMode.HEAT if self.data.summer_active: return HVACMode.OFF - if self.data.target_temperature in ( - OFF_REPORT_SET_TEMPERATURE, - OFF_API_TEMPERATURE, - ): + if self.data.target_temperature == OFF_API_TEMPERATURE: return HVACMode.OFF return HVACMode.HEAT @@ -180,7 +179,7 @@ class FritzboxThermostat(FritzBoxDeviceEntity, ClimateEntity): ) return if hvac_mode is HVACMode.OFF: - await self.async_set_temperature(temperature=OFF_REPORT_SET_TEMPERATURE) + await self.async_set_hkr_state("off") else: if value_scheduled_preset(self.data) == PRESET_ECO: target_temp = self.data.eco_temperature @@ -210,12 +209,7 @@ class FritzboxThermostat(FritzBoxDeviceEntity, ClimateEntity): translation_domain=DOMAIN, translation_key="change_preset_while_active_mode", ) - if preset_mode == PRESET_COMFORT: - await self.async_set_temperature(temperature=self.data.comfort_temperature) - elif preset_mode == PRESET_ECO: - await self.async_set_temperature(temperature=self.data.eco_temperature) - elif preset_mode == PRESET_BOOST: - await self.async_set_temperature(temperature=ON_REPORT_SET_TEMPERATURE) + await self.async_set_hkr_state(PRESET_API_HKR_STATE_MAPPING[preset_mode]) @property def extra_state_attributes(self) -> ClimateExtraAttributes: diff --git a/homeassistant/components/fronius/strings.json b/homeassistant/components/fronius/strings.json index 36778f2ca5f..6635060dd1c 100644 --- a/homeassistant/components/fronius/strings.json +++ b/homeassistant/components/fronius/strings.json @@ -184,7 +184,7 @@ "running": "Running", "standby": "[%key:common::state::standby%]", "bootloading": "Bootloading", - "error": "Error", + "error": "[%key:common::state::error%]", "idle": "[%key:common::state::idle%]", "ready": "Ready", "sleeping": "Sleeping" diff --git a/homeassistant/components/goodwe/strings.json b/homeassistant/components/goodwe/strings.json index ec4ea80e22a..6348da45618 100644 --- a/homeassistant/components/goodwe/strings.json +++ b/homeassistant/components/goodwe/strings.json @@ -36,7 +36,7 @@ "name": "Inverter operation mode", "state": { "general": "General mode", - "off_grid": "Off grid mode", + "off_grid": "Off-grid mode", "backup": "Backup mode", "eco": "Eco mode", "peak_shaving": "Peak shaving mode", diff --git a/homeassistant/components/google/quality_scale.yaml b/homeassistant/components/google/quality_scale.yaml new file mode 100644 index 00000000000..9ef6abdba90 --- /dev/null +++ b/homeassistant/components/google/quality_scale.yaml @@ -0,0 +1,119 @@ +rules: + # Bronze + config-flow: + status: todo + comment: Some fields missing data_description in the option flow. + brands: done + dependency-transparency: + status: todo + comment: | + This depends on the legacy (deprecated) oauth libraries for device + auth (no longer recommended auth). Google publishes to pypi using + an internal build system. We need to either revisit approach or + revisit our stance on this. + common-modules: done + has-entity-name: done + action-setup: + status: todo + comment: | + Actions are current setup in `async_setup_entry` and need to be moved + to `async_setup`. + appropriate-polling: done + test-before-configure: done + entity-event-setup: + status: exempt + comment: Integration does not subscribe to events. + unique-config-entry: done + entity-unique-id: done + docs-installation-instructions: done + docs-removal-instructions: todo + test-before-setup: + status: todo + comment: | + The integration does not test the connection in `async_setup_entry` but + instead does this in the calendar platform only, which can be improved. + docs-high-level-description: done + config-flow-test-coverage: + status: todo + comment: | + The config flow has 100% test coverage, however there are opportunities + to increase functionality such as checking for the specific contents + of a unique id assigned to a config entry. + docs-actions: done + runtime-data: + status: todo + comment: | + The integration stores config entry data in `hass.data` and should be + updated to use `runtime_data`. + + # Silver + log-when-unavailable: done + config-entry-unloading: done + reauthentication-flow: + status: todo + comment: | + The integration supports reauthentication, however the config flow test + coverage can be improved on reauth corner cases. + action-exceptions: done + docs-installation-parameters: todo + integration-owner: done + parallel-updates: todo + test-coverage: + status: todo + comment: One module needs an additional line of coverage to be above the bar + docs-configuration-parameters: todo + entity-unavailable: done + + # Gold + docs-examples: done + discovery-update-info: + status: exempt + comment: Google calendar does not support discovery + entity-device-class: todo + entity-translations: todo + docs-data-update: todo + entity-disabled-by-default: done + discovery: + status: exempt + comment: Google calendar does not support discovery + exception-translations: todo + devices: todo + docs-supported-devices: done + icon-translations: + status: exempt + comment: Google calendar does not have any icons + docs-known-limitations: todo + stale-devices: + status: exempt + comment: Google calendar does not have devices + docs-supported-functions: done + repair-issues: + status: todo + comment: There are some warnings/deprecations that should be repair issues + reconfiguration-flow: + status: exempt + comment: There is nothing to configure in the configuration flow + entity-category: + status: exempt + comment: The entities in google calendar do not support categories + dynamic-devices: + status: exempt + comment: Google calendar does not have devices + docs-troubleshooting: todo + diagnostics: todo + docs-use-cases: todo + + # Platinum + async-dependency: + status: done + comment: | + The main client `gcal_sync` library is async. The primary authentication + used in config flow is handled by built in async OAuth code. The + integration still supports legacy OAuth credentials setup in the + configuration flow, which is no longer recommended or described in the + documentation for new users. This legacy config flow uses oauth2client + which is not natively async. + strict-typing: + status: todo + comment: Dependency oauth2client does not confirm to PEP 561 + inject-websession: done diff --git a/homeassistant/components/google_generative_ai_conversation/config_flow.py b/homeassistant/components/google_generative_ai_conversation/config_flow.py index ec476d940d1..551f9b0c9de 100644 --- a/homeassistant/components/google_generative_ai_conversation/config_flow.py +++ b/homeassistant/components/google_generative_ai_conversation/config_flow.py @@ -208,7 +208,7 @@ class GoogleGenerativeAIOptionsFlow(OptionsFlow): async def google_generative_ai_config_option_schema( hass: HomeAssistant, - options: dict[str, Any] | MappingProxyType[str, Any], + options: Mapping[str, Any], genai_client: genai.Client, ) -> dict: """Return a schema for Google Generative AI completion options.""" diff --git a/homeassistant/components/govee_light_local/light.py b/homeassistant/components/govee_light_local/light.py index 984654477e9..c5c8ed42ad5 100644 --- a/homeassistant/components/govee_light_local/light.py +++ b/homeassistant/components/govee_light_local/light.py @@ -157,9 +157,6 @@ class GoveeLight(CoordinatorEntity[GoveeLocalApiCoordinator], LightEntity): async def async_turn_on(self, **kwargs: Any) -> None: """Turn the device on.""" - if not self.is_on or not kwargs: - await self.coordinator.turn_on(self._device) - if ATTR_BRIGHTNESS in kwargs: brightness: int = int((float(kwargs[ATTR_BRIGHTNESS]) / 255.0) * 100.0) await self.coordinator.set_brightness(self._device, brightness) @@ -187,6 +184,9 @@ class GoveeLight(CoordinatorEntity[GoveeLocalApiCoordinator], LightEntity): self._save_last_color_state() await self.coordinator.set_scene(self._device, effect) + if not self.is_on or not kwargs: + await self.coordinator.turn_on(self._device) + self.async_write_ha_state() async def async_turn_off(self, **kwargs: Any) -> None: diff --git a/homeassistant/components/group/strings.json b/homeassistant/components/group/strings.json index fb90eb9b22c..b80b78027bf 100644 --- a/homeassistant/components/group/strings.json +++ b/homeassistant/components/group/strings.json @@ -3,7 +3,7 @@ "config": { "step": { "user": { - "title": "Create Group", + "title": "Create group", "description": "Groups allow you to create a new entity that represents multiple entities of the same type.", "menu_options": { "binary_sensor": "Binary sensor group", @@ -104,7 +104,7 @@ "round_digits": "Round value to number of decimals", "device_class": "Device class", "state_class": "State class", - "unit_of_measurement": "Unit of Measurement" + "unit_of_measurement": "Unit of measurement" } }, "switch": { diff --git a/homeassistant/components/hassio/__init__.py b/homeassistant/components/hassio/__init__.py index f160c69bae7..bc0f819fde9 100644 --- a/homeassistant/components/hassio/__init__.py +++ b/homeassistant/components/hassio/__init__.py @@ -51,7 +51,6 @@ from homeassistant.helpers.hassio import ( get_supervisor_ip as _get_supervisor_ip, is_hassio as _is_hassio, ) -from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue from homeassistant.helpers.service_info.hassio import ( HassioServiceInfo as _HassioServiceInfo, ) @@ -160,7 +159,6 @@ CONFIG_SCHEMA = vol.Schema( SERVICE_ADDON_START = "addon_start" SERVICE_ADDON_STOP = "addon_stop" SERVICE_ADDON_RESTART = "addon_restart" -SERVICE_ADDON_UPDATE = "addon_update" SERVICE_ADDON_STDIN = "addon_stdin" SERVICE_HOST_SHUTDOWN = "host_shutdown" SERVICE_HOST_REBOOT = "host_reboot" @@ -241,7 +239,6 @@ MAP_SERVICE_API = { SERVICE_ADDON_START: APIEndpointSettings("/addons/{addon}/start", SCHEMA_ADDON), SERVICE_ADDON_STOP: APIEndpointSettings("/addons/{addon}/stop", SCHEMA_ADDON), SERVICE_ADDON_RESTART: APIEndpointSettings("/addons/{addon}/restart", SCHEMA_ADDON), - SERVICE_ADDON_UPDATE: APIEndpointSettings("/addons/{addon}/update", SCHEMA_ADDON), SERVICE_ADDON_STDIN: APIEndpointSettings( "/addons/{addon}/stdin", SCHEMA_ADDON_STDIN ), @@ -411,16 +408,6 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: # noqa: async def async_service_handler(service: ServiceCall) -> None: """Handle service calls for Hass.io.""" - if service.service == SERVICE_ADDON_UPDATE: - async_create_issue( - hass, - DOMAIN, - "update_service_deprecated", - breaks_in_ha_version="2025.5", - is_fixable=False, - severity=IssueSeverity.WARNING, - translation_key="update_service_deprecated", - ) api_endpoint = MAP_SERVICE_API[service.service] data = service.data.copy() diff --git a/homeassistant/components/hassio/icons.json b/homeassistant/components/hassio/icons.json index 64f032d9f80..33eb154edc4 100644 --- a/homeassistant/components/hassio/icons.json +++ b/homeassistant/components/hassio/icons.json @@ -22,9 +22,6 @@ "addon_stop": { "service": "mdi:stop" }, - "addon_update": { - "service": "mdi:update" - }, "host_reboot": { "service": "mdi:restart" }, diff --git a/homeassistant/components/hassio/services.yaml b/homeassistant/components/hassio/services.yaml index 30086e4dd2b..43143fe6889 100644 --- a/homeassistant/components/hassio/services.yaml +++ b/homeassistant/components/hassio/services.yaml @@ -30,14 +30,6 @@ addon_stop: selector: addon: -addon_update: - fields: - addon: - required: true - example: core_ssh - selector: - addon: - host_reboot: host_shutdown: backup_full: diff --git a/homeassistant/components/hassio/strings.json b/homeassistant/components/hassio/strings.json index 68a747eb16d..e34aa020c5a 100644 --- a/homeassistant/components/hassio/strings.json +++ b/homeassistant/components/hassio/strings.json @@ -225,10 +225,6 @@ "unsupported_virtualization_image": { "title": "Unsupported system - Incorrect OS image for virtualization", "description": "System is unsupported because the Home Assistant OS image in use is not intended for use in a virtualized environment. Use the link to learn more and how to fix this." - }, - "update_service_deprecated": { - "title": "Deprecated update add-on action", - "description": "The update add-on action has been deprecated and will be removed in 2025.5. Please use the update entity and the respective action to update the add-on instead." } }, "entity": { @@ -313,16 +309,6 @@ } } }, - "addon_update": { - "name": "Update add-on", - "description": "Updates an add-on. This action should be used with caution since add-on updates can contain breaking changes. It is highly recommended that you review release notes/change logs before updating an add-on.", - "fields": { - "addon": { - "name": "[%key:component::hassio::services::addon_start::fields::addon::name%]", - "description": "The add-on to update." - } - } - }, "host_reboot": { "name": "Reboot the host system", "description": "Reboots the host system." diff --git a/homeassistant/components/history_stats/data.py b/homeassistant/components/history_stats/data.py index a69abe26f6c..756a6b3ce9d 100644 --- a/homeassistant/components/history_stats/data.py +++ b/homeassistant/components/history_stats/data.py @@ -54,7 +54,7 @@ class HistoryStats: self._period = (MIN_TIME_UTC, MIN_TIME_UTC) self._state: HistoryStatsState = HistoryStatsState(None, None, self._period) self._history_current_period: list[HistoryState] = [] - self._previous_run_before_start = False + self._has_recorder_data = False self._entity_states = set(entity_states) self._duration = duration self._start = start @@ -88,20 +88,20 @@ class HistoryStats: if current_period_start_timestamp > now_timestamp: # History cannot tell the future self._history_current_period = [] - self._previous_run_before_start = True + self._has_recorder_data = False self._state = HistoryStatsState(None, None, self._period) return self._state # # We avoid querying the database if the below did NOT happen: # - # - The previous run happened before the start time - # - The start time changed - # - The period shrank in size + # - No previous run occurred (uninitialized) + # - The start time moved back in time + # - The end time moved back in time # - The previous period ended before now # if ( - not self._previous_run_before_start - and current_period_start_timestamp == previous_period_start_timestamp + self._has_recorder_data + and current_period_start_timestamp >= previous_period_start_timestamp and ( current_period_end_timestamp == previous_period_end_timestamp or ( @@ -110,6 +110,12 @@ class HistoryStats: ) ) ): + start_changed = ( + current_period_start_timestamp != previous_period_start_timestamp + ) + if start_changed: + self._prune_history_cache(current_period_start_timestamp) + new_data = False if event and (new_state := event.data["new_state"]) is not None: if ( @@ -121,7 +127,11 @@ class HistoryStats: HistoryState(new_state.state, new_state.last_changed_timestamp) ) new_data = True - if not new_data and current_period_end_timestamp < now_timestamp: + if ( + not new_data + and current_period_end_timestamp < now_timestamp + and not start_changed + ): # If period has not changed and current time after the period end... # Don't compute anything as the value cannot have changed return self._state @@ -139,7 +149,7 @@ class HistoryStats: HistoryState(new_state.state, new_state.last_changed_timestamp) ) - self._previous_run_before_start = False + self._has_recorder_data = True seconds_matched, match_count = self._async_compute_seconds_and_changes( now_timestamp, @@ -223,3 +233,18 @@ class HistoryStats: # Save value in seconds seconds_matched = elapsed return seconds_matched, match_count + + def _prune_history_cache(self, start_timestamp: float) -> None: + """Remove unnecessary old data from the history state cache from previous runs. + + Update the timestamp of the last record from before the start to the current start time. + """ + trim_count = 0 + for i, history_state in enumerate(self._history_current_period): + if history_state.last_changed >= start_timestamp: + break + history_state.last_changed = start_timestamp + if i > 0: + trim_count += 1 + if trim_count: # Don't slice if no data was removed + self._history_current_period = self._history_current_period[trim_count:] diff --git a/homeassistant/components/home_connect/coordinator.py b/homeassistant/components/home_connect/coordinator.py index ab09989e200..9e40de86e24 100644 --- a/homeassistant/components/home_connect/coordinator.py +++ b/homeassistant/components/home_connect/coordinator.py @@ -47,8 +47,8 @@ from .utils import get_dict_from_home_connect_error _LOGGER = logging.getLogger(__name__) -MAX_EXECUTIONS_TIME_WINDOW = 15 * 60 # 15 minutes -MAX_EXECUTIONS = 5 +MAX_EXECUTIONS_TIME_WINDOW = 60 * 60 # 1 hour +MAX_EXECUTIONS = 8 type HomeConnectConfigEntry = ConfigEntry[HomeConnectCoordinator] diff --git a/homeassistant/components/home_connect/strings.json b/homeassistant/components/home_connect/strings.json index 070dcf34f9c..d16459bc594 100644 --- a/homeassistant/components/home_connect/strings.json +++ b/homeassistant/components/home_connect/strings.json @@ -1536,7 +1536,7 @@ "pause": "[%key:common::state::paused%]", "actionrequired": "Action required", "finished": "Finished", - "error": "Error", + "error": "[%key:common::state::error%]", "aborting": "Aborting" } }, @@ -1587,7 +1587,7 @@ "streaminglocal": "Streaming local", "streamingcloud": "Streaming cloud", "streaminglocal_and_cloud": "Streaming local and cloud", - "error": "Error" + "error": "[%key:common::state::error%]" } }, "last_selected_map": { diff --git a/homeassistant/components/homeassistant/services.yaml b/homeassistant/components/homeassistant/services.yaml index 897b7d50e31..372f4fa9955 100644 --- a/homeassistant/components/homeassistant/services.yaml +++ b/homeassistant/components/homeassistant/services.yaml @@ -61,7 +61,7 @@ reload_config_entry: required: false example: 8955375327824e14ba89e4b29cc3ec9a selector: - text: + config_entry: save_persistent_states: diff --git a/homeassistant/components/homee/number.py b/homeassistant/components/homee/number.py index 5f76b826fcf..231c2ecac94 100644 --- a/homeassistant/components/homee/number.py +++ b/homeassistant/components/homee/number.py @@ -1,5 +1,8 @@ """The Homee number platform.""" +from collections.abc import Callable +from dataclasses import dataclass + from pyHomee.const import AttributeType from pyHomee.model import HomeeAttribute @@ -8,7 +11,7 @@ from homeassistant.components.number import ( NumberEntity, NumberEntityDescription, ) -from homeassistant.const import EntityCategory +from homeassistant.const import EntityCategory, UnitOfSpeed from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback @@ -18,69 +21,89 @@ from .entity import HomeeEntity PARALLEL_UPDATES = 0 + +@dataclass(frozen=True, kw_only=True) +class HomeeNumberEntityDescription(NumberEntityDescription): + """A class that describes Homee number entities.""" + + native_value_fn: Callable[[float], float] = lambda value: value + set_native_value_fn: Callable[[float], float] = lambda value: value + + NUMBER_DESCRIPTIONS = { - AttributeType.DOWN_POSITION: NumberEntityDescription( + AttributeType.DOWN_POSITION: HomeeNumberEntityDescription( key="down_position", entity_category=EntityCategory.CONFIG, ), - AttributeType.DOWN_SLAT_POSITION: NumberEntityDescription( + AttributeType.DOWN_SLAT_POSITION: HomeeNumberEntityDescription( key="down_slat_position", entity_category=EntityCategory.CONFIG, ), - AttributeType.DOWN_TIME: NumberEntityDescription( + AttributeType.DOWN_TIME: HomeeNumberEntityDescription( key="down_time", device_class=NumberDeviceClass.DURATION, entity_category=EntityCategory.CONFIG, ), - AttributeType.ENDPOSITION_CONFIGURATION: NumberEntityDescription( + AttributeType.ENDPOSITION_CONFIGURATION: HomeeNumberEntityDescription( key="endposition_configuration", entity_category=EntityCategory.CONFIG, ), - AttributeType.MOTION_ALARM_CANCELATION_DELAY: NumberEntityDescription( + AttributeType.MOTION_ALARM_CANCELATION_DELAY: HomeeNumberEntityDescription( key="motion_alarm_cancelation_delay", device_class=NumberDeviceClass.DURATION, entity_category=EntityCategory.CONFIG, ), - AttributeType.OPEN_WINDOW_DETECTION_SENSIBILITY: NumberEntityDescription( + AttributeType.OPEN_WINDOW_DETECTION_SENSIBILITY: HomeeNumberEntityDescription( key="open_window_detection_sensibility", entity_category=EntityCategory.CONFIG, ), - AttributeType.POLLING_INTERVAL: NumberEntityDescription( + AttributeType.POLLING_INTERVAL: HomeeNumberEntityDescription( key="polling_interval", device_class=NumberDeviceClass.DURATION, entity_category=EntityCategory.CONFIG, ), - AttributeType.SHUTTER_SLAT_TIME: NumberEntityDescription( + AttributeType.SHUTTER_SLAT_TIME: HomeeNumberEntityDescription( key="shutter_slat_time", device_class=NumberDeviceClass.DURATION, entity_category=EntityCategory.CONFIG, ), - AttributeType.SLAT_MAX_ANGLE: NumberEntityDescription( + AttributeType.SLAT_MAX_ANGLE: HomeeNumberEntityDescription( key="slat_max_angle", entity_category=EntityCategory.CONFIG, ), - AttributeType.SLAT_MIN_ANGLE: NumberEntityDescription( + AttributeType.SLAT_MIN_ANGLE: HomeeNumberEntityDescription( key="slat_min_angle", entity_category=EntityCategory.CONFIG, ), - AttributeType.SLAT_STEPS: NumberEntityDescription( + AttributeType.SLAT_STEPS: HomeeNumberEntityDescription( key="slat_steps", entity_category=EntityCategory.CONFIG, ), - AttributeType.TEMPERATURE_OFFSET: NumberEntityDescription( + AttributeType.TEMPERATURE_OFFSET: HomeeNumberEntityDescription( key="temperature_offset", entity_category=EntityCategory.CONFIG, ), - AttributeType.UP_TIME: NumberEntityDescription( + AttributeType.UP_TIME: HomeeNumberEntityDescription( key="up_time", device_class=NumberDeviceClass.DURATION, entity_category=EntityCategory.CONFIG, ), - AttributeType.WAKE_UP_INTERVAL: NumberEntityDescription( + AttributeType.WAKE_UP_INTERVAL: HomeeNumberEntityDescription( key="wake_up_interval", device_class=NumberDeviceClass.DURATION, entity_category=EntityCategory.CONFIG, ), + AttributeType.WIND_MONITORING_STATE: HomeeNumberEntityDescription( + key="wind_monitoring_state", + device_class=NumberDeviceClass.WIND_SPEED, + entity_category=EntityCategory.CONFIG, + native_min_value=0, + native_max_value=22.5, + native_step=2.5, + native_unit_of_measurement=UnitOfSpeed.METERS_PER_SECOND, + native_value_fn=lambda value: value * 2.5, + set_native_value_fn=lambda value: value / 2.5, + ), } @@ -102,20 +125,25 @@ async def async_setup_entry( class HomeeNumber(HomeeEntity, NumberEntity): """Representation of a Homee number.""" + entity_description: HomeeNumberEntityDescription + def __init__( self, attribute: HomeeAttribute, entry: HomeeConfigEntry, - description: NumberEntityDescription, + description: HomeeNumberEntityDescription, ) -> None: """Initialize a Homee number entity.""" super().__init__(attribute, entry) self.entity_description = description self._attr_translation_key = description.key - self._attr_native_unit_of_measurement = HOMEE_UNIT_TO_HA_UNIT[attribute.unit] - self._attr_native_min_value = attribute.minimum - self._attr_native_max_value = attribute.maximum - self._attr_native_step = attribute.step_value + self._attr_native_unit_of_measurement = ( + description.native_unit_of_measurement + or HOMEE_UNIT_TO_HA_UNIT[attribute.unit] + ) + self._attr_native_min_value = description.native_min_value or attribute.minimum + self._attr_native_max_value = description.native_max_value or attribute.maximum + self._attr_native_step = description.native_step or attribute.step_value @property def available(self) -> bool: @@ -123,10 +151,12 @@ class HomeeNumber(HomeeEntity, NumberEntity): return super().available and self._attribute.editable @property - def native_value(self) -> int: + def native_value(self) -> float | None: """Return the native value of the number.""" - return int(self._attribute.current_value) + return self.entity_description.native_value_fn(self._attribute.current_value) async def async_set_native_value(self, value: float) -> None: """Set the selected value.""" - await self.async_set_homee_value(value) + await self.async_set_homee_value( + self.entity_description.set_native_value_fn(value) + ) diff --git a/homeassistant/components/homee/strings.json b/homeassistant/components/homee/strings.json index 756bdbdf9eb..f8d83a3073e 100644 --- a/homeassistant/components/homee/strings.json +++ b/homeassistant/components/homee/strings.json @@ -189,6 +189,9 @@ }, "wake_up_interval": { "name": "Wake-up interval" + }, + "wind_monitoring_state": { + "name": "Threshold for wind trigger" } }, "select": { diff --git a/homeassistant/components/homekit/manifest.json b/homeassistant/components/homekit/manifest.json index 4ae2e43dfb2..431de804023 100644 --- a/homeassistant/components/homekit/manifest.json +++ b/homeassistant/components/homekit/manifest.json @@ -10,7 +10,7 @@ "loggers": ["pyhap"], "requirements": [ "HAP-python==4.9.2", - "fnv-hash-fast==1.4.0", + "fnv-hash-fast==1.5.0", "PyQRCode==1.2.1", "base36==0.1.1" ], diff --git a/homeassistant/components/homekit_controller/manifest.json b/homeassistant/components/homekit_controller/manifest.json index 6562a3edcc9..dbcd2788c8a 100644 --- a/homeassistant/components/homekit_controller/manifest.json +++ b/homeassistant/components/homekit_controller/manifest.json @@ -14,6 +14,6 @@ "documentation": "https://www.home-assistant.io/integrations/homekit_controller", "iot_class": "local_push", "loggers": ["aiohomekit", "commentjson"], - "requirements": ["aiohomekit==3.2.13"], + "requirements": ["aiohomekit==3.2.14"], "zeroconf": ["_hap._tcp.local.", "_hap._udp.local."] } diff --git a/homeassistant/components/huawei_lte/icons.json b/homeassistant/components/huawei_lte/icons.json index a338cc65ed4..22eb345eba5 100644 --- a/homeassistant/components/huawei_lte/icons.json +++ b/homeassistant/components/huawei_lte/icons.json @@ -34,7 +34,7 @@ }, "select": { "preferred_network_mode": { - "default": "mdi:transmission-tower" + "default": "mdi:antenna" } }, "switch": { diff --git a/homeassistant/components/huawei_lte/manifest.json b/homeassistant/components/huawei_lte/manifest.json index ce5316553ed..e58525e3af4 100644 --- a/homeassistant/components/huawei_lte/manifest.json +++ b/homeassistant/components/huawei_lte/manifest.json @@ -7,7 +7,7 @@ "iot_class": "local_polling", "loggers": ["huawei_lte_api.Session"], "requirements": [ - "huawei-lte-api==1.10.0", + "huawei-lte-api==1.11.0", "stringcase==1.2.0", "url-normalize==2.2.0" ], diff --git a/homeassistant/components/huawei_lte/sensor.py b/homeassistant/components/huawei_lte/sensor.py index 3543433ca45..12588786b2b 100644 --- a/homeassistant/components/huawei_lte/sensor.py +++ b/homeassistant/components/huawei_lte/sensor.py @@ -181,7 +181,7 @@ SENSOR_META: dict[str, HuaweiSensorGroup] = { "cell_id": HuaweiSensorEntityDescription( key="cell_id", translation_key="cell_id", - icon="mdi:transmission-tower", + icon="mdi:antenna", entity_category=EntityCategory.DIAGNOSTIC, ), "cqi0": HuaweiSensorEntityDescription( @@ -230,6 +230,7 @@ SENSOR_META: dict[str, HuaweiSensorGroup] = { "enodeb_id": HuaweiSensorEntityDescription( key="enodeb_id", translation_key="enodeb_id", + icon="mdi:antenna", entity_category=EntityCategory.DIAGNOSTIC, ), "lac": HuaweiSensorEntityDescription( @@ -364,7 +365,7 @@ SENSOR_META: dict[str, HuaweiSensorGroup] = { "pci": HuaweiSensorEntityDescription( key="pci", translation_key="pci", - icon="mdi:transmission-tower", + icon="mdi:antenna", entity_category=EntityCategory.DIAGNOSTIC, ), "plmn": HuaweiSensorEntityDescription( diff --git a/homeassistant/components/huawei_lte/strings.json b/homeassistant/components/huawei_lte/strings.json index 912bc174dd5..a006fe43b82 100644 --- a/homeassistant/components/huawei_lte/strings.json +++ b/homeassistant/components/huawei_lte/strings.json @@ -26,6 +26,10 @@ "data": { "password": "[%key:common::config_flow::data::password%]", "username": "[%key:common::config_flow::data::username%]" + }, + "data_description": { + "password": "[%key:component::huawei_lte::config::step::user::data_description::password%]", + "username": "[%key:component::huawei_lte::config::step::user::data_description::username%]" } }, "user": { @@ -35,6 +39,12 @@ "username": "[%key:common::config_flow::data::username%]", "verify_ssl": "[%key:common::config_flow::data::verify_ssl%]" }, + "data_description": { + "password": "Password for accessing the router's API. Typically, the same as the one used for the router's web interface.", + "url": "Base URL to the API of the router. Typically, something like `http://192.168.X.1`. This is the beginning of the location shown in a browser when accessing the router's web interface.", + "username": "Username for accessing the router's API. Typically, the same as the one used for the router's web interface. Usually, either `admin`, or left empty (recommended if that works).", + "verify_ssl": "Whether to verify the SSL certificate of the router when accessing it. Applicable only if the router is accessed via HTTPS." + }, "description": "Enter device access details.", "title": "Configure Huawei LTE" } @@ -48,6 +58,12 @@ "recipient": "SMS notification recipients", "track_wired_clients": "Track wired network clients", "unauthenticated_mode": "Unauthenticated mode (change requires reload)" + }, + "data_description": { + "name": "Used to distinguish between notification services in case there are multiple Huawei LTE devices configured. Changes to this option value take effect after Home Assistant restart.", + "recipient": "Comma separated list of default recipient SMS phone numbers for the notification service, used in case the notification sender does not specify any.", + "track_wired_clients": "Whether the device tracker entities track also clients attached to the router's wired Ethernet network, in addition to wireless clients.", + "unauthenticated_mode": "Whether to run in unauthenticated mode. Unauthenticated mode provides a limited set of features, but may help in case there are problems accessing the router's web interface from a browser while the integration is active. Changes to this option value take effect after integration reload." } } } diff --git a/homeassistant/components/hue/strings.json b/homeassistant/components/hue/strings.json index 3326dd1043f..44a6eb72acc 100644 --- a/homeassistant/components/hue/strings.json +++ b/homeassistant/components/hue/strings.json @@ -57,7 +57,7 @@ "3": "[%key:component::hue::device_automation::trigger_subtype::button_3%]", "4": "[%key:component::hue::device_automation::trigger_subtype::button_4%]", "clock_wise": "Rotation clockwise", - "counter_clock_wise": "Rotation counter-clockwise" + "counter_clock_wise": "Rotation counterclockwise" }, "trigger_type": { "remote_button_long_release": "\"{subtype}\" released after long press", @@ -96,7 +96,7 @@ "event_type": { "state": { "clock_wise": "Clockwise", - "counter_clock_wise": "Counter clockwise" + "counter_clock_wise": "Counterclockwise" } } } diff --git a/homeassistant/components/husqvarna_automower/coordinator.py b/homeassistant/components/husqvarna_automower/coordinator.py index c23ca508916..dc653d8ce80 100644 --- a/homeassistant/components/husqvarna_automower/coordinator.py +++ b/homeassistant/components/husqvarna_automower/coordinator.py @@ -61,6 +61,15 @@ class AutomowerDataUpdateCoordinator(DataUpdateCoordinator[MowerDictionary]): self._zones_last_update: dict[str, set[str]] = {} self._areas_last_update: dict[str, set[int]] = {} + def _async_add_remove_devices_and_entities(self, data: MowerDictionary) -> None: + """Add/remove devices and dynamic entities, when amount of devices changed.""" + self._async_add_remove_devices(data) + for mower_id in data: + if data[mower_id].capabilities.stay_out_zones: + self._async_add_remove_stay_out_zones(data) + if data[mower_id].capabilities.work_areas: + self._async_add_remove_work_areas(data) + async def _async_update_data(self) -> MowerDictionary: """Subscribe for websocket and poll data from the API.""" if not self.ws_connected: @@ -73,20 +82,14 @@ class AutomowerDataUpdateCoordinator(DataUpdateCoordinator[MowerDictionary]): raise UpdateFailed(err) from err except AuthError as err: raise ConfigEntryAuthFailed(err) from err - - self._async_add_remove_devices(data) - for mower_id in data: - if data[mower_id].capabilities.stay_out_zones: - self._async_add_remove_stay_out_zones(data) - for mower_id in data: - if data[mower_id].capabilities.work_areas: - self._async_add_remove_work_areas(data) + self._async_add_remove_devices_and_entities(data) return data @callback def callback(self, ws_data: MowerDictionary) -> None: """Process websocket callbacks and write them to the DataUpdateCoordinator.""" self.async_set_updated_data(ws_data) + self._async_add_remove_devices_and_entities(ws_data) async def client_listen( self, diff --git a/homeassistant/components/husqvarna_automower/sensor.py b/homeassistant/components/husqvarna_automower/sensor.py index d7a83c82185..5ad8ad91b48 100644 --- a/homeassistant/components/husqvarna_automower/sensor.py +++ b/homeassistant/components/husqvarna_automower/sensor.py @@ -40,8 +40,7 @@ PARALLEL_UPDATES = 0 ATTR_WORK_AREA_ID_ASSIGNMENT = "work_area_id_assignment" -ERROR_KEY_LIST = [ - "no_error", +ERROR_KEYS = [ "alarm_mower_in_motion", "alarm_mower_lifted", "alarm_mower_stopped", @@ -50,13 +49,11 @@ ERROR_KEY_LIST = [ "alarm_outside_geofence", "angular_sensor_problem", "battery_problem", - "battery_problem", "battery_restriction_due_to_ambient_temperature", "can_error", "charging_current_too_high", "charging_station_blocked", "charging_system_problem", - "charging_system_problem", "collision_sensor_defect", "collision_sensor_error", "collision_sensor_problem_front", @@ -67,24 +64,18 @@ ERROR_KEY_LIST = [ "connection_changed", "connection_not_changed", "connectivity_problem", - "connectivity_problem", - "connectivity_problem", - "connectivity_problem", - "connectivity_problem", - "connectivity_problem", "connectivity_settings_restored", "cutting_drive_motor_1_defect", "cutting_drive_motor_2_defect", "cutting_drive_motor_3_defect", "cutting_height_blocked", - "cutting_height_problem", "cutting_height_problem_curr", "cutting_height_problem_dir", "cutting_height_problem_drive", + "cutting_height_problem", "cutting_motor_problem", "cutting_stopped_slope_too_steep", "cutting_system_blocked", - "cutting_system_blocked", "cutting_system_imbalance_warning", "cutting_system_major_imbalance", "destination_not_reachable", @@ -92,13 +83,9 @@ ERROR_KEY_LIST = [ "docking_sensor_defect", "electronic_problem", "empty_battery", - MowerStates.ERROR.lower(), - MowerStates.ERROR_AT_POWER_UP.lower(), - MowerStates.FATAL_ERROR.lower(), "folding_cutting_deck_sensor_defect", "folding_sensor_activated", "geofence_problem", - "geofence_problem", "gps_navigation_problem", "guide_1_not_found", "guide_2_not_found", @@ -116,7 +103,6 @@ ERROR_KEY_LIST = [ "lift_sensor_defect", "lifted", "limited_cutting_height_range", - "limited_cutting_height_range", "loop_sensor_defect", "loop_sensor_problem_front", "loop_sensor_problem_left", @@ -129,6 +115,7 @@ ERROR_KEY_LIST = [ "no_accurate_position_from_satellites", "no_confirmed_position", "no_drive", + "no_error", "no_loop_signal", "no_power_in_charging_station", "no_response_from_charger", @@ -139,9 +126,6 @@ ERROR_KEY_LIST = [ "safety_function_faulty", "settings_restored", "sim_card_locked", - "sim_card_locked", - "sim_card_locked", - "sim_card_locked", "sim_card_not_found", "sim_card_requires_pin", "slipped_mower_has_slipped_situation_not_solved_with_moving_pattern", @@ -151,13 +135,6 @@ ERROR_KEY_LIST = [ "stuck_in_charging_station", "switch_cord_problem", "temporary_battery_problem", - "temporary_battery_problem", - "temporary_battery_problem", - "temporary_battery_problem", - "temporary_battery_problem", - "temporary_battery_problem", - "temporary_battery_problem", - "temporary_battery_problem", "tilt_sensor_problem", "too_high_discharge_current", "too_high_internal_current", @@ -189,11 +166,19 @@ ERROR_KEY_LIST = [ "zone_generator_problem", ] -ERROR_STATES = { - MowerStates.ERROR, +ERROR_STATES = [ MowerStates.ERROR_AT_POWER_UP, + MowerStates.ERROR, MowerStates.FATAL_ERROR, -} + MowerStates.OFF, + MowerStates.STOPPED, + MowerStates.WAIT_POWER_UP, + MowerStates.WAIT_UPDATING, +] + +ERROR_KEY_LIST = list( + dict.fromkeys(ERROR_KEYS + [state.lower() for state in ERROR_STATES]) +) RESTRICTED_REASONS: list = [ RestrictedReasons.ALL_WORK_AREAS_COMPLETED, @@ -292,6 +277,7 @@ MOWER_SENSOR_TYPES: tuple[AutomowerSensorEntityDescription, ...] = ( AutomowerSensorEntityDescription( key="cutting_blade_usage_time", translation_key="cutting_blade_usage_time", + entity_category=EntityCategory.DIAGNOSTIC, state_class=SensorStateClass.TOTAL, device_class=SensorDeviceClass.DURATION, native_unit_of_measurement=UnitOfTime.SECONDS, @@ -302,6 +288,7 @@ MOWER_SENSOR_TYPES: tuple[AutomowerSensorEntityDescription, ...] = ( AutomowerSensorEntityDescription( key="downtime", translation_key="downtime", + entity_category=EntityCategory.DIAGNOSTIC, state_class=SensorStateClass.TOTAL, device_class=SensorDeviceClass.DURATION, entity_registry_enabled_default=False, @@ -386,6 +373,7 @@ MOWER_SENSOR_TYPES: tuple[AutomowerSensorEntityDescription, ...] = ( AutomowerSensorEntityDescription( key="uptime", translation_key="uptime", + entity_category=EntityCategory.DIAGNOSTIC, state_class=SensorStateClass.TOTAL, device_class=SensorDeviceClass.DURATION, entity_registry_enabled_default=False, diff --git a/homeassistant/components/husqvarna_automower/strings.json b/homeassistant/components/husqvarna_automower/strings.json index 35ce342867f..015d322c481 100644 --- a/homeassistant/components/husqvarna_automower/strings.json +++ b/homeassistant/components/husqvarna_automower/strings.json @@ -106,10 +106,10 @@ "cutting_drive_motor_2_defect": "Cutting drive motor 2 defect", "cutting_drive_motor_3_defect": "Cutting drive motor 3 defect", "cutting_height_blocked": "Cutting height blocked", - "cutting_height_problem": "Cutting height problem", "cutting_height_problem_curr": "Cutting height problem, curr", "cutting_height_problem_dir": "Cutting height problem, dir", "cutting_height_problem_drive": "Cutting height problem, drive", + "cutting_height_problem": "Cutting height problem", "cutting_motor_problem": "Cutting motor problem", "cutting_stopped_slope_too_steep": "Cutting stopped - slope too steep", "cutting_system_blocked": "Cutting system blocked", @@ -120,8 +120,8 @@ "docking_sensor_defect": "Docking sensor defect", "electronic_problem": "Electronic problem", "empty_battery": "Empty battery", - "error": "Error", "error_at_power_up": "Error at power up", + "error": "[%key:common::state::error%]", "fatal_error": "Fatal error", "folding_cutting_deck_sensor_defect": "Folding cutting deck sensor defect", "folding_sensor_activated": "Folding sensor activated", @@ -159,6 +159,7 @@ "no_loop_signal": "No loop signal", "no_power_in_charging_station": "No power in charging station", "no_response_from_charger": "No response from charger", + "off": "[%key:common::state::off%]", "outside_working_area": "Outside working area", "poor_signal_quality": "Poor signal quality", "reference_station_communication_problem": "Reference station communication problem", @@ -172,6 +173,7 @@ "slope_too_steep": "Slope too steep", "sms_could_not_be_sent": "SMS could not be sent", "stop_button_problem": "STOP button problem", + "stopped": "[%key:common::state::stopped%]", "stuck_in_charging_station": "Stuck in charging station", "switch_cord_problem": "Switch cord problem", "temporary_battery_problem": "Temporary battery problem", @@ -187,6 +189,8 @@ "unexpected_cutting_height_adj": "Unexpected cutting height adjustment", "unexpected_error": "Unexpected error", "upside_down": "Upside down", + "wait_power_up": "Wait power up", + "wait_updating": "Wait updating", "weak_gps_signal": "Weak GPS signal", "wheel_drive_problem_left": "Left wheel drive problem", "wheel_drive_problem_rear_left": "Rear left wheel drive problem", diff --git a/homeassistant/components/hyperion/__init__.py b/homeassistant/components/hyperion/__init__.py index 94137b5dd3f..0f49bacd1ef 100644 --- a/homeassistant/components/hyperion/__init__.py +++ b/homeassistant/components/hyperion/__init__.py @@ -5,6 +5,7 @@ from __future__ import annotations import asyncio from collections.abc import Callable from contextlib import suppress +from dataclasses import dataclass import logging from typing import Any, cast @@ -22,9 +23,6 @@ from homeassistant.helpers.dispatcher import ( ) from .const import ( - CONF_INSTANCE_CLIENTS, - CONF_ON_UNLOAD, - CONF_ROOT_CLIENT, DEFAULT_NAME, DOMAIN, HYPERION_RELEASES_URL, @@ -52,15 +50,15 @@ _LOGGER = logging.getLogger(__name__) # The get_hyperion_unique_id method will create a per-entity unique id when given the # server id, an instance number and a name. -# hass.data format -# ================ -# -# hass.data[DOMAIN] = { -# : { -# "ROOT_CLIENT": , -# "ON_UNLOAD": [, ...], -# } -# } +type HyperionConfigEntry = ConfigEntry[HyperionData] + + +@dataclass +class HyperionData: + """Hyperion runtime data.""" + + root_client: client.HyperionClient + instance_clients: dict[int, client.HyperionClient] def get_hyperion_unique_id(server_id: str, instance: int, name: str) -> str: @@ -107,29 +105,29 @@ async def async_create_connect_hyperion_client( @callback def listen_for_instance_updates( hass: HomeAssistant, - config_entry: ConfigEntry, - add_func: Callable, - remove_func: Callable, + entry: HyperionConfigEntry, + add_func: Callable[[int, str], None], + remove_func: Callable[[int], None], ) -> None: """Listen for instance additions/removals.""" - hass.data[DOMAIN][config_entry.entry_id][CONF_ON_UNLOAD].extend( - [ - async_dispatcher_connect( - hass, - SIGNAL_INSTANCE_ADD.format(config_entry.entry_id), - add_func, - ), - async_dispatcher_connect( - hass, - SIGNAL_INSTANCE_REMOVE.format(config_entry.entry_id), - remove_func, - ), - ] + entry.async_on_unload( + async_dispatcher_connect( + hass, + SIGNAL_INSTANCE_ADD.format(entry.entry_id), + add_func, + ) + ) + entry.async_on_unload( + async_dispatcher_connect( + hass, + SIGNAL_INSTANCE_REMOVE.format(entry.entry_id), + remove_func, + ) ) -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_setup_entry(hass: HomeAssistant, entry: HyperionConfigEntry) -> bool: """Set up Hyperion from a config entry.""" host = entry.data[CONF_HOST] port = entry.data[CONF_PORT] @@ -185,12 +183,10 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: # We need 1 root client (to manage instances being removed/added) and then 1 client # per Hyperion server instance which is shared for all entities associated with # that instance. - hass.data.setdefault(DOMAIN, {}) - hass.data[DOMAIN][entry.entry_id] = { - CONF_ROOT_CLIENT: hyperion_client, - CONF_INSTANCE_CLIENTS: {}, - CONF_ON_UNLOAD: [], - } + entry.runtime_data = HyperionData( + root_client=hyperion_client, + instance_clients={}, + ) async def async_instances_to_clients(response: dict[str, Any]) -> None: """Convert instances to Hyperion clients.""" @@ -203,7 +199,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: device_registry = dr.async_get(hass) running_instances: set[int] = set() stopped_instances: set[int] = set() - existing_instances = hass.data[DOMAIN][entry.entry_id][CONF_INSTANCE_CLIENTS] + existing_instances = entry.runtime_data.instance_clients server_id = cast(str, entry.unique_id) # In practice, an instance can be in 3 states as seen by this function: @@ -270,39 +266,29 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: assert hyperion_client if hyperion_client.instances is not None: await async_instances_to_clients_raw(hyperion_client.instances) - hass.data[DOMAIN][entry.entry_id][CONF_ON_UNLOAD].append( - entry.add_update_listener(_async_entry_updated) - ) + entry.async_on_unload(entry.add_update_listener(_async_entry_updated)) return True -async def _async_entry_updated(hass: HomeAssistant, config_entry: ConfigEntry) -> None: +async def _async_entry_updated(hass: HomeAssistant, entry: HyperionConfigEntry) -> None: """Handle entry updates.""" - await hass.config_entries.async_reload(config_entry.entry_id) + await hass.config_entries.async_reload(entry.entry_id) -async def async_unload_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool: +async def async_unload_entry(hass: HomeAssistant, entry: HyperionConfigEntry) -> bool: """Unload a config entry.""" - unload_ok = await hass.config_entries.async_unload_platforms( - config_entry, PLATFORMS - ) - if unload_ok and config_entry.entry_id in hass.data[DOMAIN]: - config_data = hass.data[DOMAIN].pop(config_entry.entry_id) - for func in config_data[CONF_ON_UNLOAD]: - func() - + unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) + if unload_ok: # Disconnect the shared instance clients. await asyncio.gather( *( - config_data[CONF_INSTANCE_CLIENTS][ - instance_num - ].async_client_disconnect() - for instance_num in config_data[CONF_INSTANCE_CLIENTS] + inst.async_client_disconnect() + for inst in entry.runtime_data.instance_clients.values() ) ) # Disconnect the root client. - root_client = config_data[CONF_ROOT_CLIENT] + root_client = entry.runtime_data.root_client await root_client.async_client_disconnect() return unload_ok diff --git a/homeassistant/components/hyperion/camera.py b/homeassistant/components/hyperion/camera.py index 1260be20eb2..ae9c9ba9025 100644 --- a/homeassistant/components/hyperion/camera.py +++ b/homeassistant/components/hyperion/camera.py @@ -25,7 +25,6 @@ from homeassistant.components.camera import ( Camera, async_get_still_stream, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.dispatcher import ( @@ -35,12 +34,12 @@ from homeassistant.helpers.dispatcher import ( from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import ( + HyperionConfigEntry, get_hyperion_device_id, get_hyperion_unique_id, listen_for_instance_updates, ) from .const import ( - CONF_INSTANCE_CLIENTS, DOMAIN, HYPERION_MANUFACTURER_NAME, HYPERION_MODEL_NAME, @@ -53,12 +52,11 @@ IMAGE_STREAM_JPG_SENTINEL = "data:image/jpg;base64," async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + entry: HyperionConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up a Hyperion platform from config entry.""" - entry_data = hass.data[DOMAIN][config_entry.entry_id] - server_id = config_entry.unique_id + server_id = entry.unique_id def camera_unique_id(instance_num: int) -> str: """Return the camera unique_id.""" @@ -75,7 +73,7 @@ async def async_setup_entry( server_id, instance_num, instance_name, - entry_data[CONF_INSTANCE_CLIENTS][instance_num], + entry.runtime_data.instance_clients[instance_num], ) ] ) @@ -91,7 +89,7 @@ async def async_setup_entry( ), ) - listen_for_instance_updates(hass, config_entry, instance_add, instance_remove) + listen_for_instance_updates(hass, entry, instance_add, instance_remove) # A note on Hyperion streaming semantics: diff --git a/homeassistant/components/hyperion/const.py b/homeassistant/components/hyperion/const.py index 3d44dd35e08..ac04d6dad3c 100644 --- a/homeassistant/components/hyperion/const.py +++ b/homeassistant/components/hyperion/const.py @@ -3,10 +3,7 @@ CONF_AUTH_ID = "auth_id" CONF_CREATE_TOKEN = "create_token" CONF_INSTANCE = "instance" -CONF_INSTANCE_CLIENTS = "INSTANCE_CLIENTS" -CONF_ON_UNLOAD = "ON_UNLOAD" CONF_PRIORITY = "priority" -CONF_ROOT_CLIENT = "ROOT_CLIENT" CONF_EFFECT_HIDE_LIST = "effect_hide_list" CONF_EFFECT_SHOW_LIST = "effect_show_list" diff --git a/homeassistant/components/hyperion/light.py b/homeassistant/components/hyperion/light.py index f8932a682ab..4cf0ed0f5e2 100644 --- a/homeassistant/components/hyperion/light.py +++ b/homeassistant/components/hyperion/light.py @@ -5,7 +5,6 @@ from __future__ import annotations from collections.abc import Callable, Mapping, Sequence import functools import logging -from types import MappingProxyType from typing import Any from hyperion import client, const @@ -18,7 +17,6 @@ from homeassistant.components.light import ( LightEntity, LightEntityFeature, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.dispatcher import ( @@ -29,13 +27,13 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.util import color as color_util from . import ( + HyperionConfigEntry, get_hyperion_device_id, get_hyperion_unique_id, listen_for_instance_updates, ) from .const import ( CONF_EFFECT_HIDE_LIST, - CONF_INSTANCE_CLIENTS, CONF_PRIORITY, DEFAULT_ORIGIN, DEFAULT_PRIORITY, @@ -75,28 +73,26 @@ ICON_EFFECT = "mdi:lava-lamp" async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + entry: HyperionConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up a Hyperion platform from config entry.""" - entry_data = hass.data[DOMAIN][config_entry.entry_id] - server_id = config_entry.unique_id + server_id = entry.unique_id @callback def instance_add(instance_num: int, instance_name: str) -> None: """Add entities for a new Hyperion instance.""" assert server_id - args = ( - server_id, - instance_num, - instance_name, - config_entry.options, - entry_data[CONF_INSTANCE_CLIENTS][instance_num], - ) async_add_entities( [ - HyperionLight(*args), + HyperionLight( + server_id, + instance_num, + instance_name, + entry.options, + entry.runtime_data.instance_clients[instance_num], + ), ] ) @@ -111,7 +107,7 @@ async def async_setup_entry( ), ) - listen_for_instance_updates(hass, config_entry, instance_add, instance_remove) + listen_for_instance_updates(hass, entry, instance_add, instance_remove) class HyperionLight(LightEntity): @@ -129,7 +125,7 @@ class HyperionLight(LightEntity): server_id: str, instance_num: int, instance_name: str, - options: MappingProxyType[str, Any], + options: Mapping[str, Any], hyperion_client: client.HyperionClient, ) -> None: """Initialize the light.""" diff --git a/homeassistant/components/hyperion/sensor.py b/homeassistant/components/hyperion/sensor.py index 42b41acea96..bec17cfbd2f 100644 --- a/homeassistant/components/hyperion/sensor.py +++ b/homeassistant/components/hyperion/sensor.py @@ -19,7 +19,6 @@ from hyperion.const import ( ) from homeassistant.components.sensor import SensorEntity, SensorEntityDescription -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.dispatcher import ( @@ -29,12 +28,12 @@ from homeassistant.helpers.dispatcher import ( from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import ( + HyperionConfigEntry, get_hyperion_device_id, get_hyperion_unique_id, listen_for_instance_updates, ) from .const import ( - CONF_INSTANCE_CLIENTS, DOMAIN, HYPERION_MANUFACTURER_NAME, HYPERION_MODEL_NAME, @@ -62,12 +61,11 @@ def _sensor_unique_id(server_id: str, instance_num: int, suffix: str) -> str: async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + entry: HyperionConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up a Hyperion platform from config entry.""" - entry_data = hass.data[DOMAIN][config_entry.entry_id] - server_id = config_entry.unique_id + server_id = entry.unique_id @callback def instance_add(instance_num: int, instance_name: str) -> None: @@ -78,7 +76,7 @@ async def async_setup_entry( server_id, instance_num, instance_name, - entry_data[CONF_INSTANCE_CLIENTS][instance_num], + entry.runtime_data.instance_clients[instance_num], PRIORITY_SENSOR_DESCRIPTION, ) ] @@ -98,7 +96,7 @@ async def async_setup_entry( ), ) - listen_for_instance_updates(hass, config_entry, instance_add, instance_remove) + listen_for_instance_updates(hass, entry, instance_add, instance_remove) class HyperionSensor(SensorEntity): diff --git a/homeassistant/components/hyperion/switch.py b/homeassistant/components/hyperion/switch.py index 8b66783e889..c082c685304 100644 --- a/homeassistant/components/hyperion/switch.py +++ b/homeassistant/components/hyperion/switch.py @@ -26,7 +26,6 @@ from hyperion.const import ( ) from homeassistant.components.switch import SwitchEntity -from homeassistant.config_entries import ConfigEntry from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.device_registry import DeviceInfo @@ -38,12 +37,12 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.util import slugify from . import ( + HyperionConfigEntry, get_hyperion_device_id, get_hyperion_unique_id, listen_for_instance_updates, ) from .const import ( - CONF_INSTANCE_CLIENTS, DOMAIN, HYPERION_MANUFACTURER_NAME, HYPERION_MODEL_NAME, @@ -89,12 +88,11 @@ def _component_to_translation_key(component: str) -> str: async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + entry: HyperionConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up a Hyperion platform from config entry.""" - entry_data = hass.data[DOMAIN][config_entry.entry_id] - server_id = config_entry.unique_id + server_id = entry.unique_id @callback def instance_add(instance_num: int, instance_name: str) -> None: @@ -106,7 +104,7 @@ async def async_setup_entry( instance_num, instance_name, component, - entry_data[CONF_INSTANCE_CLIENTS][instance_num], + entry.runtime_data.instance_clients[instance_num], ) for component in COMPONENT_SWITCHES ) @@ -123,7 +121,7 @@ async def async_setup_entry( ), ) - listen_for_instance_updates(hass, config_entry, instance_add, instance_remove) + listen_for_instance_updates(hass, entry, instance_add, instance_remove) class HyperionComponentSwitch(SwitchEntity): diff --git a/homeassistant/components/intellifire/strings.json b/homeassistant/components/intellifire/strings.json index 423d2c0788d..7f53cb725b5 100644 --- a/homeassistant/components/intellifire/strings.json +++ b/homeassistant/components/intellifire/strings.json @@ -7,7 +7,7 @@ "description": "Select fireplace by serial number:" }, "cloud_api": { - "description": "Authenticate against IntelliFire Cloud", + "description": "Authenticate against IntelliFire cloud", "data_description": { "username": "Your IntelliFire app username", "password": "Your IntelliFire app password" @@ -45,7 +45,7 @@ "name": "Pilot flame error" }, "flame_error": { - "name": "Flame Error" + "name": "Flame error" }, "fan_delay_error": { "name": "Fan delay error" @@ -104,7 +104,7 @@ "name": "Target temperature" }, "fan_speed": { - "name": "Fan Speed" + "name": "Fan speed" }, "timer_end_timestamp": { "name": "Timer end" diff --git a/homeassistant/components/iron_os/coordinator.py b/homeassistant/components/iron_os/coordinator.py index 84c9b895766..46bbf2a4705 100644 --- a/homeassistant/components/iron_os/coordinator.py +++ b/homeassistant/components/iron_os/coordinator.py @@ -22,6 +22,7 @@ from pynecil import ( ) from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_NAME from homeassistant.core import HomeAssistant from homeassistant.exceptions import ServiceValidationError from homeassistant.helpers.debounce import Debouncer @@ -83,7 +84,11 @@ class IronOSBaseCoordinator[_DataT](DataUpdateCoordinator[_DataT]): self.device_info = await self.device.get_device_info() except CommunicationError as e: - raise UpdateFailed("Cannot connect to device") from e + raise UpdateFailed( + translation_domain=DOMAIN, + translation_key="cannot_connect", + translation_placeholders={CONF_NAME: self.config_entry.title}, + ) from e self.v223_features = AwesomeVersion(self.device_info.build) >= V223 @@ -108,7 +113,11 @@ class IronOSLiveDataCoordinator(IronOSBaseCoordinator[LiveDataResponse]): return await self.device.get_live_data() except CommunicationError as e: - raise UpdateFailed("Cannot connect to device") from e + raise UpdateFailed( + translation_domain=DOMAIN, + translation_key="cannot_connect", + translation_placeholders={CONF_NAME: self.config_entry.title}, + ) from e @property def has_tip(self) -> bool: @@ -187,4 +196,7 @@ class IronOSFirmwareUpdateCoordinator(DataUpdateCoordinator[LatestRelease]): try: return await self.github.latest_release() except UpdateException as e: - raise UpdateFailed("Failed to check for latest IronOS update") from e + raise UpdateFailed( + translation_domain=DOMAIN, + translation_key="update_check_failed", + ) from e diff --git a/homeassistant/components/iron_os/strings.json b/homeassistant/components/iron_os/strings.json index 22c194cf41f..4f455723006 100644 --- a/homeassistant/components/iron_os/strings.json +++ b/homeassistant/components/iron_os/strings.json @@ -284,6 +284,12 @@ }, "submit_setting_failed": { "message": "Failed to submit setting to device, try again later" + }, + "cannot_connect": { + "message": "Cannot connect to device {name}" + }, + "update_check_failed": { + "message": "Failed to check for latest IronOS update" } } } diff --git a/homeassistant/components/ista_ecotrend/__init__.py b/homeassistant/components/ista_ecotrend/__init__.py index 4262b354acb..e39850d6c51 100644 --- a/homeassistant/components/ista_ecotrend/__init__.py +++ b/homeassistant/components/ista_ecotrend/__init__.py @@ -4,11 +4,11 @@ from __future__ import annotations import logging -from pyecotrend_ista import KeycloakError, LoginError, PyEcotrendIsta, ServerError +from pyecotrend_ista import PyEcotrendIsta +from homeassistant.components.recorder import get_instance from homeassistant.const import CONF_EMAIL, CONF_PASSWORD, Platform from homeassistant.core import HomeAssistant -from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady from .const import DOMAIN from .coordinator import IstaConfigEntry, IstaCoordinator @@ -25,19 +25,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: IstaConfigEntry) -> bool entry.data[CONF_PASSWORD], _LOGGER, ) - try: - await hass.async_add_executor_job(ista.login) - except ServerError as e: - raise ConfigEntryNotReady( - translation_domain=DOMAIN, - translation_key="connection_exception", - ) from e - except (LoginError, KeycloakError) as e: - raise ConfigEntryAuthFailed( - translation_domain=DOMAIN, - translation_key="authentication_exception", - translation_placeholders={CONF_EMAIL: entry.data[CONF_EMAIL]}, - ) from e coordinator = IstaCoordinator(hass, entry, ista) await coordinator.async_config_entry_first_refresh() @@ -52,3 +39,9 @@ async def async_setup_entry(hass: HomeAssistant, entry: IstaConfigEntry) -> bool async def async_unload_entry(hass: HomeAssistant, entry: IstaConfigEntry) -> bool: """Unload a config entry.""" return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) + + +async def async_remove_entry(hass: HomeAssistant, entry: IstaConfigEntry) -> None: + """Handle removal of an entry.""" + statistic_ids = [f"{DOMAIN}:{name}" for name in entry.options.values()] + get_instance(hass).async_clear_statistics(statistic_ids) diff --git a/homeassistant/components/ista_ecotrend/config_flow.py b/homeassistant/components/ista_ecotrend/config_flow.py index 1a3b2109d0c..8c08d8d5ada 100644 --- a/homeassistant/components/ista_ecotrend/config_flow.py +++ b/homeassistant/components/ista_ecotrend/config_flow.py @@ -100,8 +100,19 @@ class IstaConfigFlow(ConfigFlow, domain=DOMAIN): user_input[CONF_PASSWORD], _LOGGER, ) + + def get_consumption_units() -> set[str]: + ista.login() + consumption_units = ista.get_consumption_unit_details()[ + "consumptionUnits" + ] + return {unit["id"] for unit in consumption_units} + try: - await self.hass.async_add_executor_job(ista.login) + consumption_units = await self.hass.async_add_executor_job( + get_consumption_units + ) + except ServerError: errors["base"] = "cannot_connect" except (LoginError, KeycloakError): @@ -110,6 +121,8 @@ class IstaConfigFlow(ConfigFlow, domain=DOMAIN): _LOGGER.exception("Unexpected exception") errors["base"] = "unknown" else: + if reauth_entry.unique_id not in consumption_units: + return self.async_abort(reason="unique_id_mismatch") return self.async_update_reload_and_abort(reauth_entry, data=user_input) return self.async_show_form( diff --git a/homeassistant/components/ista_ecotrend/coordinator.py b/homeassistant/components/ista_ecotrend/coordinator.py index 53ef4a46d20..13167b9d06c 100644 --- a/homeassistant/components/ista_ecotrend/coordinator.py +++ b/homeassistant/components/ista_ecotrend/coordinator.py @@ -11,7 +11,7 @@ from pyecotrend_ista import KeycloakError, LoginError, PyEcotrendIsta, ServerErr from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_EMAIL from homeassistant.core import HomeAssistant -from homeassistant.exceptions import ConfigEntryAuthFailed +from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from .const import DOMAIN @@ -25,6 +25,7 @@ class IstaCoordinator(DataUpdateCoordinator[dict[str, Any]]): """Ista EcoTrend data update coordinator.""" config_entry: IstaConfigEntry + details: dict[str, Any] def __init__( self, hass: HomeAssistant, config_entry: IstaConfigEntry, ista: PyEcotrendIsta @@ -38,22 +39,35 @@ class IstaCoordinator(DataUpdateCoordinator[dict[str, Any]]): update_interval=timedelta(days=1), ) self.ista = ista - self.details: dict[str, Any] = {} + + async def _async_setup(self) -> None: + """Set up the ista EcoTrend coordinator.""" + + try: + self.details = await self.hass.async_add_executor_job(self.get_details) + except ServerError as e: + raise ConfigEntryNotReady( + translation_domain=DOMAIN, + translation_key="connection_exception", + ) from e + except (LoginError, KeycloakError) as e: + raise ConfigEntryAuthFailed( + translation_domain=DOMAIN, + translation_key="authentication_exception", + translation_placeholders={ + CONF_EMAIL: self.config_entry.data[CONF_EMAIL] + }, + ) from e async def _async_update_data(self): """Fetch ista EcoTrend data.""" try: - await self.hass.async_add_executor_job(self.ista.login) - - if not self.details: - self.details = await self.async_get_details() - return await self.hass.async_add_executor_job(self.get_consumption_data) - except ServerError as e: raise UpdateFailed( - "Unable to connect and retrieve data from ista EcoTrend, try again later" + translation_domain=DOMAIN, + translation_key="connection_exception", ) from e except (LoginError, KeycloakError) as e: raise ConfigEntryAuthFailed( @@ -67,17 +81,17 @@ class IstaCoordinator(DataUpdateCoordinator[dict[str, Any]]): def get_consumption_data(self) -> dict[str, Any]: """Get raw json data for all consumption units.""" + self.ista.login() return { consumption_unit: self.ista.get_consumption_data(consumption_unit) for consumption_unit in self.ista.get_uuids() } - async def async_get_details(self) -> dict[str, Any]: + def get_details(self) -> dict[str, Any]: """Retrieve details of consumption units.""" - result = await self.hass.async_add_executor_job( - self.ista.get_consumption_unit_details - ) + self.ista.login() + result = self.ista.get_consumption_unit_details() return { consumption_unit: next( diff --git a/homeassistant/components/ista_ecotrend/diagnostics.py b/homeassistant/components/ista_ecotrend/diagnostics.py new file mode 100644 index 00000000000..4c61c197b5e --- /dev/null +++ b/homeassistant/components/ista_ecotrend/diagnostics.py @@ -0,0 +1,33 @@ +"""Diagnostics platform for ista EcoTrend integration.""" + +from __future__ import annotations + +from typing import Any + +from homeassistant.components.diagnostics import async_redact_data +from homeassistant.core import HomeAssistant + +from .coordinator import IstaConfigEntry + +TO_REDACT = { + "firstName", + "lastName", + "street", + "houseNumber", + "documentNumber", + "postalCode", + "city", + "propertyNumber", + "idAtCustomerUser", +} + + +async def async_get_config_entry_diagnostics( + hass: HomeAssistant, config_entry: IstaConfigEntry +) -> dict[str, Any]: + """Return diagnostics for a config entry.""" + + return { + "details": async_redact_data(config_entry.runtime_data.details, TO_REDACT), + "data": async_redact_data(config_entry.runtime_data.data, TO_REDACT), + } diff --git a/homeassistant/components/ista_ecotrend/quality_scale.yaml b/homeassistant/components/ista_ecotrend/quality_scale.yaml index b942ecba487..33cf24592b3 100644 --- a/homeassistant/components/ista_ecotrend/quality_scale.yaml +++ b/homeassistant/components/ista_ecotrend/quality_scale.yaml @@ -5,12 +5,8 @@ rules: comment: The integration registers no actions. appropriate-polling: done brands: done - common-modules: - status: todo - comment: Group the 3 different executor jobs as one executor job - config-flow-test-coverage: - status: todo - comment: test_form/docstrings outdated, test already_configuret, test abort conditions in reauth, + common-modules: done + config-flow-test-coverage: done config-flow: done dependency-transparency: done docs-actions: @@ -47,7 +43,7 @@ rules: # Gold devices: done - diagnostics: todo + diagnostics: done discovery-update-info: status: exempt comment: The integration is a web service, there are no discoverable devices. diff --git a/homeassistant/components/ista_ecotrend/strings.json b/homeassistant/components/ista_ecotrend/strings.json index e7c37461b19..466969f9ba0 100644 --- a/homeassistant/components/ista_ecotrend/strings.json +++ b/homeassistant/components/ista_ecotrend/strings.json @@ -2,7 +2,8 @@ "config": { "abort": { "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", - "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]" + "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]", + "unique_id_mismatch": "The login details correspond to a different account. Please re-authenticate to the previously configured account." }, "error": { "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", diff --git a/homeassistant/components/jellyfin/browse_media.py b/homeassistant/components/jellyfin/browse_media.py index e5648b0a34f..9eee4bbb363 100644 --- a/homeassistant/components/jellyfin/browse_media.py +++ b/homeassistant/components/jellyfin/browse_media.py @@ -73,7 +73,7 @@ async def build_root_response( children = [ await item_payload(hass, client, user_id, folder) for folder in folders["Items"] - if folder["CollectionType"] in SUPPORTED_COLLECTION_TYPES + if folder.get("CollectionType") in SUPPORTED_COLLECTION_TYPES ] return BrowseMedia( diff --git a/homeassistant/components/jvc_projector/strings.json b/homeassistant/components/jvc_projector/strings.json index c6e5736bd2d..ab17ef6e8ff 100644 --- a/homeassistant/components/jvc_projector/strings.json +++ b/homeassistant/components/jvc_projector/strings.json @@ -56,7 +56,7 @@ "on": "[%key:common::state::on%]", "warming": "Warming", "cooling": "Cooling", - "error": "Error" + "error": "[%key:common::state::error%]" } } } diff --git a/homeassistant/components/lamarzocco/coordinator.py b/homeassistant/components/lamarzocco/coordinator.py index a8b3d9d0ee7..a83f7e6ab76 100644 --- a/homeassistant/components/lamarzocco/coordinator.py +++ b/homeassistant/components/lamarzocco/coordinator.py @@ -19,7 +19,7 @@ from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, Upda from .const import DOMAIN -SCAN_INTERVAL = timedelta(seconds=30) +SCAN_INTERVAL = timedelta(seconds=15) SETTINGS_UPDATE_INTERVAL = timedelta(hours=1) SCHEDULE_UPDATE_INTERVAL = timedelta(minutes=5) _LOGGER = logging.getLogger(__name__) @@ -82,35 +82,32 @@ class LaMarzoccoUpdateCoordinator(DataUpdateCoordinator[None]): class LaMarzoccoConfigUpdateCoordinator(LaMarzoccoUpdateCoordinator): """Class to handle fetching data from the La Marzocco API centrally.""" - async def _async_connect_websocket(self) -> None: - """Set up the coordinator.""" - if not self.device.websocket.connected: - _LOGGER.debug("Init WebSocket in background task") - - self.config_entry.async_create_background_task( - hass=self.hass, - target=self.device.connect_dashboard_websocket( - update_callback=lambda _: self.async_set_updated_data(None) - ), - name="lm_websocket_task", - ) - - async def websocket_close(_: Any | None = None) -> None: - if self.device.websocket.connected: - await self.device.websocket.disconnect() - - self.config_entry.async_on_unload( - self.hass.bus.async_listen_once( - EVENT_HOMEASSISTANT_STOP, websocket_close - ) - ) - self.config_entry.async_on_unload(websocket_close) - async def _internal_async_update_data(self) -> None: """Fetch data from API endpoint.""" + + if self.device.websocket.connected: + return await self.device.get_dashboard() _LOGGER.debug("Current status: %s", self.device.dashboard.to_dict()) - await self._async_connect_websocket() + + _LOGGER.debug("Init WebSocket in background task") + + self.config_entry.async_create_background_task( + hass=self.hass, + target=self.device.connect_dashboard_websocket( + update_callback=lambda _: self.async_set_updated_data(None) + ), + name="lm_websocket_task", + ) + + async def websocket_close(_: Any | None = None) -> None: + if self.device.websocket.connected: + await self.device.websocket.disconnect() + + self.config_entry.async_on_unload( + self.hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, websocket_close) + ) + self.config_entry.async_on_unload(websocket_close) class LaMarzoccoSettingsUpdateCoordinator(LaMarzoccoUpdateCoordinator): diff --git a/homeassistant/components/lamarzocco/icons.json b/homeassistant/components/lamarzocco/icons.json index 2964f48ecbd..8ea764b4d18 100644 --- a/homeassistant/components/lamarzocco/icons.json +++ b/homeassistant/components/lamarzocco/icons.json @@ -76,6 +76,9 @@ "coffee_boiler_ready_time": { "default": "mdi:av-timer" }, + "last_cleaning_time": { + "default": "mdi:spray-bottle" + }, "steam_boiler_ready_time": { "default": "mdi:av-timer" } diff --git a/homeassistant/components/lamarzocco/manifest.json b/homeassistant/components/lamarzocco/manifest.json index 3053056a2d0..97ee68d185d 100644 --- a/homeassistant/components/lamarzocco/manifest.json +++ b/homeassistant/components/lamarzocco/manifest.json @@ -37,5 +37,5 @@ "iot_class": "cloud_push", "loggers": ["pylamarzocco"], "quality_scale": "platinum", - "requirements": ["pylamarzocco==2.0.0b1"] + "requirements": ["pylamarzocco==2.0.0b3"] } diff --git a/homeassistant/components/lamarzocco/sensor.py b/homeassistant/components/lamarzocco/sensor.py index 17f11534483..9c1214835fa 100644 --- a/homeassistant/components/lamarzocco/sensor.py +++ b/homeassistant/components/lamarzocco/sensor.py @@ -7,6 +7,7 @@ from typing import cast from pylamarzocco.const import ModelName, WidgetType from pylamarzocco.models import ( + BackFlush, BaseWidgetOutput, CoffeeBoiler, SteamBoilerLevel, @@ -84,6 +85,17 @@ ENTITIES: tuple[LaMarzoccoSensorEntityDescription, ...] = ( in (ModelName.GS3_AV, ModelName.GS3_MP, ModelName.LINEA_MINI) ), ), + LaMarzoccoSensorEntityDescription( + key="last_cleaning_time", + translation_key="last_cleaning_time", + device_class=SensorDeviceClass.TIMESTAMP, + value_fn=( + lambda config: cast( + BackFlush, config[WidgetType.CM_BACK_FLUSH] + ).last_cleaning_start_time + ), + entity_category=EntityCategory.DIAGNOSTIC, + ), ) diff --git a/homeassistant/components/lamarzocco/strings.json b/homeassistant/components/lamarzocco/strings.json index 7a77b8ad72c..9b153b5707e 100644 --- a/homeassistant/components/lamarzocco/strings.json +++ b/homeassistant/components/lamarzocco/strings.json @@ -146,6 +146,9 @@ }, "steam_boiler_ready_time": { "name": "Steam boiler ready time" + }, + "last_cleaning_time": { + "name": "Last cleaning time" } }, "switch": { diff --git a/homeassistant/components/lcn/__init__.py b/homeassistant/components/lcn/__init__.py index 256e132b30d..b3d2c14794c 100644 --- a/homeassistant/components/lcn/__init__.py +++ b/homeassistant/components/lcn/__init__.py @@ -24,12 +24,17 @@ from homeassistant.const import ( CONF_IP_ADDRESS, CONF_PASSWORD, CONF_PORT, + CONF_RESOURCE, CONF_USERNAME, Platform, ) -from homeassistant.core import HomeAssistant +from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import ConfigEntryNotReady -from homeassistant.helpers import config_validation as cv, device_registry as dr +from homeassistant.helpers import ( + config_validation as cv, + device_registry as dr, + entity_registry as er, +) from homeassistant.helpers.typing import ConfigType from .const import ( @@ -38,6 +43,7 @@ from .const import ( CONF_DIM_MODE, CONF_DOMAIN_DATA, CONF_SK_NUM_TRIES, + CONF_TARGET_VALUE_LOCKED, CONF_TRANSITION, CONNECTION, DEVICE_CONNECTIONS, @@ -155,6 +161,7 @@ async def async_migrate_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> if config_entry.minor_version < 2: new_data[CONF_ACKNOWLEDGE] = False + if config_entry.version < 2: # update to 2.1 (fix transitions for lights and switches) new_entities_data = [*new_data[CONF_ENTITIES]] for entity in new_entities_data: @@ -164,8 +171,19 @@ async def async_migrate_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> entity[CONF_DOMAIN_DATA][CONF_TRANSITION] /= 1000.0 new_data[CONF_ENTITIES] = new_entities_data + if config_entry.version < 3: + # update to 3.1 (remove resource parameter, add climate target lock value parameter) + for entity in new_data[CONF_ENTITIES]: + entity.pop(CONF_RESOURCE, None) + + if entity[CONF_DOMAIN] == Platform.CLIMATE: + entity[CONF_DOMAIN_DATA].setdefault(CONF_TARGET_VALUE_LOCKED, -1) + + # migrate climate and scene unique ids + await async_migrate_entities(hass, config_entry) + hass.config_entries.async_update_entry( - config_entry, data=new_data, minor_version=1, version=2 + config_entry, data=new_data, minor_version=1, version=3 ) _LOGGER.debug( @@ -176,6 +194,29 @@ async def async_migrate_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> return True +async def async_migrate_entities( + hass: HomeAssistant, config_entry: ConfigEntry +) -> None: + """Migrate entity registry.""" + + @callback + def update_unique_id(entity_entry: er.RegistryEntry) -> dict[str, str] | None: + """Update unique ID of entity entry.""" + # fix unique entity ids for climate and scene + if "." in entity_entry.unique_id: + if entity_entry.domain == Platform.CLIMATE: + setpoint = entity_entry.unique_id.split(".")[-1] + return { + "new_unique_id": entity_entry.unique_id.rsplit("-", 1)[0] + + f"-{setpoint}" + } + if entity_entry.domain == Platform.SCENE: + return {"new_unique_id": entity_entry.unique_id.replace(".", "")} + return None + + await er.async_migrate_entries(hass, config_entry.entry_id, update_unique_id) + + async def async_unload_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool: """Close connection to PCHK host represented by config_entry.""" # forward unloading to platforms diff --git a/homeassistant/components/lcn/config_flow.py b/homeassistant/components/lcn/config_flow.py index 63e0d8c8b26..946c7ac3724 100644 --- a/homeassistant/components/lcn/config_flow.py +++ b/homeassistant/components/lcn/config_flow.py @@ -110,7 +110,7 @@ async def validate_connection(data: ConfigType) -> str | None: class LcnFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): """Handle a LCN config flow.""" - VERSION = 2 + VERSION = 3 MINOR_VERSION = 1 async def async_step_user( diff --git a/homeassistant/components/lcn/entity.py b/homeassistant/components/lcn/entity.py index ffb680c4237..24897287449 100644 --- a/homeassistant/components/lcn/entity.py +++ b/homeassistant/components/lcn/entity.py @@ -3,18 +3,19 @@ from collections.abc import Callable from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_ADDRESS, CONF_NAME, CONF_RESOURCE +from homeassistant.const import CONF_ADDRESS, CONF_DOMAIN, CONF_NAME from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity import Entity from homeassistant.helpers.typing import ConfigType -from .const import DOMAIN +from .const import CONF_DOMAIN_DATA, DOMAIN from .helpers import ( AddressType, DeviceConnectionType, InputType, generate_unique_id, get_device_connection, + get_resource, ) @@ -48,7 +49,11 @@ class LcnEntity(Entity): def unique_id(self) -> str: """Return a unique ID.""" return generate_unique_id( - self.config_entry.entry_id, self.address, self.config[CONF_RESOURCE] + self.config_entry.entry_id, + self.address, + get_resource( + self.config[CONF_DOMAIN], self.config[CONF_DOMAIN_DATA] + ).lower(), ) async def async_added_to_hass(self) -> None: diff --git a/homeassistant/components/lcn/helpers.py b/homeassistant/components/lcn/helpers.py index 2176c669251..a2796f88368 100644 --- a/homeassistant/components/lcn/helpers.py +++ b/homeassistant/components/lcn/helpers.py @@ -19,7 +19,6 @@ from homeassistant.const import ( CONF_ENTITIES, CONF_LIGHTS, CONF_NAME, - CONF_RESOURCE, CONF_SENSORS, CONF_SWITCHES, ) @@ -29,6 +28,7 @@ from homeassistant.helpers.typing import ConfigType from .const import ( CONF_CLIMATES, + CONF_DOMAIN_DATA, CONF_HARDWARE_SERIAL, CONF_HARDWARE_TYPE, CONF_SCENES, @@ -79,9 +79,9 @@ def get_resource(domain_name: str, domain_data: ConfigType) -> str: if domain_name == "cover": return cast(str, domain_data["motor"]) if domain_name == "climate": - return f"{domain_data['source']}.{domain_data['setpoint']}" + return cast(str, domain_data["setpoint"]) if domain_name == "scene": - return f"{domain_data['register']}.{domain_data['scene']}" + return f"{domain_data['register']}{domain_data['scene']}" raise ValueError("Unknown domain") @@ -115,7 +115,9 @@ def purge_entity_registry( references_entry_data = set() for entity_data in imported_entry_data[CONF_ENTITIES]: entity_unique_id = generate_unique_id( - entry_id, entity_data[CONF_ADDRESS], entity_data[CONF_RESOURCE] + entry_id, + entity_data[CONF_ADDRESS], + get_resource(entity_data[CONF_DOMAIN], entity_data[CONF_DOMAIN_DATA]), ) entity_id = entity_registry.async_get_entity_id( entity_data[CONF_DOMAIN], DOMAIN, entity_unique_id diff --git a/homeassistant/components/lcn/manifest.json b/homeassistant/components/lcn/manifest.json index c1dd7751940..e5313eee4f3 100644 --- a/homeassistant/components/lcn/manifest.json +++ b/homeassistant/components/lcn/manifest.json @@ -8,5 +8,5 @@ "documentation": "https://www.home-assistant.io/integrations/lcn", "iot_class": "local_push", "loggers": ["pypck"], - "requirements": ["pypck==0.8.5", "lcn-frontend==0.2.3"] + "requirements": ["pypck==0.8.5", "lcn-frontend==0.2.4"] } diff --git a/homeassistant/components/lcn/websocket.py b/homeassistant/components/lcn/websocket.py index 9084ec838d9..545ee1e0043 100644 --- a/homeassistant/components/lcn/websocket.py +++ b/homeassistant/components/lcn/websocket.py @@ -19,7 +19,6 @@ from homeassistant.const import ( CONF_DOMAIN, CONF_ENTITIES, CONF_NAME, - CONF_RESOURCE, ) from homeassistant.core import HomeAssistant from homeassistant.helpers import ( @@ -343,7 +342,6 @@ async def websocket_add_entity( entity_config = { CONF_ADDRESS: msg[CONF_ADDRESS], CONF_NAME: msg[CONF_NAME], - CONF_RESOURCE: resource, CONF_DOMAIN: domain_name, CONF_DOMAIN_DATA: domain_data, } @@ -371,7 +369,15 @@ async def websocket_add_entity( vol.Required("entry_id"): cv.string, vol.Required(CONF_ADDRESS): ADDRESS_SCHEMA, vol.Required(CONF_DOMAIN): cv.string, - vol.Required(CONF_RESOURCE): cv.string, + vol.Required(CONF_DOMAIN_DATA): vol.Any( + DOMAIN_DATA_BINARY_SENSOR, + DOMAIN_DATA_SENSOR, + DOMAIN_DATA_SWITCH, + DOMAIN_DATA_LIGHT, + DOMAIN_DATA_CLIMATE, + DOMAIN_DATA_COVER, + DOMAIN_DATA_SCENE, + ), } ) @websocket_api.async_response @@ -390,7 +396,10 @@ async def websocket_delete_entity( if ( tuple(entity_config[CONF_ADDRESS]) == msg[CONF_ADDRESS] and entity_config[CONF_DOMAIN] == msg[CONF_DOMAIN] - and entity_config[CONF_RESOURCE] == msg[CONF_RESOURCE] + and get_resource( + entity_config[CONF_DOMAIN], entity_config[CONF_DOMAIN_DATA] + ) + == get_resource(msg[CONF_DOMAIN], msg[CONF_DOMAIN_DATA]) ) ), None, diff --git a/homeassistant/components/lektrico/strings.json b/homeassistant/components/lektrico/strings.json index eb223b4758b..23aac0b3059 100644 --- a/homeassistant/components/lektrico/strings.json +++ b/homeassistant/components/lektrico/strings.json @@ -88,7 +88,7 @@ "available": "Available", "charging": "[%key:common::state::charging%]", "connected": "[%key:common::state::connected%]", - "error": "Error", + "error": "[%key:common::state::error%]", "locked": "[%key:common::state::locked%]", "need_auth": "Waiting for authentication", "paused": "[%key:common::state::paused%]", @@ -118,7 +118,7 @@ "ocpp": "OCPP", "overtemperature": "Overtemperature", "switching_phases": "Switching phases", - "1p_charging_disabled": "1p charging disabled" + "1p_charging_disabled": "1P charging disabled" } }, "breaker_current": { diff --git a/homeassistant/components/linkplay/manifest.json b/homeassistant/components/linkplay/manifest.json index b57a7b68881..69a7b71eeb6 100644 --- a/homeassistant/components/linkplay/manifest.json +++ b/homeassistant/components/linkplay/manifest.json @@ -7,6 +7,6 @@ "integration_type": "hub", "iot_class": "local_polling", "loggers": ["linkplay"], - "requirements": ["python-linkplay==0.2.3"], + "requirements": ["python-linkplay==0.2.4"], "zeroconf": ["_linkplay._tcp.local."] } diff --git a/homeassistant/components/linkplay/media_player.py b/homeassistant/components/linkplay/media_player.py index 16b0d5f75f1..67aa424e3a2 100644 --- a/homeassistant/components/linkplay/media_player.py +++ b/homeassistant/components/linkplay/media_player.py @@ -2,6 +2,7 @@ from __future__ import annotations +from datetime import timedelta import logging from typing import Any @@ -120,6 +121,8 @@ SERVICE_PLAY_PRESET_SCHEMA = cv.make_entity_service_schema( ) RETRY_POLL_MAXIMUM = 3 +SCAN_INTERVAL = timedelta(seconds=5) +PARALLEL_UPDATES = 1 async def async_setup_entry( diff --git a/homeassistant/components/local_file/camera.py b/homeassistant/components/local_file/camera.py index 8be0389678d..4544f69dbee 100644 --- a/homeassistant/components/local_file/camera.py +++ b/homeassistant/components/local_file/camera.py @@ -7,38 +7,19 @@ import mimetypes import voluptuous as vol -from homeassistant.components.camera import ( - PLATFORM_SCHEMA as CAMERA_PLATFORM_SCHEMA, - Camera, -) -from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry +from homeassistant.components.camera import Camera +from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_FILE_PATH, CONF_NAME -from homeassistant.core import DOMAIN as HOMEASSISTANT_DOMAIN, HomeAssistant +from homeassistant.core import HomeAssistant from homeassistant.exceptions import ServiceValidationError -from homeassistant.helpers import ( - config_validation as cv, - entity_platform, - issue_registry as ir, -) -from homeassistant.helpers.entity_platform import ( - AddConfigEntryEntitiesCallback, - AddEntitiesCallback, -) -from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType -from homeassistant.util import slugify +from homeassistant.helpers import config_validation as cv, entity_platform +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from .const import DEFAULT_NAME, DOMAIN, SERVICE_UPDATE_FILE_PATH +from .const import SERVICE_UPDATE_FILE_PATH from .util import check_file_path_access _LOGGER = logging.getLogger(__name__) -PLATFORM_SCHEMA = CAMERA_PLATFORM_SCHEMA.extend( - { - vol.Required(CONF_FILE_PATH): cv.string, - vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, - } -) - async def async_setup_entry( hass: HomeAssistant, @@ -67,57 +48,6 @@ async def async_setup_entry( ) -async def async_setup_platform( - hass: HomeAssistant, - config: ConfigType, - async_add_entities: AddEntitiesCallback, - discovery_info: DiscoveryInfoType | None = None, -) -> None: - """Set up the Camera that works with local files.""" - file_path: str = config[CONF_FILE_PATH] - file_path_slug = slugify(file_path) - - if not await hass.async_add_executor_job(check_file_path_access, file_path): - ir.async_create_issue( - hass, - DOMAIN, - f"no_access_path_{file_path_slug}", - breaks_in_ha_version="2025.5.0", - is_fixable=False, - learn_more_url="https://www.home-assistant.io/integrations/local_file/", - severity=ir.IssueSeverity.WARNING, - translation_key="no_access_path", - translation_placeholders={ - "file_path": file_path_slug, - }, - ) - return - - ir.async_create_issue( - hass, - HOMEASSISTANT_DOMAIN, - f"deprecated_yaml_{DOMAIN}", - breaks_in_ha_version="2025.5.0", - is_fixable=False, - issue_domain=DOMAIN, - learn_more_url="https://www.home-assistant.io/integrations/local_file/", - severity=ir.IssueSeverity.WARNING, - translation_key="deprecated_yaml", - translation_placeholders={ - "domain": DOMAIN, - "integration_title": "Local file", - }, - ) - - hass.async_create_task( - hass.config_entries.flow.async_init( - DOMAIN, - context={"source": SOURCE_IMPORT}, - data=config, - ) - ) - - class LocalFile(Camera): """Representation of a local file camera.""" diff --git a/homeassistant/components/local_file/config_flow.py b/homeassistant/components/local_file/config_flow.py index 36a41c03543..c4b83f9407a 100644 --- a/homeassistant/components/local_file/config_flow.py +++ b/homeassistant/components/local_file/config_flow.py @@ -50,18 +50,12 @@ DATA_SCHEMA_SETUP = vol.Schema( CONFIG_FLOW = { "user": SchemaFlowFormStep( - schema=DATA_SCHEMA_SETUP, - validate_user_input=validate_options, - ), - "import": SchemaFlowFormStep( - schema=DATA_SCHEMA_SETUP, - validate_user_input=validate_options, - ), + schema=DATA_SCHEMA_SETUP, validate_user_input=validate_options + ) } OPTIONS_FLOW = { "init": SchemaFlowFormStep( - DATA_SCHEMA_OPTIONS, - validate_user_input=validate_options, + DATA_SCHEMA_OPTIONS, validate_user_input=validate_options ) } diff --git a/homeassistant/components/local_file/strings.json b/homeassistant/components/local_file/strings.json index 393cc5f2e46..ebf4c9d7fbf 100644 --- a/homeassistant/components/local_file/strings.json +++ b/homeassistant/components/local_file/strings.json @@ -53,11 +53,5 @@ "file_path_not_accessible": { "message": "Path {file_path} is not accessible" } - }, - "issues": { - "no_access_path": { - "title": "Incorrect file path", - "description": "While trying to import your configuration the provided file path {file_path} could not be read.\nPlease update your configuration to a correct file path and restart to fix this issue." - } } } diff --git a/homeassistant/components/logger/__init__.py b/homeassistant/components/logger/__init__.py index 15283b246b2..8593b3c478e 100644 --- a/homeassistant/components/logger/__init__.py +++ b/homeassistant/components/logger/__init__.py @@ -24,8 +24,10 @@ from .const import ( SERVICE_SET_LEVEL, ) from .helpers import ( + DATA_LOGGER, LoggerDomainConfig, LoggerSettings, + _clear_logger_overwrites, # noqa: F401 set_default_log_level, set_log_levels, ) @@ -54,7 +56,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: settings = LoggerSettings(hass, config) - domain_config = hass.data[DOMAIN] = LoggerDomainConfig({}, settings) + domain_config = hass.data[DATA_LOGGER] = LoggerDomainConfig({}, settings) logging.setLoggerClass(_get_logger_class(domain_config.overrides)) websocket_api.async_load_websocket_api(hass) diff --git a/homeassistant/components/logger/helpers.py b/homeassistant/components/logger/helpers.py index 00cea7e8aa5..19afe18e3fe 100644 --- a/homeassistant/components/logger/helpers.py +++ b/homeassistant/components/logger/helpers.py @@ -9,13 +9,14 @@ from dataclasses import asdict, dataclass from enum import StrEnum from functools import lru_cache import logging -from typing import Any, cast +from typing import Any from homeassistant.const import EVENT_LOGGING_CHANGED from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.storage import Store from homeassistant.helpers.typing import ConfigType from homeassistant.loader import IntegrationNotFound, async_get_integration +from homeassistant.util.hass_dict import HassKey from .const import ( DOMAIN, @@ -28,6 +29,8 @@ from .const import ( STORAGE_VERSION, ) +DATA_LOGGER: HassKey[LoggerDomainConfig] = HassKey(DOMAIN) + SAVE_DELAY = 15.0 # At startup, we want to save after a long delay to avoid # saving while the system is still starting up. If the system @@ -39,12 +42,6 @@ SAVE_DELAY = 15.0 SAVE_DELAY_LONG = 180.0 -@callback -def async_get_domain_config(hass: HomeAssistant) -> LoggerDomainConfig: - """Return the domain config.""" - return cast(LoggerDomainConfig, hass.data[DOMAIN]) - - @callback def set_default_log_level(hass: HomeAssistant, level: int) -> None: """Set the default log level for components.""" @@ -55,7 +52,7 @@ def set_default_log_level(hass: HomeAssistant, level: int) -> None: @callback def set_log_levels(hass: HomeAssistant, logpoints: Mapping[str, int]) -> None: """Set the specified log levels.""" - async_get_domain_config(hass).overrides.update(logpoints) + hass.data[DATA_LOGGER].overrides.update(logpoints) for key, value in logpoints.items(): _set_log_level(logging.getLogger(key), value) hass.bus.async_fire(EVENT_LOGGING_CHANGED) @@ -78,6 +75,12 @@ def _chattiest_log_level(level1: int, level2: int) -> int: return min(level1, level2) +@callback +def _clear_logger_overwrites(hass: HomeAssistant) -> None: + """Clear logger overwrites. Used for testing.""" + hass.data[DATA_LOGGER].overrides.clear() + + async def get_integration_loggers(hass: HomeAssistant, domain: str) -> set[str]: """Get loggers for an integration.""" loggers: set[str] = {f"homeassistant.components.{domain}"} diff --git a/homeassistant/components/logger/websocket_api.py b/homeassistant/components/logger/websocket_api.py index 2430f187a6f..041fe417698 100644 --- a/homeassistant/components/logger/websocket_api.py +++ b/homeassistant/components/logger/websocket_api.py @@ -12,10 +12,10 @@ from homeassistant.setup import async_get_loaded_integrations from .const import LOGSEVERITY from .helpers import ( + DATA_LOGGER, LoggerSetting, LogPersistance, LogSettingsType, - async_get_domain_config, get_logger, ) @@ -68,7 +68,7 @@ async def handle_integration_log_level( msg["id"], websocket_api.ERR_NOT_FOUND, "Integration not found" ) return - await async_get_domain_config(hass).settings.async_update( + await hass.data[DATA_LOGGER].settings.async_update( hass, msg["integration"], LoggerSetting( @@ -93,7 +93,7 @@ async def handle_module_log_level( hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any] ) -> None: """Handle setting integration log level.""" - await async_get_domain_config(hass).settings.async_update( + await hass.data[DATA_LOGGER].settings.async_update( hass, msg["module"], LoggerSetting( diff --git a/homeassistant/components/matter/strings.json b/homeassistant/components/matter/strings.json index f6e7187f8c0..fedb026bf25 100644 --- a/homeassistant/components/matter/strings.json +++ b/homeassistant/components/matter/strings.json @@ -270,7 +270,7 @@ "stopped": "[%key:common::state::stopped%]", "running": "Running", "paused": "[%key:common::state::paused%]", - "error": "Error", + "error": "[%key:common::state::error%]", "seeking_charger": "Seeking charger", "charging": "[%key:common::state::charging%]", "docked": "Docked" diff --git a/homeassistant/components/met/coordinator.py b/homeassistant/components/met/coordinator.py index de27da7a07f..8b6243d9daf 100644 --- a/homeassistant/components/met/coordinator.py +++ b/homeassistant/components/met/coordinator.py @@ -2,11 +2,10 @@ from __future__ import annotations -from collections.abc import Callable +from collections.abc import Callable, Mapping from datetime import timedelta import logging from random import randrange -from types import MappingProxyType from typing import Any, Self import metno @@ -41,7 +40,7 @@ class CannotConnect(HomeAssistantError): class MetWeatherData: """Keep data for Met.no weather entities.""" - def __init__(self, hass: HomeAssistant, config: MappingProxyType[str, Any]) -> None: + def __init__(self, hass: HomeAssistant, config: Mapping[str, Any]) -> None: """Initialise the weather entity data.""" self.hass = hass self._config = config diff --git a/homeassistant/components/met/weather.py b/homeassistant/components/met/weather.py index c4f9c8e6885..8d8317607be 100644 --- a/homeassistant/components/met/weather.py +++ b/homeassistant/components/met/weather.py @@ -2,7 +2,7 @@ from __future__ import annotations -from types import MappingProxyType +from collections.abc import Mapping from typing import TYPE_CHECKING, Any from homeassistant.components.weather import ( @@ -82,7 +82,7 @@ async def async_setup_entry( async_add_entities(entities) -def _calculate_unique_id(config: MappingProxyType[str, Any], hourly: bool) -> str: +def _calculate_unique_id(config: Mapping[str, Any], hourly: bool) -> str: """Calculate unique ID.""" name_appendix = "" if hourly: diff --git a/homeassistant/components/met_eireann/__init__.py b/homeassistant/components/met_eireann/__init__.py index 01917707bf7..62d7d21134c 100644 --- a/homeassistant/components/met_eireann/__init__.py +++ b/homeassistant/components/met_eireann/__init__.py @@ -1,8 +1,8 @@ """The met_eireann component.""" +from collections.abc import Mapping from datetime import timedelta import logging -from types import MappingProxyType from typing import Any, Self import meteireann @@ -74,7 +74,7 @@ class MetEireannWeatherData: """Keep data for Met Éireann weather entities.""" def __init__( - self, config: MappingProxyType[str, Any], weather_data: meteireann.WeatherData + self, config: Mapping[str, Any], weather_data: meteireann.WeatherData ) -> None: """Initialise the weather entity data.""" self._config = config diff --git a/homeassistant/components/met_eireann/weather.py b/homeassistant/components/met_eireann/weather.py index 72706ccb70f..97bbd952740 100644 --- a/homeassistant/components/met_eireann/weather.py +++ b/homeassistant/components/met_eireann/weather.py @@ -1,7 +1,7 @@ """Support for Met Éireann weather service.""" +from collections.abc import Mapping import logging -from types import MappingProxyType from typing import Any, cast from homeassistant.components.weather import ( @@ -64,7 +64,7 @@ async def async_setup_entry( async_add_entities([MetEireannWeather(coordinator, config_entry.data)]) -def _calculate_unique_id(config: MappingProxyType[str, Any], hourly: bool) -> str: +def _calculate_unique_id(config: Mapping[str, Any], hourly: bool) -> str: """Calculate unique ID.""" name_appendix = "" if hourly: @@ -90,7 +90,7 @@ class MetEireannWeather( def __init__( self, coordinator: DataUpdateCoordinator[MetEireannWeatherData], - config: MappingProxyType[str, Any], + config: Mapping[str, Any], ) -> None: """Initialise the platform with a data instance and site.""" super().__init__(coordinator) diff --git a/homeassistant/components/meteo_france/const.py b/homeassistant/components/meteo_france/const.py index e64a55651d3..382a56d50d7 100644 --- a/homeassistant/components/meteo_france/const.py +++ b/homeassistant/components/meteo_france/const.py @@ -40,7 +40,7 @@ ATTR_NEXT_RAIN_DT_REF = "forecast_time_ref" CONDITION_CLASSES: dict[str, list[str]] = { - ATTR_CONDITION_CLEAR_NIGHT: ["Nuit Claire", "Nuit claire"], + ATTR_CONDITION_CLEAR_NIGHT: ["Nuit Claire", "Nuit claire", "Ciel clair"], ATTR_CONDITION_CLOUDY: ["Très nuageux", "Couvert"], ATTR_CONDITION_FOG: [ "Brume ou bancs de brouillard", @@ -48,9 +48,10 @@ CONDITION_CLASSES: dict[str, list[str]] = { "Brouillard", "Brouillard givrant", "Bancs de Brouillard", + "Brouillard dense", ], ATTR_CONDITION_HAIL: ["Risque de grêle", "Risque de grèle"], - ATTR_CONDITION_LIGHTNING: ["Risque d'orages", "Orages"], + ATTR_CONDITION_LIGHTNING: ["Risque d'orages", "Orages", "Orage avec grêle"], ATTR_CONDITION_LIGHTNING_RAINY: [ "Pluie orageuses", "Pluies orageuses", @@ -62,6 +63,7 @@ CONDITION_CLASSES: dict[str, list[str]] = { "Éclaircies", "Eclaircies", "Peu nuageux", + "Variable", ], ATTR_CONDITION_POURING: ["Pluie forte"], ATTR_CONDITION_RAINY: [ @@ -83,10 +85,11 @@ CONDITION_CLASSES: dict[str, list[str]] = { "Averses de neige", "Neige forte", "Neige faible", + "Averses de neige faible", "Quelques flocons", ], ATTR_CONDITION_SNOWY_RAINY: ["Pluie et neige", "Pluie verglaçante"], - ATTR_CONDITION_SUNNY: ["Ensoleillé", "Ciel clair"], + ATTR_CONDITION_SUNNY: ["Ensoleillé"], ATTR_CONDITION_WINDY: [], ATTR_CONDITION_WINDY_VARIANT: [], ATTR_CONDITION_EXCEPTIONAL: [], diff --git a/homeassistant/components/miele/manifest.json b/homeassistant/components/miele/manifest.json index 414db320718..9cc79b099a3 100644 --- a/homeassistant/components/miele/manifest.json +++ b/homeassistant/components/miele/manifest.json @@ -8,6 +8,6 @@ "iot_class": "cloud_push", "loggers": ["pymiele"], "quality_scale": "bronze", - "requirements": ["pymiele==0.3.4"], + "requirements": ["pymiele==0.3.6"], "single_config_entry": true } diff --git a/homeassistant/components/mjpeg/config_flow.py b/homeassistant/components/mjpeg/config_flow.py index e0150f8c461..a0efb56c224 100644 --- a/homeassistant/components/mjpeg/config_flow.py +++ b/homeassistant/components/mjpeg/config_flow.py @@ -2,8 +2,8 @@ from __future__ import annotations +from collections.abc import Mapping from http import HTTPStatus -from types import MappingProxyType from typing import Any import requests @@ -34,7 +34,7 @@ from .const import CONF_MJPEG_URL, CONF_STILL_IMAGE_URL, DOMAIN, LOGGER @callback def async_get_schema( - defaults: dict[str, Any] | MappingProxyType[str, Any], show_name: bool = False + defaults: Mapping[str, Any], show_name: bool = False ) -> vol.Schema: """Return MJPEG IP Camera schema.""" schema = { diff --git a/homeassistant/components/motioneye/camera.py b/homeassistant/components/motioneye/camera.py index 159956277a8..adf380bf9eb 100644 --- a/homeassistant/components/motioneye/camera.py +++ b/homeassistant/components/motioneye/camera.py @@ -2,8 +2,8 @@ from __future__ import annotations +from collections.abc import Mapping from contextlib import suppress -from types import MappingProxyType from typing import Any import aiohttp @@ -154,7 +154,7 @@ class MotionEyeMjpegCamera(MotionEyeEntity, MjpegCamera): camera: dict[str, Any], client: MotionEyeClient, coordinator: DataUpdateCoordinator, - options: MappingProxyType[str, str], + options: Mapping[str, str], ) -> None: """Initialize a MJPEG camera.""" self._surveillance_username = username diff --git a/homeassistant/components/motioneye/entity.py b/homeassistant/components/motioneye/entity.py index 49739f2fca3..e279533f080 100644 --- a/homeassistant/components/motioneye/entity.py +++ b/homeassistant/components/motioneye/entity.py @@ -2,7 +2,7 @@ from __future__ import annotations -from types import MappingProxyType +from collections.abc import Mapping from typing import Any from motioneye_client.client import MotionEyeClient @@ -37,7 +37,7 @@ class MotionEyeEntity(CoordinatorEntity): camera: dict[str, Any], client: MotionEyeClient, coordinator: DataUpdateCoordinator, - options: MappingProxyType[str, Any], + options: Mapping[str, Any], entity_description: EntityDescription | None = None, ) -> None: """Initialize a motionEye entity.""" diff --git a/homeassistant/components/motioneye/sensor.py b/homeassistant/components/motioneye/sensor.py index c160b77c16a..c8d05c6bb4d 100644 --- a/homeassistant/components/motioneye/sensor.py +++ b/homeassistant/components/motioneye/sensor.py @@ -2,8 +2,8 @@ from __future__ import annotations +from collections.abc import Mapping import logging -from types import MappingProxyType from typing import Any from motioneye_client.client import MotionEyeClient @@ -60,7 +60,7 @@ class MotionEyeActionSensor(MotionEyeEntity, SensorEntity): camera: dict[str, Any], client: MotionEyeClient, coordinator: DataUpdateCoordinator, - options: MappingProxyType[str, str], + options: Mapping[str, str], ) -> None: """Initialize an action sensor.""" super().__init__( diff --git a/homeassistant/components/motioneye/switch.py b/homeassistant/components/motioneye/switch.py index 89d3b8a8727..afa0b9481d1 100644 --- a/homeassistant/components/motioneye/switch.py +++ b/homeassistant/components/motioneye/switch.py @@ -2,7 +2,7 @@ from __future__ import annotations -from types import MappingProxyType +from collections.abc import Mapping from typing import Any from motioneye_client.client import MotionEyeClient @@ -103,7 +103,7 @@ class MotionEyeSwitch(MotionEyeEntity, SwitchEntity): camera: dict[str, Any], client: MotionEyeClient, coordinator: DataUpdateCoordinator, - options: MappingProxyType[str, str], + options: Mapping[str, str], entity_description: SwitchEntityDescription, ) -> None: """Initialize the switch.""" diff --git a/homeassistant/components/motionmount/strings.json b/homeassistant/components/motionmount/strings.json index 75fd0773322..2c951a7aefe 100644 --- a/homeassistant/components/motionmount/strings.json +++ b/homeassistant/components/motionmount/strings.json @@ -68,7 +68,7 @@ }, "sensor": { "motionmount_error_status": { - "name": "Error Status", + "name": "Error status", "state": { "none": "None", "motor": "Motor", diff --git a/homeassistant/components/netatmo/manifest.json b/homeassistant/components/netatmo/manifest.json index 0a32777b527..84c8be1d0be 100644 --- a/homeassistant/components/netatmo/manifest.json +++ b/homeassistant/components/netatmo/manifest.json @@ -12,5 +12,5 @@ "integration_type": "hub", "iot_class": "cloud_polling", "loggers": ["pyatmo"], - "requirements": ["pyatmo==8.1.0"] + "requirements": ["pyatmo==9.0.0"] } diff --git a/homeassistant/components/ntfy/__init__.py b/homeassistant/components/ntfy/__init__.py new file mode 100644 index 00000000000..f51e5d5a0e1 --- /dev/null +++ b/homeassistant/components/ntfy/__init__.py @@ -0,0 +1,78 @@ +"""The ntfy integration.""" + +from __future__ import annotations + +import logging + +from aiontfy import Ntfy +from aiontfy.exceptions import ( + NtfyConnectionError, + NtfyHTTPError, + NtfyTimeoutError, + NtfyUnauthorizedAuthenticationError, +) + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_TOKEN, CONF_URL, Platform +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryNotReady +from homeassistant.helpers.aiohttp_client import async_get_clientsession + +from .const import DOMAIN + +_LOGGER = logging.getLogger(__name__) +PLATFORMS: list[Platform] = [Platform.NOTIFY] + + +type NtfyConfigEntry = ConfigEntry[Ntfy] + + +async def async_setup_entry(hass: HomeAssistant, entry: NtfyConfigEntry) -> bool: + """Set up ntfy from a config entry.""" + + session = async_get_clientsession(hass) + ntfy = Ntfy(entry.data[CONF_URL], session, token=entry.data.get(CONF_TOKEN)) + + try: + await ntfy.account() + except NtfyUnauthorizedAuthenticationError as e: + raise ConfigEntryNotReady( + translation_domain=DOMAIN, + translation_key="authentication_error", + ) from e + except NtfyHTTPError as e: + _LOGGER.debug("Error %s: %s [%s]", e.code, e.error, e.link) + raise ConfigEntryNotReady( + translation_domain=DOMAIN, + translation_key="server_error", + translation_placeholders={"error_msg": str(e.error)}, + ) from e + except NtfyConnectionError as e: + _LOGGER.debug("Error", exc_info=True) + raise ConfigEntryNotReady( + translation_domain=DOMAIN, + translation_key="connection_error", + ) from e + except NtfyTimeoutError as e: + raise ConfigEntryNotReady( + translation_domain=DOMAIN, + translation_key="timeout_error", + ) from e + + entry.runtime_data = ntfy + + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + + entry.async_on_unload(entry.add_update_listener(_async_update_listener)) + + return True + + +async def _async_update_listener(hass: HomeAssistant, entry: NtfyConfigEntry) -> None: + """Handle update.""" + await hass.config_entries.async_reload(entry.entry_id) + + +async def async_unload_entry(hass: HomeAssistant, entry: NtfyConfigEntry) -> bool: + """Unload a config entry.""" + return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/homeassistant/components/ntfy/config_flow.py b/homeassistant/components/ntfy/config_flow.py new file mode 100644 index 00000000000..cc4bcbf14ba --- /dev/null +++ b/homeassistant/components/ntfy/config_flow.py @@ -0,0 +1,218 @@ +"""Config flow for the ntfy integration.""" + +from __future__ import annotations + +import logging +import random +import re +import string +from typing import TYPE_CHECKING, Any + +from aiontfy import Ntfy +from aiontfy.exceptions import ( + NtfyException, + NtfyHTTPError, + NtfyUnauthorizedAuthenticationError, +) +import voluptuous as vol +from yarl import URL + +from homeassistant import data_entry_flow +from homeassistant.config_entries import ( + ConfigEntry, + ConfigFlow, + ConfigFlowResult, + ConfigSubentryFlow, + SubentryFlowResult, +) +from homeassistant.const import ( + CONF_NAME, + CONF_PASSWORD, + CONF_TOKEN, + CONF_URL, + CONF_USERNAME, +) +from homeassistant.core import callback +from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.helpers.selector import ( + TextSelector, + TextSelectorConfig, + TextSelectorType, +) + +from .const import CONF_TOPIC, DEFAULT_URL, DOMAIN, SECTION_AUTH + +_LOGGER = logging.getLogger(__name__) + +STEP_USER_DATA_SCHEMA = vol.Schema( + { + vol.Required(CONF_URL, default=DEFAULT_URL): TextSelector( + TextSelectorConfig( + type=TextSelectorType.URL, + autocomplete="url", + ), + ), + vol.Required(SECTION_AUTH): data_entry_flow.section( + vol.Schema( + { + vol.Optional(CONF_USERNAME): TextSelector( + TextSelectorConfig( + type=TextSelectorType.TEXT, + autocomplete="username", + ), + ), + vol.Optional(CONF_PASSWORD): TextSelector( + TextSelectorConfig( + type=TextSelectorType.PASSWORD, + autocomplete="current-password", + ), + ), + } + ), + {"collapsed": True}, + ), + } +) + +STEP_USER_TOPIC_SCHEMA = vol.Schema( + { + vol.Required(CONF_TOPIC): str, + vol.Optional(CONF_NAME): str, + } +) + +RE_TOPIC = re.compile("^[-_a-zA-Z0-9]{1,64}$") + + +class NtfyConfigFlow(ConfigFlow, domain=DOMAIN): + """Handle a config flow for ntfy.""" + + @classmethod + @callback + def async_get_supported_subentry_types( + cls, config_entry: ConfigEntry + ) -> dict[str, type[ConfigSubentryFlow]]: + """Return subentries supported by this integration.""" + return {"topic": TopicSubentryFlowHandler} + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle the initial step.""" + errors: dict[str, str] = {} + if user_input is not None: + url = URL(user_input[CONF_URL]) + username = user_input[SECTION_AUTH].get(CONF_USERNAME) + self._async_abort_entries_match( + { + CONF_URL: url.human_repr(), + CONF_USERNAME: username, + } + ) + session = async_get_clientsession(self.hass) + if username: + ntfy = Ntfy( + user_input[CONF_URL], + session, + username, + user_input[SECTION_AUTH].get(CONF_PASSWORD, ""), + ) + else: + ntfy = Ntfy(user_input[CONF_URL], session) + + try: + account = await ntfy.account() + token = ( + (await ntfy.generate_token("Home Assistant")).token + if account.username != "*" + else None + ) + except NtfyUnauthorizedAuthenticationError: + errors["base"] = "invalid_auth" + except NtfyHTTPError as e: + _LOGGER.debug("Error %s: %s [%s]", e.code, e.error, e.link) + errors["base"] = "cannot_connect" + except NtfyException: + errors["base"] = "cannot_connect" + except Exception: + _LOGGER.exception("Unexpected exception") + errors["base"] = "unknown" + else: + if TYPE_CHECKING: + assert url.host + return self.async_create_entry( + title=url.host, + data={ + CONF_URL: url.human_repr(), + CONF_USERNAME: username, + CONF_TOKEN: token, + }, + ) + + return self.async_show_form( + step_id="user", + data_schema=self.add_suggested_values_to_schema( + data_schema=STEP_USER_DATA_SCHEMA, suggested_values=user_input + ), + errors=errors, + ) + + +class TopicSubentryFlowHandler(ConfigSubentryFlow): + """Handle subentry flow for adding and modifying a topic.""" + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> SubentryFlowResult: + """User flow to add a new topic.""" + + return self.async_show_menu( + step_id="user", + menu_options=["add_topic", "generate_topic"], + ) + + async def async_step_generate_topic( + self, user_input: dict[str, Any] | None = None + ) -> SubentryFlowResult: + """User flow to add a new topic.""" + topic = "".join( + random.choices( + string.ascii_lowercase + string.ascii_uppercase + string.digits, + k=16, + ) + ) + return self.async_show_form( + step_id="add_topic", + data_schema=self.add_suggested_values_to_schema( + data_schema=STEP_USER_TOPIC_SCHEMA, + suggested_values={CONF_TOPIC: topic}, + ), + ) + + async def async_step_add_topic( + self, user_input: dict[str, Any] | None = None + ) -> SubentryFlowResult: + """User flow to add a new topic.""" + config_entry = self._get_entry() + errors: dict[str, str] = {} + + if user_input is not None: + if not RE_TOPIC.match(user_input[CONF_TOPIC]): + errors["base"] = "invalid_topic" + else: + for existing_subentry in config_entry.subentries.values(): + if existing_subentry.unique_id == user_input[CONF_TOPIC]: + return self.async_abort(reason="already_configured") + + return self.async_create_entry( + title=user_input.get(CONF_NAME, user_input[CONF_TOPIC]), + data=user_input, + unique_id=user_input[CONF_TOPIC], + ) + return self.async_show_form( + step_id="add_topic", + data_schema=self.add_suggested_values_to_schema( + data_schema=STEP_USER_TOPIC_SCHEMA, suggested_values=user_input + ), + errors=errors, + ) diff --git a/homeassistant/components/ntfy/const.py b/homeassistant/components/ntfy/const.py new file mode 100644 index 00000000000..78355f7e828 --- /dev/null +++ b/homeassistant/components/ntfy/const.py @@ -0,0 +1,9 @@ +"""Constants for the ntfy integration.""" + +from typing import Final + +DOMAIN = "ntfy" +DEFAULT_URL: Final = "https://ntfy.sh" + +CONF_TOPIC = "topic" +SECTION_AUTH = "auth" diff --git a/homeassistant/components/ntfy/icons.json b/homeassistant/components/ntfy/icons.json new file mode 100644 index 00000000000..9fe617880af --- /dev/null +++ b/homeassistant/components/ntfy/icons.json @@ -0,0 +1,9 @@ +{ + "entity": { + "notify": { + "publish": { + "default": "mdi:console-line" + } + } + } +} diff --git a/homeassistant/components/ntfy/manifest.json b/homeassistant/components/ntfy/manifest.json new file mode 100644 index 00000000000..95204444fbb --- /dev/null +++ b/homeassistant/components/ntfy/manifest.json @@ -0,0 +1,11 @@ +{ + "domain": "ntfy", + "name": "ntfy", + "codeowners": ["@tr4nt0r"], + "config_flow": true, + "documentation": "https://www.home-assistant.io/integrations/ntfy", + "iot_class": "cloud_push", + "loggers": ["aionfty"], + "quality_scale": "bronze", + "requirements": ["aiontfy==0.5.1"] +} diff --git a/homeassistant/components/ntfy/notify.py b/homeassistant/components/ntfy/notify.py new file mode 100644 index 00000000000..ac06e430346 --- /dev/null +++ b/homeassistant/components/ntfy/notify.py @@ -0,0 +1,86 @@ +"""ntfy notification entity.""" + +from __future__ import annotations + +from aiontfy import Message +from aiontfy.exceptions import NtfyException, NtfyHTTPError +from yarl import URL + +from homeassistant.components.notify import ( + NotifyEntity, + NotifyEntityDescription, + NotifyEntityFeature, +) +from homeassistant.config_entries import ConfigSubentry +from homeassistant.const import CONF_NAME, CONF_URL +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from . import NtfyConfigEntry +from .const import CONF_TOPIC, DOMAIN + +PARALLEL_UPDATES = 0 + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: NtfyConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up the ntfy notification entity platform.""" + + for subentry_id, subentry in config_entry.subentries.items(): + async_add_entities( + [NtfyNotifyEntity(config_entry, subentry)], config_subentry_id=subentry_id + ) + + +class NtfyNotifyEntity(NotifyEntity): + """Representation of a ntfy notification entity.""" + + entity_description = NotifyEntityDescription( + key="publish", + translation_key="publish", + name=None, + has_entity_name=True, + ) + _attr_supported_features = NotifyEntityFeature.TITLE + + def __init__( + self, + config_entry: NtfyConfigEntry, + subentry: ConfigSubentry, + ) -> None: + """Initialize a notification entity.""" + + self._attr_unique_id = f"{config_entry.entry_id}_{subentry.subentry_id}_{self.entity_description.key}" + self.topic = subentry.data[CONF_TOPIC] + + self._attr_device_info = DeviceInfo( + entry_type=DeviceEntryType.SERVICE, + manufacturer="ntfy LLC", + model="ntfy", + name=subentry.data.get(CONF_NAME, self.topic), + configuration_url=URL(config_entry.data[CONF_URL]) / self.topic, + identifiers={(DOMAIN, f"{config_entry.entry_id}_{subentry.subentry_id}")}, + ) + self.ntfy = config_entry.runtime_data + + async def async_send_message(self, message: str, title: str | None = None) -> None: + """Publish a message to a topic.""" + msg = Message(topic=self.topic, message=message, title=title) + try: + await self.ntfy.publish(msg) + except NtfyHTTPError as e: + raise HomeAssistantError( + translation_key="publish_failed_request_error", + translation_domain=DOMAIN, + translation_placeholders={"error_msg": e.error}, + ) from e + except NtfyException as e: + raise HomeAssistantError( + translation_key="publish_failed_exception", + translation_domain=DOMAIN, + ) from e diff --git a/homeassistant/components/ntfy/quality_scale.yaml b/homeassistant/components/ntfy/quality_scale.yaml new file mode 100644 index 00000000000..d476981cf6a --- /dev/null +++ b/homeassistant/components/ntfy/quality_scale.yaml @@ -0,0 +1,86 @@ +rules: + # Bronze + action-setup: + status: exempt + comment: only entity actions + appropriate-polling: + status: exempt + comment: the integration does not poll + brands: done + common-modules: + status: exempt + comment: the integration currently implements only one platform and has no coordinator + config-flow-test-coverage: done + config-flow: done + dependency-transparency: done + docs-actions: + status: exempt + comment: integration has only entity actions + docs-high-level-description: done + docs-installation-instructions: done + docs-removal-instructions: done + entity-event-setup: + status: exempt + comment: the integration does not subscribe to events + entity-unique-id: done + has-entity-name: done + runtime-data: done + test-before-configure: done + test-before-setup: done + unique-config-entry: done + + # Silver + action-exceptions: done + config-entry-unloading: done + docs-configuration-parameters: + status: exempt + comment: the integration has no options + docs-installation-parameters: done + entity-unavailable: + status: exempt + comment: the integration only implements a stateless notify entity. + integration-owner: done + log-when-unavailable: + status: exempt + comment: the integration only integrates state-less entities + parallel-updates: done + reauthentication-flow: todo + test-coverage: done + + # Gold + devices: done + diagnostics: todo + discovery-update-info: todo + discovery: todo + docs-data-update: todo + docs-examples: todo + docs-known-limitations: todo + docs-supported-devices: todo + docs-supported-functions: todo + docs-troubleshooting: todo + docs-use-cases: todo + dynamic-devices: todo + entity-category: done + entity-device-class: + status: exempt + comment: no suitable device class for the notify entity + entity-disabled-by-default: + status: exempt + comment: only one entity + entity-translations: + status: exempt + comment: the notify entity uses the device name as entity name, no translation required + exception-translations: done + icon-translations: done + reconfiguration-flow: todo + repair-issues: + status: exempt + comment: the integration has no repairs + stale-devices: + status: exempt + comment: only one device per entry, is deleted with the entry. + + # Platinum + async-dependency: done + inject-websession: done + strict-typing: done diff --git a/homeassistant/components/ntfy/strings.json b/homeassistant/components/ntfy/strings.json new file mode 100644 index 00000000000..21b1fb22200 --- /dev/null +++ b/homeassistant/components/ntfy/strings.json @@ -0,0 +1,101 @@ +{ + "common": { + "topic": "Topic", + "add_topic_description": "Set up a topic for notifications." + }, + "config": { + "step": { + "user": { + "description": "Set up **ntfy** push notification service", + "data": { + "url": "Service URL" + }, + "data_description": { + "url": "Address of the ntfy service. Modify this if you want to use a different server" + }, + "sections": { + "auth": { + "name": "Authentication", + "description": "Depending on whether the server is configured to support access control, some topics may be read/write protected so that only users with the correct credentials can subscribe or publish to them. To publish/subscribe to protected topics, you can provide a username and password.", + "data": { + "username": "[%key:common::config_flow::data::username%]", + "password": "[%key:common::config_flow::data::password%]" + }, + "data_description": { + "username": "Enter the username required to authenticate with protected ntfy topics", + "password": "Enter the password corresponding to the provided username for authentication" + } + } + } + } + }, + "error": { + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", + "unknown": "[%key:common::config_flow::error::unknown%]" + }, + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_service%]" + } + }, + "config_subentries": { + "topic": { + "step": { + "user": { + "title": "[%key:component::ntfy::common::topic%]", + "description": "[%key:component::ntfy::common::add_topic_description%]", + "menu_options": { + "add_topic": "Enter topic", + "generate_topic": "Generate topic name" + } + }, + "add_topic": { + "title": "[%key:component::ntfy::common::topic%]", + "description": "[%key:component::ntfy::common::add_topic_description%]", + "data": { + "topic": "[%key:component::ntfy::common::topic%]", + "name": "Display name" + }, + "data_description": { + "topic": "Enter the name of the topic you want to use for notifications. Topics may not be password-protected, so choose a name that's not easy to guess.", + "name": "Set an alternative name to display instead of the topic name. This helps identify topics with complex or hard-to-read names more easily." + } + } + }, + "initiate_flow": { + "user": "Add topic" + }, + "entry_type": "[%key:component::ntfy::common::topic%]", + "error": { + "publish_forbidden": "Publishing to this topic is forbidden", + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "unknown": "[%key:common::config_flow::error::unknown%]", + "invalid_topic": "Invalid topic. Only letters, numbers, underscores, or dashes allowed." + }, + "abort": { + "already_configured": "Topic is already configured" + } + } + }, + "exceptions": { + "publish_failed_request_error": { + "message": "Failed to publish notification: {error_msg}" + }, + + "publish_failed_exception": { + "message": "Failed to publish notification due to a connection error" + }, + "authentication_error": { + "message": "Failed to authenticate with ntfy service. Please verify your credentials" + }, + "server_error": { + "message": "Failed to connect to ntfy service due to a server error: {error_msg}" + }, + "connection_error": { + "message": "Failed to connect to ntfy service due to a connection error" + }, + "timeout_error": { + "message": "Failed to connect to ntfy service due to a connection timeout" + } + } +} diff --git a/homeassistant/components/nuki/manifest.json b/homeassistant/components/nuki/manifest.json index b2e039ec122..cfc147661ae 100644 --- a/homeassistant/components/nuki/manifest.json +++ b/homeassistant/components/nuki/manifest.json @@ -1,6 +1,6 @@ { "domain": "nuki", - "name": "Nuki", + "name": "Nuki Bridge", "codeowners": ["@pschmitt", "@pvizeli", "@pree"], "config_flow": true, "dependencies": ["webhook"], diff --git a/homeassistant/components/nut/__init__.py b/homeassistant/components/nut/__init__.py index 9e1e77a2aaf..ae20ed39251 100644 --- a/homeassistant/components/nut/__init__.py +++ b/homeassistant/components/nut/__init__.py @@ -185,6 +185,20 @@ async def async_unload_entry(hass: HomeAssistant, entry: NutConfigEntry) -> bool return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) +async def async_remove_config_entry_device( + hass: HomeAssistant, + config_entry: ConfigEntry, + device_entry: dr.DeviceEntry, +) -> bool: + """Remove NUT config entry from a device.""" + return not any( + identifier + for identifier in device_entry.identifiers + if identifier[0] == DOMAIN + and identifier[1] in config_entry.runtime_data.unique_id + ) + + async def _async_update_listener(hass: HomeAssistant, entry: NutConfigEntry) -> None: """Handle options update.""" await hass.config_entries.async_reload(entry.entry_id) diff --git a/homeassistant/components/nut/config_flow.py b/homeassistant/components/nut/config_flow.py index a69d898ff6c..69281e852a8 100644 --- a/homeassistant/components/nut/config_flow.py +++ b/homeassistant/components/nut/config_flow.py @@ -4,7 +4,6 @@ from __future__ import annotations from collections.abc import Mapping import logging -from types import MappingProxyType from typing import Any from aionut import NUTError, NUTLoginError @@ -19,7 +18,6 @@ from homeassistant.const import ( CONF_PORT, CONF_USERNAME, ) -from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import AbortFlow from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo @@ -34,7 +32,7 @@ PASSWORD_NOT_CHANGED = "__**password_not_changed**__" def _base_schema( - nut_config: dict[str, Any] | MappingProxyType[str, Any], + nut_config: Mapping[str, Any], use_password_not_changed: bool = False, ) -> vol.Schema: """Generate base schema.""" @@ -56,7 +54,7 @@ def _ups_schema(ups_list: dict[str, str]) -> vol.Schema: return vol.Schema({vol.Required(CONF_ALIAS): vol.In(ups_list)}) -async def validate_input(hass: HomeAssistant, data: dict[str, Any]) -> dict[str, Any]: +async def validate_input(data: dict[str, Any]) -> dict[str, Any]: """Validate the user input allows us to connect. Data has the keys from _base_schema with values provided by the user. @@ -303,7 +301,7 @@ class NutConfigFlow(ConfigFlow, domain=DOMAIN): info: dict[str, Any] = {} description_placeholders: dict[str, str] = {} try: - info = await validate_input(self.hass, config) + info = await validate_input(config) except NUTLoginError: errors[CONF_PASSWORD] = "invalid_auth" except NUTError as ex: @@ -320,8 +318,6 @@ class NutConfigFlow(ConfigFlow, domain=DOMAIN): self, entry_data: Mapping[str, Any] ) -> ConfigFlowResult: """Handle reauth.""" - entry_id = self.context["entry_id"] - self.reauth_entry = self.hass.config_entries.async_get_entry(entry_id) return await self.async_step_reauth_confirm() async def async_step_reauth_confirm( @@ -330,17 +326,16 @@ class NutConfigFlow(ConfigFlow, domain=DOMAIN): """Handle reauth input.""" errors: dict[str, str] = {} - existing_entry = self.reauth_entry - assert existing_entry - existing_data = existing_entry.data + reauth_entry = self._get_reauth_entry() + reauth_data = reauth_entry.data description_placeholders: dict[str, str] = { - CONF_HOST: existing_data[CONF_HOST], - CONF_PORT: existing_data[CONF_PORT], + CONF_HOST: reauth_data[CONF_HOST], + CONF_PORT: reauth_data[CONF_PORT], } if user_input is not None: new_config = { - **existing_data, + **reauth_data, # Username/password are optional and some servers # use ip based authentication and will fail if # username/password are provided @@ -349,9 +344,7 @@ class NutConfigFlow(ConfigFlow, domain=DOMAIN): } _, errors, placeholders = await self._async_validate_or_error(new_config) if not errors: - return self.async_update_reload_and_abort( - existing_entry, data=new_config - ) + return self.async_update_reload_and_abort(reauth_entry, data=new_config) description_placeholders.update(placeholders) return self.async_show_form( diff --git a/homeassistant/components/nut/icons.json b/homeassistant/components/nut/icons.json index a795368005c..ae87c955164 100644 --- a/homeassistant/components/nut/icons.json +++ b/homeassistant/components/nut/icons.json @@ -1,10 +1,5 @@ { "entity": { - "button": { - "outlet_number_load_cycle": { - "default": "mdi:restart" - } - }, "sensor": { "ambient_humidity_status": { "default": "mdi:information-outline" diff --git a/homeassistant/components/nut/sensor.py b/homeassistant/components/nut/sensor.py index 5822f7f7b02..ce8c10f8f41 100644 --- a/homeassistant/components/nut/sensor.py +++ b/homeassistant/components/nut/sensor.py @@ -13,7 +13,6 @@ from homeassistant.components.sensor import ( ) from homeassistant.const import ( PERCENTAGE, - STATE_UNKNOWN, EntityCategory, UnitOfApparentPower, UnitOfElectricCurrent, @@ -1120,9 +1119,9 @@ class NUTSensor(NUTBaseEntity, SensorEntity): return status.get(self.entity_description.key) -def _format_display_state(status: dict[str, str]) -> str: +def _format_display_state(status: dict[str, str]) -> str | None: """Return UPS display state.""" try: return ", ".join(STATE_TYPES[state] for state in status[KEY_STATUS].split()) except KeyError: - return STATE_UNKNOWN + return None diff --git a/homeassistant/components/nut/strings.json b/homeassistant/components/nut/strings.json index dff568944b7..df251ae632f 100644 --- a/homeassistant/components/nut/strings.json +++ b/homeassistant/components/nut/strings.json @@ -153,8 +153,14 @@ "battery_current_total": { "name": "Total battery current" }, "battery_date": { "name": "Battery date" }, "battery_mfr_date": { "name": "Battery manuf. date" }, - "battery_packs": { "name": "Number of batteries" }, - "battery_packs_bad": { "name": "Number of bad batteries" }, + "battery_packs": { + "name": "Number of batteries", + "unit_of_measurement": "packs" + }, + "battery_packs_bad": { + "name": "Number of bad batteries", + "unit_of_measurement": "packs" + }, "battery_runtime": { "name": "Battery runtime" }, "battery_runtime_low": { "name": "Low battery runtime" }, "battery_runtime_restart": { "name": "Minimum battery runtime to start" }, @@ -175,7 +181,10 @@ "input_bypass_l3_current": { "name": "Input bypass L3 current" }, "input_bypass_l3_n_voltage": { "name": "Input bypass L3-N voltage" }, "input_bypass_l3_realpower": { "name": "Input bypass L3 real power" }, - "input_bypass_phases": { "name": "Input bypass phases" }, + "input_bypass_phases": { + "name": "Input bypass phases", + "unit_of_measurement": "phase" + }, "input_bypass_realpower": { "name": "Input bypass real power" }, "input_bypass_voltage": { "name": "Input bypass voltage" }, "input_current": { "name": "Input current" }, @@ -211,7 +220,10 @@ "input_l3_n_voltage": { "name": "Input L3 voltage" }, "input_l3_realpower": { "name": "Input L3 real power" }, "input_load": { "name": "Input load" }, - "input_phases": { "name": "Input phases" }, + "input_phases": { + "name": "Input phases", + "unit_of_measurement": "phase" + }, "input_power": { "name": "Input power" }, "input_realpower": { "name": "Input real power" }, "input_sensitivity": { "name": "Input power sensitivity" }, @@ -245,7 +257,10 @@ "output_l3_n_voltage": { "name": "Output L3-N voltage" }, "output_l3_power_percent": { "name": "Output L3 power usage" }, "output_l3_realpower": { "name": "Output L3 real power" }, - "output_phases": { "name": "Output phases" }, + "output_phases": { + "name": "Output phases", + "unit_of_measurement": "phase" + }, "output_power": { "name": "Output apparent power" }, "output_power_nominal": { "name": "Nominal output power" }, "output_realpower": { "name": "Output real power" }, diff --git a/homeassistant/components/nws/sensor.py b/homeassistant/components/nws/sensor.py index 8a7631d8381..348d9ade7a3 100644 --- a/homeassistant/components/nws/sensor.py +++ b/homeassistant/components/nws/sensor.py @@ -2,9 +2,9 @@ from __future__ import annotations +from collections.abc import Mapping from dataclasses import dataclass from datetime import datetime -from types import MappingProxyType from typing import Any from homeassistant.components.sensor import ( @@ -180,7 +180,7 @@ class NWSSensor(CoordinatorEntity[TimestampDataUpdateCoordinator[None]], SensorE def __init__( self, hass: HomeAssistant, - entry_data: MappingProxyType[str, Any], + entry_data: Mapping[str, Any], nws_data: NWSData, description: NWSSensorEntityDescription, station: str, diff --git a/homeassistant/components/nws/weather.py b/homeassistant/components/nws/weather.py index c90c67edcb7..c44869939ff 100644 --- a/homeassistant/components/nws/weather.py +++ b/homeassistant/components/nws/weather.py @@ -2,8 +2,8 @@ from __future__ import annotations +from collections.abc import Mapping from functools import partial -from types import MappingProxyType from typing import Any, Required, TypedDict, cast import voluptuous as vol @@ -126,7 +126,7 @@ class ExtraForecast(TypedDict, total=False): short_description: str | None -def _calculate_unique_id(entry_data: MappingProxyType[str, Any], mode: str) -> str: +def _calculate_unique_id(entry_data: Mapping[str, Any], mode: str) -> str: """Calculate unique ID.""" latitude = entry_data[CONF_LATITUDE] longitude = entry_data[CONF_LONGITUDE] @@ -148,7 +148,7 @@ class NWSWeather(CoordinatorWeatherEntity[TimestampDataUpdateCoordinator[None]]) def __init__( self, - entry_data: MappingProxyType[str, Any], + entry_data: Mapping[str, Any], nws_data: NWSData, ) -> None: """Initialise the platform with a data instance and station name.""" diff --git a/homeassistant/components/ollama/config_flow.py b/homeassistant/components/ollama/config_flow.py index 7379ea17ba6..d7f874c261c 100644 --- a/homeassistant/components/ollama/config_flow.py +++ b/homeassistant/components/ollama/config_flow.py @@ -3,9 +3,9 @@ from __future__ import annotations import asyncio +from collections.abc import Mapping import logging import sys -from types import MappingProxyType from typing import Any import httpx @@ -219,7 +219,7 @@ class OllamaOptionsFlow(OptionsFlow): title=_get_title(self.model), data=user_input ) - options = self.config_entry.options or MappingProxyType({}) + options: Mapping[str, Any] = self.config_entry.options or {} schema = ollama_config_option_schema(self.hass, options) return self.async_show_form( step_id="init", @@ -228,7 +228,7 @@ class OllamaOptionsFlow(OptionsFlow): def ollama_config_option_schema( - hass: HomeAssistant, options: MappingProxyType[str, Any] + hass: HomeAssistant, options: Mapping[str, Any] ) -> dict: """Ollama options schema.""" hass_apis: list[SelectOptionDict] = [ diff --git a/homeassistant/components/onedrive/backup.py b/homeassistant/components/onedrive/backup.py index 41a244506ea..dfb592c8d45 100644 --- a/homeassistant/components/onedrive/backup.py +++ b/homeassistant/components/onedrive/backup.py @@ -174,11 +174,15 @@ class OneDriveBackupAgent(BackupAgent): description = dumps(backup.as_dict()) _LOGGER.debug("Creating metadata: %s", description) metadata_filename = filename.rsplit(".", 1)[0] + ".metadata.json" - metadata_file = await self._client.upload_file( - self._folder_id, - metadata_filename, - description, - ) + try: + metadata_file = await self._client.upload_file( + self._folder_id, + metadata_filename, + description, + ) + except OneDriveException: + await self._client.delete_drive_item(backup_file.id) + raise # add metadata to the metadata file metadata_description = { @@ -186,10 +190,15 @@ class OneDriveBackupAgent(BackupAgent): "backup_id": backup.backup_id, "backup_file_id": backup_file.id, } - await self._client.update_drive_item( - path_or_id=metadata_file.id, - data=ItemUpdate(description=dumps(metadata_description)), - ) + try: + await self._client.update_drive_item( + path_or_id=metadata_file.id, + data=ItemUpdate(description=dumps(metadata_description)), + ) + except OneDriveException: + await self._client.delete_drive_item(backup_file.id) + await self._client.delete_drive_item(metadata_file.id) + raise self._cache_expiration = time() @handle_backup_errors @@ -235,8 +244,12 @@ class OneDriveBackupAgent(BackupAgent): items = await self._client.list_drive_items(self._folder_id) - async def download_backup_metadata(item_id: str) -> AgentBackup: - metadata_stream = await self._client.download_drive_item(item_id) + async def download_backup_metadata(item_id: str) -> AgentBackup | None: + try: + metadata_stream = await self._client.download_drive_item(item_id) + except OneDriveException as err: + _LOGGER.warning("Error downloading metadata for %s: %s", item_id, err) + return None metadata_json = loads(await metadata_stream.read()) return AgentBackup.from_dict(metadata_json) @@ -246,6 +259,8 @@ class OneDriveBackupAgent(BackupAgent): metadata_description_json := unescape(item.description) ): backup = await download_backup_metadata(item.id) + if backup is None: + continue metadata_description = loads(metadata_description_json) backups[backup.backup_id] = OneDriveBackup( backup=backup, diff --git a/homeassistant/components/onewire/sensor.py b/homeassistant/components/onewire/sensor.py index 5e1c7d35bd6..7039dc09858 100644 --- a/homeassistant/components/onewire/sensor.py +++ b/homeassistant/components/onewire/sensor.py @@ -7,7 +7,6 @@ import dataclasses from datetime import timedelta import logging import os -from types import MappingProxyType from typing import Any from pyownet import protocol @@ -415,7 +414,7 @@ async def async_setup_entry( def get_entities( onewire_hub: OneWireHub, devices: list[OWDeviceDescription], - options: MappingProxyType[str, Any], + options: Mapping[str, Any], ) -> list[OneWireSensorEntity]: """Get a list of entities.""" entities: list[OneWireSensorEntity] = [] diff --git a/homeassistant/components/openai_conversation/config_flow.py b/homeassistant/components/openai_conversation/config_flow.py index 5c8ab674bef..fbe64492b3c 100644 --- a/homeassistant/components/openai_conversation/config_flow.py +++ b/homeassistant/components/openai_conversation/config_flow.py @@ -2,6 +2,7 @@ from __future__ import annotations +from collections.abc import Mapping import json import logging from types import MappingProxyType @@ -243,7 +244,7 @@ class OpenAIOptionsFlow(OptionsFlow): def openai_config_option_schema( hass: HomeAssistant, - options: dict[str, Any] | MappingProxyType[str, Any], + options: Mapping[str, Any], ) -> VolDictType: """Return a schema for OpenAI completion options.""" hass_apis: list[SelectOptionDict] = [ diff --git a/homeassistant/components/opentherm_gw/climate.py b/homeassistant/components/opentherm_gw/climate.py index c69151c293a..68463e764f2 100644 --- a/homeassistant/components/opentherm_gw/climate.py +++ b/homeassistant/components/opentherm_gw/climate.py @@ -2,9 +2,9 @@ from __future__ import annotations +from collections.abc import Mapping from dataclasses import dataclass import logging -from types import MappingProxyType from typing import Any from pyotgw import vars as gw_vars @@ -94,7 +94,7 @@ class OpenThermClimate(OpenThermStatusEntity, ClimateEntity): self, gw_hub: OpenThermGatewayHub, description: OpenThermClimateEntityDescription, - options: MappingProxyType[str, Any], + options: Mapping[str, Any], ) -> None: """Initialize the entity.""" super().__init__(gw_hub, description) diff --git a/homeassistant/components/overkiz/__init__.py b/homeassistant/components/overkiz/__init__.py index 8aa1ed0e4fe..c9bf618ee8f 100644 --- a/homeassistant/components/overkiz/__init__.py +++ b/homeassistant/components/overkiz/__init__.py @@ -12,6 +12,7 @@ from pyoverkiz.enums import APIType, OverkizState, UIClass, UIWidget from pyoverkiz.exceptions import ( BadCredentialsException, MaintenanceException, + NotAuthenticatedException, NotSuchTokenException, TooManyRequestsException, ) @@ -92,7 +93,11 @@ async def async_setup_entry(hass: HomeAssistant, entry: OverkizDataConfigEntry) scenarios = await client.get_scenarios() else: scenarios = [] - except (BadCredentialsException, NotSuchTokenException) as exception: + except ( + BadCredentialsException, + NotSuchTokenException, + NotAuthenticatedException, + ) as exception: raise ConfigEntryAuthFailed("Invalid authentication") from exception except TooManyRequestsException as exception: raise ConfigEntryNotReady("Too many requests, try again later") from exception diff --git a/homeassistant/components/overkiz/climate/atlantic_electrical_heater_with_adjustable_temperature_setpoint.py b/homeassistant/components/overkiz/climate/atlantic_electrical_heater_with_adjustable_temperature_setpoint.py index 93c7d03293b..041571f7b5f 100644 --- a/homeassistant/components/overkiz/climate/atlantic_electrical_heater_with_adjustable_temperature_setpoint.py +++ b/homeassistant/components/overkiz/climate/atlantic_electrical_heater_with_adjustable_temperature_setpoint.py @@ -13,6 +13,7 @@ from homeassistant.components.climate import ( PRESET_NONE, ClimateEntity, ClimateEntityFeature, + HVACAction, HVACMode, ) from homeassistant.const import ATTR_TEMPERATURE, UnitOfTemperature @@ -56,6 +57,12 @@ OVERKIZ_TO_HVAC_MODE: dict[str, HVACMode] = { OverkizCommandParam.INTERNAL: HVACMode.AUTO, } +OVERKIZ_TO_HVAC_ACTION: dict[str, HVACAction] = { + OverkizCommandParam.STANDBY: HVACAction.IDLE, + OverkizCommandParam.INCREASE: HVACAction.HEATING, + OverkizCommandParam.NONE: HVACAction.OFF, +} + HVAC_MODE_TO_OVERKIZ = {v: k for k, v in OVERKIZ_TO_HVAC_MODE.items()} TEMPERATURE_SENSOR_DEVICE_INDEX = 2 @@ -102,6 +109,14 @@ class AtlanticElectricalHeaterWithAdjustableTemperatureSetpoint( OverkizCommand.SET_OPERATING_MODE, HVAC_MODE_TO_OVERKIZ[hvac_mode] ) + @property + def hvac_action(self) -> HVACAction: + """Return the current running hvac operation ie. heating, idle, off.""" + states = self.device.states + if (state := states[OverkizState.CORE_REGULATION_MODE]) and state.value_as_str: + return OVERKIZ_TO_HVAC_ACTION[state.value_as_str] + return HVACAction.OFF + @property def preset_mode(self) -> str | None: """Return the current preset mode, e.g., home, away, temp.""" diff --git a/homeassistant/components/overkiz/climate/atlantic_electrical_towel_dryer.py b/homeassistant/components/overkiz/climate/atlantic_electrical_towel_dryer.py index 0b5ba3ffcc7..e0cfebc2449 100644 --- a/homeassistant/components/overkiz/climate/atlantic_electrical_towel_dryer.py +++ b/homeassistant/components/overkiz/climate/atlantic_electrical_towel_dryer.py @@ -20,11 +20,13 @@ from ..coordinator import OverkizDataUpdateCoordinator from ..entity import OverkizEntity PRESET_DRYING = "drying" +PRESET_PROG = "prog" OVERKIZ_TO_HVAC_MODE: dict[str, HVACMode] = { OverkizCommandParam.EXTERNAL: HVACMode.HEAT, # manu - OverkizCommandParam.INTERNAL: HVACMode.AUTO, # prog - OverkizCommandParam.STANDBY: HVACMode.OFF, + OverkizCommandParam.INTERNAL: HVACMode.AUTO, # prog (schedule, user program) - mapped as preset + OverkizCommandParam.AUTO: HVACMode.AUTO, # auto (intelligent, user behavior) + OverkizCommandParam.STANDBY: HVACMode.OFF, # off } HVAC_MODE_TO_OVERKIZ = {v: k for k, v in OVERKIZ_TO_HVAC_MODE.items()} @@ -33,7 +35,6 @@ OVERKIZ_TO_PRESET_MODE: dict[str, str] = { OverkizCommandParam.BOOST: PRESET_BOOST, OverkizCommandParam.DRYING: PRESET_DRYING, } - PRESET_MODE_TO_OVERKIZ = {v: k for k, v in OVERKIZ_TO_PRESET_MODE.items()} TEMPERATURE_SENSOR_DEVICE_INDEX = 7 @@ -43,9 +44,15 @@ class AtlanticElectricalTowelDryer(OverkizEntity, ClimateEntity): """Representation of Atlantic Electrical Towel Dryer.""" _attr_hvac_modes = [*HVAC_MODE_TO_OVERKIZ] - _attr_preset_modes = [*PRESET_MODE_TO_OVERKIZ] + _attr_preset_modes = [PRESET_NONE, PRESET_PROG] _attr_temperature_unit = UnitOfTemperature.CELSIUS _attr_translation_key = DOMAIN + _attr_supported_features = ( + ClimateEntityFeature.TARGET_TEMPERATURE + | ClimateEntityFeature.TURN_OFF + | ClimateEntityFeature.TURN_ON + | ClimateEntityFeature.PRESET_MODE + ) def __init__( self, device_url: str, coordinator: OverkizDataUpdateCoordinator @@ -56,15 +63,15 @@ class AtlanticElectricalTowelDryer(OverkizEntity, ClimateEntity): TEMPERATURE_SENSOR_DEVICE_INDEX ) - self._attr_supported_features = ( - ClimateEntityFeature.TARGET_TEMPERATURE - | ClimateEntityFeature.TURN_OFF - | ClimateEntityFeature.TURN_ON - ) - - # Not all AtlanticElectricalTowelDryer models support presets, thus we need to check if the command is available + # Not all AtlanticElectricalTowelDryer models support temporary presets, + # thus we check if the command is available and then extend the presets if self.executor.has_command(OverkizCommand.SET_TOWEL_DRYER_TEMPORARY_STATE): - self._attr_supported_features |= ClimateEntityFeature.PRESET_MODE + # Extend preset modes with supported temporary presets, avoiding duplicates + self._attr_preset_modes += [ + mode + for mode in PRESET_MODE_TO_OVERKIZ + if mode not in self._attr_preset_modes + ] @property def hvac_mode(self) -> HVACMode: @@ -120,16 +127,53 @@ class AtlanticElectricalTowelDryer(OverkizEntity, ClimateEntity): @property def preset_mode(self) -> str | None: """Return the current preset mode, e.g., home, away, temp.""" - return OVERKIZ_TO_PRESET_MODE[ - cast( - str, - self.executor.select_state(OverkizState.IO_TOWEL_DRYER_TEMPORARY_STATE), - ) - ] + if ( + OverkizState.CORE_OPERATING_MODE in self.device.states + and cast(str, self.executor.select_state(OverkizState.CORE_OPERATING_MODE)) + == OverkizCommandParam.INTERNAL + ): + return PRESET_PROG + + if PRESET_DRYING in self._attr_preset_modes: + return OVERKIZ_TO_PRESET_MODE[ + cast( + str, + self.executor.select_state( + OverkizState.IO_TOWEL_DRYER_TEMPORARY_STATE + ), + ) + ] + + return PRESET_NONE async def async_set_preset_mode(self, preset_mode: str) -> None: """Set new preset mode.""" - await self.executor.async_execute_command( - OverkizCommand.SET_TOWEL_DRYER_TEMPORARY_STATE, - PRESET_MODE_TO_OVERKIZ[preset_mode], - ) + # If the preset mode is set to prog, we need to set the operating mode to internal + if preset_mode == PRESET_PROG: + # If currently in a temporary preset (drying or boost), turn it off before turn on prog + if self.preset_mode in (PRESET_DRYING, PRESET_BOOST): + await self.executor.async_execute_command( + OverkizCommand.SET_TOWEL_DRYER_TEMPORARY_STATE, + OverkizCommandParam.PERMANENT_HEATING, + ) + + await self.executor.async_execute_command( + OverkizCommand.SET_TOWEL_DRYER_OPERATING_MODE, + OverkizCommandParam.INTERNAL, + ) + + # If the preset mode is set from prog to none, we need to set the operating mode to external + # This will set the towel dryer to auto (intelligent mode) + elif preset_mode == PRESET_NONE and self.preset_mode == PRESET_PROG: + await self.executor.async_execute_command( + OverkizCommand.SET_TOWEL_DRYER_OPERATING_MODE, + OverkizCommandParam.AUTO, + ) + + # Normal behavior of setting a preset mode + # for towel dryers that support temporary presets + elif PRESET_DRYING in self._attr_preset_modes: + await self.executor.async_execute_command( + OverkizCommand.SET_TOWEL_DRYER_TEMPORARY_STATE, + PRESET_MODE_TO_OVERKIZ[preset_mode], + ) diff --git a/homeassistant/components/overkiz/climate/somfy_heating_temperature_interface.py b/homeassistant/components/overkiz/climate/somfy_heating_temperature_interface.py index 5ca17f9b6b1..381ec4d83ba 100644 --- a/homeassistant/components/overkiz/climate/somfy_heating_temperature_interface.py +++ b/homeassistant/components/overkiz/climate/somfy_heating_temperature_interface.py @@ -77,7 +77,7 @@ class SomfyHeatingTemperatureInterface(OverkizEntity, ClimateEntity): | ClimateEntityFeature.TURN_OFF | ClimateEntityFeature.TURN_ON ) - _attr_hvac_modes = [*HVAC_MODES_TO_OVERKIZ] + _attr_hvac_modes = [*HVAC_MODES_TO_OVERKIZ, HVACMode.OFF] _attr_preset_modes = [*PRESET_MODES_TO_OVERKIZ] # Both min and max temp values have been retrieved from the Somfy Application. _attr_min_temp = 15.0 @@ -110,9 +110,14 @@ class SomfyHeatingTemperatureInterface(OverkizEntity, ClimateEntity): async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None: """Set new target hvac mode.""" - await self.executor.async_execute_command( - OverkizCommand.SET_ACTIVE_MODE, HVAC_MODES_TO_OVERKIZ[hvac_mode] - ) + if hvac_mode is HVACMode.OFF: + await self.executor.async_execute_command( + OverkizCommand.SET_ON_OFF, OverkizCommandParam.OFF + ) + else: + await self.executor.async_execute_command( + OverkizCommand.SET_ACTIVE_MODE, HVAC_MODES_TO_OVERKIZ[hvac_mode] + ) @property def preset_mode(self) -> str | None: diff --git a/homeassistant/components/overkiz/config_flow.py b/homeassistant/components/overkiz/config_flow.py index af955e5fb95..520e9460147 100644 --- a/homeassistant/components/overkiz/config_flow.py +++ b/homeassistant/components/overkiz/config_flow.py @@ -13,12 +13,12 @@ from pyoverkiz.exceptions import ( BadCredentialsException, CozyTouchBadCredentialsException, MaintenanceException, + NotAuthenticatedException, NotSuchTokenException, TooManyAttemptsBannedException, TooManyRequestsException, UnknownUserException, ) -from pyoverkiz.models import OverkizServer from pyoverkiz.obfuscate import obfuscate_id from pyoverkiz.utils import generate_local_server, is_overkiz_gateway import voluptuous as vol @@ -31,7 +31,6 @@ from homeassistant.const import ( CONF_USERNAME, CONF_VERIFY_SSL, ) -from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.aiohttp_client import async_create_clientsession from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo @@ -39,15 +38,12 @@ from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo from .const import CONF_API_TYPE, CONF_HUB, DEFAULT_SERVER, DOMAIN, LOGGER -class DeveloperModeDisabled(HomeAssistantError): - """Error to indicate Somfy Developer Mode is disabled.""" - - class OverkizConfigFlow(ConfigFlow, domain=DOMAIN): """Handle a config flow for Overkiz (by Somfy).""" VERSION = 1 + _verify_ssl: bool = True _api_type: APIType = APIType.CLOUD _user: str | None = None _server: str = DEFAULT_SERVER @@ -57,27 +53,36 @@ class OverkizConfigFlow(ConfigFlow, domain=DOMAIN): """Validate user credentials.""" user_input[CONF_API_TYPE] = self._api_type - client = self._create_cloud_client( - username=user_input[CONF_USERNAME], - password=user_input[CONF_PASSWORD], - server=SUPPORTED_SERVERS[user_input[CONF_HUB]], - ) - await client.login(register_event_listener=False) - - # For Local API, we create and activate a local token if self._api_type == APIType.LOCAL: - user_input[CONF_TOKEN] = await self._create_local_api_token( - cloud_client=client, - host=user_input[CONF_HOST], + user_input[CONF_VERIFY_SSL] = self._verify_ssl + session = async_create_clientsession( + self.hass, verify_ssl=user_input[CONF_VERIFY_SSL] + ) + client = OverkizClient( + username="", + password="", + token=user_input[CONF_TOKEN], + session=session, + server=generate_local_server(host=user_input[CONF_HOST]), verify_ssl=user_input[CONF_VERIFY_SSL], ) + else: # APIType.CLOUD + session = async_create_clientsession(self.hass) + client = OverkizClient( + username=user_input[CONF_USERNAME], + password=user_input[CONF_PASSWORD], + server=SUPPORTED_SERVERS[user_input[CONF_HUB]], + session=session, + ) + + await client.login(register_event_listener=False) # Set main gateway id as unique id if gateways := await client.get_gateways(): for gateway in gateways: if is_overkiz_gateway(gateway.id): - gateway_id = gateway.id - await self.async_set_unique_id(gateway_id, raise_on_progress=False) + await self.async_set_unique_id(gateway.id, raise_on_progress=False) + break return user_input @@ -141,15 +146,13 @@ class OverkizConfigFlow(ConfigFlow, domain=DOMAIN): if user_input: self._user = user_input[CONF_USERNAME] - - # inherit the server from previous step user_input[CONF_HUB] = self._server try: await self.async_validate_input(user_input) except TooManyRequestsException: errors["base"] = "too_many_requests" - except BadCredentialsException as exception: + except (BadCredentialsException, NotAuthenticatedException) as exception: # If authentication with CozyTouch auth server is valid, but token is invalid # for Overkiz API server, the hardware is not supported. if user_input[CONF_HUB] in { @@ -211,16 +214,18 @@ class OverkizConfigFlow(ConfigFlow, domain=DOMAIN): if user_input: self._host = user_input[CONF_HOST] - self._user = user_input[CONF_USERNAME] - - # inherit the server from previous step + self._verify_ssl = user_input[CONF_VERIFY_SSL] user_input[CONF_HUB] = self._server try: user_input = await self.async_validate_input(user_input) except TooManyRequestsException: errors["base"] = "too_many_requests" - except BadCredentialsException: + except ( + BadCredentialsException, + NotSuchTokenException, + NotAuthenticatedException, + ): errors["base"] = "invalid_auth" except ClientConnectorCertificateError as exception: errors["base"] = "certificate_verify_failed" @@ -232,10 +237,6 @@ class OverkizConfigFlow(ConfigFlow, domain=DOMAIN): errors["base"] = "server_in_maintenance" except TooManyAttemptsBannedException: errors["base"] = "too_many_attempts" - except NotSuchTokenException: - errors["base"] = "no_such_token" - except DeveloperModeDisabled: - errors["base"] = "developer_mode_disabled" except UnknownUserException: # Somfy Protect accounts are not supported since they don't use # the Overkiz API server. Login will return unknown user. @@ -264,9 +265,8 @@ class OverkizConfigFlow(ConfigFlow, domain=DOMAIN): data_schema=vol.Schema( { vol.Required(CONF_HOST, default=self._host): str, - vol.Required(CONF_USERNAME, default=self._user): str, - vol.Required(CONF_PASSWORD): str, - vol.Required(CONF_VERIFY_SSL, default=True): bool, + vol.Required(CONF_TOKEN): str, + vol.Required(CONF_VERIFY_SSL, default=self._verify_ssl): bool, } ), description_placeholders=description_placeholders, @@ -320,64 +320,15 @@ class OverkizConfigFlow(ConfigFlow, domain=DOMAIN): self, entry_data: Mapping[str, Any] ) -> ConfigFlowResult: """Handle reauth.""" - # overkiz entries always have unique IDs + # Overkiz entries always have unique IDs self.context["title_placeholders"] = {"gateway_id": cast(str, self.unique_id)} - - self._user = entry_data[CONF_USERNAME] - self._server = entry_data[CONF_HUB] self._api_type = entry_data.get(CONF_API_TYPE, APIType.CLOUD) + self._server = entry_data[CONF_HUB] if self._api_type == APIType.LOCAL: self._host = entry_data[CONF_HOST] + self._verify_ssl = entry_data[CONF_VERIFY_SSL] + else: + self._user = entry_data[CONF_USERNAME] return await self.async_step_user(dict(entry_data)) - - def _create_cloud_client( - self, username: str, password: str, server: OverkizServer - ) -> OverkizClient: - session = async_create_clientsession(self.hass) - return OverkizClient( - username=username, password=password, server=server, session=session - ) - - async def _create_local_api_token( - self, cloud_client: OverkizClient, host: str, verify_ssl: bool - ) -> str: - """Create local API token.""" - # Create session on Somfy cloud server to generate an access token for local API - gateways = await cloud_client.get_gateways() - - gateway_id = "" - for gateway in gateways: - # Overkiz can return multiple gateways, but we only can generate a token - # for the main gateway. - if is_overkiz_gateway(gateway.id): - gateway_id = gateway.id - - developer_mode = await cloud_client.get_setup_option( - f"developerMode-{gateway_id}" - ) - - if developer_mode is None: - raise DeveloperModeDisabled - - token = await cloud_client.generate_local_token(gateway_id) - await cloud_client.activate_local_token( - gateway_id=gateway_id, token=token, label="Home Assistant/local" - ) - - session = async_create_clientsession(self.hass, verify_ssl=verify_ssl) - - # Local API - local_client = OverkizClient( - username="", - password="", - token=token, - session=session, - server=generate_local_server(host=host), - verify_ssl=verify_ssl, - ) - - await local_client.login() - - return token diff --git a/homeassistant/components/overkiz/coordinator.py b/homeassistant/components/overkiz/coordinator.py index 4b79cfc9c06..598bf4b06d0 100644 --- a/homeassistant/components/overkiz/coordinator.py +++ b/homeassistant/components/overkiz/coordinator.py @@ -79,7 +79,7 @@ class OverkizDataUpdateCoordinator(DataUpdateCoordinator[dict[str, Device]]): """Fetch Overkiz data via event listener.""" try: events = await self.client.fetch_events() - except BadCredentialsException as exception: + except (BadCredentialsException, NotAuthenticatedException) as exception: raise ConfigEntryAuthFailed("Invalid authentication.") from exception except TooManyConcurrentRequestsException as exception: raise UpdateFailed("Too many concurrent requests.") from exception @@ -98,7 +98,7 @@ class OverkizDataUpdateCoordinator(DataUpdateCoordinator[dict[str, Device]]): try: await self.client.login() self.devices = await self._get_devices() - except BadCredentialsException as exception: + except (BadCredentialsException, NotAuthenticatedException) as exception: raise ConfigEntryAuthFailed("Invalid authentication.") from exception except TooManyRequestsException as exception: raise UpdateFailed("Too many requests, try again later.") from exception diff --git a/homeassistant/components/overkiz/icons.json b/homeassistant/components/overkiz/icons.json new file mode 100644 index 00000000000..3347750063e --- /dev/null +++ b/homeassistant/components/overkiz/icons.json @@ -0,0 +1,46 @@ +{ + "entity": { + "climate": { + "overkiz": { + "state_attributes": { + "preset_mode": { + "state": { + "auto": "mdi:thermostat-auto", + "comfort-1": "mdi:thermometer", + "comfort-2": "mdi:thermometer-low", + "drying": "mdi:hair-dryer", + "frost_protection": "mdi:snowflake", + "prog": "mdi:clock-outline", + "external": "mdi:remote" + } + } + } + } + }, + "select": { + "open_closed_pedestrian": { + "default": "mdi:content-save-cog" + }, + "open_closed_partial": { + "default": "mdi:content-save-cog" + }, + "memorized_simple_volume": { + "default": "mdi:volume-medium", + "state": { + "highest": "mdi:volume-high", + "standard": "mdi:volume-medium" + } + }, + "operating_mode": { + "default": "mdi:sun-snowflake", + "state": { + "heating": "mdi:heat-wave", + "cooling": "mdi:snowflake" + } + }, + "active_zones": { + "default": "mdi:shield-lock" + } + } + } +} diff --git a/homeassistant/components/overkiz/manifest.json b/homeassistant/components/overkiz/manifest.json index 7f4be56979a..6f1af6d5aca 100644 --- a/homeassistant/components/overkiz/manifest.json +++ b/homeassistant/components/overkiz/manifest.json @@ -13,7 +13,7 @@ "integration_type": "hub", "iot_class": "local_polling", "loggers": ["boto3", "botocore", "pyhumps", "pyoverkiz", "s3transfer"], - "requirements": ["pyoverkiz==1.17.0"], + "requirements": ["pyoverkiz==1.17.1"], "zeroconf": [ { "type": "_kizbox._tcp.local.", diff --git a/homeassistant/components/overkiz/select.py b/homeassistant/components/overkiz/select.py index e23dafdaab8..d93b71b540f 100644 --- a/homeassistant/components/overkiz/select.py +++ b/homeassistant/components/overkiz/select.py @@ -72,7 +72,6 @@ SELECT_DESCRIPTIONS: list[OverkizSelectDescription] = [ OverkizSelectDescription( key=OverkizState.CORE_OPEN_CLOSED_PEDESTRIAN, name="Position", - icon="mdi:content-save-cog", options=[ OverkizCommandParam.OPEN, OverkizCommandParam.PEDESTRIAN, @@ -84,7 +83,6 @@ SELECT_DESCRIPTIONS: list[OverkizSelectDescription] = [ OverkizSelectDescription( key=OverkizState.CORE_OPEN_CLOSED_PARTIAL, name="Position", - icon="mdi:content-save-cog", options=[ OverkizCommandParam.OPEN, OverkizCommandParam.PARTIAL, @@ -96,7 +94,6 @@ SELECT_DESCRIPTIONS: list[OverkizSelectDescription] = [ OverkizSelectDescription( key=OverkizState.IO_MEMORIZED_SIMPLE_VOLUME, name="Memorized simple volume", - icon="mdi:volume-high", options=[OverkizCommandParam.STANDARD, OverkizCommandParam.HIGHEST], select_option=_select_option_memorized_simple_volume, entity_category=EntityCategory.CONFIG, @@ -106,20 +103,20 @@ SELECT_DESCRIPTIONS: list[OverkizSelectDescription] = [ OverkizSelectDescription( key=OverkizState.OVP_HEATING_TEMPERATURE_INTERFACE_OPERATING_MODE, name="Operating mode", - icon="mdi:sun-snowflake", options=[OverkizCommandParam.HEATING, OverkizCommandParam.COOLING], select_option=lambda option, execute_command: execute_command( OverkizCommand.SET_OPERATING_MODE, option ), entity_category=EntityCategory.CONFIG, + translation_key="operating_mode", ), # StatefulAlarmController OverkizSelectDescription( key=OverkizState.CORE_ACTIVE_ZONES, name="Active zones", - icon="mdi:shield-lock", options=["", "A", "B", "C", "A,B", "B,C", "A,C", "A,B,C"], select_option=_select_option_active_zone, + translation_key="active_zones", ), ] diff --git a/homeassistant/components/overkiz/strings.json b/homeassistant/components/overkiz/strings.json index 363147150dc..c8f0fae3622 100644 --- a/homeassistant/components/overkiz/strings.json +++ b/homeassistant/components/overkiz/strings.json @@ -32,17 +32,15 @@ } }, "local": { - "description": "By activating the [Developer Mode of your TaHoma box](https://github.com/Somfy-Developer/Somfy-TaHoma-Developer-Mode#getting-started), you can authorize third-party software (like Home Assistant) to connect to it via your local network.\n\nAfter activation, enter your application credentials and change the host to include your Gateway PIN or enter the IP address of your gateway.", + "description": "By activating the [Developer Mode of your TaHoma box](https://github.com/Somfy-Developer/Somfy-TaHoma-Developer-Mode#getting-started), you can authorize third-party software (like Home Assistant) to connect to it via your local network.\n\n1. Open the TaHoma By Somfy application on your device.\n2. Navigate to the Help & advanced features -> Advanced features menu in the application.\n3. Activate Developer Mode by tapping 7 times on the version number of your gateway (like 2025.1.4-11).\n4. Generate a token from the Developer Mode menu to authenticate your API calls.\n\n5. Enter the generated token below and update the host to include your Gateway PIN or the IP address of your gateway.", "data": { "host": "[%key:common::config_flow::data::host%]", - "username": "[%key:common::config_flow::data::username%]", - "password": "[%key:common::config_flow::data::password%]", + "token": "[%key:common::config_flow::data::api_token%]", "verify_ssl": "[%key:common::config_flow::data::verify_ssl%]" }, "data_description": { "host": "The hostname or IP address of your Overkiz hub.", - "username": "The username of your cloud account (app).", - "password": "The password of your cloud account (app).", + "token": "Token generated by the app used to control your device.", "verify_ssl": "Verify the SSL certificate. Select this only if you are connecting via the hostname." } } @@ -73,8 +71,8 @@ "state": { "auto": "[%key:common::state::auto%]", "manual": "[%key:common::state::manual%]", - "comfort-1": "Comfort 1", - "comfort-2": "Comfort 2", + "comfort-1": "Comfort -1°C", + "comfort-2": "Comfort -2°C", "drying": "Drying", "external": "External", "freeze": "Freeze", @@ -114,6 +112,12 @@ "highest": "Highest", "standard": "Standard" } + }, + "operating_mode": { + "state": { + "heating": "[%key:component::climate::entity_component::_::state_attributes::hvac_action::state::heating%]", + "cooling": "[%key:component::climate::entity_component::_::state_attributes::hvac_action::state::cooling%]" + } } }, "sensor": { diff --git a/homeassistant/components/peblar/strings.json b/homeassistant/components/peblar/strings.json index 416f1a2c062..9d88892fef1 100644 --- a/homeassistant/components/peblar/strings.json +++ b/homeassistant/components/peblar/strings.json @@ -108,7 +108,7 @@ "name": "State", "state": { "charging": "[%key:common::state::charging%]", - "error": "Error", + "error": "[%key:common::state::error%]", "fault": "Fault", "invalid": "Invalid", "no_ev_connected": "No EV connected", diff --git a/homeassistant/components/prusalink/strings.json b/homeassistant/components/prusalink/strings.json index 036bd2c9c6e..6c698cf3dc2 100644 --- a/homeassistant/components/prusalink/strings.json +++ b/homeassistant/components/prusalink/strings.json @@ -37,7 +37,7 @@ "paused": "[%key:common::state::paused%]", "finished": "Finished", "stopped": "[%key:common::state::stopped%]", - "error": "Error", + "error": "[%key:common::state::error%]", "attention": "Attention", "ready": "Ready" } diff --git a/homeassistant/components/random/strings.json b/homeassistant/components/random/strings.json index e5c5543e39f..bacd6dd5a17 100644 --- a/homeassistant/components/random/strings.json +++ b/homeassistant/components/random/strings.json @@ -84,8 +84,10 @@ "options": { "apparent_power": "[%key:component::sensor::entity_component::apparent_power::name%]", "aqi": "[%key:component::sensor::entity_component::aqi::name%]", + "area": "[%key:component::sensor::entity_component::area::name%]", "atmospheric_pressure": "[%key:component::sensor::entity_component::atmospheric_pressure::name%]", "battery": "[%key:component::sensor::entity_component::battery::name%]", + "blood_glucose_concentration": "[%key:component::sensor::entity_component::blood_glucose_concentration::name%]", "carbon_dioxide": "[%key:component::sensor::entity_component::carbon_dioxide::name%]", "carbon_monoxide": "[%key:component::sensor::entity_component::carbon_monoxide::name%]", "conductivity": "[%key:component::sensor::entity_component::conductivity::name%]", diff --git a/homeassistant/components/recorder/manifest.json b/homeassistant/components/recorder/manifest.json index 82fdeaca045..01b5d089bf3 100644 --- a/homeassistant/components/recorder/manifest.json +++ b/homeassistant/components/recorder/manifest.json @@ -8,7 +8,7 @@ "quality_scale": "internal", "requirements": [ "SQLAlchemy==2.0.40", - "fnv-hash-fast==1.4.0", + "fnv-hash-fast==1.5.0", "psutil-home-assistant==0.0.1" ] } diff --git a/homeassistant/components/renault/config_flow.py b/homeassistant/components/renault/config_flow.py index 90d2c11613c..a7998af953a 100644 --- a/homeassistant/components/renault/config_flow.py +++ b/homeassistant/components/renault/config_flow.py @@ -64,9 +64,10 @@ class RenaultFlowHandler(ConfigFlow, domain=DOMAIN): if login_success: return await self.async_step_kamereon() errors["base"] = "invalid_credentials" + return self.async_show_form( step_id="user", - data_schema=USER_SCHEMA, + data_schema=self.add_suggested_values_to_schema(USER_SCHEMA, user_input), errors=errors, ) diff --git a/homeassistant/components/renault/quality_scale.yaml b/homeassistant/components/renault/quality_scale.yaml index f2d70622192..a4e3252dcd6 100644 --- a/homeassistant/components/renault/quality_scale.yaml +++ b/homeassistant/components/renault/quality_scale.yaml @@ -52,7 +52,7 @@ rules: entity-device-class: done entity-disabled-by-default: done entity-translations: done - exception-translations: todo + exception-translations: done icon-translations: done reconfiguration-flow: todo repair-issues: done diff --git a/homeassistant/components/renault/renault_vehicle.py b/homeassistant/components/renault/renault_vehicle.py index 8d096a734e1..2ecaa7e1061 100644 --- a/homeassistant/components/renault/renault_vehicle.py +++ b/homeassistant/components/renault/renault_vehicle.py @@ -43,7 +43,11 @@ def with_error_wrapping[**_P, _R]( try: return await func(self, *args, **kwargs) except RenaultException as err: - raise HomeAssistantError(err) from err + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="unknown_error", + translation_placeholders={"error": str(err)}, + ) from err return wrapper diff --git a/homeassistant/components/renault/strings.json b/homeassistant/components/renault/strings.json index 727e8cf32f1..1e6af2b10fe 100644 --- a/homeassistant/components/renault/strings.json +++ b/homeassistant/components/renault/strings.json @@ -232,6 +232,9 @@ }, "no_config_entry_for_device": { "message": "No loaded config entry was found for device with ID {device_id}" + }, + "unknown_error": { + "message": "An unknown error occurred while communicating with the Renault servers: {error}" } } } diff --git a/homeassistant/components/roborock/strings.json b/homeassistant/components/roborock/strings.json index 0f36fbec3d5..b68d747e9a2 100644 --- a/homeassistant/components/roborock/strings.json +++ b/homeassistant/components/roborock/strings.json @@ -232,7 +232,7 @@ "charging_problem": "Charging problem", "paused": "[%key:common::state::paused%]", "spot_cleaning": "Spot cleaning", - "error": "Error", + "error": "[%key:common::state::error%]", "shutting_down": "Shutting down", "updating": "Updating", "docking": "Docking", diff --git a/homeassistant/components/russound_rio/manifest.json b/homeassistant/components/russound_rio/manifest.json index acedbaf0573..e16e589e648 100644 --- a/homeassistant/components/russound_rio/manifest.json +++ b/homeassistant/components/russound_rio/manifest.json @@ -7,6 +7,6 @@ "iot_class": "local_push", "loggers": ["aiorussound"], "quality_scale": "silver", - "requirements": ["aiorussound==4.5.0"], + "requirements": ["aiorussound==4.5.2"], "zeroconf": ["_rio._tcp.local."] } diff --git a/homeassistant/components/schlage/manifest.json b/homeassistant/components/schlage/manifest.json index 61cc2a3c63d..893c30dfd41 100644 --- a/homeassistant/components/schlage/manifest.json +++ b/homeassistant/components/schlage/manifest.json @@ -5,5 +5,5 @@ "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/schlage", "iot_class": "cloud_polling", - "requirements": ["pyschlage==2024.11.0"] + "requirements": ["pyschlage==2025.4.0"] } diff --git a/homeassistant/components/sentry/__init__.py b/homeassistant/components/sentry/__init__.py index 904d493a863..5b89518c616 100644 --- a/homeassistant/components/sentry/__init__.py +++ b/homeassistant/components/sentry/__init__.py @@ -2,8 +2,8 @@ from __future__ import annotations +from collections.abc import Mapping import re -from types import MappingProxyType from typing import Any import sentry_sdk @@ -120,7 +120,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: def process_before_send( hass: HomeAssistant, - options: MappingProxyType[str, Any], + options: Mapping[str, Any], channel: str, huuid: str, system_info: dict[str, bool | str], diff --git a/homeassistant/components/shelly/climate.py b/homeassistant/components/shelly/climate.py index 498f2d3dba9..f8cdb13ba9f 100644 --- a/homeassistant/components/shelly/climate.py +++ b/homeassistant/components/shelly/climate.py @@ -7,12 +7,7 @@ from dataclasses import asdict, dataclass from typing import Any, cast from aioshelly.block_device import Block -from aioshelly.const import ( - BLU_TRV_IDENTIFIER, - BLU_TRV_MODEL_NAME, - BLU_TRV_TIMEOUT, - RPC_GENERATIONS, -) +from aioshelly.const import BLU_TRV_IDENTIFIER, BLU_TRV_MODEL_NAME, RPC_GENERATIONS from aioshelly.exceptions import DeviceConnectionError, InvalidAuthError from homeassistant.components.climate import ( @@ -48,7 +43,7 @@ from .const import ( SHTRV_01_TEMPERATURE_SETTINGS, ) from .coordinator import ShellyBlockCoordinator, ShellyConfigEntry, ShellyRpcCoordinator -from .entity import ShellyRpcEntity +from .entity import ShellyRpcEntity, rpc_call from .utils import ( async_remove_shelly_entity, get_device_entry_gen, @@ -601,17 +596,12 @@ class RpcBluTrvClimate(ShellyRpcEntity, ClimateEntity): return HVACAction.HEATING + @rpc_call async def async_set_temperature(self, **kwargs: Any) -> None: """Set new target temperature.""" if (target_temp := kwargs.get(ATTR_TEMPERATURE)) is None: return - await self.call_rpc( - "BluTRV.Call", - { - "id": self._id, - "method": "Trv.SetTarget", - "params": {"id": 0, "target_C": target_temp}, - }, - timeout=BLU_TRV_TIMEOUT, + await self.coordinator.device.blu_trv_set_target_temperature( + self._id, target_temp ) diff --git a/homeassistant/components/shelly/entity.py b/homeassistant/components/shelly/entity.py index 9ed3f47b41a..e8bf0d61b06 100644 --- a/homeassistant/components/shelly/entity.py +++ b/homeassistant/components/shelly/entity.py @@ -2,9 +2,10 @@ from __future__ import annotations -from collections.abc import Callable, Mapping +from collections.abc import Awaitable, Callable, Coroutine, Mapping from dataclasses import dataclass -from typing import Any, cast +from functools import wraps +from typing import Any, Concatenate, cast from aioshelly.block_device import Block from aioshelly.exceptions import DeviceConnectionError, InvalidAuthError, RpcCallError @@ -314,6 +315,41 @@ class RestEntityDescription(EntityDescription): value: Callable[[dict, Any], Any] | None = None +def rpc_call[_T: ShellyRpcEntity, **_P]( + func: Callable[Concatenate[_T, _P], Awaitable[None]], +) -> Callable[Concatenate[_T, _P], Coroutine[Any, Any, None]]: + """Catch rpc_call exceptions.""" + + @wraps(func) + async def cmd_wrapper(self: _T, *args: _P.args, **kwargs: _P.kwargs) -> None: + """Wrap all command methods.""" + try: + await func(self, *args, **kwargs) + except DeviceConnectionError as err: + self.coordinator.last_update_success = False + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="device_communication_action_error", + translation_placeholders={ + "entity": self.entity_id, + "device": self.coordinator.name, + }, + ) from err + except RpcCallError as err: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="rpc_call_action_error", + translation_placeholders={ + "entity": self.entity_id, + "device": self.coordinator.name, + }, + ) from err + except InvalidAuthError: + await self.coordinator.async_shutdown_device_and_start_reauth() + + return cmd_wrapper + + class ShellyBlockEntity(CoordinatorEntity[ShellyBlockCoordinator]): """Helper class to represent a block entity.""" @@ -392,6 +428,7 @@ class ShellyRpcEntity(CoordinatorEntity[ShellyRpcCoordinator]): """Handle device update.""" self.async_write_ha_state() + @rpc_call async def call_rpc( self, method: str, params: Any, timeout: float | None = None ) -> Any: @@ -403,31 +440,9 @@ class ShellyRpcEntity(CoordinatorEntity[ShellyRpcCoordinator]): params, timeout, ) - try: - if timeout: - return await self.coordinator.device.call_rpc(method, params, timeout) - return await self.coordinator.device.call_rpc(method, params) - except DeviceConnectionError as err: - self.coordinator.last_update_success = False - raise HomeAssistantError( - translation_domain=DOMAIN, - translation_key="device_communication_action_error", - translation_placeholders={ - "entity": self.entity_id, - "device": self.coordinator.name, - }, - ) from err - except RpcCallError as err: - raise HomeAssistantError( - translation_domain=DOMAIN, - translation_key="rpc_call_action_error", - translation_placeholders={ - "entity": self.entity_id, - "device": self.coordinator.name, - }, - ) from err - except InvalidAuthError: - await self.coordinator.async_shutdown_device_and_start_reauth() + if timeout: + return await self.coordinator.device.call_rpc(method, params, timeout) + return await self.coordinator.device.call_rpc(method, params) class ShellyBlockAttributeEntity(ShellyBlockEntity, Entity): diff --git a/homeassistant/components/shelly/manifest.json b/homeassistant/components/shelly/manifest.json index 19ccd1354a7..22f64b60727 100644 --- a/homeassistant/components/shelly/manifest.json +++ b/homeassistant/components/shelly/manifest.json @@ -8,7 +8,7 @@ "integration_type": "device", "iot_class": "local_push", "loggers": ["aioshelly"], - "requirements": ["aioshelly==13.4.1"], + "requirements": ["aioshelly==13.5.0"], "zeroconf": [ { "type": "_http._tcp.local.", diff --git a/homeassistant/components/shelly/number.py b/homeassistant/components/shelly/number.py index c629eb4a57a..83606df5a4d 100644 --- a/homeassistant/components/shelly/number.py +++ b/homeassistant/components/shelly/number.py @@ -7,7 +7,7 @@ from dataclasses import dataclass from typing import TYPE_CHECKING, Any, Final, cast from aioshelly.block_device import Block -from aioshelly.const import BLU_TRV_TIMEOUT, RPC_GENERATIONS +from aioshelly.const import RPC_GENERATIONS from aioshelly.exceptions import DeviceConnectionError, InvalidAuthError from homeassistant.components.number import ( @@ -34,6 +34,7 @@ from .entity import ( ShellySleepingBlockAttributeEntity, async_setup_entry_attribute_entities, async_setup_entry_rpc, + rpc_call, ) from .utils import ( async_remove_orphaned_entities, @@ -59,7 +60,6 @@ class RpcNumberDescription(RpcEntityDescription, NumberEntityDescription): step_fn: Callable[[dict], float] | None = None mode_fn: Callable[[dict], NumberMode] | None = None method: str - method_params_fn: Callable[[int, float], dict] class RpcNumber(ShellyRpcAttributeEntity, NumberEntity): @@ -98,15 +98,16 @@ class RpcNumber(ShellyRpcAttributeEntity, NumberEntity): return self.attribute_value + @rpc_call async def async_set_native_value(self, value: float) -> None: """Change the value.""" + method = getattr(self.coordinator.device, self.entity_description.method) + if TYPE_CHECKING: assert isinstance(self._id, int) + assert method is not None - await self.call_rpc( - self.entity_description.method, - self.entity_description.method_params_fn(self._id, value), - ) + await method(self._id, value) class RpcBluTrvNumber(RpcNumber): @@ -127,17 +128,6 @@ class RpcBluTrvNumber(RpcNumber): connections={(CONNECTION_BLUETOOTH, ble_addr)} ) - async def async_set_native_value(self, value: float) -> None: - """Change the value.""" - if TYPE_CHECKING: - assert isinstance(self._id, int) - - await self.call_rpc( - self.entity_description.method, - self.entity_description.method_params_fn(self._id, value), - timeout=BLU_TRV_TIMEOUT, - ) - class RpcBluTrvExtTempNumber(RpcBluTrvNumber): """Represent a RPC BluTrv External Temperature number.""" @@ -187,12 +177,7 @@ RPC_NUMBERS: Final = { mode=NumberMode.BOX, entity_category=EntityCategory.CONFIG, native_unit_of_measurement=UnitOfTemperature.CELSIUS, - method="BluTRV.Call", - method_params_fn=lambda idx, value: { - "id": idx, - "method": "Trv.SetExternalTemperature", - "params": {"id": 0, "t_C": value}, - }, + method="blu_trv_set_external_temperature", entity_class=RpcBluTrvExtTempNumber, ), "number": RpcNumberDescription( @@ -209,8 +194,7 @@ RPC_NUMBERS: Final = { unit=lambda config: config["meta"]["ui"]["unit"] if config["meta"]["ui"]["unit"] else None, - method="Number.Set", - method_params_fn=lambda idx, value: {"id": idx, "value": value}, + method="number_set", ), "valve_position": RpcNumberDescription( key="blutrv", @@ -222,12 +206,7 @@ RPC_NUMBERS: Final = { native_step=1, mode=NumberMode.SLIDER, native_unit_of_measurement=PERCENTAGE, - method="BluTRV.Call", - method_params_fn=lambda idx, value: { - "id": idx, - "method": "Trv.SetPosition", - "params": {"id": 0, "pos": int(value)}, - }, + method="blu_trv_set_valve_position", removal_condition=lambda config, _status, key: config[key].get("enable", True) is True, entity_class=RpcBluTrvNumber, diff --git a/homeassistant/components/shelly/select.py b/homeassistant/components/shelly/select.py index 1fb3dfb3447..98d374b496d 100644 --- a/homeassistant/components/shelly/select.py +++ b/homeassistant/components/shelly/select.py @@ -20,6 +20,7 @@ from .entity import ( RpcEntityDescription, ShellyRpcAttributeEntity, async_setup_entry_rpc, + rpc_call, ) from .utils import ( async_remove_orphaned_entities, @@ -75,6 +76,7 @@ class RpcSelect(ShellyRpcAttributeEntity, SelectEntity): """Represent a RPC select entity.""" entity_description: RpcSelectDescription + _id: int def __init__( self, @@ -96,8 +98,9 @@ class RpcSelect(ShellyRpcAttributeEntity, SelectEntity): return self.option_map[self.attribute_value] + @rpc_call async def async_select_option(self, option: str) -> None: """Change the value.""" - await self.call_rpc( - "Enum.Set", {"id": self._id, "value": self.reversed_option_map[option]} + await self.coordinator.device.enum_set( + self._id, self.reversed_option_map[option] ) diff --git a/homeassistant/components/shelly/text.py b/homeassistant/components/shelly/text.py index f64d1252b7e..8bca94603be 100644 --- a/homeassistant/components/shelly/text.py +++ b/homeassistant/components/shelly/text.py @@ -20,6 +20,7 @@ from .entity import ( RpcEntityDescription, ShellyRpcAttributeEntity, async_setup_entry_rpc, + rpc_call, ) from .utils import ( async_remove_orphaned_entities, @@ -75,6 +76,7 @@ class RpcText(ShellyRpcAttributeEntity, TextEntity): """Represent a RPC text entity.""" entity_description: RpcTextDescription + _id: int @property def native_value(self) -> str | None: @@ -84,6 +86,7 @@ class RpcText(ShellyRpcAttributeEntity, TextEntity): return self.attribute_value + @rpc_call async def async_set_value(self, value: str) -> None: """Change the value.""" - await self.call_rpc("Text.Set", {"id": self._id, "value": value}) + await self.coordinator.device.text_set(self._id, value) diff --git a/homeassistant/components/shelly/utils.py b/homeassistant/components/shelly/utils.py index 9284afdd567..0c8048d34e4 100644 --- a/homeassistant/components/shelly/utils.py +++ b/homeassistant/components/shelly/utils.py @@ -2,10 +2,9 @@ from __future__ import annotations -from collections.abc import Iterable +from collections.abc import Iterable, Mapping from datetime import datetime, timedelta from ipaddress import IPv4Address, IPv6Address, ip_address -from types import MappingProxyType from typing import TYPE_CHECKING, Any, cast from aiohttp.web import Request, WebSocketResponse @@ -546,7 +545,7 @@ def is_rpc_wifi_stations_disabled( return True -def get_http_port(data: MappingProxyType[str, Any]) -> int: +def get_http_port(data: Mapping[str, Any]) -> int: """Get port from config entry data.""" return cast(int, data.get(CONF_PORT, DEFAULT_HTTP_PORT)) diff --git a/homeassistant/components/smarty/__init__.py b/homeassistant/components/smarty/__init__.py index aab8c6ab3c7..1803f501dc7 100644 --- a/homeassistant/components/smarty/__init__.py +++ b/homeassistant/components/smarty/__init__.py @@ -1,34 +1,10 @@ """Support to control a Salda Smarty XP/XV ventilation unit.""" -import ipaddress -import logging +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant -import voluptuous as vol - -from homeassistant.config_entries import SOURCE_IMPORT -from homeassistant.const import CONF_HOST, CONF_NAME, Platform -from homeassistant.core import DOMAIN as HOMEASSISTANT_DOMAIN, HomeAssistant -from homeassistant.data_entry_flow import FlowResultType -from homeassistant.helpers import config_validation as cv, issue_registry as ir -from homeassistant.helpers.typing import ConfigType - -from .const import DOMAIN from .coordinator import SmartyConfigEntry, SmartyCoordinator -_LOGGER = logging.getLogger(__name__) - -CONFIG_SCHEMA = vol.Schema( - { - DOMAIN: vol.Schema( - { - vol.Required(CONF_HOST): vol.All(ipaddress.ip_address, cv.string), - vol.Optional(CONF_NAME, default="Smarty"): cv.string, - } - ) - }, - extra=vol.ALLOW_EXTRA, -) - PLATFORMS = [ Platform.BINARY_SENSOR, Platform.BUTTON, @@ -38,54 +14,6 @@ PLATFORMS = [ ] -async def async_setup(hass: HomeAssistant, hass_config: ConfigType) -> bool: - """Create a smarty system.""" - if config := hass_config.get(DOMAIN): - hass.async_create_task(_async_import(hass, config)) - return True - - -async def _async_import(hass: HomeAssistant, config: ConfigType) -> None: - """Set up the smarty environment.""" - - if not hass.config_entries.async_entries(DOMAIN): - # Start import flow - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": SOURCE_IMPORT}, data=config - ) - if result["type"] == FlowResultType.ABORT: - ir.async_create_issue( - hass, - DOMAIN, - f"deprecated_yaml_import_issue_{result['reason']}", - breaks_in_ha_version="2025.5.0", - is_fixable=False, - issue_domain=DOMAIN, - severity=ir.IssueSeverity.WARNING, - translation_key=f"deprecated_yaml_import_issue_{result['reason']}", - translation_placeholders={ - "domain": DOMAIN, - "integration_title": "Smarty", - }, - ) - return - - ir.async_create_issue( - hass, - HOMEASSISTANT_DOMAIN, - f"deprecated_yaml_{DOMAIN}", - breaks_in_ha_version="2025.5.0", - is_fixable=False, - issue_domain=DOMAIN, - severity=ir.IssueSeverity.WARNING, - translation_key="deprecated_yaml", - translation_placeholders={ - "domain": DOMAIN, - "integration_title": "Smarty", - }, - ) - - async def async_setup_entry(hass: HomeAssistant, entry: SmartyConfigEntry) -> bool: """Set up the Smarty environment from a config entry.""" diff --git a/homeassistant/components/smarty/config_flow.py b/homeassistant/components/smarty/config_flow.py index a7f0bdd4123..5abae121cd7 100644 --- a/homeassistant/components/smarty/config_flow.py +++ b/homeassistant/components/smarty/config_flow.py @@ -7,7 +7,7 @@ from pysmarty2 import Smarty import voluptuous as vol from homeassistant.config_entries import ConfigFlow, ConfigFlowResult -from homeassistant.const import CONF_HOST, CONF_NAME +from homeassistant.const import CONF_HOST from .const import DOMAIN @@ -50,17 +50,3 @@ class SmartyConfigFlow(ConfigFlow, domain=DOMAIN): data_schema=vol.Schema({vol.Required(CONF_HOST): str}), errors=errors, ) - - async def async_step_import( - self, import_config: dict[str, Any] - ) -> ConfigFlowResult: - """Handle a flow initialized by import.""" - error = await self.hass.async_add_executor_job( - self._test_connection, import_config[CONF_HOST] - ) - if not error: - return self.async_create_entry( - title=import_config[CONF_NAME], - data={CONF_HOST: import_config[CONF_HOST]}, - ) - return self.async_abort(reason=error) diff --git a/homeassistant/components/smarty/entity.py b/homeassistant/components/smarty/entity.py index d26b56d489f..f6533000f45 100644 --- a/homeassistant/components/smarty/entity.py +++ b/homeassistant/components/smarty/entity.py @@ -3,7 +3,7 @@ from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.update_coordinator import CoordinatorEntity -from . import DOMAIN +from .const import DOMAIN from .coordinator import SmartyCoordinator diff --git a/homeassistant/components/smarty/strings.json b/homeassistant/components/smarty/strings.json index 341a300a26e..d9852ab40d3 100644 --- a/homeassistant/components/smarty/strings.json +++ b/homeassistant/components/smarty/strings.json @@ -20,20 +20,6 @@ "unknown": "[%key:common::config_flow::error::unknown%]" } }, - "issues": { - "deprecated_yaml_import_issue_unknown": { - "title": "YAML import failed with unknown error", - "description": "Configuring {integration_title} using YAML is being removed but there was an unknown error while importing your existing configuration.\nSetup will not proceed.\n\nVerify that your {integration_title} is operating correctly and restart Home Assistant to attempt the import again.\n\nAlternatively, you may remove the `{domain}` configuration from your configuration.yaml entirely, restart Home Assistant, and add the {integration_title} integration manually." - }, - "deprecated_yaml_import_issue_auth_error": { - "title": "YAML import failed due to an authentication error", - "description": "Configuring {integration_title} using YAML is being removed but there was an authentication error while importing your existing configuration.\nSetup will not proceed.\n\nVerify that your {integration_title} is operating correctly and restart Home Assistant to attempt the import again.\n\nAlternatively, you may remove the `{domain}` configuration from your configuration.yaml entirely, restart Home Assistant, and add the {integration_title} integration manually." - }, - "deprecated_yaml_import_issue_cannot_connect": { - "title": "YAML import failed due to a connection error", - "description": "Configuring {integration_title} using YAML is being removed but there was a connect error while importing your existing configuration.\nSetup will not proceed.\n\nVerify that your {integration_title} is operating correctly and restart Home Assistant to attempt the import again.\n\nAlternatively, you may remove the `{domain}` configuration from your configuration.yaml entirely, restart Home Assistant, and add the {integration_title} integration manually." - } - }, "entity": { "binary_sensor": { "alarm": { diff --git a/homeassistant/components/swiss_public_transport/helper.py b/homeassistant/components/swiss_public_transport/helper.py index e41901337f4..72dc1afab8a 100644 --- a/homeassistant/components/swiss_public_transport/helper.py +++ b/homeassistant/components/swiss_public_transport/helper.py @@ -1,7 +1,7 @@ """Helper functions for swiss_public_transport.""" +from collections.abc import Mapping from datetime import timedelta -from types import MappingProxyType from typing import Any from opendata_transport import OpendataTransport @@ -36,7 +36,7 @@ def dict_duration_to_str_duration( return f"{d['hours']:02d}:{d['minutes']:02d}:{d['seconds']:02d}" -def unique_id_from_config(config: MappingProxyType[str, Any] | dict[str, Any]) -> str: +def unique_id_from_config(config: Mapping[str, Any]) -> str: """Build a unique id from a config entry.""" return ( f"{config[CONF_START]} {config[CONF_DESTINATION]}" diff --git a/homeassistant/components/tailscale/binary_sensor.py b/homeassistant/components/tailscale/binary_sensor.py index 6569b40ada2..65ac69d89c7 100644 --- a/homeassistant/components/tailscale/binary_sensor.py +++ b/homeassistant/components/tailscale/binary_sensor.py @@ -46,37 +46,61 @@ BINARY_SENSORS: tuple[TailscaleBinarySensorEntityDescription, ...] = ( key="client_supports_hair_pinning", translation_key="client_supports_hair_pinning", entity_category=EntityCategory.DIAGNOSTIC, - is_on_fn=lambda device: device.client_connectivity.client_supports.hair_pinning, + is_on_fn=lambda device: ( + device.client_connectivity.client_supports.hair_pinning + if device.client_connectivity is not None + else None + ), ), TailscaleBinarySensorEntityDescription( key="client_supports_ipv6", translation_key="client_supports_ipv6", entity_category=EntityCategory.DIAGNOSTIC, - is_on_fn=lambda device: device.client_connectivity.client_supports.ipv6, + is_on_fn=lambda device: ( + device.client_connectivity.client_supports.ipv6 + if device.client_connectivity is not None + else None + ), ), TailscaleBinarySensorEntityDescription( key="client_supports_pcp", translation_key="client_supports_pcp", entity_category=EntityCategory.DIAGNOSTIC, - is_on_fn=lambda device: device.client_connectivity.client_supports.pcp, + is_on_fn=lambda device: ( + device.client_connectivity.client_supports.pcp + if device.client_connectivity is not None + else None + ), ), TailscaleBinarySensorEntityDescription( key="client_supports_pmp", translation_key="client_supports_pmp", entity_category=EntityCategory.DIAGNOSTIC, - is_on_fn=lambda device: device.client_connectivity.client_supports.pmp, + is_on_fn=lambda device: ( + device.client_connectivity.client_supports.pmp + if device.client_connectivity is not None + else None + ), ), TailscaleBinarySensorEntityDescription( key="client_supports_udp", translation_key="client_supports_udp", entity_category=EntityCategory.DIAGNOSTIC, - is_on_fn=lambda device: device.client_connectivity.client_supports.udp, + is_on_fn=lambda device: ( + device.client_connectivity.client_supports.udp + if device.client_connectivity is not None + else None + ), ), TailscaleBinarySensorEntityDescription( key="client_supports_upnp", translation_key="client_supports_upnp", entity_category=EntityCategory.DIAGNOSTIC, - is_on_fn=lambda device: device.client_connectivity.client_supports.upnp, + is_on_fn=lambda device: ( + device.client_connectivity.client_supports.upnp + if device.client_connectivity is not None + else None + ), ), ) diff --git a/homeassistant/components/tailscale/manifest.json b/homeassistant/components/tailscale/manifest.json index 7d571fe0675..8c005888387 100644 --- a/homeassistant/components/tailscale/manifest.json +++ b/homeassistant/components/tailscale/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/tailscale", "integration_type": "hub", "iot_class": "cloud_polling", - "requirements": ["tailscale==0.6.1"] + "requirements": ["tailscale==0.6.2"] } diff --git a/homeassistant/components/tesla_fleet/strings.json b/homeassistant/components/tesla_fleet/strings.json index fcd2e07306f..04bad432919 100644 --- a/homeassistant/components/tesla_fleet/strings.json +++ b/homeassistant/components/tesla_fleet/strings.json @@ -199,7 +199,7 @@ "name": "Charge limit" }, "off_grid_vehicle_charging_reserve_percent": { - "name": "Off grid reserve" + "name": "Off-grid reserve" } }, "select": { diff --git a/homeassistant/components/tesla_wall_connector/strings.json b/homeassistant/components/tesla_wall_connector/strings.json index b356a9f3ebc..f1247ea8f9f 100644 --- a/homeassistant/components/tesla_wall_connector/strings.json +++ b/homeassistant/components/tesla_wall_connector/strings.json @@ -38,7 +38,7 @@ "connected": "Vehicle connected", "ready": "Ready to charge", "negotiating": "Negotiating connection", - "error": "Error", + "error": "[%key:common::state::error%]", "charging_finished": "Charging finished", "waiting_car": "Waiting for car", "charging_reduced": "Charging (reduced)", diff --git a/homeassistant/components/teslemetry/__init__.py b/homeassistant/components/teslemetry/__init__.py index b820d2d1b43..d09ea66d479 100644 --- a/homeassistant/components/teslemetry/__init__.py +++ b/homeassistant/components/teslemetry/__init__.py @@ -100,6 +100,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: TeslemetryConfigEntry) - access_token, server=f"{region.lower()}.teslemetry.com", parse_timestamp=True, + manual=True, ) for product in products: @@ -236,6 +237,8 @@ async def async_setup_entry(hass: HomeAssistant, entry: TeslemetryConfigEntry) - entry.runtime_data = TeslemetryData(vehicles, energysites, scopes) await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + entry.async_create_background_task(hass, stream.listen(), "Teslemetry Stream") + return True diff --git a/homeassistant/components/teslemetry/binary_sensor.py b/homeassistant/components/teslemetry/binary_sensor.py index a5ea30e014d..3918484ea97 100644 --- a/homeassistant/components/teslemetry/binary_sensor.py +++ b/homeassistant/components/teslemetry/binary_sensor.py @@ -367,6 +367,51 @@ VEHICLE_DESCRIPTIONS: tuple[TeslemetryBinarySensorEntityDescription, ...] = ( streaming_firmware="2024.44.32", entity_registry_enabled_default=False, ), + TeslemetryBinarySensorEntityDescription( + key="charge_enable_request", + streaming_listener=lambda x, y: x.listen_ChargeEnableRequest(y), + entity_registry_enabled_default=False, + ), + TeslemetryBinarySensorEntityDescription( + key="defrost_for_preconditioning", + streaming_listener=lambda x, y: x.listen_DefrostForPreconditioning(y), + entity_registry_enabled_default=False, + streaming_firmware="2024.44.25", + ), + TeslemetryBinarySensorEntityDescription( + key="lights_high_beams", + streaming_listener=lambda x, y: x.listen_LightsHighBeams(y), + entity_registry_enabled_default=False, + streaming_firmware="2025.2.6", + ), + TeslemetryBinarySensorEntityDescription( + key="seat_vent_enabled", + streaming_listener=lambda x, y: x.listen_SeatVentEnabled(y), + entity_registry_enabled_default=False, + streaming_firmware="2025.2.6", + ), + TeslemetryBinarySensorEntityDescription( + key="speed_limit_mode", + streaming_listener=lambda x, y: x.listen_SpeedLimitMode(y), + entity_registry_enabled_default=False, + ), + TeslemetryBinarySensorEntityDescription( + key="remote_start_enabled", + streaming_listener=lambda x, y: x.listen_RemoteStartEnabled(y), + entity_registry_enabled_default=False, + ), + TeslemetryBinarySensorEntityDescription( + key="hvil", + streaming_listener=lambda x, y: x.listen_Hvil(lambda z: y(z == "Fault")), + device_class=BinarySensorDeviceClass.PROBLEM, + entity_registry_enabled_default=False, + entity_category=EntityCategory.DIAGNOSTIC, + ), + TeslemetryBinarySensorEntityDescription( + key="hvac_auto_mode", + streaming_listener=lambda x, y: x.listen_HvacAutoMode(lambda z: y(z == "On")), + entity_registry_enabled_default=False, + ), ) diff --git a/homeassistant/components/teslemetry/cover.py b/homeassistant/components/teslemetry/cover.py index de91f43f084..cde1d3f7d4f 100644 --- a/homeassistant/components/teslemetry/cover.py +++ b/homeassistant/components/teslemetry/cover.py @@ -175,7 +175,7 @@ class TeslemetryStreamingWindowEntity( self.async_on_remove( self.stream.async_add_listener( self._handle_stream_update, - {"vin": self.vin, "data": {self.streaming_key: None}}, + {"vin": self.vin, "data": None}, ) ) for signal in ( diff --git a/homeassistant/components/teslemetry/entity.py b/homeassistant/components/teslemetry/entity.py index 3d145d24b0c..8234e552eec 100644 --- a/homeassistant/components/teslemetry/entity.py +++ b/homeassistant/components/teslemetry/entity.py @@ -3,10 +3,8 @@ from abc import abstractmethod from typing import Any -from propcache.api import cached_property from tesla_fleet_api.const import Scope from tesla_fleet_api.teslemetry import EnergySite, Vehicle -from teslemetry_stream import Signal from homeassistant.exceptions import ServiceValidationError from homeassistant.helpers.device_registry import DeviceInfo @@ -20,7 +18,6 @@ from .coordinator import ( TeslemetryEnergySiteLiveCoordinator, TeslemetryVehicleDataCoordinator, ) -from .helpers import wake_up_vehicle from .models import TeslemetryEnergyData, TeslemetryVehicleData @@ -126,10 +123,6 @@ class TeslemetryVehicleEntity(TeslemetryEntity): """Return a specific value from coordinator data.""" return self.coordinator.data.get(self.key) - async def wake_up_if_asleep(self) -> None: - """Wake up the vehicle if its asleep.""" - await wake_up_vehicle(self.vehicle) - class TeslemetryEnergyLiveEntity(TeslemetryEntity): """Parent class for Teslemetry Energy Site Live entities.""" @@ -249,11 +242,8 @@ class TeslemetryWallConnectorEntity(TeslemetryEntity): class TeslemetryVehicleStreamEntity(TeslemetryRootEntity): """Parent class for Teslemetry Vehicle Stream entities.""" - def __init__( - self, data: TeslemetryVehicleData, key: str, streaming_key: Signal | None = None - ) -> None: + def __init__(self, data: TeslemetryVehicleData, key: str) -> None: """Initialize common aspects of a Teslemetry entity.""" - self.streaming_key = streaming_key self.vehicle = data self.api = data.api @@ -265,32 +255,7 @@ class TeslemetryVehicleStreamEntity(TeslemetryRootEntity): self._attr_unique_id = f"{data.vin}-{key}" self._attr_device_info = data.device - async def async_added_to_hass(self) -> None: - """When entity is added to hass.""" - await super().async_added_to_hass() - if self.streaming_key: - self.async_on_remove( - self.stream.async_add_listener( - self._handle_stream_update, - {"vin": self.vin, "data": {self.streaming_key: None}}, - ) - ) - self.vehicle.config_entry.async_create_background_task( - self.hass, - self.add_field(self.streaming_key), - f"Adding field {self.streaming_key.value} to {self.vehicle.vin}", - ) - - def _handle_stream_update(self, data: dict[str, Any]) -> None: - """Handle updated data from the stream.""" - self._async_value_from_stream(data["data"][self.streaming_key]) - self.async_write_ha_state() - - def _async_value_from_stream(self, value: Any) -> None: - """Update the entity with the latest value from the stream.""" - raise NotImplementedError - - @cached_property + @property def available(self) -> bool: """Return True if entity is available.""" return self.stream.connected diff --git a/homeassistant/components/teslemetry/helpers.py b/homeassistant/components/teslemetry/helpers.py index 30601feccbc..c6f15d7bfdf 100644 --- a/homeassistant/components/teslemetry/helpers.py +++ b/homeassistant/components/teslemetry/helpers.py @@ -1,13 +1,12 @@ """Teslemetry helper functions.""" -import asyncio from typing import Any from tesla_fleet_api.exceptions import TeslaFleetError from homeassistant.exceptions import HomeAssistantError -from .const import DOMAIN, LOGGER, TeslemetryState +from .const import DOMAIN, LOGGER def flatten(data: dict[str, Any], parent: str | None = None) -> dict[str, Any]: @@ -23,34 +22,6 @@ def flatten(data: dict[str, Any], parent: str | None = None) -> dict[str, Any]: return result -async def wake_up_vehicle(vehicle) -> None: - """Wake up a vehicle.""" - async with vehicle.wakelock: - times = 0 - while vehicle.coordinator.data["state"] != TeslemetryState.ONLINE: - try: - if times == 0: - cmd = await vehicle.api.wake_up() - else: - cmd = await vehicle.api.vehicle() - state = cmd["response"]["state"] - except TeslaFleetError as e: - raise HomeAssistantError( - translation_domain=DOMAIN, - translation_key="wake_up_failed", - translation_placeholders={"message": e.message}, - ) from e - vehicle.coordinator.data["state"] = state - if state != TeslemetryState.ONLINE: - times += 1 - if times >= 4: # Give up after 30 seconds total - raise HomeAssistantError( - translation_domain=DOMAIN, - translation_key="wake_up_timeout", - ) - await asyncio.sleep(times * 5) - - async def handle_command(command) -> dict[str, Any]: """Handle a command.""" try: diff --git a/homeassistant/components/teslemetry/icons.json b/homeassistant/components/teslemetry/icons.json index 9996a508177..e03ac8eb41a 100644 --- a/homeassistant/components/teslemetry/icons.json +++ b/homeassistant/components/teslemetry/icons.json @@ -42,6 +42,48 @@ "off": "mdi:tire", "on": "mdi:car-tire-alert" } + }, + "charge_enable_request": { + "state": { + "off": "mdi:battery-off-outline", + "on": "mdi:battery-charging-outline" + } + }, + "defrost_for_preconditioning": { + "state": { + "off": "mdi:snowflake-off", + "on": "mdi:snowflake-melt" + } + }, + "lights_high_beams": { + "state": { + "off": "mdi:car-light-dimmed", + "on": "mdi:car-light-high" + } + }, + "seat_vent_enabled": { + "state": { + "off": "mdi:car-seat", + "on": "mdi:fan" + } + }, + "speed_limit_mode": { + "state": { + "off": "mdi:speedometer", + "on": "mdi:car-speed-limiter" + } + }, + "remote_start_enabled": { + "state": { + "off": "mdi:remote-off", + "on": "mdi:remote" + } + }, + "hvac_auto_mode": { + "state": { + "off": "mdi:hvac-off", + "on": "mdi:hvac" + } } }, "button": { diff --git a/homeassistant/components/teslemetry/manifest.json b/homeassistant/components/teslemetry/manifest.json index 4c21bb017d8..8194fb3d6db 100644 --- a/homeassistant/components/teslemetry/manifest.json +++ b/homeassistant/components/teslemetry/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/teslemetry", "iot_class": "cloud_polling", "loggers": ["tesla-fleet-api"], - "requirements": ["tesla-fleet-api==1.0.17", "teslemetry-stream==0.7.1"] + "requirements": ["tesla-fleet-api==1.0.17", "teslemetry-stream==0.7.5"] } diff --git a/homeassistant/components/teslemetry/number.py b/homeassistant/components/teslemetry/number.py index ff25dec59b8..117c0a8c233 100644 --- a/homeassistant/components/teslemetry/number.py +++ b/homeassistant/components/teslemetry/number.py @@ -243,6 +243,7 @@ class TeslemetryStreamingNumberEntity( self._attr_native_value = last_number_data.native_value if last_number_data.native_max_value: self._attr_native_max_value = last_number_data.native_max_value + self.async_write_ha_state() # Add listeners self.async_on_remove( diff --git a/homeassistant/components/teslemetry/sensor.py b/homeassistant/components/teslemetry/sensor.py index fb653314bc5..e75c4e91f6d 100644 --- a/homeassistant/components/teslemetry/sensor.py +++ b/homeassistant/components/teslemetry/sensor.py @@ -6,7 +6,6 @@ from collections.abc import Callable from dataclasses import dataclass from datetime import datetime, timedelta -from propcache.api import cached_property from teslemetry_stream import TeslemetryStreamVehicle from homeassistant.components.sensor import ( @@ -636,11 +635,6 @@ class TeslemetryStreamSensorEntity(TeslemetryVehicleStreamEntity, RestoreSensor) ) ) - @cached_property - def available(self) -> bool: - """Return True if entity is available.""" - return self.stream.connected - def _async_value_from_stream(self, value: StateType) -> None: """Update the value of the entity.""" self._attr_native_value = value diff --git a/homeassistant/components/teslemetry/services.py b/homeassistant/components/teslemetry/services.py index 8215adb5711..2f21073d227 100644 --- a/homeassistant/components/teslemetry/services.py +++ b/homeassistant/components/teslemetry/services.py @@ -12,7 +12,7 @@ from homeassistant.exceptions import HomeAssistantError, ServiceValidationError from homeassistant.helpers import config_validation as cv, device_registry as dr from .const import DOMAIN -from .helpers import handle_command, handle_vehicle_command, wake_up_vehicle +from .helpers import handle_command, handle_vehicle_command from .models import TeslemetryEnergyData, TeslemetryVehicleData _LOGGER = logging.getLogger(__name__) @@ -107,7 +107,6 @@ def async_register_services(hass: HomeAssistant) -> None: config = async_get_config_for_device(hass, device) vehicle = async_get_vehicle_for_entry(hass, device, config) - await wake_up_vehicle(vehicle) await handle_vehicle_command( vehicle.api.navigation_gps_request( lat=call.data[ATTR_GPS][CONF_LATITUDE], @@ -148,7 +147,6 @@ def async_register_services(hass: HomeAssistant) -> None: translation_domain=DOMAIN, translation_key="set_scheduled_charging_time" ) - await wake_up_vehicle(vehicle) await handle_vehicle_command( vehicle.api.set_scheduled_charging(enable=call.data["enable"], time=time) ) @@ -205,7 +203,6 @@ def async_register_services(hass: HomeAssistant) -> None: translation_key="set_scheduled_departure_off_peak", ) - await wake_up_vehicle(vehicle) await handle_vehicle_command( vehicle.api.set_scheduled_departure( enable, @@ -242,7 +239,6 @@ def async_register_services(hass: HomeAssistant) -> None: config = async_get_config_for_device(hass, device) vehicle = async_get_vehicle_for_entry(hass, device, config) - await wake_up_vehicle(vehicle) await handle_vehicle_command( vehicle.api.set_valet_mode( call.data.get("enable"), call.data.get("pin", "") @@ -268,7 +264,6 @@ def async_register_services(hass: HomeAssistant) -> None: config = async_get_config_for_device(hass, device) vehicle = async_get_vehicle_for_entry(hass, device, config) - await wake_up_vehicle(vehicle) enable = call.data.get("enable") if enable is True: await handle_vehicle_command( diff --git a/homeassistant/components/teslemetry/strings.json b/homeassistant/components/teslemetry/strings.json index 69b1551a561..0115ed0eac8 100644 --- a/homeassistant/components/teslemetry/strings.json +++ b/homeassistant/components/teslemetry/strings.json @@ -190,6 +190,30 @@ }, "located_at_favorite": { "name": "Located at favorite" + }, + "charge_enable_request": { + "name": "Charge enable request" + }, + "defrost_for_preconditioning": { + "name": "Defrost for preconditioning" + }, + "lights_high_beams": { + "name": "High beams" + }, + "seat_vent_enabled": { + "name": "Seat vent enabled" + }, + "speed_limit_mode": { + "name": "Speed limited" + }, + "remote_start_enabled": { + "name": "Remote start" + }, + "hvil": { + "name": "High voltage interlock loop fault" + }, + "hvac_auto_mode": { + "name": "HVAC auto mode" } }, "button": { @@ -363,7 +387,7 @@ "name": "Charge limit" }, "off_grid_vehicle_charging_reserve_percent": { - "name": "Off grid reserve" + "name": "Off-grid reserve" } }, "cover": { @@ -495,10 +519,10 @@ "name": "Island status", "state": { "island_status_unknown": "Unknown", - "on_grid": "On grid", - "off_grid": "Off grid", - "off_grid_intentional": "Off grid intentional", - "off_grid_unintentional": "Off grid unintentional" + "on_grid": "On-grid", + "off_grid": "Off-grid", + "off_grid_intentional": "Off-grid intentional", + "off_grid_unintentional": "Off-grid unintentional" } }, "load_power": { @@ -662,7 +686,7 @@ "message": "Departure time required to enable preconditioning" }, "set_scheduled_departure_off_peak": { - "message": "To enable scheduled departure, end off peak time is required." + "message": "To enable scheduled departure, 'End off-peak time' is required." }, "invalid_device": { "message": "Invalid device ID: {device_id}" @@ -752,15 +776,15 @@ }, "end_off_peak_time": { "description": "Time to complete charging by.", - "name": "End off peak time" + "name": "End off-peak time" }, "off_peak_charging_enabled": { - "description": "Enable off peak charging.", - "name": "Off peak charging enabled" + "description": "Enable off-peak charging.", + "name": "Off-peak charging enabled" }, "off_peak_charging_weekdays_only": { - "description": "Enable off peak charging on weekdays only.", - "name": "Off peak charging weekdays only" + "description": "Enable off-peak charging on weekdays only.", + "name": "Off-peak charging weekdays only" }, "preconditioning_enabled": { "description": "Enable preconditioning.", diff --git a/homeassistant/components/tessie/strings.json b/homeassistant/components/tessie/strings.json index 1c0ec7ecc80..fa0c7f8c1f7 100644 --- a/homeassistant/components/tessie/strings.json +++ b/homeassistant/components/tessie/strings.json @@ -217,7 +217,7 @@ "connected": "[%key:common::state::connected%]", "scheduled": "Scheduled", "negotiating": "Negotiating", - "error": "Error", + "error": "[%key:common::state::error%]", "charging_finished": "Charging finished", "waiting_car": "Waiting car", "charging_reduced": "Charging reduced" @@ -495,7 +495,7 @@ "name": "Speed limit" }, "off_grid_vehicle_charging_reserve_percent": { - "name": "Off grid reserve" + "name": "Off-grid reserve" } }, "update": { diff --git a/homeassistant/components/tplink_omada/config_flow.py b/homeassistant/components/tplink_omada/config_flow.py index eeeddb62495..6fec7d30381 100644 --- a/homeassistant/components/tplink_omada/config_flow.py +++ b/homeassistant/components/tplink_omada/config_flow.py @@ -5,7 +5,6 @@ from __future__ import annotations from collections.abc import Mapping import logging import re -from types import MappingProxyType from typing import Any, NamedTuple from urllib.parse import urlsplit @@ -45,7 +44,7 @@ STEP_USER_DATA_SCHEMA = vol.Schema( async def create_omada_client( - hass: HomeAssistant, data: MappingProxyType[str, Any] + hass: HomeAssistant, data: Mapping[str, Any] ) -> OmadaClient: """Create a TP-Link Omada client API for the given config entry.""" @@ -84,7 +83,7 @@ class HubInfo(NamedTuple): async def _validate_input(hass: HomeAssistant, data: dict[str, Any]) -> HubInfo: """Validate the user input allows us to connect.""" - client = await create_omada_client(hass, MappingProxyType(data)) + client = await create_omada_client(hass, data) controller_id = await client.login() name = await client.get_controller_name() sites = await client.get_sites() diff --git a/homeassistant/components/tplink_omada/strings.json b/homeassistant/components/tplink_omada/strings.json index 73cea692dbf..99c509a73a7 100644 --- a/homeassistant/components/tplink_omada/strings.json +++ b/homeassistant/components/tplink_omada/strings.json @@ -24,14 +24,14 @@ "username": "[%key:common::config_flow::data::username%]", "password": "[%key:common::config_flow::data::password%]" }, - "title": "Update TP-Link Omada Credentials", + "title": "Update TP-Link Omada credentials", "description": "The provided credentials have stopped working. Please update them." } }, "error": { "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", - "unsupported_controller": "Omada Controller version not supported.", + "unsupported_controller": "Omada controller version not supported.", "unknown": "[%key:common::config_flow::error::unknown%]", "no_sites_found": "No sites found which the user can manage." }, @@ -46,31 +46,31 @@ "name": "Port {port_name} PoE" }, "wan_connect_ipv4": { - "name": "Port {port_name} Internet Connected" + "name": "Port {port_name} Internet connected" }, "wan_connect_ipv6": { - "name": "Port {port_name} Internet Connected (IPv6)" + "name": "Port {port_name} Internet connected (IPv6)" } }, "binary_sensor": { "wan_link": { - "name": "Port {port_name} Internet Link" + "name": "Port {port_name} Internet link" }, "online_detection": { - "name": "Port {port_name} Online Detection" + "name": "Port {port_name} online detection" }, "lan_status": { - "name": "Port {port_name} LAN Status" + "name": "Port {port_name} LAN status" }, "poe_delivery": { - "name": "Port {port_name} PoE Delivery" + "name": "Port {port_name} PoE delivery" } }, "sensor": { "device_status": { "name": "Device status", "state": { - "error": "Error", + "error": "[%key:common::state::error%]", "disconnected": "[%key:common::state::disconnected%]", "connected": "[%key:common::state::connected%]", "pending": "Pending", @@ -91,7 +91,7 @@ "services": { "reconnect_client": { "name": "Reconnect wireless client", - "description": "Tries to get wireless client to reconnect to Omada Network.", + "description": "Tries to get wireless client to reconnect to Omada network.", "fields": { "mac": { "name": "MAC address", diff --git a/homeassistant/components/unifi/hub/api.py b/homeassistant/components/unifi/hub/api.py index acdd941dd15..8cfe06c1b55 100644 --- a/homeassistant/components/unifi/hub/api.py +++ b/homeassistant/components/unifi/hub/api.py @@ -3,8 +3,8 @@ from __future__ import annotations import asyncio +from collections.abc import Mapping import ssl -from types import MappingProxyType from typing import Any, Literal from aiohttp import CookieJar @@ -27,7 +27,7 @@ from ..errors import AuthenticationRequired, CannotConnect async def get_unifi_api( hass: HomeAssistant, - config: MappingProxyType[str, Any], + config: Mapping[str, Any], ) -> aiounifi.Controller: """Create a aiounifi object and verify authentication.""" ssl_context: ssl.SSLContext | Literal[False] = False diff --git a/homeassistant/components/uptimerobot/manifest.json b/homeassistant/components/uptimerobot/manifest.json index 67e57f46986..6fe8083ffc6 100644 --- a/homeassistant/components/uptimerobot/manifest.json +++ b/homeassistant/components/uptimerobot/manifest.json @@ -6,5 +6,6 @@ "documentation": "https://www.home-assistant.io/integrations/uptimerobot", "iot_class": "cloud_polling", "loggers": ["pyuptimerobot"], + "quality_scale": "bronze", "requirements": ["pyuptimerobot==22.2.0"] } diff --git a/homeassistant/components/uptimerobot/quality_scale.yaml b/homeassistant/components/uptimerobot/quality_scale.yaml index 1ab2c117483..43076320b8f 100644 --- a/homeassistant/components/uptimerobot/quality_scale.yaml +++ b/homeassistant/components/uptimerobot/quality_scale.yaml @@ -6,9 +6,7 @@ rules: appropriate-polling: done brands: done common-modules: done - config-flow-test-coverage: - status: todo - comment: fix name and docstring + config-flow-test-coverage: done config-flow: done dependency-transparency: done docs-actions: @@ -41,9 +39,7 @@ rules: log-when-unavailable: done parallel-updates: done reauthentication-flow: done - test-coverage: - status: todo - comment: recheck typos + test-coverage: done # Gold devices: done diff --git a/homeassistant/components/utility_meter/sensor.py b/homeassistant/components/utility_meter/sensor.py index 425dfa2c3fd..cda538386c1 100644 --- a/homeassistant/components/utility_meter/sensor.py +++ b/homeassistant/components/utility_meter/sensor.py @@ -577,10 +577,10 @@ class UtilityMeterSensor(RestoreSensor): async def _async_reset_meter(self, event): """Reset the utility meter status.""" - await self._program_reset() - await self.async_reset_meter(self._tariff_entity) + await self._program_reset() + async def async_reset_meter(self, entity_id): """Reset meter.""" if self._tariff_entity is not None and self._tariff_entity != entity_id: diff --git a/homeassistant/components/vodafone_station/__init__.py b/homeassistant/components/vodafone_station/__init__.py index 9f118fe4fbd..b4ba5663ac2 100644 --- a/homeassistant/components/vodafone_station/__init__.py +++ b/homeassistant/components/vodafone_station/__init__.py @@ -3,7 +3,6 @@ from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME, Platform from homeassistant.core import HomeAssistant -from .const import DOMAIN from .coordinator import VodafoneConfigEntry, VodafoneStationRouter PLATFORMS = [Platform.BUTTON, Platform.DEVICE_TRACKER, Platform.SENSOR] @@ -36,7 +35,6 @@ async def async_unload_entry(hass: HomeAssistant, entry: VodafoneConfigEntry) -> coordinator = entry.runtime_data await coordinator.api.logout() await coordinator.api.close() - hass.data[DOMAIN].pop(entry.entry_id) return unload_ok diff --git a/homeassistant/components/voip/assist_satellite.py b/homeassistant/components/voip/assist_satellite.py index 2c0a3b9641a..6c63710a5b1 100644 --- a/homeassistant/components/voip/assist_satellite.py +++ b/homeassistant/components/voip/assist_satellite.py @@ -408,10 +408,18 @@ class VoipAssistSatellite(VoIPEntity, AssistSatelliteEntity, RtpDatagramProtocol """Play an announcement once.""" _LOGGER.debug("Playing announcement") - try: - await asyncio.sleep(_ANNOUNCEMENT_BEFORE_DELAY) - await self._send_tts(announcement.original_media_id, wait_for_tone=False) + if announcement.tts_token is None: + _LOGGER.error("Only TTS announcements are supported") + return + await asyncio.sleep(_ANNOUNCEMENT_BEFORE_DELAY) + stream = tts.async_get_stream(self.hass, announcement.tts_token) + if stream is None: + _LOGGER.error("TTS stream no longer available") + return + + try: + await self._send_tts(stream, wait_for_tone=False) if not self._run_pipeline_after_announce: # Delay before looping announcement await asyncio.sleep(_ANNOUNCEMENT_AFTER_DELAY) @@ -442,11 +450,14 @@ class VoipAssistSatellite(VoIPEntity, AssistSatelliteEntity, RtpDatagramProtocol ) elif event.type == PipelineEventType.TTS_END: # Send TTS audio to caller over RTP - if event.data and (tts_output := event.data["tts_output"]): - media_id = tts_output["media_id"] + if ( + event.data + and (tts_output := event.data["tts_output"]) + and (stream := tts.async_get_stream(self.hass, tts_output["token"])) + ): self.config_entry.async_create_background_task( self.hass, - self._send_tts(media_id), + self._send_tts(tts_stream=stream), "voip_pipeline_tts", ) else: @@ -457,19 +468,22 @@ class VoipAssistSatellite(VoIPEntity, AssistSatelliteEntity, RtpDatagramProtocol self._pipeline_had_error = True _LOGGER.warning(event) - async def _send_tts(self, media_id: str, wait_for_tone: bool = True) -> None: + async def _send_tts( + self, + tts_stream: tts.ResultStream, + wait_for_tone: bool = True, + ) -> None: """Send TTS audio to caller via RTP.""" try: if self.transport is None: return # not connected - extension, data = await tts.async_get_media_source_audio( - self.hass, - media_id, - ) + data = b"".join([chunk async for chunk in tts_stream.async_stream_result()]) - if extension != "wav": - raise ValueError(f"Only WAV audio can be streamed, got {extension}") + if tts_stream.extension != "wav": + raise ValueError( + f"Only TTS WAV audio can be streamed, got {tts_stream.extension}" + ) if wait_for_tone and ((self._tones & Tones.PROCESSING) == Tones.PROCESSING): # Don't overlap TTS and processing beep diff --git a/homeassistant/components/wemo/__init__.py b/homeassistant/components/wemo/__init__.py index 3ef7ac92f98..96e61dfded6 100644 --- a/homeassistant/components/wemo/__init__.py +++ b/homeassistant/components/wemo/__init__.py @@ -21,7 +21,7 @@ from homeassistant.util.async_ import gather_with_limited_concurrency from .const import DOMAIN from .coordinator import DeviceCoordinator, async_register_device -from .models import WemoConfigEntryData, WemoData, async_wemo_data +from .models import DATA_WEMO, WemoConfigEntryData, WemoData # Max number of devices to initialize at once. This limit is in place to # avoid tying up too many executor threads with WeMo device setup. @@ -117,7 +117,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up a wemo config entry.""" - wemo_data = async_wemo_data(hass) + wemo_data = hass.data[DATA_WEMO] dispatcher = WemoDispatcher(entry) discovery = WemoDiscovery(hass, dispatcher, wemo_data.static_config, entry) wemo_data.config_entry_data = WemoConfigEntryData( @@ -138,7 +138,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Unload a wemo config entry.""" _LOGGER.debug("Unloading WeMo") - wemo_data = async_wemo_data(hass) + wemo_data = hass.data[DATA_WEMO] wemo_data.config_entry_data.discovery.async_stop_discovery() @@ -161,7 +161,7 @@ async def async_wemo_dispatcher_connect( module = dispatch.__module__ # Example: "homeassistant.components.wemo.switch" platform = Platform(module.rsplit(".", 1)[1]) - dispatcher = async_wemo_data(hass).config_entry_data.dispatcher + dispatcher = hass.data[DATA_WEMO].config_entry_data.dispatcher await dispatcher.async_connect_platform(platform, dispatch) diff --git a/homeassistant/components/wemo/coordinator.py b/homeassistant/components/wemo/coordinator.py index 0aaedf598d2..6cda83f6419 100644 --- a/homeassistant/components/wemo/coordinator.py +++ b/homeassistant/components/wemo/coordinator.py @@ -29,7 +29,7 @@ from homeassistant.helpers.device_registry import CONNECTION_UPNP, DeviceInfo from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from .const import DOMAIN, WEMO_SUBSCRIPTION_EVENT -from .models import async_wemo_data +from .models import DATA_WEMO _LOGGER = logging.getLogger(__name__) @@ -316,9 +316,9 @@ def async_get_coordinator(hass: HomeAssistant, device_id: str) -> DeviceCoordina @callback def _async_coordinators(hass: HomeAssistant) -> dict[str, DeviceCoordinator]: - return async_wemo_data(hass).config_entry_data.device_coordinators + return hass.data[DATA_WEMO].config_entry_data.device_coordinators @callback def _async_registry(hass: HomeAssistant) -> SubscriptionRegistry: - return async_wemo_data(hass).registry + return hass.data[DATA_WEMO].registry diff --git a/homeassistant/components/wemo/models.py b/homeassistant/components/wemo/models.py index 80213c9ba33..b96cd502cd4 100644 --- a/homeassistant/components/wemo/models.py +++ b/homeassistant/components/wemo/models.py @@ -4,11 +4,11 @@ from __future__ import annotations from collections.abc import Sequence from dataclasses import dataclass -from typing import TYPE_CHECKING, cast +from typing import TYPE_CHECKING import pywemo -from homeassistant.core import HomeAssistant, callback +from homeassistant.util.hass_dict import HassKey from .const import DOMAIN @@ -16,6 +16,8 @@ if TYPE_CHECKING: # Avoid circular dependencies. from . import HostPortTuple, WemoDiscovery, WemoDispatcher from .coordinator import DeviceCoordinator +DATA_WEMO: HassKey[WemoData] = HassKey(DOMAIN) + @dataclass class WemoConfigEntryData: @@ -37,9 +39,3 @@ class WemoData: # unloaded. It's a programmer error if config_entry_data is accessed when the # config entry is not loaded config_entry_data: WemoConfigEntryData = None # type: ignore[assignment] - - -@callback -def async_wemo_data(hass: HomeAssistant) -> WemoData: - """Fetch WemoData with proper typing.""" - return cast(WemoData, hass.data[DOMAIN]) diff --git a/homeassistant/components/whirlpool/climate.py b/homeassistant/components/whirlpool/climate.py index 0cc9e8bca84..75967bb81d4 100644 --- a/homeassistant/components/whirlpool/climate.py +++ b/homeassistant/components/whirlpool/climate.py @@ -63,13 +63,14 @@ async def async_setup_entry( ) -> None: """Set up entry.""" appliances_manager = config_entry.runtime_data - aircons = [AirConEntity(hass, aircon) for aircon in appliances_manager.aircons] - async_add_entities(aircons) + async_add_entities(AirConEntity(aircon) for aircon in appliances_manager.aircons) class AirConEntity(WhirlpoolEntity, ClimateEntity): """Representation of an air conditioner.""" + _appliance: Aircon + _attr_fan_modes = SUPPORTED_FAN_MODES _attr_name = None _attr_hvac_modes = SUPPORTED_HVAC_MODES @@ -86,86 +87,81 @@ class AirConEntity(WhirlpoolEntity, ClimateEntity): _attr_target_temperature_step = SUPPORTED_TARGET_TEMPERATURE_STEP _attr_temperature_unit = UnitOfTemperature.CELSIUS - def __init__(self, hass: HomeAssistant, aircon: Aircon) -> None: - """Initialize the entity.""" - super().__init__(aircon) - self._aircon = aircon - @property def current_temperature(self) -> float: """Return the current temperature.""" - return self._aircon.get_current_temp() + return self._appliance.get_current_temp() @property def target_temperature(self) -> float: """Return the temperature we try to reach.""" - return self._aircon.get_temp() + return self._appliance.get_temp() async def async_set_temperature(self, **kwargs: Any) -> None: """Set new target temperature.""" - await self._aircon.set_temp(kwargs.get(ATTR_TEMPERATURE)) + await self._appliance.set_temp(kwargs.get(ATTR_TEMPERATURE)) @property def current_humidity(self) -> int: """Return the current humidity.""" - return self._aircon.get_current_humidity() + return self._appliance.get_current_humidity() @property def target_humidity(self) -> int: """Return the humidity we try to reach.""" - return self._aircon.get_humidity() + return self._appliance.get_humidity() async def async_set_humidity(self, humidity: int) -> None: """Set new target humidity.""" - await self._aircon.set_humidity(humidity) + await self._appliance.set_humidity(humidity) @property def hvac_mode(self) -> HVACMode | None: """Return current operation ie. heat, cool, fan.""" - if not self._aircon.get_power_on(): + if not self._appliance.get_power_on(): return HVACMode.OFF - mode: AirconMode = self._aircon.get_mode() + mode: AirconMode = self._appliance.get_mode() return AIRCON_MODE_MAP.get(mode) async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None: """Set HVAC mode.""" if hvac_mode == HVACMode.OFF: - await self._aircon.set_power_on(False) + await self._appliance.set_power_on(False) return if not (mode := HVAC_MODE_TO_AIRCON_MODE.get(hvac_mode)): raise ValueError(f"Invalid hvac mode {hvac_mode}") - await self._aircon.set_mode(mode) - if not self._aircon.get_power_on(): - await self._aircon.set_power_on(True) + await self._appliance.set_mode(mode) + if not self._appliance.get_power_on(): + await self._appliance.set_power_on(True) @property def fan_mode(self) -> str: """Return the fan setting.""" - fanspeed = self._aircon.get_fanspeed() + fanspeed = self._appliance.get_fanspeed() return AIRCON_FANSPEED_MAP.get(fanspeed, FAN_OFF) async def async_set_fan_mode(self, fan_mode: str) -> None: """Set fan mode.""" if not (fanspeed := FAN_MODE_TO_AIRCON_FANSPEED.get(fan_mode)): raise ValueError(f"Invalid fan mode {fan_mode}") - await self._aircon.set_fanspeed(fanspeed) + await self._appliance.set_fanspeed(fanspeed) @property def swing_mode(self) -> str: """Return the swing setting.""" - return SWING_HORIZONTAL if self._aircon.get_h_louver_swing() else SWING_OFF + return SWING_HORIZONTAL if self._appliance.get_h_louver_swing() else SWING_OFF async def async_set_swing_mode(self, swing_mode: str) -> None: """Set new target temperature.""" - await self._aircon.set_h_louver_swing(swing_mode == SWING_HORIZONTAL) + await self._appliance.set_h_louver_swing(swing_mode == SWING_HORIZONTAL) async def async_turn_on(self) -> None: """Turn device on.""" - await self._aircon.set_power_on(True) + await self._appliance.set_power_on(True) async def async_turn_off(self) -> None: """Turn device off.""" - await self._aircon.set_power_on(False) + await self._appliance.set_power_on(False) diff --git a/homeassistant/components/yale/manifest.json b/homeassistant/components/yale/manifest.json index 5c8e98b1e6e..4d9ea9ec2c9 100644 --- a/homeassistant/components/yale/manifest.json +++ b/homeassistant/components/yale/manifest.json @@ -13,5 +13,5 @@ "documentation": "https://www.home-assistant.io/integrations/yale", "iot_class": "cloud_push", "loggers": ["socketio", "engineio", "yalexs"], - "requirements": ["yalexs==8.10.0", "yalexs-ble==2.5.7"] + "requirements": ["yalexs==8.10.0", "yalexs-ble==2.6.0"] } diff --git a/homeassistant/components/yalexs_ble/manifest.json b/homeassistant/components/yalexs_ble/manifest.json index c44f0fdd1e9..2387f5dc15f 100644 --- a/homeassistant/components/yalexs_ble/manifest.json +++ b/homeassistant/components/yalexs_ble/manifest.json @@ -12,5 +12,5 @@ "dependencies": ["bluetooth_adapters"], "documentation": "https://www.home-assistant.io/integrations/yalexs_ble", "iot_class": "local_push", - "requirements": ["yalexs-ble==2.5.7"] + "requirements": ["yalexs-ble==2.6.0"] } diff --git a/homeassistant/components/zeroconf/__init__.py b/homeassistant/components/zeroconf/__init__.py index 86f8dbca792..383276d645f 100644 --- a/homeassistant/components/zeroconf/__init__.py +++ b/homeassistant/components/zeroconf/__init__.py @@ -2,26 +2,17 @@ from __future__ import annotations -import contextlib from contextlib import suppress -from fnmatch import translate -from functools import lru_cache, partial +from functools import partial from ipaddress import IPv4Address, IPv6Address import logging -import re import sys -from typing import TYPE_CHECKING, Any, Final, cast +from typing import Any, cast import voluptuous as vol -from zeroconf import ( - BadTypeInNameException, - InterfaceChoice, - IPVersion, - ServiceStateChange, -) -from zeroconf.asyncio import AsyncServiceBrowser, AsyncServiceInfo +from zeroconf import InterfaceChoice, IPVersion +from zeroconf.asyncio import AsyncServiceInfo -from homeassistant import config_entries from homeassistant.components import network from homeassistant.const import ( EVENT_HOMEASSISTANT_CLOSE, @@ -29,55 +20,40 @@ from homeassistant.const import ( __version__, ) from homeassistant.core import Event, HomeAssistant, callback -from homeassistant.helpers import config_validation as cv, discovery_flow, instance_id +from homeassistant.helpers import config_validation as cv, instance_id from homeassistant.helpers.deprecation import ( DeprecatedConstant, all_with_deprecated_constants, check_if_deprecated_constant, dir_with_deprecated_constants, ) -from homeassistant.helpers.discovery_flow import DiscoveryKey -from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.network import NoURLAvailableError, get_url from homeassistant.helpers.service_info.zeroconf import ( ATTR_PROPERTIES_ID as _ATTR_PROPERTIES_ID, ZeroconfServiceInfo as _ZeroconfServiceInfo, ) from homeassistant.helpers.typing import ConfigType -from homeassistant.loader import ( - HomeKitDiscoveredIntegration, - ZeroconfMatcher, - async_get_homekit, - async_get_zeroconf, - bind_hass, -) +from homeassistant.loader import async_get_homekit, async_get_zeroconf, bind_hass from homeassistant.setup import async_when_setup_or_start +from .const import DOMAIN, ZEROCONF_TYPE +from .discovery import ( # noqa: F401 + DATA_DISCOVERY, + ZeroconfDiscovery, + build_homekit_model_lookups, + info_from_service, +) from .models import HaAsyncZeroconf, HaZeroconf from .usage import install_multiple_zeroconf_catcher _LOGGER = logging.getLogger(__name__) -DOMAIN = "zeroconf" - -ZEROCONF_TYPE = "_home-assistant._tcp.local." -HOMEKIT_TYPES = [ - "_hap._tcp.local.", - # Thread based devices - "_hap._udp.local.", -] -_HOMEKIT_MODEL_SPLITS = (None, " ", "-") - CONF_DEFAULT_INTERFACE = "default_interface" CONF_IPV6 = "ipv6" DEFAULT_DEFAULT_INTERFACE = True DEFAULT_IPV6 = True -HOMEKIT_PAIRED_STATUS_FLAG = "sf" -HOMEKIT_MODEL_LOWER = "md" -HOMEKIT_MODEL_UPPER = "MD" - # Property key=value has a max length of 255 # so we use 230 to leave space for key= MAX_PROPERTY_VALUE_LEN = 230 @@ -85,10 +61,6 @@ MAX_PROPERTY_VALUE_LEN = 230 # Dns label max length MAX_NAME_LEN = 63 -ATTR_DOMAIN: Final = "domain" -ATTR_NAME: Final = "name" -ATTR_PROPERTIES: Final = "properties" - # Attributes for ZeroconfServiceInfo[ATTR_PROPERTIES] _DEPRECATED_ATTR_PROPERTIES_ID = DeprecatedConstant( _ATTR_PROPERTIES_ID, @@ -214,7 +186,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: zeroconf = cast(HaZeroconf, aio_zc.zeroconf) zeroconf_types = await async_get_zeroconf(hass) homekit_models = await async_get_homekit(hass) - homekit_model_lookup, homekit_model_matchers = _build_homekit_model_lookups( + homekit_model_lookup, homekit_model_matchers = build_homekit_model_lookups( homekit_models ) discovery = ZeroconfDiscovery( @@ -225,6 +197,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: homekit_model_matchers, ) await discovery.async_setup() + hass.data[DATA_DISCOVERY] = discovery async def _async_zeroconf_hass_start(hass: HomeAssistant, comp: str) -> None: """Expose Home Assistant on zeroconf when it starts. @@ -243,25 +216,6 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: return True -def _build_homekit_model_lookups( - homekit_models: dict[str, HomeKitDiscoveredIntegration], -) -> tuple[ - dict[str, HomeKitDiscoveredIntegration], - dict[re.Pattern, HomeKitDiscoveredIntegration], -]: - """Build lookups for homekit models.""" - homekit_model_lookup: dict[str, HomeKitDiscoveredIntegration] = {} - homekit_model_matchers: dict[re.Pattern, HomeKitDiscoveredIntegration] = {} - - for model, discovery in homekit_models.items(): - if "*" in model or "?" in model or "[" in model: - homekit_model_matchers[_compile_fnmatch(model)] = discovery - else: - homekit_model_lookup[model] = discovery - - return homekit_model_lookup, homekit_model_matchers - - def _filter_disallowed_characters(name: str) -> str: """Filter disallowed characters from a string. @@ -315,299 +269,6 @@ async def _async_register_hass_zc_service( await aio_zc.async_register_service(info, allow_name_change=True) -def _match_against_props(matcher: dict[str, str], props: dict[str, str | None]) -> bool: - """Check a matcher to ensure all values in props.""" - for key, value in matcher.items(): - prop_val = props.get(key) - if prop_val is None or not _memorized_fnmatch(prop_val.lower(), value): - return False - return True - - -def is_homekit_paired(props: dict[str, Any]) -> bool: - """Check properties to see if a device is homekit paired.""" - if HOMEKIT_PAIRED_STATUS_FLAG not in props: - return False - with contextlib.suppress(ValueError): - # 0 means paired and not discoverable by iOS clients) - return int(props[HOMEKIT_PAIRED_STATUS_FLAG]) == 0 - # If we cannot tell, we assume its not paired - return False - - -class ZeroconfDiscovery: - """Discovery via zeroconf.""" - - def __init__( - self, - hass: HomeAssistant, - zeroconf: HaZeroconf, - zeroconf_types: dict[str, list[ZeroconfMatcher]], - homekit_model_lookups: dict[str, HomeKitDiscoveredIntegration], - homekit_model_matchers: dict[re.Pattern, HomeKitDiscoveredIntegration], - ) -> None: - """Init discovery.""" - self.hass = hass - self.zeroconf = zeroconf - self.zeroconf_types = zeroconf_types - self.homekit_model_lookups = homekit_model_lookups - self.homekit_model_matchers = homekit_model_matchers - self.async_service_browser: AsyncServiceBrowser | None = None - - async def async_setup(self) -> None: - """Start discovery.""" - types = list(self.zeroconf_types) - # We want to make sure we know about other HomeAssistant - # instances as soon as possible to avoid name conflicts - # so we always browse for ZEROCONF_TYPE - types.extend( - hk_type - for hk_type in (ZEROCONF_TYPE, *HOMEKIT_TYPES) - if hk_type not in self.zeroconf_types - ) - _LOGGER.debug("Starting Zeroconf browser for: %s", types) - self.async_service_browser = AsyncServiceBrowser( - self.zeroconf, types, handlers=[self.async_service_update] - ) - - async_dispatcher_connect( - self.hass, - config_entries.signal_discovered_config_entry_removed(DOMAIN), - self._handle_config_entry_removed, - ) - - async def async_stop(self) -> None: - """Cancel the service browser and stop processing the queue.""" - if self.async_service_browser: - await self.async_service_browser.async_cancel() - - @callback - def _handle_config_entry_removed( - self, - entry: config_entries.ConfigEntry, - ) -> None: - """Handle config entry changes.""" - for discovery_key in entry.discovery_keys[DOMAIN]: - if discovery_key.version != 1: - continue - _type = discovery_key.key[0] - name = discovery_key.key[1] - _LOGGER.debug("Rediscover service %s.%s", _type, name) - self._async_service_update(self.zeroconf, _type, name) - - def _async_dismiss_discoveries(self, name: str) -> None: - """Dismiss all discoveries for the given name.""" - for flow in self.hass.config_entries.flow.async_progress_by_init_data_type( - _ZeroconfServiceInfo, - lambda service_info: bool(service_info.name == name), - ): - self.hass.config_entries.flow.async_abort(flow["flow_id"]) - - @callback - def async_service_update( - self, - zeroconf: HaZeroconf, - service_type: str, - name: str, - state_change: ServiceStateChange, - ) -> None: - """Service state changed.""" - _LOGGER.debug( - "service_update: type=%s name=%s state_change=%s", - service_type, - name, - state_change, - ) - - if state_change is ServiceStateChange.Removed: - self._async_dismiss_discoveries(name) - return - - self._async_service_update(zeroconf, service_type, name) - - @callback - def _async_service_update( - self, - zeroconf: HaZeroconf, - service_type: str, - name: str, - ) -> None: - """Service state added or changed.""" - try: - async_service_info = AsyncServiceInfo(service_type, name) - except BadTypeInNameException as ex: - # Some devices broadcast a name that is not a valid DNS name - # This is a bug in the device firmware and we should ignore it - _LOGGER.debug("Bad name in zeroconf record: %s: %s", name, ex) - return - - if async_service_info.load_from_cache(zeroconf): - self._async_process_service_update(async_service_info, service_type, name) - else: - self.hass.async_create_background_task( - self._async_lookup_and_process_service_update( - zeroconf, async_service_info, service_type, name - ), - name=f"zeroconf lookup {name}.{service_type}", - ) - - async def _async_lookup_and_process_service_update( - self, - zeroconf: HaZeroconf, - async_service_info: AsyncServiceInfo, - service_type: str, - name: str, - ) -> None: - """Update and process a zeroconf update.""" - await async_service_info.async_request(zeroconf, 3000) - self._async_process_service_update(async_service_info, service_type, name) - - @callback - def _async_process_service_update( - self, async_service_info: AsyncServiceInfo, service_type: str, name: str - ) -> None: - """Process a zeroconf update.""" - info = info_from_service(async_service_info) - if not info: - # Prevent the browser thread from collapsing - _LOGGER.debug("Failed to get addresses for device %s", name) - return - _LOGGER.debug("Discovered new device %s %s", name, info) - props: dict[str, str | None] = info.properties - discovery_key = DiscoveryKey( - domain=DOMAIN, - key=(info.type, info.name), - version=1, - ) - domain = None - - # If we can handle it as a HomeKit discovery, we do that here. - if service_type in HOMEKIT_TYPES and ( - homekit_discovery := async_get_homekit_discovery( - self.homekit_model_lookups, self.homekit_model_matchers, props - ) - ): - domain = homekit_discovery.domain - discovery_flow.async_create_flow( - self.hass, - homekit_discovery.domain, - {"source": config_entries.SOURCE_HOMEKIT}, - info, - discovery_key=discovery_key, - ) - # Continue on here as homekit_controller - # still needs to get updates on devices - # so it can see when the 'c#' field is updated. - # - # We only send updates to homekit_controller - # if the device is already paired in order to avoid - # offering a second discovery for the same device - if not is_homekit_paired(props) and not homekit_discovery.always_discover: - # If the device is paired with HomeKit we must send on - # the update to homekit_controller so it can see when - # the 'c#' field is updated. This is used to detect - # when the device has been reset or updated. - # - # If the device is not paired and we should not always - # discover it, we can stop here. - return - - if not (matchers := self.zeroconf_types.get(service_type)): - return - - # Not all homekit types are currently used for discovery - # so not all service type exist in zeroconf_types - for matcher in matchers: - if len(matcher) > 1: - if ATTR_NAME in matcher and not _memorized_fnmatch( - info.name.lower(), matcher[ATTR_NAME] - ): - continue - if ATTR_PROPERTIES in matcher and not _match_against_props( - matcher[ATTR_PROPERTIES], props - ): - continue - - matcher_domain = matcher[ATTR_DOMAIN] - # Create a type annotated regular dict since this is a hot path and creating - # a regular dict is slightly cheaper than calling ConfigFlowContext - context: config_entries.ConfigFlowContext = { - "source": config_entries.SOURCE_ZEROCONF, - } - if domain: - # Domain of integration that offers alternative API to handle - # this device. - context["alternative_domain"] = domain - - discovery_flow.async_create_flow( - self.hass, - matcher_domain, - context, - info, - discovery_key=discovery_key, - ) - - -def async_get_homekit_discovery( - homekit_model_lookups: dict[str, HomeKitDiscoveredIntegration], - homekit_model_matchers: dict[re.Pattern, HomeKitDiscoveredIntegration], - props: dict[str, Any], -) -> HomeKitDiscoveredIntegration | None: - """Handle a HomeKit discovery. - - Return the domain to forward the discovery data to - """ - if not ( - model := props.get(HOMEKIT_MODEL_LOWER) or props.get(HOMEKIT_MODEL_UPPER) - ) or not isinstance(model, str): - return None - - for split_str in _HOMEKIT_MODEL_SPLITS: - key = (model.split(split_str))[0] if split_str else model - if discovery := homekit_model_lookups.get(key): - return discovery - - for pattern, discovery in homekit_model_matchers.items(): - if pattern.match(model): - return discovery - - return None - - -def info_from_service(service: AsyncServiceInfo) -> _ZeroconfServiceInfo | None: - """Return prepared info from mDNS entries.""" - # See https://ietf.org/rfc/rfc6763.html#section-6.4 and - # https://ietf.org/rfc/rfc6763.html#section-6.5 for expected encodings - # for property keys and values - if not (maybe_ip_addresses := service.ip_addresses_by_version(IPVersion.All)): - return None - if TYPE_CHECKING: - ip_addresses = cast(list[IPv4Address | IPv6Address], maybe_ip_addresses) - else: - ip_addresses = maybe_ip_addresses - ip_address: IPv4Address | IPv6Address | None = None - for ip_addr in ip_addresses: - if not ip_addr.is_link_local and not ip_addr.is_unspecified: - ip_address = ip_addr - break - if not ip_address: - return None - - if TYPE_CHECKING: - assert service.server is not None, ( - "server cannot be none if there are addresses" - ) - return _ZeroconfServiceInfo( - ip_address=ip_address, - ip_addresses=ip_addresses, - port=service.port, - hostname=service.server, - type=service.type, - name=service.name, - properties=service.decoded_properties, - ) - - def _suppress_invalid_properties(properties: dict) -> None: """Suppress any properties that will cause zeroconf to fail to startup.""" @@ -644,27 +305,6 @@ def _truncate_location_name_to_valid(location_name: str) -> str: return location_name.encode("utf-8")[:MAX_NAME_LEN].decode("utf-8", "ignore") -@lru_cache(maxsize=4096, typed=True) -def _compile_fnmatch(pattern: str) -> re.Pattern: - """Compile a fnmatch pattern.""" - return re.compile(translate(pattern)) - - -@lru_cache(maxsize=1024, typed=True) -def _memorized_fnmatch(name: str, pattern: str) -> bool: - """Memorized version of fnmatch that has a larger lru_cache. - - The default version of fnmatch only has a lru_cache of 256 entries. - With many devices we quickly reach that limit and end up compiling - the same pattern over and over again. - - Zeroconf has its own memorized fnmatch with its own lru_cache - since the data is going to be relatively the same - since the devices will not change frequently - """ - return bool(_compile_fnmatch(pattern).match(name)) - - # These can be removed if no deprecated constant are in this module anymore __getattr__ = partial(check_if_deprecated_constant, module_globals=globals()) __dir__ = partial( diff --git a/homeassistant/components/zeroconf/const.py b/homeassistant/components/zeroconf/const.py new file mode 100644 index 00000000000..3a99a6758ca --- /dev/null +++ b/homeassistant/components/zeroconf/const.py @@ -0,0 +1,5 @@ +"""Zeroconf constants.""" + +DOMAIN = "zeroconf" + +ZEROCONF_TYPE = "_home-assistant._tcp.local." diff --git a/homeassistant/components/zeroconf/discovery.py b/homeassistant/components/zeroconf/discovery.py new file mode 100644 index 00000000000..b2e06c19948 --- /dev/null +++ b/homeassistant/components/zeroconf/discovery.py @@ -0,0 +1,385 @@ +"""Zeroconf discovery for Home Assistant.""" + +from __future__ import annotations + +import contextlib +from fnmatch import translate +from functools import lru_cache +from ipaddress import IPv4Address, IPv6Address +import logging +import re +from typing import TYPE_CHECKING, Any, Final, cast + +from zeroconf import BadTypeInNameException, IPVersion, ServiceStateChange +from zeroconf.asyncio import AsyncServiceBrowser, AsyncServiceInfo + +from homeassistant import config_entries +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers import discovery_flow +from homeassistant.helpers.discovery_flow import DiscoveryKey +from homeassistant.helpers.dispatcher import async_dispatcher_connect +from homeassistant.helpers.service_info.zeroconf import ( + ZeroconfServiceInfo as _ZeroconfServiceInfo, +) +from homeassistant.loader import HomeKitDiscoveredIntegration, ZeroconfMatcher +from homeassistant.util.hass_dict import HassKey + +from .const import DOMAIN + +if TYPE_CHECKING: + from .models import HaZeroconf + +_LOGGER = logging.getLogger(__name__) + +ZEROCONF_TYPE = "_home-assistant._tcp.local." +HOMEKIT_TYPES = [ + "_hap._tcp.local.", + # Thread based devices + "_hap._udp.local.", +] +_HOMEKIT_MODEL_SPLITS = (None, " ", "-") + + +HOMEKIT_PAIRED_STATUS_FLAG = "sf" +HOMEKIT_MODEL_LOWER = "md" +HOMEKIT_MODEL_UPPER = "MD" + +ATTR_DOMAIN: Final = "domain" +ATTR_NAME: Final = "name" +ATTR_PROPERTIES: Final = "properties" + + +DATA_DISCOVERY: HassKey[ZeroconfDiscovery] = HassKey("zeroconf_discovery") + + +def build_homekit_model_lookups( + homekit_models: dict[str, HomeKitDiscoveredIntegration], +) -> tuple[ + dict[str, HomeKitDiscoveredIntegration], + dict[re.Pattern, HomeKitDiscoveredIntegration], +]: + """Build lookups for homekit models.""" + homekit_model_lookup: dict[str, HomeKitDiscoveredIntegration] = {} + homekit_model_matchers: dict[re.Pattern, HomeKitDiscoveredIntegration] = {} + + for model, discovery in homekit_models.items(): + if "*" in model or "?" in model or "[" in model: + homekit_model_matchers[_compile_fnmatch(model)] = discovery + else: + homekit_model_lookup[model] = discovery + + return homekit_model_lookup, homekit_model_matchers + + +@lru_cache(maxsize=4096, typed=True) +def _compile_fnmatch(pattern: str) -> re.Pattern: + """Compile a fnmatch pattern.""" + return re.compile(translate(pattern)) + + +@lru_cache(maxsize=1024, typed=True) +def _memorized_fnmatch(name: str, pattern: str) -> bool: + """Memorized version of fnmatch that has a larger lru_cache. + + The default version of fnmatch only has a lru_cache of 256 entries. + With many devices we quickly reach that limit and end up compiling + the same pattern over and over again. + + Zeroconf has its own memorized fnmatch with its own lru_cache + since the data is going to be relatively the same + since the devices will not change frequently + """ + return bool(_compile_fnmatch(pattern).match(name)) + + +def _match_against_props(matcher: dict[str, str], props: dict[str, str | None]) -> bool: + """Check a matcher to ensure all values in props.""" + for key, value in matcher.items(): + prop_val = props.get(key) + if prop_val is None or not _memorized_fnmatch(prop_val.lower(), value): + return False + return True + + +def is_homekit_paired(props: dict[str, Any]) -> bool: + """Check properties to see if a device is homekit paired.""" + if HOMEKIT_PAIRED_STATUS_FLAG not in props: + return False + with contextlib.suppress(ValueError): + # 0 means paired and not discoverable by iOS clients) + return int(props[HOMEKIT_PAIRED_STATUS_FLAG]) == 0 + # If we cannot tell, we assume its not paired + return False + + +def async_get_homekit_discovery( + homekit_model_lookups: dict[str, HomeKitDiscoveredIntegration], + homekit_model_matchers: dict[re.Pattern, HomeKitDiscoveredIntegration], + props: dict[str, Any], +) -> HomeKitDiscoveredIntegration | None: + """Handle a HomeKit discovery. + + Return the domain to forward the discovery data to + """ + if not ( + model := props.get(HOMEKIT_MODEL_LOWER) or props.get(HOMEKIT_MODEL_UPPER) + ) or not isinstance(model, str): + return None + + for split_str in _HOMEKIT_MODEL_SPLITS: + key = (model.split(split_str))[0] if split_str else model + if discovery := homekit_model_lookups.get(key): + return discovery + + for pattern, discovery in homekit_model_matchers.items(): + if pattern.match(model): + return discovery + + return None + + +def info_from_service(service: AsyncServiceInfo) -> _ZeroconfServiceInfo | None: + """Return prepared info from mDNS entries.""" + # See https://ietf.org/rfc/rfc6763.html#section-6.4 and + # https://ietf.org/rfc/rfc6763.html#section-6.5 for expected encodings + # for property keys and values + if not (maybe_ip_addresses := service.ip_addresses_by_version(IPVersion.All)): + return None + if TYPE_CHECKING: + ip_addresses = cast(list[IPv4Address | IPv6Address], maybe_ip_addresses) + else: + ip_addresses = maybe_ip_addresses + ip_address: IPv4Address | IPv6Address | None = None + for ip_addr in ip_addresses: + if not ip_addr.is_link_local and not ip_addr.is_unspecified: + ip_address = ip_addr + break + if not ip_address: + return None + + if TYPE_CHECKING: + assert service.server is not None, ( + "server cannot be none if there are addresses" + ) + return _ZeroconfServiceInfo( + ip_address=ip_address, + ip_addresses=ip_addresses, + port=service.port, + hostname=service.server, + type=service.type, + name=service.name, + properties=service.decoded_properties, + ) + + +class ZeroconfDiscovery: + """Discovery via zeroconf.""" + + def __init__( + self, + hass: HomeAssistant, + zeroconf: HaZeroconf, + zeroconf_types: dict[str, list[ZeroconfMatcher]], + homekit_model_lookups: dict[str, HomeKitDiscoveredIntegration], + homekit_model_matchers: dict[re.Pattern, HomeKitDiscoveredIntegration], + ) -> None: + """Init discovery.""" + self.hass = hass + self.zeroconf = zeroconf + self.zeroconf_types = zeroconf_types + self.homekit_model_lookups = homekit_model_lookups + self.homekit_model_matchers = homekit_model_matchers + self.async_service_browser: AsyncServiceBrowser | None = None + + async def async_setup(self) -> None: + """Start discovery.""" + types = list(self.zeroconf_types) + # We want to make sure we know about other HomeAssistant + # instances as soon as possible to avoid name conflicts + # so we always browse for ZEROCONF_TYPE + types.extend( + hk_type + for hk_type in (ZEROCONF_TYPE, *HOMEKIT_TYPES) + if hk_type not in self.zeroconf_types + ) + _LOGGER.debug("Starting Zeroconf browser for: %s", types) + self.async_service_browser = AsyncServiceBrowser( + self.zeroconf, types, handlers=[self.async_service_update] + ) + + async_dispatcher_connect( + self.hass, + config_entries.signal_discovered_config_entry_removed(DOMAIN), + self._handle_config_entry_removed, + ) + + async def async_stop(self) -> None: + """Cancel the service browser and stop processing the queue.""" + if self.async_service_browser: + await self.async_service_browser.async_cancel() + + @callback + def _handle_config_entry_removed( + self, + entry: config_entries.ConfigEntry, + ) -> None: + """Handle config entry changes.""" + for discovery_key in entry.discovery_keys[DOMAIN]: + if discovery_key.version != 1: + continue + _type = discovery_key.key[0] + name = discovery_key.key[1] + _LOGGER.debug("Rediscover service %s.%s", _type, name) + self._async_service_update(self.zeroconf, _type, name) + + def _async_dismiss_discoveries(self, name: str) -> None: + """Dismiss all discoveries for the given name.""" + for flow in self.hass.config_entries.flow.async_progress_by_init_data_type( + _ZeroconfServiceInfo, + lambda service_info: bool(service_info.name == name), + ): + self.hass.config_entries.flow.async_abort(flow["flow_id"]) + + @callback + def async_service_update( + self, + zeroconf: HaZeroconf, + service_type: str, + name: str, + state_change: ServiceStateChange, + ) -> None: + """Service state changed.""" + _LOGGER.debug( + "service_update: type=%s name=%s state_change=%s", + service_type, + name, + state_change, + ) + + if state_change is ServiceStateChange.Removed: + self._async_dismiss_discoveries(name) + return + + self._async_service_update(zeroconf, service_type, name) + + @callback + def _async_service_update( + self, + zeroconf: HaZeroconf, + service_type: str, + name: str, + ) -> None: + """Service state added or changed.""" + try: + async_service_info = AsyncServiceInfo(service_type, name) + except BadTypeInNameException as ex: + # Some devices broadcast a name that is not a valid DNS name + # This is a bug in the device firmware and we should ignore it + _LOGGER.debug("Bad name in zeroconf record: %s: %s", name, ex) + return + + if async_service_info.load_from_cache(zeroconf): + self._async_process_service_update(async_service_info, service_type, name) + else: + self.hass.async_create_background_task( + self._async_lookup_and_process_service_update( + zeroconf, async_service_info, service_type, name + ), + name=f"zeroconf lookup {name}.{service_type}", + ) + + async def _async_lookup_and_process_service_update( + self, + zeroconf: HaZeroconf, + async_service_info: AsyncServiceInfo, + service_type: str, + name: str, + ) -> None: + """Update and process a zeroconf update.""" + await async_service_info.async_request(zeroconf, 3000) + self._async_process_service_update(async_service_info, service_type, name) + + @callback + def _async_process_service_update( + self, async_service_info: AsyncServiceInfo, service_type: str, name: str + ) -> None: + """Process a zeroconf update.""" + info = info_from_service(async_service_info) + if not info: + # Prevent the browser thread from collapsing + _LOGGER.debug("Failed to get addresses for device %s", name) + return + _LOGGER.debug("Discovered new device %s %s", name, info) + props: dict[str, str | None] = info.properties + discovery_key = DiscoveryKey( + domain=DOMAIN, + key=(info.type, info.name), + version=1, + ) + domain = None + + # If we can handle it as a HomeKit discovery, we do that here. + if service_type in HOMEKIT_TYPES and ( + homekit_discovery := async_get_homekit_discovery( + self.homekit_model_lookups, self.homekit_model_matchers, props + ) + ): + domain = homekit_discovery.domain + discovery_flow.async_create_flow( + self.hass, + homekit_discovery.domain, + {"source": config_entries.SOURCE_HOMEKIT}, + info, + discovery_key=discovery_key, + ) + # Continue on here as homekit_controller + # still needs to get updates on devices + # so it can see when the 'c#' field is updated. + # + # We only send updates to homekit_controller + # if the device is already paired in order to avoid + # offering a second discovery for the same device + if not is_homekit_paired(props) and not homekit_discovery.always_discover: + # If the device is paired with HomeKit we must send on + # the update to homekit_controller so it can see when + # the 'c#' field is updated. This is used to detect + # when the device has been reset or updated. + # + # If the device is not paired and we should not always + # discover it, we can stop here. + return + + if not (matchers := self.zeroconf_types.get(service_type)): + return + + # Not all homekit types are currently used for discovery + # so not all service type exist in zeroconf_types + for matcher in matchers: + if len(matcher) > 1: + if ATTR_NAME in matcher and not _memorized_fnmatch( + info.name.lower(), matcher[ATTR_NAME] + ): + continue + if ATTR_PROPERTIES in matcher and not _match_against_props( + matcher[ATTR_PROPERTIES], props + ): + continue + + matcher_domain = matcher[ATTR_DOMAIN] + # Create a type annotated regular dict since this is a hot path and creating + # a regular dict is slightly cheaper than calling ConfigFlowContext + context: config_entries.ConfigFlowContext = { + "source": config_entries.SOURCE_ZEROCONF, + } + if domain: + # Domain of integration that offers alternative API to handle + # this device. + context["alternative_domain"] = domain + + discovery_flow.async_create_flow( + self.hass, + matcher_domain, + context, + info, + discovery_key=discovery_key, + ) diff --git a/homeassistant/components/zha/strings.json b/homeassistant/components/zha/strings.json index 79cb05c3a0e..be7add23d56 100644 --- a/homeassistant/components/zha/strings.json +++ b/homeassistant/components/zha/strings.json @@ -1509,7 +1509,7 @@ "name": "Software error", "state": { "nothing": "Good", - "something": "Error" + "something": "[%key:common::state::error%]" }, "state_attributes": { "top_pcb_sensor_error": { diff --git a/homeassistant/components/zone/__init__.py b/homeassistant/components/zone/__init__.py index 813425c95f2..42988f49dc0 100644 --- a/homeassistant/components/zone/__init__.py +++ b/homeassistant/components/zone/__init__.py @@ -2,7 +2,7 @@ from __future__ import annotations -from collections.abc import Callable, Iterable +from collections.abc import Callable import logging from operator import attrgetter import sys @@ -47,6 +47,7 @@ from homeassistant.helpers import ( ) from homeassistant.helpers.typing import ConfigType, VolDictType from homeassistant.loader import bind_hass +from homeassistant.util.hass_dict import HassKey from homeassistant.util.location import distance from .const import ATTR_PASSIVE, ATTR_RADIUS, CONF_PASSIVE, DOMAIN, HOME_ZONE @@ -108,6 +109,9 @@ ENTITY_ID_SORTER = attrgetter("entity_id") ZONE_ENTITY_IDS = "zone_entity_ids" +DATA_ZONE_STORAGE_COLLECTION: HassKey[ZoneStorageCollection] = HassKey(DOMAIN) +DATA_ZONE_ENTITY_IDS: HassKey[list[str]] = HassKey(ZONE_ENTITY_IDS) + @bind_hass def async_active_zone( @@ -122,7 +126,7 @@ def async_active_zone( closest: State | None = None # This can be called before async_setup by device tracker - zone_entity_ids: Iterable[str] = hass.data.get(ZONE_ENTITY_IDS, ()) + zone_entity_ids = hass.data.get(DATA_ZONE_ENTITY_IDS, ()) for entity_id in zone_entity_ids: if ( @@ -168,8 +172,8 @@ def async_active_zone( @callback def async_setup_track_zone_entity_ids(hass: HomeAssistant) -> None: """Set up track of entity IDs for zones.""" - zone_entity_ids: list[str] = hass.states.async_entity_ids(DOMAIN) - hass.data[ZONE_ENTITY_IDS] = zone_entity_ids + zone_entity_ids = hass.states.async_entity_ids(DOMAIN) + hass.data[DATA_ZONE_ENTITY_IDS] = zone_entity_ids @callback def _async_add_zone_entity_id( @@ -290,7 +294,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: hass.bus.async_listen(EVENT_CORE_CONFIG_UPDATE, core_config_updated) - hass.data[DOMAIN] = storage_collection + hass.data[DATA_ZONE_STORAGE_COLLECTION] = storage_collection return True @@ -312,13 +316,11 @@ async def async_setup_entry( hass: HomeAssistant, config_entry: config_entries.ConfigEntry ) -> bool: """Set up zone as config entry.""" - storage_collection = cast(ZoneStorageCollection, hass.data[DOMAIN]) - data = dict(config_entry.data) data.setdefault(CONF_PASSIVE, DEFAULT_PASSIVE) data.setdefault(CONF_RADIUS, DEFAULT_RADIUS) - await storage_collection.async_create_item(data) + await hass.data[DATA_ZONE_STORAGE_COLLECTION].async_create_item(data) hass.async_create_task( hass.config_entries.async_remove(config_entry.entry_id), eager_start=True diff --git a/homeassistant/core.py b/homeassistant/core.py index b33e9496c7c..65f3b7502a5 100644 --- a/homeassistant/core.py +++ b/homeassistant/core.py @@ -427,9 +427,6 @@ class HomeAssistant: def __init__(self, config_dir: str) -> None: """Initialize new Home Assistant object.""" - # pylint: disable-next=import-outside-toplevel - from . import loader - # pylint: disable-next=import-outside-toplevel from .core_config import Config @@ -443,8 +440,6 @@ class HomeAssistant: self.states = StateMachine(self.bus, self.loop) self.config = Config(self, config_dir) self.config.async_initialize() - self.components = loader.Components(self) - self.helpers = loader.Helpers(self) self.state: CoreState = CoreState.not_running self.exit_code: int = 0 # If not None, use to signal end-of-loop diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index c53c83bad38..f6c658b396a 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -428,6 +428,7 @@ FLOWS = { "nobo_hub", "nordpool", "notion", + "ntfy", "nuheat", "nuki", "nut", diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index 8dda9de3705..63e97c96585 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -4430,6 +4430,12 @@ "config_flow": false, "iot_class": "cloud_polling" }, + "ntfy": { + "name": "ntfy", + "integration_type": "hub", + "config_flow": true, + "iot_class": "cloud_push" + }, "nuheat": { "name": "NuHeat", "integration_type": "hub", @@ -4437,7 +4443,7 @@ "iot_class": "cloud_polling" }, "nuki": { - "name": "Nuki", + "name": "Nuki Bridge", "integration_type": "hub", "config_flow": true, "iot_class": "local_polling" diff --git a/homeassistant/helpers/config_validation.py b/homeassistant/helpers/config_validation.py index 5c1a7c99565..655913558d6 100644 --- a/homeassistant/helpers/config_validation.py +++ b/homeassistant/helpers/config_validation.py @@ -21,7 +21,7 @@ from socket import ( # type: ignore[attr-defined] # private, not in typeshed _GLOBAL_DEFAULT_TIMEOUT, ) import threading -from typing import Any, cast, overload +from typing import TYPE_CHECKING, Any, cast, overload from urllib.parse import urlparse from uuid import UUID @@ -355,7 +355,13 @@ def ensure_list[_T](value: _T | None) -> list[_T] | list[Any]: """Wrap value in list if it is not one.""" if value is None: return [] - return cast(list[_T], value) if isinstance(value, list) else [value] + if isinstance(value, list): + if TYPE_CHECKING: + # https://github.com/home-assistant/core/pull/71960 + # cast with a type variable is still slow. + return cast(list[_T], value) + return value # type: ignore[unreachable] + return [value] def entity_id(value: Any) -> str: diff --git a/homeassistant/loader.py b/homeassistant/loader.py index 2498cf39ffe..0980a6f2ba9 100644 --- a/homeassistant/loader.py +++ b/homeassistant/loader.py @@ -1710,76 +1710,6 @@ class ModuleWrapper: return value -class Components: - """Helper to load components.""" - - def __init__(self, hass: HomeAssistant) -> None: - """Initialize the Components class.""" - self._hass = hass - - def __getattr__(self, comp_name: str) -> ModuleWrapper: - """Fetch a component.""" - # Test integration cache - integration = self._hass.data[DATA_INTEGRATIONS].get(comp_name) - - if isinstance(integration, Integration): - component: ComponentProtocol | None = integration.get_component() - else: - # Fallback to importing old-school - component = _load_file(self._hass, comp_name, _lookup_path(self._hass)) - - if component is None: - raise ImportError(f"Unable to load {comp_name}") - - # Local import to avoid circular dependencies - # pylint: disable-next=import-outside-toplevel - from .helpers.frame import ReportBehavior, report_usage - - report_usage( - f"accesses hass.components.{comp_name}, which" - f" should be updated to import functions used from {comp_name} directly", - core_behavior=ReportBehavior.IGNORE, - core_integration_behavior=ReportBehavior.IGNORE, - custom_integration_behavior=ReportBehavior.LOG, - breaks_in_ha_version="2025.3", - ) - - wrapped = ModuleWrapper(self._hass, component) - setattr(self, comp_name, wrapped) - return wrapped - - -class Helpers: - """Helper to load helpers.""" - - def __init__(self, hass: HomeAssistant) -> None: - """Initialize the Helpers class.""" - self._hass = hass - - def __getattr__(self, helper_name: str) -> ModuleWrapper: - """Fetch a helper.""" - helper = importlib.import_module(f"homeassistant.helpers.{helper_name}") - - # Local import to avoid circular dependencies - # pylint: disable-next=import-outside-toplevel - from .helpers.frame import ReportBehavior, report_usage - - report_usage( - ( - f"accesses hass.helpers.{helper_name}, which" - f" should be updated to import functions used from {helper_name} directly" - ), - core_behavior=ReportBehavior.IGNORE, - core_integration_behavior=ReportBehavior.IGNORE, - custom_integration_behavior=ReportBehavior.LOG, - breaks_in_ha_version="2025.5", - ) - - wrapped = ModuleWrapper(self._hass, helper) - setattr(self, helper_name, wrapped) - return wrapped - - def bind_hass[_CallableT: Callable[..., Any]](func: _CallableT) -> _CallableT: """Decorate function to indicate that first argument is hass. diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 3baebae8a6e..6bfb571126a 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -6,7 +6,7 @@ aiodns==3.2.0 aiohasupervisor==0.3.1b1 aiohttp-asyncmdnsresolver==0.1.1 aiohttp-fast-zlib==0.2.3 -aiohttp==3.11.16 +aiohttp==3.11.18 aiohttp_cors==0.7.0 aiousbwatcher==1.1.1 aiozoneinfo==0.2.3 @@ -31,7 +31,7 @@ ciso8601==2.3.2 cronsim==2.6 cryptography==44.0.1 dbus-fast==2.43.0 -fnv-hash-fast==1.4.0 +fnv-hash-fast==1.5.0 go2rtc-client==0.1.2 ha-ffmpeg==3.2.2 habluetooth==3.39.0 diff --git a/mypy.ini b/mypy.ini index 0e42a6c3594..5c6db87590f 100644 --- a/mypy.ini +++ b/mypy.ini @@ -3386,6 +3386,16 @@ disallow_untyped_defs = true warn_return_any = true warn_unreachable = true +[mypy-homeassistant.components.ntfy.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + [mypy-homeassistant.components.number.*] check_untyped_defs = true disallow_incomplete_defs = true diff --git a/pylint/plugins/hass_enforce_type_hints.py b/pylint/plugins/hass_enforce_type_hints.py index ca7777da959..4f9f7603328 100644 --- a/pylint/plugins/hass_enforce_type_hints.py +++ b/pylint/plugins/hass_enforce_type_hints.py @@ -597,6 +597,16 @@ _CLASS_MATCH: dict[str, list[ClassTypeHintMatch]] = { ), ], ), + ClassTypeHintMatch( + base_class="ConfigSubentryFlow", + matches=[ + TypeHintMatch( + function_name="async_step_*", + arg_types={}, + return_type="SubentryFlowResult", + ), + ], + ), ], } # Overriding properties and functions are normally checked by mypy, and will only diff --git a/pyproject.toml b/pyproject.toml index e100863510d..9487ac6c984 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,5 +1,5 @@ [build-system] -requires = ["setuptools==77.0.3"] +requires = ["setuptools==78.1.1"] build-backend = "setuptools.build_meta" [project] @@ -28,7 +28,7 @@ dependencies = [ # change behavior based on presence of supervisor. Deprecated with #127228 # Lib can be removed with 2025.11 "aiohasupervisor==0.3.1b1", - "aiohttp==3.11.16", + "aiohttp==3.11.18", "aiohttp_cors==0.7.0", "aiohttp-fast-zlib==0.2.3", "aiohttp-asyncmdnsresolver==0.1.1", @@ -44,7 +44,7 @@ dependencies = [ "certifi>=2021.5.30", "ciso8601==2.3.2", "cronsim==2.6", - "fnv-hash-fast==1.4.0", + "fnv-hash-fast==1.5.0", # ha-ffmpeg is indirectly imported from onboarding via the import chain # onboarding->cloud->assist_pipeline->tts->ffmpeg. Onboarding needs # to be setup in stage 0, but we don't want to also promote cloud with all its diff --git a/requirements.txt b/requirements.txt index bfc330650e4..fe8a0a919aa 100644 --- a/requirements.txt +++ b/requirements.txt @@ -5,7 +5,7 @@ # Home Assistant Core aiodns==3.2.0 aiohasupervisor==0.3.1b1 -aiohttp==3.11.16 +aiohttp==3.11.18 aiohttp_cors==0.7.0 aiohttp-fast-zlib==0.2.3 aiohttp-asyncmdnsresolver==0.1.1 @@ -21,7 +21,7 @@ bcrypt==4.2.0 certifi>=2021.5.30 ciso8601==2.3.2 cronsim==2.6 -fnv-hash-fast==1.4.0 +fnv-hash-fast==1.5.0 ha-ffmpeg==3.2.2 hass-nabucasa==0.94.0 hassil==2.2.3 diff --git a/requirements_all.txt b/requirements_all.txt index 322f0c229f9..f5858a93e2e 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -179,7 +179,7 @@ aioacaia==0.1.14 aioairq==0.4.4 # homeassistant.components.airzone_cloud -aioairzone-cloud==0.6.11 +aioairzone-cloud==0.6.12 # homeassistant.components.airzone aioairzone==1.0.0 @@ -267,7 +267,7 @@ aiohasupervisor==0.3.1b1 aiohomeconnect==0.17.0 # homeassistant.components.homekit_controller -aiohomekit==3.2.13 +aiohomekit==3.2.14 # homeassistant.components.mcp_server aiohttp_sse==2.2.0 @@ -314,6 +314,9 @@ aionanoleaf==0.2.1 # homeassistant.components.notion aionotion==2024.03.0 +# homeassistant.components.ntfy +aiontfy==0.5.1 + # homeassistant.components.nut aionut==4.3.4 @@ -362,7 +365,7 @@ aioridwell==2024.01.0 aioruckus==0.42 # homeassistant.components.russound_rio -aiorussound==4.5.0 +aiorussound==4.5.2 # homeassistant.components.ruuvi_gateway aioruuvigateway==0.1.0 @@ -371,7 +374,7 @@ aioruuvigateway==0.1.0 aiosenz==1.0.0 # homeassistant.components.shelly -aioshelly==13.4.1 +aioshelly==13.5.0 # homeassistant.components.skybell aioskybell==22.7.0 @@ -784,7 +787,7 @@ devolo-home-control-api==0.18.3 devolo-plc-api==1.5.1 # homeassistant.components.chacon_dio -dio-chacon-wifi-api==1.2.1 +dio-chacon-wifi-api==1.2.2 # homeassistant.components.directv directv==0.4.0 @@ -948,7 +951,7 @@ flux-led==1.2.0 # homeassistant.components.homekit # homeassistant.components.recorder -fnv-hash-fast==1.4.0 +fnv-hash-fast==1.5.0 # homeassistant.components.foobot foobot_async==1.0.0 @@ -1172,7 +1175,7 @@ horimote==0.4.1 httplib2==0.20.4 # homeassistant.components.huawei_lte -huawei-lte-api==1.10.0 +huawei-lte-api==1.11.0 # homeassistant.components.huum huum==0.7.12 @@ -1308,7 +1311,7 @@ lakeside==0.13 laundrify-aio==1.2.2 # homeassistant.components.lcn -lcn-frontend==0.2.3 +lcn-frontend==0.2.4 # homeassistant.components.ld2410_ble ld2410-ble==0.1.1 @@ -1834,7 +1837,7 @@ pyasuswrt==0.1.21 pyatag==0.3.5.3 # homeassistant.components.netatmo -pyatmo==8.1.0 +pyatmo==9.0.0 # homeassistant.components.apple_tv pyatv==0.16.0 @@ -2089,7 +2092,7 @@ pykwb==0.0.8 pylacrosse==0.4 # homeassistant.components.lamarzocco -pylamarzocco==2.0.0b1 +pylamarzocco==2.0.0b3 # homeassistant.components.lastfm pylast==5.1.0 @@ -2134,7 +2137,7 @@ pymeteoclimatic==0.1.0 pymicro-vad==1.0.1 # homeassistant.components.miele -pymiele==0.3.4 +pymiele==0.3.6 # homeassistant.components.xiaomi_tv pymitv==1.4.3 @@ -2214,7 +2217,7 @@ pyotgw==2.2.2 pyotp==2.8.0 # homeassistant.components.overkiz -pyoverkiz==1.17.0 +pyoverkiz==1.17.1 # homeassistant.components.onewire pyownet==0.10.0.post1 @@ -2289,7 +2292,7 @@ pysabnzbd==1.1.1 pysaj==0.0.16 # homeassistant.components.schlage -pyschlage==2024.11.0 +pyschlage==2025.4.0 # homeassistant.components.sensibo pysensibo==1.1.0 @@ -2436,7 +2439,7 @@ python-juicenet==1.1.0 python-kasa[speedups]==0.10.2 # homeassistant.components.linkplay -python-linkplay==0.2.3 +python-linkplay==0.2.4 # homeassistant.components.lirc # python-lirc==1.2.3 @@ -2855,7 +2858,7 @@ systembridgeconnector==4.1.5 systembridgemodels==4.2.4 # homeassistant.components.tailscale -tailscale==0.6.1 +tailscale==0.6.2 # homeassistant.components.tank_utility tank-utility==1.5.0 @@ -2893,7 +2896,7 @@ tesla-powerwall==0.5.2 tesla-wall-connector==1.0.2 # homeassistant.components.teslemetry -teslemetry-stream==0.7.1 +teslemetry-stream==0.7.5 # homeassistant.components.tessie tessie-api==0.1.1 @@ -3121,7 +3124,7 @@ yalesmartalarmclient==0.4.3 # homeassistant.components.august # homeassistant.components.yale # homeassistant.components.yalexs_ble -yalexs-ble==2.5.7 +yalexs-ble==2.6.0 # homeassistant.components.august # homeassistant.components.yale diff --git a/requirements_test_all.txt b/requirements_test_all.txt index ad4501e45ad..f63ae3132ea 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -167,7 +167,7 @@ aioacaia==0.1.14 aioairq==0.4.4 # homeassistant.components.airzone_cloud -aioairzone-cloud==0.6.11 +aioairzone-cloud==0.6.12 # homeassistant.components.airzone aioairzone==1.0.0 @@ -252,7 +252,7 @@ aiohasupervisor==0.3.1b1 aiohomeconnect==0.17.0 # homeassistant.components.homekit_controller -aiohomekit==3.2.13 +aiohomekit==3.2.14 # homeassistant.components.mcp_server aiohttp_sse==2.2.0 @@ -296,6 +296,9 @@ aionanoleaf==0.2.1 # homeassistant.components.notion aionotion==2024.03.0 +# homeassistant.components.ntfy +aiontfy==0.5.1 + # homeassistant.components.nut aionut==4.3.4 @@ -344,7 +347,7 @@ aioridwell==2024.01.0 aioruckus==0.42 # homeassistant.components.russound_rio -aiorussound==4.5.0 +aiorussound==4.5.2 # homeassistant.components.ruuvi_gateway aioruuvigateway==0.1.0 @@ -353,7 +356,7 @@ aioruuvigateway==0.1.0 aiosenz==1.0.0 # homeassistant.components.shelly -aioshelly==13.4.1 +aioshelly==13.5.0 # homeassistant.components.skybell aioskybell==22.7.0 @@ -675,7 +678,7 @@ devolo-home-control-api==0.18.3 devolo-plc-api==1.5.1 # homeassistant.components.chacon_dio -dio-chacon-wifi-api==1.2.1 +dio-chacon-wifi-api==1.2.2 # homeassistant.components.directv directv==0.4.0 @@ -808,7 +811,7 @@ flux-led==1.2.0 # homeassistant.components.homekit # homeassistant.components.recorder -fnv-hash-fast==1.4.0 +fnv-hash-fast==1.5.0 # homeassistant.components.foobot foobot_async==1.0.0 @@ -999,7 +1002,7 @@ homematicip==2.0.0 httplib2==0.20.4 # homeassistant.components.huawei_lte -huawei-lte-api==1.10.0 +huawei-lte-api==1.11.0 # homeassistant.components.huum huum==0.7.12 @@ -1108,7 +1111,7 @@ lacrosse-view==1.1.1 laundrify-aio==1.2.2 # homeassistant.components.lcn -lcn-frontend==0.2.3 +lcn-frontend==0.2.4 # homeassistant.components.ld2410_ble ld2410-ble==0.1.1 @@ -1515,7 +1518,7 @@ pyasuswrt==0.1.21 pyatag==0.3.5.3 # homeassistant.components.netatmo -pyatmo==8.1.0 +pyatmo==9.0.0 # homeassistant.components.apple_tv pyatv==0.16.0 @@ -1704,7 +1707,7 @@ pykrakenapi==0.1.8 pykulersky==0.5.8 # homeassistant.components.lamarzocco -pylamarzocco==2.0.0b1 +pylamarzocco==2.0.0b3 # homeassistant.components.lastfm pylast==5.1.0 @@ -1746,7 +1749,7 @@ pymeteoclimatic==0.1.0 pymicro-vad==1.0.1 # homeassistant.components.miele -pymiele==0.3.4 +pymiele==0.3.6 # homeassistant.components.mochad pymochad==0.2.0 @@ -1811,7 +1814,7 @@ pyotgw==2.2.2 pyotp==2.8.0 # homeassistant.components.overkiz -pyoverkiz==1.17.0 +pyoverkiz==1.17.1 # homeassistant.components.onewire pyownet==0.10.0.post1 @@ -1871,7 +1874,7 @@ pyrympro==0.0.9 pysabnzbd==1.1.1 # homeassistant.components.schlage -pyschlage==2024.11.0 +pyschlage==2025.4.0 # homeassistant.components.sensibo pysensibo==1.1.0 @@ -1976,7 +1979,7 @@ python-juicenet==1.1.0 python-kasa[speedups]==0.10.2 # homeassistant.components.linkplay -python-linkplay==0.2.3 +python-linkplay==0.2.4 # homeassistant.components.matter python-matter-server==7.0.0 @@ -2311,7 +2314,7 @@ systembridgeconnector==4.1.5 systembridgemodels==4.2.4 # homeassistant.components.tailscale -tailscale==0.6.1 +tailscale==0.6.2 # homeassistant.components.tellduslive tellduslive==0.10.12 @@ -2334,7 +2337,7 @@ tesla-powerwall==0.5.2 tesla-wall-connector==1.0.2 # homeassistant.components.teslemetry -teslemetry-stream==0.7.1 +teslemetry-stream==0.7.5 # homeassistant.components.tessie tessie-api==0.1.1 @@ -2523,7 +2526,7 @@ yalesmartalarmclient==0.4.3 # homeassistant.components.august # homeassistant.components.yale # homeassistant.components.yalexs_ble -yalexs-ble==2.5.7 +yalexs-ble==2.6.0 # homeassistant.components.august # homeassistant.components.yale diff --git a/script/hassfest/dependencies.py b/script/hassfest/dependencies.py index 370be8d66f1..ee932280201 100644 --- a/script/hassfest/dependencies.py +++ b/script/hassfest/dependencies.py @@ -84,37 +84,6 @@ class ImportCollector(ast.NodeVisitor): if name_node.name.startswith("homeassistant.components."): self._add_reference(name_node.name.split(".")[2]) - def visit_Attribute(self, node: ast.Attribute) -> None: - """Visit Attribute node.""" - # hass.components.hue.async_create() - # Name(id=hass) - # .Attribute(attr=hue) - # .Attribute(attr=async_create) - - # self.hass.components.hue.async_create() - # Name(id=self) - # .Attribute(attr=hass) or .Attribute(attr=_hass) - # .Attribute(attr=hue) - # .Attribute(attr=async_create) - if ( - isinstance(node.value, ast.Attribute) - and node.value.attr == "components" - and ( - ( - isinstance(node.value.value, ast.Name) - and node.value.value.id == "hass" - ) - or ( - isinstance(node.value.value, ast.Attribute) - and node.value.value.attr in ("hass", "_hass") - ) - ) - ): - self._add_reference(node.attr) - else: - # Have it visit other kids - self.generic_visit(node) - ALLOWED_USED_COMPONENTS = { *{platform.value for platform in Platform}, diff --git a/script/hassfest/quality_scale.py b/script/hassfest/quality_scale.py index 5885b4acb1f..2215be04840 100644 --- a/script/hassfest/quality_scale.py +++ b/script/hassfest/quality_scale.py @@ -439,7 +439,6 @@ INTEGRATIONS_WITHOUT_QUALITY_SCALE_FILE = [ "goalzero", "gogogate2", "goodwe", - "google", "google_assistant", "google_assistant_sdk", "google_cloud", @@ -1397,7 +1396,6 @@ INTEGRATIONS_WITHOUT_SCALE = [ "energy", "energyzero", "enigma2", - "enphase_envoy", "enocean", "entur_public_transport", "environment_canada", @@ -2133,7 +2131,6 @@ INTEGRATIONS_WITHOUT_SCALE = [ "upcloud", "upnp", "uptime", - "uptimerobot", "usb", "usgs_earthquakes_feed", "utility_meter", diff --git a/script/licenses.py b/script/licenses.py index ab8ab62eb1d..aed3bec9998 100644 --- a/script/licenses.py +++ b/script/licenses.py @@ -191,7 +191,6 @@ EXCEPTIONS = { "enocean", # https://github.com/kipe/enocean/pull/142 "imutils", # https://github.com/PyImageSearch/imutils/pull/292 "iso4217", # Public domain - "jaraco.itertools", # MIT - https://github.com/jaraco/jaraco.itertools/issues/21 "kiwiki_client", # https://github.com/c7h/kiwiki_client/pull/6 "ld2410-ble", # https://github.com/930913/ld2410-ble/pull/7 "maxcube-api", # https://github.com/uebelack/python-maxcube-api/pull/48 @@ -205,6 +204,11 @@ EXCEPTIONS = { "repoze.lru", "sharp_aquos_rc", # https://github.com/jmoore987/sharp_aquos_rc/pull/14 "tapsaff", # https://github.com/bazwilliams/python-taps-aff/pull/5 + # --- + # https://github.com/jaraco/skeleton/pull/170 + # https://github.com/jaraco/skeleton/pull/171 + "jaraco.itertools", # MIT - https://github.com/jaraco/jaraco.itertools/issues/21 + "setuptools", # MIT } TODO = { diff --git a/tests/common.py b/tests/common.py index f426d2aebd2..7b4bf987608 100644 --- a/tests/common.py +++ b/tests/common.py @@ -46,6 +46,11 @@ from homeassistant.components import device_automation, persistent_notification from homeassistant.components.device_automation import ( # noqa: F401 _async_get_device_automation_capabilities as async_get_device_automation_capabilities, ) +from homeassistant.components.logger import ( + DOMAIN as LOGGER_DOMAIN, + SERVICE_SET_LEVEL, + _clear_logger_overwrites, +) from homeassistant.config import IntegrationConfigInfo, async_process_component_config from homeassistant.config_entries import ConfigEntry, ConfigFlow, ConfigFlowResult from homeassistant.const import ( @@ -1688,6 +1693,28 @@ def async_mock_cloud_connection_status(hass: HomeAssistant, connected: bool) -> async_dispatcher_send(hass, SIGNAL_CLOUD_CONNECTION_STATE, state) +@asynccontextmanager +async def async_call_logger_set_level( + logger: str, + level: Literal["DEBUG", "INFO", "WARNING", "ERROR", "FATAL", "CRITICAL"], + *, + hass: HomeAssistant, + caplog: pytest.LogCaptureFixture, +) -> AsyncGenerator[None]: + """Context manager to reset loggers after logger.set_level call.""" + assert LOGGER_DOMAIN in hass.data, "'logger' integration not setup" + with caplog.at_level(logging.NOTSET, logger): + await hass.services.async_call( + LOGGER_DOMAIN, + SERVICE_SET_LEVEL, + {logger: level}, + blocking=True, + ) + await hass.async_block_till_done() + yield + _clear_logger_overwrites(hass) + + def import_and_test_deprecated_constant_enum( caplog: pytest.LogCaptureFixture, module: ModuleType, @@ -1884,3 +1911,16 @@ def get_quality_scale(integration: str) -> dict[str, QualityScaleStatus]: ) for rule, details in raw["rules"].items() } + + +def get_schema_suggested_value(schema: vol.Schema, key: str) -> Any | None: + """Get suggested value for key in voluptuous schema.""" + for schema_key in schema: + if schema_key == key: + if ( + schema_key.description is None + or "suggested_value" not in schema_key.description + ): + return None + return schema_key.description["suggested_value"] + return None diff --git a/tests/components/apsystems/snapshots/test_binary_sensor.ambr b/tests/components/apsystems/snapshots/test_binary_sensor.ambr index 381fc1864fc..d2e73347c83 100644 --- a/tests/components/apsystems/snapshots/test_binary_sensor.ambr +++ b/tests/components/apsystems/snapshots/test_binary_sensor.ambr @@ -120,7 +120,7 @@ }), 'original_device_class': , 'original_icon': None, - 'original_name': 'Off grid status', + 'original_name': 'Off-grid status', 'platform': 'apsystems', 'previous_unique_id': None, 'supported_features': 0, @@ -133,7 +133,7 @@ StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'problem', - 'friendly_name': 'Mock Title Off grid status', + 'friendly_name': 'Mock Title Off-grid status', }), 'context': , 'entity_id': 'binary_sensor.mock_title_off_grid_status', diff --git a/tests/components/bluetooth/test_manager.py b/tests/components/bluetooth/test_manager.py index 48d1a38375d..bf773b69a99 100644 --- a/tests/components/bluetooth/test_manager.py +++ b/tests/components/bluetooth/test_manager.py @@ -61,6 +61,7 @@ from . import ( from tests.common import ( MockConfigEntry, MockModule, + async_call_logger_set_level, async_fire_time_changed, load_fixture, mock_integration, @@ -1144,54 +1145,45 @@ async def test_debug_logging( ) -> None: """Test debug logging.""" assert await async_setup_component(hass, "logger", {"logger": {}}) - await hass.services.async_call( - "logger", - "set_level", - {"homeassistant.components.bluetooth": "DEBUG"}, - blocking=True, - ) - await hass.async_block_till_done() + async with async_call_logger_set_level( + "homeassistant.components.bluetooth", "DEBUG", hass=hass, caplog=caplog + ): + address = "44:44:33:11:23:41" + start_time_monotonic = 50.0 - address = "44:44:33:11:23:41" - start_time_monotonic = 50.0 + switchbot_device_poor_signal_hci0 = generate_ble_device( + address, "wohand_poor_signal_hci0" + ) + switchbot_adv_poor_signal_hci0 = generate_advertisement_data( + local_name="wohand_poor_signal_hci0", service_uuids=[], rssi=-100 + ) + inject_advertisement_with_time_and_source( + hass, + switchbot_device_poor_signal_hci0, + switchbot_adv_poor_signal_hci0, + start_time_monotonic, + "hci0", + ) + assert "wohand_poor_signal_hci0" in caplog.text + caplog.clear() - switchbot_device_poor_signal_hci0 = generate_ble_device( - address, "wohand_poor_signal_hci0" - ) - switchbot_adv_poor_signal_hci0 = generate_advertisement_data( - local_name="wohand_poor_signal_hci0", service_uuids=[], rssi=-100 - ) - inject_advertisement_with_time_and_source( - hass, - switchbot_device_poor_signal_hci0, - switchbot_adv_poor_signal_hci0, - start_time_monotonic, - "hci0", - ) - assert "wohand_poor_signal_hci0" in caplog.text - caplog.clear() - - await hass.services.async_call( - "logger", - "set_level", - {"homeassistant.components.bluetooth": "WARNING"}, - blocking=True, - ) - - switchbot_device_good_signal_hci0 = generate_ble_device( - address, "wohand_good_signal_hci0" - ) - switchbot_adv_good_signal_hci0 = generate_advertisement_data( - local_name="wohand_good_signal_hci0", service_uuids=[], rssi=-33 - ) - inject_advertisement_with_time_and_source( - hass, - switchbot_device_good_signal_hci0, - switchbot_adv_good_signal_hci0, - start_time_monotonic, - "hci0", - ) - assert "wohand_good_signal_hci0" not in caplog.text + async with async_call_logger_set_level( + "homeassistant.components.bluetooth", "WARNING", hass=hass, caplog=caplog + ): + switchbot_device_good_signal_hci0 = generate_ble_device( + address, "wohand_good_signal_hci0" + ) + switchbot_adv_good_signal_hci0 = generate_advertisement_data( + local_name="wohand_good_signal_hci0", service_uuids=[], rssi=-33 + ) + inject_advertisement_with_time_and_source( + hass, + switchbot_device_good_signal_hci0, + switchbot_adv_good_signal_hci0, + start_time_monotonic, + "hci0", + ) + assert "wohand_good_signal_hci0" not in caplog.text @pytest.mark.usefixtures("enable_bluetooth", "macos_adapter") diff --git a/tests/components/bluetooth/test_scanner.py b/tests/components/bluetooth/test_scanner.py index 6acb86476e7..142438fbb95 100644 --- a/tests/components/bluetooth/test_scanner.py +++ b/tests/components/bluetooth/test_scanner.py @@ -29,7 +29,11 @@ from . import ( patch_bluetooth_time, ) -from tests.common import MockConfigEntry, async_fire_time_changed +from tests.common import ( + MockConfigEntry, + async_call_logger_set_level, + async_fire_time_changed, +) # If the adapter is in a stuck state the following errors are raised: NEED_RESET_ERRORS = [ @@ -482,70 +486,67 @@ async def test_adapter_fails_to_start_and_takes_a_bit_to_init( ) -> None: """Test we can recover the adapter at startup and we wait for Dbus to init.""" assert await async_setup_component(hass, "logger", {}) - await hass.services.async_call( - "logger", - "set_level", - {"homeassistant.components.bluetooth": "DEBUG"}, - blocking=True, - ) - called_start = 0 - called_stop = 0 - _callback = None - mock_discovered = [] - - class MockBleakScanner: - async def start(self, *args, **kwargs): - """Mock Start.""" - nonlocal called_start - called_start += 1 - if called_start == 1: - raise BleakError("org.freedesktop.DBus.Error.UnknownObject") - if called_start == 2: - raise BleakError("org.bluez.Error.InProgress") - if called_start == 3: - raise BleakError("org.bluez.Error.InProgress") - - async def stop(self, *args, **kwargs): - """Mock Start.""" - nonlocal called_stop - called_stop += 1 - - @property - def discovered_devices(self): - """Mock discovered_devices.""" - nonlocal mock_discovered - return mock_discovered - - def register_detection_callback(self, callback: AdvertisementDataCallback): - """Mock Register Detection Callback.""" - nonlocal _callback - _callback = callback - - scanner = MockBleakScanner() - start_time_monotonic = time.monotonic() - - with ( - patch( - "habluetooth.scanner.ADAPTER_INIT_TIME", - 0, - ), - patch_bluetooth_time( - start_time_monotonic, - ), - patch( - "habluetooth.scanner.OriginalBleakScanner", - return_value=scanner, - ), - patch( - "habluetooth.util.recover_adapter", return_value=True - ) as mock_recover_adapter, + async with async_call_logger_set_level( + "homeassistant.components.bluetooth", "DEBUG", hass=hass, caplog=caplog ): - await async_setup_with_one_adapter(hass) + called_start = 0 + called_stop = 0 + _callback = None + mock_discovered = [] - assert called_start == 4 + class MockBleakScanner: + async def start(self, *args, **kwargs): + """Mock Start.""" + nonlocal called_start + called_start += 1 + if called_start == 1: + raise BleakError("org.freedesktop.DBus.Error.UnknownObject") + if called_start == 2: + raise BleakError("org.bluez.Error.InProgress") + if called_start == 3: + raise BleakError("org.bluez.Error.InProgress") - assert len(mock_recover_adapter.mock_calls) == 1 - assert "Waiting for adapter to initialize" in caplog.text + async def stop(self, *args, **kwargs): + """Mock Start.""" + nonlocal called_stop + called_stop += 1 + + @property + def discovered_devices(self): + """Mock discovered_devices.""" + nonlocal mock_discovered + return mock_discovered + + def register_detection_callback(self, callback: AdvertisementDataCallback): + """Mock Register Detection Callback.""" + nonlocal _callback + _callback = callback + + scanner = MockBleakScanner() + start_time_monotonic = time.monotonic() + + with ( + patch( + "habluetooth.scanner.ADAPTER_INIT_TIME", + 0, + ), + patch_bluetooth_time( + start_time_monotonic, + ), + patch( + "habluetooth.scanner.OriginalBleakScanner", + return_value=scanner, + ), + patch( + "habluetooth.util.recover_adapter", return_value=True + ) as mock_recover_adapter, + ): + await async_setup_with_one_adapter(hass) + + assert called_start == 4 + + assert len(mock_recover_adapter.mock_calls) == 1 + assert "Waiting for adapter to initialize" in caplog.text @pytest.mark.usefixtures("one_adapter") diff --git a/tests/components/canary/__init__.py b/tests/components/canary/__init__.py index 13c4b84ab94..b247bfc35d6 100644 --- a/tests/components/canary/__init__.py +++ b/tests/components/canary/__init__.py @@ -37,13 +37,6 @@ YAML_CONFIG = { } -def _patch_async_setup(return_value=True): - return patch( - "homeassistant.components.canary.async_setup", - return_value=return_value, - ) - - def _patch_async_setup_entry(return_value=True): return patch( "homeassistant.components.canary.async_setup_entry", diff --git a/tests/components/canary/test_alarm_control_panel.py b/tests/components/canary/test_alarm_control_panel.py index a194621b0d9..2df75ad5c59 100644 --- a/tests/components/canary/test_alarm_control_panel.py +++ b/tests/components/canary/test_alarm_control_panel.py @@ -8,7 +8,6 @@ from homeassistant.components.alarm_control_panel import ( DOMAIN as ALARM_DOMAIN, AlarmControlPanelState, ) -from homeassistant.components.canary import DOMAIN from homeassistant.const import ( SERVICE_ALARM_ARM_AWAY, SERVICE_ALARM_ARM_HOME, @@ -19,9 +18,8 @@ from homeassistant.const import ( from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er from homeassistant.helpers.entity_component import async_update_entity -from homeassistant.setup import async_setup_component -from . import mock_device, mock_location, mock_mode +from . import init_integration, mock_device, mock_location, mock_mode async def test_alarm_control_panel( @@ -43,10 +41,8 @@ async def test_alarm_control_panel( instance = canary.return_value instance.get_locations.return_value = [mocked_location] - config = {DOMAIN: {"username": "test-username", "password": "test-password"}} with patch("homeassistant.components.canary.PLATFORMS", ["alarm_control_panel"]): - assert await async_setup_component(hass, DOMAIN, config) - await hass.async_block_till_done() + await init_integration(hass) entity_id = "alarm_control_panel.home" entity_entry = entity_registry.async_get(entity_id) @@ -124,10 +120,8 @@ async def test_alarm_control_panel_services(hass: HomeAssistant, canary) -> None instance = canary.return_value instance.get_locations.return_value = [mocked_location] - config = {DOMAIN: {"username": "test-username", "password": "test-password"}} with patch("homeassistant.components.canary.PLATFORMS", ["alarm_control_panel"]): - assert await async_setup_component(hass, DOMAIN, config) - await hass.async_block_till_done() + await init_integration(hass) entity_id = "alarm_control_panel.home" diff --git a/tests/components/canary/test_config_flow.py b/tests/components/canary/test_config_flow.py index 552aa9089ce..06aadc8297c 100644 --- a/tests/components/canary/test_config_flow.py +++ b/tests/components/canary/test_config_flow.py @@ -15,7 +15,7 @@ from homeassistant.const import CONF_TIMEOUT from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType -from . import USER_INPUT, _patch_async_setup, _patch_async_setup_entry, init_integration +from . import USER_INPUT, _patch_async_setup_entry, init_integration async def test_user_form(hass: HomeAssistant, canary_config_flow) -> None: @@ -27,10 +27,7 @@ async def test_user_form(hass: HomeAssistant, canary_config_flow) -> None: assert result["type"] is FlowResultType.FORM assert result["errors"] == {} - with ( - _patch_async_setup() as mock_setup, - _patch_async_setup_entry() as mock_setup_entry, - ): + with _patch_async_setup_entry() as mock_setup_entry: result = await hass.config_entries.flow.async_configure( result["flow_id"], USER_INPUT, @@ -41,7 +38,6 @@ async def test_user_form(hass: HomeAssistant, canary_config_flow) -> None: assert result["title"] == "test-username" assert result["data"] == {**USER_INPUT, CONF_TIMEOUT: DEFAULT_TIMEOUT} - assert len(mock_setup.mock_calls) == 1 assert len(mock_setup_entry.mock_calls) == 1 @@ -120,7 +116,7 @@ async def test_options_flow(hass: HomeAssistant, canary) -> None: assert result["type"] is FlowResultType.FORM assert result["step_id"] == "init" - with _patch_async_setup(), _patch_async_setup_entry(): + with _patch_async_setup_entry(): result = await hass.config_entries.options.async_configure( result["flow_id"], user_input={CONF_FFMPEG_ARGUMENTS: "-v", CONF_TIMEOUT: 7}, diff --git a/tests/components/canary/test_init.py b/tests/components/canary/test_init.py index e0d1c532efc..67cb11207df 100644 --- a/tests/components/canary/test_init.py +++ b/tests/components/canary/test_init.py @@ -1,59 +1,12 @@ """The tests for the Canary component.""" -from unittest.mock import patch - from requests import ConnectTimeout -from homeassistant.components.camera import DOMAIN as CAMERA_DOMAIN -from homeassistant.components.canary.const import CONF_FFMPEG_ARGUMENTS, DOMAIN +from homeassistant.components.canary.const import DOMAIN from homeassistant.config_entries import ConfigEntryState -from homeassistant.const import CONF_PASSWORD, CONF_TIMEOUT, CONF_USERNAME from homeassistant.core import HomeAssistant -from homeassistant.setup import async_setup_component -from . import YAML_CONFIG, init_integration - - -async def test_import_from_yaml(hass: HomeAssistant, canary) -> None: - """Test import from YAML.""" - with patch( - "homeassistant.components.canary.async_setup_entry", - return_value=True, - ): - assert await async_setup_component(hass, DOMAIN, {DOMAIN: YAML_CONFIG}) - await hass.async_block_till_done() - - entries = hass.config_entries.async_entries(DOMAIN) - assert len(entries) == 1 - - assert entries[0].data[CONF_USERNAME] == "test-username" - assert entries[0].data[CONF_PASSWORD] == "test-password" - assert entries[0].data[CONF_TIMEOUT] == 5 - - -async def test_import_from_yaml_ffmpeg(hass: HomeAssistant, canary) -> None: - """Test import from YAML with ffmpeg arguments.""" - with patch( - "homeassistant.components.canary.async_setup_entry", - return_value=True, - ): - assert await async_setup_component( - hass, - DOMAIN, - { - DOMAIN: YAML_CONFIG, - CAMERA_DOMAIN: [{"platform": DOMAIN, CONF_FFMPEG_ARGUMENTS: "-v"}], - }, - ) - await hass.async_block_till_done() - - entries = hass.config_entries.async_entries(DOMAIN) - assert len(entries) == 1 - - assert entries[0].data[CONF_USERNAME] == "test-username" - assert entries[0].data[CONF_PASSWORD] == "test-password" - assert entries[0].data[CONF_TIMEOUT] == 5 - assert entries[0].data.get(CONF_FFMPEG_ARGUMENTS) == "-v" +from . import init_integration async def test_unload_entry(hass: HomeAssistant, canary) -> None: diff --git a/tests/components/canary/test_sensor.py b/tests/components/canary/test_sensor.py index afcf9f16db4..b5a79724ddb 100644 --- a/tests/components/canary/test_sensor.py +++ b/tests/components/canary/test_sensor.py @@ -20,10 +20,9 @@ from homeassistant.const import ( from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.helpers.entity_component import async_update_entity -from homeassistant.setup import async_setup_component from homeassistant.util.dt import utcnow -from . import mock_device, mock_location, mock_reading +from . import init_integration, mock_device, mock_location, mock_reading from tests.common import async_fire_time_changed @@ -48,10 +47,8 @@ async def test_sensors_pro( mock_reading("air_quality", "0.59"), ] - config = {DOMAIN: {"username": "test-username", "password": "test-password"}} with patch("homeassistant.components.canary.PLATFORMS", ["sensor"]): - assert await async_setup_component(hass, DOMAIN, config) - await hass.async_block_till_done() + await init_integration(hass) sensors = { "home_dining_room_temperature": ( @@ -112,10 +109,8 @@ async def test_sensors_attributes_pro(hass: HomeAssistant, canary) -> None: mock_reading("air_quality", "0.59"), ] - config = {DOMAIN: {"username": "test-username", "password": "test-password"}} with patch("homeassistant.components.canary.PLATFORMS", ["sensor"]): - assert await async_setup_component(hass, DOMAIN, config) - await hass.async_block_till_done() + await init_integration(hass) entity_id = "sensor.home_dining_room_air_quality" state1 = hass.states.get(entity_id) @@ -175,10 +170,8 @@ async def test_sensors_flex( mock_reading("wifi", "-57"), ] - config = {DOMAIN: {"username": "test-username", "password": "test-password"}} with patch("homeassistant.components.canary.PLATFORMS", ["sensor"]): - assert await async_setup_component(hass, DOMAIN, config) - await hass.async_block_till_done() + await init_integration(hass) sensors = { "home_dining_room_battery": ( diff --git a/tests/components/cast/test_config_flow.py b/tests/components/cast/test_config_flow.py index e02230892bf..99f3113a10b 100644 --- a/tests/components/cast/test_config_flow.py +++ b/tests/components/cast/test_config_flow.py @@ -10,7 +10,7 @@ from homeassistant.components.cast.home_assistant_cast import CAST_USER_NAME from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType -from tests.common import MockConfigEntry +from tests.common import MockConfigEntry, get_schema_suggested_value async def test_creating_entry_sets_up_media_player(hass: HomeAssistant) -> None: @@ -141,16 +141,6 @@ async def test_zeroconf_setup_onboarding(hass: HomeAssistant) -> None: } -def get_suggested(schema, key): - """Get suggested value for key in voluptuous schema.""" - for k in schema: - if k == key: - if k.description is None or "suggested_value" not in k.description: - return None - return k.description["suggested_value"] - return None - - @pytest.mark.parametrize( ("parameter", "initial", "suggested", "user_input", "updated"), [ @@ -219,9 +209,9 @@ async def test_option_flow( for other_param in basic_parameters: if other_param == parameter: continue - assert get_suggested(data_schema, other_param) == [] + assert get_schema_suggested_value(data_schema, other_param) == [] if parameter in basic_parameters: - assert get_suggested(data_schema, parameter) == suggested + assert get_schema_suggested_value(data_schema, parameter) == suggested user_input_dict = {} if parameter in basic_parameters: @@ -244,9 +234,9 @@ async def test_option_flow( for other_param in advanced_parameters: if other_param == parameter: continue - assert get_suggested(data_schema, other_param) == "" + assert get_schema_suggested_value(data_schema, other_param) == "" if parameter in advanced_parameters: - assert get_suggested(data_schema, parameter) == suggested + assert get_schema_suggested_value(data_schema, parameter) == suggested user_input_dict = {} if parameter in advanced_parameters: diff --git a/tests/components/derivative/test_config_flow.py b/tests/components/derivative/test_config_flow.py index efdde93173c..3f27d2366a5 100644 --- a/tests/components/derivative/test_config_flow.py +++ b/tests/components/derivative/test_config_flow.py @@ -10,7 +10,7 @@ from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType from homeassistant.helpers import selector -from tests.common import MockConfigEntry +from tests.common import MockConfigEntry, get_schema_suggested_value @pytest.mark.parametrize("platform", ["sensor"]) @@ -64,17 +64,6 @@ async def test_config_flow(hass: HomeAssistant, platform) -> None: assert config_entry.title == "My derivative" -def get_suggested(schema, key): - """Get suggested value for key in voluptuous schema.""" - for k in schema: - if k == key: - if k.description is None or "suggested_value" not in k.description: - return None - return k.description["suggested_value"] - # Wanted key absent from schema - raise KeyError("Wanted key absent from schema") - - @pytest.mark.parametrize("platform", ["sensor"]) async def test_options(hass: HomeAssistant, platform) -> None: """Test reconfiguring.""" @@ -104,10 +93,10 @@ async def test_options(hass: HomeAssistant, platform) -> None: assert result["type"] is FlowResultType.FORM assert result["step_id"] == "init" schema = result["data_schema"].schema - assert get_suggested(schema, "round") == 1.0 - assert get_suggested(schema, "time_window") == {"seconds": 0.0} - assert get_suggested(schema, "unit_prefix") == "k" - assert get_suggested(schema, "unit_time") == "min" + assert get_schema_suggested_value(schema, "round") == 1.0 + assert get_schema_suggested_value(schema, "time_window") == {"seconds": 0.0} + assert get_schema_suggested_value(schema, "unit_prefix") == "k" + assert get_schema_suggested_value(schema, "unit_time") == "min" source = schema["source"] assert isinstance(source, selector.EntitySelector) diff --git a/tests/components/dhcp/test_init.py b/tests/components/dhcp/test_init.py index 223dc83f83a..f036902faed 100644 --- a/tests/components/dhcp/test_init.py +++ b/tests/components/dhcp/test_init.py @@ -1,5 +1,7 @@ """Test the DHCP discovery integration.""" +from __future__ import annotations + from collections.abc import Awaitable, Callable import datetime import threading @@ -24,6 +26,7 @@ from homeassistant.components.device_tracker import ( SourceType, ) from homeassistant.components.dhcp.const import DOMAIN +from homeassistant.components.dhcp.models import DHCPData from homeassistant.const import ( EVENT_HOMEASSISTANT_STARTED, EVENT_HOMEASSISTANT_STOP, @@ -147,12 +150,12 @@ async def _async_get_handle_dhcp_packet( integration_matchers: dhcp.DhcpMatchers, address_data: dict | None = None, ) -> Callable[[Any], Awaitable[None]]: + """Make a handler for a dhcp packet.""" if address_data is None: address_data = {} dhcp_watcher = dhcp.DHCPWatcher( hass, - address_data, - integration_matchers, + DHCPData(integration_matchers, set(), address_data), ) with patch("aiodhcpwatcher.async_start"): await dhcp_watcher.async_start() @@ -666,6 +669,45 @@ async def test_setup_fails_with_broken_libpcap( ) +def _make_device_tracker_watcher( + hass: HomeAssistant, matchers: list[dhcp.DHCPMatcher] +) -> dhcp.DeviceTrackerWatcher: + return dhcp.DeviceTrackerWatcher( + hass, + DHCPData( + dhcp.async_index_integration_matchers(matchers), + set(), + {}, + ), + ) + + +def _make_device_tracker_registered_watcher( + hass: HomeAssistant, matchers: list[dhcp.DHCPMatcher] +) -> dhcp.DeviceTrackerRegisteredWatcher: + return dhcp.DeviceTrackerRegisteredWatcher( + hass, + DHCPData( + dhcp.async_index_integration_matchers(matchers), + set(), + {}, + ), + ) + + +def _make_network_watcher( + hass: HomeAssistant, matchers: list[dhcp.DHCPMatcher] +) -> dhcp.NetworkWatcher: + return dhcp.NetworkWatcher( + hass, + DHCPData( + dhcp.async_index_integration_matchers(matchers), + set(), + {}, + ), + ) + + async def test_device_tracker_hostname_and_macaddress_exists_before_start( hass: HomeAssistant, ) -> None: @@ -682,18 +724,15 @@ async def test_device_tracker_hostname_and_macaddress_exists_before_start( ) with patch.object(hass.config_entries.flow, "async_init") as mock_init: - device_tracker_watcher = dhcp.DeviceTrackerWatcher( + device_tracker_watcher = _make_device_tracker_watcher( hass, - {}, - dhcp.async_index_integration_matchers( - [ - { - "domain": "mock-domain", - "hostname": "connect", - "macaddress": "B8B7F1*", - } - ] - ), + [ + { + "domain": "mock-domain", + "hostname": "connect", + "macaddress": "B8B7F1*", + } + ], ) device_tracker_watcher.async_start() await hass.async_block_till_done() @@ -716,18 +755,15 @@ async def test_device_tracker_hostname_and_macaddress_exists_before_start( async def test_device_tracker_registered(hass: HomeAssistant) -> None: """Test matching based on hostname and macaddress when registered.""" with patch.object(hass.config_entries.flow, "async_init") as mock_init: - device_tracker_watcher = dhcp.DeviceTrackerRegisteredWatcher( + device_tracker_watcher = _make_device_tracker_registered_watcher( hass, - {}, - dhcp.async_index_integration_matchers( - [ - { - "domain": "mock-domain", - "hostname": "connect", - "macaddress": "B8B7F1*", - } - ] - ), + [ + { + "domain": "mock-domain", + "hostname": "connect", + "macaddress": "B8B7F1*", + } + ], ) device_tracker_watcher.async_start() await hass.async_block_till_done() @@ -756,18 +792,15 @@ async def test_device_tracker_registered(hass: HomeAssistant) -> None: async def test_device_tracker_registered_hostname_none(hass: HomeAssistant) -> None: """Test handle None hostname.""" with patch.object(hass.config_entries.flow, "async_init") as mock_init: - device_tracker_watcher = dhcp.DeviceTrackerRegisteredWatcher( + device_tracker_watcher = _make_device_tracker_watcher( hass, - {}, - dhcp.async_index_integration_matchers( - [ - { - "domain": "mock-domain", - "hostname": "connect", - "macaddress": "B8B7F1*", - } - ] - ), + [ + { + "domain": "mock-domain", + "hostname": "connect", + "macaddress": "B8B7F1*", + } + ], ) device_tracker_watcher.async_start() await hass.async_block_till_done() @@ -789,18 +822,15 @@ async def test_device_tracker_hostname_and_macaddress_after_start( """Test matching based on hostname and macaddress after start.""" with patch.object(hass.config_entries.flow, "async_init") as mock_init: - device_tracker_watcher = dhcp.DeviceTrackerWatcher( + device_tracker_watcher = _make_device_tracker_watcher( hass, - {}, - dhcp.async_index_integration_matchers( - [ - { - "domain": "mock-domain", - "hostname": "connect", - "macaddress": "B8B7F1*", - } - ] - ), + [ + { + "domain": "mock-domain", + "hostname": "connect", + "macaddress": "B8B7F1*", + } + ], ) device_tracker_watcher.async_start() await hass.async_block_till_done() @@ -837,18 +867,15 @@ async def test_device_tracker_hostname_and_macaddress_after_start_not_home( """Test matching based on hostname and macaddress after start but not home.""" with patch.object(hass.config_entries.flow, "async_init") as mock_init: - device_tracker_watcher = dhcp.DeviceTrackerWatcher( + device_tracker_watcher = _make_device_tracker_watcher( hass, - {}, - dhcp.async_index_integration_matchers( - [ - { - "domain": "mock-domain", - "hostname": "connect", - "macaddress": "B8B7F1*", - } - ] - ), + [ + { + "domain": "mock-domain", + "hostname": "connect", + "macaddress": "B8B7F1*", + } + ], ) device_tracker_watcher.async_start() await hass.async_block_till_done() @@ -875,9 +902,8 @@ async def test_device_tracker_hostname_and_macaddress_after_start_not_router( """Test matching based on hostname and macaddress after start but not router.""" with patch.object(hass.config_entries.flow, "async_init") as mock_init: - device_tracker_watcher = dhcp.DeviceTrackerWatcher( + device_tracker_watcher = _make_device_tracker_watcher( hass, - {}, [{"domain": "mock-domain", "hostname": "connect", "macaddress": "B8B7F1*"}], ) device_tracker_watcher.async_start() @@ -905,9 +931,8 @@ async def test_device_tracker_hostname_and_macaddress_after_start_hostname_missi """Test matching based on hostname and macaddress after start but missing hostname.""" with patch.object(hass.config_entries.flow, "async_init") as mock_init: - device_tracker_watcher = dhcp.DeviceTrackerWatcher( + device_tracker_watcher = _make_device_tracker_watcher( hass, - {}, [{"domain": "mock-domain", "hostname": "connect", "macaddress": "B8B7F1*"}], ) device_tracker_watcher.async_start() @@ -934,9 +959,8 @@ async def test_device_tracker_invalid_ip_address( """Test an invalid ip address.""" with patch.object(hass.config_entries.flow, "async_init") as mock_init: - device_tracker_watcher = dhcp.DeviceTrackerWatcher( + device_tracker_watcher = _make_device_tracker_watcher( hass, - {}, [{"domain": "mock-domain", "hostname": "connect", "macaddress": "B8B7F1*"}], ) device_tracker_watcher.async_start() @@ -974,18 +998,15 @@ async def test_device_tracker_ignore_self_assigned_ips_before_start( ) with patch.object(hass.config_entries.flow, "async_init") as mock_init: - device_tracker_watcher = dhcp.DeviceTrackerWatcher( + device_tracker_watcher = _make_device_tracker_watcher( hass, - {}, - dhcp.async_index_integration_matchers( - [ - { - "domain": "mock-domain", - "hostname": "connect", - "macaddress": "B8B7F1*", - } - ] - ), + [ + { + "domain": "mock-domain", + "hostname": "connect", + "macaddress": "B8B7F1*", + } + ], ) device_tracker_watcher.async_start() await hass.async_block_till_done() @@ -1010,18 +1031,15 @@ async def test_aiodiscover_finds_new_hosts(hass: HomeAssistant) -> None: ], ), ): - device_tracker_watcher = dhcp.NetworkWatcher( + device_tracker_watcher = _make_network_watcher( hass, - {}, - dhcp.async_index_integration_matchers( - [ - { - "domain": "mock-domain", - "hostname": "connect", - "macaddress": "B8B7F1*", - } - ] - ), + [ + { + "domain": "mock-domain", + "hostname": "connect", + "macaddress": "B8B7F1*", + } + ], ) device_tracker_watcher.async_start() await hass.async_block_till_done() @@ -1073,18 +1091,15 @@ async def test_aiodiscover_does_not_call_again_on_shorter_hostname( ], ), ): - device_tracker_watcher = dhcp.NetworkWatcher( + device_tracker_watcher = _make_network_watcher( hass, - {}, - dhcp.async_index_integration_matchers( - [ - { - "domain": "mock-domain", - "hostname": "irobot-*", - "macaddress": "B8B7F1*", - } - ] - ), + [ + { + "domain": "mock-domain", + "hostname": "irobot-*", + "macaddress": "B8B7F1*", + } + ], ) device_tracker_watcher.async_start() await hass.async_block_till_done() @@ -1123,19 +1138,17 @@ async def test_aiodiscover_finds_new_hosts_after_interval(hass: HomeAssistant) - return_value=[], ), ): - device_tracker_watcher = dhcp.NetworkWatcher( + device_tracker_watcher = _make_network_watcher( hass, - {}, - dhcp.async_index_integration_matchers( - [ - { - "domain": "mock-domain", - "hostname": "connect", - "macaddress": "B8B7F1*", - } - ] - ), + [ + { + "domain": "mock-domain", + "hostname": "connect", + "macaddress": "B8B7F1*", + } + ], ) + device_tracker_watcher.async_start() await hass.async_block_till_done() @@ -1235,7 +1248,7 @@ async def test_dhcp_rediscover( hass, integration_matchers, address_data ) rediscovery_watcher = dhcp.RediscoveryWatcher( - hass, address_data, integration_matchers + hass, DHCPData(integration_matchers, set(), address_data) ) rediscovery_watcher.async_start() with patch.object(hass.config_entries.flow, "async_init") as mock_init: @@ -1329,7 +1342,7 @@ async def test_dhcp_rediscover_no_match( hass, integration_matchers, address_data ) rediscovery_watcher = dhcp.RediscoveryWatcher( - hass, address_data, integration_matchers + hass, DHCPData(integration_matchers, set(), address_data) ) rediscovery_watcher.async_start() with patch.object(hass.config_entries.flow, "async_init") as mock_init: diff --git a/tests/components/dhcp/test_websocket_api.py b/tests/components/dhcp/test_websocket_api.py new file mode 100644 index 00000000000..eb008c49ab1 --- /dev/null +++ b/tests/components/dhcp/test_websocket_api.py @@ -0,0 +1,75 @@ +"""The tests for the dhcp WebSocket API.""" + +import asyncio +from collections.abc import Callable +from unittest.mock import patch + +import aiodhcpwatcher + +from homeassistant.components.dhcp import DOMAIN +from homeassistant.core import EVENT_HOMEASSISTANT_STARTED, HomeAssistant +from homeassistant.setup import async_setup_component + +from tests.typing import WebSocketGenerator + + +async def test_subscribe_discovery( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, +) -> None: + """Test dhcp subscribe_discovery.""" + saved_callback: Callable[[aiodhcpwatcher.DHCPRequest], None] | None = None + + async def mock_start( + callback: Callable[[aiodhcpwatcher.DHCPRequest], None], + ) -> None: + """Mock start.""" + nonlocal saved_callback + saved_callback = callback + + with ( + patch("homeassistant.components.dhcp.aiodhcpwatcher.async_start", mock_start), + patch("homeassistant.components.dhcp.DiscoverHosts"), + ): + await async_setup_component(hass, DOMAIN, {}) + await hass.async_block_till_done() + hass.bus.async_fire(EVENT_HOMEASSISTANT_STARTED) + await hass.async_block_till_done() + + saved_callback(aiodhcpwatcher.DHCPRequest("4.3.2.2", "happy", "44:44:33:11:23:12")) + client = await hass_ws_client() + await client.send_json( + { + "id": 1, + "type": "dhcp/subscribe_discovery", + } + ) + async with asyncio.timeout(1): + response = await client.receive_json() + assert response["success"] + + async with asyncio.timeout(1): + response = await client.receive_json() + assert response["event"] == { + "add": [ + { + "hostname": "happy", + "ip_address": "4.3.2.2", + "mac_address": "44:44:33:11:23:12", + } + ] + } + + saved_callback(aiodhcpwatcher.DHCPRequest("4.3.2.1", "sad", "44:44:33:11:23:13")) + + async with asyncio.timeout(1): + response = await client.receive_json() + assert response["event"] == { + "add": [ + { + "hostname": "sad", + "ip_address": "4.3.2.1", + "mac_address": "44:44:33:11:23:13", + } + ] + } diff --git a/tests/components/energy/test_websocket_api.py b/tests/components/energy/test_websocket_api.py index e4b0e568a70..54f2a971fd4 100644 --- a/tests/components/energy/test_websocket_api.py +++ b/tests/components/energy/test_websocket_api.py @@ -1165,3 +1165,59 @@ async def test_fossil_energy_consumption_check_missing_hour( hour3.isoformat(), hour4.isoformat(), ] + + +@pytest.mark.freeze_time("2021-08-01 00:00:00+00:00") +async def test_fossil_energy_consumption_missing_sum( + recorder_mock: Recorder, hass: HomeAssistant, hass_ws_client: WebSocketGenerator +) -> None: + """Test fossil_energy_consumption statistics missing sum.""" + now = dt_util.utcnow() + later = dt_util.as_utc(dt_util.parse_datetime("2022-09-01 00:00:00")) + + await async_setup_component(hass, "history", {}) + await async_setup_component(hass, "sensor", {}) + await async_recorder_block_till_done(hass) + + period1 = dt_util.as_utc(dt_util.parse_datetime("2021-09-01 00:00:00")) + period2 = dt_util.as_utc(dt_util.parse_datetime("2021-09-30 23:00:00")) + period3 = dt_util.as_utc(dt_util.parse_datetime("2021-10-01 00:00:00")) + period4 = dt_util.as_utc(dt_util.parse_datetime("2021-10-31 23:00:00")) + + external_energy_statistics_1 = ( + {"start": period1, "last_reset": None, "state": 0, "mean": 2}, + {"start": period2, "last_reset": None, "state": 1, "mean": 3}, + {"start": period3, "last_reset": None, "state": 2, "mean": 4}, + {"start": period4, "last_reset": None, "state": 3, "mean": 5}, + ) + external_energy_metadata_1 = { + "has_mean": True, + "has_sum": False, + "name": "Mean imported energy", + "source": "test", + "statistic_id": "test:mean_energy_import_tariff", + "unit_of_measurement": "kWh", + } + + async_add_external_statistics( + hass, external_energy_metadata_1, external_energy_statistics_1 + ) + await async_wait_recording_done(hass) + + client = await hass_ws_client() + await client.send_json( + { + "id": 1, + "type": "energy/fossil_energy_consumption", + "start_time": now.isoformat(), + "end_time": later.isoformat(), + "energy_statistic_ids": [ + "test:mean_energy_import_tariff", + ], + "co2_statistic_id": "", + "period": "hour", + } + ) + response = await client.receive_json() + assert response["success"] + assert response["result"] == {} diff --git a/tests/components/esphome/test_assist_satellite.py b/tests/components/esphome/test_assist_satellite.py index 3f6db1dd9c9..c072e5fda4a 100644 --- a/tests/components/esphome/test_assist_satellite.py +++ b/tests/components/esphome/test_assist_satellite.py @@ -345,6 +345,23 @@ async def test_pipeline_api_audio( {"url": get_url(hass) + mock_tts_result_stream.url}, ) + event_callback( + PipelineEvent( + type=PipelineEventType.RUN_START, + data={ + "tts_output": { + "media_id": "test-media-id", + "url": mock_tts_result_stream.url, + "token": mock_tts_result_stream.token, + } + }, + ) + ) + assert mock_client.send_voice_assistant_event.call_args_list[-1].args == ( + VoiceAssistantEventType.VOICE_ASSISTANT_RUN_START, + {"url": get_url(hass) + mock_tts_result_stream.url}, + ) + event_callback(PipelineEvent(type=PipelineEventType.RUN_END)) assert mock_client.send_voice_assistant_event.call_args_list[-1].args == ( VoiceAssistantEventType.VOICE_ASSISTANT_RUN_END, diff --git a/tests/components/esphome/test_config_flow.py b/tests/components/esphome/test_config_flow.py index 9d400ba618b..3f948076d2e 100644 --- a/tests/components/esphome/test_config_flow.py +++ b/tests/components/esphome/test_config_flow.py @@ -1,5 +1,6 @@ """Test config flow.""" +from collections.abc import Awaitable, Callable from ipaddress import ip_address import json from typing import Any @@ -9,10 +10,13 @@ from aioesphomeapi import ( APIClient, APIConnectionError, DeviceInfo, + EntityInfo, + EntityState, InvalidAuthAPIError, InvalidEncryptionKeyAPIError, RequiresEncryptionAPIError, ResolveAPIError, + UserService, ) import aiohttp import pytest @@ -27,6 +31,7 @@ from homeassistant.components.esphome.const import ( DEFAULT_NEW_CONFIG_ALLOW_ALLOW_SERVICE_CALLS, DOMAIN, ) +from homeassistant.config_entries import ConfigFlowResult from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_PORT from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType @@ -50,9 +55,20 @@ def mock_setup_entry(): yield -@pytest.mark.usefixtures("mock_zeroconf") +def get_flow_context(hass: HomeAssistant, result: ConfigFlowResult) -> dict[str, Any]: + """Get the flow context from the result of async_init or async_configure.""" + flow = next( + flow + for flow in hass.config_entries.flow.async_progress() + if flow["flow_id"] == result["flow_id"] + ) + + return flow["context"] + + +@pytest.mark.usefixtures("mock_setup_entry", "mock_zeroconf") async def test_user_connection_works( - hass: HomeAssistant, mock_client, mock_setup_entry: None + hass: HomeAssistant, mock_client: APIClient ) -> None: """Test we can finish a config flow.""" result = await hass.config_entries.flow.async_init( @@ -93,10 +109,8 @@ async def test_user_connection_works( assert mock_client.noise_psk is None -@pytest.mark.usefixtures("mock_zeroconf") -async def test_user_connection_updates_host( - hass: HomeAssistant, mock_client, mock_setup_entry: None -) -> None: +@pytest.mark.usefixtures("mock_client", "mock_setup_entry", "mock_zeroconf") +async def test_user_connection_updates_host(hass: HomeAssistant) -> None: """Test setup up the same name updates the host.""" entry = MockConfigEntry( domain=DOMAIN, @@ -119,14 +133,17 @@ async def test_user_connection_updates_host( data={CONF_HOST: "127.0.0.1", CONF_PORT: 80}, ) assert result["type"] is FlowResultType.ABORT - assert result["reason"] == "already_configured" + assert result["reason"] == "already_configured_updates" + assert result["description_placeholders"] == { + "title": "Mock Title", + "name": "unknown", + "mac": "11:22:33:44:55:aa", + } assert entry.data[CONF_HOST] == "127.0.0.1" -@pytest.mark.usefixtures("mock_zeroconf") -async def test_user_sets_unique_id( - hass: HomeAssistant, mock_client, mock_setup_entry: None -) -> None: +@pytest.mark.usefixtures("mock_client", "mock_setup_entry", "mock_zeroconf") +async def test_user_sets_unique_id(hass: HomeAssistant) -> None: """Test that the user flow sets the unique id.""" service_info = ZeroconfServiceInfo( ip_address=ip_address("192.168.43.183"), @@ -145,6 +162,9 @@ async def test_user_sets_unique_id( assert discovery_result["type"] is FlowResultType.FORM assert discovery_result["step_id"] == "discovery_confirm" + assert discovery_result["description_placeholders"] == { + "name": "test8266", + } discovery_result = await hass.config_entries.flow.async_configure( discovery_result["flow_id"], @@ -173,13 +193,16 @@ async def test_user_sets_unique_id( {CONF_HOST: "127.0.0.1", CONF_PORT: 6053}, ) assert result["type"] is FlowResultType.ABORT - assert result["reason"] == "already_configured" + assert result["reason"] == "already_configured_updates" + assert result["description_placeholders"] == { + "title": "test", + "name": "test", + "mac": "11:22:33:44:55:aa", + } -@pytest.mark.usefixtures("mock_zeroconf") -async def test_user_resolve_error( - hass: HomeAssistant, mock_client, mock_setup_entry: None -) -> None: +@pytest.mark.usefixtures("mock_setup_entry", "mock_zeroconf") +async def test_user_resolve_error(hass: HomeAssistant, mock_client: APIClient) -> None: """Test user step with IP resolve error.""" with patch( @@ -201,11 +224,27 @@ async def test_user_resolve_error( assert len(mock_client.device_info.mock_calls) == 1 assert len(mock_client.disconnect.mock_calls) == 1 + # Now simulate the user retrying with the same host and a successful connection + mock_client.device_info.side_effect = None -@pytest.mark.usefixtures("mock_zeroconf") -async def test_user_causes_zeroconf_to_abort( - hass: HomeAssistant, mock_client, mock_setup_entry: None -) -> None: + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={CONF_HOST: "127.0.0.1", CONF_PORT: 6053}, + ) + + assert result2["type"] == FlowResultType.CREATE_ENTRY + assert result2["title"] == "test" + assert result2["data"] == { + CONF_HOST: "127.0.0.1", + CONF_PORT: 6053, + CONF_DEVICE_NAME: "test", + CONF_PASSWORD: "", + CONF_NOISE_PSK: "", + } + + +@pytest.mark.usefixtures("mock_client", "mock_setup_entry", "mock_zeroconf") +async def test_user_causes_zeroconf_to_abort(hass: HomeAssistant) -> None: """Test that the user flow sets the unique id and aborts the zeroconf flow.""" service_info = ZeroconfServiceInfo( ip_address=ip_address("192.168.43.183"), @@ -224,6 +263,9 @@ async def test_user_causes_zeroconf_to_abort( assert discovery_result["type"] is FlowResultType.FORM assert discovery_result["step_id"] == "discovery_confirm" + assert discovery_result["description_placeholders"] == { + "name": "test8266", + } result = await hass.config_entries.flow.async_init( "esphome", @@ -250,9 +292,10 @@ async def test_user_causes_zeroconf_to_abort( assert not hass.config_entries.flow.async_progress_by_handler(DOMAIN) -@pytest.mark.usefixtures("mock_zeroconf") +@pytest.mark.usefixtures("mock_setup_entry", "mock_zeroconf") async def test_user_connection_error( - hass: HomeAssistant, mock_client, mock_setup_entry: None + hass: HomeAssistant, + mock_client: APIClient, ) -> None: """Test user step with connection error.""" mock_client.device_info.side_effect = APIConnectionError @@ -271,10 +314,29 @@ async def test_user_connection_error( assert len(mock_client.device_info.mock_calls) == 1 assert len(mock_client.disconnect.mock_calls) == 1 + # Now simulate the user retrying with the same host and a successful connection + mock_client.device_info.side_effect = None -@pytest.mark.usefixtures("mock_zeroconf") + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={CONF_HOST: "127.0.0.1", CONF_PORT: 6053}, + ) + + assert result2["type"] == FlowResultType.CREATE_ENTRY + assert result2["title"] == "test" + assert result2["data"] == { + CONF_HOST: "127.0.0.1", + CONF_PORT: 6053, + CONF_DEVICE_NAME: "test", + CONF_PASSWORD: "", + CONF_NOISE_PSK: "", + } + + +@pytest.mark.usefixtures("mock_setup_entry", "mock_zeroconf") async def test_user_with_password( - hass: HomeAssistant, mock_client, mock_setup_entry: None + hass: HomeAssistant, + mock_client: APIClient, ) -> None: """Test user step with password.""" mock_client.device_info.return_value = DeviceInfo(uses_password=True, name="test") @@ -287,6 +349,7 @@ async def test_user_with_password( assert result["type"] is FlowResultType.FORM assert result["step_id"] == "authenticate" + assert result["description_placeholders"] == {"name": "test"} result = await hass.config_entries.flow.async_configure( result["flow_id"], user_input={CONF_PASSWORD: "password1"} @@ -304,7 +367,9 @@ async def test_user_with_password( @pytest.mark.usefixtures("mock_zeroconf") -async def test_user_invalid_password(hass: HomeAssistant, mock_client) -> None: +async def test_user_invalid_password( + hass: HomeAssistant, mock_client: APIClient +) -> None: """Test user step with invalid password.""" mock_client.device_info.return_value = DeviceInfo(uses_password=True, name="test") @@ -316,6 +381,7 @@ async def test_user_invalid_password(hass: HomeAssistant, mock_client) -> None: assert result["type"] is FlowResultType.FORM assert result["step_id"] == "authenticate" + assert result["description_placeholders"] == {"name": "test"} mock_client.connect.side_effect = InvalidAuthAPIError @@ -325,20 +391,35 @@ async def test_user_invalid_password(hass: HomeAssistant, mock_client) -> None: assert result["type"] is FlowResultType.FORM assert result["step_id"] == "authenticate" + assert result["description_placeholders"] == {"name": "test"} assert result["errors"] == {"base": "invalid_auth"} + mock_client.connect.side_effect = None -@pytest.mark.usefixtures("mock_zeroconf") + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={CONF_PASSWORD: "good"} + ) + + assert result2["type"] == FlowResultType.CREATE_ENTRY + assert result2["title"] == "test" + assert result2["data"] == { + CONF_HOST: "127.0.0.1", + CONF_PORT: 6053, + CONF_DEVICE_NAME: "test", + CONF_PASSWORD: "good", + CONF_NOISE_PSK: "", + } + + +@pytest.mark.usefixtures("mock_dashboard", "mock_setup_entry", "mock_zeroconf") async def test_user_dashboard_has_wrong_key( hass: HomeAssistant, - mock_client, - mock_dashboard: dict[str, Any], - mock_setup_entry: None, + mock_client: APIClient, ) -> None: """Test user step with key from dashboard that is incorrect.""" mock_client.device_info.side_effect = [ RequiresEncryptionAPIError, - InvalidEncryptionKeyAPIError, + InvalidEncryptionKeyAPIError("Wrong key", "test"), DeviceInfo( uses_password=False, name="test", @@ -359,6 +440,7 @@ async def test_user_dashboard_has_wrong_key( assert result["type"] is FlowResultType.FORM assert result["step_id"] == "encryption_key" + assert result["description_placeholders"] == {"name": "test"} result = await hass.config_entries.flow.async_configure( result["flow_id"], user_input={CONF_NOISE_PSK: VALID_NOISE_PSK} @@ -375,12 +457,11 @@ async def test_user_dashboard_has_wrong_key( assert mock_client.noise_psk == VALID_NOISE_PSK -@pytest.mark.usefixtures("mock_zeroconf") +@pytest.mark.usefixtures("mock_setup_entry", "mock_zeroconf") async def test_user_discovers_name_and_gets_key_from_dashboard( hass: HomeAssistant, - mock_client, + mock_client: APIClient, mock_dashboard: dict[str, Any], - mock_setup_entry: None, ) -> None: """Test user step can discover the name and get the key from the dashboard.""" mock_client.device_info.side_effect = [ @@ -427,13 +508,12 @@ async def test_user_discovers_name_and_gets_key_from_dashboard( "dashboard_exception", [aiohttp.ClientError(), json.JSONDecodeError("test", "test", 0)], ) -@pytest.mark.usefixtures("mock_zeroconf") +@pytest.mark.usefixtures("mock_setup_entry", "mock_zeroconf") async def test_user_discovers_name_and_gets_key_from_dashboard_fails( hass: HomeAssistant, dashboard_exception: Exception, - mock_client, + mock_client: APIClient, mock_dashboard: dict[str, Any], - mock_setup_entry: None, ) -> None: """Test user step can discover the name and get the key from the dashboard.""" mock_client.device_info.side_effect = [ @@ -467,6 +547,7 @@ async def test_user_discovers_name_and_gets_key_from_dashboard_fails( assert result["type"] is FlowResultType.FORM assert result["step_id"] == "encryption_key" + assert result["description_placeholders"] == {"name": "test"} result = await hass.config_entries.flow.async_configure( result["flow_id"], user_input={CONF_NOISE_PSK: VALID_NOISE_PSK} @@ -483,12 +564,11 @@ async def test_user_discovers_name_and_gets_key_from_dashboard_fails( assert mock_client.noise_psk == VALID_NOISE_PSK -@pytest.mark.usefixtures("mock_zeroconf") +@pytest.mark.usefixtures("mock_setup_entry", "mock_zeroconf") async def test_user_discovers_name_and_dashboard_is_unavailable( hass: HomeAssistant, - mock_client, + mock_client: APIClient, mock_dashboard: dict[str, Any], - mock_setup_entry: None, ) -> None: """Test user step can discover the name but the dashboard is unavailable.""" mock_client.device_info.side_effect = [ @@ -522,6 +602,7 @@ async def test_user_discovers_name_and_dashboard_is_unavailable( assert result["type"] is FlowResultType.FORM assert result["step_id"] == "encryption_key" + assert result["description_placeholders"] == {"name": "test"} result = await hass.config_entries.flow.async_configure( result["flow_id"], user_input={CONF_NOISE_PSK: VALID_NOISE_PSK} @@ -538,9 +619,9 @@ async def test_user_discovers_name_and_dashboard_is_unavailable( assert mock_client.noise_psk == VALID_NOISE_PSK -@pytest.mark.usefixtures("mock_zeroconf") +@pytest.mark.usefixtures("mock_setup_entry", "mock_zeroconf") async def test_login_connection_error( - hass: HomeAssistant, mock_client, mock_setup_entry: None + hass: HomeAssistant, mock_client: APIClient ) -> None: """Test user step with connection error on login attempt.""" mock_client.device_info.return_value = DeviceInfo(uses_password=True, name="test") @@ -553,6 +634,7 @@ async def test_login_connection_error( assert result["type"] is FlowResultType.FORM assert result["step_id"] == "authenticate" + assert result["description_placeholders"] == {"name": "test"} mock_client.connect.side_effect = APIConnectionError @@ -562,13 +644,28 @@ async def test_login_connection_error( assert result["type"] is FlowResultType.FORM assert result["step_id"] == "authenticate" + assert result["description_placeholders"] == {"name": "test"} assert result["errors"] == {"base": "connection_error"} + mock_client.connect.side_effect = None -@pytest.mark.usefixtures("mock_zeroconf") -async def test_discovery_initiation( - hass: HomeAssistant, mock_client, mock_setup_entry: None -) -> None: + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={CONF_PASSWORD: "good"} + ) + + assert result2["type"] == FlowResultType.CREATE_ENTRY + assert result2["title"] == "test" + assert result2["data"] == { + CONF_HOST: "127.0.0.1", + CONF_PORT: 6053, + CONF_DEVICE_NAME: "test", + CONF_PASSWORD: "good", + CONF_NOISE_PSK: "", + } + + +@pytest.mark.usefixtures("mock_client", "mock_setup_entry", "mock_zeroconf") +async def test_discovery_initiation(hass: HomeAssistant) -> None: """Test discovery importing works.""" service_info = ZeroconfServiceInfo( ip_address=ip_address("192.168.43.183"), @@ -578,12 +675,18 @@ async def test_discovery_initiation( port=6053, properties={ "mac": "1122334455aa", + "friendly_name": "The Test", }, type="mock_type", ) flow = await hass.config_entries.flow.async_init( "esphome", context={"source": config_entries.SOURCE_ZEROCONF}, data=service_info ) + assert get_flow_context(hass, flow) == { + "source": config_entries.SOURCE_ZEROCONF, + "title_placeholders": {"name": "The Test (test)"}, + "unique_id": "11:22:33:44:55:aa", + } result = await hass.config_entries.flow.async_configure( flow["flow_id"], user_input={} @@ -598,10 +701,8 @@ async def test_discovery_initiation( assert result["result"].unique_id == "11:22:33:44:55:aa" -@pytest.mark.usefixtures("mock_zeroconf") -async def test_discovery_no_mac( - hass: HomeAssistant, mock_client, mock_setup_entry: None -) -> None: +@pytest.mark.usefixtures("mock_client", "mock_setup_entry", "mock_zeroconf") +async def test_discovery_no_mac(hass: HomeAssistant) -> None: """Test discovery aborted if old ESPHome without mac in zeroconf.""" service_info = ZeroconfServiceInfo( ip_address=ip_address("192.168.43.183"), @@ -619,9 +720,8 @@ async def test_discovery_no_mac( assert flow["reason"] == "mdns_missing_mac" -async def test_discovery_already_configured( - hass: HomeAssistant, mock_client: APIClient, mock_setup_entry: None -) -> None: +@pytest.mark.usefixtures("mock_client", "mock_setup_entry", "mock_zeroconf") +async def test_discovery_already_configured(hass: HomeAssistant) -> None: """Test discovery aborts if already configured via hostname.""" entry = MockConfigEntry( domain=DOMAIN, @@ -645,12 +745,16 @@ async def test_discovery_already_configured( ) assert result["type"] is FlowResultType.ABORT - assert result["reason"] == "already_configured" + assert result["reason"] == "already_configured_updates" + assert result["description_placeholders"] == { + "title": "Mock Title", + "name": "unknown", + "mac": "11:22:33:44:55:aa", + } -async def test_discovery_duplicate_data( - hass: HomeAssistant, mock_client: APIClient, mock_setup_entry: None -) -> None: +@pytest.mark.usefixtures("mock_client", "mock_setup_entry", "mock_zeroconf") +async def test_discovery_duplicate_data(hass: HomeAssistant) -> None: """Test discovery aborts if same mDNS packet arrives.""" service_info = ZeroconfServiceInfo( ip_address=ip_address("192.168.43.183"), @@ -667,6 +771,7 @@ async def test_discovery_duplicate_data( ) assert result["type"] is FlowResultType.FORM assert result["step_id"] == "discovery_confirm" + assert result["description_placeholders"] == {"name": "test"} result = await hass.config_entries.flow.async_init( "esphome", data=service_info, context={"source": config_entries.SOURCE_ZEROCONF} @@ -675,9 +780,8 @@ async def test_discovery_duplicate_data( assert result["reason"] == "already_in_progress" -async def test_discovery_updates_unique_id( - hass: HomeAssistant, mock_client: APIClient, mock_setup_entry: None -) -> None: +@pytest.mark.usefixtures("mock_client", "mock_setup_entry", "mock_zeroconf") +async def test_discovery_updates_unique_id(hass: HomeAssistant) -> None: """Test a duplicate discovery host aborts and updates existing entry.""" entry = MockConfigEntry( domain=DOMAIN, @@ -701,15 +805,18 @@ async def test_discovery_updates_unique_id( ) assert result["type"] is FlowResultType.ABORT - assert result["reason"] == "already_configured" + assert result["reason"] == "already_configured_updates" + assert result["description_placeholders"] == { + "title": "Mock Title", + "name": "unknown", + "mac": "11:22:33:44:55:aa", + } assert entry.unique_id == "11:22:33:44:55:aa" -@pytest.mark.usefixtures("mock_zeroconf") -async def test_user_requires_psk( - hass: HomeAssistant, mock_client, mock_setup_entry: None -) -> None: +@pytest.mark.usefixtures("mock_setup_entry", "mock_zeroconf") +async def test_user_requires_psk(hass: HomeAssistant, mock_client: APIClient) -> None: """Test user step with requiring encryption key.""" mock_client.device_info.side_effect = RequiresEncryptionAPIError @@ -722,15 +829,38 @@ async def test_user_requires_psk( assert result["type"] is FlowResultType.FORM assert result["step_id"] == "encryption_key" assert result["errors"] == {} + assert result["description_placeholders"] == {"name": "ESPHome"} assert len(mock_client.connect.mock_calls) == 2 assert len(mock_client.device_info.mock_calls) == 2 assert len(mock_client.disconnect.mock_calls) == 2 + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={CONF_NOISE_PSK: INVALID_NOISE_PSK} + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "encryption_key" + assert result["errors"] == {"base": "requires_encryption_key"} + assert result["description_placeholders"] == {"name": "ESPHome"} -@pytest.mark.usefixtures("mock_zeroconf") + mock_client.device_info.side_effect = None + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={CONF_NOISE_PSK: VALID_NOISE_PSK} + ) + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["data"] == { + CONF_HOST: "127.0.0.1", + CONF_PORT: 6053, + CONF_PASSWORD: "", + CONF_NOISE_PSK: VALID_NOISE_PSK, + CONF_DEVICE_NAME: "test", + } + assert mock_client.noise_psk == VALID_NOISE_PSK + + +@pytest.mark.usefixtures("mock_setup_entry", "mock_zeroconf") async def test_encryption_key_valid_psk( - hass: HomeAssistant, mock_client, mock_setup_entry: None + hass: HomeAssistant, mock_client: APIClient ) -> None: """Test encryption key step with valid key.""" @@ -744,6 +874,7 @@ async def test_encryption_key_valid_psk( assert result["type"] is FlowResultType.FORM assert result["step_id"] == "encryption_key" + assert result["description_placeholders"] == {"name": "ESPHome"} mock_client.device_info = AsyncMock( return_value=DeviceInfo(uses_password=False, name="test") @@ -763,9 +894,9 @@ async def test_encryption_key_valid_psk( assert mock_client.noise_psk == VALID_NOISE_PSK -@pytest.mark.usefixtures("mock_zeroconf") +@pytest.mark.usefixtures("mock_setup_entry", "mock_zeroconf") async def test_encryption_key_invalid_psk( - hass: HomeAssistant, mock_client, mock_setup_entry: None + hass: HomeAssistant, mock_client: APIClient ) -> None: """Test encryption key step with invalid key.""" @@ -779,6 +910,7 @@ async def test_encryption_key_invalid_psk( assert result["type"] is FlowResultType.FORM assert result["step_id"] == "encryption_key" + assert result["description_placeholders"] == {"name": "ESPHome"} mock_client.device_info.side_effect = InvalidEncryptionKeyAPIError result = await hass.config_entries.flow.async_configure( @@ -788,26 +920,28 @@ async def test_encryption_key_invalid_psk( assert result["type"] is FlowResultType.FORM assert result["step_id"] == "encryption_key" assert result["errors"] == {"base": "invalid_psk"} + assert result["description_placeholders"] == {"name": "ESPHome"} assert mock_client.noise_psk == INVALID_NOISE_PSK - -@pytest.mark.usefixtures("mock_zeroconf") -async def test_reauth_initiation(hass: HomeAssistant, mock_client) -> None: - """Test reauth initiation shows form.""" - entry = MockConfigEntry( - domain=DOMAIN, - data={CONF_HOST: "127.0.0.1", CONF_PORT: 6053, CONF_PASSWORD: ""}, + mock_client.device_info.side_effect = None + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={CONF_NOISE_PSK: VALID_NOISE_PSK} ) - entry.add_to_hass(hass) - result = await entry.start_reauth_flow(hass) - assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "reauth_confirm" + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["data"] == { + CONF_HOST: "127.0.0.1", + CONF_PORT: 6053, + CONF_PASSWORD: "", + CONF_NOISE_PSK: VALID_NOISE_PSK, + CONF_DEVICE_NAME: "test", + } + assert mock_client.noise_psk == VALID_NOISE_PSK -@pytest.mark.usefixtures("mock_zeroconf") +@pytest.mark.usefixtures("mock_setup_entry", "mock_zeroconf") async def test_reauth_confirm_valid( - hass: HomeAssistant, mock_client, mock_setup_entry: None + hass: HomeAssistant, mock_client: APIClient ) -> None: """Test reauth initiation with valid PSK.""" entry = MockConfigEntry( @@ -818,6 +952,11 @@ async def test_reauth_confirm_valid( entry.add_to_hass(hass) result = await entry.start_reauth_flow(hass) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "reauth_confirm" + assert result["description_placeholders"] == { + "name": "Mock Title (test)", + } mock_client.device_info.return_value = DeviceInfo( uses_password=False, name="test", mac_address="11:22:33:44:55:aa" @@ -873,12 +1012,11 @@ async def test_reauth_attempt_to_change_mac_aborts( } -@pytest.mark.usefixtures("mock_zeroconf") +@pytest.mark.usefixtures("mock_setup_entry", "mock_zeroconf") async def test_reauth_fixed_via_dashboard( hass: HomeAssistant, - mock_client, + mock_client: APIClient, mock_dashboard: dict[str, Any], - mock_setup_entry: None, ) -> None: """Test reauth fixed automatically via dashboard.""" @@ -920,13 +1058,12 @@ async def test_reauth_fixed_via_dashboard( assert len(mock_get_encryption_key.mock_calls) == 1 -@pytest.mark.usefixtures("mock_zeroconf") +@pytest.mark.usefixtures("mock_setup_entry", "mock_zeroconf") async def test_reauth_fixed_via_dashboard_add_encryption_remove_password( hass: HomeAssistant, - mock_client, + mock_client: APIClient, mock_dashboard: dict[str, Any], mock_config_entry: MockConfigEntry, - mock_setup_entry: None, ) -> None: """Test reauth fixed automatically via dashboard with password removed.""" mock_client.device_info.side_effect = ( @@ -957,12 +1094,11 @@ async def test_reauth_fixed_via_dashboard_add_encryption_remove_password( assert len(mock_get_encryption_key.mock_calls) == 1 +@pytest.mark.usefixtures("mock_dashboard", "mock_setup_entry", "mock_zeroconf") async def test_reauth_fixed_via_remove_password( hass: HomeAssistant, - mock_client, + mock_client: APIClient, mock_config_entry: MockConfigEntry, - mock_dashboard: dict[str, Any], - mock_setup_entry: None, ) -> None: """Test reauth fixed automatically by seeing password removed.""" mock_client.device_info.return_value = DeviceInfo( @@ -976,12 +1112,11 @@ async def test_reauth_fixed_via_remove_password( assert mock_config_entry.data[CONF_PASSWORD] == "" -@pytest.mark.usefixtures("mock_zeroconf") +@pytest.mark.usefixtures("mock_setup_entry", "mock_zeroconf") async def test_reauth_fixed_via_dashboard_at_confirm( hass: HomeAssistant, - mock_client, + mock_client: APIClient, mock_dashboard: dict[str, Any], - mock_setup_entry: None, ) -> None: """Test reauth fixed automatically via dashboard at confirm step.""" @@ -1005,6 +1140,9 @@ async def test_reauth_fixed_via_dashboard_at_confirm( assert result["type"] is FlowResultType.FORM, result assert result["step_id"] == "reauth_confirm" + assert result["description_placeholders"] == { + "name": "Mock Title (test)", + } mock_dashboard["configured"].append( { @@ -1029,9 +1167,9 @@ async def test_reauth_fixed_via_dashboard_at_confirm( assert len(mock_get_encryption_key.mock_calls) == 1 -@pytest.mark.usefixtures("mock_zeroconf") +@pytest.mark.usefixtures("mock_setup_entry", "mock_zeroconf") async def test_reauth_confirm_invalid( - hass: HomeAssistant, mock_client, mock_setup_entry: None + hass: HomeAssistant, mock_client: APIClient ) -> None: """Test reauth initiation with invalid PSK.""" entry = MockConfigEntry( @@ -1050,6 +1188,9 @@ async def test_reauth_confirm_invalid( assert result["type"] is FlowResultType.FORM assert result["step_id"] == "reauth_confirm" + assert result["description_placeholders"] == { + "name": "Mock Title (test)", + } assert result["errors"] assert result["errors"]["base"] == "invalid_psk" @@ -1067,9 +1208,9 @@ async def test_reauth_confirm_invalid( assert entry.data[CONF_NOISE_PSK] == VALID_NOISE_PSK -@pytest.mark.usefixtures("mock_zeroconf") +@pytest.mark.usefixtures("mock_setup_entry", "mock_zeroconf") async def test_reauth_confirm_invalid_with_unique_id( - hass: HomeAssistant, mock_client, mock_setup_entry: None + hass: HomeAssistant, mock_client: APIClient ) -> None: """Test reauth initiation with invalid PSK.""" entry = MockConfigEntry( @@ -1088,6 +1229,9 @@ async def test_reauth_confirm_invalid_with_unique_id( assert result["type"] is FlowResultType.FORM assert result["step_id"] == "reauth_confirm" + assert result["description_placeholders"] == { + "name": "Mock Title (test)", + } assert result["errors"] assert result["errors"]["base"] == "invalid_psk" @@ -1105,10 +1249,8 @@ async def test_reauth_confirm_invalid_with_unique_id( assert entry.data[CONF_NOISE_PSK] == VALID_NOISE_PSK -@pytest.mark.usefixtures("mock_zeroconf") -async def test_reauth_encryption_key_removed( - hass: HomeAssistant, mock_client: APIClient, mock_setup_entry: None -) -> None: +@pytest.mark.usefixtures("mock_client", "mock_setup_entry", "mock_zeroconf") +async def test_reauth_encryption_key_removed(hass: HomeAssistant) -> None: """Test reauth when the encryption key was removed.""" entry = MockConfigEntry( domain=DOMAIN, @@ -1125,6 +1267,9 @@ async def test_reauth_encryption_key_removed( result = await entry.start_reauth_flow(hass) assert result["type"] is FlowResultType.FORM assert result["step_id"] == "reauth_encryption_removed_confirm" + assert result["description_placeholders"] == { + "name": "Mock Title (test)", + } result = await hass.config_entries.flow.async_configure( result["flow_id"], user_input={} @@ -1135,8 +1280,9 @@ async def test_reauth_encryption_key_removed( assert entry.data[CONF_NOISE_PSK] == "" +@pytest.mark.usefixtures("mock_setup_entry", "mock_zeroconf") async def test_discovery_dhcp_updates_host( - hass: HomeAssistant, mock_client: APIClient, mock_setup_entry: None + hass: HomeAssistant, mock_client: APIClient ) -> None: """Test dhcp discovery updates host and aborts.""" entry = MockConfigEntry( @@ -1159,13 +1305,20 @@ async def test_discovery_dhcp_updates_host( ) assert result["type"] is FlowResultType.ABORT - assert result["reason"] == "already_configured" + assert result["reason"] == "already_configured_updates" + assert result["description_placeholders"] == { + "title": "Mock Title", + "name": "unknown", + "mac": "11:22:33:44:55:aa", + } assert entry.data[CONF_HOST] == "192.168.43.184" +@pytest.mark.usefixtures("mock_setup_entry") async def test_discovery_dhcp_does_not_update_host_wrong_mac( - hass: HomeAssistant, mock_client: APIClient, mock_setup_entry: None + hass: HomeAssistant, + mock_client: APIClient, ) -> None: """Test dhcp discovery does not update the host if the mac is wrong.""" entry = MockConfigEntry( @@ -1188,14 +1341,20 @@ async def test_discovery_dhcp_does_not_update_host_wrong_mac( ) assert result["type"] is FlowResultType.ABORT - assert result["reason"] == "already_configured" + assert result["reason"] == "already_configured_detailed" + assert result["description_placeholders"] == { + "title": "Mock Title", + "name": "unknown", + "mac": "11:22:33:44:55:aa", + } # Mac was wrong, should not update assert entry.data[CONF_HOST] == "192.168.43.183" +@pytest.mark.usefixtures("mock_setup_entry") async def test_discovery_dhcp_does_not_update_host_wrong_mac_bad_key( - hass: HomeAssistant, mock_client: APIClient, mock_setup_entry: None + hass: HomeAssistant, mock_client: APIClient ) -> None: """Test dhcp discovery does not update the host if the mac is wrong.""" entry = MockConfigEntry( @@ -1217,14 +1376,20 @@ async def test_discovery_dhcp_does_not_update_host_wrong_mac_bad_key( ) assert result["type"] is FlowResultType.ABORT - assert result["reason"] == "already_configured" + assert result["reason"] == "already_configured_detailed" + assert result["description_placeholders"] == { + "title": "Mock Title", + "name": "unknown", + "mac": "11:22:33:44:55:aa", + } # Mac was wrong, should not update assert entry.data[CONF_HOST] == "192.168.43.183" +@pytest.mark.usefixtures("mock_setup_entry") async def test_discovery_dhcp_does_not_update_host_missing_mac_bad_key( - hass: HomeAssistant, mock_client: APIClient, mock_setup_entry: None + hass: HomeAssistant, mock_client: APIClient ) -> None: """Test dhcp discovery does not update the host if the mac is missing.""" entry = MockConfigEntry( @@ -1246,14 +1411,20 @@ async def test_discovery_dhcp_does_not_update_host_missing_mac_bad_key( ) assert result["type"] is FlowResultType.ABORT - assert result["reason"] == "already_configured" + assert result["reason"] == "already_configured_detailed" + assert result["description_placeholders"] == { + "title": "Mock Title", + "name": "unknown", + "mac": "11:22:33:44:55:aa", + } # Mac was missing, should not update assert entry.data[CONF_HOST] == "192.168.43.183" +@pytest.mark.usefixtures("mock_setup_entry") async def test_discovery_dhcp_no_changes( - hass: HomeAssistant, mock_client: APIClient, mock_setup_entry: None + hass: HomeAssistant, mock_client: APIClient ) -> None: """Test dhcp discovery updates host and aborts.""" entry = MockConfigEntry( @@ -1279,9 +1450,8 @@ async def test_discovery_dhcp_no_changes( assert entry.data[CONF_HOST] == "192.168.43.183" -async def test_discovery_hassio( - hass: HomeAssistant, mock_dashboard: dict[str, Any] -) -> None: +@pytest.mark.usefixtures("mock_dashboard") +async def test_discovery_hassio(hass: HomeAssistant) -> None: """Test dashboard discovery.""" result = await hass.config_entries.flow.async_init( "esphome", @@ -1305,12 +1475,11 @@ async def test_discovery_hassio( assert dash.addon_slug == "mock-slug" -@pytest.mark.usefixtures("mock_zeroconf") +@pytest.mark.usefixtures("mock_setup_entry", "mock_zeroconf") async def test_zeroconf_encryption_key_via_dashboard( hass: HomeAssistant, - mock_client, + mock_client: APIClient, mock_dashboard: dict[str, Any], - mock_setup_entry: None, ) -> None: """Test encryption key retrieved from dashboard.""" service_info = ZeroconfServiceInfo( @@ -1330,6 +1499,7 @@ async def test_zeroconf_encryption_key_via_dashboard( assert flow["type"] is FlowResultType.FORM assert flow["step_id"] == "discovery_confirm" + assert flow["description_placeholders"] == {"name": "test8266"} mock_dashboard["configured"].append( { @@ -1371,12 +1541,11 @@ async def test_zeroconf_encryption_key_via_dashboard( assert mock_client.noise_psk == VALID_NOISE_PSK -@pytest.mark.usefixtures("mock_zeroconf") +@pytest.mark.usefixtures("mock_setup_entry", "mock_zeroconf") async def test_zeroconf_encryption_key_via_dashboard_with_api_encryption_prop( hass: HomeAssistant, - mock_client, + mock_client: APIClient, mock_dashboard: dict[str, Any], - mock_setup_entry: None, ) -> None: """Test encryption key retrieved from dashboard with api_encryption property set.""" service_info = ZeroconfServiceInfo( @@ -1397,6 +1566,7 @@ async def test_zeroconf_encryption_key_via_dashboard_with_api_encryption_prop( assert flow["type"] is FlowResultType.FORM assert flow["step_id"] == "discovery_confirm" + assert flow["description_placeholders"] == {"name": "test8266"} mock_dashboard["configured"].append( { @@ -1437,12 +1607,10 @@ async def test_zeroconf_encryption_key_via_dashboard_with_api_encryption_prop( assert mock_client.noise_psk == VALID_NOISE_PSK -@pytest.mark.usefixtures("mock_zeroconf") +@pytest.mark.usefixtures("mock_dashboard", "mock_setup_entry", "mock_zeroconf") async def test_zeroconf_no_encryption_key_via_dashboard( hass: HomeAssistant, - mock_client, - mock_dashboard: dict[str, Any], - mock_setup_entry: None, + mock_client: APIClient, ) -> None: """Test encryption key not retrieved from dashboard.""" service_info = ZeroconfServiceInfo( @@ -1462,6 +1630,7 @@ async def test_zeroconf_no_encryption_key_via_dashboard( assert flow["type"] is FlowResultType.FORM assert flow["step_id"] == "discovery_confirm" + assert flow["description_placeholders"] == {"name": "test8266"} await dashboard.async_get_dashboard(hass).async_refresh() @@ -1473,12 +1642,31 @@ async def test_zeroconf_no_encryption_key_via_dashboard( assert result["type"] is FlowResultType.FORM assert result["step_id"] == "encryption_key" + assert result["description_placeholders"] == {"name": "test8266"} + + mock_client.device_info.side_effect = None + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={CONF_NOISE_PSK: VALID_NOISE_PSK} + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["data"] == { + CONF_HOST: "192.168.43.183", + CONF_PORT: 6053, + CONF_PASSWORD: "", + CONF_NOISE_PSK: VALID_NOISE_PSK, + CONF_DEVICE_NAME: "test", + } + assert mock_client.noise_psk == VALID_NOISE_PSK async def test_option_flow_allow_service_calls( hass: HomeAssistant, mock_client: APIClient, - mock_generic_device_entry, + mock_generic_device_entry: Callable[ + [APIClient, list[EntityInfo], list[UserService], list[EntityState]], + Awaitable[MockConfigEntry], + ], ) -> None: """Test config flow options for allow service calls.""" entry = await mock_generic_device_entry( @@ -1523,7 +1711,10 @@ async def test_option_flow_allow_service_calls( async def test_option_flow_subscribe_logs( hass: HomeAssistant, mock_client: APIClient, - mock_generic_device_entry, + mock_generic_device_entry: Callable[ + [APIClient, list[EntityInfo], list[UserService], list[EntityState]], + Awaitable[MockConfigEntry], + ], ) -> None: """Test config flow options with subscribe logs.""" entry = await mock_generic_device_entry( @@ -1559,11 +1750,10 @@ async def test_option_flow_subscribe_logs( assert len(mock_reload.mock_calls) == 1 -@pytest.mark.usefixtures("mock_zeroconf") +@pytest.mark.usefixtures("mock_setup_entry", "mock_zeroconf") async def test_user_discovers_name_no_dashboard( hass: HomeAssistant, - mock_client, - mock_setup_entry: None, + mock_client: APIClient, ) -> None: """Test user step can discover the name and the there is not dashboard.""" mock_client.device_info.side_effect = [ @@ -1585,6 +1775,7 @@ async def test_user_discovers_name_no_dashboard( assert result["type"] is FlowResultType.FORM assert result["step_id"] == "encryption_key" + assert result["description_placeholders"] == {"name": "test"} result = await hass.config_entries.flow.async_configure( result["flow_id"], user_input={CONF_NOISE_PSK: VALID_NOISE_PSK} @@ -1601,7 +1792,9 @@ async def test_user_discovers_name_no_dashboard( assert mock_client.noise_psk == VALID_NOISE_PSK -async def mqtt_discovery_test_abort(hass: HomeAssistant, payload: str, reason: str): +async def mqtt_discovery_test_abort( + hass: HomeAssistant, payload: str, reason: str +) -> None: """Test discovery aborted.""" service_info = MqttServiceInfo( topic="esphome/discover/test", @@ -1618,44 +1811,34 @@ async def mqtt_discovery_test_abort(hass: HomeAssistant, payload: str, reason: s assert flow["reason"] == reason -@pytest.mark.usefixtures("mock_zeroconf") -async def test_discovery_mqtt_no_mac( - hass: HomeAssistant, mock_client, mock_setup_entry: None -) -> None: +@pytest.mark.usefixtures("mock_client", "mock_setup_entry", "mock_zeroconf") +async def test_discovery_mqtt_no_mac(hass: HomeAssistant) -> None: """Test discovery aborted if mac is missing in MQTT payload.""" await mqtt_discovery_test_abort(hass, "{}", "mqtt_missing_mac") -@pytest.mark.usefixtures("mock_zeroconf") -async def test_discovery_mqtt_empty_payload( - hass: HomeAssistant, mock_client, mock_setup_entry: None -) -> None: +@pytest.mark.usefixtures("mock_client", "mock_setup_entry", "mock_zeroconf") +async def test_discovery_mqtt_empty_payload(hass: HomeAssistant) -> None: """Test discovery aborted if MQTT payload is empty.""" await mqtt_discovery_test_abort(hass, "", "mqtt_missing_payload") -@pytest.mark.usefixtures("mock_zeroconf") -async def test_discovery_mqtt_no_api( - hass: HomeAssistant, mock_client, mock_setup_entry: None -) -> None: +@pytest.mark.usefixtures("mock_client", "mock_setup_entry", "mock_zeroconf") +async def test_discovery_mqtt_no_api(hass: HomeAssistant) -> None: """Test discovery aborted if api/port is missing in MQTT payload.""" await mqtt_discovery_test_abort(hass, '{"mac":"abcdef123456"}', "mqtt_missing_api") -@pytest.mark.usefixtures("mock_zeroconf") -async def test_discovery_mqtt_no_ip( - hass: HomeAssistant, mock_client, mock_setup_entry: None -) -> None: +@pytest.mark.usefixtures("mock_client", "mock_setup_entry", "mock_zeroconf") +async def test_discovery_mqtt_no_ip(hass: HomeAssistant) -> None: """Test discovery aborted if ip is missing in MQTT payload.""" await mqtt_discovery_test_abort( hass, '{"mac":"abcdef123456","port":6053}', "mqtt_missing_ip" ) -@pytest.mark.usefixtures("mock_zeroconf") -async def test_discovery_mqtt_initiation( - hass: HomeAssistant, mock_client, mock_setup_entry: None -) -> None: +@pytest.mark.usefixtures("mock_client", "mock_setup_entry", "mock_zeroconf") +async def test_discovery_mqtt_initiation(hass: HomeAssistant) -> None: """Test discovery importing works.""" service_info = MqttServiceInfo( topic="esphome/discover/test", @@ -1682,11 +1865,10 @@ async def test_discovery_mqtt_initiation( assert result["result"].unique_id == "11:22:33:44:55:aa" -@pytest.mark.usefixtures("mock_zeroconf") +@pytest.mark.usefixtures("mock_setup_entry", "mock_zeroconf") async def test_user_flow_name_conflict_migrate( hass: HomeAssistant, - mock_client, - mock_setup_entry: None, + mock_client: APIClient, ) -> None: """Test handle migration on name conflict.""" existing_entry = MockConfigEntry( @@ -1733,11 +1915,10 @@ async def test_user_flow_name_conflict_migrate( assert existing_entry.unique_id == "11:22:33:44:55:aa" -@pytest.mark.usefixtures("mock_zeroconf") +@pytest.mark.usefixtures("mock_setup_entry", "mock_zeroconf") async def test_user_flow_name_conflict_overwrite( hass: HomeAssistant, - mock_client, - mock_setup_entry: None, + mock_client: APIClient, ) -> None: """Test handle overwrite on name conflict.""" existing_entry = MockConfigEntry( @@ -1877,6 +2058,54 @@ async def test_reconfig_success_with_new_ip_same_name( assert entry.data[CONF_NOISE_PSK] == VALID_NOISE_PSK +@pytest.mark.usefixtures("mock_zeroconf", "mock_setup_entry") +async def test_reconfig_success_noise_psk_changes( + hass: HomeAssistant, mock_client: APIClient +) -> None: + """Test reconfig initiation with new ip and new noise psk.""" + entry = MockConfigEntry( + domain=DOMAIN, + data={ + CONF_HOST: "127.0.0.1", + CONF_PORT: 6053, + CONF_PASSWORD: "", + CONF_DEVICE_NAME: "test", + CONF_NOISE_PSK: VALID_NOISE_PSK, + }, + unique_id="11:22:33:44:55:aa", + ) + entry.add_to_hass(hass) + + result = await entry.start_reconfigure_flow(hass) + mock_client.device_info.side_effect = [ + RequiresEncryptionAPIError, + InvalidEncryptionKeyAPIError("Wrong key", "test"), + DeviceInfo(uses_password=False, name="test", mac_address="11:22:33:44:55:aa"), + ] + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={CONF_HOST: "127.0.0.1", CONF_PORT: 6053} + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "encryption_key" + assert result["description_placeholders"] == {"name": "Mock Title (test)"} + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={CONF_NOISE_PSK: VALID_NOISE_PSK} + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "encryption_key" + assert result["description_placeholders"] == {"name": "Mock Title (test)"} + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={CONF_NOISE_PSK: VALID_NOISE_PSK} + ) + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reconfigure_successful" + assert entry.data[CONF_HOST] == "127.0.0.1" + assert entry.data[CONF_DEVICE_NAME] == "test" + assert entry.data[CONF_NOISE_PSK] == VALID_NOISE_PSK + + @pytest.mark.usefixtures("mock_zeroconf", "mock_setup_entry") async def test_reconfig_name_conflict_with_existing_entry( hass: HomeAssistant, mock_client: APIClient @@ -1999,7 +2228,12 @@ async def test_reconfig_mac_used_by_other_entry( ) assert result["type"] is FlowResultType.ABORT - assert result["reason"] == "already_configured" + assert result["reason"] == "already_configured_updates" + assert result["description_placeholders"] == { + "title": "Mock Title", + "name": "test4", + "mac": "11:22:33:44:55:bb", + } @pytest.mark.usefixtures("mock_zeroconf", "mock_setup_entry") diff --git a/tests/components/esphome/test_entity.py b/tests/components/esphome/test_entity.py index 5c82337e71b..290b1871cd7 100644 --- a/tests/components/esphome/test_entity.py +++ b/tests/components/esphome/test_entity.py @@ -17,6 +17,7 @@ from aioesphomeapi import ( ) from homeassistant.const import ( + ATTR_FRIENDLY_NAME, ATTR_RESTORED, EVENT_HOMEASSISTANT_STOP, STATE_OFF, @@ -503,3 +504,40 @@ async def test_esphome_device_without_friendly_name( state = hass.states.get("binary_sensor.test_mybinary_sensor") assert state is not None assert state.state == STATE_ON + + +async def test_entity_without_name_device_with_friendly_name( + hass: HomeAssistant, + mock_client: APIClient, + hass_storage: dict[str, Any], + mock_esphome_device: Callable[ + [APIClient, list[EntityInfo], list[UserService], list[EntityState]], + Awaitable[MockESPHomeDevice], + ], +) -> None: + """Test name and entity_id for a device a friendly name and an entity without a name.""" + entity_info = [ + BinarySensorInfo( + object_id="mybinary_sensor", + key=1, + name="", + unique_id="my_binary_sensor", + ), + ] + states = [ + BinarySensorState(key=1, state=True, missing_state=False), + ] + user_service = [] + await mock_esphome_device( + mock_client=mock_client, + entity_info=entity_info, + user_service=user_service, + states=states, + device_info={"friendly_name": "The Best Mixer", "name": "mixer"}, + ) + state = hass.states.get("binary_sensor.mixer") + assert state is not None + assert state.state == STATE_ON + # Make sure we have set the name to `None` as otherwise + # the friendly_name will be "The Best Mixer " + assert state.attributes[ATTR_FRIENDLY_NAME] == "The Best Mixer" diff --git a/tests/components/esphome/test_manager.py b/tests/components/esphome/test_manager.py index a4cef909fcc..aa4ca665602 100644 --- a/tests/components/esphome/test_manager.py +++ b/tests/components/esphome/test_manager.py @@ -49,7 +49,12 @@ from homeassistant.setup import async_setup_component from .conftest import MockESPHomeDevice -from tests.common import MockConfigEntry, async_capture_events, async_mock_service +from tests.common import ( + MockConfigEntry, + async_call_logger_set_level, + async_capture_events, + async_mock_service, +) async def test_esphome_device_subscribe_logs( @@ -83,62 +88,50 @@ async def test_esphome_device_subscribe_logs( ) await hass.async_block_till_done() - await hass.services.async_call( - "logger", - "set_level", - {"homeassistant.components.esphome": "DEBUG"}, - blocking=True, - ) - assert device.current_log_level == LogLevel.LOG_LEVEL_VERY_VERBOSE + async with async_call_logger_set_level( + "homeassistant.components.esphome", "DEBUG", hass=hass, caplog=caplog + ): + assert device.current_log_level == LogLevel.LOG_LEVEL_VERY_VERBOSE - caplog.set_level(logging.DEBUG) - device.mock_on_log_message( - Mock(level=LogLevel.LOG_LEVEL_INFO, message=b"test_log_message") - ) - await hass.async_block_till_done() - assert "test_log_message" in caplog.text + caplog.set_level(logging.DEBUG) + device.mock_on_log_message( + Mock(level=LogLevel.LOG_LEVEL_INFO, message=b"test_log_message") + ) + await hass.async_block_till_done() + assert "test_log_message" in caplog.text - device.mock_on_log_message( - Mock(level=LogLevel.LOG_LEVEL_ERROR, message=b"test_error_log_message") - ) - await hass.async_block_till_done() - assert "test_error_log_message" in caplog.text + device.mock_on_log_message( + Mock(level=LogLevel.LOG_LEVEL_ERROR, message=b"test_error_log_message") + ) + await hass.async_block_till_done() + assert "test_error_log_message" in caplog.text - caplog.set_level(logging.ERROR) - device.mock_on_log_message( - Mock(level=LogLevel.LOG_LEVEL_DEBUG, message=b"test_debug_log_message") - ) - await hass.async_block_till_done() - assert "test_debug_log_message" not in caplog.text + caplog.set_level(logging.ERROR) + device.mock_on_log_message( + Mock(level=LogLevel.LOG_LEVEL_DEBUG, message=b"test_debug_log_message") + ) + await hass.async_block_till_done() + assert "test_debug_log_message" not in caplog.text - caplog.set_level(logging.DEBUG) - device.mock_on_log_message( - Mock(level=LogLevel.LOG_LEVEL_DEBUG, message=b"test_debug_log_message") - ) - await hass.async_block_till_done() - assert "test_debug_log_message" in caplog.text + caplog.set_level(logging.DEBUG) + device.mock_on_log_message( + Mock(level=LogLevel.LOG_LEVEL_DEBUG, message=b"test_debug_log_message") + ) + await hass.async_block_till_done() + assert "test_debug_log_message" in caplog.text - await hass.services.async_call( - "logger", - "set_level", - {"homeassistant.components.esphome": "WARNING"}, - blocking=True, - ) - assert device.current_log_level == LogLevel.LOG_LEVEL_WARN - await hass.services.async_call( - "logger", - "set_level", - {"homeassistant.components.esphome": "ERROR"}, - blocking=True, - ) - assert device.current_log_level == LogLevel.LOG_LEVEL_ERROR - await hass.services.async_call( - "logger", - "set_level", - {"homeassistant.components.esphome": "INFO"}, - blocking=True, - ) - assert device.current_log_level == LogLevel.LOG_LEVEL_CONFIG + async with async_call_logger_set_level( + "homeassistant.components.esphome", "WARNING", hass=hass, caplog=caplog + ): + assert device.current_log_level == LogLevel.LOG_LEVEL_WARN + async with async_call_logger_set_level( + "homeassistant.components.esphome", "ERROR", hass=hass, caplog=caplog + ): + assert device.current_log_level == LogLevel.LOG_LEVEL_ERROR + async with async_call_logger_set_level( + "homeassistant.components.esphome", "INFO", hass=hass, caplog=caplog + ): + assert device.current_log_level == LogLevel.LOG_LEVEL_CONFIG async def test_esphome_device_service_calls_not_allowed( @@ -750,7 +743,12 @@ async def test_connection_aborted_wrong_device( ) assert result["type"] is FlowResultType.ABORT - assert result["reason"] == "already_configured" + assert result["reason"] == "already_configured_updates" + assert result["description_placeholders"] == { + "title": "Mock Title", + "name": "test", + "mac": "11:22:33:44:55:aa", + } assert entry.data[CONF_HOST] == "192.168.43.184" await hass.async_block_till_done() assert len(new_info.mock_calls) == 2 @@ -819,7 +817,12 @@ async def test_connection_aborted_wrong_device_same_name( ) assert result["type"] is FlowResultType.ABORT - assert result["reason"] == "already_configured" + assert result["reason"] == "already_configured_updates" + assert result["description_placeholders"] == { + "title": "Mock Title", + "name": "test", + "mac": "11:22:33:44:55:aa", + } assert entry.data[CONF_HOST] == "192.168.43.184" await hass.async_block_till_done() assert len(new_info.mock_calls) == 2 @@ -958,6 +961,7 @@ async def test_debug_logging( [APIClient, list[EntityInfo], list[UserService], list[EntityState]], Awaitable[MockConfigEntry], ], + caplog: pytest.LogCaptureFixture, ) -> None: """Test enabling and disabling debug logging.""" assert await async_setup_component(hass, "logger", {"logger": {}}) @@ -967,24 +971,16 @@ async def test_debug_logging( user_service=[], states=[], ) - await hass.services.async_call( - "logger", - "set_level", - {"homeassistant.components.esphome": "DEBUG"}, - blocking=True, - ) - await hass.async_block_till_done() - mock_client.set_debug.assert_has_calls([call(True)]) + async with async_call_logger_set_level( + "homeassistant.components.esphome", "DEBUG", hass=hass, caplog=caplog + ): + mock_client.set_debug.assert_has_calls([call(True)]) + mock_client.reset_mock() - mock_client.reset_mock() - await hass.services.async_call( - "logger", - "set_level", - {"homeassistant.components.esphome": "WARNING"}, - blocking=True, - ) - await hass.async_block_till_done() - mock_client.set_debug.assert_has_calls([call(False)]) + async with async_call_logger_set_level( + "homeassistant.components.esphome", "WARNING", hass=hass, caplog=caplog + ): + mock_client.set_debug.assert_has_calls([call(False)]) async def test_esphome_device_with_dash_in_name_user_services( diff --git a/tests/components/flexit_bacnet/test_climate.py b/tests/components/flexit_bacnet/test_climate.py index be361541c39..e3c04a1a48f 100644 --- a/tests/components/flexit_bacnet/test_climate.py +++ b/tests/components/flexit_bacnet/test_climate.py @@ -27,7 +27,7 @@ from homeassistant.components.flexit_bacnet.const import PRESET_TO_VENTILATION_M from homeassistant.const import ATTR_ENTITY_ID, ATTR_TEMPERATURE, Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers import entity_registry as er +from homeassistant.helpers import entity_component, entity_registry as er from . import setup_with_selected_platforms @@ -156,14 +156,14 @@ async def test_hvac_action( # Simulate electric heater being ON mock_flexit_bacnet.electric_heater = True - await hass.helpers.entity_component.async_update_entity(ENTITY_ID) + await entity_component.async_update_entity(hass, ENTITY_ID) state = hass.states.get(ENTITY_ID) assert state.attributes[ATTR_HVAC_ACTION] == HVACAction.HEATING # Simulate electric heater being OFF mock_flexit_bacnet.electric_heater = False - await hass.helpers.entity_component.async_update_entity(ENTITY_ID) + await entity_component.async_update_entity(hass, ENTITY_ID) state = hass.states.get(ENTITY_ID) assert state.attributes[ATTR_HVAC_ACTION] == HVACAction.FAN diff --git a/tests/components/freebox/conftest.py b/tests/components/freebox/conftest.py index e6adae572f3..abf0153fede 100644 --- a/tests/components/freebox/conftest.py +++ b/tests/components/freebox/conftest.py @@ -16,7 +16,9 @@ from .const import ( DATA_HOME_PIR_GET_VALUE, DATA_HOME_SET_VALUE, DATA_LAN_GET_HOSTS_LIST, + DATA_LAN_GET_HOSTS_LIST_GUEST, DATA_LAN_GET_HOSTS_LIST_MODE_BRIDGE, + DATA_LAN_GET_INTERFACES, DATA_STORAGE_GET_DISKS, DATA_STORAGE_GET_RAIDS, DATA_SYSTEM_GET_CONFIG, @@ -68,7 +70,12 @@ def mock_router(mock_device_registry_devices): instance.open = AsyncMock() instance.system.get_config = AsyncMock(return_value=DATA_SYSTEM_GET_CONFIG) # device_tracker - instance.lan.get_hosts_list = AsyncMock(return_value=DATA_LAN_GET_HOSTS_LIST) + instance.lan.get_interfaces = AsyncMock(return_value=DATA_LAN_GET_INTERFACES) + instance.lan.get_hosts_list = AsyncMock( + side_effect=lambda interface: DATA_LAN_GET_HOSTS_LIST + if interface == "pub" + else DATA_LAN_GET_HOSTS_LIST_GUEST + ) # sensor instance.call.get_calls_log = AsyncMock(return_value=DATA_CALL_GET_CALLS_LOG) instance.storage.get_disks = AsyncMock(return_value=DATA_STORAGE_GET_DISKS) @@ -96,6 +103,12 @@ def mock_router(mock_device_registry_devices): def mock_router_bridge_mode(mock_device_registry_devices, router): """Mock a successful connection to Freebox Bridge mode.""" + router().lan.get_interfaces = AsyncMock( + side_effect=HttpRequestError( + f"Request failed (APIResponse: {json.dumps(DATA_LAN_GET_HOSTS_LIST_MODE_BRIDGE)})" + ) + ) + router().lan.get_hosts_list = AsyncMock( side_effect=HttpRequestError( f"Request failed (APIResponse: {json.dumps(DATA_LAN_GET_HOSTS_LIST_MODE_BRIDGE)})" diff --git a/tests/components/freebox/const.py b/tests/components/freebox/const.py index 5211b793918..47dfac636a7 100644 --- a/tests/components/freebox/const.py +++ b/tests/components/freebox/const.py @@ -25,7 +25,11 @@ DATA_WIFI_GET_GLOBAL_CONFIG = load_json_object_fixture( ) # device_tracker +DATA_LAN_GET_INTERFACES = load_json_array_fixture("freebox/lan_get_interfaces.json") DATA_LAN_GET_HOSTS_LIST = load_json_array_fixture("freebox/lan_get_hosts_list.json") +DATA_LAN_GET_HOSTS_LIST_GUEST = load_json_array_fixture( + "freebox/lan_get_hosts_list_guest.json" +) DATA_LAN_GET_HOSTS_LIST_MODE_BRIDGE = load_json_object_fixture( "freebox/lan_get_hosts_list_bridge.json" ) diff --git a/tests/components/freebox/fixtures/lan_get_hosts_list_guest.json b/tests/components/freebox/fixtures/lan_get_hosts_list_guest.json new file mode 100644 index 00000000000..9e2cdffef0a --- /dev/null +++ b/tests/components/freebox/fixtures/lan_get_hosts_list_guest.json @@ -0,0 +1,81 @@ +[ + { + "l2ident": { + "id": "8C:97:EA:00:00:01", + "type": "mac_address" + }, + "active": true, + "persistent": false, + "names": [ + { + "name": "d633d0c8-958c-42cc-e807-d881b476924b", + "source": "mdns" + }, + { + "name": "Freebox Player POP 2", + "source": "mdns_srv" + } + ], + "vendor_name": "Freebox SAS", + "host_type": "smartphone", + "interface": "pub", + "id": "ether-8c:97:ea:00:00:01", + "last_time_reachable": 1614107662, + "primary_name_manual": false, + "l3connectivities": [ + { + "addr": "192.168.27.181", + "active": true, + "reachable": true, + "last_activity": 1614107614, + "af": "ipv4", + "last_time_reachable": 1614104242 + }, + { + "addr": "fe80::dcef:dbba:6604:31d1", + "active": true, + "reachable": true, + "last_activity": 1614107645, + "af": "ipv6", + "last_time_reachable": 1614107645 + }, + { + "addr": "2a01:e34:eda1:eb40:8102:4704:7ce0:2ace", + "active": false, + "reachable": false, + "last_activity": 1611574428, + "af": "ipv6", + "last_time_reachable": 1611574428 + }, + { + "addr": "2a01:e34:eda1:eb40:c8e5:c524:c96d:5f5e", + "active": false, + "reachable": false, + "last_activity": 1612475101, + "af": "ipv6", + "last_time_reachable": 1612475101 + }, + { + "addr": "2a01:e34:eda1:eb40:583a:49df:1df0:c2df", + "active": true, + "reachable": true, + "last_activity": 1614107652, + "af": "ipv6", + "last_time_reachable": 1614107652 + }, + { + "addr": "2a01:e34:eda1:eb40:147e:3569:86ab:6aaa", + "active": false, + "reachable": false, + "last_activity": 1612486752, + "af": "ipv6", + "last_time_reachable": 1612486752 + } + ], + "default_name": "Freebox Player POP", + "model": "fbx8am", + "reachable": true, + "last_activity": 1614107652, + "primary_name": "Freebox Player POP" + } +] diff --git a/tests/components/freebox/fixtures/lan_get_interfaces.json b/tests/components/freebox/fixtures/lan_get_interfaces.json new file mode 100644 index 00000000000..2646ee38b50 --- /dev/null +++ b/tests/components/freebox/fixtures/lan_get_interfaces.json @@ -0,0 +1,4 @@ +[ + { "name": "pub", "host_count": 4 }, + { "name": "wifiguest", "host_count": 1 } +] diff --git a/tests/components/freebox/test_device_tracker.py b/tests/components/freebox/test_device_tracker.py index 405166d6ba2..f0821daabc3 100644 --- a/tests/components/freebox/test_device_tracker.py +++ b/tests/components/freebox/test_device_tracker.py @@ -21,14 +21,14 @@ async def test_router_mode( """Test get_hosts_list invoqued multiple times if freebox into router mode.""" await setup_platform(hass, DEVICE_TRACKER_DOMAIN) - assert router().lan.get_hosts_list.call_count == 1 + assert router().lan.get_hosts_list.call_count == 2 # Simulate an update freezer.tick(SCAN_INTERVAL) async_fire_time_changed(hass) await hass.async_block_till_done() - assert router().lan.get_hosts_list.call_count == 2 + assert router().lan.get_hosts_list.call_count == 4 async def test_bridge_mode( @@ -36,15 +36,15 @@ async def test_bridge_mode( freezer: FrozenDateTimeFactory, router_bridge_mode: Mock, ) -> None: - """Test get_hosts_list invoqued once if freebox into bridge mode.""" + """Test get_interfaces invoqued once if freebox into bridge mode.""" await setup_platform(hass, DEVICE_TRACKER_DOMAIN) - assert router_bridge_mode().lan.get_hosts_list.call_count == 1 + assert router_bridge_mode().lan.get_interfaces.call_count == 1 # Simulate an update freezer.tick(SCAN_INTERVAL) async_fire_time_changed(hass) await hass.async_block_till_done() - # If get_hosts_list failed, not called again - assert router_bridge_mode().lan.get_hosts_list.call_count == 1 + # If get_interfaces failed, not called again + assert router_bridge_mode().lan.get_interfaces.call_count == 1 diff --git a/tests/components/freebox/test_router.py b/tests/components/freebox/test_router.py index 623f595e1ad..3d98abf71a2 100644 --- a/tests/components/freebox/test_router.py +++ b/tests/components/freebox/test_router.py @@ -35,7 +35,10 @@ async def test_get_hosts_list_if_supported( assert supports_hosts is True # List must not be empty; but it's content depends on how many unit tests are executed... assert fbx_devices + # We expect 4 devices from lan_get_hosts_list.json and 1 from lan_get_hosts_list_guest.json + assert len(fbx_devices) == 5 assert "d633d0c8-958c-43cc-e807-d881b076924b" in str(fbx_devices) + assert "d633d0c8-958c-42cc-e807-d881b476924b" in str(fbx_devices) async def test_get_hosts_list_if_supported_bridge( diff --git a/tests/components/fritzbox/test_climate.py b/tests/components/fritzbox/test_climate.py index e21191fcbbb..5bf81ef0238 100644 --- a/tests/components/fritzbox/test_climate.py +++ b/tests/components/fritzbox/test_climate.py @@ -94,7 +94,7 @@ async def test_target_temperature_on(hass: HomeAssistant, fritz: Mock) -> None: state = hass.states.get(ENTITY_ID) assert state - assert state.attributes[ATTR_TEMPERATURE] == 30 + assert state.attributes[ATTR_TEMPERATURE] is None async def test_target_temperature_off(hass: HomeAssistant, fritz: Mock) -> None: @@ -107,7 +107,7 @@ async def test_target_temperature_off(hass: HomeAssistant, fritz: Mock) -> None: state = hass.states.get(ENTITY_ID) assert state - assert state.attributes[ATTR_TEMPERATURE] == 0 + assert state.attributes[ATTR_TEMPERATURE] is None async def test_update(hass: HomeAssistant, fritz: Mock) -> None: @@ -177,15 +177,20 @@ async def test_update_error(hass: HomeAssistant, fritz: Mock) -> None: @pytest.mark.parametrize( - ("service_data", "expected_call_args"), + ( + "service_data", + "expected_set_target_temperature_call_args", + "expected_set_hkr_state_call_args", + ), [ - ({ATTR_TEMPERATURE: 23}, [call(23, True)]), + ({ATTR_TEMPERATURE: 23}, [call(23, True)], []), ( { ATTR_HVAC_MODE: HVACMode.OFF, ATTR_TEMPERATURE: 23, }, - [call(0, True)], + [], + [call("off", True)], ), ( { @@ -193,6 +198,7 @@ async def test_update_error(hass: HomeAssistant, fritz: Mock) -> None: ATTR_TEMPERATURE: 23, }, [call(23, True)], + [], ), ], ) @@ -200,7 +206,8 @@ async def test_set_temperature( hass: HomeAssistant, fritz: Mock, service_data: dict, - expected_call_args: list[_Call], + expected_set_target_temperature_call_args: list[_Call], + expected_set_hkr_state_call_args: list[_Call], ) -> None: """Test setting temperature.""" device = FritzDeviceClimateMock() @@ -214,29 +221,60 @@ async def test_set_temperature( {ATTR_ENTITY_ID: ENTITY_ID, **service_data}, True, ) - assert device.set_target_temperature.call_count == len(expected_call_args) - assert device.set_target_temperature.call_args_list == expected_call_args + assert device.set_target_temperature.call_count == len( + expected_set_target_temperature_call_args + ) + assert ( + device.set_target_temperature.call_args_list + == expected_set_target_temperature_call_args + ) + assert device.set_hkr_state.call_count == len(expected_set_hkr_state_call_args) + assert device.set_hkr_state.call_args_list == expected_set_hkr_state_call_args @pytest.mark.parametrize( - ("service_data", "target_temperature", "current_preset", "expected_call_args"), + ( + "service_data", + "target_temperature", + "current_preset", + "expected_set_target_temperature_call_args", + "expected_set_hkr_state_call_args", + ), [ - # mode off always sets target temperature to 0 - ({ATTR_HVAC_MODE: HVACMode.OFF}, 22, PRESET_COMFORT, [call(0, True)]), - ({ATTR_HVAC_MODE: HVACMode.OFF}, 16, PRESET_ECO, [call(0, True)]), - ({ATTR_HVAC_MODE: HVACMode.OFF}, 16, None, [call(0, True)]), + # mode off always sets hkr state off + ({ATTR_HVAC_MODE: HVACMode.OFF}, 22, PRESET_COMFORT, [], [call("off", True)]), + ({ATTR_HVAC_MODE: HVACMode.OFF}, 16, PRESET_ECO, [], [call("off", True)]), + ({ATTR_HVAC_MODE: HVACMode.OFF}, 16, None, [], [call("off", True)]), # mode heat sets target temperature based on current scheduled preset, # when not already in mode heat - ({ATTR_HVAC_MODE: HVACMode.HEAT}, 0.0, PRESET_COMFORT, [call(22, True)]), - ({ATTR_HVAC_MODE: HVACMode.HEAT}, 0.0, PRESET_ECO, [call(16, True)]), - ({ATTR_HVAC_MODE: HVACMode.HEAT}, 0.0, None, [call(22, True)]), + ( + {ATTR_HVAC_MODE: HVACMode.HEAT}, + OFF_API_TEMPERATURE, + PRESET_COMFORT, + [call(22, True)], + [], + ), + ( + {ATTR_HVAC_MODE: HVACMode.HEAT}, + OFF_API_TEMPERATURE, + PRESET_ECO, + [call(16, True)], + [], + ), + ( + {ATTR_HVAC_MODE: HVACMode.HEAT}, + OFF_API_TEMPERATURE, + None, + [call(22, True)], + [], + ), # mode heat does not set target temperature, when already in mode heat - ({ATTR_HVAC_MODE: HVACMode.HEAT}, 16, PRESET_COMFORT, []), - ({ATTR_HVAC_MODE: HVACMode.HEAT}, 16, PRESET_ECO, []), - ({ATTR_HVAC_MODE: HVACMode.HEAT}, 16, None, []), - ({ATTR_HVAC_MODE: HVACMode.HEAT}, 22, PRESET_COMFORT, []), - ({ATTR_HVAC_MODE: HVACMode.HEAT}, 22, PRESET_ECO, []), - ({ATTR_HVAC_MODE: HVACMode.HEAT}, 22, None, []), + ({ATTR_HVAC_MODE: HVACMode.HEAT}, 16, PRESET_COMFORT, [], []), + ({ATTR_HVAC_MODE: HVACMode.HEAT}, 16, PRESET_ECO, [], []), + ({ATTR_HVAC_MODE: HVACMode.HEAT}, 16, None, [], []), + ({ATTR_HVAC_MODE: HVACMode.HEAT}, 22, PRESET_COMFORT, [], []), + ({ATTR_HVAC_MODE: HVACMode.HEAT}, 22, PRESET_ECO, [], []), + ({ATTR_HVAC_MODE: HVACMode.HEAT}, 22, None, [], []), ], ) async def test_set_hvac_mode( @@ -245,7 +283,8 @@ async def test_set_hvac_mode( service_data: dict, target_temperature: float, current_preset: str, - expected_call_args: list[_Call], + expected_set_target_temperature_call_args: list[_Call], + expected_set_hkr_state_call_args: list[_Call], ) -> None: """Test setting hvac mode.""" device = FritzDeviceClimateMock() @@ -269,16 +308,23 @@ async def test_set_hvac_mode( True, ) - assert device.set_target_temperature.call_count == len(expected_call_args) - assert device.set_target_temperature.call_args_list == expected_call_args + assert device.set_target_temperature.call_count == len( + expected_set_target_temperature_call_args + ) + assert ( + device.set_target_temperature.call_args_list + == expected_set_target_temperature_call_args + ) + assert device.set_hkr_state.call_count == len(expected_set_hkr_state_call_args) + assert device.set_hkr_state.call_args_list == expected_set_hkr_state_call_args @pytest.mark.parametrize( ("comfort_temperature", "expected_call_args"), [ - (20, [call(20, True)]), - (28, [call(28, True)]), - (ON_API_TEMPERATURE, [call(30, True)]), + (20, [call("comfort", True)]), + (28, [call("comfort", True)]), + (ON_API_TEMPERATURE, [call("comfort", True)]), ], ) async def test_set_preset_mode_comfort( @@ -300,16 +346,16 @@ async def test_set_preset_mode_comfort( {ATTR_ENTITY_ID: ENTITY_ID, ATTR_PRESET_MODE: PRESET_COMFORT}, True, ) - assert device.set_target_temperature.call_count == len(expected_call_args) - assert device.set_target_temperature.call_args_list == expected_call_args + assert device.set_hkr_state.call_count == len(expected_call_args) + assert device.set_hkr_state.call_args_list == expected_call_args @pytest.mark.parametrize( ("eco_temperature", "expected_call_args"), [ - (20, [call(20, True)]), - (16, [call(16, True)]), - (OFF_API_TEMPERATURE, [call(0, True)]), + (20, [call("eco", True)]), + (16, [call("eco", True)]), + (OFF_API_TEMPERATURE, [call("eco", True)]), ], ) async def test_set_preset_mode_eco( @@ -331,8 +377,8 @@ async def test_set_preset_mode_eco( {ATTR_ENTITY_ID: ENTITY_ID, ATTR_PRESET_MODE: PRESET_ECO}, True, ) - assert device.set_target_temperature.call_count == len(expected_call_args) - assert device.set_target_temperature.call_args_list == expected_call_args + assert device.set_hkr_state.call_count == len(expected_call_args) + assert device.set_hkr_state.call_args_list == expected_call_args async def test_set_preset_mode_boost( @@ -351,8 +397,8 @@ async def test_set_preset_mode_boost( {ATTR_ENTITY_ID: ENTITY_ID, ATTR_PRESET_MODE: PRESET_BOOST}, True, ) - assert device.set_target_temperature.call_count == 1 - assert device.set_target_temperature.call_args_list == [call(30, True)] + assert device.set_hkr_state.call_count == 1 + assert device.set_hkr_state.call_args_list == [call("on", True)] async def test_preset_mode_update(hass: HomeAssistant, fritz: Mock) -> None: diff --git a/tests/components/govee_light_local/test_light.py b/tests/components/govee_light_local/test_light.py index c5dde6a9b9e..40748c0598e 100644 --- a/tests/components/govee_light_local/test_light.py +++ b/tests/components/govee_light_local/test_light.py @@ -1,13 +1,24 @@ """Test Govee light local.""" from errno import EADDRINUSE, ENETDOWN -from unittest.mock import AsyncMock, MagicMock, patch +from unittest.mock import AsyncMock, MagicMock, call, patch from govee_local_api import GoveeDevice +import pytest from homeassistant.components.govee_light_local.const import DOMAIN -from homeassistant.components.light import ATTR_SUPPORTED_COLOR_MODES, ColorMode +from homeassistant.components.light import ( + ATTR_BRIGHTNESS, + ATTR_BRIGHTNESS_PCT, + ATTR_COLOR_TEMP_KELVIN, + ATTR_EFFECT, + ATTR_RGB_COLOR, + ATTR_SUPPORTED_COLOR_MODES, + DOMAIN as LIGHT_DOMAIN, + ColorMode, +) from homeassistant.config_entries import ConfigEntryState +from homeassistant.const import SERVICE_TURN_OFF, SERVICE_TURN_ON from homeassistant.core import HomeAssistant from .conftest import DEFAULT_CAPABILITIES, SCENE_CAPABILITIES @@ -197,8 +208,8 @@ async def test_light_on_off(hass: HomeAssistant, mock_govee_api: MagicMock) -> N assert light.state == "off" await hass.services.async_call( - "light", - "turn_on", + LIGHT_DOMAIN, + SERVICE_TURN_ON, {"entity_id": light.entity_id}, blocking=True, ) @@ -211,8 +222,8 @@ async def test_light_on_off(hass: HomeAssistant, mock_govee_api: MagicMock) -> N # Turn off await hass.services.async_call( - "light", - "turn_off", + LIGHT_DOMAIN, + SERVICE_TURN_OFF, {"entity_id": light.entity_id}, blocking=True, ) @@ -224,6 +235,77 @@ async def test_light_on_off(hass: HomeAssistant, mock_govee_api: MagicMock) -> N mock_govee_api.turn_on_off.assert_awaited_with(mock_govee_api.devices[0], False) +@pytest.mark.parametrize( + ("attribute", "value", "mock_call", "mock_call_args", "mock_call_kwargs"), + [ + ( + ATTR_RGB_COLOR, + [100, 255, 50], + "set_color", + [], + {"temperature": None, "rgb": (100, 255, 50)}, + ), + ( + ATTR_COLOR_TEMP_KELVIN, + 4400, + "set_color", + [], + {"temperature": 4400, "rgb": None}, + ), + (ATTR_EFFECT, "sunrise", "set_scene", ["sunrise"], {}), + ], +) +async def test_turn_on_call_order( + hass: HomeAssistant, + mock_govee_api: MagicMock, + attribute: str, + value: str | int | list[int], + mock_call: str, + mock_call_args: list[str], + mock_call_kwargs: dict[str, any], +) -> None: + """Test that turn_on is called after set_brightness/set_color/set_preset.""" + mock_govee_api.devices = [ + GoveeDevice( + controller=mock_govee_api, + ip="192.168.1.100", + fingerprint="asdawdqwdqwd", + sku="H615A", + capabilities=SCENE_CAPABILITIES, + ) + ] + + entry = MockConfigEntry(domain=DOMAIN) + entry.add_to_hass(hass) + + assert await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + assert len(hass.states.async_all()) == 1 + + light = hass.states.get("light.H615A") + assert light is not None + assert light.state == "off" + + await hass.services.async_call( + LIGHT_DOMAIN, + SERVICE_TURN_ON, + {"entity_id": light.entity_id, ATTR_BRIGHTNESS_PCT: 50, attribute: value}, + blocking=True, + ) + await hass.async_block_till_done() + + mock_govee_api.assert_has_calls( + [ + call.set_brightness(mock_govee_api.devices[0], 50), + getattr(call, mock_call)( + mock_govee_api.devices[0], *mock_call_args, **mock_call_kwargs + ), + call.turn_on_off(mock_govee_api.devices[0], True), + ] + ) + + async def test_light_brightness(hass: HomeAssistant, mock_govee_api: MagicMock) -> None: """Test changing brightness.""" mock_govee_api.devices = [ @@ -249,8 +331,8 @@ async def test_light_brightness(hass: HomeAssistant, mock_govee_api: MagicMock) assert light.state == "off" await hass.services.async_call( - "light", - "turn_on", + LIGHT_DOMAIN, + SERVICE_TURN_ON, {"entity_id": light.entity_id, "brightness_pct": 50}, blocking=True, ) @@ -260,12 +342,12 @@ async def test_light_brightness(hass: HomeAssistant, mock_govee_api: MagicMock) assert light is not None assert light.state == "on" mock_govee_api.set_brightness.assert_awaited_with(mock_govee_api.devices[0], 50) - assert light.attributes["brightness"] == 127 + assert light.attributes[ATTR_BRIGHTNESS] == 127 await hass.services.async_call( - "light", - "turn_on", - {"entity_id": light.entity_id, "brightness": 255}, + LIGHT_DOMAIN, + SERVICE_TURN_ON, + {"entity_id": light.entity_id, ATTR_BRIGHTNESS: 255}, blocking=True, ) await hass.async_block_till_done() @@ -273,13 +355,13 @@ async def test_light_brightness(hass: HomeAssistant, mock_govee_api: MagicMock) light = hass.states.get("light.H615A") assert light is not None assert light.state == "on" - assert light.attributes["brightness"] == 255 + assert light.attributes[ATTR_BRIGHTNESS] == 255 mock_govee_api.set_brightness.assert_awaited_with(mock_govee_api.devices[0], 100) await hass.services.async_call( - "light", - "turn_on", - {"entity_id": light.entity_id, "brightness": 255}, + LIGHT_DOMAIN, + SERVICE_TURN_ON, + {"entity_id": light.entity_id, ATTR_BRIGHTNESS: 255}, blocking=True, ) await hass.async_block_till_done() @@ -287,7 +369,7 @@ async def test_light_brightness(hass: HomeAssistant, mock_govee_api: MagicMock) light = hass.states.get("light.H615A") assert light is not None assert light.state == "on" - assert light.attributes["brightness"] == 255 + assert light.attributes[ATTR_BRIGHTNESS] == 255 mock_govee_api.set_brightness.assert_awaited_with(mock_govee_api.devices[0], 100) @@ -316,9 +398,9 @@ async def test_light_color(hass: HomeAssistant, mock_govee_api: MagicMock) -> No assert light.state == "off" await hass.services.async_call( - "light", - "turn_on", - {"entity_id": light.entity_id, "rgb_color": [100, 255, 50]}, + LIGHT_DOMAIN, + SERVICE_TURN_ON, + {"entity_id": light.entity_id, ATTR_RGB_COLOR: [100, 255, 50]}, blocking=True, ) await hass.async_block_till_done() @@ -326,7 +408,7 @@ async def test_light_color(hass: HomeAssistant, mock_govee_api: MagicMock) -> No light = hass.states.get("light.H615A") assert light is not None assert light.state == "on" - assert light.attributes["rgb_color"] == (100, 255, 50) + assert light.attributes[ATTR_RGB_COLOR] == (100, 255, 50) assert light.attributes["color_mode"] == ColorMode.RGB mock_govee_api.set_color.assert_awaited_with( @@ -334,8 +416,8 @@ async def test_light_color(hass: HomeAssistant, mock_govee_api: MagicMock) -> No ) await hass.services.async_call( - "light", - "turn_on", + LIGHT_DOMAIN, + SERVICE_TURN_ON, {"entity_id": light.entity_id, "kelvin": 4400}, blocking=True, ) @@ -378,9 +460,9 @@ async def test_scene_on(hass: HomeAssistant, mock_govee_api: MagicMock) -> None: assert light.state == "off" await hass.services.async_call( - "light", - "turn_on", - {"entity_id": light.entity_id, "effect": "sunrise"}, + LIGHT_DOMAIN, + SERVICE_TURN_ON, + {"entity_id": light.entity_id, ATTR_EFFECT: "sunrise"}, blocking=True, ) await hass.async_block_till_done() @@ -388,7 +470,7 @@ async def test_scene_on(hass: HomeAssistant, mock_govee_api: MagicMock) -> None: light = hass.states.get("light.H615A") assert light is not None assert light.state == "on" - assert light.attributes["effect"] == "sunrise" + assert light.attributes[ATTR_EFFECT] == "sunrise" mock_govee_api.turn_on_off.assert_awaited_with(mock_govee_api.devices[0], True) @@ -422,16 +504,16 @@ async def test_scene_restore_rgb( # Set initial color await hass.services.async_call( - "light", - "turn_on", - {"entity_id": light.entity_id, "rgb_color": initial_color}, + LIGHT_DOMAIN, + SERVICE_TURN_ON, + {"entity_id": light.entity_id, ATTR_RGB_COLOR: initial_color}, blocking=True, ) await hass.async_block_till_done() await hass.services.async_call( - "light", - "turn_on", - {"entity_id": light.entity_id, "brightness": 255}, + LIGHT_DOMAIN, + SERVICE_TURN_ON, + {"entity_id": light.entity_id, ATTR_BRIGHTNESS: 255}, blocking=True, ) await hass.async_block_till_done() @@ -439,15 +521,15 @@ async def test_scene_restore_rgb( light = hass.states.get("light.H615A") assert light is not None assert light.state == "on" - assert light.attributes["rgb_color"] == initial_color - assert light.attributes["brightness"] == 255 + assert light.attributes[ATTR_RGB_COLOR] == initial_color + assert light.attributes[ATTR_BRIGHTNESS] == 255 mock_govee_api.turn_on_off.assert_awaited_with(mock_govee_api.devices[0], True) # Activate scene await hass.services.async_call( - "light", - "turn_on", - {"entity_id": light.entity_id, "effect": "sunrise"}, + LIGHT_DOMAIN, + SERVICE_TURN_ON, + {"entity_id": light.entity_id, ATTR_EFFECT: "sunrise"}, blocking=True, ) await hass.async_block_till_done() @@ -455,14 +537,14 @@ async def test_scene_restore_rgb( light = hass.states.get("light.H615A") assert light is not None assert light.state == "on" - assert light.attributes["effect"] == "sunrise" + assert light.attributes[ATTR_EFFECT] == "sunrise" mock_govee_api.turn_on_off.assert_awaited_with(mock_govee_api.devices[0], True) # Deactivate scene await hass.services.async_call( - "light", - "turn_on", - {"entity_id": light.entity_id, "effect": "none"}, + LIGHT_DOMAIN, + SERVICE_TURN_ON, + {"entity_id": light.entity_id, ATTR_EFFECT: "none"}, blocking=True, ) await hass.async_block_till_done() @@ -470,9 +552,9 @@ async def test_scene_restore_rgb( light = hass.states.get("light.H615A") assert light is not None assert light.state == "on" - assert light.attributes["effect"] is None - assert light.attributes["rgb_color"] == initial_color - assert light.attributes["brightness"] == 255 + assert light.attributes[ATTR_EFFECT] is None + assert light.attributes[ATTR_RGB_COLOR] == initial_color + assert light.attributes[ATTR_BRIGHTNESS] == 255 async def test_scene_restore_temperature( @@ -505,8 +587,8 @@ async def test_scene_restore_temperature( # Set initial color await hass.services.async_call( - "light", - "turn_on", + LIGHT_DOMAIN, + SERVICE_TURN_ON, {"entity_id": light.entity_id, "color_temp_kelvin": initial_color}, blocking=True, ) @@ -520,9 +602,9 @@ async def test_scene_restore_temperature( # Activate scene await hass.services.async_call( - "light", - "turn_on", - {"entity_id": light.entity_id, "effect": "sunrise"}, + LIGHT_DOMAIN, + SERVICE_TURN_ON, + {"entity_id": light.entity_id, ATTR_EFFECT: "sunrise"}, blocking=True, ) await hass.async_block_till_done() @@ -530,14 +612,14 @@ async def test_scene_restore_temperature( light = hass.states.get("light.H615A") assert light is not None assert light.state == "on" - assert light.attributes["effect"] == "sunrise" + assert light.attributes[ATTR_EFFECT] == "sunrise" mock_govee_api.set_scene.assert_awaited_with(mock_govee_api.devices[0], "sunrise") # Deactivate scene await hass.services.async_call( - "light", - "turn_on", - {"entity_id": light.entity_id, "effect": "none"}, + LIGHT_DOMAIN, + SERVICE_TURN_ON, + {"entity_id": light.entity_id, ATTR_EFFECT: "none"}, blocking=True, ) await hass.async_block_till_done() @@ -545,7 +627,7 @@ async def test_scene_restore_temperature( light = hass.states.get("light.H615A") assert light is not None assert light.state == "on" - assert light.attributes["effect"] is None + assert light.attributes[ATTR_EFFECT] is None assert light.attributes["color_temp_kelvin"] == initial_color @@ -577,16 +659,16 @@ async def test_scene_none(hass: HomeAssistant, mock_govee_api: MagicMock) -> Non # Set initial color await hass.services.async_call( - "light", - "turn_on", - {"entity_id": light.entity_id, "rgb_color": initial_color}, + LIGHT_DOMAIN, + SERVICE_TURN_ON, + {"entity_id": light.entity_id, ATTR_RGB_COLOR: initial_color}, blocking=True, ) await hass.async_block_till_done() await hass.services.async_call( - "light", - "turn_on", - {"entity_id": light.entity_id, "brightness": 255}, + LIGHT_DOMAIN, + SERVICE_TURN_ON, + {"entity_id": light.entity_id, ATTR_BRIGHTNESS: 255}, blocking=True, ) await hass.async_block_till_done() @@ -594,21 +676,20 @@ async def test_scene_none(hass: HomeAssistant, mock_govee_api: MagicMock) -> Non light = hass.states.get("light.H615A") assert light is not None assert light.state == "on" - assert light.attributes["rgb_color"] == initial_color - assert light.attributes["brightness"] == 255 + assert light.attributes[ATTR_RGB_COLOR] == initial_color + assert light.attributes[ATTR_BRIGHTNESS] == 255 mock_govee_api.turn_on_off.assert_awaited_with(mock_govee_api.devices[0], True) # Activate scene await hass.services.async_call( - "light", - "turn_on", - {"entity_id": light.entity_id, "effect": "none"}, + LIGHT_DOMAIN, + SERVICE_TURN_ON, + {"entity_id": light.entity_id, ATTR_EFFECT: "none"}, blocking=True, ) await hass.async_block_till_done() - light = hass.states.get("light.H615A") assert light is not None assert light.state == "on" - assert light.attributes["effect"] is None + assert light.attributes[ATTR_EFFECT] is None mock_govee_api.set_scene.assert_not_called() diff --git a/tests/components/group/test_config_flow.py b/tests/components/group/test_config_flow.py index 461df19ebf8..30adae2fd2a 100644 --- a/tests/components/group/test_config_flow.py +++ b/tests/components/group/test_config_flow.py @@ -12,7 +12,7 @@ from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType from homeassistant.helpers import entity_registry as er -from tests.common import MockConfigEntry +from tests.common import MockConfigEntry, get_schema_suggested_value from tests.typing import WebSocketGenerator @@ -201,17 +201,6 @@ async def test_config_flow_hides_members( assert entity_registry.async_get(f"{group_type}.three").hidden_by == hidden_by -def get_suggested(schema, key): - """Get suggested value for key in voluptuous schema.""" - for k in schema: - if k == key: - if k.description is None or "suggested_value" not in k.description: - return None - return k.description["suggested_value"] - # Wanted key absent from schema - raise KeyError("Wanted key absent from schema") - - @pytest.mark.parametrize( ("group_type", "member_state", "extra_options", "options_options"), [ @@ -269,7 +258,9 @@ async def test_options( result = await hass.config_entries.options.async_init(config_entry.entry_id) assert result["type"] is FlowResultType.FORM assert result["step_id"] == group_type - assert get_suggested(result["data_schema"].schema, "entities") == members1 + assert ( + get_schema_suggested_value(result["data_schema"].schema, "entities") == members1 + ) assert "name" not in result["data_schema"].schema assert result["data_schema"].schema["entities"].config["exclude_entities"] == [ f"{group_type}.bed_room" @@ -316,8 +307,8 @@ async def test_options( assert result["type"] is FlowResultType.FORM assert result["step_id"] == group_type - assert get_suggested(result["data_schema"].schema, "entities") is None - assert get_suggested(result["data_schema"].schema, "name") is None + assert get_schema_suggested_value(result["data_schema"].schema, "entities") is None + assert get_schema_suggested_value(result["data_schema"].schema, "name") is None @pytest.mark.parametrize( diff --git a/tests/components/habitica/test_config_flow.py b/tests/components/habitica/test_config_flow.py index 07678b031bc..5ec998ec82e 100644 --- a/tests/components/habitica/test_config_flow.py +++ b/tests/components/habitica/test_config_flow.py @@ -76,8 +76,9 @@ async def test_form_login(hass: HomeAssistant, mock_setup_entry: AsyncMock) -> N assert "login" in result["menu_options"] assert result["step_id"] == "user" - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": "login"} + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {"next_step_id": "login"}, ) assert result["type"] is FlowResultType.FORM assert result["errors"] == {} @@ -123,8 +124,9 @@ async def test_form_login_errors( assert result["type"] is FlowResultType.MENU assert result["step_id"] == "user" - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": "login"} + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {"next_step_id": "login"}, ) habitica.login.side_effect = raise_error @@ -156,7 +158,7 @@ async def test_form_login_errors( @pytest.mark.usefixtures("habitica") -async def test_form__already_configured( +async def test_form_already_configured( hass: HomeAssistant, config_entry: MockConfigEntry, ) -> None: @@ -171,13 +173,14 @@ async def test_form__already_configured( assert result["type"] is FlowResultType.MENU assert result["step_id"] == "user" - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": "advanced"} + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {"next_step_id": "login"}, ) result = await hass.config_entries.flow.async_configure( result["flow_id"], - user_input=MOCK_DATA_ADVANCED_STEP, + user_input=MOCK_DATA_LOGIN_STEP, ) assert result["type"] is FlowResultType.ABORT @@ -196,19 +199,14 @@ async def test_form_advanced(hass: HomeAssistant, mock_setup_entry: AsyncMock) - assert "advanced" in result["menu_options"] assert result["step_id"] == "user" - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": "advanced"} + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {"next_step_id": "advanced"}, ) assert result["type"] is FlowResultType.FORM assert result["errors"] == {} assert result["step_id"] == "advanced" - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": "advanced"} - ) - assert result["type"] is FlowResultType.FORM - assert result["errors"] == {} - result = await hass.config_entries.flow.async_configure( result["flow_id"], user_input=MOCK_DATA_ADVANCED_STEP, @@ -249,8 +247,9 @@ async def test_form_advanced_errors( assert result["type"] is FlowResultType.MENU assert result["step_id"] == "user" - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": "advanced"} + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {"next_step_id": "advanced"}, ) habitica.get_user.side_effect = raise_error @@ -298,8 +297,9 @@ async def test_form_advanced_already_configured( assert result["type"] is FlowResultType.MENU assert result["step_id"] == "user" - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": "advanced"} + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {"next_step_id": "advanced"}, ) result = await hass.config_entries.flow.async_configure( diff --git a/tests/components/hassio/test_init.py b/tests/components/hassio/test_init.py index 48c09d2feed..e6699cfe68e 100644 --- a/tests/components/hassio/test_init.py +++ b/tests/components/hassio/test_init.py @@ -26,7 +26,7 @@ from homeassistant.components.hassio.config import STORAGE_KEY from homeassistant.components.hassio.const import REQUEST_REFRESH_DELAY from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN from homeassistant.core import HomeAssistant -from homeassistant.helpers import device_registry as dr, issue_registry as ir +from homeassistant.helpers import device_registry as dr from homeassistant.helpers.hassio import is_hassio from homeassistant.helpers.service_info.hassio import HassioServiceInfo from homeassistant.setup import async_setup_component @@ -473,7 +473,6 @@ async def test_service_register(hass: HomeAssistant) -> None: assert hass.services.has_service("hassio", "addon_start") assert hass.services.has_service("hassio", "addon_stop") assert hass.services.has_service("hassio", "addon_restart") - assert hass.services.has_service("hassio", "addon_update") assert hass.services.has_service("hassio", "addon_stdin") assert hass.services.has_service("hassio", "host_shutdown") assert hass.services.has_service("hassio", "host_reboot") @@ -492,7 +491,6 @@ async def test_service_calls( supervisor_client: AsyncMock, addon_installed: AsyncMock, supervisor_is_connected: AsyncMock, - issue_registry: ir.IssueRegistry, ) -> None: """Call service and check the API calls behind that.""" supervisor_is_connected.side_effect = SupervisorError @@ -519,21 +517,19 @@ async def test_service_calls( await hass.services.async_call("hassio", "addon_start", {"addon": "test"}) await hass.services.async_call("hassio", "addon_stop", {"addon": "test"}) await hass.services.async_call("hassio", "addon_restart", {"addon": "test"}) - await hass.services.async_call("hassio", "addon_update", {"addon": "test"}) - assert (DOMAIN, "update_service_deprecated") in issue_registry.issues await hass.services.async_call( "hassio", "addon_stdin", {"addon": "test", "input": "test"} ) await hass.async_block_till_done() - assert aioclient_mock.call_count + len(supervisor_client.mock_calls) == 25 + assert aioclient_mock.call_count + len(supervisor_client.mock_calls) == 24 assert aioclient_mock.mock_calls[-1][2] == "test" await hass.services.async_call("hassio", "host_shutdown", {}) await hass.services.async_call("hassio", "host_reboot", {}) await hass.async_block_till_done() - assert aioclient_mock.call_count + len(supervisor_client.mock_calls) == 27 + assert aioclient_mock.call_count + len(supervisor_client.mock_calls) == 26 await hass.services.async_call("hassio", "backup_full", {}) await hass.services.async_call( @@ -548,7 +544,7 @@ async def test_service_calls( ) await hass.async_block_till_done() - assert aioclient_mock.call_count + len(supervisor_client.mock_calls) == 29 + assert aioclient_mock.call_count + len(supervisor_client.mock_calls) == 28 assert aioclient_mock.mock_calls[-1][2] == { "name": "2021-11-13 03:48:00", "homeassistant": True, @@ -573,7 +569,7 @@ async def test_service_calls( ) await hass.async_block_till_done() - assert aioclient_mock.call_count + len(supervisor_client.mock_calls) == 31 + assert aioclient_mock.call_count + len(supervisor_client.mock_calls) == 30 assert aioclient_mock.mock_calls[-1][2] == { "addons": ["test"], "folders": ["ssl"], @@ -592,7 +588,7 @@ async def test_service_calls( ) await hass.async_block_till_done() - assert aioclient_mock.call_count + len(supervisor_client.mock_calls) == 32 + assert aioclient_mock.call_count + len(supervisor_client.mock_calls) == 31 assert aioclient_mock.mock_calls[-1][2] == { "name": "backup_name", "location": "backup_share", @@ -608,7 +604,7 @@ async def test_service_calls( ) await hass.async_block_till_done() - assert aioclient_mock.call_count + len(supervisor_client.mock_calls) == 33 + assert aioclient_mock.call_count + len(supervisor_client.mock_calls) == 32 assert aioclient_mock.mock_calls[-1][2] == { "name": "2021-11-13 03:48:00", "location": None, @@ -627,7 +623,7 @@ async def test_service_calls( ) await hass.async_block_till_done() - assert aioclient_mock.call_count + len(supervisor_client.mock_calls) == 35 + assert aioclient_mock.call_count + len(supervisor_client.mock_calls) == 34 assert aioclient_mock.mock_calls[-1][2] == { "name": "2021-11-13 11:48:00", "location": None, diff --git a/tests/components/history_stats/test_sensor.py b/tests/components/history_stats/test_sensor.py index e2dba1b9355..ee426cf3048 100644 --- a/tests/components/history_stats/test_sensor.py +++ b/tests/components/history_stats/test_sensor.py @@ -969,6 +969,135 @@ async def test_async_start_from_history_and_switch_to_watching_state_changes_mul assert hass.states.get("sensor.sensor4").state == "87.5" +async def test_start_from_history_then_watch_state_changes_sliding( + recorder_mock: Recorder, + hass: HomeAssistant, +) -> None: + """Test we startup from history and switch to watching state changes. + + With a sliding window, history_stats does not requery the recorder. + """ + await hass.config.async_set_time_zone("UTC") + utcnow = dt_util.utcnow() + start_time = utcnow.replace(hour=0, minute=0, second=0, microsecond=0) + time = start_time + + def _fake_states(*args, **kwargs): + return { + "binary_sensor.state": [ + ha.State( + "binary_sensor.state", + "off", + last_changed=start_time - timedelta(hours=1), + last_updated=start_time - timedelta(hours=1), + ), + ] + } + + with ( + patch( + "homeassistant.components.recorder.history.state_changes_during_period", + _fake_states, + ), + freeze_time(start_time), + ): + await async_setup_component( + hass, + "sensor", + { + "sensor": [ + { + "platform": "history_stats", + "entity_id": "binary_sensor.state", + "name": f"sensor{i}", + "state": "on", + "end": "{{ utcnow() }}", + "duration": {"hours": 1}, + "type": sensor_type, + } + for i, sensor_type in enumerate(["time", "ratio", "count"]) + ] + }, + ) + await hass.async_block_till_done() + + for i in range(3): + await async_update_entity(hass, f"sensor.sensor{i}") + await hass.async_block_till_done() + + assert hass.states.get("sensor.sensor0").state == "0.0" + assert hass.states.get("sensor.sensor1").state == "0.0" + assert hass.states.get("sensor.sensor2").state == "0" + + with freeze_time(time): + hass.states.async_set("binary_sensor.state", "on") + await hass.async_block_till_done() + async_fire_time_changed(hass, time) + await hass.async_block_till_done() + + assert hass.states.get("sensor.sensor0").state == "0.0" + assert hass.states.get("sensor.sensor1").state == "0.0" + assert hass.states.get("sensor.sensor2").state == "1" + + # After sensor has been on for 15 minutes, check state + time += timedelta(minutes=15) # 00:15 + with freeze_time(time): + async_fire_time_changed(hass, time) + await hass.async_block_till_done() + + assert hass.states.get("sensor.sensor0").state == "0.25" + assert hass.states.get("sensor.sensor1").state == "25.0" + assert hass.states.get("sensor.sensor2").state == "1" + + with freeze_time(time): + hass.states.async_set("binary_sensor.state", "off") + await hass.async_block_till_done() + async_fire_time_changed(hass, time) + await hass.async_block_till_done() + + time += timedelta(minutes=30) # 00:45 + + with freeze_time(time): + async_fire_time_changed(hass, time) + await hass.async_block_till_done() + + assert hass.states.get("sensor.sensor0").state == "0.25" + assert hass.states.get("sensor.sensor1").state == "25.0" + assert hass.states.get("sensor.sensor2").state == "1" + + time += timedelta(minutes=20) # 01:05 + + with freeze_time(time): + async_fire_time_changed(hass, time) + await hass.async_block_till_done() + + # Sliding window will have started to erase the initial on period, so now it will only be on for 10 minutes + assert hass.states.get("sensor.sensor0").state == "0.17" + assert hass.states.get("sensor.sensor1").state == "16.7" + assert hass.states.get("sensor.sensor2").state == "1" + + time += timedelta(minutes=5) # 01:10 + + with freeze_time(time): + async_fire_time_changed(hass, time) + await hass.async_block_till_done() + + # Sliding window will continue to erase the initial on period, so now it will only be on for 5 minutes + assert hass.states.get("sensor.sensor0").state == "0.08" + assert hass.states.get("sensor.sensor1").state == "8.3" + assert hass.states.get("sensor.sensor2").state == "1" + + time += timedelta(minutes=10) # 01:20 + + with freeze_time(time): + async_fire_time_changed(hass, time) + await hass.async_block_till_done() + + assert hass.states.get("sensor.sensor0").state == "0.0" + assert hass.states.get("sensor.sensor1").state == "0.0" + assert hass.states.get("sensor.sensor2").state == "0" + + async def test_does_not_work_into_the_future( recorder_mock: Recorder, hass: HomeAssistant ) -> None: @@ -1366,10 +1495,6 @@ async def test_measure_from_end_going_backwards( past_next_update = start_time + timedelta(minutes=30) with ( - patch( - "homeassistant.components.recorder.history.state_changes_during_period", - _fake_states, - ), freeze_time(past_next_update), ): async_fire_time_changed(hass, past_next_update) @@ -1526,29 +1651,10 @@ async def test_state_change_during_window_rollover( assert hass.states.get("sensor.sensor1").state == "11.98" - # One minute has passed and the time has now rolled over into a new day, resetting the recorder window. The sensor will then query the database for updates, - # and will see that the sensor is ON starting from midnight. + # One minute has passed and the time has now rolled over into a new day, resetting the recorder window. + # The sensor will be ON since midnight. t3 = t2 + timedelta(minutes=1) - - def _fake_states_t3(*args, **kwargs): - return { - "binary_sensor.state": [ - ha.State( - "binary_sensor.state", - "on", - last_changed=t3.replace(hour=0, minute=0, second=0, microsecond=0), - last_updated=t3.replace(hour=0, minute=0, second=0, microsecond=0), - ), - ] - } - - with ( - patch( - "homeassistant.components.recorder.history.state_changes_during_period", - _fake_states_t3, - ), - freeze_time(t3), - ): + with freeze_time(t3): # The sensor turns off around this time, before the sensor does its normal polled update. hass.states.async_set("binary_sensor.state", "off") await hass.async_block_till_done(wait_background_tasks=True) diff --git a/tests/components/home_connect/test_coordinator.py b/tests/components/home_connect/test_coordinator.py index a74c4199318..31bb6d8d6a7 100644 --- a/tests/components/home_connect/test_coordinator.py +++ b/tests/components/home_connect/test_coordinator.py @@ -651,7 +651,7 @@ async def test_coordinator_disabling_updates_for_appliance( EventType.CONNECTED, data=ArrayOfEvents([]), ) - for _ in range(5) + for _ in range(8) ] ) await hass.async_block_till_done() @@ -744,7 +744,7 @@ async def test_coordinator_disabling_updates_for_appliance_is_gone_after_entry_r EventType.CONNECTED, data=ArrayOfEvents([]), ) - for _ in range(5) + for _ in range(8) ] ) await hass.async_block_till_done() diff --git a/tests/components/homee/fixtures/numbers.json b/tests/components/homee/fixtures/numbers.json index c8773a89568..fd00ca4b5bd 100644 --- a/tests/components/homee/fixtures/numbers.json +++ b/tests/components/homee/fixtures/numbers.json @@ -19,7 +19,7 @@ "security": 0, "attributes": [ { - "id": 2, + "id": 1, "node_id": 1, "instance": 0, "minimum": 0, @@ -40,7 +40,7 @@ "name": "" }, { - "id": 3, + "id": 2, "node_id": 1, "instance": 0, "minimum": -75, @@ -61,7 +61,7 @@ "name": "" }, { - "id": 4, + "id": 3, "node_id": 1, "instance": 0, "minimum": 4, @@ -82,7 +82,7 @@ "name": "" }, { - "id": 5, + "id": 4, "node_id": 1, "instance": 0, "minimum": 0, @@ -103,7 +103,7 @@ "name": "" }, { - "id": 6, + "id": 5, "node_id": 1, "instance": 0, "minimum": 1, @@ -124,7 +124,7 @@ "name": "" }, { - "id": 7, + "id": 6, "node_id": 1, "instance": 0, "minimum": 0, @@ -145,7 +145,7 @@ "name": "" }, { - "id": 8, + "id": 7, "node_id": 1, "instance": 0, "minimum": 5, @@ -166,7 +166,7 @@ "name": "" }, { - "id": 9, + "id": 8, "node_id": 1, "instance": 0, "minimum": 0, @@ -187,7 +187,7 @@ "name": "" }, { - "id": 10, + "id": 9, "node_id": 1, "instance": 0, "minimum": -127, @@ -208,7 +208,7 @@ "name": "" }, { - "id": 11, + "id": 10, "node_id": 1, "instance": 0, "minimum": -127, @@ -229,7 +229,7 @@ "name": "" }, { - "id": 12, + "id": 11, "node_id": 1, "instance": 0, "minimum": 1, @@ -250,7 +250,7 @@ "name": "" }, { - "id": 13, + "id": 12, "node_id": 1, "instance": 0, "minimum": -5, @@ -271,7 +271,7 @@ "name": "" }, { - "id": 14, + "id": 13, "node_id": 1, "instance": 0, "minimum": 4, @@ -292,7 +292,7 @@ "name": "" }, { - "id": 15, + "id": 14, "node_id": 1, "instance": 0, "minimum": 30, @@ -313,7 +313,7 @@ "name": "" }, { - "id": 16, + "id": 15, "node_id": 1, "instance": 0, "minimum": 0, @@ -332,6 +332,27 @@ "based_on": 1, "data": "fixed_value", "name": "" + }, + { + "id": 16, + "node_id": 1, + "instance": 0, + "minimum": 0, + "maximum": 9, + "current_value": 2.0, + "target_value": 2.0, + "last_value": 2.0, + "unit": "n/a", + "step_value": 1.0, + "editable": 1, + "type": 338, + "state": 1, + "last_changed": 1684668852, + "changed_by": 1, + "changed_by_id": 0, + "based_on": 1, + "data": "", + "name": "" } ] } diff --git a/tests/components/homee/snapshots/test_number.ambr b/tests/components/homee/snapshots/test_number.ambr index 04b1aefab00..1fa2e0ef697 100644 --- a/tests/components/homee/snapshots/test_number.ambr +++ b/tests/components/homee/snapshots/test_number.ambr @@ -34,7 +34,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'down_time', - 'unique_id': '00055511EECC-1-4', + 'unique_id': '00055511EECC-1-3', 'unit_of_measurement': , }) # --- @@ -54,7 +54,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '57', + 'state': '57.0', }) # --- # name: test_number_snapshot[number.test_number_down_position-entry] @@ -92,7 +92,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'down_position', - 'unique_id': '00055511EECC-1-2', + 'unique_id': '00055511EECC-1-1', 'unit_of_measurement': '%', }) # --- @@ -111,7 +111,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '100', + 'state': '100.0', }) # --- # name: test_number_snapshot[number.test_number_down_slat_position-entry] @@ -149,7 +149,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'down_slat_position', - 'unique_id': '00055511EECC-1-3', + 'unique_id': '00055511EECC-1-2', 'unit_of_measurement': '°', }) # --- @@ -168,7 +168,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '38', + 'state': '38.0', }) # --- # name: test_number_snapshot[number.test_number_end_position-entry] @@ -206,7 +206,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'endposition_configuration', - 'unique_id': '00055511EECC-1-5', + 'unique_id': '00055511EECC-1-4', 'unit_of_measurement': None, }) # --- @@ -224,7 +224,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '129', + 'state': '129.0', }) # --- # name: test_number_snapshot[number.test_number_maximum_slat_angle-entry] @@ -262,7 +262,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'slat_max_angle', - 'unique_id': '00055511EECC-1-10', + 'unique_id': '00055511EECC-1-9', 'unit_of_measurement': '°', }) # --- @@ -281,7 +281,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '75', + 'state': '75.0', }) # --- # name: test_number_snapshot[number.test_number_minimum_slat_angle-entry] @@ -319,7 +319,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'slat_min_angle', - 'unique_id': '00055511EECC-1-11', + 'unique_id': '00055511EECC-1-10', 'unit_of_measurement': '°', }) # --- @@ -338,7 +338,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '-75', + 'state': '-75.0', }) # --- # name: test_number_snapshot[number.test_number_motion_alarm_delay-entry] @@ -376,7 +376,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'motion_alarm_cancelation_delay', - 'unique_id': '00055511EECC-1-6', + 'unique_id': '00055511EECC-1-5', 'unit_of_measurement': , }) # --- @@ -434,7 +434,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'polling_interval', - 'unique_id': '00055511EECC-1-8', + 'unique_id': '00055511EECC-1-7', 'unit_of_measurement': , }) # --- @@ -454,7 +454,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '30', + 'state': '30.0', }) # --- # name: test_number_snapshot[number.test_number_slat_steps-entry] @@ -492,7 +492,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'slat_steps', - 'unique_id': '00055511EECC-1-12', + 'unique_id': '00055511EECC-1-11', 'unit_of_measurement': None, }) # --- @@ -510,7 +510,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '6', + 'state': '6.0', }) # --- # name: test_number_snapshot[number.test_number_slat_turn_duration-entry] @@ -548,7 +548,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'shutter_slat_time', - 'unique_id': '00055511EECC-1-9', + 'unique_id': '00055511EECC-1-8', 'unit_of_measurement': , }) # --- @@ -568,7 +568,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '1', + 'state': '1.6', }) # --- # name: test_number_snapshot[number.test_number_temperature_offset-entry] @@ -606,7 +606,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'temperature_offset', - 'unique_id': '00055511EECC-1-13', + 'unique_id': '00055511EECC-1-12', 'unit_of_measurement': , }) # --- @@ -628,6 +628,64 @@ 'state': 'unavailable', }) # --- +# name: test_number_snapshot[number.test_number_threshold_for_wind_trigger-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max': 22.5, + 'min': 0, + 'mode': , + 'step': 2.5, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': , + 'entity_id': 'number.test_number_threshold_for_wind_trigger', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Threshold for wind trigger', + 'platform': 'homee', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'wind_monitoring_state', + 'unique_id': '00055511EECC-1-16', + 'unit_of_measurement': , + }) +# --- +# name: test_number_snapshot[number.test_number_threshold_for_wind_trigger-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'wind_speed', + 'friendly_name': 'Test Number Threshold for wind trigger', + 'max': 22.5, + 'min': 0, + 'mode': , + 'step': 2.5, + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'number.test_number_threshold_for_wind_trigger', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '5.0', + }) +# --- # name: test_number_snapshot[number.test_number_up_movement_duration-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -663,7 +721,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'up_time', - 'unique_id': '00055511EECC-1-14', + 'unique_id': '00055511EECC-1-13', 'unit_of_measurement': , }) # --- @@ -683,7 +741,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '57', + 'state': '57.0', }) # --- # name: test_number_snapshot[number.test_number_wake_up_interval-entry] @@ -721,7 +779,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'wake_up_interval', - 'unique_id': '00055511EECC-1-15', + 'unique_id': '00055511EECC-1-14', 'unit_of_measurement': , }) # --- @@ -741,7 +799,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '600', + 'state': '600.0', }) # --- # name: test_number_snapshot[number.test_number_window_open_sensibility-entry] @@ -779,7 +837,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'open_window_detection_sensibility', - 'unique_id': '00055511EECC-1-7', + 'unique_id': '00055511EECC-1-6', 'unit_of_measurement': None, }) # --- @@ -797,6 +855,6 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '3', + 'state': '3.0', }) # --- diff --git a/tests/components/homee/test_number.py b/tests/components/homee/test_number.py index 73ca707c2d5..2825152241a 100644 --- a/tests/components/homee/test_number.py +++ b/tests/components/homee/test_number.py @@ -2,6 +2,7 @@ from unittest.mock import MagicMock, patch +import pytest from syrupy.assertion import SnapshotAssertion from homeassistant.components.number import ( @@ -18,24 +19,62 @@ from . import build_mock_node, setup_integration from tests.common import MockConfigEntry, snapshot_platform -async def test_set_value( - hass: HomeAssistant, - mock_homee: MagicMock, - mock_config_entry: MockConfigEntry, +async def setup_numbers( + hass: HomeAssistant, mock_homee: MagicMock, mock_config_entry: MockConfigEntry ) -> None: - """Test set_value service.""" + """Set up the number platform.""" mock_homee.nodes = [build_mock_node("numbers.json")] mock_homee.get_node_by_id.return_value = mock_homee.nodes[0] await setup_integration(hass, mock_config_entry) + +@pytest.mark.parametrize( + ("entity_id", "expected"), + [ + ("number.test_number_down_position", 100.0), + ("number.test_number_threshold_for_wind_trigger", 5.0), + ], +) +async def test_value_fn( + hass: HomeAssistant, + mock_homee: MagicMock, + mock_config_entry: MockConfigEntry, + entity_id: str, + expected: float, +) -> None: + """Test the value_fn of the number entity.""" + await setup_numbers(hass, mock_homee, mock_config_entry) + + assert hass.states.get(entity_id).state == str(expected) + + +@pytest.mark.parametrize( + ("entity_id", "attribute_index", "value", "expected"), + [ + ("number.test_number_down_position", 0, 90, 90), + ("number.test_number_threshold_for_wind_trigger", 15, 7.5, 3), + ], +) +async def test_set_value( + hass: HomeAssistant, + mock_homee: MagicMock, + mock_config_entry: MockConfigEntry, + entity_id: str, + attribute_index: int, + value: float, + expected: float, +) -> None: + """Test set_value service.""" + await setup_numbers(hass, mock_homee, mock_config_entry) + await hass.services.async_call( NUMBER_DOMAIN, SERVICE_SET_VALUE, - {ATTR_ENTITY_ID: "number.test_number_down_position", ATTR_VALUE: 90}, + {ATTR_ENTITY_ID: entity_id, ATTR_VALUE: value}, blocking=True, ) - number = mock_homee.nodes[0].attributes[0] - mock_homee.set_value.assert_called_once_with(number.node_id, number.id, 90) + number = mock_homee.nodes[0].attributes[attribute_index] + mock_homee.set_value.assert_called_once_with(number.node_id, number.id, expected) async def test_set_value_not_editable( @@ -44,9 +83,7 @@ async def test_set_value_not_editable( mock_config_entry: MockConfigEntry, ) -> None: """Test set_value if attribute is not editable.""" - mock_homee.nodes = [build_mock_node("numbers.json")] - mock_homee.get_node_by_id.return_value = mock_homee.nodes[0] - await setup_integration(hass, mock_config_entry) + await setup_numbers(hass, mock_homee, mock_config_entry) await hass.services.async_call( NUMBER_DOMAIN, @@ -66,9 +103,7 @@ async def test_number_snapshot( snapshot: SnapshotAssertion, ) -> None: """Test the multisensor snapshot.""" - mock_homee.nodes = [build_mock_node("numbers.json")] - mock_homee.get_node_by_id.return_value = mock_homee.nodes[0] with patch("homeassistant.components.homee.PLATFORMS", [Platform.NUMBER]): - await setup_integration(hass, mock_config_entry) + await setup_numbers(hass, mock_homee, mock_config_entry) await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) diff --git a/tests/components/http/test_init.py b/tests/components/http/test_init.py index 4d96f2267fa..2937e673946 100644 --- a/tests/components/http/test_init.py +++ b/tests/components/http/test_init.py @@ -22,7 +22,7 @@ from homeassistant.setup import async_setup_component from homeassistant.util import dt as dt_util from homeassistant.util.ssl import server_context_intermediate, server_context_modern -from tests.common import async_fire_time_changed +from tests.common import async_call_logger_set_level, async_fire_time_changed from tests.typing import ClientSessionGenerator @@ -505,27 +505,21 @@ async def test_logging( ) ) hass.states.async_set("logging.entity", "hello") - await hass.services.async_call( - "logger", - "set_level", - {"aiohttp.access": "info"}, - blocking=True, - ) - client = await hass_client() - response = await client.get("/api/states/logging.entity") - assert response.status == HTTPStatus.OK + async with async_call_logger_set_level( + "aiohttp.access", "INFO", hass=hass, caplog=caplog + ): + client = await hass_client() + response = await client.get("/api/states/logging.entity") + assert response.status == HTTPStatus.OK - assert "GET /api/states/logging.entity" in caplog.text - caplog.clear() - await hass.services.async_call( - "logger", - "set_level", - {"aiohttp.access": "warning"}, - blocking=True, - ) - response = await client.get("/api/states/logging.entity") - assert response.status == HTTPStatus.OK - assert "GET /api/states/logging.entity" not in caplog.text + assert "GET /api/states/logging.entity" in caplog.text + caplog.clear() + async with async_call_logger_set_level( + "aiohttp.access", "WARNING", hass=hass, caplog=caplog + ): + response = await client.get("/api/states/logging.entity") + assert response.status == HTTPStatus.OK + assert "GET /api/states/logging.entity" not in caplog.text async def test_register_static_paths( diff --git a/tests/components/husqvarna_automower/snapshots/test_sensor.ambr b/tests/components/husqvarna_automower/snapshots/test_sensor.ambr index 92320de6fdb..979d40a53d8 100644 --- a/tests/components/husqvarna_automower/snapshots/test_sensor.ambr +++ b/tests/components/husqvarna_automower/snapshots/test_sensor.ambr @@ -65,7 +65,7 @@ 'device_id': , 'disabled_by': None, 'domain': 'sensor', - 'entity_category': None, + 'entity_category': , 'entity_id': 'sensor.test_mower_1_cutting_blade_usage_time', 'has_entity_name': True, 'hidden_by': None, @@ -120,7 +120,7 @@ 'device_id': , 'disabled_by': None, 'domain': 'sensor', - 'entity_category': None, + 'entity_category': , 'entity_id': 'sensor.test_mower_1_downtime', 'has_entity_name': True, 'hidden_by': None, @@ -171,7 +171,6 @@ 'area_id': None, 'capabilities': dict({ 'options': list([ - 'no_error', 'alarm_mower_in_motion', 'alarm_mower_lifted', 'alarm_mower_stopped', @@ -180,13 +179,11 @@ 'alarm_outside_geofence', 'angular_sensor_problem', 'battery_problem', - 'battery_problem', 'battery_restriction_due_to_ambient_temperature', 'can_error', 'charging_current_too_high', 'charging_station_blocked', 'charging_system_problem', - 'charging_system_problem', 'collision_sensor_defect', 'collision_sensor_error', 'collision_sensor_problem_front', @@ -197,24 +194,18 @@ 'connection_changed', 'connection_not_changed', 'connectivity_problem', - 'connectivity_problem', - 'connectivity_problem', - 'connectivity_problem', - 'connectivity_problem', - 'connectivity_problem', 'connectivity_settings_restored', 'cutting_drive_motor_1_defect', 'cutting_drive_motor_2_defect', 'cutting_drive_motor_3_defect', 'cutting_height_blocked', - 'cutting_height_problem', 'cutting_height_problem_curr', 'cutting_height_problem_dir', 'cutting_height_problem_drive', + 'cutting_height_problem', 'cutting_motor_problem', 'cutting_stopped_slope_too_steep', 'cutting_system_blocked', - 'cutting_system_blocked', 'cutting_system_imbalance_warning', 'cutting_system_major_imbalance', 'destination_not_reachable', @@ -222,13 +213,9 @@ 'docking_sensor_defect', 'electronic_problem', 'empty_battery', - 'error', - 'error_at_power_up', - 'fatal_error', 'folding_cutting_deck_sensor_defect', 'folding_sensor_activated', 'geofence_problem', - 'geofence_problem', 'gps_navigation_problem', 'guide_1_not_found', 'guide_2_not_found', @@ -246,7 +233,6 @@ 'lift_sensor_defect', 'lifted', 'limited_cutting_height_range', - 'limited_cutting_height_range', 'loop_sensor_defect', 'loop_sensor_problem_front', 'loop_sensor_problem_left', @@ -259,6 +245,7 @@ 'no_accurate_position_from_satellites', 'no_confirmed_position', 'no_drive', + 'no_error', 'no_loop_signal', 'no_power_in_charging_station', 'no_response_from_charger', @@ -269,9 +256,6 @@ 'safety_function_faulty', 'settings_restored', 'sim_card_locked', - 'sim_card_locked', - 'sim_card_locked', - 'sim_card_locked', 'sim_card_not_found', 'sim_card_requires_pin', 'slipped_mower_has_slipped_situation_not_solved_with_moving_pattern', @@ -281,13 +265,6 @@ 'stuck_in_charging_station', 'switch_cord_problem', 'temporary_battery_problem', - 'temporary_battery_problem', - 'temporary_battery_problem', - 'temporary_battery_problem', - 'temporary_battery_problem', - 'temporary_battery_problem', - 'temporary_battery_problem', - 'temporary_battery_problem', 'tilt_sensor_problem', 'too_high_discharge_current', 'too_high_internal_current', @@ -317,6 +294,13 @@ 'wrong_loop_signal', 'wrong_pin_code', 'zone_generator_problem', + 'error_at_power_up', + 'error', + 'fatal_error', + 'off', + 'stopped', + 'wait_power_up', + 'wait_updating', ]), }), 'config_entry_id': , @@ -353,7 +337,6 @@ 'device_class': 'enum', 'friendly_name': 'Test Mower 1 Error', 'options': list([ - 'no_error', 'alarm_mower_in_motion', 'alarm_mower_lifted', 'alarm_mower_stopped', @@ -362,13 +345,11 @@ 'alarm_outside_geofence', 'angular_sensor_problem', 'battery_problem', - 'battery_problem', 'battery_restriction_due_to_ambient_temperature', 'can_error', 'charging_current_too_high', 'charging_station_blocked', 'charging_system_problem', - 'charging_system_problem', 'collision_sensor_defect', 'collision_sensor_error', 'collision_sensor_problem_front', @@ -379,24 +360,18 @@ 'connection_changed', 'connection_not_changed', 'connectivity_problem', - 'connectivity_problem', - 'connectivity_problem', - 'connectivity_problem', - 'connectivity_problem', - 'connectivity_problem', 'connectivity_settings_restored', 'cutting_drive_motor_1_defect', 'cutting_drive_motor_2_defect', 'cutting_drive_motor_3_defect', 'cutting_height_blocked', - 'cutting_height_problem', 'cutting_height_problem_curr', 'cutting_height_problem_dir', 'cutting_height_problem_drive', + 'cutting_height_problem', 'cutting_motor_problem', 'cutting_stopped_slope_too_steep', 'cutting_system_blocked', - 'cutting_system_blocked', 'cutting_system_imbalance_warning', 'cutting_system_major_imbalance', 'destination_not_reachable', @@ -404,13 +379,9 @@ 'docking_sensor_defect', 'electronic_problem', 'empty_battery', - 'error', - 'error_at_power_up', - 'fatal_error', 'folding_cutting_deck_sensor_defect', 'folding_sensor_activated', 'geofence_problem', - 'geofence_problem', 'gps_navigation_problem', 'guide_1_not_found', 'guide_2_not_found', @@ -428,7 +399,6 @@ 'lift_sensor_defect', 'lifted', 'limited_cutting_height_range', - 'limited_cutting_height_range', 'loop_sensor_defect', 'loop_sensor_problem_front', 'loop_sensor_problem_left', @@ -441,6 +411,7 @@ 'no_accurate_position_from_satellites', 'no_confirmed_position', 'no_drive', + 'no_error', 'no_loop_signal', 'no_power_in_charging_station', 'no_response_from_charger', @@ -451,9 +422,6 @@ 'safety_function_faulty', 'settings_restored', 'sim_card_locked', - 'sim_card_locked', - 'sim_card_locked', - 'sim_card_locked', 'sim_card_not_found', 'sim_card_requires_pin', 'slipped_mower_has_slipped_situation_not_solved_with_moving_pattern', @@ -463,13 +431,6 @@ 'stuck_in_charging_station', 'switch_cord_problem', 'temporary_battery_problem', - 'temporary_battery_problem', - 'temporary_battery_problem', - 'temporary_battery_problem', - 'temporary_battery_problem', - 'temporary_battery_problem', - 'temporary_battery_problem', - 'temporary_battery_problem', 'tilt_sensor_problem', 'too_high_discharge_current', 'too_high_internal_current', @@ -499,6 +460,13 @@ 'wrong_loop_signal', 'wrong_pin_code', 'zone_generator_problem', + 'error_at_power_up', + 'error', + 'fatal_error', + 'off', + 'stopped', + 'wait_power_up', + 'wait_updating', ]), }), 'context': , @@ -1280,7 +1248,7 @@ 'device_id': , 'disabled_by': None, 'domain': 'sensor', - 'entity_category': None, + 'entity_category': , 'entity_id': 'sensor.test_mower_1_uptime', 'has_entity_name': True, 'hidden_by': None, @@ -1449,7 +1417,6 @@ 'area_id': None, 'capabilities': dict({ 'options': list([ - 'no_error', 'alarm_mower_in_motion', 'alarm_mower_lifted', 'alarm_mower_stopped', @@ -1458,13 +1425,11 @@ 'alarm_outside_geofence', 'angular_sensor_problem', 'battery_problem', - 'battery_problem', 'battery_restriction_due_to_ambient_temperature', 'can_error', 'charging_current_too_high', 'charging_station_blocked', 'charging_system_problem', - 'charging_system_problem', 'collision_sensor_defect', 'collision_sensor_error', 'collision_sensor_problem_front', @@ -1475,24 +1440,18 @@ 'connection_changed', 'connection_not_changed', 'connectivity_problem', - 'connectivity_problem', - 'connectivity_problem', - 'connectivity_problem', - 'connectivity_problem', - 'connectivity_problem', 'connectivity_settings_restored', 'cutting_drive_motor_1_defect', 'cutting_drive_motor_2_defect', 'cutting_drive_motor_3_defect', 'cutting_height_blocked', - 'cutting_height_problem', 'cutting_height_problem_curr', 'cutting_height_problem_dir', 'cutting_height_problem_drive', + 'cutting_height_problem', 'cutting_motor_problem', 'cutting_stopped_slope_too_steep', 'cutting_system_blocked', - 'cutting_system_blocked', 'cutting_system_imbalance_warning', 'cutting_system_major_imbalance', 'destination_not_reachable', @@ -1500,13 +1459,9 @@ 'docking_sensor_defect', 'electronic_problem', 'empty_battery', - 'error', - 'error_at_power_up', - 'fatal_error', 'folding_cutting_deck_sensor_defect', 'folding_sensor_activated', 'geofence_problem', - 'geofence_problem', 'gps_navigation_problem', 'guide_1_not_found', 'guide_2_not_found', @@ -1524,7 +1479,6 @@ 'lift_sensor_defect', 'lifted', 'limited_cutting_height_range', - 'limited_cutting_height_range', 'loop_sensor_defect', 'loop_sensor_problem_front', 'loop_sensor_problem_left', @@ -1537,6 +1491,7 @@ 'no_accurate_position_from_satellites', 'no_confirmed_position', 'no_drive', + 'no_error', 'no_loop_signal', 'no_power_in_charging_station', 'no_response_from_charger', @@ -1547,9 +1502,6 @@ 'safety_function_faulty', 'settings_restored', 'sim_card_locked', - 'sim_card_locked', - 'sim_card_locked', - 'sim_card_locked', 'sim_card_not_found', 'sim_card_requires_pin', 'slipped_mower_has_slipped_situation_not_solved_with_moving_pattern', @@ -1559,13 +1511,6 @@ 'stuck_in_charging_station', 'switch_cord_problem', 'temporary_battery_problem', - 'temporary_battery_problem', - 'temporary_battery_problem', - 'temporary_battery_problem', - 'temporary_battery_problem', - 'temporary_battery_problem', - 'temporary_battery_problem', - 'temporary_battery_problem', 'tilt_sensor_problem', 'too_high_discharge_current', 'too_high_internal_current', @@ -1595,6 +1540,13 @@ 'wrong_loop_signal', 'wrong_pin_code', 'zone_generator_problem', + 'error_at_power_up', + 'error', + 'fatal_error', + 'off', + 'stopped', + 'wait_power_up', + 'wait_updating', ]), }), 'config_entry_id': , @@ -1631,7 +1583,6 @@ 'device_class': 'enum', 'friendly_name': 'Test Mower 2 Error', 'options': list([ - 'no_error', 'alarm_mower_in_motion', 'alarm_mower_lifted', 'alarm_mower_stopped', @@ -1640,13 +1591,11 @@ 'alarm_outside_geofence', 'angular_sensor_problem', 'battery_problem', - 'battery_problem', 'battery_restriction_due_to_ambient_temperature', 'can_error', 'charging_current_too_high', 'charging_station_blocked', 'charging_system_problem', - 'charging_system_problem', 'collision_sensor_defect', 'collision_sensor_error', 'collision_sensor_problem_front', @@ -1657,24 +1606,18 @@ 'connection_changed', 'connection_not_changed', 'connectivity_problem', - 'connectivity_problem', - 'connectivity_problem', - 'connectivity_problem', - 'connectivity_problem', - 'connectivity_problem', 'connectivity_settings_restored', 'cutting_drive_motor_1_defect', 'cutting_drive_motor_2_defect', 'cutting_drive_motor_3_defect', 'cutting_height_blocked', - 'cutting_height_problem', 'cutting_height_problem_curr', 'cutting_height_problem_dir', 'cutting_height_problem_drive', + 'cutting_height_problem', 'cutting_motor_problem', 'cutting_stopped_slope_too_steep', 'cutting_system_blocked', - 'cutting_system_blocked', 'cutting_system_imbalance_warning', 'cutting_system_major_imbalance', 'destination_not_reachable', @@ -1682,13 +1625,9 @@ 'docking_sensor_defect', 'electronic_problem', 'empty_battery', - 'error', - 'error_at_power_up', - 'fatal_error', 'folding_cutting_deck_sensor_defect', 'folding_sensor_activated', 'geofence_problem', - 'geofence_problem', 'gps_navigation_problem', 'guide_1_not_found', 'guide_2_not_found', @@ -1706,7 +1645,6 @@ 'lift_sensor_defect', 'lifted', 'limited_cutting_height_range', - 'limited_cutting_height_range', 'loop_sensor_defect', 'loop_sensor_problem_front', 'loop_sensor_problem_left', @@ -1719,6 +1657,7 @@ 'no_accurate_position_from_satellites', 'no_confirmed_position', 'no_drive', + 'no_error', 'no_loop_signal', 'no_power_in_charging_station', 'no_response_from_charger', @@ -1729,9 +1668,6 @@ 'safety_function_faulty', 'settings_restored', 'sim_card_locked', - 'sim_card_locked', - 'sim_card_locked', - 'sim_card_locked', 'sim_card_not_found', 'sim_card_requires_pin', 'slipped_mower_has_slipped_situation_not_solved_with_moving_pattern', @@ -1741,13 +1677,6 @@ 'stuck_in_charging_station', 'switch_cord_problem', 'temporary_battery_problem', - 'temporary_battery_problem', - 'temporary_battery_problem', - 'temporary_battery_problem', - 'temporary_battery_problem', - 'temporary_battery_problem', - 'temporary_battery_problem', - 'temporary_battery_problem', 'tilt_sensor_problem', 'too_high_discharge_current', 'too_high_internal_current', @@ -1777,6 +1706,13 @@ 'wrong_loop_signal', 'wrong_pin_code', 'zone_generator_problem', + 'error_at_power_up', + 'error', + 'fatal_error', + 'off', + 'stopped', + 'wait_power_up', + 'wait_updating', ]), }), 'context': , diff --git a/tests/components/integration/test_config_flow.py b/tests/components/integration/test_config_flow.py index f8387d85174..37b0760dc03 100644 --- a/tests/components/integration/test_config_flow.py +++ b/tests/components/integration/test_config_flow.py @@ -10,7 +10,7 @@ from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType from homeassistant.helpers import selector -from tests.common import MockConfigEntry +from tests.common import MockConfigEntry, get_schema_suggested_value @pytest.mark.parametrize("platform", ["sensor"]) @@ -67,17 +67,6 @@ async def test_config_flow(hass: HomeAssistant, platform) -> None: assert config_entry.title == "My integration" -def get_suggested(schema, key): - """Get suggested value for key in voluptuous schema.""" - for k in schema: - if k == key: - if k.description is None or "suggested_value" not in k.description: - return None - return k.description["suggested_value"] - # Wanted key absent from schema - raise KeyError("Wanted key absent from schema") - - @pytest.mark.parametrize("platform", ["sensor"]) async def test_options(hass: HomeAssistant, platform) -> None: """Test reconfiguring.""" @@ -108,7 +97,7 @@ async def test_options(hass: HomeAssistant, platform) -> None: assert result["type"] is FlowResultType.FORM assert result["step_id"] == "init" schema = result["data_schema"].schema - assert get_suggested(schema, "round") == 1.0 + assert get_schema_suggested_value(schema, "round") == 1.0 source = schema["source"] assert isinstance(source, selector.EntitySelector) diff --git a/tests/components/intellifire/snapshots/test_binary_sensor.ambr b/tests/components/intellifire/snapshots/test_binary_sensor.ambr index afa3c1fa8a9..c2ed8ff17b0 100644 --- a/tests/components/intellifire/snapshots/test_binary_sensor.ambr +++ b/tests/components/intellifire/snapshots/test_binary_sensor.ambr @@ -366,7 +366,7 @@ }), 'original_device_class': , 'original_icon': None, - 'original_name': 'Flame Error', + 'original_name': 'Flame error', 'platform': 'intellifire', 'previous_unique_id': None, 'supported_features': 0, @@ -380,7 +380,7 @@ 'attributes': ReadOnlyDict({ 'attribution': 'Data provided by unpublished Intellifire API', 'device_class': 'problem', - 'friendly_name': 'IntelliFire Flame Error', + 'friendly_name': 'IntelliFire Flame error', }), 'context': , 'entity_id': 'binary_sensor.intellifire_flame_error', diff --git a/tests/components/intellifire/snapshots/test_sensor.ambr b/tests/components/intellifire/snapshots/test_sensor.ambr index 548c8d5a8aa..3826b75a417 100644 --- a/tests/components/intellifire/snapshots/test_sensor.ambr +++ b/tests/components/intellifire/snapshots/test_sensor.ambr @@ -171,7 +171,7 @@ }), 'original_device_class': None, 'original_icon': None, - 'original_name': 'Fan Speed', + 'original_name': 'Fan speed', 'platform': 'intellifire', 'previous_unique_id': None, 'supported_features': 0, @@ -184,7 +184,7 @@ StateSnapshot({ 'attributes': ReadOnlyDict({ 'attribution': 'Data provided by unpublished Intellifire API', - 'friendly_name': 'IntelliFire Fan Speed', + 'friendly_name': 'IntelliFire Fan speed', 'state_class': , }), 'context': , diff --git a/tests/components/ista_ecotrend/conftest.py b/tests/components/ista_ecotrend/conftest.py index 7edf2e4717b..58977c99b59 100644 --- a/tests/components/ista_ecotrend/conftest.py +++ b/tests/components/ista_ecotrend/conftest.py @@ -80,7 +80,7 @@ def mock_ista() -> Generator[MagicMock]: "26e93f1a-c828-11ea-87d0-0242ac130003", "eaf5c5c8-889f-4a3c-b68c-e9a676505762", ] - client.get_consumption_data = get_consumption_data + client.get_consumption_data.side_effect = get_consumption_data yield client diff --git a/tests/components/ista_ecotrend/snapshots/test_diagnostics.ambr b/tests/components/ista_ecotrend/snapshots/test_diagnostics.ambr new file mode 100644 index 00000000000..c9f5e72ae1f --- /dev/null +++ b/tests/components/ista_ecotrend/snapshots/test_diagnostics.ambr @@ -0,0 +1,205 @@ +# serializer version: 1 +# name: test_diagnostics + dict({ + 'data': dict({ + '26e93f1a-c828-11ea-87d0-0242ac130003': dict({ + 'consumptionUnitId': '26e93f1a-c828-11ea-87d0-0242ac130003', + 'consumptions': list([ + dict({ + 'date': dict({ + 'month': 5, + 'year': 2024, + }), + 'readings': list([ + dict({ + 'additionalValue': '38,0', + 'type': 'heating', + 'value': '35', + }), + dict({ + 'additionalValue': '57,0', + 'type': 'warmwater', + 'value': '1,0', + }), + dict({ + 'type': 'water', + 'value': '5,0', + }), + ]), + }), + dict({ + 'date': dict({ + 'month': 4, + 'year': 2024, + }), + 'readings': list([ + dict({ + 'additionalValue': '113,0', + 'type': 'heating', + 'value': '104', + }), + dict({ + 'additionalValue': '61,1', + 'type': 'warmwater', + 'value': '1,1', + }), + dict({ + 'type': 'water', + 'value': '6,8', + }), + ]), + }), + ]), + 'costs': list([ + dict({ + 'costsByEnergyType': list([ + dict({ + 'type': 'heating', + 'value': 21, + }), + dict({ + 'type': 'warmwater', + 'value': 7, + }), + dict({ + 'type': 'water', + 'value': 3, + }), + ]), + 'date': dict({ + 'month': 5, + 'year': 2024, + }), + }), + dict({ + 'costsByEnergyType': list([ + dict({ + 'type': 'heating', + 'value': 62, + }), + dict({ + 'type': 'warmwater', + 'value': 7, + }), + dict({ + 'type': 'water', + 'value': 2, + }), + ]), + 'date': dict({ + 'month': 4, + 'year': 2024, + }), + }), + ]), + }), + 'eaf5c5c8-889f-4a3c-b68c-e9a676505762': dict({ + 'consumptionUnitId': 'eaf5c5c8-889f-4a3c-b68c-e9a676505762', + 'consumptions': list([ + dict({ + 'date': dict({ + 'month': 5, + 'year': 2024, + }), + 'readings': list([ + dict({ + 'additionalValue': '38,0', + 'type': 'heating', + 'value': '35', + }), + dict({ + 'additionalValue': '57,0', + 'type': 'warmwater', + 'value': '1,0', + }), + dict({ + 'type': 'water', + 'value': '5,0', + }), + ]), + }), + dict({ + 'date': dict({ + 'month': 4, + 'year': 2024, + }), + 'readings': list([ + dict({ + 'additionalValue': '113,0', + 'type': 'heating', + 'value': '104', + }), + dict({ + 'additionalValue': '61,1', + 'type': 'warmwater', + 'value': '1,1', + }), + dict({ + 'type': 'water', + 'value': '6,8', + }), + ]), + }), + ]), + 'costs': list([ + dict({ + 'costsByEnergyType': list([ + dict({ + 'type': 'heating', + 'value': 21, + }), + dict({ + 'type': 'warmwater', + 'value': 7, + }), + dict({ + 'type': 'water', + 'value': 3, + }), + ]), + 'date': dict({ + 'month': 5, + 'year': 2024, + }), + }), + dict({ + 'costsByEnergyType': list([ + dict({ + 'type': 'heating', + 'value': 62, + }), + dict({ + 'type': 'warmwater', + 'value': 7, + }), + dict({ + 'type': 'water', + 'value': 2, + }), + ]), + 'date': dict({ + 'month': 4, + 'year': 2024, + }), + }), + ]), + }), + }), + 'details': dict({ + '26e93f1a-c828-11ea-87d0-0242ac130003': dict({ + 'address': dict({ + 'houseNumber': '**REDACTED**', + 'street': '**REDACTED**', + }), + 'id': '26e93f1a-c828-11ea-87d0-0242ac130003', + }), + 'eaf5c5c8-889f-4a3c-b68c-e9a676505762': dict({ + 'address': dict({ + 'houseNumber': '**REDACTED**', + 'street': '**REDACTED**', + }), + 'id': 'eaf5c5c8-889f-4a3c-b68c-e9a676505762', + }), + }), + }) +# --- diff --git a/tests/components/ista_ecotrend/test_config_flow.py b/tests/components/ista_ecotrend/test_config_flow.py index d6c88c51c99..f5110988585 100644 --- a/tests/components/ista_ecotrend/test_config_flow.py +++ b/tests/components/ista_ecotrend/test_config_flow.py @@ -11,6 +11,8 @@ from homeassistant.const import CONF_EMAIL, CONF_PASSWORD from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType +from tests.common import MockConfigEntry + @pytest.mark.usefixtures("mock_ista") async def test_form(hass: HomeAssistant, mock_setup_entry: AsyncMock) -> None: @@ -47,14 +49,14 @@ async def test_form(hass: HomeAssistant, mock_setup_entry: AsyncMock) -> None: (IndexError, "unknown"), ], ) -async def test_form_invalid_auth( +async def test_form_error_and_recover( hass: HomeAssistant, mock_setup_entry: AsyncMock, mock_ista: MagicMock, side_effect: Exception, error_text: str, ) -> None: - """Test we handle invalid auth.""" + """Test config flow error and recover.""" result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_USER} ) @@ -89,10 +91,10 @@ async def test_form_invalid_auth( assert len(mock_setup_entry.mock_calls) == 1 +@pytest.mark.usefixtures("mock_ista") async def test_reauth( hass: HomeAssistant, - ista_config_entry: AsyncMock, - mock_ista: MagicMock, + ista_config_entry: MockConfigEntry, ) -> None: """Test reauth flow.""" @@ -131,12 +133,12 @@ async def test_reauth( ) async def test_reauth_error_and_recover( hass: HomeAssistant, - ista_config_entry: AsyncMock, + ista_config_entry: MockConfigEntry, mock_ista: MagicMock, side_effect: Exception, error_text: str, ) -> None: - """Test reauth flow.""" + """Test reauth flow error and recover.""" ista_config_entry.add_to_hass(hass) @@ -174,3 +176,65 @@ async def test_reauth_error_and_recover( CONF_PASSWORD: "new-password", } assert len(hass.config_entries.async_entries()) == 1 + + +@pytest.mark.usefixtures("mock_ista") +async def test_form__already_configured( + hass: HomeAssistant, + ista_config_entry: MockConfigEntry, +) -> None: + """Test we abort form login when entry is already configured.""" + + ista_config_entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ + CONF_EMAIL: "new@example.com", + CONF_PASSWORD: "new-password", + }, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" + + +@pytest.mark.usefixtures("mock_ista") +async def test_flow_reauth_unique_id_mismatch(hass: HomeAssistant) -> None: + """Test reauth flow unique id mismatch.""" + + config_entry = MockConfigEntry( + domain=DOMAIN, + data={ + CONF_EMAIL: "test@example.com", + CONF_PASSWORD: "test-password", + }, + unique_id="42243134-21f6-40a2-a79f-e417a3a12104", + ) + + config_entry.add_to_hass(hass) + result = await config_entry.start_reauth_flow(hass) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "reauth_confirm" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_EMAIL: "new@example.com", + CONF_PASSWORD: "new-password", + }, + ) + + await hass.async_block_till_done() + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "unique_id_mismatch" + + assert len(hass.config_entries.async_entries()) == 1 diff --git a/tests/components/ista_ecotrend/test_diagnostics.py b/tests/components/ista_ecotrend/test_diagnostics.py new file mode 100644 index 00000000000..83e28b0b7f8 --- /dev/null +++ b/tests/components/ista_ecotrend/test_diagnostics.py @@ -0,0 +1,27 @@ +"""Tests for ista EcoTrend diagnostics platform .""" + +import pytest +from syrupy.assertion import SnapshotAssertion + +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry +from tests.components.diagnostics import get_diagnostics_for_config_entry +from tests.typing import ClientSessionGenerator + + +@pytest.mark.usefixtures("mock_ista") +async def test_diagnostics( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + ista_config_entry: MockConfigEntry, + snapshot: SnapshotAssertion, +) -> None: + """Test diagnostics.""" + ista_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(ista_config_entry.entry_id) + await hass.async_block_till_done() + assert ( + await get_diagnostics_for_config_entry(hass, hass_client, ista_config_entry) + == snapshot + ) diff --git a/tests/components/ista_ecotrend/test_init.py b/tests/components/ista_ecotrend/test_init.py index a15e4577252..b73232a7d74 100644 --- a/tests/components/ista_ecotrend/test_init.py +++ b/tests/components/ista_ecotrend/test_init.py @@ -1,11 +1,12 @@ """Test the ista EcoTrend init.""" -from unittest.mock import MagicMock +from unittest.mock import MagicMock, patch from pyecotrend_ista import KeycloakError, LoginError, ParserError, ServerError import pytest from syrupy.assertion import SnapshotAssertion +from homeassistant.components.ista_ecotrend.const import DOMAIN from homeassistant.config_entries import SOURCE_REAUTH, ConfigEntryState from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr @@ -60,7 +61,7 @@ async def test_config_entry_auth_failed( mock_ista: MagicMock, side_effect: Exception, ) -> None: - """Test config entry not ready.""" + """Test config entry auth failed.""" mock_ista.login.side_effect = side_effect ista_config_entry.add_to_hass(hass) await hass.config_entries.async_setup(ista_config_entry.entry_id) @@ -88,3 +89,49 @@ async def test_device_registry( device_registry, ista_config_entry.entry_id ): assert device == snapshot + + +async def test_update_failed( + hass: HomeAssistant, + ista_config_entry: MockConfigEntry, + mock_ista: MagicMock, +) -> None: + """Test coordinator update failed.""" + + with patch( + "homeassistant.components.ista_ecotrend.PLATFORMS", + [], + ): + mock_ista.get_consumption_data.side_effect = ServerError + ista_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(ista_config_entry.entry_id) + await hass.async_block_till_done() + + assert ista_config_entry.state is ConfigEntryState.SETUP_RETRY + + +async def test_auth_failed( + hass: HomeAssistant, ista_config_entry: MockConfigEntry, mock_ista: MagicMock +) -> None: + """Test coordinator auth failed and reauth flow started.""" + with patch( + "homeassistant.components.ista_ecotrend.PLATFORMS", + [], + ): + mock_ista.get_consumption_data.side_effect = LoginError + ista_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(ista_config_entry.entry_id) + await hass.async_block_till_done() + + assert ista_config_entry.state is ConfigEntryState.SETUP_ERROR + + flows = hass.config_entries.flow.async_progress() + assert len(flows) == 1 + + flow = flows[0] + assert flow.get("step_id") == "reauth_confirm" + assert flow.get("handler") == DOMAIN + + assert "context" in flow + assert flow["context"].get("source") == SOURCE_REAUTH + assert flow["context"].get("entry_id") == ista_config_entry.entry_id diff --git a/tests/components/ista_ecotrend/test_statistics.py b/tests/components/ista_ecotrend/test_statistics.py index aa4f71037c4..b5f419437c5 100644 --- a/tests/components/ista_ecotrend/test_statistics.py +++ b/tests/components/ista_ecotrend/test_statistics.py @@ -84,3 +84,61 @@ async def test_statistics_import( assert stats[statistic_id] == snapshot(name=f"{statistic_id}_3months") assert len(stats[statistic_id]) == 3 + + +@pytest.mark.usefixtures("recorder_mock", "mock_ista") +async def test_remove( + hass: HomeAssistant, + ista_config_entry: MockConfigEntry, +) -> None: + """Test remove config entry and clear statistics.""" + ista_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(ista_config_entry.entry_id) + await hass.async_block_till_done() + + assert ista_config_entry.state is ConfigEntryState.LOADED + await async_wait_recording_done(hass) + + assert await hass.async_add_executor_job( + statistics_during_period, + hass, + datetime.datetime.fromtimestamp(0, tz=datetime.UTC), + None, + {"ista_ecotrend:bahnhofsstr_1a_heating"}, + "month", + None, + {"state", "sum"}, + ) + + assert await hass.config_entries.async_unload(ista_config_entry.entry_id) + await hass.async_block_till_done() + + assert ista_config_entry.state is ConfigEntryState.NOT_LOADED + await async_wait_recording_done(hass) + + assert await hass.async_add_executor_job( + statistics_during_period, + hass, + datetime.datetime.fromtimestamp(0, tz=datetime.UTC), + None, + {"ista_ecotrend:bahnhofsstr_1a_heating"}, + "month", + None, + {"state", "sum"}, + ) + + assert await hass.config_entries.async_remove(ista_config_entry.entry_id) + await hass.async_block_till_done() + + await async_wait_recording_done(hass) + + assert not await hass.async_add_executor_job( + statistics_during_period, + hass, + datetime.datetime.fromtimestamp(0, tz=datetime.UTC), + None, + {"ista_ecotrend:bahnhofsstr_1a_heating"}, + "month", + None, + {"state", "sum"}, + ) diff --git a/tests/components/lamarzocco/fixtures/config_gs3.json b/tests/components/lamarzocco/fixtures/config_gs3.json index 0c6c6c70b0a..8958bb90fc4 100644 --- a/tests/components/lamarzocco/fixtures/config_gs3.json +++ b/tests/components/lamarzocco/fixtures/config_gs3.json @@ -299,7 +299,7 @@ "code": "CMBackFlush", "index": 1, "output": { - "lastCleaningStartTime": null, + "lastCleaningStartTime": 1743236747166, "status": "Off" }, "tutorialUrl": "http://lamarzocco.com/it/en/app/support/cleaning-and-backflush/#gs3-av" diff --git a/tests/components/lamarzocco/snapshots/test_diagnostics.ambr b/tests/components/lamarzocco/snapshots/test_diagnostics.ambr index 6026ea0d7f4..31292862824 100644 --- a/tests/components/lamarzocco/snapshots/test_diagnostics.ambr +++ b/tests/components/lamarzocco/snapshots/test_diagnostics.ambr @@ -7,7 +7,7 @@ 'coffee_station': None, 'config': dict({ 'CMBackFlush': dict({ - 'last_cleaning_start_time': None, + 'last_cleaning_start_time': '2025-03-29T08:25:47.166000+00:00', 'status': 'Off', }), 'CMCoffeeBoiler': dict({ @@ -571,7 +571,7 @@ 'code': 'CMBackFlush', 'index': 1, 'output': dict({ - 'last_cleaning_start_time': None, + 'last_cleaning_start_time': '2025-03-29T08:25:47.166000+00:00', 'status': 'Off', }), }), diff --git a/tests/components/lamarzocco/snapshots/test_sensor.ambr b/tests/components/lamarzocco/snapshots/test_sensor.ambr index 311e7416b1c..f23771b77b4 100644 --- a/tests/components/lamarzocco/snapshots/test_sensor.ambr +++ b/tests/components/lamarzocco/snapshots/test_sensor.ambr @@ -47,6 +47,54 @@ 'state': 'unknown', }) # --- +# name: test_sensors[sensor.gs012345_last_cleaning_time-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.gs012345_last_cleaning_time', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Last cleaning time', + 'platform': 'lamarzocco', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'last_cleaning_time', + 'unique_id': 'GS012345_last_cleaning_time', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensors[sensor.gs012345_last_cleaning_time-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'timestamp', + 'friendly_name': 'GS012345 Last cleaning time', + }), + 'context': , + 'entity_id': 'sensor.gs012345_last_cleaning_time', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '2025-03-29T08:25:47+00:00', + }) +# --- # name: test_sensors[sensor.gs012345_steam_boiler_ready_time-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ diff --git a/tests/components/lamarzocco/test_binary_sensor.py b/tests/components/lamarzocco/test_binary_sensor.py index 2fbd58eab85..8e92c9bbba9 100644 --- a/tests/components/lamarzocco/test_binary_sensor.py +++ b/tests/components/lamarzocco/test_binary_sensor.py @@ -65,6 +65,7 @@ async def test_sensor_going_unavailable( assert state assert state.state != STATE_UNAVAILABLE + mock_lamarzocco.websocket.connected = False mock_lamarzocco.get_dashboard.side_effect = RequestNotSuccessful("") freezer.tick(timedelta(minutes=10)) async_fire_time_changed(hass) diff --git a/tests/components/lamarzocco/test_init.py b/tests/components/lamarzocco/test_init.py index 94429913ed7..31510ad1426 100644 --- a/tests/components/lamarzocco/test_init.py +++ b/tests/components/lamarzocco/test_init.py @@ -53,6 +53,7 @@ async def test_config_entry_not_ready( mock_lamarzocco: MagicMock, ) -> None: """Test the La Marzocco configuration entry not ready.""" + mock_lamarzocco.websocket.connected = False mock_lamarzocco.get_dashboard.side_effect = RequestNotSuccessful("") await async_init_integration(hass, mock_config_entry) @@ -90,6 +91,7 @@ async def test_invalid_auth( mock_lamarzocco: MagicMock, ) -> None: """Test auth error during setup.""" + mock_lamarzocco.websocket.connected = False mock_lamarzocco.get_dashboard.side_effect = AuthFail("") await async_init_integration(hass, mock_config_entry) diff --git a/tests/components/lcn/conftest.py b/tests/components/lcn/conftest.py index d8dee472946..e588cc7b952 100644 --- a/tests/components/lcn/conftest.py +++ b/tests/components/lcn/conftest.py @@ -88,7 +88,7 @@ def create_config_entry( title = entry_data[CONF_HOST] return MockConfigEntry( - entry_id=fixture_filename, + entry_id=fixture_filename.replace(".", "_"), domain=DOMAIN, title=title, data=entry_data, diff --git a/tests/components/lcn/fixtures/config_entry_pchk.json b/tests/components/lcn/fixtures/config_entry_pchk.json index 068b8757707..f319e37b265 100644 --- a/tests/components/lcn/fixtures/config_entry_pchk.json +++ b/tests/components/lcn/fixtures/config_entry_pchk.json @@ -27,7 +27,6 @@ { "address": [0, 7, false], "name": "Light_Output1", - "resource": "output1", "domain": "light", "domain_data": { "output": "OUTPUT1", @@ -38,7 +37,6 @@ { "address": [0, 7, false], "name": "Light_Output2", - "resource": "output2", "domain": "light", "domain_data": { "output": "OUTPUT2", @@ -49,7 +47,6 @@ { "address": [0, 7, false], "name": "Light_Relay1", - "resource": "relay1", "domain": "light", "domain_data": { "output": "RELAY1", @@ -60,7 +57,6 @@ { "address": [0, 7, false], "name": "Switch_Output1", - "resource": "output1", "domain": "switch", "domain_data": { "output": "OUTPUT1" @@ -69,7 +65,6 @@ { "address": [0, 7, false], "name": "Switch_Output2", - "resource": "output2", "domain": "switch", "domain_data": { "output": "OUTPUT2" @@ -78,7 +73,6 @@ { "address": [0, 7, false], "name": "Switch_Relay1", - "resource": "relay1", "domain": "switch", "domain_data": { "output": "RELAY1" @@ -87,7 +81,6 @@ { "address": [0, 7, false], "name": "Switch_Relay2", - "resource": "relay2", "domain": "switch", "domain_data": { "output": "RELAY2" @@ -96,7 +89,6 @@ { "address": [0, 7, false], "name": "Switch_Regulator1", - "resource": "r1varsetpoint", "domain": "switch", "domain_data": { "output": "R1VARSETPOINT" @@ -105,7 +97,6 @@ { "address": [0, 7, false], "name": "Switch_KeyLock1", - "resource": "a1", "domain": "switch", "domain_data": { "output": "A1" @@ -114,7 +105,6 @@ { "address": [0, 5, true], "name": "Switch_Group5", - "resource": "relay1", "domain": "switch", "domain_data": { "output": "RELAY1" @@ -123,7 +113,6 @@ { "address": [0, 7, false], "name": "Cover_Outputs", - "resource": "outputs", "domain": "cover", "domain_data": { "motor": "OUTPUTS", @@ -133,7 +122,6 @@ { "address": [0, 7, false], "name": "Cover_Relays", - "resource": "motor1", "domain": "cover", "domain_data": { "motor": "MOTOR1", @@ -143,12 +131,12 @@ { "address": [0, 7, false], "name": "Climate1", - "resource": "var1.r1varsetpoint", "domain": "climate", "domain_data": { "source": "VAR1", "setpoint": "R1VARSETPOINT", "lockable": true, + "target_value_locked": -1, "min_temp": 0.0, "max_temp": 40.0, "unit_of_measurement": "°C" @@ -157,7 +145,6 @@ { "address": [0, 7, false], "name": "Romantic", - "resource": "0.0", "domain": "scene", "domain_data": { "register": 0, @@ -169,7 +156,6 @@ { "address": [0, 7, false], "name": "Romantic Transition", - "resource": "0.1", "domain": "scene", "domain_data": { "register": 0, @@ -181,7 +167,6 @@ { "address": [0, 7, false], "name": "Sensor_LockRegulator1", - "resource": "r1varsetpoint", "domain": "binary_sensor", "domain_data": { "source": "R1VARSETPOINT" @@ -190,7 +175,6 @@ { "address": [0, 7, false], "name": "Binary_Sensor1", - "resource": "binsensor1", "domain": "binary_sensor", "domain_data": { "source": "BINSENSOR1" @@ -199,7 +183,6 @@ { "address": [0, 7, false], "name": "Sensor_KeyLock", - "resource": "a5", "domain": "binary_sensor", "domain_data": { "source": "A5" @@ -208,7 +191,6 @@ { "address": [0, 7, false], "name": "Sensor_Var1", - "resource": "var1", "domain": "sensor", "domain_data": { "source": "VAR1", @@ -218,7 +200,6 @@ { "address": [0, 7, false], "name": "Sensor_Setpoint1", - "resource": "r1varsetpoint", "domain": "sensor", "domain_data": { "source": "R1VARSETPOINT", @@ -228,7 +209,6 @@ { "address": [0, 7, false], "name": "Sensor_Led6", - "resource": "led6", "domain": "sensor", "domain_data": { "source": "LED6", @@ -238,7 +218,6 @@ { "address": [0, 7, false], "name": "Sensor_LogicOp1", - "resource": "logicop1", "domain": "sensor", "domain_data": { "source": "LOGICOP1", diff --git a/tests/components/lcn/fixtures/config_entry_pchk_v2_1.json b/tests/components/lcn/fixtures/config_entry_pchk_v2_1.json new file mode 100644 index 00000000000..3b4938b8600 --- /dev/null +++ b/tests/components/lcn/fixtures/config_entry_pchk_v2_1.json @@ -0,0 +1,96 @@ +{ + "host": "pchk", + "ip_address": "192.168.2.41", + "port": 4114, + "username": "lcn", + "password": "lcn", + "sk_num_tries": 0, + "dim_mode": "STEPS200", + "acknowledge": false, + "devices": [ + { + "address": [0, 7, false], + "name": "TestModule", + "hardware_serial": -1, + "software_serial": -1, + "hardware_type": -1 + } + ], + "entities": [ + { + "address": [0, 7, false], + "name": "Light_Output1", + "resource": "output1", + "domain": "light", + "domain_data": { + "output": "OUTPUT1", + "dimmable": true, + "transition": 5.0 + } + }, + { + "address": [0, 7, false], + "name": "Switch_Relay1", + "resource": "relay1", + "domain": "switch", + "domain_data": { + "output": "RELAY1" + } + }, + { + "address": [0, 7, false], + "name": "Cover_Relays", + "resource": "motor1", + "domain": "cover", + "domain_data": { + "motor": "MOTOR1", + "reverse_time": "RT1200" + } + }, + { + "address": [0, 7, false], + "name": "Climate1", + "resource": "var1.r1varsetpoint", + "domain": "climate", + "domain_data": { + "source": "VAR1", + "setpoint": "R1VARSETPOINT", + "lockable": true, + "min_temp": 0.0, + "max_temp": 40.0, + "unit_of_measurement": "°C" + } + }, + { + "address": [0, 7, false], + "name": "Romantic", + "resource": "0.0", + "domain": "scene", + "domain_data": { + "register": 0, + "scene": 0, + "outputs": ["OUTPUT1", "OUTPUT2", "RELAY1"], + "transition": 0.0 + } + }, + { + "address": [0, 7, false], + "name": "Binary_Sensor1", + "resource": "binsensor1", + "domain": "binary_sensor", + "domain_data": { + "source": "BINSENSOR1" + } + }, + { + "address": [0, 7, false], + "name": "Sensor_Var1", + "resource": "var1", + "domain": "sensor", + "domain_data": { + "source": "VAR1", + "unit_of_measurement": "°C" + } + } + ] +} diff --git a/tests/components/lcn/snapshots/test_binary_sensor.ambr b/tests/components/lcn/snapshots/test_binary_sensor.ambr index d2d697569d1..383c9038d78 100644 --- a/tests/components/lcn/snapshots/test_binary_sensor.ambr +++ b/tests/components/lcn/snapshots/test_binary_sensor.ambr @@ -29,7 +29,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, - 'unique_id': 'lcn/config_entry_pchk.json-m000007-binsensor1', + 'unique_id': 'lcn/config_entry_pchk_json-m000007-binsensor1', 'unit_of_measurement': None, }) # --- @@ -76,7 +76,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, - 'unique_id': 'lcn/config_entry_pchk.json-m000007-a5', + 'unique_id': 'lcn/config_entry_pchk_json-m000007-a5', 'unit_of_measurement': None, }) # --- @@ -123,7 +123,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, - 'unique_id': 'lcn/config_entry_pchk.json-m000007-r1varsetpoint', + 'unique_id': 'lcn/config_entry_pchk_json-m000007-r1varsetpoint', 'unit_of_measurement': None, }) # --- diff --git a/tests/components/lcn/snapshots/test_climate.ambr b/tests/components/lcn/snapshots/test_climate.ambr index 81745ca8515..bd371c02492 100644 --- a/tests/components/lcn/snapshots/test_climate.ambr +++ b/tests/components/lcn/snapshots/test_climate.ambr @@ -36,7 +36,7 @@ 'previous_unique_id': None, 'supported_features': , 'translation_key': None, - 'unique_id': 'lcn/config_entry_pchk.json-m000007-var1.r1varsetpoint', + 'unique_id': 'lcn/config_entry_pchk_json-m000007-r1varsetpoint', 'unit_of_measurement': None, }) # --- diff --git a/tests/components/lcn/snapshots/test_cover.ambr b/tests/components/lcn/snapshots/test_cover.ambr index d399626537d..3e9c4ee72eb 100644 --- a/tests/components/lcn/snapshots/test_cover.ambr +++ b/tests/components/lcn/snapshots/test_cover.ambr @@ -29,7 +29,7 @@ 'previous_unique_id': None, 'supported_features': , 'translation_key': None, - 'unique_id': 'lcn/config_entry_pchk.json-m000007-outputs', + 'unique_id': 'lcn/config_entry_pchk_json-m000007-outputs', 'unit_of_measurement': None, }) # --- @@ -78,7 +78,7 @@ 'previous_unique_id': None, 'supported_features': , 'translation_key': None, - 'unique_id': 'lcn/config_entry_pchk.json-m000007-motor1', + 'unique_id': 'lcn/config_entry_pchk_json-m000007-motor1', 'unit_of_measurement': None, }) # --- diff --git a/tests/components/lcn/snapshots/test_init.ambr b/tests/components/lcn/snapshots/test_init.ambr index ea6267aaa0b..8d7a858cf16 100644 --- a/tests/components/lcn/snapshots/test_init.ambr +++ b/tests/components/lcn/snapshots/test_init.ambr @@ -30,7 +30,6 @@ 'transition': 5.0, }), 'name': 'Light_Output1', - 'resource': 'output1', }), ]), 'host': 'pchk', @@ -72,7 +71,6 @@ 'transition': 5.0, }), 'name': 'Light_Output1', - 'resource': 'output1', }), dict({ 'address': tuple( @@ -87,7 +85,6 @@ 'transition': 0.0, }), 'name': 'Light_Output2', - 'resource': 'output2', }), dict({ 'address': tuple( @@ -107,7 +104,6 @@ 'transition': 0.0, }), 'name': 'Romantic', - 'resource': '0.0', }), dict({ 'address': tuple( @@ -127,7 +123,134 @@ 'transition': 10.0, }), 'name': 'Romantic Transition', - 'resource': '0.1', + }), + ]), + 'host': 'pchk', + 'ip_address': '192.168.2.41', + 'password': 'lcn', + 'port': 4114, + 'sk_num_tries': 0, + 'username': 'lcn', + }) +# --- +# name: test_migrate_2_1 + dict({ + 'acknowledge': False, + 'devices': list([ + dict({ + 'address': tuple( + 0, + 7, + False, + ), + 'hardware_serial': -1, + 'hardware_type': -1, + 'name': 'TestModule', + 'software_serial': -1, + }), + ]), + 'dim_mode': 'STEPS200', + 'entities': list([ + dict({ + 'address': tuple( + 0, + 7, + False, + ), + 'domain': 'light', + 'domain_data': dict({ + 'dimmable': True, + 'output': 'OUTPUT1', + 'transition': 5.0, + }), + 'name': 'Light_Output1', + }), + dict({ + 'address': tuple( + 0, + 7, + False, + ), + 'domain': 'switch', + 'domain_data': dict({ + 'output': 'RELAY1', + }), + 'name': 'Switch_Relay1', + }), + dict({ + 'address': tuple( + 0, + 7, + False, + ), + 'domain': 'cover', + 'domain_data': dict({ + 'motor': 'MOTOR1', + 'reverse_time': 'RT1200', + }), + 'name': 'Cover_Relays', + }), + dict({ + 'address': tuple( + 0, + 7, + False, + ), + 'domain': 'climate', + 'domain_data': dict({ + 'lockable': True, + 'max_temp': 40.0, + 'min_temp': 0.0, + 'setpoint': 'R1VARSETPOINT', + 'source': 'VAR1', + 'target_value_locked': -1, + 'unit_of_measurement': '°C', + }), + 'name': 'Climate1', + }), + dict({ + 'address': tuple( + 0, + 7, + False, + ), + 'domain': 'scene', + 'domain_data': dict({ + 'outputs': list([ + 'OUTPUT1', + 'OUTPUT2', + 'RELAY1', + ]), + 'register': 0, + 'scene': 0, + 'transition': 0.0, + }), + 'name': 'Romantic', + }), + dict({ + 'address': tuple( + 0, + 7, + False, + ), + 'domain': 'binary_sensor', + 'domain_data': dict({ + 'source': 'BINSENSOR1', + }), + 'name': 'Binary_Sensor1', + }), + dict({ + 'address': tuple( + 0, + 7, + False, + ), + 'domain': 'sensor', + 'domain_data': dict({ + 'source': 'VAR1', + 'unit_of_measurement': '°C', + }), + 'name': 'Sensor_Var1', }), ]), 'host': 'pchk', diff --git a/tests/components/lcn/snapshots/test_light.ambr b/tests/components/lcn/snapshots/test_light.ambr index 638cddc15cd..5bfd00fb0d7 100644 --- a/tests/components/lcn/snapshots/test_light.ambr +++ b/tests/components/lcn/snapshots/test_light.ambr @@ -33,7 +33,7 @@ 'previous_unique_id': None, 'supported_features': , 'translation_key': None, - 'unique_id': 'lcn/config_entry_pchk.json-m000007-output1', + 'unique_id': 'lcn/config_entry_pchk_json-m000007-output1', 'unit_of_measurement': None, }) # --- @@ -90,7 +90,7 @@ 'previous_unique_id': None, 'supported_features': , 'translation_key': None, - 'unique_id': 'lcn/config_entry_pchk.json-m000007-output2', + 'unique_id': 'lcn/config_entry_pchk_json-m000007-output2', 'unit_of_measurement': None, }) # --- @@ -146,7 +146,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, - 'unique_id': 'lcn/config_entry_pchk.json-m000007-relay1', + 'unique_id': 'lcn/config_entry_pchk_json-m000007-relay1', 'unit_of_measurement': None, }) # --- diff --git a/tests/components/lcn/snapshots/test_scene.ambr b/tests/components/lcn/snapshots/test_scene.ambr index a5576158621..6dac4868437 100644 --- a/tests/components/lcn/snapshots/test_scene.ambr +++ b/tests/components/lcn/snapshots/test_scene.ambr @@ -29,7 +29,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, - 'unique_id': 'lcn/config_entry_pchk.json-m000007-0.0', + 'unique_id': 'lcn/config_entry_pchk_json-m000007-00', 'unit_of_measurement': None, }) # --- @@ -76,7 +76,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, - 'unique_id': 'lcn/config_entry_pchk.json-m000007-0.1', + 'unique_id': 'lcn/config_entry_pchk_json-m000007-01', 'unit_of_measurement': None, }) # --- diff --git a/tests/components/lcn/snapshots/test_sensor.ambr b/tests/components/lcn/snapshots/test_sensor.ambr index f8d57ed8904..1e172dda7e9 100644 --- a/tests/components/lcn/snapshots/test_sensor.ambr +++ b/tests/components/lcn/snapshots/test_sensor.ambr @@ -29,7 +29,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, - 'unique_id': 'lcn/config_entry_pchk.json-m000007-led6', + 'unique_id': 'lcn/config_entry_pchk_json-m000007-led6', 'unit_of_measurement': None, }) # --- @@ -76,7 +76,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, - 'unique_id': 'lcn/config_entry_pchk.json-m000007-logicop1', + 'unique_id': 'lcn/config_entry_pchk_json-m000007-logicop1', 'unit_of_measurement': None, }) # --- @@ -123,7 +123,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, - 'unique_id': 'lcn/config_entry_pchk.json-m000007-r1varsetpoint', + 'unique_id': 'lcn/config_entry_pchk_json-m000007-r1varsetpoint', 'unit_of_measurement': , }) # --- @@ -172,7 +172,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, - 'unique_id': 'lcn/config_entry_pchk.json-m000007-var1', + 'unique_id': 'lcn/config_entry_pchk_json-m000007-var1', 'unit_of_measurement': , }) # --- diff --git a/tests/components/lcn/snapshots/test_switch.ambr b/tests/components/lcn/snapshots/test_switch.ambr index bc69b0ed483..7ba943a671f 100644 --- a/tests/components/lcn/snapshots/test_switch.ambr +++ b/tests/components/lcn/snapshots/test_switch.ambr @@ -29,7 +29,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, - 'unique_id': 'lcn/config_entry_pchk.json-g000005-relay1', + 'unique_id': 'lcn/config_entry_pchk_json-g000005-relay1', 'unit_of_measurement': None, }) # --- @@ -76,7 +76,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, - 'unique_id': 'lcn/config_entry_pchk.json-m000007-a1', + 'unique_id': 'lcn/config_entry_pchk_json-m000007-a1', 'unit_of_measurement': None, }) # --- @@ -123,7 +123,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, - 'unique_id': 'lcn/config_entry_pchk.json-m000007-output1', + 'unique_id': 'lcn/config_entry_pchk_json-m000007-output1', 'unit_of_measurement': None, }) # --- @@ -170,7 +170,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, - 'unique_id': 'lcn/config_entry_pchk.json-m000007-output2', + 'unique_id': 'lcn/config_entry_pchk_json-m000007-output2', 'unit_of_measurement': None, }) # --- @@ -217,7 +217,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, - 'unique_id': 'lcn/config_entry_pchk.json-m000007-r1varsetpoint', + 'unique_id': 'lcn/config_entry_pchk_json-m000007-r1varsetpoint', 'unit_of_measurement': None, }) # --- @@ -264,7 +264,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, - 'unique_id': 'lcn/config_entry_pchk.json-m000007-relay1', + 'unique_id': 'lcn/config_entry_pchk_json-m000007-relay1', 'unit_of_measurement': None, }) # --- @@ -311,7 +311,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, - 'unique_id': 'lcn/config_entry_pchk.json-m000007-relay2', + 'unique_id': 'lcn/config_entry_pchk_json-m000007-relay2', 'unit_of_measurement': None, }) # --- diff --git a/tests/components/lcn/test_init.py b/tests/components/lcn/test_init.py index ef3c2d3cb66..da967782539 100644 --- a/tests/components/lcn/test_init.py +++ b/tests/components/lcn/test_init.py @@ -138,15 +138,12 @@ async def test_async_entry_reload_on_host_event_received( async def test_migrate_1_1(hass: HomeAssistant, snapshot: SnapshotAssertion) -> None: """Test migration config entry.""" entry_v1_1 = create_config_entry("pchk_v1_1", version=(1, 1)) - entry_v1_1.add_to_hass(hass) - - await hass.config_entries.async_setup(entry_v1_1.entry_id) - await hass.async_block_till_done() + await init_integration(hass, entry_v1_1) entry_migrated = hass.config_entries.async_get_entry(entry_v1_1.entry_id) assert entry_migrated.state is ConfigEntryState.LOADED - assert entry_migrated.version == 2 + assert entry_migrated.version == 3 assert entry_migrated.minor_version == 1 assert entry_migrated.data == snapshot @@ -155,14 +152,51 @@ async def test_migrate_1_1(hass: HomeAssistant, snapshot: SnapshotAssertion) -> async def test_migrate_1_2(hass: HomeAssistant, snapshot: SnapshotAssertion) -> None: """Test migration config entry.""" entry_v1_2 = create_config_entry("pchk_v1_2", version=(1, 2)) - entry_v1_2.add_to_hass(hass) - - await hass.config_entries.async_setup(entry_v1_2.entry_id) - await hass.async_block_till_done() + await init_integration(hass, entry_v1_2) entry_migrated = hass.config_entries.async_get_entry(entry_v1_2.entry_id) assert entry_migrated.state is ConfigEntryState.LOADED - assert entry_migrated.version == 2 + assert entry_migrated.version == 3 assert entry_migrated.minor_version == 1 assert entry_migrated.data == snapshot + + +@patch("homeassistant.components.lcn.PchkConnectionManager", MockPchkConnectionManager) +async def test_migrate_2_1(hass: HomeAssistant, snapshot: SnapshotAssertion) -> None: + """Test migration config entry.""" + entry_v2_1 = create_config_entry("pchk_v2_1", version=(2, 1)) + await init_integration(hass, entry_v2_1) + + entry_migrated = hass.config_entries.async_get_entry(entry_v2_1.entry_id) + assert entry_migrated.state is ConfigEntryState.LOADED + assert entry_migrated.version == 3 + assert entry_migrated.minor_version == 1 + assert entry_migrated.data == snapshot + + +@pytest.mark.parametrize( + ("entity_id", "replace"), + [ + ("climate.climate1", ("-r1varsetpoint", "-var1.r1varsetpoint")), + ("scene.romantic", ("-00", "-0.0")), + ], +) +@patch("homeassistant.components.lcn.PchkConnectionManager", MockPchkConnectionManager) +async def test_entity_migration_on_2_1( + hass: HomeAssistant, entity_registry: er.EntityRegistry, entity_id, replace +) -> None: + """Test entity.unique_id migration on config_entry migration from 2.1.""" + entry_v2_1 = create_config_entry("pchk_v2_1", version=(2, 1)) + await init_integration(hass, entry_v2_1) + + migrated_unique_id = entity_registry.async_get(entity_id).unique_id + old_unique_id = migrated_unique_id.replace(*replace) + entity_registry.async_update_entity(entity_id, new_unique_id=old_unique_id) + assert entity_registry.async_get(entity_id).unique_id == old_unique_id + + await hass.config_entries.async_unload(entry_v2_1.entry_id) + + entry_v2_1 = create_config_entry("pchk_v2_1", version=(2, 1)) + await init_integration(hass, entry_v2_1) + assert entity_registry.async_get(entity_id).unique_id == migrated_unique_id diff --git a/tests/components/lcn/test_websocket.py b/tests/components/lcn/test_websocket.py index 2c5fff89e19..02bf6b4c546 100644 --- a/tests/components/lcn/test_websocket.py +++ b/tests/components/lcn/test_websocket.py @@ -7,14 +7,13 @@ import pytest from homeassistant.components.lcn import AddressType from homeassistant.components.lcn.const import CONF_DOMAIN_DATA -from homeassistant.components.lcn.helpers import get_device_config, get_resource +from homeassistant.components.lcn.helpers import get_device_config from homeassistant.const import ( CONF_ADDRESS, CONF_DEVICES, CONF_DOMAIN, CONF_ENTITIES, CONF_NAME, - CONF_RESOURCE, CONF_TYPE, ) from homeassistant.core import HomeAssistant @@ -52,7 +51,7 @@ ENTITIES_DELETE_PAYLOAD = { "entry_id": "", CONF_ADDRESS: (0, 7, False), CONF_DOMAIN: "switch", - CONF_RESOURCE: "relay1", + CONF_DOMAIN_DATA: {"output": "RELAY1"}, } @@ -184,18 +183,14 @@ async def test_lcn_entities_add_command( for key in (CONF_ADDRESS, CONF_NAME, CONF_DOMAIN, CONF_DOMAIN_DATA) } - resource = get_resource( - ENTITIES_ADD_PAYLOAD[CONF_DOMAIN], ENTITIES_ADD_PAYLOAD[CONF_DOMAIN_DATA] - ).lower() - - assert {**entity_config, CONF_RESOURCE: resource} not in entry.data[CONF_ENTITIES] + assert entity_config not in entry.data[CONF_ENTITIES] await client.send_json_auto_id({**ENTITIES_ADD_PAYLOAD, "entry_id": entry.entry_id}) res = await client.receive_json() assert res["success"], res - assert {**entity_config, CONF_RESOURCE: resource} in entry.data[CONF_ENTITIES] + assert entity_config in entry.data[CONF_ENTITIES] async def test_lcn_entities_delete_command( @@ -213,7 +208,8 @@ async def test_lcn_entities_delete_command( for entity in entry.data[CONF_ENTITIES] if entity[CONF_ADDRESS] == ENTITIES_DELETE_PAYLOAD[CONF_ADDRESS] and entity[CONF_DOMAIN] == ENTITIES_DELETE_PAYLOAD[CONF_DOMAIN] - and entity[CONF_RESOURCE] == ENTITIES_DELETE_PAYLOAD[CONF_RESOURCE] + and entity[CONF_DOMAIN_DATA] + == ENTITIES_DELETE_PAYLOAD[CONF_DOMAIN_DATA] ] ) == 1 @@ -233,7 +229,8 @@ async def test_lcn_entities_delete_command( for entity in entry.data[CONF_ENTITIES] if entity[CONF_ADDRESS] == ENTITIES_DELETE_PAYLOAD[CONF_ADDRESS] and entity[CONF_DOMAIN] == ENTITIES_DELETE_PAYLOAD[CONF_DOMAIN] - and entity[CONF_RESOURCE] == ENTITIES_DELETE_PAYLOAD[CONF_RESOURCE] + and entity[CONF_DOMAIN_DATA] + == ENTITIES_DELETE_PAYLOAD[CONF_DOMAIN_DATA] ] ) == 0 diff --git a/tests/components/lg_thinq/conftest.py b/tests/components/lg_thinq/conftest.py index 17bbf068305..2eaddf1a83b 100644 --- a/tests/components/lg_thinq/conftest.py +++ b/tests/components/lg_thinq/conftest.py @@ -94,7 +94,7 @@ def mock_invalid_thinq_api(mock_config_thinq_api: AsyncMock) -> AsyncMock: @pytest.fixture -def mock_thinq_api() -> Generator[AsyncMock]: +def mock_thinq_api(mock_thinq_mqtt_client: None) -> Generator[AsyncMock]: """Mock a thinq api.""" with patch("homeassistant.components.lg_thinq.ThinQApi", autospec=True) as mock_api: thinq_api = mock_api.return_value @@ -111,7 +111,7 @@ def mock_thinq_api() -> Generator[AsyncMock]: @pytest.fixture -def mock_thinq_mqtt_client() -> Generator[AsyncMock]: +def mock_thinq_mqtt_client() -> Generator[None]: """Mock a thinq mqtt client.""" with patch( "homeassistant.components.lg_thinq.mqtt.ThinQMQTTClient", diff --git a/tests/components/lg_thinq/test_init.py b/tests/components/lg_thinq/test_init.py index bf24704d379..d4c14e2e0c0 100644 --- a/tests/components/lg_thinq/test_init.py +++ b/tests/components/lg_thinq/test_init.py @@ -15,7 +15,6 @@ from tests.common import MockConfigEntry async def test_load_unload_entry( hass: HomeAssistant, mock_thinq_api: AsyncMock, - mock_thinq_mqtt_client: AsyncMock, mock_config_entry: MockConfigEntry, ) -> None: """Test load and unload entry.""" @@ -37,7 +36,6 @@ async def test_load_unload_entry( async def test_config_not_ready( hass: HomeAssistant, mock_thinq_api: AsyncMock, - mock_thinq_mqtt_client: AsyncMock, mock_config_entry: MockConfigEntry, exception: Exception, ) -> None: diff --git a/tests/components/local_file/test_camera.py b/tests/components/local_file/test_camera.py index 0eb48aa3060..6b7e505fa26 100644 --- a/tests/components/local_file/test_camera.py +++ b/tests/components/local_file/test_camera.py @@ -13,11 +13,8 @@ from homeassistant.components.local_file.const import ( ) from homeassistant.config_entries import SOURCE_USER from homeassistant.const import ATTR_ENTITY_ID, CONF_FILE_PATH -from homeassistant.core import DOMAIN as HOMEASSISTANT_DOMAIN, HomeAssistant +from homeassistant.core import HomeAssistant from homeassistant.exceptions import ServiceValidationError -from homeassistant.helpers import issue_registry as ir -from homeassistant.setup import async_setup_component -from homeassistant.util import slugify from tests.common import MockConfigEntry from tests.typing import ClientSessionGenerator @@ -212,76 +209,3 @@ async def test_update_file_path( service_data, blocking=True, ) - - -async def test_import_from_yaml_success( - hass: HomeAssistant, issue_registry: ir.IssueRegistry -) -> None: - """Test import.""" - - with ( - patch("os.path.isfile", Mock(return_value=True)), - patch("os.access", Mock(return_value=True)), - patch( - "homeassistant.components.local_file.camera.mimetypes.guess_type", - Mock(return_value=(None, None)), - ), - ): - await async_setup_component( - hass, - "camera", - { - "camera": { - "name": "config_test", - "platform": "local_file", - "file_path": "mock.file", - } - }, - ) - await hass.async_block_till_done() - - assert hass.config_entries.async_has_entries(DOMAIN) - state = hass.states.get("camera.config_test") - assert state.attributes.get("file_path") == "mock.file" - - issue = issue_registry.async_get_issue( - HOMEASSISTANT_DOMAIN, f"deprecated_yaml_{DOMAIN}" - ) - assert issue - assert issue.translation_key == "deprecated_yaml" - - -async def test_import_from_yaml_fails( - hass: HomeAssistant, issue_registry: ir.IssueRegistry -) -> None: - """Test import fails due to not accessible file.""" - - with ( - patch("os.path.isfile", Mock(return_value=True)), - patch("os.access", Mock(return_value=False)), - patch( - "homeassistant.components.local_file.camera.mimetypes.guess_type", - Mock(return_value=(None, None)), - ), - ): - await async_setup_component( - hass, - "camera", - { - "camera": { - "name": "config_test", - "platform": "local_file", - "file_path": "mock.file", - } - }, - ) - await hass.async_block_till_done() - - assert not hass.config_entries.async_has_entries(DOMAIN) - assert not hass.states.get("camera.config_test") - - issue = issue_registry.async_get_issue( - DOMAIN, f"no_access_path_{slugify('mock.file')}" - ) - assert issue - assert issue.translation_key == "no_access_path" diff --git a/tests/components/local_file/test_config_flow.py b/tests/components/local_file/test_config_flow.py index dda9d606107..d828c947d0d 100644 --- a/tests/components/local_file/test_config_flow.py +++ b/tests/components/local_file/test_config_flow.py @@ -175,61 +175,3 @@ async def test_entry_already_exist( assert result["type"] is FlowResultType.ABORT assert result["reason"] == "already_configured" - - -@pytest.mark.usefixtures("mock_setup_entry") -async def test_import(hass: HomeAssistant) -> None: - """Test import.""" - - with ( - patch("os.path.isfile", Mock(return_value=True)), - patch("os.access", Mock(return_value=True)), - patch( - "homeassistant.components.local_file.camera.mimetypes.guess_type", - Mock(return_value=(None, None)), - ), - ): - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": config_entries.SOURCE_IMPORT}, - data={ - "name": DEFAULT_NAME, - "file_path": "mock/path.jpg", - }, - ) - await hass.async_block_till_done() - - assert result["type"] is FlowResultType.CREATE_ENTRY - assert result["version"] == 1 - assert result["options"] == { - CONF_NAME: DEFAULT_NAME, - CONF_FILE_PATH: "mock/path.jpg", - } - - -@pytest.mark.usefixtures("mock_setup_entry") -async def test_import_already_exist( - hass: HomeAssistant, loaded_entry: MockConfigEntry -) -> None: - """Test import abort existing entry.""" - - with ( - patch("os.path.isfile", Mock(return_value=True)), - patch("os.access", Mock(return_value=True)), - patch( - "homeassistant.components.local_file.camera.mimetypes.guess_type", - Mock(return_value=(None, None)), - ), - ): - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": config_entries.SOURCE_IMPORT}, - data={ - CONF_NAME: DEFAULT_NAME, - CONF_FILE_PATH: "mock.file", - }, - ) - await hass.async_block_till_done() - - assert result["type"] is FlowResultType.ABORT - assert result["reason"] == "already_configured" diff --git a/tests/components/logger/test_init.py b/tests/components/logger/test_init.py index 24e58a77226..53b8b72b385 100644 --- a/tests/components/logger/test_init.py +++ b/tests/components/logger/test_init.py @@ -15,7 +15,7 @@ from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component from homeassistant.util import dt as dt_util -from tests.common import async_fire_time_changed +from tests.common import async_call_logger_set_level, async_fire_time_changed HASS_NS = "unused.homeassistant" COMPONENTS_NS = f"{HASS_NS}.components" @@ -73,28 +73,27 @@ async def test_log_filtering( msg_test(filter_logger, True, "format string shouldfilter%s", "not") # Filtering should work even if log level is modified - await hass.services.async_call( - "logger", - "set_level", - {"test.filter": "warning"}, - blocking=True, - ) - assert filter_logger.getEffectiveLevel() == logging.WARNING - msg_test( - filter_logger, - False, - "this line containing shouldfilterall should still be filtered", - ) + async with async_call_logger_set_level( + "test.filter", "WARNING", hass=hass, caplog=caplog + ): + assert filter_logger.getEffectiveLevel() == logging.WARNING + msg_test( + filter_logger, + False, + "this line containing shouldfilterall should still be filtered", + ) - # Filtering should be scoped to a service - msg_test( - filter_logger, True, "this line containing otherfilterer should not be filtered" - ) - msg_test( - logging.getLogger("test.other_filter"), - False, - "this line containing otherfilterer SHOULD be filtered", - ) + # Filtering should be scoped to a service + msg_test( + filter_logger, + True, + "this line containing otherfilterer should not be filtered", + ) + msg_test( + logging.getLogger("test.other_filter"), + False, + "this line containing otherfilterer SHOULD be filtered", + ) async def test_setting_level(hass: HomeAssistant) -> None: diff --git a/tests/components/logger/test_websocket_api.py b/tests/components/logger/test_websocket_api.py index 8fcafcd05a4..debe26576bd 100644 --- a/tests/components/logger/test_websocket_api.py +++ b/tests/components/logger/test_websocket_api.py @@ -4,7 +4,7 @@ import logging from unittest.mock import patch from homeassistant import loader -from homeassistant.components.logger.helpers import async_get_domain_config +from homeassistant.components.logger.helpers import DATA_LOGGER from homeassistant.components.websocket_api import TYPE_RESULT from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component @@ -76,7 +76,7 @@ async def test_integration_log_level( assert msg["type"] == TYPE_RESULT assert msg["success"] - assert async_get_domain_config(hass).overrides == { + assert hass.data[DATA_LOGGER].overrides == { "homeassistant.components.websocket_api": logging.DEBUG } @@ -126,7 +126,7 @@ async def test_custom_integration_log_level( assert msg["type"] == TYPE_RESULT assert msg["success"] - assert async_get_domain_config(hass).overrides == { + assert hass.data[DATA_LOGGER].overrides == { "homeassistant.components.hue": logging.DEBUG, "custom_components.hue": logging.DEBUG, "some_other_logger": logging.DEBUG, @@ -182,7 +182,7 @@ async def test_module_log_level( assert msg["type"] == TYPE_RESULT assert msg["success"] - assert async_get_domain_config(hass).overrides == { + assert hass.data[DATA_LOGGER].overrides == { "homeassistant.components.websocket_api": logging.DEBUG, "homeassistant.components.other_component": logging.WARNING, } @@ -199,7 +199,7 @@ async def test_module_log_level_override( {"logger": {"logs": {"homeassistant.components.websocket_api": "warning"}}}, ) - assert async_get_domain_config(hass).overrides == { + assert hass.data[DATA_LOGGER].overrides == { "homeassistant.components.websocket_api": logging.WARNING } @@ -218,7 +218,7 @@ async def test_module_log_level_override( assert msg["type"] == TYPE_RESULT assert msg["success"] - assert async_get_domain_config(hass).overrides == { + assert hass.data[DATA_LOGGER].overrides == { "homeassistant.components.websocket_api": logging.ERROR } @@ -237,7 +237,7 @@ async def test_module_log_level_override( assert msg["type"] == TYPE_RESULT assert msg["success"] - assert async_get_domain_config(hass).overrides == { + assert hass.data[DATA_LOGGER].overrides == { "homeassistant.components.websocket_api": logging.DEBUG } @@ -256,6 +256,6 @@ async def test_module_log_level_override( assert msg["type"] == TYPE_RESULT assert msg["success"] - assert async_get_domain_config(hass).overrides == { + assert hass.data[DATA_LOGGER].overrides == { "homeassistant.components.websocket_api": logging.NOTSET } diff --git a/tests/components/min_max/test_config_flow.py b/tests/components/min_max/test_config_flow.py index 93f8426e428..a9db7cab904 100644 --- a/tests/components/min_max/test_config_flow.py +++ b/tests/components/min_max/test_config_flow.py @@ -9,7 +9,7 @@ from homeassistant.components.min_max.const import DOMAIN from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType -from tests.common import MockConfigEntry +from tests.common import MockConfigEntry, get_schema_suggested_value @pytest.mark.parametrize("platform", ["sensor"]) @@ -55,17 +55,6 @@ async def test_config_flow(hass: HomeAssistant, platform: str) -> None: assert config_entry.title == "My min_max" -def get_suggested(schema, key): - """Get suggested value for key in voluptuous schema.""" - for k in schema: - if k == key: - if k.description is None or "suggested_value" not in k.description: - return None - return k.description["suggested_value"] - # Wanted key absent from schema - raise KeyError("Wanted key absent from schema") - - @pytest.mark.parametrize("platform", ["sensor"]) async def test_options(hass: HomeAssistant, platform: str) -> None: """Test reconfiguring.""" @@ -96,9 +85,9 @@ async def test_options(hass: HomeAssistant, platform: str) -> None: assert result["type"] is FlowResultType.FORM assert result["step_id"] == "init" schema = result["data_schema"].schema - assert get_suggested(schema, "entity_ids") == input_sensors1 - assert get_suggested(schema, "round_digits") == 0 - assert get_suggested(schema, "type") == "min" + assert get_schema_suggested_value(schema, "entity_ids") == input_sensors1 + assert get_schema_suggested_value(schema, "round_digits") == 0 + assert get_schema_suggested_value(schema, "type") == "min" result = await hass.config_entries.options.async_configure( result["flow_id"], diff --git a/tests/components/mqtt/test_config_flow.py b/tests/components/mqtt/test_config_flow.py index cfc9e0bede0..5824c9b886d 100644 --- a/tests/components/mqtt/test_config_flow.py +++ b/tests/components/mqtt/test_config_flow.py @@ -42,7 +42,7 @@ from .common import ( MOCK_SWITCH_SUBENTRY_DATA_SINGLE_STATE_CLASS, ) -from tests.common import MockConfigEntry, MockMqttReasonCode +from tests.common import MockConfigEntry, MockMqttReasonCode, get_schema_suggested_value from tests.typing import MqttMockHAClientGenerator, MqttMockPahoClient ADD_ON_DISCOVERY_INFO = { @@ -1453,19 +1453,6 @@ def get_default(schema: vol.Schema, key: str) -> Any | None: return None -def get_suggested(schema: vol.Schema, key: str) -> Any | None: - """Get suggested value for key in voluptuous schema.""" - for schema_key in schema: # type:ignore[attr-defined] - if schema_key == key: - if ( - schema_key.description is None - or "suggested_value" not in schema_key.description - ): - return None - return schema_key.description["suggested_value"] - return None - - @pytest.mark.usefixtures("mock_reload_after_entry_update") async def test_option_flow_default_suggested_values( hass: HomeAssistant, @@ -1520,7 +1507,7 @@ async def test_option_flow_default_suggested_values( for key, value in defaults.items(): assert get_default(result["data_schema"].schema, key) == value for key, value in suggested.items(): - assert get_suggested(result["data_schema"].schema, key) == value + assert get_schema_suggested_value(result["data_schema"].schema, key) == value result = await hass.config_entries.options.async_configure( result["flow_id"], @@ -1556,7 +1543,7 @@ async def test_option_flow_default_suggested_values( for key, value in defaults.items(): assert get_default(result["data_schema"].schema, key) == value for key, value in suggested.items(): - assert get_suggested(result["data_schema"].schema, key) == value + assert get_schema_suggested_value(result["data_schema"].schema, key) == value result = await hass.config_entries.options.async_configure( result["flow_id"], @@ -2038,7 +2025,7 @@ async def test_try_connection_with_advanced_parameters( for k, v in defaults.items(): assert get_default(result["data_schema"].schema, k) == v for k, v in suggested.items(): - assert get_suggested(result["data_schema"].schema, k) == v + assert get_schema_suggested_value(result["data_schema"].schema, k) == v # test we can change username and password mock_try_connection_success.reset_mock() diff --git a/tests/components/netatmo/fixtures/homesdata.json b/tests/components/netatmo/fixtures/homesdata.json index ccc71dc6b41..344d3ecc29c 100644 --- a/tests/components/netatmo/fixtures/homesdata.json +++ b/tests/components/netatmo/fixtures/homesdata.json @@ -630,7 +630,7 @@ "name": "Default", "selected": true, "id": "591b54a2764ff4d50d8b5795", - "type": "therm" + "type": "cooling" }, { "zones": [ @@ -778,6 +778,8 @@ } ], "therm_setpoint_default_duration": 120, + "temperature_control_mode": "cooling", + "cooling_mode": "schedule", "persons": [ { "id": "91827374-7e04-5298-83ad-a0cb8372dff1", diff --git a/tests/components/netatmo/snapshots/test_diagnostics.ambr b/tests/components/netatmo/snapshots/test_diagnostics.ambr index 4ea7e30bcf9..3a66aa84c41 100644 --- a/tests/components/netatmo/snapshots/test_diagnostics.ambr +++ b/tests/components/netatmo/snapshots/test_diagnostics.ambr @@ -8,6 +8,7 @@ 'homes': list([ dict({ 'altitude': 112, + 'cooling_mode': 'schedule', 'coordinates': '**REDACTED**', 'country': 'DE', 'id': '91763b24c43d3e344f424e8b', @@ -539,7 +540,7 @@ 'name': '**REDACTED**', 'selected': True, 'timetable': '**REDACTED**', - 'type': 'therm', + 'type': 'cooling', 'zones': '**REDACTED**', }), dict({ @@ -552,6 +553,7 @@ 'zones': '**REDACTED**', }), ]), + 'temperature_control_mode': 'cooling', 'therm_mode': 'schedule', 'therm_setpoint_default_duration': 120, 'timezone': 'Europe/Berlin', diff --git a/tests/components/ntfy/__init__.py b/tests/components/ntfy/__init__.py new file mode 100644 index 00000000000..e059dc61ae9 --- /dev/null +++ b/tests/components/ntfy/__init__.py @@ -0,0 +1 @@ +"""Tests for ntfy integration.""" diff --git a/tests/components/ntfy/conftest.py b/tests/components/ntfy/conftest.py new file mode 100644 index 00000000000..b0279dff2ad --- /dev/null +++ b/tests/components/ntfy/conftest.py @@ -0,0 +1,75 @@ +"""Common fixtures for the ntfy tests.""" + +from collections.abc import Generator +from datetime import datetime +from unittest.mock import AsyncMock, MagicMock, patch + +from aiontfy import AccountTokenResponse +import pytest + +from homeassistant.components.ntfy.const import CONF_TOPIC, DOMAIN +from homeassistant.config_entries import ConfigSubentryData +from homeassistant.const import CONF_TOKEN, CONF_URL, CONF_USERNAME + +from tests.common import MockConfigEntry + + +@pytest.fixture +def mock_setup_entry() -> Generator[AsyncMock]: + """Override async_setup_entry.""" + with patch( + "homeassistant.components.ntfy.async_setup_entry", return_value=True + ) as mock_setup_entry: + yield mock_setup_entry + + +@pytest.fixture +def mock_aiontfy() -> Generator[AsyncMock]: + """Mock aiontfy.""" + + with ( + patch("homeassistant.components.ntfy.Ntfy", autospec=True) as mock_client, + patch("homeassistant.components.ntfy.config_flow.Ntfy", new=mock_client), + ): + client = mock_client.return_value + + client.publish.return_value = {} + client.generate_token.return_value = AccountTokenResponse( + token="token", last_access=datetime.now() + ) + yield client + + +@pytest.fixture(autouse=True) +def mock_random() -> Generator[MagicMock]: + """Mock random.""" + + with patch( + "homeassistant.components.ntfy.config_flow.random.choices", + return_value=["randomtopic"], + ) as mock_client: + yield mock_client + + +@pytest.fixture(name="config_entry") +def mock_config_entry() -> MockConfigEntry: + """Mock ntfy configuration entry.""" + return MockConfigEntry( + domain=DOMAIN, + title="ntfy.sh", + data={ + CONF_URL: "https://ntfy.sh/", + CONF_USERNAME: None, + CONF_TOKEN: "token", + }, + entry_id="123456789", + subentries_data=[ + ConfigSubentryData( + data={CONF_TOPIC: "mytopic"}, + subentry_id="ABCDEF", + subentry_type="topic", + title="mytopic", + unique_id="mytopic", + ) + ], + ) diff --git a/tests/components/ntfy/snapshots/test_notify.ambr b/tests/components/ntfy/snapshots/test_notify.ambr new file mode 100644 index 00000000000..619ae59cc2f --- /dev/null +++ b/tests/components/ntfy/snapshots/test_notify.ambr @@ -0,0 +1,49 @@ +# serializer version: 1 +# name: test_notify_platform[notify.mytopic-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'notify', + 'entity_category': None, + 'entity_id': 'notify.mytopic', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': None, + 'platform': 'ntfy', + 'previous_unique_id': None, + 'supported_features': , + 'translation_key': 'publish', + 'unique_id': '123456789_ABCDEF_publish', + 'unit_of_measurement': None, + }) +# --- +# name: test_notify_platform[notify.mytopic-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'mytopic', + 'supported_features': , + }), + 'context': , + 'entity_id': 'notify.mytopic', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- diff --git a/tests/components/ntfy/test_config_flow.py b/tests/components/ntfy/test_config_flow.py new file mode 100644 index 00000000000..9e719eff154 --- /dev/null +++ b/tests/components/ntfy/test_config_flow.py @@ -0,0 +1,350 @@ +"""Test the ntfy config flow.""" + +from typing import Any +from unittest.mock import AsyncMock + +from aiontfy.exceptions import ( + NtfyException, + NtfyHTTPError, + NtfyUnauthorizedAuthenticationError, +) +import pytest + +from homeassistant.components.ntfy.const import CONF_TOPIC, DOMAIN, SECTION_AUTH +from homeassistant.config_entries import SOURCE_USER, ConfigSubentry +from homeassistant.const import ( + CONF_NAME, + CONF_PASSWORD, + CONF_TOKEN, + CONF_URL, + CONF_USERNAME, +) +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType + +from tests.common import MockConfigEntry + + +@pytest.mark.parametrize( + ("user_input", "entry_data"), + [ + ( + { + CONF_URL: "https://ntfy.sh", + SECTION_AUTH: {CONF_USERNAME: "username", CONF_PASSWORD: "password"}, + }, + { + CONF_URL: "https://ntfy.sh/", + CONF_USERNAME: "username", + CONF_TOKEN: "token", + }, + ), + ( + {CONF_URL: "https://ntfy.sh", SECTION_AUTH: {}}, + {CONF_URL: "https://ntfy.sh/", CONF_USERNAME: None, CONF_TOKEN: "token"}, + ), + ], +) +@pytest.mark.usefixtures("mock_aiontfy") +async def test_form( + hass: HomeAssistant, + mock_setup_entry: AsyncMock, + user_input: dict[str, Any], + entry_data: dict[str, Any], +) -> None: + """Test we get the form.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {} + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input, + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == "ntfy.sh" + assert result["data"] == entry_data + assert len(mock_setup_entry.mock_calls) == 1 + + +@pytest.mark.parametrize( + ("exception", "error"), + [ + ( + NtfyHTTPError(418001, 418, "I'm a teapot", ""), + "cannot_connect", + ), + ( + NtfyUnauthorizedAuthenticationError( + 40101, + 401, + "unauthorized", + "https://ntfy.sh/docs/publish/#authentication", + ), + "invalid_auth", + ), + (NtfyException, "cannot_connect"), + (TypeError, "unknown"), + ], +) +async def test_form_errors( + hass: HomeAssistant, + mock_setup_entry: AsyncMock, + mock_aiontfy: AsyncMock, + exception: Exception, + error: str, +) -> None: + """Test we handle invalid auth.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + mock_aiontfy.account.side_effect = exception + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_URL: "https://ntfy.sh", + SECTION_AUTH: {CONF_USERNAME: "username", CONF_PASSWORD: "password"}, + }, + ) + + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {"base": error} + + mock_aiontfy.account.side_effect = None + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_URL: "https://ntfy.sh", + SECTION_AUTH: {CONF_USERNAME: "username", CONF_PASSWORD: "password"}, + }, + ) + await hass.async_block_till_done() + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == "ntfy.sh" + assert result["data"] == { + CONF_URL: "https://ntfy.sh/", + CONF_USERNAME: "username", + CONF_TOKEN: "token", + } + assert len(mock_setup_entry.mock_calls) == 1 + + +@pytest.mark.usefixtures("mock_aiontfy") +async def test_form_already_configured( + hass: HomeAssistant, + config_entry: MockConfigEntry, +) -> None: + """Test we abort when entry is already configured.""" + + config_entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={CONF_URL: "https://ntfy.sh", SECTION_AUTH: {}}, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" + + +@pytest.mark.usefixtures("mock_aiontfy") +async def test_add_topic_flow(hass: HomeAssistant) -> None: + """Test add topic subentry flow.""" + config_entry = MockConfigEntry( + domain=DOMAIN, + data={CONF_URL: "https://ntfy.sh/", CONF_USERNAME: None}, + ) + config_entry.add_to_hass(hass) + + assert await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + result = await hass.config_entries.subentries.async_init( + (config_entry.entry_id, "topic"), + context={"source": SOURCE_USER}, + ) + + assert result["type"] is FlowResultType.MENU + assert "add_topic" in result["menu_options"] + assert result["step_id"] == "user" + + result = await hass.config_entries.subentries.async_configure( + result["flow_id"], + {"next_step_id": "add_topic"}, + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "add_topic" + + result = await hass.config_entries.subentries.async_configure( + result["flow_id"], + user_input={CONF_TOPIC: "mytopic"}, + ) + assert result["type"] is FlowResultType.CREATE_ENTRY + subentry_id = list(config_entry.subentries)[0] + assert config_entry.subentries == { + subentry_id: ConfigSubentry( + data={CONF_TOPIC: "mytopic"}, + subentry_id=subentry_id, + subentry_type="topic", + title="mytopic", + unique_id="mytopic", + ) + } + + await hass.async_block_till_done() + + +@pytest.mark.usefixtures("mock_aiontfy") +async def test_generated_topic(hass: HomeAssistant, mock_random: AsyncMock) -> None: + """Test add topic subentry flow with generated topic name.""" + config_entry = MockConfigEntry( + domain=DOMAIN, + data={CONF_URL: "https://ntfy.sh/"}, + ) + config_entry.add_to_hass(hass) + + assert await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + result = await hass.config_entries.subentries.async_init( + (config_entry.entry_id, "topic"), + context={"source": SOURCE_USER}, + ) + + assert result["type"] is FlowResultType.MENU + assert "generate_topic" in result["menu_options"] + assert result["step_id"] == "user" + + result = await hass.config_entries.subentries.async_configure( + result["flow_id"], + {"next_step_id": "generate_topic"}, + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "add_topic" + + result = await hass.config_entries.subentries.async_configure( + result["flow_id"], + user_input={CONF_TOPIC: ""}, + ) + + mock_random.assert_called_once() + + result = await hass.config_entries.subentries.async_configure( + result["flow_id"], + user_input={CONF_TOPIC: "randomtopic", CONF_NAME: "mytopic"}, + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + subentry_id = list(config_entry.subentries)[0] + assert config_entry.subentries == { + subentry_id: ConfigSubentry( + data={CONF_TOPIC: "randomtopic", CONF_NAME: "mytopic"}, + subentry_id=subentry_id, + subentry_type="topic", + title="mytopic", + unique_id="randomtopic", + ) + } + + +@pytest.mark.usefixtures("mock_aiontfy") +async def test_invalid_topic(hass: HomeAssistant, mock_random: AsyncMock) -> None: + """Test add topic subentry flow with invalid topic name.""" + config_entry = MockConfigEntry( + domain=DOMAIN, + data={CONF_URL: "https://ntfy.sh/"}, + ) + config_entry.add_to_hass(hass) + + assert await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + result = await hass.config_entries.subentries.async_init( + (config_entry.entry_id, "topic"), + context={"source": SOURCE_USER}, + ) + + assert result["type"] is FlowResultType.MENU + assert "add_topic" in result["menu_options"] + assert result["step_id"] == "user" + + result = await hass.config_entries.subentries.async_configure( + result["flow_id"], + {"next_step_id": "add_topic"}, + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "add_topic" + + result = await hass.config_entries.subentries.async_configure( + result["flow_id"], + user_input={CONF_TOPIC: "invalid,topic"}, + ) + + assert result["type"] == FlowResultType.FORM + assert result["errors"] == {"base": "invalid_topic"} + + result = await hass.config_entries.subentries.async_configure( + result["flow_id"], + user_input={CONF_TOPIC: "mytopic"}, + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + subentry_id = list(config_entry.subentries)[0] + assert config_entry.subentries == { + subentry_id: ConfigSubentry( + data={CONF_TOPIC: "mytopic"}, + subentry_id=subentry_id, + subentry_type="topic", + title="mytopic", + unique_id="mytopic", + ) + } + + +@pytest.mark.usefixtures("mock_aiontfy") +async def test_topic_already_configured( + hass: HomeAssistant, + config_entry: MockConfigEntry, +) -> None: + """Test we abort when entry is already configured.""" + + config_entry.add_to_hass(hass) + + assert await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + result = await hass.config_entries.subentries.async_init( + (config_entry.entry_id, "topic"), + context={"source": SOURCE_USER}, + ) + assert result["type"] is FlowResultType.MENU + assert "add_topic" in result["menu_options"] + assert result["step_id"] == "user" + + result = await hass.config_entries.subentries.async_configure( + result["flow_id"], + {"next_step_id": "add_topic"}, + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "add_topic" + + result = await hass.config_entries.subentries.async_configure( + result["flow_id"], + user_input={CONF_TOPIC: "mytopic"}, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" diff --git a/tests/components/ntfy/test_init.py b/tests/components/ntfy/test_init.py new file mode 100644 index 00000000000..2ee90854426 --- /dev/null +++ b/tests/components/ntfy/test_init.py @@ -0,0 +1,60 @@ +"""Tests for the ntfy integration.""" + +from unittest.mock import AsyncMock + +from aiontfy.exceptions import ( + NtfyConnectionError, + NtfyHTTPError, + NtfyTimeoutError, + NtfyUnauthorizedAuthenticationError, +) +import pytest + +from homeassistant.config_entries import ConfigEntryState +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry + + +@pytest.mark.usefixtures("mock_aiontfy") +async def test_entry_setup_unload( + hass: HomeAssistant, config_entry: MockConfigEntry +) -> None: + """Test integration setup and unload.""" + + config_entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + assert config_entry.state is ConfigEntryState.LOADED + + assert await hass.config_entries.async_unload(config_entry.entry_id) + + assert config_entry.state is ConfigEntryState.NOT_LOADED + + +@pytest.mark.parametrize( + ("exception"), + [ + NtfyUnauthorizedAuthenticationError( + 40101, 401, "unauthorized", "https://ntfy.sh/docs/publish/#authentication" + ), + NtfyHTTPError(418001, 418, "I'm a teapot", ""), + NtfyConnectionError, + NtfyTimeoutError, + ], +) +async def test_config_entry_not_ready( + hass: HomeAssistant, + config_entry: MockConfigEntry, + mock_aiontfy: AsyncMock, + exception: Exception, +) -> None: + """Test config entry not ready.""" + + mock_aiontfy.account.side_effect = exception + config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + assert config_entry.state is ConfigEntryState.SETUP_RETRY diff --git a/tests/components/ntfy/test_notify.py b/tests/components/ntfy/test_notify.py new file mode 100644 index 00000000000..76bf1049ae8 --- /dev/null +++ b/tests/components/ntfy/test_notify.py @@ -0,0 +1,137 @@ +"""Tests for the ntfy notify platform.""" + +from collections.abc import AsyncGenerator +from unittest.mock import patch + +from aiontfy import Message +from aiontfy.exceptions import NtfyException, NtfyHTTPError +from freezegun.api import freeze_time +import pytest +from syrupy.assertion import SnapshotAssertion + +from homeassistant.components.notify import ( + ATTR_MESSAGE, + ATTR_TITLE, + DOMAIN as NOTIFY_DOMAIN, + SERVICE_SEND_MESSAGE, +) +from homeassistant.config_entries import ConfigEntryState +from homeassistant.const import ATTR_ENTITY_ID, STATE_UNKNOWN, Platform +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers import entity_registry as er + +from tests.common import AsyncMock, MockConfigEntry, snapshot_platform + + +@pytest.fixture(autouse=True) +async def notify_only() -> AsyncGenerator[None]: + """Enable only the notify platform.""" + with patch( + "homeassistant.components.ntfy.PLATFORMS", + [Platform.NOTIFY], + ): + yield + + +@pytest.mark.usefixtures("mock_aiontfy") +async def test_notify_platform( + hass: HomeAssistant, + config_entry: MockConfigEntry, + snapshot: SnapshotAssertion, + entity_registry: er.EntityRegistry, +) -> None: + """Test setup of the ntfy notify platform.""" + + config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + assert config_entry.state is ConfigEntryState.LOADED + + await snapshot_platform(hass, entity_registry, snapshot, config_entry.entry_id) + + +@freeze_time("2025-01-09T12:00:00+00:00") +async def test_send_message( + hass: HomeAssistant, + config_entry: MockConfigEntry, + mock_aiontfy: AsyncMock, +) -> None: + """Test publishing ntfy message.""" + + config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + assert config_entry.state is ConfigEntryState.LOADED + + state = hass.states.get("notify.mytopic") + assert state + assert state.state == STATE_UNKNOWN + + await hass.services.async_call( + NOTIFY_DOMAIN, + SERVICE_SEND_MESSAGE, + { + ATTR_ENTITY_ID: "notify.mytopic", + ATTR_MESSAGE: "triggered", + ATTR_TITLE: "test", + }, + blocking=True, + ) + + state = hass.states.get("notify.mytopic") + assert state + assert state.state == "2025-01-09T12:00:00+00:00" + + mock_aiontfy.publish.assert_called_once_with( + Message(topic="mytopic", message="triggered", title="test") + ) + + +@pytest.mark.parametrize( + ("exception", "error_msg"), + [ + ( + NtfyHTTPError(41801, 418, "I'm a teapot", ""), + "Failed to publish notification: I'm a teapot", + ), + ( + NtfyException, + "Failed to publish notification due to a connection error", + ), + ], +) +async def test_send_message_exception( + hass: HomeAssistant, + config_entry: MockConfigEntry, + mock_aiontfy: AsyncMock, + exception: Exception, + error_msg: str, +) -> None: + """Test publish message exceptions.""" + + config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + assert config_entry.state is ConfigEntryState.LOADED + + mock_aiontfy.publish.side_effect = exception + + with pytest.raises(HomeAssistantError, match=error_msg): + await hass.services.async_call( + NOTIFY_DOMAIN, + SERVICE_SEND_MESSAGE, + { + ATTR_ENTITY_ID: "notify.mytopic", + ATTR_MESSAGE: "triggered", + ATTR_TITLE: "test", + }, + blocking=True, + ) + + mock_aiontfy.publish.assert_called_once_with( + Message(topic="mytopic", message="triggered", title="test") + ) diff --git a/tests/components/nut/test_config_flow.py b/tests/components/nut/test_config_flow.py index c0e7f9ffeff..6e308e22faa 100644 --- a/tests/components/nut/test_config_flow.py +++ b/tests/components/nut/test_config_flow.py @@ -5,7 +5,7 @@ from unittest.mock import patch from aionut import NUTError, NUTLoginError -from homeassistant import config_entries, setup +from homeassistant import config_entries from homeassistant.components.nut.config_flow import PASSWORD_NOT_CHANGED from homeassistant.components.nut.const import DOMAIN from homeassistant.const import ( @@ -86,7 +86,6 @@ async def test_form_zeroconf(hass: HomeAssistant) -> None: async def test_form_user_one_alias(hass: HomeAssistant) -> None: """Test we can configure a device with one alias.""" - await setup.async_setup_component(hass, "persistent_notification", {}) result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} ) @@ -131,8 +130,6 @@ async def test_form_user_one_alias(hass: HomeAssistant) -> None: async def test_form_user_multiple_aliases(hass: HomeAssistant) -> None: """Test we can configure device with multiple aliases.""" - await setup.async_setup_component(hass, "persistent_notification", {}) - config_entry = MockConfigEntry( domain=DOMAIN, data={CONF_HOST: "2.2.2.2", CONF_PORT: 123, CONF_RESOURCES: ["battery.charge"]}, @@ -202,7 +199,6 @@ async def test_form_user_one_alias_with_ignored_entry(hass: HomeAssistant) -> No ) ignored_entry.add_to_hass(hass) - await setup.async_setup_component(hass, "persistent_notification", {}) result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} ) diff --git a/tests/components/nut/test_init.py b/tests/components/nut/test_init.py index b3cf23bddcc..6f1fb94478d 100644 --- a/tests/components/nut/test_init.py +++ b/tests/components/nut/test_init.py @@ -18,10 +18,12 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr +from homeassistant.setup import async_setup_component from .util import _get_mock_nutclient, async_init_integration from tests.common import MockConfigEntry +from tests.typing import WebSocketGenerator async def test_config_entry_migrations(hass: HomeAssistant) -> None: @@ -84,6 +86,78 @@ async def test_async_setup_entry(hass: HomeAssistant) -> None: assert not hass.data.get(DOMAIN) +async def test_remove_device_valid( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + device_registry: dr.DeviceRegistry, +) -> None: + """Test that we cannot remove a device that still exists.""" + assert await async_setup_component(hass, "config", {}) + + mock_serial_number = "A00000000000" + config_entry = await async_init_integration( + hass, + username="someuser", + password="somepassword", + list_vars={"ups.serial": mock_serial_number}, + list_ups={"ups1": "UPS 1"}, + list_commands_return_value=[], + ) + + device_registry = dr.async_get(hass) + assert device_registry is not None + + device_entry = device_registry.async_get_device( + identifiers={(DOMAIN, mock_serial_number)} + ) + + assert device_entry is not None + assert device_entry.serial_number == mock_serial_number + + client = await hass_ws_client(hass) + response = await client.remove_device(device_entry.id, config_entry.entry_id) + assert not response["success"] + + +async def test_remove_device_stale( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + device_registry: dr.DeviceRegistry, +) -> None: + """Test that we can remove a device that no longer exists.""" + assert await async_setup_component(hass, "config", {}) + + mock_serial_number = "A00000000000" + config_entry = await async_init_integration( + hass, + username="someuser", + password="somepassword", + list_vars={"ups.serial": mock_serial_number}, + list_ups={"ups1": "UPS 1"}, + list_commands_return_value=[], + ) + + device_registry = dr.async_get(hass) + assert device_registry is not None + + device_entry = device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + identifiers={(DOMAIN, "remove-device-id")}, + ) + + assert device_entry is not None + + client = await hass_ws_client(hass) + response = await client.remove_device(device_entry.id, config_entry.entry_id) + assert response["success"] + + # Verify that device entry is removed + device_entry = device_registry.async_get_device( + identifiers={(DOMAIN, "remove-device-id")} + ) + assert device_entry is None + + async def test_config_not_ready( hass: HomeAssistant, caplog: pytest.LogCaptureFixture, @@ -148,7 +222,10 @@ async def test_auth_fails( assert flows[0]["context"]["source"] == "reauth" -async def test_serial_number(hass: HomeAssistant) -> None: +async def test_serial_number( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, +) -> None: """Test for serial number set on device.""" mock_serial_number = "A00000000000" await async_init_integration( @@ -160,9 +237,6 @@ async def test_serial_number(hass: HomeAssistant) -> None: list_commands_return_value=[], ) - device_registry = dr.async_get(hass) - assert device_registry is not None - device_entry = device_registry.async_get_device( identifiers={(DOMAIN, mock_serial_number)} ) @@ -171,7 +245,10 @@ async def test_serial_number(hass: HomeAssistant) -> None: assert device_entry.serial_number == mock_serial_number -async def test_device_location(hass: HomeAssistant) -> None: +async def test_device_location( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, +) -> None: """Test for suggested location on device.""" mock_serial_number = "A00000000000" mock_device_location = "XYZ Location" @@ -187,9 +264,6 @@ async def test_device_location(hass: HomeAssistant) -> None: list_commands_return_value=[], ) - device_registry = dr.async_get(hass) - assert device_registry is not None - device_entry = device_registry.async_get_device( identifiers={(DOMAIN, mock_serial_number)} ) diff --git a/tests/components/nut/test_sensor.py b/tests/components/nut/test_sensor.py index 89f06c934f8..db9028222b1 100644 --- a/tests/components/nut/test_sensor.py +++ b/tests/components/nut/test_sensor.py @@ -125,7 +125,6 @@ async def test_pdu_devices_with_unique_ids( _test_sensor_and_attributes( hass, entity_registry, - model, unique_id=f"{unique_id_base}_input.voltage", device_id="sensor.ups1_input_voltage", state_value="122.91", @@ -140,7 +139,6 @@ async def test_pdu_devices_with_unique_ids( _test_sensor_and_attributes( hass, entity_registry, - model, unique_id=f"{unique_id_base}_ambient.humidity.status", device_id="sensor.ups1_ambient_humidity_status", state_value="good", @@ -153,7 +151,6 @@ async def test_pdu_devices_with_unique_ids( _test_sensor_and_attributes( hass, entity_registry, - model, unique_id=f"{unique_id_base}_ambient.temperature.status", device_id="sensor.ups1_ambient_temperature_status", state_value="good", @@ -334,7 +331,6 @@ async def test_pdu_dynamic_outlets( _test_sensor_and_attributes( hass, entity_registry, - model, unique_id=f"{unique_id_base}_outlet.1.current", device_id="sensor.ups1_outlet_a1_current", state_value="0", @@ -348,7 +344,6 @@ async def test_pdu_dynamic_outlets( _test_sensor_and_attributes( hass, entity_registry, - model, unique_id=f"{unique_id_base}_outlet.24.current", device_id="sensor.ups1_outlet_a24_current", state_value="0.19", diff --git a/tests/components/nut/util.py b/tests/components/nut/util.py index 889fdc327af..49510fc9d72 100644 --- a/tests/components/nut/util.py +++ b/tests/components/nut/util.py @@ -43,7 +43,7 @@ async def async_init_integration( hass: HomeAssistant, ups_fixture: str | None = None, host: str = "mock", - port: str = "mock", + port: int = 1234, username: str = "mock", password: str = "mock", alias: str | None = None, @@ -104,7 +104,6 @@ async def async_init_integration( def _test_sensor_and_attributes( hass: HomeAssistant, entity_registry: er.EntityRegistry, - model: str, unique_id: str, device_id: str, state_value: str, diff --git a/tests/components/onedrive/test_backup.py b/tests/components/onedrive/test_backup.py index a81eb03a51c..f3f2fbdad40 100644 --- a/tests/components/onedrive/test_backup.py +++ b/tests/components/onedrive/test_backup.py @@ -75,7 +75,6 @@ async def test_agents_info( async def test_agents_list_backups( hass: HomeAssistant, hass_ws_client: WebSocketGenerator, - mock_config_entry: MockConfigEntry, ) -> None: """Test agent list backups.""" @@ -105,6 +104,22 @@ async def test_agents_list_backups( ] +async def test_agents_list_backups_with_download_failure( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + mock_onedrive_client: MagicMock, +) -> None: + """Test agent list backups still works if one of the items fails to download.""" + mock_onedrive_client.download_drive_item.side_effect = OneDriveException("test") + client = await hass_ws_client(hass) + await client.send_json_auto_id({"type": "backup/info"}) + response = await client.receive_json() + + assert response["success"] + assert response["result"]["agent_errors"] == {} + assert response["result"]["backups"] == [] + + async def test_agents_get_backup( hass: HomeAssistant, hass_ws_client: WebSocketGenerator, @@ -231,6 +246,78 @@ async def test_agents_upload_corrupt_upload( assert "Hash validation failed, backup file might be corrupt" in caplog.text +async def test_agents_upload_metadata_upload_failed( + hass_client: ClientSessionGenerator, + caplog: pytest.LogCaptureFixture, + mock_onedrive_client: MagicMock, + mock_large_file_upload_client: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test metadata upload fails.""" + client = await hass_client() + test_backup = AgentBackup.from_dict(BACKUP_METADATA) + mock_onedrive_client.upload_file.side_effect = OneDriveException("test") + + with ( + patch( + "homeassistant.components.backup.manager.BackupManager.async_get_backup", + ) as fetch_backup, + patch( + "homeassistant.components.backup.manager.read_backup", + return_value=test_backup, + ), + patch("pathlib.Path.open") as mocked_open, + ): + mocked_open.return_value.read = Mock(side_effect=[b"test", b""]) + fetch_backup.return_value = test_backup + resp = await client.post( + f"/api/backup/upload?agent_id={DOMAIN}.{mock_config_entry.unique_id}", + data={"file": StringIO("test")}, + ) + + assert resp.status == 201 + assert f"Uploading backup {test_backup.backup_id}" in caplog.text + mock_large_file_upload_client.assert_called_once() + mock_onedrive_client.delete_drive_item.assert_called_once() + assert mock_onedrive_client.update_drive_item.call_count == 0 + + +async def test_agents_upload_metadata_metadata_failed( + hass_client: ClientSessionGenerator, + caplog: pytest.LogCaptureFixture, + mock_onedrive_client: MagicMock, + mock_large_file_upload_client: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test metadata upload on file description update.""" + client = await hass_client() + test_backup = AgentBackup.from_dict(BACKUP_METADATA) + mock_onedrive_client.update_drive_item.side_effect = OneDriveException("test") + + with ( + patch( + "homeassistant.components.backup.manager.BackupManager.async_get_backup", + ) as fetch_backup, + patch( + "homeassistant.components.backup.manager.read_backup", + return_value=test_backup, + ), + patch("pathlib.Path.open") as mocked_open, + ): + mocked_open.return_value.read = Mock(side_effect=[b"test", b""]) + fetch_backup.return_value = test_backup + resp = await client.post( + f"/api/backup/upload?agent_id={DOMAIN}.{mock_config_entry.unique_id}", + data={"file": StringIO("test")}, + ) + + assert resp.status == 201 + assert f"Uploading backup {test_backup.backup_id}" in caplog.text + mock_large_file_upload_client.assert_called_once() + assert mock_onedrive_client.update_drive_item.call_count == 1 + assert mock_onedrive_client.delete_drive_item.call_count == 2 + + async def test_agents_download( hass_client: ClientSessionGenerator, mock_onedrive_client: MagicMock, diff --git a/tests/components/overkiz/test_config_flow.py b/tests/components/overkiz/test_config_flow.py index 711cc6c1d86..5c98b4e9260 100644 --- a/tests/components/overkiz/test_config_flow.py +++ b/tests/components/overkiz/test_config_flow.py @@ -40,6 +40,7 @@ TEST_GATEWAY_ID3 = "SOMFY_PROTECT-v0NT53occUBPyuJRzx59kalW1hFfzimN" TEST_HOST = "gateway-1234-5678-9123.local:8443" TEST_HOST2 = "192.168.11.104:8443" +TEST_TOKEN = "1234123412341234" MOCK_GATEWAY_RESPONSE = [Mock(id=TEST_GATEWAY_ID)] MOCK_GATEWAY2_RESPONSE = [Mock(id=TEST_GATEWAY_ID3), Mock(id=TEST_GATEWAY_ID2)] @@ -152,7 +153,7 @@ async def test_form_only_cloud_supported( async def test_form_local_happy_flow( hass: HomeAssistant, mock_setup_entry: AsyncMock ) -> None: - """Test we get the form.""" + """Test local API configuration flow.""" result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} ) @@ -179,21 +180,27 @@ async def test_form_local_happy_flow( "pyoverkiz.client.OverkizClient", login=AsyncMock(return_value=True), get_gateways=AsyncMock(return_value=MOCK_GATEWAY_RESPONSE), - get_setup_option=AsyncMock(return_value=True), - generate_local_token=AsyncMock(return_value="1234123412341234"), - activate_local_token=AsyncMock(return_value=True), ): - await hass.config_entries.flow.async_configure( + result4 = await hass.config_entries.flow.async_configure( result["flow_id"], { - "username": TEST_EMAIL, - "password": TEST_PASSWORD, "host": "gateway-1234-5678-1234.local:8443", + "token": TEST_TOKEN, + "verify_ssl": True, }, ) await hass.async_block_till_done() + assert result4["type"] is FlowResultType.CREATE_ENTRY + assert result4["title"] == "gateway-1234-5678-1234.local:8443" + assert result4["data"] == { + "host": "gateway-1234-5678-1234.local:8443", + "token": TEST_TOKEN, + "verify_ssl": True, + "hub": TEST_SERVER, + "api_type": "local", + } assert len(mock_setup_entry.mock_calls) == 1 @@ -262,7 +269,7 @@ async def test_form_invalid_auth_cloud( (MaintenanceException, "server_in_maintenance"), (TooManyAttemptsBannedException, "too_many_attempts"), (UnknownUserException, "unsupported_hardware"), - (NotSuchTokenException, "no_such_token"), + (NotSuchTokenException, "invalid_auth"), (Exception, "unknown"), ], ) @@ -297,8 +304,7 @@ async def test_form_invalid_auth_local( result["flow_id"], { "host": TEST_HOST, - "username": TEST_EMAIL, - "password": TEST_PASSWORD, + "token": TEST_TOKEN, "verify_ssl": True, }, ) @@ -309,52 +315,6 @@ async def test_form_invalid_auth_local( assert result4["errors"] == {"base": error} -async def test_form_local_developer_mode_disabled( - hass: HomeAssistant, mock_setup_entry: AsyncMock -) -> None: - """Test we get the form.""" - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_USER} - ) - - assert result["type"] is FlowResultType.FORM - - result2 = await hass.config_entries.flow.async_configure( - result["flow_id"], - {"hub": TEST_SERVER}, - ) - - assert result2["type"] is FlowResultType.FORM - assert result2["step_id"] == "local_or_cloud" - - result3 = await hass.config_entries.flow.async_configure( - result["flow_id"], - {"api_type": "local"}, - ) - - assert result3["type"] is FlowResultType.FORM - assert result3["step_id"] == "local" - - with patch.multiple( - "pyoverkiz.client.OverkizClient", - login=AsyncMock(return_value=True), - get_gateways=AsyncMock(return_value=MOCK_GATEWAY_RESPONSE), - get_setup_option=AsyncMock(return_value=None), - ): - result4 = await hass.config_entries.flow.async_configure( - result["flow_id"], - { - "username": TEST_EMAIL, - "password": TEST_PASSWORD, - "host": "gateway-1234-5678-1234.local:8443", - "verify_ssl": True, - }, - ) - - assert result4["type"] is FlowResultType.FORM - assert result4["errors"] == {"base": "developer_mode_disabled"} - - @pytest.mark.parametrize( ("side_effect", "error"), [ @@ -444,16 +404,18 @@ async def test_cloud_abort_on_duplicate_entry( async def test_local_abort_on_duplicate_entry( hass: HomeAssistant, mock_setup_entry: AsyncMock ) -> None: - """Test we get the form.""" + """Test local API configuration is aborted if gateway already exists.""" MockConfigEntry( domain=DOMAIN, unique_id=TEST_GATEWAY_ID, + version=2, data={ "host": TEST_HOST, - "username": TEST_EMAIL, - "password": TEST_PASSWORD, + "token": TEST_TOKEN, + "verify_ssl": True, "hub": TEST_SERVER, + "api_type": "local", }, ).add_to_hass(hass) @@ -484,15 +446,12 @@ async def test_local_abort_on_duplicate_entry( login=AsyncMock(return_value=True), get_gateways=AsyncMock(return_value=MOCK_GATEWAY_RESPONSE), get_setup_option=AsyncMock(return_value=True), - generate_local_token=AsyncMock(return_value="1234123412341234"), - activate_local_token=AsyncMock(return_value=True), ): result4 = await hass.config_entries.flow.async_configure( result["flow_id"], { "host": TEST_HOST, - "username": TEST_EMAIL, - "password": TEST_PASSWORD, + "token": TEST_TOKEN, "verify_ssl": True, }, ) @@ -639,18 +598,18 @@ async def test_cloud_reauth_wrong_account(hass: HomeAssistant) -> None: assert result2["reason"] == "reauth_wrong_account" -async def test_local_reauth_success(hass: HomeAssistant) -> None: - """Test reauthentication flow.""" - +async def test_local_reauth_legacy(hass: HomeAssistant) -> None: + """Test legacy reauthentication flow with username/password.""" mock_entry = MockConfigEntry( domain=DOMAIN, unique_id=TEST_GATEWAY_ID, version=2, data={ + "host": TEST_HOST, "username": TEST_EMAIL, "password": TEST_PASSWORD, + "verify_ssl": True, "hub": TEST_SERVER, - "host": TEST_HOST, "api_type": "local", }, ) @@ -672,36 +631,85 @@ async def test_local_reauth_success(hass: HomeAssistant) -> None: "pyoverkiz.client.OverkizClient", login=AsyncMock(return_value=True), get_gateways=AsyncMock(return_value=MOCK_GATEWAY_RESPONSE), - get_setup_option=AsyncMock(return_value=True), - generate_local_token=AsyncMock(return_value="1234123412341234"), - activate_local_token=AsyncMock(return_value=True), ): result3 = await hass.config_entries.flow.async_configure( result["flow_id"], - user_input={ - "username": TEST_EMAIL, - "password": TEST_PASSWORD2, + { + "host": TEST_HOST, + "token": "new_token", + "verify_ssl": True, }, ) assert result3["type"] is FlowResultType.ABORT assert result3["reason"] == "reauth_successful" - assert mock_entry.data["username"] == TEST_EMAIL - assert mock_entry.data["password"] == TEST_PASSWORD2 + assert mock_entry.data["host"] == TEST_HOST + assert mock_entry.data["token"] == "new_token" + assert mock_entry.data["verify_ssl"] is True + + +async def test_local_reauth_success(hass: HomeAssistant) -> None: + """Test modern local reauth flow.""" + mock_entry = MockConfigEntry( + domain=DOMAIN, + unique_id=TEST_GATEWAY_ID, + version=2, + data={ + "host": TEST_HOST, + "token": "old_token", + "verify_ssl": True, + "hub": TEST_SERVER, + "api_type": "local", + }, + ) + mock_entry.add_to_hass(hass) + + result = await mock_entry.start_reauth_flow(hass) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "local_or_cloud" + + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + {"api_type": "local"}, + ) + + assert result2["step_id"] == "local" + + with patch.multiple( + "pyoverkiz.client.OverkizClient", + login=AsyncMock(return_value=True), + get_gateways=AsyncMock(return_value=MOCK_GATEWAY_RESPONSE), + ): + result3 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + "host": TEST_HOST, + "token": "new_token", + "verify_ssl": True, + }, + ) + + assert result3["type"] is FlowResultType.ABORT + assert result3["reason"] == "reauth_successful" + assert mock_entry.data["host"] == TEST_HOST + assert mock_entry.data["token"] == "new_token" + assert mock_entry.data["verify_ssl"] is True + assert "username" not in mock_entry.data + assert "password" not in mock_entry.data async def test_local_reauth_wrong_account(hass: HomeAssistant) -> None: - """Test reauthentication flow.""" + """Test local reauth flow with wrong gateway account.""" mock_entry = MockConfigEntry( domain=DOMAIN, unique_id=TEST_GATEWAY_ID2, version=2, data={ - "username": TEST_EMAIL, - "password": TEST_PASSWORD, - "hub": TEST_SERVER, "host": TEST_HOST, + "token": "old_token", + "verify_ssl": True, + "hub": TEST_SERVER, "api_type": "local", }, ) @@ -722,15 +730,13 @@ async def test_local_reauth_wrong_account(hass: HomeAssistant) -> None: "pyoverkiz.client.OverkizClient", login=AsyncMock(return_value=True), get_gateways=AsyncMock(return_value=MOCK_GATEWAY_RESPONSE), - get_setup_option=AsyncMock(return_value=True), - generate_local_token=AsyncMock(return_value="1234123412341234"), - activate_local_token=AsyncMock(return_value=True), ): result3 = await hass.config_entries.flow.async_configure( result["flow_id"], - user_input={ - "username": TEST_EMAIL, - "password": TEST_PASSWORD2, + { + "host": TEST_HOST, + "token": "new_token", + "verify_ssl": True, }, ) @@ -897,27 +903,27 @@ async def test_local_zeroconf_flow( "pyoverkiz.client.OverkizClient", login=AsyncMock(return_value=True), get_gateways=AsyncMock(return_value=MOCK_GATEWAY_RESPONSE), - get_setup_option=AsyncMock(return_value=True), - generate_local_token=AsyncMock(return_value="1234123412341234"), - activate_local_token=AsyncMock(return_value=True), ): result4 = await hass.config_entries.flow.async_configure( result["flow_id"], - {"username": TEST_EMAIL, "password": TEST_PASSWORD, "verify_ssl": False}, + { + "host": "gateway-1234-5678-9123.local:8443", + "token": TEST_TOKEN, + "verify_ssl": False, + }, ) assert result4["type"] is FlowResultType.CREATE_ENTRY assert result4["title"] == "gateway-1234-5678-9123.local:8443" - assert result4["data"] == { - "username": TEST_EMAIL, - "password": TEST_PASSWORD, - "hub": TEST_SERVER, - "host": "gateway-1234-5678-9123.local:8443", - "api_type": "local", - "token": "1234123412341234", - "verify_ssl": False, - } + # Verify no username/password in data + assert result4["data"] == { + "host": "gateway-1234-5678-9123.local:8443", + "token": TEST_TOKEN, + "verify_ssl": False, + "hub": TEST_SERVER, + "api_type": "local", + } assert len(mock_setup_entry.mock_calls) == 1 diff --git a/tests/components/renault/test_config_flow.py b/tests/components/renault/test_config_flow.py index 781b7efe226..9c3c82eaf3a 100644 --- a/tests/components/renault/test_config_flow.py +++ b/tests/components/renault/test_config_flow.py @@ -19,7 +19,7 @@ from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType from homeassistant.helpers import aiohttp_client -from tests.common import MockConfigEntry, load_fixture +from tests.common import MockConfigEntry, get_schema_suggested_value, load_fixture pytestmark = pytest.mark.usefixtures("mock_setup_entry") @@ -67,6 +67,11 @@ async def test_config_flow_single_account( assert result["step_id"] == "user" assert result["errors"] == {"base": error} + data_schema = result["data_schema"].schema + assert get_schema_suggested_value(data_schema, CONF_LOCALE) == "fr_FR" + assert get_schema_suggested_value(data_schema, CONF_USERNAME) == "email@test.com" + assert get_schema_suggested_value(data_schema, CONF_PASSWORD) == "test" + renault_account = AsyncMock() type(renault_account).account_id = PropertyMock(return_value="account_id_1") renault_account.get_vehicles.return_value = ( diff --git a/tests/components/renault/test_services.py b/tests/components/renault/test_services.py index 970d7cf4ad8..1aa31768004 100644 --- a/tests/components/renault/test_services.py +++ b/tests/components/renault/test_services.py @@ -72,13 +72,14 @@ async def test_service_set_ac_cancel( ATTR_VEHICLE: get_device_id(hass), } - with ( - patch( - "renault_api.renault_vehicle.RenaultVehicle.set_ac_stop", - side_effect=RenaultException("Didn't work"), - ) as mock_action, - pytest.raises(HomeAssistantError, match="Didn't work"), - ): + with patch( + "renault_api.renault_vehicle.RenaultVehicle.set_ac_stop", + return_value=( + schemas.KamereonVehicleHvacStartActionDataSchema.loads( + load_fixture("renault/action.set_ac_stop.json") + ) + ), + ) as mock_action: await hass.services.async_call( DOMAIN, SERVICE_AC_CANCEL, service_data=data, blocking=True ) @@ -380,3 +381,28 @@ async def test_service_invalid_device_id2( ) assert err.value.translation_key == "no_config_entry_for_device" assert err.value.translation_placeholders == {"device_id": "REG-NUMBER"} + + +async def test_service_exception( + hass: HomeAssistant, config_entry: ConfigEntry +) -> None: + """Test that service invokes renault_api with correct data.""" + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + data = { + ATTR_VEHICLE: get_device_id(hass), + } + + with ( + patch( + "renault_api.renault_vehicle.RenaultVehicle.set_ac_stop", + side_effect=RenaultException("Didn't work"), + ) as mock_action, + pytest.raises(HomeAssistantError, match="Didn't work"), + ): + await hass.services.async_call( + DOMAIN, SERVICE_AC_CANCEL, service_data=data, blocking=True + ) + assert len(mock_action.mock_calls) == 1 + assert mock_action.mock_calls[0][1] == () diff --git a/tests/components/shelly/test_climate.py b/tests/components/shelly/test_climate.py index b2135fb38af..81914bb6a90 100644 --- a/tests/components/shelly/test_climate.py +++ b/tests/components/shelly/test_climate.py @@ -5,12 +5,11 @@ from unittest.mock import AsyncMock, Mock, PropertyMock from aioshelly.const import ( BLU_TRV_IDENTIFIER, - BLU_TRV_TIMEOUT, MODEL_BLU_GATEWAY_G3, MODEL_VALVE, MODEL_WALL_DISPLAY, ) -from aioshelly.exceptions import DeviceConnectionError, InvalidAuthError +from aioshelly.exceptions import DeviceConnectionError, InvalidAuthError, RpcCallError import pytest from syrupy import SnapshotAssertion @@ -799,15 +798,7 @@ async def test_blu_trv_climate_set_temperature( ) mock_blu_trv.mock_update() - mock_blu_trv.call_rpc.assert_called_once_with( - "BluTRV.Call", - { - "id": 200, - "method": "Trv.SetTarget", - "params": {"id": 0, "target_C": 28.0}, - }, - BLU_TRV_TIMEOUT, - ) + mock_blu_trv.blu_trv_set_target_temperature.assert_called_once_with(200, 28.0) assert (state := hass.states.get(entity_id)) assert state.attributes[ATTR_TEMPERATURE] == 28 @@ -857,3 +848,66 @@ async def test_blu_trv_climate_hvac_action( assert (state := hass.states.get(entity_id)) assert state.attributes[ATTR_HVAC_ACTION] == HVACAction.HEATING + + +@pytest.mark.parametrize( + ("exception", "error"), + [ + ( + DeviceConnectionError, + "Device communication error occurred while calling action for climate.trv_name of Test name", + ), + ( + RpcCallError(999), + "RPC call error occurred while calling action for climate.trv_name of Test name", + ), + ], +) +async def test_blu_trv_set_target_temp_exc( + hass: HomeAssistant, + mock_blu_trv: Mock, + exception: Exception, + error: str, +) -> None: + """BLU TRV target temperature setting test with excepton.""" + await init_integration(hass, 3, model=MODEL_BLU_GATEWAY_G3) + + mock_blu_trv.blu_trv_set_target_temperature.side_effect = exception + + with pytest.raises(HomeAssistantError, match=error): + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_TEMPERATURE, + {ATTR_ENTITY_ID: "climate.trv_name", ATTR_TEMPERATURE: 28}, + blocking=True, + ) + + +async def test_blu_trv_set_target_temp_auth_error( + hass: HomeAssistant, + mock_blu_trv: Mock, +) -> None: + """BLU TRV target temperature setting test with authentication error.""" + entry = await init_integration(hass, 3, model=MODEL_BLU_GATEWAY_G3) + + mock_blu_trv.blu_trv_set_target_temperature.side_effect = InvalidAuthError + + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_TEMPERATURE, + {ATTR_ENTITY_ID: "climate.trv_name", ATTR_TEMPERATURE: 28}, + blocking=True, + ) + + assert entry.state is ConfigEntryState.LOADED + + flows = hass.config_entries.flow.async_progress() + assert len(flows) == 1 + + flow = flows[0] + assert flow.get("step_id") == "reauth_confirm" + assert flow.get("handler") == DOMAIN + + assert "context" in flow + assert flow["context"].get("source") == SOURCE_REAUTH + assert flow["context"].get("entry_id") == entry.entry_id diff --git a/tests/components/shelly/test_number.py b/tests/components/shelly/test_number.py index 41002917d86..8589d643b2b 100644 --- a/tests/components/shelly/test_number.py +++ b/tests/components/shelly/test_number.py @@ -3,8 +3,8 @@ from copy import deepcopy from unittest.mock import AsyncMock, Mock -from aioshelly.const import BLU_TRV_TIMEOUT, MODEL_BLU_GATEWAY_G3 -from aioshelly.exceptions import DeviceConnectionError, InvalidAuthError +from aioshelly.const import MODEL_BLU_GATEWAY_G3 +from aioshelly.exceptions import DeviceConnectionError, InvalidAuthError, RpcCallError import pytest from syrupy import SnapshotAssertion @@ -334,6 +334,8 @@ async def test_rpc_device_virtual_number( blocking=True, ) mock_rpc_device.mock_update() + mock_rpc_device.number_set.assert_called_once_with(203, 56.7) + assert (state := hass.states.get(entity_id)) assert state.state == "56.7" @@ -446,15 +448,7 @@ async def test_blu_trv_ext_temp_set_value( blocking=True, ) mock_blu_trv.mock_update() - mock_blu_trv.call_rpc.assert_called_once_with( - "BluTRV.Call", - { - "id": 200, - "method": "Trv.SetExternalTemperature", - "params": {"id": 0, "t_C": 22.2}, - }, - BLU_TRV_TIMEOUT, - ) + mock_blu_trv.blu_trv_set_external_temperature.assert_called_once_with(200, 22.2) assert (state := hass.states.get(entity_id)) assert state.state == "22.2" @@ -487,17 +481,77 @@ async def test_blu_trv_valve_pos_set_value( blocking=True, ) mock_blu_trv.mock_update() - mock_blu_trv.call_rpc.assert_called_once_with( - "BluTRV.Call", - { - "id": 200, - "method": "Trv.SetPosition", - "params": {"id": 0, "pos": 20}, - }, - BLU_TRV_TIMEOUT, - ) - # device only accepts int for 'pos' value - assert isinstance(mock_blu_trv.call_rpc.call_args[0][1]["params"]["pos"], int) + mock_blu_trv.blu_trv_set_valve_position.assert_called_once_with(200, 20.0) assert (state := hass.states.get(entity_id)) assert state.state == "20" + + +@pytest.mark.parametrize( + ("exception", "error"), + [ + ( + DeviceConnectionError, + "Device communication error occurred while calling action for number.trv_name_external_temperature of Test name", + ), + ( + RpcCallError(999), + "RPC call error occurred while calling action for number.trv_name_external_temperature of Test name", + ), + ], +) +async def test_blu_trv_number_exc( + hass: HomeAssistant, + mock_blu_trv: Mock, + exception: Exception, + error: str, +) -> None: + """Test RPC/BLU TRV number with exception.""" + await init_integration(hass, 3, model=MODEL_BLU_GATEWAY_G3) + + mock_blu_trv.blu_trv_set_external_temperature.side_effect = exception + + with pytest.raises(HomeAssistantError, match=error): + await hass.services.async_call( + NUMBER_DOMAIN, + SERVICE_SET_VALUE, + { + ATTR_ENTITY_ID: f"{NUMBER_DOMAIN}.trv_name_external_temperature", + ATTR_VALUE: 20.0, + }, + blocking=True, + ) + + +async def test_blu_trv_number_reauth_error( + hass: HomeAssistant, + mock_blu_trv: Mock, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Test RPC/BLU TRV number with authentication error.""" + entry = await init_integration(hass, 3, model=MODEL_BLU_GATEWAY_G3) + + mock_blu_trv.blu_trv_set_external_temperature.side_effect = InvalidAuthError + + await hass.services.async_call( + NUMBER_DOMAIN, + SERVICE_SET_VALUE, + { + ATTR_ENTITY_ID: f"{NUMBER_DOMAIN}.trv_name_external_temperature", + ATTR_VALUE: 20.0, + }, + blocking=True, + ) + + assert entry.state is ConfigEntryState.LOADED + + flows = hass.config_entries.flow.async_progress() + assert len(flows) == 1 + + flow = flows[0] + assert flow.get("step_id") == "reauth_confirm" + assert flow.get("handler") == DOMAIN + + assert "context" in flow + assert flow["context"].get("source") == SOURCE_REAUTH + assert flow["context"].get("entry_id") == entry.entry_id diff --git a/tests/components/shelly/test_select.py b/tests/components/shelly/test_select.py index 39e426baa58..bb68edd1961 100644 --- a/tests/components/shelly/test_select.py +++ b/tests/components/shelly/test_select.py @@ -3,6 +3,7 @@ from copy import deepcopy from unittest.mock import Mock +from aioshelly.exceptions import DeviceConnectionError, InvalidAuthError, RpcCallError import pytest from homeassistant.components.select import ( @@ -11,8 +12,11 @@ from homeassistant.components.select import ( DOMAIN as SELECT_PLATFORM, SERVICE_SELECT_OPTION, ) +from homeassistant.components.shelly.const import DOMAIN +from homeassistant.config_entries import SOURCE_REAUTH, ConfigEntryState from homeassistant.const import ATTR_ENTITY_ID, STATE_UNKNOWN from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.device_registry import DeviceRegistry from homeassistant.helpers.entity_registry import EntityRegistry @@ -81,7 +85,7 @@ async def test_rpc_device_virtual_enum( blocking=True, ) # 'Title 1' corresponds to 'option 1' - assert mock_rpc_device.call_rpc.call_args[0][1] == {"id": 203, "value": "option 1"} + mock_rpc_device.enum_set.assert_called_once_with(203, "option 1") mock_rpc_device.mock_update() assert (state := hass.states.get(entity_id)) @@ -149,3 +153,108 @@ async def test_rpc_remove_virtual_enum_when_orphaned( await hass.async_block_till_done() assert entity_registry.async_get(entity_id) is None + + +@pytest.mark.parametrize( + ("exception", "error"), + [ + ( + DeviceConnectionError, + "Device communication error occurred while calling action for select.test_name_enum_203 of Test name", + ), + ( + RpcCallError(999), + "RPC call error occurred while calling action for select.test_name_enum_203 of Test name", + ), + ], +) +async def test_select_set_exc( + hass: HomeAssistant, + mock_rpc_device: Mock, + monkeypatch: pytest.MonkeyPatch, + exception: Exception, + error: str, +) -> None: + """Test select setting with exception.""" + config = deepcopy(mock_rpc_device.config) + config["enum:203"] = { + "name": None, + "options": ["option 1", "option 2", "option 3"], + "meta": { + "ui": { + "view": "dropdown", + "titles": {"option 1": "Title 1", "option 2": None}, + } + }, + } + monkeypatch.setattr(mock_rpc_device, "config", config) + + status = deepcopy(mock_rpc_device.status) + status["enum:203"] = {"value": "option 1"} + monkeypatch.setattr(mock_rpc_device, "status", status) + + await init_integration(hass, 3) + + mock_rpc_device.enum_set.side_effect = exception + + with pytest.raises(HomeAssistantError, match=error): + await hass.services.async_call( + SELECT_PLATFORM, + SERVICE_SELECT_OPTION, + { + ATTR_ENTITY_ID: f"{SELECT_PLATFORM}.test_name_enum_203", + ATTR_OPTION: "option 2", + }, + blocking=True, + ) + + +async def test_select_set_reauth_error( + hass: HomeAssistant, + mock_rpc_device: Mock, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Test select setting with authentication error.""" + config = deepcopy(mock_rpc_device.config) + config["enum:203"] = { + "name": None, + "options": ["option 1", "option 2", "option 3"], + "meta": { + "ui": { + "view": "dropdown", + "titles": {"option 1": "Title 1", "option 2": None}, + } + }, + } + monkeypatch.setattr(mock_rpc_device, "config", config) + + status = deepcopy(mock_rpc_device.status) + status["enum:203"] = {"value": "option 1"} + monkeypatch.setattr(mock_rpc_device, "status", status) + + entry = await init_integration(hass, 3) + + mock_rpc_device.enum_set.side_effect = InvalidAuthError + + await hass.services.async_call( + SELECT_PLATFORM, + SERVICE_SELECT_OPTION, + { + ATTR_ENTITY_ID: f"{SELECT_PLATFORM}.test_name_enum_203", + ATTR_OPTION: "option 2", + }, + blocking=True, + ) + + assert entry.state is ConfigEntryState.LOADED + + flows = hass.config_entries.flow.async_progress() + assert len(flows) == 1 + + flow = flows[0] + assert flow.get("step_id") == "reauth_confirm" + assert flow.get("handler") == DOMAIN + + assert "context" in flow + assert flow["context"].get("source") == SOURCE_REAUTH + assert flow["context"].get("entry_id") == entry.entry_id diff --git a/tests/components/shelly/test_text.py b/tests/components/shelly/test_text.py index a4812cc4160..165272313cb 100644 --- a/tests/components/shelly/test_text.py +++ b/tests/components/shelly/test_text.py @@ -3,15 +3,19 @@ from copy import deepcopy from unittest.mock import Mock +from aioshelly.exceptions import DeviceConnectionError, InvalidAuthError, RpcCallError import pytest +from homeassistant.components.shelly.const import DOMAIN from homeassistant.components.text import ( ATTR_VALUE, DOMAIN as TEXT_PLATFORM, SERVICE_SET_VALUE, ) +from homeassistant.config_entries import SOURCE_REAUTH, ConfigEntryState from homeassistant.const import ATTR_ENTITY_ID from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.device_registry import DeviceRegistry from homeassistant.helpers.entity_registry import EntityRegistry @@ -67,6 +71,7 @@ async def test_rpc_device_virtual_text( blocking=True, ) mock_rpc_device.mock_update() + mock_rpc_device.text_set.assert_called_once_with(203, "sed do eiusmod") assert (state := hass.states.get(entity_id)) assert state.state == "sed do eiusmod" @@ -127,3 +132,96 @@ async def test_rpc_remove_virtual_text_when_orphaned( await hass.async_block_till_done() assert entity_registry.async_get(entity_id) is None + + +@pytest.mark.parametrize( + ("exception", "error"), + [ + ( + DeviceConnectionError, + "Device communication error occurred while calling action for text.test_name_text_203 of Test name", + ), + ( + RpcCallError(999), + "RPC call error occurred while calling action for text.test_name_text_203 of Test name", + ), + ], +) +async def test_text_set_exc( + hass: HomeAssistant, + mock_rpc_device: Mock, + monkeypatch: pytest.MonkeyPatch, + exception: Exception, + error: str, +) -> None: + """Test text setting with exception.""" + config = deepcopy(mock_rpc_device.config) + config["text:203"] = { + "name": None, + "meta": {"ui": {"view": "field"}}, + } + monkeypatch.setattr(mock_rpc_device, "config", config) + + status = deepcopy(mock_rpc_device.status) + status["text:203"] = {"value": "lorem ipsum"} + monkeypatch.setattr(mock_rpc_device, "status", status) + + await init_integration(hass, 3) + + mock_rpc_device.text_set.side_effect = exception + + with pytest.raises(HomeAssistantError, match=error): + await hass.services.async_call( + TEXT_PLATFORM, + SERVICE_SET_VALUE, + { + ATTR_ENTITY_ID: f"{TEXT_PLATFORM}.test_name_text_203", + ATTR_VALUE: "new value", + }, + blocking=True, + ) + + +async def test_text_set_reauth_error( + hass: HomeAssistant, + mock_rpc_device: Mock, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Test text setting with authentication error.""" + config = deepcopy(mock_rpc_device.config) + config["text:203"] = { + "name": None, + "meta": {"ui": {"view": "field"}}, + } + monkeypatch.setattr(mock_rpc_device, "config", config) + + status = deepcopy(mock_rpc_device.status) + status["text:203"] = {"value": "lorem ipsum"} + monkeypatch.setattr(mock_rpc_device, "status", status) + + entry = await init_integration(hass, 3) + + mock_rpc_device.text_set.side_effect = InvalidAuthError + + await hass.services.async_call( + TEXT_PLATFORM, + SERVICE_SET_VALUE, + { + ATTR_ENTITY_ID: f"{TEXT_PLATFORM}.test_name_text_203", + ATTR_VALUE: "new value", + }, + blocking=True, + ) + + assert entry.state is ConfigEntryState.LOADED + + flows = hass.config_entries.flow.async_progress() + assert len(flows) == 1 + + flow = flows[0] + assert flow.get("step_id") == "reauth_confirm" + assert flow.get("handler") == DOMAIN + + assert "context" in flow + assert flow["context"].get("source") == SOURCE_REAUTH + assert flow["context"].get("entry_id") == entry.entry_id diff --git a/tests/components/smarty/conftest.py b/tests/components/smarty/conftest.py index a9b518d88f4..fe2fb4c7bab 100644 --- a/tests/components/smarty/conftest.py +++ b/tests/components/smarty/conftest.py @@ -5,7 +5,7 @@ from unittest.mock import AsyncMock, patch import pytest -from homeassistant.components.smarty import DOMAIN +from homeassistant.components.smarty.const import DOMAIN from homeassistant.const import CONF_HOST from tests.common import MockConfigEntry diff --git a/tests/components/smarty/test_config_flow.py b/tests/components/smarty/test_config_flow.py index fad4f27ca1c..831aca52c73 100644 --- a/tests/components/smarty/test_config_flow.py +++ b/tests/components/smarty/test_config_flow.py @@ -3,8 +3,8 @@ from unittest.mock import AsyncMock from homeassistant.components.smarty.const import DOMAIN -from homeassistant.config_entries import SOURCE_IMPORT, SOURCE_USER -from homeassistant.const import CONF_HOST, CONF_NAME +from homeassistant.config_entries import SOURCE_USER +from homeassistant.const import CONF_HOST from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType @@ -114,52 +114,3 @@ async def test_existing_entry( assert result["type"] is FlowResultType.ABORT assert result["reason"] == "already_configured" - - -async def test_import_flow( - hass: HomeAssistant, mock_smarty: AsyncMock, mock_setup_entry: AsyncMock -) -> None: - """Test the import flow.""" - - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": SOURCE_IMPORT}, - data={CONF_HOST: "192.168.0.2", CONF_NAME: "Smarty"}, - ) - assert result["type"] is FlowResultType.CREATE_ENTRY - assert result["title"] == "Smarty" - assert result["data"] == {CONF_HOST: "192.168.0.2"} - - assert len(mock_setup_entry.mock_calls) == 1 - - -async def test_import_cannot_connect( - hass: HomeAssistant, mock_smarty: AsyncMock -) -> None: - """Test we handle cannot connect error.""" - - mock_smarty.update.return_value = False - - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": SOURCE_IMPORT}, - data={CONF_HOST: "192.168.0.2", CONF_NAME: "Smarty"}, - ) - assert result["type"] is FlowResultType.ABORT - assert result["reason"] == "cannot_connect" - - -async def test_import_unknown_error( - hass: HomeAssistant, mock_smarty: AsyncMock -) -> None: - """Test we handle unknown error.""" - - mock_smarty.update.side_effect = Exception - - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": SOURCE_IMPORT}, - data={CONF_HOST: "192.168.0.2", CONF_NAME: "Smarty"}, - ) - assert result["type"] is FlowResultType.ABORT - assert result["reason"] == "unknown" diff --git a/tests/components/smarty/test_init.py b/tests/components/smarty/test_init.py index 0366ea9eade..6468fd74507 100644 --- a/tests/components/smarty/test_init.py +++ b/tests/components/smarty/test_init.py @@ -4,68 +4,15 @@ from unittest.mock import AsyncMock from syrupy import SnapshotAssertion -from homeassistant.components.smarty import DOMAIN -from homeassistant.const import CONF_HOST, CONF_NAME -from homeassistant.core import DOMAIN as HOMEASSISTANT_DOMAIN, HomeAssistant -from homeassistant.helpers import device_registry as dr, issue_registry as ir -from homeassistant.setup import async_setup_component +from homeassistant.components.smarty.const import DOMAIN +from homeassistant.core import HomeAssistant +from homeassistant.helpers import device_registry as dr from . import setup_integration from tests.common import MockConfigEntry -async def test_import_flow( - hass: HomeAssistant, - mock_smarty: AsyncMock, - issue_registry: ir.IssueRegistry, - mock_setup_entry: AsyncMock, -) -> None: - """Test import flow.""" - assert await async_setup_component( - hass, DOMAIN, {DOMAIN: {CONF_HOST: "192.168.0.2", CONF_NAME: "smarty"}} - ) - await hass.async_block_till_done() - assert len(hass.config_entries.async_entries(DOMAIN)) == 1 - assert (HOMEASSISTANT_DOMAIN, "deprecated_yaml_smarty") in issue_registry.issues - - -async def test_import_flow_already_exists( - hass: HomeAssistant, - mock_smarty: AsyncMock, - issue_registry: ir.IssueRegistry, - mock_setup_entry: AsyncMock, - mock_config_entry: MockConfigEntry, -) -> None: - """Test import flow when entry already exists.""" - mock_config_entry.add_to_hass(hass) - assert await async_setup_component( - hass, DOMAIN, {DOMAIN: {CONF_HOST: "192.168.0.2", CONF_NAME: "smarty"}} - ) - await hass.async_block_till_done() - assert len(hass.config_entries.async_entries(DOMAIN)) == 1 - assert (HOMEASSISTANT_DOMAIN, "deprecated_yaml_smarty") in issue_registry.issues - - -async def test_import_flow_error( - hass: HomeAssistant, - mock_smarty: AsyncMock, - issue_registry: ir.IssueRegistry, - mock_setup_entry: AsyncMock, -) -> None: - """Test import flow when error occurs.""" - mock_smarty.update.return_value = False - assert await async_setup_component( - hass, DOMAIN, {DOMAIN: {CONF_HOST: "192.168.0.2", CONF_NAME: "smarty"}} - ) - await hass.async_block_till_done() - assert len(hass.config_entries.async_entries(DOMAIN)) == 0 - assert ( - DOMAIN, - "deprecated_yaml_import_issue_cannot_connect", - ) in issue_registry.issues - - async def test_device( hass: HomeAssistant, snapshot: SnapshotAssertion, diff --git a/tests/components/switch_as_x/test_config_flow.py b/tests/components/switch_as_x/test_config_flow.py index 2da4c52c7f9..a371cdea63b 100644 --- a/tests/components/switch_as_x/test_config_flow.py +++ b/tests/components/switch_as_x/test_config_flow.py @@ -20,7 +20,7 @@ from homeassistant.helpers import entity_registry as er from . import PLATFORMS_TO_TEST, STATE_MAP -from tests.common import MockConfigEntry +from tests.common import MockConfigEntry, get_schema_suggested_value @pytest.mark.parametrize("target_domain", PLATFORMS_TO_TEST) @@ -160,9 +160,7 @@ async def test_options( result = await hass.config_entries.options.async_init(config_entry.entry_id) assert result["type"] is FlowResultType.FORM assert result["step_id"] == "init" - schema = result["data_schema"].schema - schema_key = next(k for k in schema if k == CONF_INVERT) - assert schema_key.description["suggested_value"] is True + assert get_schema_suggested_value(result["data_schema"].schema, CONF_INVERT) is True result = await hass.config_entries.options.async_configure( result["flow_id"], diff --git a/tests/components/switchbot/__init__.py b/tests/components/switchbot/__init__.py index bb7f950b0da..80606fb45f0 100644 --- a/tests/components/switchbot/__init__.py +++ b/tests/components/switchbot/__init__.py @@ -463,3 +463,28 @@ HUMIDIFIER_SERVICE_INFO = BluetoothServiceInfoBleak( connectable=True, tx_power=-127, ) + + +WOSTRIP_SERVICE_INFO = BluetoothServiceInfoBleak( + name="WoStrip", + address="AA:BB:CC:DD:EE:FF", + manufacturer_data={ + 2409: b'\x84\xf7\x03\xb3?\xde\x04\xe4"\x0c\x00\x00\x00\x00\x00\x00' + }, + service_data={"00000d00-0000-1000-8000-00805f9b34fb": b"r\x00d"}, + service_uuids=["cba20d00-224d-11e6-9fb8-0002a5d5c51b"], + rssi=-60, + source="local", + advertisement=generate_advertisement_data( + local_name="WoStrip", + manufacturer_data={ + 2409: b'\x84\xf7\x03\xb3?\xde\x04\xe4"\x0c\x00\x00\x00\x00\x00\x00' + }, + service_data={"00000d00-0000-1000-8000-00805f9b34fb": b"r\x00d"}, + service_uuids=["cba20d00-224d-11e6-9fb8-0002a5d5c51b"], + ), + device=generate_ble_device("AA:BB:CC:DD:EE:FF", "WoStrip"), + time=0, + connectable=True, + tx_power=-127, +) diff --git a/tests/components/switchbot/test_light.py b/tests/components/switchbot/test_light.py new file mode 100644 index 00000000000..ef46017e9ae --- /dev/null +++ b/tests/components/switchbot/test_light.py @@ -0,0 +1,139 @@ +"""Test the switchbot lights.""" + +from collections.abc import Callable +from typing import Any +from unittest.mock import AsyncMock, patch + +import pytest +from switchbot import ColorMode as switchbotColorMode + +from homeassistant.components.light import ( + ATTR_BRIGHTNESS, + ATTR_COLOR_TEMP_KELVIN, + ATTR_RGB_COLOR, + DOMAIN as LIGHT_DOMAIN, + SERVICE_TURN_OFF, + SERVICE_TURN_ON, +) +from homeassistant.const import ATTR_ENTITY_ID +from homeassistant.core import HomeAssistant + +from . import WOSTRIP_SERVICE_INFO + +from tests.common import MockConfigEntry +from tests.components.bluetooth import inject_bluetooth_service_info + + +@pytest.mark.parametrize( + ( + "service", + "service_data", + "mock_method", + "expected_args", + "color_modes", + "color_mode", + ), + [ + ( + SERVICE_TURN_OFF, + {}, + "turn_off", + (), + {switchbotColorMode.RGB}, + switchbotColorMode.RGB, + ), + ( + SERVICE_TURN_ON, + {}, + "turn_on", + (), + {switchbotColorMode.RGB}, + switchbotColorMode.RGB, + ), + ( + SERVICE_TURN_ON, + {ATTR_BRIGHTNESS: 128}, + "set_brightness", + (round(128 / 255 * 100),), + {switchbotColorMode.RGB}, + switchbotColorMode.RGB, + ), + ( + SERVICE_TURN_ON, + {ATTR_RGB_COLOR: (255, 0, 0)}, + "set_rgb", + (round(255 / 255 * 100), 255, 0, 0), + {switchbotColorMode.RGB}, + switchbotColorMode.RGB, + ), + ( + SERVICE_TURN_ON, + {ATTR_COLOR_TEMP_KELVIN: 4000}, + "set_color_temp", + (100, 4000), + {switchbotColorMode.COLOR_TEMP}, + switchbotColorMode.COLOR_TEMP, + ), + ], +) +async def test_light_strip_services( + hass: HomeAssistant, + mock_entry_factory: Callable[[str], MockConfigEntry], + service: str, + service_data: dict, + mock_method: str, + expected_args: Any, + color_modes: set | None, + color_mode: switchbotColorMode | None, +) -> None: + """Test all SwitchBot light strip services with proper parameters.""" + inject_bluetooth_service_info(hass, WOSTRIP_SERVICE_INFO) + + entry = mock_entry_factory(sensor_type="light_strip") + entry.add_to_hass(hass) + entity_id = "light.test_name" + + with ( + patch("switchbot.SwitchbotLightStrip.color_modes", new=color_modes), + patch("switchbot.SwitchbotLightStrip.color_mode", new=color_mode), + patch( + "switchbot.SwitchbotLightStrip.turn_on", + new=AsyncMock(return_value=True), + ) as mock_turn_on, + patch( + "switchbot.SwitchbotLightStrip.turn_off", + new=AsyncMock(return_value=True), + ) as mock_turn_off, + patch( + "switchbot.SwitchbotLightStrip.set_brightness", + new=AsyncMock(return_value=True), + ) as mock_set_brightness, + patch( + "switchbot.SwitchbotLightStrip.set_rgb", + new=AsyncMock(return_value=True), + ) as mock_set_rgb, + patch( + "switchbot.SwitchbotLightStrip.set_color_temp", + new=AsyncMock(return_value=True), + ) as mock_set_color_temp, + patch("switchbot.SwitchbotLightStrip.update", new=AsyncMock(return_value=None)), + ): + assert await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + await hass.services.async_call( + LIGHT_DOMAIN, + service, + {**service_data, ATTR_ENTITY_ID: entity_id}, + blocking=True, + ) + + mock_map = { + "turn_off": mock_turn_off, + "turn_on": mock_turn_on, + "set_brightness": mock_set_brightness, + "set_rgb": mock_set_rgb, + "set_color_temp": mock_set_color_temp, + } + mock_instance = mock_map[mock_method] + mock_instance.assert_awaited_once_with(*expected_args) diff --git a/tests/components/tailscale/fixtures/devices.json b/tests/components/tailscale/fixtures/devices.json index 66dc262127a..fdfd1d9259a 100644 --- a/tests/components/tailscale/fixtures/devices.json +++ b/tests/components/tailscale/fixtures/devices.json @@ -104,6 +104,28 @@ "upnp": false } } + }, + { + "addresses": ["100.11.11.113"], + "id": "123458", + "user": "frenck", + "name": "host-no-connectivity.homeassistant.github", + "hostname": "host-no-connectivity", + "clientVersion": "1.14.0-t5cff36945-g809e87bba", + "updateAvailable": true, + "os": "linux", + "created": "2021-08-29T09:49:06Z", + "lastSeen": "2021-11-15T20:37:03Z", + "keyExpiryDisabled": false, + "expires": "2022-02-25T09:49:06Z", + "authorized": true, + "isExternal": false, + "machineKey": "mkey:mock", + "nodeKey": "nodekey:mock", + "blocksIncomingConnections": false, + "enabledRoutes": ["0.0.0.0/0", "10.10.10.0/23", "::/0"], + "advertisedRoutes": ["0.0.0.0/0", "10.10.10.0/23", "::/0"], + "clientConnectivity": null } ] } diff --git a/tests/components/tailscale/snapshots/test_diagnostics.ambr b/tests/components/tailscale/snapshots/test_diagnostics.ambr index eba8d9bd145..f3f90c641ea 100644 --- a/tests/components/tailscale/snapshots/test_diagnostics.ambr +++ b/tests/components/tailscale/snapshots/test_diagnostics.ambr @@ -82,6 +82,38 @@ 'update_available': True, 'user': '**REDACTED**', }), + dict({ + 'addresses': '**REDACTED**', + 'advertised_routes': list([ + '0.0.0.0/0', + '10.10.10.0/23', + '::/0', + ]), + 'authorized': True, + 'blocks_incoming_connections': False, + 'client_connectivity': None, + 'client_version': '1.14.0-t5cff36945-g809e87bba', + 'created': '2021-08-29T09:49:06+00:00', + 'device_id': '**REDACTED**', + 'enabled_routes': list([ + '0.0.0.0/0', + '10.10.10.0/23', + '::/0', + ]), + 'expires': '2022-02-25T09:49:06+00:00', + 'hostname': '**REDACTED**', + 'is_external': False, + 'key_expiry_disabled': False, + 'last_seen': '2021-11-15T20:37:03+00:00', + 'machine_key': '**REDACTED**', + 'name': '**REDACTED**', + 'node_key': '**REDACTED**', + 'os': 'linux', + 'tags': list([ + ]), + 'update_available': True, + 'user': '**REDACTED**', + }), ]), }) # --- diff --git a/tests/components/tailscale/test_binary_sensor.py b/tests/components/tailscale/test_binary_sensor.py index b2b593101d7..e0ac97865f0 100644 --- a/tests/components/tailscale/test_binary_sensor.py +++ b/tests/components/tailscale/test_binary_sensor.py @@ -6,7 +6,12 @@ from homeassistant.components.binary_sensor import ( BinarySensorDeviceClass, ) from homeassistant.components.tailscale.const import DOMAIN -from homeassistant.const import ATTR_DEVICE_CLASS, ATTR_FRIENDLY_NAME, EntityCategory +from homeassistant.const import ( + ATTR_DEVICE_CLASS, + ATTR_FRIENDLY_NAME, + STATE_UNKNOWN, + EntityCategory, +) from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr, entity_registry as er @@ -122,3 +127,19 @@ async def test_tailscale_binary_sensors( device_entry.configuration_url == "https://login.tailscale.com/admin/machines/100.11.11.111" ) + + # Check host without client connectivity attribute + state = hass.states.get("binary_sensor.host_no_connectivity_supports_hairpinning") + entry = entity_registry.async_get( + "binary_sensor.host_no_connectivity_supports_hairpinning" + ) + assert entry + assert state + assert entry.unique_id == "123458_client_supports_hair_pinning" + assert entry.entity_category == EntityCategory.DIAGNOSTIC + assert state.state == STATE_UNKNOWN + assert ( + state.attributes.get(ATTR_FRIENDLY_NAME) + == "host-no-connectivity Supports hairpinning" + ) + assert ATTR_DEVICE_CLASS not in state.attributes diff --git a/tests/components/template/test_config_flow.py b/tests/components/template/test_config_flow.py index 21d740b165b..2c4e24ddf71 100644 --- a/tests/components/template/test_config_flow.py +++ b/tests/components/template/test_config_flow.py @@ -13,7 +13,7 @@ from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType from homeassistant.helpers import device_registry as dr -from tests.common import MockConfigEntry +from tests.common import MockConfigEntry, get_schema_suggested_value from tests.typing import WebSocketGenerator SWITCH_BEFORE_OPTIONS = { @@ -407,17 +407,6 @@ async def test_config_flow_device( } -def get_suggested(schema, key): - """Get suggested value for key in voluptuous schema.""" - for k in schema: - if k == key: - if k.description is None or "suggested_value" not in k.description: - return None - return k.description["suggested_value"] - # If the desired key is missing from the schema, return None - return None - - @pytest.mark.parametrize( ( "template_type", @@ -608,7 +597,7 @@ async def test_options( result = await hass.config_entries.options.async_init(config_entry.entry_id) assert result["type"] is FlowResultType.FORM assert result["step_id"] == template_type - assert get_suggested( + assert get_schema_suggested_value( result["data_schema"].schema, key_template ) == old_state_template.get(key_template) assert "name" not in result["data_schema"].schema @@ -655,8 +644,10 @@ async def test_options( assert result["type"] is FlowResultType.FORM assert result["step_id"] == template_type - assert get_suggested(result["data_schema"].schema, "name") is None - assert get_suggested(result["data_schema"].schema, key_template) is None + assert get_schema_suggested_value(result["data_schema"].schema, "name") is None + assert ( + get_schema_suggested_value(result["data_schema"].schema, key_template) is None + ) @pytest.mark.parametrize( diff --git a/tests/components/tesla_fleet/snapshots/test_number.ambr b/tests/components/tesla_fleet/snapshots/test_number.ambr index 1981544a024..a3fccf3a45a 100644 --- a/tests/components/tesla_fleet/snapshots/test_number.ambr +++ b/tests/components/tesla_fleet/snapshots/test_number.ambr @@ -88,7 +88,7 @@ }), 'original_device_class': , 'original_icon': 'mdi:battery-unknown', - 'original_name': 'Off grid reserve', + 'original_name': 'Off-grid reserve', 'platform': 'tesla_fleet', 'previous_unique_id': None, 'supported_features': 0, @@ -101,7 +101,7 @@ StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'battery', - 'friendly_name': 'Energy Site Off grid reserve', + 'friendly_name': 'Energy Site Off-grid reserve', 'icon': 'mdi:battery-unknown', 'max': 100, 'min': 0, diff --git a/tests/components/teslemetry/snapshots/test_binary_sensor.ambr b/tests/components/teslemetry/snapshots/test_binary_sensor.ambr index 9521b313a2d..1558004b1e9 100644 --- a/tests/components/teslemetry/snapshots/test_binary_sensor.ambr +++ b/tests/components/teslemetry/snapshots/test_binary_sensor.ambr @@ -566,6 +566,53 @@ 'state': 'on', }) # --- +# name: test_binary_sensor[binary_sensor.test_charge_enable_request-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.test_charge_enable_request', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Charge enable request', + 'platform': 'teslemetry', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'charge_enable_request', + 'unique_id': 'LRW3F7EK4NC700000-charge_enable_request', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensor[binary_sensor.test_charge_enable_request-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Test Charge enable request', + }), + 'context': , + 'entity_id': 'binary_sensor.test_charge_enable_request', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- # name: test_binary_sensor[binary_sensor.test_charge_port_cold_weather_mode-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -755,6 +802,53 @@ 'state': 'unknown', }) # --- +# name: test_binary_sensor[binary_sensor.test_defrost_for_preconditioning-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.test_defrost_for_preconditioning', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Defrost for preconditioning', + 'platform': 'teslemetry', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'defrost_for_preconditioning', + 'unique_id': 'LRW3F7EK4NC700000-defrost_for_preconditioning', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensor[binary_sensor.test_defrost_for_preconditioning-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Test Defrost for preconditioning', + }), + 'context': , + 'entity_id': 'binary_sensor.test_defrost_for_preconditioning', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- # name: test_binary_sensor[binary_sensor.test_drive_rail-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -1324,6 +1418,101 @@ 'state': 'unknown', }) # --- +# name: test_binary_sensor[binary_sensor.test_high_beams-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.test_high_beams', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'High beams', + 'platform': 'teslemetry', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'lights_high_beams', + 'unique_id': 'LRW3F7EK4NC700000-lights_high_beams', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensor[binary_sensor.test_high_beams-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Test High beams', + }), + 'context': , + 'entity_id': 'binary_sensor.test_high_beams', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_binary_sensor[binary_sensor.test_high_voltage_interlock_loop_fault-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': , + 'entity_id': 'binary_sensor.test_high_voltage_interlock_loop_fault', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'High voltage interlock loop fault', + 'platform': 'teslemetry', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'hvil', + 'unique_id': 'LRW3F7EK4NC700000-hvil', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensor[binary_sensor.test_high_voltage_interlock_loop_fault-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'problem', + 'friendly_name': 'Test High voltage interlock loop fault', + }), + 'context': , + 'entity_id': 'binary_sensor.test_high_voltage_interlock_loop_fault', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- # name: test_binary_sensor[binary_sensor.test_homelink_nearby-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -1371,6 +1560,53 @@ 'state': 'unknown', }) # --- +# name: test_binary_sensor[binary_sensor.test_hvac_auto_mode-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.test_hvac_auto_mode', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'HVAC auto mode', + 'platform': 'teslemetry', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'hvac_auto_mode', + 'unique_id': 'LRW3F7EK4NC700000-hvac_auto_mode', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensor[binary_sensor.test_hvac_auto_mode-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Test HVAC auto mode', + }), + 'context': , + 'entity_id': 'binary_sensor.test_hvac_auto_mode', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- # name: test_binary_sensor[binary_sensor.test_located_at_favorite-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -1986,6 +2222,53 @@ 'state': 'unknown', }) # --- +# name: test_binary_sensor[binary_sensor.test_remote_start-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.test_remote_start', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Remote start', + 'platform': 'teslemetry', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'remote_start_enabled', + 'unique_id': 'LRW3F7EK4NC700000-remote_start_enabled', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensor[binary_sensor.test_remote_start-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Test Remote start', + }), + 'context': , + 'entity_id': 'binary_sensor.test_remote_start', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- # name: test_binary_sensor[binary_sensor.test_right_hand_drive-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -2080,6 +2363,53 @@ 'state': 'unknown', }) # --- +# name: test_binary_sensor[binary_sensor.test_seat_vent_enabled-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.test_seat_vent_enabled', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Seat vent enabled', + 'platform': 'teslemetry', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'seat_vent_enabled', + 'unique_id': 'LRW3F7EK4NC700000-seat_vent_enabled', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensor[binary_sensor.test_seat_vent_enabled-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Test Seat vent enabled', + }), + 'context': , + 'entity_id': 'binary_sensor.test_seat_vent_enabled', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- # name: test_binary_sensor[binary_sensor.test_service_mode-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -2127,6 +2457,53 @@ 'state': 'unknown', }) # --- +# name: test_binary_sensor[binary_sensor.test_speed_limited-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.test_speed_limited', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Speed limited', + 'platform': 'teslemetry', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'speed_limit_mode', + 'unique_id': 'LRW3F7EK4NC700000-speed_limit_mode', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensor[binary_sensor.test_speed_limited-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Test Speed limited', + }), + 'context': , + 'entity_id': 'binary_sensor.test_speed_limited', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- # name: test_binary_sensor[binary_sensor.test_status-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -2715,6 +3092,19 @@ 'state': 'on', }) # --- +# name: test_binary_sensor_refresh[binary_sensor.test_charge_enable_request-statealt] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Test Charge enable request', + }), + 'context': , + 'entity_id': 'binary_sensor.test_charge_enable_request', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- # name: test_binary_sensor_refresh[binary_sensor.test_charge_port_cold_weather_mode-statealt] StateSnapshot({ 'attributes': ReadOnlyDict({ @@ -2768,6 +3158,19 @@ 'state': 'unknown', }) # --- +# name: test_binary_sensor_refresh[binary_sensor.test_defrost_for_preconditioning-statealt] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Test Defrost for preconditioning', + }), + 'context': , + 'entity_id': 'binary_sensor.test_defrost_for_preconditioning', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- # name: test_binary_sensor_refresh[binary_sensor.test_drive_rail-statealt] StateSnapshot({ 'attributes': ReadOnlyDict({ @@ -2929,6 +3332,33 @@ 'state': 'unknown', }) # --- +# name: test_binary_sensor_refresh[binary_sensor.test_high_beams-statealt] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Test High beams', + }), + 'context': , + 'entity_id': 'binary_sensor.test_high_beams', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_binary_sensor_refresh[binary_sensor.test_high_voltage_interlock_loop_fault-statealt] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'problem', + 'friendly_name': 'Test High voltage interlock loop fault', + }), + 'context': , + 'entity_id': 'binary_sensor.test_high_voltage_interlock_loop_fault', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- # name: test_binary_sensor_refresh[binary_sensor.test_homelink_nearby-statealt] StateSnapshot({ 'attributes': ReadOnlyDict({ @@ -2942,6 +3372,19 @@ 'state': 'unknown', }) # --- +# name: test_binary_sensor_refresh[binary_sensor.test_hvac_auto_mode-statealt] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Test HVAC auto mode', + }), + 'context': , + 'entity_id': 'binary_sensor.test_hvac_auto_mode', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- # name: test_binary_sensor_refresh[binary_sensor.test_located_at_favorite-statealt] StateSnapshot({ 'attributes': ReadOnlyDict({ @@ -3115,6 +3558,19 @@ 'state': 'unknown', }) # --- +# name: test_binary_sensor_refresh[binary_sensor.test_remote_start-statealt] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Test Remote start', + }), + 'context': , + 'entity_id': 'binary_sensor.test_remote_start', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- # name: test_binary_sensor_refresh[binary_sensor.test_right_hand_drive-statealt] StateSnapshot({ 'attributes': ReadOnlyDict({ @@ -3141,6 +3597,19 @@ 'state': 'unknown', }) # --- +# name: test_binary_sensor_refresh[binary_sensor.test_seat_vent_enabled-statealt] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Test Seat vent enabled', + }), + 'context': , + 'entity_id': 'binary_sensor.test_seat_vent_enabled', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- # name: test_binary_sensor_refresh[binary_sensor.test_service_mode-statealt] StateSnapshot({ 'attributes': ReadOnlyDict({ @@ -3154,6 +3623,19 @@ 'state': 'unknown', }) # --- +# name: test_binary_sensor_refresh[binary_sensor.test_speed_limited-statealt] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Test Speed limited', + }), + 'context': , + 'entity_id': 'binary_sensor.test_speed_limited', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- # name: test_binary_sensor_refresh[binary_sensor.test_status-statealt] StateSnapshot({ 'attributes': ReadOnlyDict({ diff --git a/tests/components/teslemetry/snapshots/test_number.ambr b/tests/components/teslemetry/snapshots/test_number.ambr index 5ca9feb22f2..2c6705074f3 100644 --- a/tests/components/teslemetry/snapshots/test_number.ambr +++ b/tests/components/teslemetry/snapshots/test_number.ambr @@ -88,7 +88,7 @@ }), 'original_device_class': , 'original_icon': 'mdi:battery-unknown', - 'original_name': 'Off grid reserve', + 'original_name': 'Off-grid reserve', 'platform': 'teslemetry', 'previous_unique_id': None, 'supported_features': 0, @@ -101,7 +101,7 @@ StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'battery', - 'friendly_name': 'Energy Site Off grid reserve', + 'friendly_name': 'Energy Site Off-grid reserve', 'icon': 'mdi:battery-unknown', 'max': 100, 'min': 0, diff --git a/tests/components/tessie/snapshots/test_number.ambr b/tests/components/tessie/snapshots/test_number.ambr index 0e43695ca78..e865058c4a2 100644 --- a/tests/components/tessie/snapshots/test_number.ambr +++ b/tests/components/tessie/snapshots/test_number.ambr @@ -88,7 +88,7 @@ }), 'original_device_class': , 'original_icon': 'mdi:battery-unknown', - 'original_name': 'Off grid reserve', + 'original_name': 'Off-grid reserve', 'platform': 'tessie', 'previous_unique_id': None, 'supported_features': 0, @@ -101,7 +101,7 @@ StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'battery', - 'friendly_name': 'Energy Site Off grid reserve', + 'friendly_name': 'Energy Site Off-grid reserve', 'icon': 'mdi:battery-unknown', 'max': 100, 'min': 0, diff --git a/tests/components/threshold/test_config_flow.py b/tests/components/threshold/test_config_flow.py index c13717800bf..5d9d22c3f81 100644 --- a/tests/components/threshold/test_config_flow.py +++ b/tests/components/threshold/test_config_flow.py @@ -11,7 +11,7 @@ from homeassistant.const import ATTR_UNIT_OF_MEASUREMENT, UnitOfTemperature from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType -from tests.common import MockConfigEntry +from tests.common import MockConfigEntry, get_schema_suggested_value from tests.typing import WebSocketGenerator @@ -88,17 +88,6 @@ async def test_fail(hass: HomeAssistant, extra_input_data, error) -> None: assert result["errors"] == {"base": error} -def get_suggested(schema, key): - """Get suggested value for key in voluptuous schema.""" - for k in schema: - if k == key: - if k.description is None or "suggested_value" not in k.description: - return None - return k.description["suggested_value"] - # Wanted key absent from schema - raise KeyError("Wanted key absent from schema") - - async def test_options(hass: HomeAssistant) -> None: """Test reconfiguring.""" input_sensor = "sensor.input" @@ -125,9 +114,9 @@ async def test_options(hass: HomeAssistant) -> None: assert result["type"] is FlowResultType.FORM assert result["step_id"] == "init" schema = result["data_schema"].schema - assert get_suggested(schema, "hysteresis") == 0.0 - assert get_suggested(schema, "lower") == -2.0 - assert get_suggested(schema, "upper") is None + assert get_schema_suggested_value(schema, "hysteresis") == 0.0 + assert get_schema_suggested_value(schema, "lower") == -2.0 + assert get_schema_suggested_value(schema, "upper") is None result = await hass.config_entries.options.async_configure( result["flow_id"], diff --git a/tests/components/tod/test_config_flow.py b/tests/components/tod/test_config_flow.py index 81f10061774..125a969c09d 100644 --- a/tests/components/tod/test_config_flow.py +++ b/tests/components/tod/test_config_flow.py @@ -9,7 +9,7 @@ from homeassistant.components.tod.const import DOMAIN from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType -from tests.common import MockConfigEntry +from tests.common import MockConfigEntry, get_schema_suggested_value @pytest.mark.parametrize("platform", ["sensor"]) @@ -55,17 +55,6 @@ async def test_config_flow(hass: HomeAssistant, platform) -> None: assert config_entry.title == "My tod" -def get_suggested(schema, key): - """Get suggested value for key in voluptuous schema.""" - for k in schema: - if k == key: - if k.description is None or "suggested_value" not in k.description: - return None - return k.description["suggested_value"] - # Wanted key absent from schema - raise KeyError("Wanted key absent from schema") - - @pytest.mark.freeze_time("2022-03-16 17:37:00", tz_offset=-7) async def test_options(hass: HomeAssistant) -> None: """Test reconfiguring.""" @@ -88,8 +77,8 @@ async def test_options(hass: HomeAssistant) -> None: assert result["type"] is FlowResultType.FORM assert result["step_id"] == "init" schema = result["data_schema"].schema - assert get_suggested(schema, "after_time") == "10:00" - assert get_suggested(schema, "before_time") == "18:05" + assert get_schema_suggested_value(schema, "after_time") == "10:00" + assert get_schema_suggested_value(schema, "before_time") == "18:05" result = await hass.config_entries.options.async_configure( result["flow_id"], diff --git a/tests/components/tplink_omada/snapshots/test_switch.ambr b/tests/components/tplink_omada/snapshots/test_switch.ambr index dde196deaaf..eae97f2aae1 100644 --- a/tests/components/tplink_omada/snapshots/test_switch.ambr +++ b/tests/components/tplink_omada/snapshots/test_switch.ambr @@ -2,7 +2,7 @@ # name: test_gateway_api_fail_disables_switch_entities StateSnapshot({ 'attributes': ReadOnlyDict({ - 'friendly_name': 'Test Router Port 4 Internet Connected', + 'friendly_name': 'Test Router Port 4 Internet connected', }), 'context': , 'entity_id': 'switch.test_router_port_4_internet_connected', @@ -15,7 +15,7 @@ # name: test_gateway_connect_ipv4_switch StateSnapshot({ 'attributes': ReadOnlyDict({ - 'friendly_name': 'Test Router Port 4 Internet Connected', + 'friendly_name': 'Test Router Port 4 Internet connected', }), 'context': , 'entity_id': 'switch.test_router_port_4_internet_connected', @@ -28,7 +28,7 @@ # name: test_gateway_port_change_disables_switch_entities StateSnapshot({ 'attributes': ReadOnlyDict({ - 'friendly_name': 'Test Router Port 4 Internet Connected', + 'friendly_name': 'Test Router Port 4 Internet connected', }), 'context': , 'entity_id': 'switch.test_router_port_4_internet_connected', diff --git a/tests/components/unifiprotect/test_binary_sensor.py b/tests/components/unifiprotect/test_binary_sensor.py index 3a8d5d952ce..3aa441659b0 100644 --- a/tests/components/unifiprotect/test_binary_sensor.py +++ b/tests/components/unifiprotect/test_binary_sensor.py @@ -407,7 +407,7 @@ async def test_binary_sensor_update_mount_type_garage( ) -> None: """Test binary_sensor motion entity.""" - await init_entry(hass, ufp, [sensor_all], debug=True) + await init_entry(hass, ufp, [sensor_all]) assert_entity_counts(hass, Platform.BINARY_SENSOR, 11, 11) _, entity_id = ids_from_device_description( diff --git a/tests/components/unifiprotect/utils.py b/tests/components/unifiprotect/utils.py index 7dd0362f17c..06ffe16ab87 100644 --- a/tests/components/unifiprotect/utils.py +++ b/tests/components/unifiprotect/utils.py @@ -25,7 +25,6 @@ from homeassistant.const import Platform from homeassistant.core import HomeAssistant, split_entity_id from homeassistant.helpers import entity_registry as er from homeassistant.helpers.entity import EntityDescription -from homeassistant.setup import async_setup_component from homeassistant.util import dt as dt_util from tests.common import MockConfigEntry, async_fire_time_changed @@ -167,7 +166,6 @@ async def init_entry( ufp: MockUFPFixture, devices: Sequence[ProtectAdoptableDeviceModel], regenerate_ids: bool = True, - debug: bool = False, ) -> None: """Initialize Protect entry with given devices.""" @@ -175,14 +173,6 @@ async def init_entry( for device in devices: add_device(ufp.api.bootstrap, device, regenerate_ids) - if debug: - assert await async_setup_component(hass, "logger", {"logger": {}}) - await hass.services.async_call( - "logger", - "set_level", - {"homeassistant.components.unifiprotect": "DEBUG"}, - blocking=True, - ) await hass.config_entries.async_setup(ufp.entry.entry_id) await hass.async_block_till_done() diff --git a/tests/components/uptimerobot/test_binary_sensor.py b/tests/components/uptimerobot/test_binary_sensor.py index 4b27ab5ff05..3de9b9ec399 100644 --- a/tests/components/uptimerobot/test_binary_sensor.py +++ b/tests/components/uptimerobot/test_binary_sensor.py @@ -34,8 +34,8 @@ async def test_presentation(hass: HomeAssistant) -> None: assert entity.attributes["target"] == MOCK_UPTIMEROBOT_MONITOR["url"] -async def test_unaviable_on_update_failure(hass: HomeAssistant) -> None: - """Test entity unaviable on update failure.""" +async def test_unavailable_on_update_failure(hass: HomeAssistant) -> None: + """Test entity unavailable on update failure.""" await setup_uptimerobot_integration(hass) entity = hass.states.get(UPTIMEROBOT_BINARY_SENSOR_TEST_ENTITY) diff --git a/tests/components/uptimerobot/test_config_flow.py b/tests/components/uptimerobot/test_config_flow.py index 3ba5ad696a6..c7ae6a5d772 100644 --- a/tests/components/uptimerobot/test_config_flow.py +++ b/tests/components/uptimerobot/test_config_flow.py @@ -24,8 +24,8 @@ from .common import ( from tests.common import MockConfigEntry -async def test_form(hass: HomeAssistant) -> None: - """Test we get the form.""" +async def test_user(hass: HomeAssistant) -> None: + """Test user flow.""" result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} @@ -56,8 +56,8 @@ async def test_form(hass: HomeAssistant) -> None: assert len(mock_setup_entry.mock_calls) == 1 -async def test_form_read_only(hass: HomeAssistant) -> None: - """Test we get the form.""" +async def test_user_key_read_only(hass: HomeAssistant) -> None: + """Test user flow with read only key.""" result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} @@ -87,8 +87,8 @@ async def test_form_read_only(hass: HomeAssistant) -> None: (UptimeRobotAuthenticationException, "invalid_api_key"), ], ) -async def test_form_exception_thrown(hass: HomeAssistant, exception, error_key) -> None: - """Test that we handle exceptions.""" +async def test_exception_thrown(hass: HomeAssistant, exception, error_key) -> None: + """Test user flow throwing exceptions.""" result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} ) @@ -106,10 +106,8 @@ async def test_form_exception_thrown(hass: HomeAssistant, exception, error_key) assert result2["errors"]["base"] == error_key -async def test_form_api_error( - hass: HomeAssistant, caplog: pytest.LogCaptureFixture -) -> None: - """Test we handle unexpected error.""" +async def test_api_error(hass: HomeAssistant, caplog: pytest.LogCaptureFixture) -> None: + """Test expected API error is catch.""" result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} ) diff --git a/tests/components/uptimerobot/test_init.py b/tests/components/uptimerobot/test_init.py index 187178de78d..435b0737c6d 100644 --- a/tests/components/uptimerobot/test_init.py +++ b/tests/components/uptimerobot/test_init.py @@ -239,7 +239,6 @@ async def test_device_management( freezer.tick(COORDINATOR_UPDATE_INTERVAL) async_fire_time_changed(hass) await hass.async_block_till_done() - await hass.async_block_till_done() devices = dr.async_entries_for_config_entry(device_registry, mock_entry.entry_id) assert len(devices) == 1 diff --git a/tests/components/utility_meter/test_config_flow.py b/tests/components/utility_meter/test_config_flow.py index 4901e069aee..01fd80acc0e 100644 --- a/tests/components/utility_meter/test_config_flow.py +++ b/tests/components/utility_meter/test_config_flow.py @@ -10,7 +10,7 @@ from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType from homeassistant.helpers import device_registry as dr, entity_registry as er -from tests.common import MockConfigEntry +from tests.common import MockConfigEntry, get_schema_suggested_value @pytest.mark.parametrize("platform", ["sensor"]) @@ -253,17 +253,6 @@ async def test_always_available(hass: HomeAssistant) -> None: } -def get_suggested(schema, key): - """Get suggested value for key in voluptuous schema.""" - for k in schema: - if k == key: - if k.description is None or "suggested_value" not in k.description: - return None - return k.description["suggested_value"] - # Wanted key absent from schema - raise KeyError("Wanted key absent from schema") - - async def test_options(hass: HomeAssistant) -> None: """Test reconfiguring.""" input_sensor1_entity_id = "sensor.input1" @@ -293,8 +282,8 @@ async def test_options(hass: HomeAssistant) -> None: assert result["type"] is FlowResultType.FORM assert result["step_id"] == "init" schema = result["data_schema"].schema - assert get_suggested(schema, "source") == input_sensor1_entity_id - assert get_suggested(schema, "periodically_resetting") is True + assert get_schema_suggested_value(schema, "source") == input_sensor1_entity_id + assert get_schema_suggested_value(schema, "periodically_resetting") is True result = await hass.config_entries.options.async_configure( result["flow_id"], diff --git a/tests/components/utility_meter/test_sensor.py b/tests/components/utility_meter/test_sensor.py index c671969c5ac..2de2ee553b3 100644 --- a/tests/components/utility_meter/test_sensor.py +++ b/tests/components/utility_meter/test_sensor.py @@ -43,6 +43,7 @@ from homeassistant.const import ( ) from homeassistant.core import CoreState, HomeAssistant, State from homeassistant.helpers import device_registry as dr, entity_registry as er +from homeassistant.helpers.event import async_track_state_change_event from homeassistant.setup import async_setup_component from homeassistant.util import dt as dt_util @@ -1637,8 +1638,21 @@ async def _test_self_reset( now += timedelta(seconds=30) with freeze_time(now): + # Listen for events and check that state in the first event after reset is actually 0, issue #142053 + events = [] + + async def handle_energy_bill_event(event): + events.append(event) + + unsub = async_track_state_change_event( + hass, + "sensor.energy_bill", + handle_energy_bill_event, + ) + async_fire_time_changed(hass, now) await hass.async_block_till_done() + unsub() hass.states.async_set( entity_id, 6, @@ -1654,6 +1668,10 @@ async def _test_self_reset( state.attributes.get("last_reset") == dt_util.as_utc(now).isoformat() ) # last_reset is kept in UTC assert state.state == "3" + # In first event state should be 0 + assert len(events) == 2 + assert events[0].data.get("new_state").state == "0" + assert events[1].data.get("new_state").state == "0" else: assert state.attributes.get("last_period") == "0" assert state.state == "5" diff --git a/tests/components/vodafone_station/conftest.py b/tests/components/vodafone_station/conftest.py index a065a1e8065..778d8fdaa41 100644 --- a/tests/components/vodafone_station/conftest.py +++ b/tests/components/vodafone_station/conftest.py @@ -5,7 +5,7 @@ from datetime import UTC, datetime from aiovodafone import VodafoneStationDevice import pytest -from homeassistant.components.vodafone_station import DOMAIN +from homeassistant.components.vodafone_station.const import DOMAIN from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME from .const import DEVICE_1_HOST, DEVICE_1_MAC, DEVICE_2_MAC diff --git a/tests/components/vodafone_station/test_init.py b/tests/components/vodafone_station/test_init.py index 12b3c3dce8f..053f0a95fe4 100644 --- a/tests/components/vodafone_station/test_init.py +++ b/tests/components/vodafone_station/test_init.py @@ -3,6 +3,8 @@ from unittest.mock import AsyncMock from homeassistant.components.device_tracker import CONF_CONSIDER_HOME +from homeassistant.components.vodafone_station.const import DOMAIN +from homeassistant.config_entries import ConfigEntryState from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType @@ -31,3 +33,21 @@ async def test_reload_config_entry_with_options( assert result["data"] == { CONF_CONSIDER_HOME: 37, } + + +async def test_unload_entry( + hass: HomeAssistant, + mock_vodafone_station_router: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test unloading the config entry.""" + await setup_integration(hass, mock_config_entry) + + assert len(hass.config_entries.async_entries(DOMAIN)) == 1 + assert mock_config_entry.state is ConfigEntryState.LOADED + + assert await hass.config_entries.async_unload(mock_config_entry.entry_id) + await hass.async_block_till_done() + + assert mock_config_entry.state is ConfigEntryState.NOT_LOADED + assert not hass.data.get(DOMAIN) diff --git a/tests/components/voip/test_voip.py b/tests/components/voip/test_voip.py index 7ac76227a1b..345f0399645 100644 --- a/tests/components/voip/test_voip.py +++ b/tests/components/voip/test_voip.py @@ -38,12 +38,12 @@ def mock_tts_cache_dir_autouse(mock_tts_cache_dir: Path) -> None: """Mock the TTS cache dir with empty dir.""" -def _empty_wav() -> bytes: +def _empty_wav(framerate=16000) -> bytes: """Return bytes of an empty WAV file.""" with io.BytesIO() as wav_io: wav_file: wave.Wave_write = wave.open(wav_io, "wb") with wav_file: - wav_file.setframerate(16000) + wav_file.setframerate(framerate) wav_file.setsampwidth(2) wav_file.setnchannels(1) @@ -307,10 +307,11 @@ async def test_pipeline( assert satellite.state == AssistSatelliteState.RESPONDING # Proceed with media output + mock_tts_result_stream = MockResultStream(hass, "wav", _empty_wav()) event_callback( assist_pipeline.PipelineEvent( type=assist_pipeline.PipelineEventType.TTS_END, - data={"tts_output": {"media_id": _MEDIA_ID}}, + data={"tts_output": {"token": mock_tts_result_stream.token}}, ) ) @@ -326,22 +327,11 @@ async def test_pipeline( original_tts_response_finished() done.set() - async def async_get_media_source_audio( - hass: HomeAssistant, - media_source_id: str, - ) -> tuple[str, bytes]: - assert media_source_id == _MEDIA_ID - return ("wav", _empty_wav()) - with ( patch( "homeassistant.components.assist_satellite.entity.async_pipeline_from_audio_stream", new=async_pipeline_from_audio_stream, ), - patch( - "homeassistant.components.voip.assist_satellite.tts.async_get_media_source_audio", - new=async_get_media_source_audio, - ), patch.object(satellite, "tts_response_finished", tts_response_finished), ): satellite._tones = Tones(0) @@ -457,10 +447,11 @@ async def test_tts_timeout( ) # Proceed with media output + mock_tts_result_stream = MockResultStream(hass, "wav", _empty_wav()) event_callback( assist_pipeline.PipelineEvent( type=assist_pipeline.PipelineEventType.TTS_END, - data={"tts_output": {"media_id": _MEDIA_ID}}, + data={"tts_output": {"token": mock_tts_result_stream.token}}, ) ) @@ -474,22 +465,9 @@ async def test_tts_timeout( # Block here to force a timeout in _send_tts await asyncio.sleep(2) - async def async_get_media_source_audio( - hass: HomeAssistant, - media_source_id: str, - ) -> tuple[str, bytes]: - # Should time out immediately - return ("wav", _empty_wav()) - - with ( - patch( - "homeassistant.components.assist_satellite.entity.async_pipeline_from_audio_stream", - new=async_pipeline_from_audio_stream, - ), - patch( - "homeassistant.components.voip.assist_satellite.tts.async_get_media_source_audio", - new=async_get_media_source_audio, - ), + with patch( + "homeassistant.components.assist_satellite.entity.async_pipeline_from_audio_stream", + new=async_pipeline_from_audio_stream, ): satellite._tts_extra_timeout = 0.001 for tone in Tones: @@ -568,29 +546,18 @@ async def test_tts_wrong_extension( ) # Proceed with media output + # Should fail because it's not "wav" + mock_tts_result_stream = MockResultStream(hass, "mp3", b"") event_callback( assist_pipeline.PipelineEvent( type=assist_pipeline.PipelineEventType.TTS_END, - data={"tts_output": {"media_id": _MEDIA_ID}}, + data={"tts_output": {"token": mock_tts_result_stream.token}}, ) ) - async def async_get_media_source_audio( - hass: HomeAssistant, - media_source_id: str, - ) -> tuple[str, bytes]: - # Should fail because it's not "wav" - return ("mp3", b"") - - with ( - patch( - "homeassistant.components.assist_satellite.entity.async_pipeline_from_audio_stream", - new=async_pipeline_from_audio_stream, - ), - patch( - "homeassistant.components.voip.assist_satellite.tts.async_get_media_source_audio", - new=async_get_media_source_audio, - ), + with patch( + "homeassistant.components.assist_satellite.entity.async_pipeline_from_audio_stream", + new=async_pipeline_from_audio_stream, ): satellite.transport = Mock() @@ -663,36 +630,18 @@ async def test_tts_wrong_wav_format( ) # Proceed with media output + # Should fail because it's not 16Khz + mock_tts_result_stream = MockResultStream(hass, "wav", _empty_wav(22050)) event_callback( assist_pipeline.PipelineEvent( type=assist_pipeline.PipelineEventType.TTS_END, - data={"tts_output": {"media_id": _MEDIA_ID}}, + data={"tts_output": {"token": mock_tts_result_stream.token}}, ) ) - async def async_get_media_source_audio( - hass: HomeAssistant, - media_source_id: str, - ) -> tuple[str, bytes]: - # Should fail because it's not 16Khz, 16-bit mono - with io.BytesIO() as wav_io: - wav_file: wave.Wave_write = wave.open(wav_io, "wb") - with wav_file: - wav_file.setframerate(22050) - wav_file.setsampwidth(2) - wav_file.setnchannels(2) - - return ("wav", wav_io.getvalue()) - - with ( - patch( - "homeassistant.components.assist_satellite.entity.async_pipeline_from_audio_stream", - new=async_pipeline_from_audio_stream, - ), - patch( - "homeassistant.components.voip.assist_satellite.tts.async_get_media_source_audio", - new=async_get_media_source_audio, - ), + with patch( + "homeassistant.components.assist_satellite.entity.async_pipeline_from_audio_stream", + new=async_pipeline_from_audio_stream, ): satellite.transport = Mock() @@ -878,10 +827,11 @@ async def test_announce( assert err.value.translation_domain == "voip" assert err.value.translation_key == "non_tts_announcement" + mock_tts_result_stream = MockResultStream(hass, "wav", _empty_wav()) announcement = assist_satellite.AssistSatelliteAnnouncement( message="test announcement", media_id=_MEDIA_ID, - tts_token="test-token", + tts_token=mock_tts_result_stream.token, original_media_id=_MEDIA_ID, media_id_source="tts", ) @@ -907,7 +857,9 @@ async def test_announce( async with asyncio.timeout(1): await announce_task - mock_send_tts.assert_called_once_with(_MEDIA_ID, wait_for_tone=False) + mock_send_tts.assert_called_once_with( + mock_tts_result_stream, wait_for_tone=False + ) @pytest.mark.usefixtures("socket_enabled") @@ -926,10 +878,11 @@ async def test_voip_id_is_ip_address( & assist_satellite.AssistSatelliteEntityFeature.ANNOUNCE ) + mock_tts_result_stream = MockResultStream(hass, "wav", _empty_wav()) announcement = assist_satellite.AssistSatelliteAnnouncement( message="test announcement", media_id=_MEDIA_ID, - tts_token="test-token", + tts_token=mock_tts_result_stream.token, original_media_id=_MEDIA_ID, media_id_source="tts", ) @@ -960,7 +913,9 @@ async def test_voip_id_is_ip_address( async with asyncio.timeout(1): await announce_task - mock_send_tts.assert_called_once_with(_MEDIA_ID, wait_for_tone=False) + mock_send_tts.assert_called_once_with( + mock_tts_result_stream, wait_for_tone=False + ) @pytest.mark.usefixtures("socket_enabled") @@ -979,10 +934,11 @@ async def test_announce_timeout( & assist_satellite.AssistSatelliteEntityFeature.ANNOUNCE ) + mock_tts_result_stream = MockResultStream(hass, "wav", _empty_wav()) announcement = assist_satellite.AssistSatelliteAnnouncement( message="test announcement", media_id=_MEDIA_ID, - tts_token="test-token", + tts_token=mock_tts_result_stream.token, original_media_id=_MEDIA_ID, media_id_source="tts", ) @@ -1020,10 +976,11 @@ async def test_start_conversation( & assist_satellite.AssistSatelliteEntityFeature.START_CONVERSATION ) + mock_tts_result_stream = MockResultStream(hass, "wav", _empty_wav()) announcement = assist_satellite.AssistSatelliteAnnouncement( message="test announcement", media_id=_MEDIA_ID, - tts_token="test-token", + tts_token=mock_tts_result_stream.token, original_media_id=_MEDIA_ID, media_id_source="tts", ) @@ -1061,10 +1018,11 @@ async def test_start_conversation( ) # Proceed with media output + mock_tts_result_stream = MockResultStream(hass, "wav", _empty_wav()) event_callback( assist_pipeline.PipelineEvent( type=assist_pipeline.PipelineEventType.TTS_END, - data={"tts_output": {"media_id": _MEDIA_ID}}, + data={"tts_output": {"token": mock_tts_result_stream.token}}, ) ) diff --git a/tests/components/websocket_api/test_http.py b/tests/components/websocket_api/test_http.py index 075f5fa9c0a..b4b11d9cf02 100644 --- a/tests/components/websocket_api/test_http.py +++ b/tests/components/websocket_api/test_http.py @@ -19,7 +19,7 @@ from homeassistant.core import HomeAssistant, callback from homeassistant.setup import async_setup_component from homeassistant.util.dt import utcnow -from tests.common import async_fire_time_changed +from tests.common import async_call_logger_set_level, async_fire_time_changed from tests.typing import MockHAClientWebSocket, WebSocketGenerator @@ -533,27 +533,19 @@ async def test_enable_disable_debug_logging( ) -> None: """Test enabling and disabling debug logging.""" assert await async_setup_component(hass, "logger", {"logger": {}}) - await hass.services.async_call( - "logger", - "set_level", - {"homeassistant.components.websocket_api": "DEBUG"}, - blocking=True, - ) - await hass.async_block_till_done() - await websocket_client.send_json({"id": 1, "type": "ping"}) - msg = await websocket_client.receive_json() - assert msg["id"] == 1 - assert msg["type"] == "pong" - assert 'Sending b\'{"id":1,"type":"pong"}\'' in caplog.text - await hass.services.async_call( - "logger", - "set_level", - {"homeassistant.components.websocket_api": "WARNING"}, - blocking=True, - ) - await hass.async_block_till_done() - await websocket_client.send_json({"id": 2, "type": "ping"}) - msg = await websocket_client.receive_json() - assert msg["id"] == 2 - assert msg["type"] == "pong" - assert 'Sending b\'{"id":2,"type":"pong"}\'' not in caplog.text + async with async_call_logger_set_level( + "homeassistant.components.websocket_api", "DEBUG", hass=hass, caplog=caplog + ): + await websocket_client.send_json({"id": 1, "type": "ping"}) + msg = await websocket_client.receive_json() + assert msg["id"] == 1 + assert msg["type"] == "pong" + assert 'Sending b\'{"id":1,"type":"pong"}\'' in caplog.text + async with async_call_logger_set_level( + "homeassistant.components.websocket_api", "WARNING", hass=hass, caplog=caplog + ): + await websocket_client.send_json({"id": 2, "type": "ping"}) + msg = await websocket_client.receive_json() + assert msg["id"] == 2 + assert msg["type"] == "pong" + assert 'Sending b\'{"id":2,"type":"pong"}\'' not in caplog.text diff --git a/tests/components/whirlpool/conftest.py b/tests/components/whirlpool/conftest.py index f59b2d015fc..7447c1edd5a 100644 --- a/tests/components/whirlpool/conftest.py +++ b/tests/components/whirlpool/conftest.py @@ -1,10 +1,10 @@ """Fixtures for the Whirlpool Sixth Sense integration tests.""" from unittest import mock -from unittest.mock import AsyncMock, MagicMock +from unittest.mock import Mock import pytest -from whirlpool import aircon, washerdryer +from whirlpool import aircon, appliancesmanager, auth, washerdryer from whirlpool.backendselector import Brand, Region from .const import MOCK_SAID1, MOCK_SAID2 @@ -36,12 +36,13 @@ def fixture_brand(request: pytest.FixtureRequest) -> tuple[str, Brand]: def fixture_mock_auth_api(): """Set up Auth fixture.""" with ( - mock.patch("homeassistant.components.whirlpool.Auth") as mock_auth, + mock.patch( + "homeassistant.components.whirlpool.Auth", spec=auth.Auth + ) as mock_auth, mock.patch( "homeassistant.components.whirlpool.config_flow.Auth", new=mock_auth ), ): - mock_auth.return_value.do_auth = AsyncMock() mock_auth.return_value.is_access_token_valid.return_value = True yield mock_auth @@ -53,16 +54,14 @@ def fixture_mock_appliances_manager_api( """Set up AppliancesManager fixture.""" with ( mock.patch( - "homeassistant.components.whirlpool.AppliancesManager" + "homeassistant.components.whirlpool.AppliancesManager", + spec=appliancesmanager.AppliancesManager, ) as mock_appliances_manager, mock.patch( "homeassistant.components.whirlpool.config_flow.AppliancesManager", new=mock_appliances_manager, ), ): - mock_appliances_manager.return_value.fetch_appliances = AsyncMock() - mock_appliances_manager.return_value.connect = AsyncMock() - mock_appliances_manager.return_value.disconnect = AsyncMock() mock_appliances_manager.return_value.aircons = [ mock_aircon1_api, mock_aircon2_api, @@ -91,12 +90,11 @@ def fixture_mock_backend_selector_api(): def get_aircon_mock(said): """Get a mock of an air conditioner.""" - mock_aircon = mock.Mock(said=said) + mock_aircon = Mock(spec=aircon.Aircon, said=said) mock_aircon.name = f"Aircon {said}" - mock_aircon.register_attr_callback = MagicMock() - mock_aircon.appliance_info.data_model = "aircon_model" - mock_aircon.appliance_info.category = "aircon" - mock_aircon.appliance_info.model_number = "12345" + mock_aircon.appliance_info = Mock( + data_model="aircon_model", category="aircon", model_number="12345" + ) mock_aircon.get_online.return_value = True mock_aircon.get_power_on.return_value = True mock_aircon.get_mode.return_value = aircon.Mode.Cool @@ -107,14 +105,6 @@ def get_aircon_mock(said): mock_aircon.get_humidity.return_value = 50 mock_aircon.get_h_louver_swing.return_value = True - mock_aircon.set_power_on = AsyncMock() - mock_aircon.set_mode = AsyncMock() - mock_aircon.set_temp = AsyncMock() - mock_aircon.set_humidity = AsyncMock() - mock_aircon.set_mode = AsyncMock() - mock_aircon.set_fanspeed = AsyncMock() - mock_aircon.set_h_louver_swing = AsyncMock() - return mock_aircon @@ -133,13 +123,11 @@ def fixture_mock_aircon2_api(): @pytest.fixture def mock_washer_api(): """Get a mock of a washer.""" - mock_washer = mock.Mock(said="said_washer") + mock_washer = Mock(spec=washerdryer.WasherDryer, said="said_washer") mock_washer.name = "Washer" - mock_washer.fetch_data = AsyncMock() - mock_washer.register_attr_callback = MagicMock() - mock_washer.appliance_info.data_model = "washer" - mock_washer.appliance_info.category = "washer_dryer" - mock_washer.appliance_info.model_number = "12345" + mock_washer.appliance_info = Mock( + data_model="washer", category="washer_dryer", model_number="12345" + ) mock_washer.get_online.return_value = True mock_washer.get_machine_state.return_value = ( washerdryer.MachineState.RunningMainCycle @@ -160,13 +148,11 @@ def mock_washer_api(): @pytest.fixture def mock_dryer_api(): """Get a mock of a dryer.""" - mock_dryer = mock.Mock(said="said_dryer") + mock_dryer = mock.Mock(spec=washerdryer.WasherDryer, said="said_dryer") mock_dryer.name = "Dryer" - mock_dryer.fetch_data = AsyncMock() - mock_dryer.register_attr_callback = MagicMock() - mock_dryer.appliance_info.data_model = "dryer" - mock_dryer.appliance_info.category = "washer_dryer" - mock_dryer.appliance_info.model_number = "12345" + mock_dryer.appliance_info = Mock( + data_model="dryer", category="washer_dryer", model_number="12345" + ) mock_dryer.get_online.return_value = True mock_dryer.get_machine_state.return_value = ( washerdryer.MachineState.RunningMainCycle @@ -179,5 +165,4 @@ def mock_dryer_api(): mock_dryer.get_cycle_status_soaking.return_value = False mock_dryer.get_cycle_status_spinning.return_value = False mock_dryer.get_cycle_status_washing.return_value = False - return mock_dryer diff --git a/tests/components/whirlpool/test_config_flow.py b/tests/components/whirlpool/test_config_flow.py index 5cfc6e4db10..6563f88515f 100644 --- a/tests/components/whirlpool/test_config_flow.py +++ b/tests/components/whirlpool/test_config_flow.py @@ -196,6 +196,7 @@ async def test_no_appliances_flow( region: tuple[str, Region], brand: tuple[str, Brand], mock_appliances_manager_api: MagicMock, + mock_whirlpool_setup_entry: MagicMock, ) -> None: """Test we get an error with no appliances.""" result = await hass.config_entries.flow.async_init( @@ -205,6 +206,7 @@ async def test_no_appliances_flow( assert result["type"] is FlowResultType.FORM assert result["step_id"] == config_entries.SOURCE_USER + original_aircons = mock_appliances_manager_api.return_value.aircons mock_appliances_manager_api.return_value.aircons = [] mock_appliances_manager_api.return_value.washer_dryers = [] result = await hass.config_entries.flow.async_configure( @@ -214,6 +216,14 @@ async def test_no_appliances_flow( assert result["type"] is FlowResultType.FORM assert result["errors"] == {"base": "no_appliances"} + # Test that it succeeds if appliances are found + mock_appliances_manager_api.return_value.aircons = original_aircons + result = await hass.config_entries.flow.async_configure( + result["flow_id"], CONFIG_INPUT | {CONF_REGION: region[0], CONF_BRAND: brand[0]} + ) + + assert_successful_user_flow(mock_whirlpool_setup_entry, result, region[0], brand[0]) + @pytest.mark.usefixtures( "mock_auth_api", "mock_appliances_manager_api", "mock_whirlpool_setup_entry" diff --git a/tests/components/zeroconf/test_init.py b/tests/components/zeroconf/test_init.py index 56262600511..847727796bb 100644 --- a/tests/components/zeroconf/test_init.py +++ b/tests/components/zeroconf/test_init.py @@ -14,6 +14,7 @@ from zeroconf.asyncio import AsyncServiceInfo from homeassistant import config_entries from homeassistant.components import zeroconf +from homeassistant.components.zeroconf import discovery from homeassistant.const import ( EVENT_COMPONENT_LOADED, EVENT_HOMEASSISTANT_CLOSE, @@ -181,10 +182,10 @@ async def test_setup(hass: HomeAssistant, mock_async_zeroconf: MagicMock) -> Non ), patch.object(hass.config_entries.flow, "async_init") as mock_config_flow, patch.object( - zeroconf, "AsyncServiceBrowser", side_effect=service_update_mock + discovery, "AsyncServiceBrowser", side_effect=service_update_mock ) as mock_service_browser, patch( - "homeassistant.components.zeroconf.AsyncServiceInfo", + "homeassistant.components.zeroconf.discovery.AsyncServiceInfo", side_effect=get_service_info_mock, ), ): @@ -214,7 +215,7 @@ async def test_setup_with_overly_long_url_and_name( """Test we still setup with long urls and names.""" with ( patch.object(hass.config_entries.flow, "async_init"), - patch.object(zeroconf, "AsyncServiceBrowser", side_effect=service_update_mock), + patch.object(discovery, "AsyncServiceBrowser", side_effect=service_update_mock), patch( "homeassistant.components.zeroconf.get_url", return_value=( @@ -240,7 +241,7 @@ async def test_setup_with_overly_long_url_and_name( ), ), patch( - "homeassistant.components.zeroconf.AsyncServiceInfo.async_request", + "homeassistant.components.zeroconf.discovery.AsyncServiceInfo.async_request", ), ): assert await async_setup_component(hass, zeroconf.DOMAIN, {zeroconf.DOMAIN: {}}) @@ -258,9 +259,9 @@ async def test_setup_with_defaults( """Test default interface config.""" with ( patch.object(hass.config_entries.flow, "async_init"), - patch.object(zeroconf, "AsyncServiceBrowser", side_effect=service_update_mock), + patch.object(discovery, "AsyncServiceBrowser", side_effect=service_update_mock), patch( - "homeassistant.components.zeroconf.AsyncServiceInfo", + "homeassistant.components.zeroconf.discovery.AsyncServiceInfo", side_effect=get_service_info_mock, ), ): @@ -302,10 +303,10 @@ async def test_zeroconf_match_macaddress(hass: HomeAssistant) -> None: ), patch.object(hass.config_entries.flow, "async_init") as mock_config_flow, patch.object( - zeroconf, "AsyncServiceBrowser", side_effect=http_only_service_update_mock + discovery, "AsyncServiceBrowser", side_effect=http_only_service_update_mock ) as mock_service_browser, patch( - "homeassistant.components.zeroconf.AsyncServiceInfo", + "homeassistant.components.zeroconf.discovery.AsyncServiceInfo", side_effect=get_zeroconf_info_mock("FFAADDCC11DD"), ), ): @@ -351,10 +352,10 @@ async def test_zeroconf_match_manufacturer(hass: HomeAssistant) -> None: ), patch.object(hass.config_entries.flow, "async_init") as mock_config_flow, patch.object( - zeroconf, "AsyncServiceBrowser", side_effect=http_only_service_update_mock + discovery, "AsyncServiceBrowser", side_effect=http_only_service_update_mock ) as mock_service_browser, patch( - "homeassistant.components.zeroconf.AsyncServiceInfo", + "homeassistant.components.zeroconf.discovery.AsyncServiceInfo", side_effect=get_zeroconf_info_mock_manufacturer("Samsung Electronics"), ), ): @@ -392,10 +393,10 @@ async def test_zeroconf_match_model(hass: HomeAssistant) -> None: ), patch.object(hass.config_entries.flow, "async_init") as mock_config_flow, patch.object( - zeroconf, "AsyncServiceBrowser", side_effect=http_only_service_update_mock + discovery, "AsyncServiceBrowser", side_effect=http_only_service_update_mock ) as mock_service_browser, patch( - "homeassistant.components.zeroconf.AsyncServiceInfo", + "homeassistant.components.zeroconf.discovery.AsyncServiceInfo", side_effect=get_zeroconf_info_mock_model("appletv"), ), ): @@ -433,10 +434,10 @@ async def test_zeroconf_match_manufacturer_not_present(hass: HomeAssistant) -> N ), patch.object(hass.config_entries.flow, "async_init") as mock_config_flow, patch.object( - zeroconf, "AsyncServiceBrowser", side_effect=http_only_service_update_mock + discovery, "AsyncServiceBrowser", side_effect=http_only_service_update_mock ) as mock_service_browser, patch( - "homeassistant.components.zeroconf.AsyncServiceInfo", + "homeassistant.components.zeroconf.discovery.AsyncServiceInfo", side_effect=get_zeroconf_info_mock("aabbccddeeff"), ), ): @@ -469,10 +470,10 @@ async def test_zeroconf_no_match(hass: HomeAssistant) -> None: ), patch.object(hass.config_entries.flow, "async_init") as mock_config_flow, patch.object( - zeroconf, "AsyncServiceBrowser", side_effect=http_only_service_update_mock + discovery, "AsyncServiceBrowser", side_effect=http_only_service_update_mock ) as mock_service_browser, patch( - "homeassistant.components.zeroconf.AsyncServiceInfo", + "homeassistant.components.zeroconf.discovery.AsyncServiceInfo", side_effect=get_zeroconf_info_mock("FFAADDCC11DD"), ), ): @@ -509,10 +510,10 @@ async def test_zeroconf_no_match_manufacturer(hass: HomeAssistant) -> None: ), patch.object(hass.config_entries.flow, "async_init") as mock_config_flow, patch.object( - zeroconf, "AsyncServiceBrowser", side_effect=http_only_service_update_mock + discovery, "AsyncServiceBrowser", side_effect=http_only_service_update_mock ) as mock_service_browser, patch( - "homeassistant.components.zeroconf.AsyncServiceInfo", + "homeassistant.components.zeroconf.discovery.AsyncServiceInfo", side_effect=get_zeroconf_info_mock_manufacturer("Not Samsung Electronics"), ), ): @@ -540,14 +541,14 @@ async def test_homekit_match_partial_space(hass: HomeAssistant) -> None: ), patch.object(hass.config_entries.flow, "async_init") as mock_config_flow, patch.object( - zeroconf, + discovery, "AsyncServiceBrowser", side_effect=lambda *args, **kwargs: service_update_mock( *args, **kwargs, limit_service="_hap._tcp.local." ), ) as mock_service_browser, patch( - "homeassistant.components.zeroconf.AsyncServiceInfo", + "homeassistant.components.zeroconf.discovery.AsyncServiceInfo", side_effect=get_homekit_info_mock("LIFX bulb", HOMEKIT_STATUS_UNPAIRED), ), ): @@ -588,14 +589,14 @@ async def test_device_with_invalid_name( ), patch.object(hass.config_entries.flow, "async_init") as mock_config_flow, patch.object( - zeroconf, + discovery, "AsyncServiceBrowser", side_effect=lambda *args, **kwargs: service_update_mock( *args, **kwargs, limit_service="_hap._tcp.local." ), ) as mock_service_browser, patch( - "homeassistant.components.zeroconf.AsyncServiceInfo", + "homeassistant.components.zeroconf.discovery.AsyncServiceInfo", side_effect=BadTypeInNameException, ), ): @@ -624,14 +625,14 @@ async def test_homekit_match_partial_dash(hass: HomeAssistant) -> None: ), patch.object(hass.config_entries.flow, "async_init") as mock_config_flow, patch.object( - zeroconf, + discovery, "AsyncServiceBrowser", side_effect=lambda *args, **kwargs: service_update_mock( *args, **kwargs, limit_service="_hap._udp.local." ), ) as mock_service_browser, patch( - "homeassistant.components.zeroconf.AsyncServiceInfo", + "homeassistant.components.zeroconf.discovery.AsyncServiceInfo", side_effect=get_homekit_info_mock( "Smart Bridge-001", HOMEKIT_STATUS_UNPAIRED ), @@ -662,14 +663,14 @@ async def test_homekit_match_partial_fnmatch(hass: HomeAssistant) -> None: ), patch.object(hass.config_entries.flow, "async_init") as mock_config_flow, patch.object( - zeroconf, + discovery, "AsyncServiceBrowser", side_effect=lambda *args, **kwargs: service_update_mock( *args, **kwargs, limit_service="_hap._tcp.local." ), ) as mock_service_browser, patch( - "homeassistant.components.zeroconf.AsyncServiceInfo", + "homeassistant.components.zeroconf.discovery.AsyncServiceInfo", side_effect=get_homekit_info_mock("YLDP13YL", HOMEKIT_STATUS_UNPAIRED), ), ): @@ -698,14 +699,14 @@ async def test_homekit_match_full(hass: HomeAssistant) -> None: ), patch.object(hass.config_entries.flow, "async_init") as mock_config_flow, patch.object( - zeroconf, + discovery, "AsyncServiceBrowser", side_effect=lambda *args, **kwargs: service_update_mock( *args, **kwargs, limit_service="_hap._udp.local." ), ) as mock_service_browser, patch( - "homeassistant.components.zeroconf.AsyncServiceInfo", + "homeassistant.components.zeroconf.discovery.AsyncServiceInfo", side_effect=get_homekit_info_mock("BSB002", HOMEKIT_STATUS_UNPAIRED), ), ): @@ -737,14 +738,14 @@ async def test_homekit_already_paired(hass: HomeAssistant) -> None: ), patch.object(hass.config_entries.flow, "async_init") as mock_config_flow, patch.object( - zeroconf, + discovery, "AsyncServiceBrowser", side_effect=lambda *args, **kwargs: service_update_mock( *args, **kwargs, limit_service="_hap._tcp.local." ), ) as mock_service_browser, patch( - "homeassistant.components.zeroconf.AsyncServiceInfo", + "homeassistant.components.zeroconf.discovery.AsyncServiceInfo", side_effect=get_homekit_info_mock("tado", HOMEKIT_STATUS_PAIRED), ), ): @@ -774,14 +775,14 @@ async def test_homekit_invalid_paring_status(hass: HomeAssistant) -> None: ), patch.object(hass.config_entries.flow, "async_init") as mock_config_flow, patch.object( - zeroconf, + discovery, "AsyncServiceBrowser", side_effect=lambda *args, **kwargs: service_update_mock( *args, **kwargs, limit_service="_hap._tcp.local." ), ) as mock_service_browser, patch( - "homeassistant.components.zeroconf.AsyncServiceInfo", + "homeassistant.components.zeroconf.discovery.AsyncServiceInfo", side_effect=get_homekit_info_mock("Smart Bridge", b"invalid"), ), ): @@ -805,10 +806,10 @@ async def test_homekit_not_paired(hass: HomeAssistant) -> None: ), patch.object(hass.config_entries.flow, "async_init") as mock_config_flow, patch.object( - zeroconf, "AsyncServiceBrowser", side_effect=service_update_mock + discovery, "AsyncServiceBrowser", side_effect=service_update_mock ) as mock_service_browser, patch( - "homeassistant.components.zeroconf.AsyncServiceInfo", + "homeassistant.components.zeroconf.discovery.AsyncServiceInfo", side_effect=get_homekit_info_mock( "this_will_not_match_any_integration", HOMEKIT_STATUS_UNPAIRED ), @@ -847,14 +848,14 @@ async def test_homekit_controller_still_discovered_unpaired_for_cloud( ), patch.object(hass.config_entries.flow, "async_init") as mock_config_flow, patch.object( - zeroconf, + discovery, "AsyncServiceBrowser", side_effect=lambda *args, **kwargs: service_update_mock( *args, **kwargs, limit_service="_hap._udp.local." ), ) as mock_service_browser, patch( - "homeassistant.components.zeroconf.AsyncServiceInfo", + "homeassistant.components.zeroconf.discovery.AsyncServiceInfo", side_effect=get_homekit_info_mock("Rachio-xyz", HOMEKIT_STATUS_UNPAIRED), ), ): @@ -892,14 +893,14 @@ async def test_homekit_controller_still_discovered_unpaired_for_polling( ), patch.object(hass.config_entries.flow, "async_init") as mock_config_flow, patch.object( - zeroconf, + discovery, "AsyncServiceBrowser", side_effect=lambda *args, **kwargs: service_update_mock( *args, **kwargs, limit_service="_hap._udp.local." ), ) as mock_service_browser, patch( - "homeassistant.components.zeroconf.AsyncServiceInfo", + "homeassistant.components.zeroconf.discovery.AsyncServiceInfo", side_effect=get_homekit_info_mock("iSmartGate", HOMEKIT_STATUS_UNPAIRED), ), ): @@ -1053,9 +1054,9 @@ async def test_removed_ignored(hass: HomeAssistant) -> None: ) with ( - patch.object(zeroconf, "AsyncServiceBrowser", side_effect=service_update_mock), + patch.object(discovery, "AsyncServiceBrowser", side_effect=service_update_mock), patch( - "homeassistant.components.zeroconf.AsyncServiceInfo", + "homeassistant.components.zeroconf.discovery.AsyncServiceInfo", side_effect=get_service_info_mock, ) as mock_service_info, ): @@ -1088,13 +1089,13 @@ async def test_async_detect_interfaces_setting_non_loopback_route( with ( patch("homeassistant.components.zeroconf.HaZeroconf") as mock_zc, patch.object(hass.config_entries.flow, "async_init"), - patch.object(zeroconf, "AsyncServiceBrowser", side_effect=service_update_mock), + patch.object(discovery, "AsyncServiceBrowser", side_effect=service_update_mock), patch( "homeassistant.components.zeroconf.network.async_get_loaded_adapters", return_value=_ADAPTER_WITH_DEFAULT_ENABLED, ), patch( - "homeassistant.components.zeroconf.AsyncServiceInfo", + "homeassistant.components.zeroconf.discovery.AsyncServiceInfo", side_effect=get_service_info_mock, ), ): @@ -1176,13 +1177,13 @@ async def test_async_detect_interfaces_setting_empty_route_linux( patch("homeassistant.components.zeroconf.sys.platform", "linux"), patch("homeassistant.components.zeroconf.HaZeroconf") as mock_zc, patch.object(hass.config_entries.flow, "async_init"), - patch.object(zeroconf, "AsyncServiceBrowser", side_effect=service_update_mock), + patch.object(discovery, "AsyncServiceBrowser", side_effect=service_update_mock), patch( "homeassistant.components.zeroconf.network.async_get_loaded_adapters", return_value=_ADAPTERS_WITH_MANUAL_CONFIG, ), patch( - "homeassistant.components.zeroconf.AsyncServiceInfo", + "homeassistant.components.zeroconf.discovery.AsyncServiceInfo", side_effect=get_service_info_mock, ), ): @@ -1210,13 +1211,13 @@ async def test_async_detect_interfaces_setting_empty_route_freebsd( patch("homeassistant.components.zeroconf.sys.platform", "freebsd"), patch("homeassistant.components.zeroconf.HaZeroconf") as mock_zc, patch.object(hass.config_entries.flow, "async_init"), - patch.object(zeroconf, "AsyncServiceBrowser", side_effect=service_update_mock), + patch.object(discovery, "AsyncServiceBrowser", side_effect=service_update_mock), patch( "homeassistant.components.zeroconf.network.async_get_loaded_adapters", return_value=_ADAPTERS_WITH_MANUAL_CONFIG, ), patch( - "homeassistant.components.zeroconf.AsyncServiceInfo", + "homeassistant.components.zeroconf.discovery.AsyncServiceInfo", side_effect=get_service_info_mock, ), ): @@ -1261,13 +1262,13 @@ async def test_async_detect_interfaces_explicitly_set_ipv6_linux( patch("homeassistant.components.zeroconf.sys.platform", "linux"), patch("homeassistant.components.zeroconf.HaZeroconf") as mock_zc, patch.object(hass.config_entries.flow, "async_init"), - patch.object(zeroconf, "AsyncServiceBrowser", side_effect=service_update_mock), + patch.object(discovery, "AsyncServiceBrowser", side_effect=service_update_mock), patch( "homeassistant.components.zeroconf.network.async_get_loaded_adapters", return_value=_ADAPTER_WITH_DEFAULT_ENABLED_AND_IPV6, ), patch( - "homeassistant.components.zeroconf.AsyncServiceInfo", + "homeassistant.components.zeroconf.discovery.AsyncServiceInfo", side_effect=get_service_info_mock, ), ): @@ -1290,13 +1291,13 @@ async def test_async_detect_interfaces_explicitly_set_ipv6_freebsd( patch("homeassistant.components.zeroconf.sys.platform", "freebsd"), patch("homeassistant.components.zeroconf.HaZeroconf") as mock_zc, patch.object(hass.config_entries.flow, "async_init"), - patch.object(zeroconf, "AsyncServiceBrowser", side_effect=service_update_mock), + patch.object(discovery, "AsyncServiceBrowser", side_effect=service_update_mock), patch( "homeassistant.components.zeroconf.network.async_get_loaded_adapters", return_value=_ADAPTER_WITH_DEFAULT_ENABLED_AND_IPV6, ), patch( - "homeassistant.components.zeroconf.AsyncServiceInfo", + "homeassistant.components.zeroconf.discovery.AsyncServiceInfo", side_effect=get_service_info_mock, ), ): @@ -1319,13 +1320,13 @@ async def test_async_detect_interfaces_explicitly_before_setup( patch("homeassistant.components.zeroconf.sys.platform", "linux"), patch("homeassistant.components.zeroconf.HaZeroconf") as mock_zc, patch.object(hass.config_entries.flow, "async_init"), - patch.object(zeroconf, "AsyncServiceBrowser", side_effect=service_update_mock), + patch.object(discovery, "AsyncServiceBrowser", side_effect=service_update_mock), patch( "homeassistant.components.zeroconf.network.async_get_loaded_adapters", return_value=_ADAPTER_WITH_DEFAULT_ENABLED_AND_IPV6, ), patch( - "homeassistant.components.zeroconf.AsyncServiceInfo", + "homeassistant.components.zeroconf.discovery.AsyncServiceInfo", side_effect=get_service_info_mock, ), ): @@ -1359,14 +1360,14 @@ async def test_setup_with_disallowed_characters_in_local_name( """Test we still setup with disallowed characters in the location name.""" with ( patch.object(hass.config_entries.flow, "async_init"), - patch.object(zeroconf, "AsyncServiceBrowser", side_effect=service_update_mock), + patch.object(discovery, "AsyncServiceBrowser", side_effect=service_update_mock), patch.object( hass.config, "location_name", "My.House", ), patch( - "homeassistant.components.zeroconf.AsyncServiceInfo.async_request", + "homeassistant.components.zeroconf.discovery.AsyncServiceInfo.async_request", ), ): assert await async_setup_component(hass, zeroconf.DOMAIN, {zeroconf.DOMAIN: {}}) @@ -1422,10 +1423,10 @@ async def test_zeroconf_removed(hass: HomeAssistant) -> None: ) as mock_async_progress_by_init_data_type, patch.object(hass.config_entries.flow, "async_abort") as mock_async_abort, patch.object( - zeroconf, "AsyncServiceBrowser", side_effect=_device_removed_mock + discovery, "AsyncServiceBrowser", side_effect=_device_removed_mock ) as mock_service_browser, patch( - "homeassistant.components.zeroconf.AsyncServiceInfo", + "homeassistant.components.zeroconf.discovery.AsyncServiceInfo", side_effect=get_zeroconf_info_mock("FFAADDCC11DD"), ), ): @@ -1545,10 +1546,10 @@ async def test_zeroconf_rediscover( ), patch.object(hass.config_entries.flow, "async_init") as mock_config_flow, patch.object( - zeroconf, "AsyncServiceBrowser", side_effect=http_only_service_update_mock + discovery, "AsyncServiceBrowser", side_effect=http_only_service_update_mock ) as mock_service_browser, patch( - "homeassistant.components.zeroconf.AsyncServiceInfo", + "homeassistant.components.zeroconf.discovery.AsyncServiceInfo", side_effect=get_zeroconf_info_mock("FFAADDCC11DD"), ), ): @@ -1665,10 +1666,10 @@ async def test_zeroconf_rediscover_no_match( ), patch.object(hass.config_entries.flow, "async_init") as mock_config_flow, patch.object( - zeroconf, "AsyncServiceBrowser", side_effect=http_only_service_update_mock + discovery, "AsyncServiceBrowser", side_effect=http_only_service_update_mock ) as mock_service_browser, patch( - "homeassistant.components.zeroconf.AsyncServiceInfo", + "homeassistant.components.zeroconf.discovery.AsyncServiceInfo", side_effect=get_zeroconf_info_mock("FFAADDCC11DD"), ), ): diff --git a/tests/components/zwave_js/test_init.py b/tests/components/zwave_js/test_init.py index 4abda90b5cf..a0423efdf52 100644 --- a/tests/components/zwave_js/test_init.py +++ b/tests/components/zwave_js/test_init.py @@ -23,7 +23,6 @@ from zwave_js_server.model.node import Node, NodeDataType from zwave_js_server.model.version import VersionInfo from homeassistant.components.hassio import HassioAPIError -from homeassistant.components.logger import DOMAIN as LOGGER_DOMAIN, SERVICE_SET_LEVEL from homeassistant.components.persistent_notification import async_dismiss from homeassistant.components.zwave_js import DOMAIN from homeassistant.components.zwave_js.helpers import get_device_id, get_device_id_ext @@ -42,6 +41,7 @@ from .common import AIR_TEMPERATURE_SENSOR, EATON_RF9640_ENTITY from tests.common import ( MockConfigEntry, + async_call_logger_set_level, async_fire_time_changed, async_get_persistent_notifications, ) @@ -2018,7 +2018,9 @@ async def test_identify_event( assert "network with the home ID `3245146787`" in notifications[msg_id]["message"] -async def test_server_logging(hass: HomeAssistant, client: MagicMock) -> None: +async def test_server_logging( + hass: HomeAssistant, client: MagicMock, caplog: pytest.LogCaptureFixture +) -> None: """Test automatic server logging functionality.""" def _reset_mocks(): @@ -2037,83 +2039,82 @@ async def test_server_logging(hass: HomeAssistant, client: MagicMock) -> None: # Setup logger and set log level to debug to trigger event listener assert await async_setup_component(hass, "logger", {"logger": {}}) - assert logging.getLogger("zwave_js_server").getEffectiveLevel() == logging.INFO - client.async_send_command.reset_mock() - await hass.services.async_call( - LOGGER_DOMAIN, SERVICE_SET_LEVEL, {"zwave_js_server": "debug"}, blocking=True - ) - await hass.async_block_till_done() assert logging.getLogger("zwave_js_server").getEffectiveLevel() == logging.DEBUG + client.async_send_command.reset_mock() + async with async_call_logger_set_level( + "zwave_js_server", "DEBUG", hass=hass, caplog=caplog + ): + assert logging.getLogger("zwave_js_server").getEffectiveLevel() == logging.DEBUG - # Validate that the server logging was enabled - assert len(client.async_send_command.call_args_list) == 1 - assert client.async_send_command.call_args[0][0] == { - "command": "driver.update_log_config", - "config": {"level": "debug"}, - } - assert client.enable_server_logging.called - assert not client.disable_server_logging.called + # Validate that the server logging was enabled + assert len(client.async_send_command.call_args_list) == 1 + assert client.async_send_command.call_args[0][0] == { + "command": "driver.update_log_config", + "config": {"level": "debug"}, + } + assert client.enable_server_logging.called + assert not client.disable_server_logging.called - _reset_mocks() + _reset_mocks() - # Emulate server by setting log level to debug - event = Event( - type="log config updated", - data={ - "source": "driver", - "event": "log config updated", - "config": { - "enabled": False, - "level": "debug", - "logToFile": True, - "filename": "test", - "forceConsole": True, + # Emulate server by setting log level to debug + event = Event( + type="log config updated", + data={ + "source": "driver", + "event": "log config updated", + "config": { + "enabled": False, + "level": "debug", + "logToFile": True, + "filename": "test", + "forceConsole": True, + }, }, - }, - ) - client.driver.receive_event(event) + ) + client.driver.receive_event(event) - # "Enable" server logging and unload the entry - client.server_logging_enabled = True - await hass.config_entries.async_unload(entry.entry_id) + # "Enable" server logging and unload the entry + client.server_logging_enabled = True + await hass.config_entries.async_unload(entry.entry_id) - # Validate that the server logging was disabled - assert len(client.async_send_command.call_args_list) == 1 - assert client.async_send_command.call_args[0][0] == { - "command": "driver.update_log_config", - "config": {"level": "info"}, - } - assert not client.enable_server_logging.called - assert client.disable_server_logging.called + # Validate that the server logging was disabled + assert len(client.async_send_command.call_args_list) == 1 + assert client.async_send_command.call_args[0][0] == { + "command": "driver.update_log_config", + "config": {"level": "info"}, + } + assert not client.enable_server_logging.called + assert client.disable_server_logging.called - _reset_mocks() + _reset_mocks() - # Validate that the server logging doesn't get enabled because HA thinks it already - # is enabled - await hass.config_entries.async_setup(entry.entry_id) - await hass.async_block_till_done() - assert len(client.async_send_command.call_args_list) == 2 - assert client.async_send_command.call_args_list[0][0][0] == { - "command": "controller.get_provisioning_entries", - } - assert client.async_send_command.call_args_list[1][0][0] == { - "command": "controller.get_provisioning_entry", - "dskOrNodeId": 1, - } - assert not client.enable_server_logging.called - assert not client.disable_server_logging.called + # Validate that the server logging doesn't get enabled because HA thinks it already + # is enabled + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + assert len(client.async_send_command.call_args_list) == 2 + assert client.async_send_command.call_args_list[0][0][0] == { + "command": "controller.get_provisioning_entries", + } + assert client.async_send_command.call_args_list[1][0][0] == { + "command": "controller.get_provisioning_entry", + "dskOrNodeId": 1, + } + assert not client.enable_server_logging.called + assert not client.disable_server_logging.called - _reset_mocks() + _reset_mocks() - # "Disable" server logging and unload the entry - client.server_logging_enabled = False - await hass.config_entries.async_unload(entry.entry_id) + # "Disable" server logging and unload the entry + client.server_logging_enabled = False + await hass.config_entries.async_unload(entry.entry_id) - # Validate that the server logging was not disabled because HA thinks it is already - # is disabled - assert len(client.async_send_command.call_args_list) == 0 - assert not client.enable_server_logging.called - assert not client.disable_server_logging.called + # Validate that the server logging was not disabled because HA thinks it is already + # is disabled + assert len(client.async_send_command.call_args_list) == 0 + assert not client.enable_server_logging.called + assert not client.disable_server_logging.called async def test_factory_reset_node( diff --git a/tests/conftest.py b/tests/conftest.py index a34c20a1445..5e1a97863f8 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1346,7 +1346,10 @@ def mock_zeroconf() -> Generator[MagicMock]: with ( patch("homeassistant.components.zeroconf.HaZeroconf", autospec=True) as mock_zc, - patch("homeassistant.components.zeroconf.AsyncServiceBrowser", autospec=True), + patch( + "homeassistant.components.zeroconf.discovery.AsyncServiceBrowser", + autospec=True, + ), ): zc = mock_zc.return_value # DNSCache has strong Cython type checks, and MagicMock does not work diff --git a/tests/hassfest/test_dependencies.py b/tests/hassfest/test_dependencies.py index 84e02b2d9d5..26ed8a01ba8 100644 --- a/tests/hassfest/test_dependencies.py +++ b/tests/hassfest/test_dependencies.py @@ -68,33 +68,6 @@ import homeassistant.components.renamed_absolute as hue assert mock_collector.unfiltered_referenced == {"renamed_absolute"} -def test_hass_components_var(mock_collector) -> None: - """Test detecting a hass_components_var reference.""" - mock_collector.visit( - ast.parse( - """ -def bla(hass): - hass.components.hass_components_var.async_do_something() -""" - ) - ) - assert mock_collector.unfiltered_referenced == {"hass_components_var"} - - -def test_hass_components_class(mock_collector) -> None: - """Test detecting a hass_components_class reference.""" - mock_collector.visit( - ast.parse( - """ -class Hello: - def something(self): - self.hass.components.hass_components_class.async_yo() -""" - ) - ) - assert mock_collector.unfiltered_referenced == {"hass_components_class"} - - def test_all_imports(mock_collector) -> None: """Test all imports together.""" mock_collector.visit( @@ -108,13 +81,6 @@ from homeassistant.components.subimport.smart_home import EVENT_ALEXA_SMART_HOME from homeassistant.components.child_import_field import bla import homeassistant.components.renamed_absolute as hue - -def bla(hass): - hass.components.hass_components_var.async_do_something() - -class Hello: - def something(self): - self.hass.components.hass_components_class.async_yo() """ ) ) @@ -123,6 +89,4 @@ class Hello: "subimport", "child_import_field", "renamed_absolute", - "hass_components_var", - "hass_components_class", } diff --git a/tests/pylint/test_enforce_type_hints.py b/tests/pylint/test_enforce_type_hints.py index efa3ca9523a..c9748cc61f8 100644 --- a/tests/pylint/test_enforce_type_hints.py +++ b/tests/pylint/test_enforce_type_hints.py @@ -2,6 +2,7 @@ from __future__ import annotations +from collections.abc import Callable import re from types import ModuleType from unittest.mock import patch @@ -375,12 +376,11 @@ def test_invalid_config_flow_step( type_hint_checker.visit_classdef(class_node) -def test_invalid_custom_config_flow_step( - linter: UnittestLinter, type_hint_checker: BaseChecker -) -> None: - """Ensure invalid hints are rejected for ConfigFlow step.""" - class_node, func_node, arg_node = astroid.extract_node( - """ +@pytest.mark.parametrize( + ("code", "expected_messages_fn"), + [ + ( + """ class FlowHandler(): pass @@ -392,34 +392,79 @@ def test_invalid_custom_config_flow_step( ): async def async_step_axis_specific( #@ self, - device_config: dict #@ + device_config: dict ): pass - """, +""", + lambda func_node: [ + pylint.testutils.MessageTest( + msg_id="hass-return-type", + node=func_node, + args=("ConfigFlowResult", "async_step_axis_specific"), + line=11, + col_offset=4, + end_line=11, + end_col_offset=38, + ), + ], + ), + ( + """ + class FlowHandler(): + pass + + class ConfigSubentryFlow(FlowHandler): + pass + + class CustomSubentryFlowHandler(ConfigSubentryFlow): #@ + async def async_step_user( #@ + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + pass +""", + lambda func_node: [ + pylint.testutils.MessageTest( + msg_id="hass-return-type", + node=func_node, + args=("SubentryFlowResult", "async_step_user"), + line=9, + col_offset=4, + end_line=9, + end_col_offset=29, + ), + ], + ), + ], + ids=[ + "Config flow", + "Config subentry flow", + ], +) +def test_invalid_flow_step( + linter: UnittestLinter, + type_hint_checker: BaseChecker, + code: str, + expected_messages_fn: Callable[ + [astroid.NodeNG], tuple[pylint.testutils.MessageTest, ...] + ], +) -> None: + """Ensure invalid hints are rejected for flow step.""" + class_node, func_node = astroid.extract_node( + code, "homeassistant.components.pylint_test.config_flow", ) type_hint_checker.visit_module(class_node.parent) with assert_adds_messages( linter, - pylint.testutils.MessageTest( - msg_id="hass-return-type", - node=func_node, - args=("ConfigFlowResult", "async_step_axis_specific"), - line=11, - col_offset=4, - end_line=11, - end_col_offset=38, - ), + *expected_messages_fn(func_node), ): type_hint_checker.visit_classdef(class_node) -def test_valid_config_flow_step( - linter: UnittestLinter, type_hint_checker: BaseChecker -) -> None: - """Ensure valid hints are accepted for ConfigFlow step.""" - class_node = astroid.extract_node( +@pytest.mark.parametrize( + "code", + [ """ class FlowHandler(): pass @@ -436,6 +481,33 @@ def test_valid_config_flow_step( ) -> ConfigFlowResult: pass """, + """ + class FlowHandler(): + pass + + class ConfigSubentryFlow(FlowHandler): + pass + + class CustomSubentryFlowHandler(ConfigSubentryFlow): #@ + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> SubentryFlowResult: + pass +""", + ], + ids=[ + "Config flow", + "Config subentry flow", + ], +) +def test_valid_flow_step( + linter: UnittestLinter, + type_hint_checker: BaseChecker, + code: str, +) -> None: + """Ensure valid hints are accepted for flow step.""" + class_node = astroid.extract_node( + code, "homeassistant.components.pylint_test.config_flow", ) type_hint_checker.visit_module(class_node.parent) diff --git a/tests/test_loader.py b/tests/test_loader.py index 793e0de6fef..16515cbd4e6 100644 --- a/tests/test_loader.py +++ b/tests/test_loader.py @@ -12,13 +12,13 @@ from awesomeversion import AwesomeVersion import pytest from homeassistant import loader -from homeassistant.components import http, hue +from homeassistant.components import hue from homeassistant.components.hue import light as hue_light -from homeassistant.core import HomeAssistant, callback +from homeassistant.core import HomeAssistant from homeassistant.helpers.json import json_dumps from homeassistant.util.json import json_loads -from .common import MockModule, async_get_persistent_notifications, mock_integration +from .common import MockModule, mock_integration async def test_circular_component_dependencies(hass: HomeAssistant) -> None: @@ -114,48 +114,6 @@ async def test_nonexistent_component_dependencies(hass: HomeAssistant) -> None: assert result == {} -def test_component_loader(hass: HomeAssistant) -> None: - """Test loading components.""" - components = loader.Components(hass) - assert components.http.CONFIG_SCHEMA is http.CONFIG_SCHEMA - assert hass.components.http.CONFIG_SCHEMA is http.CONFIG_SCHEMA - - -def test_component_loader_non_existing(hass: HomeAssistant) -> None: - """Test loading components.""" - components = loader.Components(hass) - with pytest.raises(ImportError): - _ = components.non_existing - - -async def test_component_wrapper(hass: HomeAssistant) -> None: - """Test component wrapper.""" - components = loader.Components(hass) - components.persistent_notification.async_create("message") - - notifications = async_get_persistent_notifications(hass) - assert len(notifications) - - -async def test_helpers_wrapper(hass: HomeAssistant) -> None: - """Test helpers wrapper.""" - helpers = loader.Helpers(hass) - - result = [] - - @callback - def discovery_callback(service, discovered): - """Handle discovery callback.""" - result.append(discovered) - - helpers.discovery.async_listen("service_name", discovery_callback) - - await helpers.discovery.async_discover("service_name", "hello", None, {}) - await hass.async_block_till_done() - - assert result == ["hello"] - - @pytest.mark.usefixtures("enable_custom_integrations") async def test_custom_component_name(hass: HomeAssistant) -> None: """Test the name attribute of custom components.""" @@ -168,10 +126,6 @@ async def test_custom_component_name(hass: HomeAssistant) -> None: assert int_comp.__name__ == "custom_components.test_package" assert int_comp.__package__ == "custom_components.test_package" - comp = hass.components.test_package - assert comp.__name__ == "custom_components.test_package" - assert comp.__package__ == "custom_components.test_package" - integration = await loader.async_get_integration(hass, "test") platform = integration.get_platform("light") assert integration.get_platform_cached("light") is platform @@ -1349,42 +1303,6 @@ async def test_config_folder_not_in_path() -> None: import tests.testing_config.check_config_not_in_path # noqa: F401 -@pytest.mark.parametrize( - ("integration_frame_path", "expected"), - [ - pytest.param( - "custom_components/test_integration_frame", True, id="custom integration" - ), - pytest.param( - "homeassistant/components/test_integration_frame", - False, - id="core integration", - ), - pytest.param("homeassistant/test_integration_frame", False, id="core"), - ], -) -@pytest.mark.usefixtures("mock_integration_frame") -async def test_hass_components_use_reported( - hass: HomeAssistant, - caplog: pytest.LogCaptureFixture, - expected: bool, -) -> None: - """Test whether use of hass.components is reported.""" - with ( - patch( - "homeassistant.components.http.start_http_server_and_save_config", - return_value=None, - ), - ): - await hass.components.http.start_http_server_and_save_config(hass, [], None) - - reported = ( - "Detected that custom integration 'test_integration_frame'" - " accesses hass.components.http, which should be updated" - ) in caplog.text - assert reported == expected - - async def test_async_get_component_preloads_config_and_config_flow( hass: HomeAssistant, ) -> None: @@ -2044,42 +1962,6 @@ async def test_has_services(hass: HomeAssistant) -> None: assert integration.has_services is True -@pytest.mark.parametrize( - ("integration_frame_path", "expected"), - [ - pytest.param( - "custom_components/test_integration_frame", True, id="custom integration" - ), - pytest.param( - "homeassistant/components/test_integration_frame", - False, - id="core integration", - ), - pytest.param("homeassistant/test_integration_frame", False, id="core"), - ], -) -@pytest.mark.usefixtures("mock_integration_frame") -async def test_hass_helpers_use_reported( - hass: HomeAssistant, - caplog: pytest.LogCaptureFixture, - expected: bool, -) -> None: - """Test whether use of hass.helpers is reported.""" - with ( - patch( - "homeassistant.helpers.aiohttp_client.async_get_clientsession", - return_value=None, - ), - ): - hass.helpers.aiohttp_client.async_get_clientsession() - - reported = ( - "Detected that custom integration 'test_integration_frame' " - "accesses hass.helpers.aiohttp_client, which should be updated" - ) in caplog.text - assert reported == expected - - async def test_manifest_json_fragment_round_trip(hass: HomeAssistant) -> None: """Test json_fragment roundtrip.""" integration = await loader.async_get_integration(hass, "hue")