diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index f14dcb8db0c5..0594c8bf983f 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -55,10 +55,12 @@ env: # and some queries that work on MariaDB do not work on MySQL MARIADB_VERSIONS: "['mariadb:10.3.32','mariadb:10.6.10','mariadb:10.10.3','mariadb:10.11.2','mariadb:11.4.9','mysql:8.0.32']" # 12 is the oldest supported version - # - 12.14 is the latest (as of 9 Feb 2023) - # 15 is the latest version - # - 15.2 is the latest (as of 9 Feb 2023) - POSTGRESQL_VERSIONS: "['postgres:12.14','postgres:15.2']" + # - 12.22 is the latest (as of 19 Aug 2026) + # 15 is a supported version + # - 15.19 is the latest (as of 19 Aug 2026) + # 18 is the latest version + # - 18.6 is the latest (as of 19 Aug 2026) + POSTGRESQL_VERSIONS: "['postgres:12.22','postgres:15.19', 'postgres:18.6']" UV_CACHE_DIR: /tmp/uv-cache APT_CACHE_VERSION: 1 SQLALCHEMY_WARN_20: 1 @@ -472,6 +474,32 @@ jobs: run: | uv run --no-project python -m script.gen_copilot_instructions validate + gen-recorder-db-versions: + name: Check recorder database versions + runs-on: ubuntu-24.04 + permissions: + contents: read + needs: + - info + # Only run on push to the dev branch; this job reaches out to endoflife.date, and + # we do not want a new MariaDB/MySQL release to fail CI on PR runs or the rc/master + # branches. + if: github.event_name == 'push' && github.ref == 'refs/heads/dev' + steps: + - name: Check out code from GitHub + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - name: Set up Python + id: python + uses: ./.github/actions/setup-uv-python + with: + uv-version: ${{ needs.info.outputs.uv_version }} + python-version: ${{ needs.info.outputs.default_python }} + - name: Check MariaDB and MySQL versions are up to date + run: | + uv run --no-project python -m script.gen_recorder_db_versions validate + dependency-review: name: Dependency review runs-on: ubuntu-24.04 @@ -833,9 +861,6 @@ jobs: - name: Register Python problem matcher run: | echo "::add-matcher::.github/workflows/matchers/python.json" - - name: Register pytest slow test problem matcher - run: | - echo "::add-matcher::.github/workflows/matchers/pytest-slow.json" - name: Download pytest_buckets uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 with: @@ -867,7 +892,8 @@ jobs: python3 -b -X dev -m pytest \ -qq \ --timeout=9 \ - --durations=10 \ + --durations=0 \ + --durations-min=1 \ --numprocesses auto \ --snapshot-details \ --dist=loadfile \ @@ -969,9 +995,6 @@ jobs: - name: Register Python problem matcher run: | echo "::add-matcher::.github/workflows/matchers/python.json" - - name: Register pytest slow test problem matcher - run: | - echo "::add-matcher::.github/workflows/matchers/pytest-slow.json" - name: Install SQL Python libraries run: | . venv/bin/activate @@ -1010,7 +1033,8 @@ jobs: --snapshot-details \ ${cov_params[@]} \ -o console_output_style=count \ - --durations=10 \ + --durations=0 \ + --durations-min=10 \ -p no:sugar \ --exclude-warning-annotations \ --dburl=mysql://root:password@127.0.0.1/homeassistant-test \ @@ -1064,6 +1088,9 @@ jobs: - 5432:5432 env: POSTGRES_PASSWORD: password + # 18+ images default PGDATA to /var/lib/postgresql//docker, + # which is not covered by the tmpfs below + PGDATA: /var/lib/postgresql/data options: >- --health-cmd="pg_isready -hlocalhost -Upostgres" --health-interval=5s --health-timeout=2s --health-retries=3 @@ -1119,9 +1146,6 @@ jobs: - name: Register Python problem matcher run: | echo "::add-matcher::.github/workflows/matchers/python.json" - - name: Register pytest slow test problem matcher - run: | - echo "::add-matcher::.github/workflows/matchers/pytest-slow.json" - name: Install SQL Python libraries run: | . venv/bin/activate diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index afc6f0245eb1..995fbd4b2d7d 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -28,11 +28,11 @@ jobs: persist-credentials: false - name: Initialize CodeQL - uses: github/codeql-action/init@5595ccaf912efad79be6eef63a5619ff05969be3 # v4.37.6 + uses: github/codeql-action/init@ff2f1c621b7f889edc0d3c761ac2e6a3f8cdb0dd # v4.37.7 with: languages: python - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@5595ccaf912efad79be6eef63a5619ff05969be3 # v4.37.6 + uses: github/codeql-action/analyze@ff2f1c621b7f889edc0d3c761ac2e6a3f8cdb0dd # v4.37.7 with: category: "/language:python" diff --git a/homeassistant/components/actron_air/__init__.py b/homeassistant/components/actron_air/__init__.py index ee362231efce..0a28550741cc 100644 --- a/homeassistant/components/actron_air/__init__.py +++ b/homeassistant/components/actron_air/__init__.py @@ -7,6 +7,7 @@ from homeassistant.const import CONF_API_TOKEN, Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady from homeassistant.helpers import device_registry as dr +from homeassistant.helpers.aiohttp_client import async_get_clientsession from .const import DOMAIN, LOGGER from .coordinator import ( @@ -27,7 +28,10 @@ PLATFORMS = [ async def async_setup_entry(hass: HomeAssistant, entry: ActronAirConfigEntry) -> bool: """Set up Actron Air integration from a config entry.""" - api = ActronAirAPI(refresh_token=entry.data[CONF_API_TOKEN]) + api = ActronAirAPI( + refresh_token=entry.data[CONF_API_TOKEN], + session=async_get_clientsession(hass), + ) systems: list[ActronAirSystemInfo] = [] try: diff --git a/homeassistant/components/actron_air/config_flow.py b/homeassistant/components/actron_air/config_flow.py index d0c91ba017d5..c3616a09370b 100644 --- a/homeassistant/components/actron_air/config_flow.py +++ b/homeassistant/components/actron_air/config_flow.py @@ -14,6 +14,7 @@ from homeassistant.config_entries import ( ) from homeassistant.const import CONF_API_TOKEN from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers.aiohttp_client import async_get_clientsession from .const import DOMAIN, LOGGER @@ -37,7 +38,7 @@ class ActronAirConfigFlow(ConfigFlow, domain=DOMAIN): """Handle the initial step.""" if self._api is None: LOGGER.debug("Initiating device authorization") - self._api = ActronAirAPI() + self._api = ActronAirAPI(session=async_get_clientsession(self.hass)) try: device_code_response = await self._api.request_device_code() except ActronAirAuthError as err: diff --git a/homeassistant/components/actron_air/manifest.json b/homeassistant/components/actron_air/manifest.json index a21ba35947e8..314f0506c415 100644 --- a/homeassistant/components/actron_air/manifest.json +++ b/homeassistant/components/actron_air/manifest.json @@ -13,5 +13,5 @@ "integration_type": "hub", "iot_class": "cloud_polling", "quality_scale": "silver", - "requirements": ["actron-neo-api==0.5.13"] + "requirements": ["actron-neo-api==0.5.14"] } diff --git a/homeassistant/components/actron_air/quality_scale.yaml b/homeassistant/components/actron_air/quality_scale.yaml index cb8dbad465e0..b3ca9c7f02bb 100644 --- a/homeassistant/components/actron_air/quality_scale.yaml +++ b/homeassistant/components/actron_air/quality_scale.yaml @@ -74,5 +74,5 @@ rules: # Platinum async-dependency: done - inject-websession: todo + inject-websession: done strict-typing: done diff --git a/homeassistant/components/alexa_devices/coordinator.py b/homeassistant/components/alexa_devices/coordinator.py index e7fe1cddb216..738ba2efb05e 100644 --- a/homeassistant/components/alexa_devices/coordinator.py +++ b/homeassistant/components/alexa_devices/coordinator.py @@ -132,8 +132,8 @@ class AmazonDevicesCoordinator(DataUpdateCoordinator[dict[str, AmazonDevice]]): device_registry = dr.async_get(hass) self.previous_devices: set[str] = { identifier - for device in device_registry.devices.get_devices_for_config_entry_id( - entry.entry_id + for device in dr.async_entries_for_config_entry( + device_registry, entry.entry_id ) if device.entry_type != dr.DeviceEntryType.SERVICE for identifier_domain, identifier in device.identifiers diff --git a/homeassistant/components/analytics/analytics.py b/homeassistant/components/analytics/analytics.py index e5c5e4419ce2..14a9f14e1a02 100644 --- a/homeassistant/components/analytics/analytics.py +++ b/homeassistant/components/analytics/analytics.py @@ -774,7 +774,7 @@ async def _async_snapshot_payload(hass: HomeAssistant) -> dict: # noqa: C901 removed_devices: set[str] = set() # Get device list - for device_entry in (*dev_reg.devices.values(), *dev_reg.child_devices.values()): + for device_entry in (*dev_reg.devices, *dev_reg.child_devices): config_entry = hass.config_entries.async_get_entry(device_entry.config_entry_id) if config_entry is None: diff --git a/homeassistant/components/androidtv_remote/remote.py b/homeassistant/components/androidtv_remote/remote.py index 7ecf4b3edf66..8a22a3fbccfc 100644 --- a/homeassistant/components/androidtv_remote/remote.py +++ b/homeassistant/components/androidtv_remote/remote.py @@ -2,7 +2,7 @@ import asyncio from collections.abc import Iterable -from typing import Any, override +from typing import Any, Final, override from homeassistant.components.remote import ( ATTR_ACTIVITY, @@ -16,14 +16,35 @@ from homeassistant.components.remote import ( RemoteEntityFeature, ) from homeassistant.core import HomeAssistant, callback +from homeassistant.exceptions import ServiceValidationError from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from .const import CONF_APP_NAME +from .const import CONF_APP_NAME, DOMAIN from .entity import AndroidTVRemoteBaseEntity from .helpers import AndroidTVRemoteConfigEntry PARALLEL_UPDATES = 0 +PREFIX_SEPARATOR: Final[str] = ":" +# Only direction prefixes are stripped; other colon conventions (e.g. text:) pass through to the library unchanged. +VALID_PREFIXES: Final[frozenset[str]] = frozenset( + { + "SHORT", + "START_LONG", + "END_LONG", + } +) + + +def _parse_command(single_command: str) -> tuple[str, str | None]: + """Split an optional prefix from the key code.""" + prefix, separator, rest = single_command.partition(PREFIX_SEPARATOR) + if separator: + normalized = prefix.upper() + if normalized in VALID_PREFIXES: + return rest, normalized + return single_command, None + async def async_setup_entry( hass: HomeAssistant, @@ -105,10 +126,24 @@ class AndroidTVRemoteEntity(AndroidTVRemoteBaseEntity, RemoteEntity): for _ in range(num_repeats): for single_command in command: + key_code, direction = _parse_command(single_command) + if direction is not None: + if not key_code: + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="empty_key_code", + translation_placeholders={"command": single_command}, + ) + if hold_secs: + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="direction_prefix_with_hold_secs", + translation_placeholders={"command": single_command}, + ) if hold_secs: - self._send_key_command(single_command, "START_LONG") + self._send_key_command(key_code, "START_LONG") await asyncio.sleep(hold_secs) - self._send_key_command(single_command, "END_LONG") + self._send_key_command(key_code, "END_LONG") else: - self._send_key_command(single_command, "SHORT") + self._send_key_command(key_code, direction or "SHORT") await asyncio.sleep(delay_secs) diff --git a/homeassistant/components/androidtv_remote/strings.json b/homeassistant/components/androidtv_remote/strings.json index e1d768f0adc4..e71f0ed7644b 100644 --- a/homeassistant/components/androidtv_remote/strings.json +++ b/homeassistant/components/androidtv_remote/strings.json @@ -56,6 +56,12 @@ "connection_closed": { "message": "Connection to the Android TV device is closed" }, + "direction_prefix_with_hold_secs": { + "message": "Command \"{command}\" combines a direction prefix with hold_secs; specify only one" + }, + "empty_key_code": { + "message": "Command \"{command}\" is missing a key code after the direction prefix" + }, "invalid_channel": { "message": "Channel must be numeric: {media_id}" }, diff --git a/homeassistant/components/anova/sensor.py b/homeassistant/components/anova/sensor.py index e6a74c7052b5..f407c04cfc25 100644 --- a/homeassistant/components/anova/sensor.py +++ b/homeassistant/components/anova/sensor.py @@ -33,6 +33,7 @@ SENSOR_DESCRIPTIONS: list[AnovaSensorEntityDescription] = [ key="cook_time", state_class=SensorStateClass.TOTAL_INCREASING, native_unit_of_measurement=UnitOfTime.SECONDS, + suggested_unit_of_measurement=UnitOfTime.HOURS, translation_key="cook_time", device_class=SensorDeviceClass.DURATION, value_fn=lambda data: data.cook_time, @@ -62,6 +63,7 @@ SENSOR_DESCRIPTIONS: list[AnovaSensorEntityDescription] = [ AnovaSensorEntityDescription( key="cook_time_remaining", native_unit_of_measurement=UnitOfTime.SECONDS, + suggested_unit_of_measurement=UnitOfTime.HOURS, translation_key="cook_time_remaining", device_class=SensorDeviceClass.DURATION, value_fn=lambda data: data.cook_time_remaining, diff --git a/homeassistant/components/ariston/__init__.py b/homeassistant/components/ariston/__init__.py new file mode 100644 index 000000000000..8fc1cb48fc27 --- /dev/null +++ b/homeassistant/components/ariston/__init__.py @@ -0,0 +1 @@ +"""Virtual integration: Ariston.""" diff --git a/homeassistant/components/ariston/manifest.json b/homeassistant/components/ariston/manifest.json new file mode 100644 index 000000000000..83d61f9dcec0 --- /dev/null +++ b/homeassistant/components/ariston/manifest.json @@ -0,0 +1,6 @@ +{ + "domain": "ariston", + "name": "Ariston", + "integration_type": "virtual", + "supported_by": "midea" +} diff --git a/homeassistant/components/assist_satellite/__init__.py b/homeassistant/components/assist_satellite/__init__.py index abc435f4a22e..a1fd10f07ab6 100644 --- a/homeassistant/components/assist_satellite/__init__.py +++ b/homeassistant/components/assist_satellite/__init__.py @@ -131,6 +131,8 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: f"Invalid Assist satellite entity id: {satellite_entity_id}" ) + satellite_entity.async_set_context(call.context) + ask_question_args = { "question": call.data.get("question"), "question_media_id": call.data.get("question_media_id"), diff --git a/homeassistant/components/assist_satellite/entity.py b/homeassistant/components/assist_satellite/entity.py index 24b27e363f3f..00e2d1582f2a 100644 --- a/homeassistant/components/assist_satellite/entity.py +++ b/homeassistant/components/assist_satellite/entity.py @@ -7,7 +7,6 @@ import contextlib from dataclasses import dataclass, field from enum import StrEnum import logging -import time from typing import Any, Literal, final, override from hassil import Intents, recognize @@ -442,6 +441,8 @@ class AssistSatelliteEntity(entity.Entity): start_stage: PipelineStage = PipelineStage.STT, end_stage: PipelineStage = PipelineStage.TTS, wake_word_phrase: str | None = None, + *, + context: Context | None = None, ) -> None: """Triggers an Assist pipeline in Home Assistant from a satellite.""" await self._cancel_running_pipeline() @@ -485,15 +486,8 @@ class AssistSatelliteEntity(entity.Entity): device_id = self.registry_entry.device_id if self.registry_entry else None - # Refresh context if necessary - if ( - (self._context is None) - or (self._context_set is None) - or ((time.time() - self._context_set) > entity.CONTEXT_RECENT_TIME_SECONDS) - ): - self.async_set_context(Context()) - - assert self._context is not None + context = context or Context() + self.async_set_context(context) # Set entity state based on pipeline events self._run_has_tts = False @@ -511,7 +505,7 @@ class AssistSatelliteEntity(entity.Entity): self.hass, async_pipeline_from_audio_stream( self.hass, - context=self._context, + context=context, event_callback=self._internal_on_pipeline_event, stt_metadata=stt.SpeechMetadata( language="", # set in async_pipeline_from_audio_stream diff --git a/homeassistant/components/august/util.py b/homeassistant/components/august/util.py index 7dafaabfe5f4..48d31412ce9a 100644 --- a/homeassistant/components/august/util.py +++ b/homeassistant/components/august/util.py @@ -1,6 +1,6 @@ """August util functions.""" -from datetime import datetime, timedelta +from datetime import timedelta from functools import partial import aiohttp @@ -11,6 +11,7 @@ from yalexs.manager.const import ACTIVITY_UPDATE_INTERVAL from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import aiohttp_client +from homeassistant.util import dt as dt_util from . import AugustData @@ -61,7 +62,7 @@ def _activity_time_based(latest: Activity) -> Activity | None: """Get the latest state of the sensor.""" start = latest.activity_start_time end = latest.activity_end_time + TIME_TO_DECLARE_DETECTION - if start <= datetime.now() <= end: # pylint: disable=home-assistant-enforce-naive-now + if start <= dt_util.naive_now() <= end: return latest return None diff --git a/homeassistant/components/auth/indieauth.py b/homeassistant/components/auth/indieauth.py index 8e5e9812da7d..e16e9eef8afe 100644 --- a/homeassistant/components/auth/indieauth.py +++ b/homeassistant/components/auth/indieauth.py @@ -1,7 +1,9 @@ """Helpers to resolve client ID/secret.""" from html.parser import HTMLParser +from http import HTTPStatus from ipaddress import ip_address +import json import logging from typing import override from urllib.parse import ParseResult, urljoin, urlparse @@ -14,6 +16,9 @@ from homeassistant.util.network import is_local _LOGGER = logging.getLogger(__name__) +# We limit reads of a client_id page to the first 10kB. +MAX_FETCH_BYTES = 10240 + async def verify_redirect_uri( hass: HomeAssistant, client_id: str, redirect_uri: str @@ -24,7 +29,10 @@ async def verify_redirect_uri( except ValueError: return False - redirect_parts = _parse_url(redirect_uri) + try: + redirect_parts = _parse_url(redirect_uri) + except ValueError: + return False # Verify redirect url and client url have same scheme and domain. is_valid = ( @@ -53,7 +61,15 @@ async def verify_redirect_uri( # IndieAuth 4.2.2 allows for redirect_uri to be on different domain # but needs to be specified in link tag when fetching `client_id`. redirect_uris = await fetch_redirect_uris(hass, client_id) - return redirect_uri in redirect_uris + if redirect_uri in redirect_uris: + return True + _LOGGER.debug( + "redirect_uri %s is not among the advertised redirect uris %s for client_id %s", + redirect_uri, + redirect_uris, + client_id, + ) + return False class LinkTagParser(HTMLParser): @@ -63,7 +79,7 @@ class LinkTagParser(HTMLParser): """Initialize a link tag parser.""" super().__init__() self.rel = rel - self.found: list[str | None] = [] + self.found: list[str] = [] @override def handle_starttag(self, tag: str, attrs: list[tuple[str, str | None]]) -> None: @@ -73,48 +89,115 @@ class LinkTagParser(HTMLParser): attributes: dict[str, str | None] = dict(attrs) - if attributes.get("rel") == self.rel: - self.found.append(attributes.get("href")) + # Skip tags with a missing or empty href: urljoin resolves those to + # the client_id URL itself instead of naming a redirect target. + if attributes.get("rel") == self.rel and (href := attributes.get("href")): + self.found.append(href) + + +def _reject_json_constant(constant: str) -> None: + """Reject NaN/Infinity/-Infinity, which RFC 8259 does not allow.""" + raise ValueError(f"Invalid JSON constant: {constant}") + + +def _is_valid_metadata_client_id(url: str) -> bool: + """Validate a client_id URL for the metadata-document fallback. + + The client identifier URL must be https with a path component and no + fragment (a bare trailing # counts as a fragment component). The remaining + client identifier rules are enforced upstream by _parse_client_id. + """ + try: + parts = urlparse(url) + # urlparse defers port validation until the attribute is accessed. + _ = parts.port + except ValueError: + return False + return parts.scheme == "https" and bool(parts.path) and "#" not in url + + +def _is_valid_metadata_redirect_uri(redirect_uri: str) -> bool: + """Validate a client ID metadata document redirect_uris entry. + + Entries must be absolute, fragment-free URIs: a non-empty scheme (so + private-use schemes like app:/callback stay valid) and no fragment per + RFC 6749 3.1.2 (a bare trailing # counts as a fragment component). + """ + try: + parts = urlparse(redirect_uri) + # urlparse defers port validation until the attribute is accessed. + _ = parts.port + except ValueError: + return False + return bool(parts.scheme) and "#" not in redirect_uri async def fetch_redirect_uris(hass: HomeAssistant, url: str) -> list[str]: - """Find link tag with redirect_uri values. + """Find the redirect_uri values that a client_id advertises. + + We support two formats, checked in this order: IndieAuth 4.2.2 The client SHOULD publish one or more tags or Link HTTP headers with a rel attribute of redirect_uri at the client_id URL. - We limit to the first 10kB of the page. + OAuth Client ID Metadata Document + (draft-ietf-oauth-client-id-metadata-document) + + The client_id URL returns a JSON document with a redirect_uris array. As we + advertise client_id_metadata_document_supported in the authorization server + metadata, we fall back to this format when no link tags are found. + + We read roughly the first 10kB of the page and a fetch error yields no + redirect uris. We do not implement extracting redirect uris from headers. """ - parser = LinkTagParser("redirect_uri") - chunks = 0 + body: bytes = b"" + status: int | None = None + redirected = False try: async with ( aiohttp.ClientSession() as session, session.get(url, timeout=aiohttp.ClientTimeout(total=5)) as resp, ): + status = resp.status + redirected = bool(resp.history) async for data in resp.content.iter_chunked(1024): - parser.feed(data.decode()) - chunks += 1 + body += data - if chunks == 10: + if len(body) >= MAX_FETCH_BYTES: break except TimeoutError: _LOGGER.error("Timeout while looking up redirect_uri %s", url) + return [] except aiohttp.client_exceptions.ClientSSLError: _LOGGER.error("SSL error while looking up redirect_uri %s", url) + return [] except aiohttp.client_exceptions.ClientOSError as ex: _LOGGER.error("OS error while looking up redirect_uri %s: %s", url, ex.strerror) + return [] except aiohttp.client_exceptions.ClientConnectionError: _LOGGER.error( "Low level connection error while looking up redirect_uri %s", url ) + return [] except aiohttp.client_exceptions.ClientError: _LOGGER.error("Unknown error while looking up redirect_uri %s", url) + return [] + + if redirect_uris := _parse_link_tag_redirect_uris(url, body): + return redirect_uris + + return _parse_metadata_document_redirect_uris(url, body, status, redirected) + + +def _parse_link_tag_redirect_uris(url: str, body: bytes) -> list[str]: + """Find values in the client_id page body.""" + parser = LinkTagParser("redirect_uri") + parser.feed(body.decode(errors="replace")) # Authorization endpoints verifying that a redirect_uri is allowed for use # by a client MUST look for an exact match of the given redirect_uri in the @@ -123,6 +206,77 @@ async def fetch_redirect_uris(hass: HomeAssistant, url: str) -> list[str]: return [urljoin(url, found) for found in parser.found] +def _parse_metadata_document_redirect_uris( + url: str, body: bytes, status: int | None, redirected: bool +) -> list[str]: + """Parse the client_id page body as an OAuth Client ID Metadata Document. + + Per draft-ietf-oauth-client-id-metadata-document the document only counts + when the client_id URL is https with a path and no fragment, the response + was a direct 200 (not redirected), the document's client_id round-trips, + and every redirect_uris entry is an absolute, fragment-free URI matched + exactly. The url and its document are client-controlled and fetched + unauthenticated, so rejections log at DEBUG (higher levels would be a + log-flood vector). + """ + # A body at the read cap may be truncated; a truncated prefix must not be + # trusted even if it happens to be parseable. + if ( + len(body) >= MAX_FETCH_BYTES + or status != HTTPStatus.OK + or redirected + or not _is_valid_metadata_client_id(url) + ): + _LOGGER.debug( + "Not treating %s as a client ID metadata document: body length %s," + " status %s, redirected %s (client_id must be a fragment-free https" + " URL with a path)", + url, + len(body), + status, + redirected, + ) + return [] + + try: + # Strict decode (RFC 8259 requires UTF-8): the link tag parser's + # lenient replacement decode would mask invalid bytes as U+FFFD. + document = json.loads(body.decode(), parse_constant=_reject_json_constant) + except UnicodeDecodeError: + _LOGGER.debug("Client ID metadata document at %s is not valid UTF-8", url) + return [] + except ValueError: + _LOGGER.debug("Client ID metadata document at %s is not valid JSON", url) + return [] + + if not isinstance(document, dict): + _LOGGER.debug("Client ID metadata document at %s is not a JSON object", url) + return [] + + if document.get("client_id") != url: + _LOGGER.debug( + "Client ID metadata document at %s client_id does not match the" + " document URL", + url, + ) + return [] + + # redirect_uris entries are returned unmodified for RFC 6749 exact matching + # rather than resolving relative references. + redirect_uris = document.get("redirect_uris") + if not isinstance(redirect_uris, list) or not all( + isinstance(redirect_uri, str) and _is_valid_metadata_redirect_uri(redirect_uri) + for redirect_uri in redirect_uris + ): + _LOGGER.debug( + "Client ID metadata document at %s has missing or invalid redirect_uris", + url, + ) + return [] + + return redirect_uris + + def verify_client_id(client_id: str) -> bool: """Verify that the client id is valid.""" try: diff --git a/homeassistant/components/auth/login_flow.py b/homeassistant/components/auth/login_flow.py index 60b844a483e8..a19508f3918e 100644 --- a/homeassistant/components/auth/login_flow.py +++ b/homeassistant/components/auth/login_flow.py @@ -137,12 +137,11 @@ class WellKnownOAuthInfoView(HomeAssistantView): "authorization_endpoint": f"{url_prefix}/auth/authorize", "token_endpoint": f"{url_prefix}/auth/token", "revocation_endpoint": f"{url_prefix}/auth/revoke", - # Home Assistant already accepts URL-based client_ids via - # IndieAuth without prior registration, which is compatible with - # draft-ietf-oauth-client-id-metadata-document. This flag - # advertises that support to encourage clients to use it. The - # metadata document is not actually fetched as IndieAuth doesn't - # require it. + # Home Assistant accepts URL-based client_ids via IndieAuth without + # prior registration, and discovers allowed redirect URIs from link + # tags or a Client ID Metadata Document served at the client_id URL. + # This flag advertises that support + # (draft-ietf-oauth-client-id-metadata-document). "client_id_metadata_document_supported": True, "response_types_supported": ["code"], "service_documentation": ( diff --git a/homeassistant/components/bang_olufsen/event.py b/homeassistant/components/bang_olufsen/event.py index 625b742164ad..d7a6fe6456e7 100644 --- a/homeassistant/components/bang_olufsen/event.py +++ b/homeassistant/components/bang_olufsen/event.py @@ -55,9 +55,7 @@ async def async_setup_entry( # As it has to be removed from the device on the app. device_registry = dr.async_get(hass) - devices = device_registry.devices.get_devices_for_config_entry_id( - config_entry.entry_id - ) + devices = dr.async_entries_for_config_entry(device_registry, config_entry.entry_id) for device in devices: if device.model == BeoModel.BEOREMOTE_ONE and device.serial_number not in { remote.serial_number for remote in remotes diff --git a/homeassistant/components/bang_olufsen/websocket.py b/homeassistant/components/bang_olufsen/websocket.py index 0ed29ed916f6..321d1d19e851 100644 --- a/homeassistant/components/bang_olufsen/websocket.py +++ b/homeassistant/components/bang_olufsen/websocket.py @@ -185,8 +185,8 @@ class BeoWebsocket(BeoBase): # Get remote devices connected to the device from Home Assistant device_serial_numbers = [ device.serial_number - for device in device_registry.devices.get_devices_for_config_entry_id( - self.entry.entry_id + for device in dr.async_entries_for_config_entry( + device_registry, self.entry.entry_id ) if device.serial_number is not None and device.model == BeoModel.BEOREMOTE_ONE diff --git a/homeassistant/components/bluetooth/manifest.json b/homeassistant/components/bluetooth/manifest.json index 6b02775717a6..4c85a25b9d27 100644 --- a/homeassistant/components/bluetooth/manifest.json +++ b/homeassistant/components/bluetooth/manifest.json @@ -19,8 +19,8 @@ "bleak-retry-connector==4.6.3", "bluetooth-adapters==2.4.0", "bluetooth-auto-recovery==1.6.4", - "bluetooth-data-tools==1.29.18", + "bluetooth-data-tools==1.29.21", "dbus-fast==5.0.22", - "habluetooth==6.26.5" + "habluetooth==6.26.7" ] } diff --git a/homeassistant/components/braviatv/coordinator.py b/homeassistant/components/braviatv/coordinator.py index d752651132fc..0b0ae819075b 100644 --- a/homeassistant/components/braviatv/coordinator.py +++ b/homeassistant/components/braviatv/coordinator.py @@ -23,6 +23,7 @@ from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryAuthFailed, HomeAssistantError from homeassistant.helpers.debounce import Debouncer from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed +from homeassistant.util import dt as dt_util from .const import ( CONF_NICKNAME, @@ -240,11 +241,13 @@ class BraviaTVCoordinator(DataUpdateCoordinator[None]): self.source = None if start_datetime := playing_info.get("startDateTime"): start_datetime = datetime.fromisoformat(start_datetime) - current_datetime = datetime.now().replace(tzinfo=start_datetime.tzinfo) # pylint: disable=home-assistant-enforce-naive-now - self.media_position = int( - (current_datetime - start_datetime).total_seconds() - ) - self.media_position_updated_at = datetime.now() # pylint: disable=home-assistant-enforce-naive-now + if start_datetime.tzinfo is None: + start_datetime = start_datetime.replace( + tzinfo=dt_util.get_default_time_zone() + ) + now = dt_util.utcnow() + self.media_position = int((now - start_datetime).total_seconds()) + self.media_position_updated_at = now else: self.media_position = None self.media_position_updated_at = None diff --git a/homeassistant/components/broadlink/heartbeat.py b/homeassistant/components/broadlink/heartbeat.py index 388dbc4f2f67..b4ad268731db 100644 --- a/homeassistant/components/broadlink/heartbeat.py +++ b/homeassistant/components/broadlink/heartbeat.py @@ -8,6 +8,7 @@ import broadlink as blk from homeassistant.const import CONF_HOST from homeassistant.core import CALLBACK_TYPE, HomeAssistant from homeassistant.helpers import event +from homeassistant.util import dt as dt_util from .const import DOMAIN @@ -31,7 +32,7 @@ class BroadlinkHeartbeat: async def async_setup(self) -> None: """Set up the heartbeat.""" if self._unsubscribe is None: - await self.async_heartbeat(dt.datetime.now()) # pylint: disable=home-assistant-enforce-naive-now + await self.async_heartbeat(dt_util.utcnow()) self._unsubscribe = event.async_track_time_interval( self._hass, self.async_heartbeat, self.HEARTBEAT_INTERVAL ) diff --git a/homeassistant/components/buienradar/const.py b/homeassistant/components/buienradar/const.py index fd92afd59b0c..c8e460a636ee 100644 --- a/homeassistant/components/buienradar/const.py +++ b/homeassistant/components/buienradar/const.py @@ -14,6 +14,9 @@ CONF_TIMEFRAME = "timeframe" SUPPORTED_COUNTRY_CODES = ["NL", "BE"] DEFAULT_COUNTRY = "NL" +SERVICE_TIME_ZONE = "Europe/Amsterdam" +"""Time zone of the buienradar.nl service, which updates around local midnight.""" + SCHEDULE_OK = 10 """Schedule next call after (minutes).""" SCHEDULE_NOK = 2 diff --git a/homeassistant/components/buienradar/util.py b/homeassistant/components/buienradar/util.py index 7ffa6c744ece..b399a5d82b8a 100644 --- a/homeassistant/components/buienradar/util.py +++ b/homeassistant/components/buienradar/util.py @@ -1,6 +1,6 @@ """Shared utilities for different supported platforms.""" -from datetime import datetime, timedelta +from datetime import timedelta from http import HTTPStatus import logging from typing import Any @@ -34,7 +34,7 @@ from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.event import async_track_point_in_utc_time from homeassistant.util import dt as dt_util -from .const import DEFAULT_TIMEOUT, SCHEDULE_NOK, SCHEDULE_OK +from .const import DEFAULT_TIMEOUT, SCHEDULE_NOK, SCHEDULE_OK, SERVICE_TIME_ZONE __all__ = ["BrData"] _LOGGER = logging.getLogger(__name__) @@ -158,7 +158,12 @@ class BrData: _LOGGER.debug("Buienradar parsed data: %s", result) if result.get(SUCCESS) is not True: - if int(datetime.now().strftime("%H")) > 0: # pylint: disable=home-assistant-enforce-naive-now + # buienradar.nl updates its forecast for the next day between 00:00 + # and 01:00 CE(S)T and often serves nothing during that hour, so the + # warning is only meaningful outside it. The hour that decides this + # is the one at the service, not in the user's configured time zone. + service_tz = await dt_util.async_get_time_zone(SERVICE_TIME_ZONE) + if service_tz is None or dt_util.utcnow().astimezone(service_tz).hour > 0: _LOGGER.warning( "Unable to parse data from Buienradar. (Msg: %s)", result.get(MESSAGE), diff --git a/homeassistant/components/caldav/api.py b/homeassistant/components/caldav/api.py index b64b7fb8e734..0f0b5a60e409 100644 --- a/homeassistant/components/caldav/api.py +++ b/homeassistant/components/caldav/api.py @@ -1,5 +1,4 @@ """Library for working with CalDAV api.""" -# pylint: disable=home-assistant-use-runtime-data # Uses legacy hass.data[DOMAIN] pattern import logging @@ -8,7 +7,7 @@ from caldav.lib.error import DAVError from homeassistant.core import HomeAssistant -from .const import DOMAIN +from .const import WARNED_CALENDARS _LOGGER = logging.getLogger(__name__) @@ -45,9 +44,7 @@ async def async_get_calendars( calendars, needs_warning = await hass.async_add_executor_job(_get_calendars) if needs_warning: - warned_calendars: set[tuple[str, str]] = hass.data.setdefault( - DOMAIN, {} - ).setdefault("warned_calendars", set()) + warned_calendars = hass.data.setdefault(WARNED_CALENDARS, set()) for url, name, comp in needs_warning: # This workaround and warning can be removed when we upgrade to caldav 3.0 if (url, comp) not in warned_calendars: diff --git a/homeassistant/components/caldav/const.py b/homeassistant/components/caldav/const.py index e133bb1b8bc8..5c3b512be451 100644 --- a/homeassistant/components/caldav/const.py +++ b/homeassistant/components/caldav/const.py @@ -2,5 +2,12 @@ from typing import Final +from homeassistant.util.hass_dict import HassKey + DOMAIN: Final = "caldav" TIMEOUT: Final = 30 + +# Calendars we have already warned about, keyed by (url, component). This is +# deliberately not stored on a config entry: the warning is per CalDAV server +# and must survive reloads, and the same server may back more than one entry. +WARNED_CALENDARS: HassKey[set[tuple[str, str]]] = HassKey(f"{DOMAIN}_warned_calendars") diff --git a/homeassistant/components/centriconnect/coordinator.py b/homeassistant/components/centriconnect/coordinator.py index 1733a18fa958..a77a8f6d8184 100644 --- a/homeassistant/components/centriconnect/coordinator.py +++ b/homeassistant/components/centriconnect/coordinator.py @@ -71,7 +71,9 @@ class CentriConnectCoordinator(DataUpdateCoordinator[Tank]): try: tank_data = await self.api_client.async_get_tank_data() except CentriConnectError as err: - raise UpdateFailed("Could not fetch device info") from err + raise UpdateFailed( + translation_domain=DOMAIN, translation_key="entry_setup_failed" + ) from err self.device_info = CentriConnectDeviceInfo( device_id=tank_data.device_id, device_name=tank_data.device_name, @@ -87,7 +89,19 @@ class CentriConnectCoordinator(DataUpdateCoordinator[Tank]): try: state = await self.api_client.async_get_tank_data() except CentriConnectConnectionError as err: - raise UpdateFailed(f"Error communicating with device: {err}") from err + raise UpdateFailed( + translation_domain=DOMAIN, + translation_key="communication_error", + translation_placeholders={ + "error": repr(err), + }, + ) from err except CentriConnectError as err: - raise UpdateFailed(f"Unexpected response: {err}") from err + raise UpdateFailed( + translation_domain=DOMAIN, + translation_key="unexpected_response", + translation_placeholders={ + "error": repr(err), + }, + ) from err return state diff --git a/homeassistant/components/centriconnect/quality_scale.yaml b/homeassistant/components/centriconnect/quality_scale.yaml index 8fed92abc5b9..d0bc918ebcd6 100644 --- a/homeassistant/components/centriconnect/quality_scale.yaml +++ b/homeassistant/components/centriconnect/quality_scale.yaml @@ -68,7 +68,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: diff --git a/homeassistant/components/centriconnect/strings.json b/homeassistant/components/centriconnect/strings.json index fffe7a037a52..4f9c6cc8943e 100644 --- a/homeassistant/components/centriconnect/strings.json +++ b/homeassistant/components/centriconnect/strings.json @@ -65,5 +65,16 @@ "name": "Tank size" } } + }, + "exceptions": { + "communication_error": { + "message": "Error communicating with device: {error}" + }, + "entry_setup_failed": { + "message": "Could not fetch device info" + }, + "unexpected_response": { + "message": "Unexpected response: {error}" + } } } diff --git a/homeassistant/components/cloud/alexa_config.py b/homeassistant/components/cloud/alexa_config.py index 4a019672dc49..feba03668c4c 100644 --- a/homeassistant/components/cloud/alexa_config.py +++ b/homeassistant/components/cloud/alexa_config.py @@ -383,7 +383,7 @@ class CloudAlexaConfig(alexa_config.AbstractConfig): # State reporting is reported as a property on entities. # So when we change it, we need to sync all entities. - await self.async_sync_entities() + await self._async_sync_entities_unless_relink_needed() return # Nothing to do if no Alexa related things have changed @@ -396,7 +396,14 @@ class CloudAlexaConfig(alexa_config.AbstractConfig): ): return - await self.async_sync_entities() + await self._async_sync_entities_unless_relink_needed() + + async def _async_sync_entities_unless_relink_needed(self) -> None: + """Sync entities, tolerating an account with no linked Alexa skill.""" + try: + await self.async_sync_entities() + except alexa_errors.NoTokenAvailable, alexa_errors.RequireRelink: + await self.set_authorized(False) @callback def _async_exposed_entities_updated(self) -> None: diff --git a/homeassistant/components/cloud/http_api.py b/homeassistant/components/cloud/http_api.py index a962c1fb6853..eea633b7b5aa 100644 --- a/homeassistant/components/cloud/http_api.py +++ b/homeassistant/components/cloud/http_api.py @@ -79,6 +79,10 @@ _CLOUD_ERRORS: dict[ HTTPStatus.BAD_GATEWAY, "Unable to reach the Home Assistant Cloud.", ), + auth.AuthTimeoutError: ( + HTTPStatus.GATEWAY_TIMEOUT, + "Authentication timed out.", + ), aiohttp.ClientError: ( HTTPStatus.INTERNAL_SERVER_ERROR, "Error making internal request", diff --git a/homeassistant/components/cloud/manifest.json b/homeassistant/components/cloud/manifest.json index bd5f79524ac4..24a6452041e2 100644 --- a/homeassistant/components/cloud/manifest.json +++ b/homeassistant/components/cloud/manifest.json @@ -13,6 +13,6 @@ "integration_type": "system", "iot_class": "cloud_push", "loggers": ["acme", "hass_nabucasa", "snitun"], - "requirements": ["hass-nabucasa==2.2.0", "openai==2.45.0"], + "requirements": ["hass-nabucasa==2.3.0", "openai==2.45.0"], "single_config_entry": true } diff --git a/homeassistant/components/compensation/manifest.json b/homeassistant/components/compensation/manifest.json index 4de2a39ec325..5b256b28690c 100644 --- a/homeassistant/components/compensation/manifest.json +++ b/homeassistant/components/compensation/manifest.json @@ -5,5 +5,5 @@ "documentation": "https://www.home-assistant.io/integrations/compensation", "iot_class": "calculated", "quality_scale": "legacy", - "requirements": ["numpy==2.3.2"] + "requirements": ["numpy==2.5.2"] } diff --git a/homeassistant/components/config/device_registry.py b/homeassistant/components/config/device_registry.py index 462448c5536a..52e5ac63ac01 100644 --- a/homeassistant/components/config/device_registry.py +++ b/homeassistant/components/config/device_registry.py @@ -65,7 +65,7 @@ def websocket_list_composite_splits( None, ), } - for composite_id, devices in registry.devices.get_composite_splits().items() + for composite_id, devices in registry._devices.get_composite_splits().items() # noqa: SLF001 }, ) @@ -92,8 +92,7 @@ def websocket_list_devices( inner = b",".join( [ entry.json_repr - for container in (registry.devices, registry.child_devices) - for entry in container.values() + for entry in (*registry.devices, *registry.child_devices) if entry.json_repr is not None ] ) @@ -179,8 +178,27 @@ def websocket_update_device( # Convert labels to a set msg["labels"] = set(msg["labels"]) + device_id = msg["device_id"] + + # A composite device id has no single underlying device to update; reject it. + if ( + registry.async_get( + device_id, include_main_devices=False, include_child_devices=False + ) + is not None + ): + connection.send_error( + msg_id, websocket_api.ERR_NOT_ALLOWED, "Cannot update a composite device" + ) + return + if ( + device := registry.async_get(device_id, include_composite_devices=False) + ) is None: + connection.send_error(msg_id, websocket_api.ERR_NOT_FOUND, "Device not found") + return + entry: dr.AnyDeviceEntry | None - if msg["device_id"] in registry.child_devices: + if isinstance(device, dr.ChildDeviceEntry): entry = registry.async_update_child_device(**msg) else: entry = registry.async_update_device(**msg) @@ -207,10 +225,16 @@ async def _async_remove_device( device_id = msg["device_id"] # A composite device id has no single underlying device to remove; reject it. - if registry.async_is_composite_device_id(device_id): + if ( + registry.async_get( + device_id, include_main_devices=False, include_child_devices=False + ) + is not None + ): raise HomeAssistantError("Cannot remove a composite device") - - if (device_entry := registry.async_get(device_id)) is None: + if ( + device_entry := registry.async_get(device_id, include_composite_devices=False) + ) is None: raise HomeAssistantError("Unknown device") if ( diff --git a/homeassistant/components/deconz/logbook.py b/homeassistant/components/deconz/logbook.py index 2ce0f45af98c..77690bf33723 100644 --- a/homeassistant/components/deconz/logbook.py +++ b/homeassistant/components/deconz/logbook.py @@ -137,7 +137,9 @@ def async_describe_events( @callback def async_describe_deconz_alarm_event(event: Event) -> dict[str, str]: """Describe deCONZ logbook alarm event.""" - if device := device_registry.devices.get(event.data[ATTR_DEVICE_ID]): + if device := device_registry.async_get( + event.data[ATTR_DEVICE_ID], include_child_devices=False + ): deconz_alarm_event = _get_deconz_event_from_device(hass, device) name = deconz_alarm_event.device.name else: @@ -153,7 +155,9 @@ def async_describe_events( @callback def async_describe_deconz_event(event: Event) -> dict[str, str]: """Describe deCONZ logbook event.""" - if device := device_registry.devices.get(event.data[ATTR_DEVICE_ID]): + if device := device_registry.async_get( + event.data[ATTR_DEVICE_ID], include_child_devices=False + ): deconz_event = _get_deconz_event_from_device(hass, device) name = deconz_event.device.name else: diff --git a/homeassistant/components/deconz/services.py b/homeassistant/components/deconz/services.py index 2b87d97416d7..51375b82617e 100644 --- a/homeassistant/components/deconz/services.py +++ b/homeassistant/components/deconz/services.py @@ -181,8 +181,8 @@ async def async_remove_orphaned_entries_service(hub: DeconzHub) -> None: entities_to_be_removed = [] devices_to_be_removed = [ entry.id - for entry in device_registry.devices.get_devices_for_config_entry_id( - hub.config_entry.entry_id + for entry in dr.async_entries_for_config_entry( + device_registry, hub.config_entry.entry_id ) ] diff --git a/homeassistant/components/derivative/sensor.py b/homeassistant/components/derivative/sensor.py index eb33dad6cced..3fd227d1156f 100644 --- a/homeassistant/components/derivative/sensor.py +++ b/homeassistant/components/derivative/sensor.py @@ -489,7 +489,8 @@ class DerivativeSensor(RestoreSensor, SensorEntity): old_timestamp: datetime, ) -> None: """Handle the sensor state changes.""" - if not _is_decimal_state(old_value): + recovered_from_invalid = not _is_decimal_state(old_value) + if recovered_from_invalid: if self._last_valid_state_time: old_value = self._last_valid_state_time[0] old_timestamp = self._last_valid_state_time[1] @@ -550,6 +551,12 @@ class DerivativeSensor(RestoreSensor, SensorEntity): "%s: Dropping sample as source total_increasing sensor decreased", self.entity_id, ) + if recovered_from_invalid: + # Reset while recovering from an invalid source: re-baseline + # and report zero so the entity doesn't stay stuck unavailable. + self._state_list = [] + self._last_valid_state_time = (new_state.state, new_timestamp) + self._write_native_value(Decimal(0)) return # add latest derivative to the window list diff --git a/homeassistant/components/device_automation/__init__.py b/homeassistant/components/device_automation/__init__.py index 1931c99c53e8..06af33fdbefe 100644 --- a/homeassistant/components/device_automation/__init__.py +++ b/homeassistant/components/device_automation/__init__.py @@ -240,7 +240,7 @@ async def async_get_device_automations( entity_registry = er.async_get(hass) domain_devices: dict[str, set[str]] = {} device_entities_domains: dict[str, set[str]] = {} - match_device_ids = set(device_ids or device_registry.devices) + match_device_ids = set(device_ids or device_registry._devices) # noqa: SLF001 combined_results: dict[str, list[dict[str, Any]]] = {} for device_id in match_device_ids: diff --git a/homeassistant/components/device_automation/helpers.py b/homeassistant/components/device_automation/helpers.py index f7c5bfc32b5c..6b91465a7e42 100644 --- a/homeassistant/components/device_automation/helpers.py +++ b/homeassistant/components/device_automation/helpers.py @@ -53,7 +53,10 @@ def _resolve_device_id(hass: HomeAssistant, device_id: str, domain: str) -> str: knows the current device id, not the removed composite id. """ device_registry = dr.async_get(hass) - if device_id in device_registry.devices: + if ( + device_registry.async_get(device_id, include_composite_devices=False) + is not None + ): return device_id if not ( split_devices := device_registry.async_get_devices_for_composite_device_id( diff --git a/homeassistant/components/devolo_home_network/device_tracker.py b/homeassistant/components/devolo_home_network/device_tracker.py index 2910dd3067b5..185e01a09432 100644 --- a/homeassistant/components/devolo_home_network/device_tracker.py +++ b/homeassistant/components/devolo_home_network/device_tracker.py @@ -75,6 +75,7 @@ async def async_setup_entry( async_add_entities(missing) restore_entities() + new_device_callback() entry.async_on_unload( coordinators[CONNECTED_WIFI_CLIENTS].async_add_listener(new_device_callback) ) diff --git a/homeassistant/components/diagnostics/__init__.py b/homeassistant/components/diagnostics/__init__.py index 4696ae371c4c..f30f38a9ddad 100644 --- a/homeassistant/components/diagnostics/__init__.py +++ b/homeassistant/components/diagnostics/__init__.py @@ -19,7 +19,6 @@ from homeassistant.helpers import ( integration_platform, issue_registry as ir, ) -from homeassistant.helpers.device_registry import DeviceEntry from homeassistant.helpers.json import ( ExtendedJSONEncoder, find_paths_unserializable_data, @@ -62,7 +61,7 @@ class DiagnosticsPlatformData: ) device_diagnostics: ( Callable[ - [HomeAssistant, ConfigEntry, DeviceEntry], + [HomeAssistant, ConfigEntry, dr.AnyDeviceEntry], Coroutine[Any, Any, Mapping[str, Any]], ] | None @@ -100,9 +99,12 @@ class DiagnosticsProtocol(Protocol): """Return diagnostics for a config entry.""" async def async_get_device_diagnostics( - self, hass: HomeAssistant, config_entry: ConfigEntry, device: DeviceEntry + self, hass: HomeAssistant, config_entry: ConfigEntry, device: dr.AnyDeviceEntry ) -> Mapping[str, Any]: - """Return diagnostics for a device.""" + """Return diagnostics for a device. + + Only integrations that register child devices can receive a child device. + """ @callback @@ -314,10 +316,7 @@ class DownloadDiagnosticsView(http.HomeAssistantView): if info.device_diagnostics is None: return web.Response(status=HTTPStatus.NOT_FOUND) - # A device's diagnostics may be requested for a child device, but the - # callback is currently typed for a main device. Ignoring the mismatch until - # DiagnosticsPlatformData.device_diagnostics is widened to accept AnyDeviceEntry. - data = await info.device_diagnostics(hass, config_entry, device) # type: ignore[arg-type] + data = await info.device_diagnostics(hass, config_entry, device) return await _async_get_json_file_response( hass, data, data_issues, filename, config_entry.domain, d_id, sub_id ) diff --git a/homeassistant/components/ecobee/climate.py b/homeassistant/components/ecobee/climate.py index a6602bce1ccc..ec7bde1e9ef8 100644 --- a/homeassistant/components/ecobee/climate.py +++ b/homeassistant/components/ecobee/climate.py @@ -494,7 +494,7 @@ class Thermostat(ClimateEntity): "id": device.id, "name_by_user": device.name_by_user or device.name, } - for device in device_registry.devices.values() + for device in device_registry.devices for sensor_info in sensors_info if device.name == sensor_info["name"] and any(identifier[0] == DOMAIN for identifier in device.identifiers) @@ -830,7 +830,7 @@ class Thermostat(ClimateEntity): return sorted( [ device.name_by_user or device.name - for device in device_registry.devices.values() + for device in device_registry.devices for sensor_name in sensor_names if device.name == sensor_name and any(identifier[0] == DOMAIN for identifier in device.identifiers) diff --git a/homeassistant/components/ekeybionyx/config_flow.py b/homeassistant/components/ekeybionyx/config_flow.py index b77d4cc4b98e..41dd47eb1bc8 100644 --- a/homeassistant/components/ekeybionyx/config_flow.py +++ b/homeassistant/components/ekeybionyx/config_flow.py @@ -29,6 +29,8 @@ from .const import API_URL, DOMAIN, INTEGRATION_NAME, SCOPE # does not end with space or dot VALID_NAME_PATTERN = re.compile(r"^(?![\d\s])[\w\d \.]*[\w\d]$") +DELETION_POLL_INTERVAL = 5 + class ConfigFlowEkeyApi(ekey_bionyxpy.AbstractAuth): """Authentication implementation used during config flow, without refresh. @@ -276,4 +278,4 @@ class OAuth2FlowHandler( ][0] if self._data["system"].function_webhook_quotas["used"] == 0: break - await asyncio.sleep(5) + await asyncio.sleep(DELETION_POLL_INTERVAL) diff --git a/homeassistant/components/enphase_envoy/diagnostics.py b/homeassistant/components/enphase_envoy/diagnostics.py index 1e0679bea5d9..9dccdd46cf18 100644 --- a/homeassistant/components/enphase_envoy/diagnostics.py +++ b/homeassistant/components/enphase_envoy/diagnostics.py @@ -4,7 +4,7 @@ import copy from datetime import datetime from typing import TYPE_CHECKING, Any -from aiohttp import ClientResponse +from aiohttp import ClientError, ClientResponse from pyenphase.envoy import Envoy from pyenphase.exceptions import EnvoyError @@ -92,6 +92,10 @@ async def _get_fixture_collection(envoy: Envoy, serial: str) -> dict[str, Any]: "code": response.status, } ) + except ClientError as err: + fixture_data[f"{end_point}_log"] = { + "Error": f"Aiohttp Client error {type(err).__name__ if not hasattr(err, 'status') else err.status}" + } except EnvoyError as err: fixture_data[f"{end_point}_log"] = {"Error": repr(err)} return fixture_data diff --git a/homeassistant/components/enphase_envoy/manifest.json b/homeassistant/components/enphase_envoy/manifest.json index 015b358ced71..046a8b033511 100644 --- a/homeassistant/components/enphase_envoy/manifest.json +++ b/homeassistant/components/enphase_envoy/manifest.json @@ -8,7 +8,7 @@ "iot_class": "local_polling", "loggers": ["pyenphase"], "quality_scale": "platinum", - "requirements": ["pyenphase==3.2.1"], + "requirements": ["pyenphase==4.0.0"], "zeroconf": [ { "type": "_enphase-envoy._tcp.local." diff --git a/homeassistant/components/enphase_envoy/sensor.py b/homeassistant/components/enphase_envoy/sensor.py index baf61e471153..676e8352b76e 100644 --- a/homeassistant/components/enphase_envoy/sensor.py +++ b/homeassistant/components/enphase_envoy/sensor.py @@ -21,7 +21,7 @@ from pyenphase import ( EnvoySystemConsumption, EnvoySystemProduction, ) -from pyenphase.const import PHASENAMES +from pyenphase.const import PHASENAMES, SupportedFeatures from pyenphase.models.acb import ACBChargeStatus, ACBSleepState from pyenphase.models.meters import ( CtMeterStatus, @@ -383,7 +383,7 @@ class EnvoyCTSensorEntityDescription(SensorEntityDescription): """Describes an Envoy CT sensor entity.""" value_fn: Callable[ - [EnvoyMeterData], + [EnvoyMeterData | None], int | float | str | CtType | CtMeterStatus | CtStatusFlags | CtState | None, ] on_phase: str | None = None @@ -586,7 +586,9 @@ CT_SENSORS = ( translation_key=(translation_key if translation_key != "" else key), entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, - value_fn=lambda ct: 0 if ct.status_flags is None else len(ct.status_flags), + value_fn=lambda ct: ( + 0 if ct is None or ct.status_flags is None else len(ct.status_flags) + ), cttype=cttype, ) for cttype, key, translation_key in ( @@ -1020,7 +1022,9 @@ async def async_setup_entry( ) -> None: """Set up envoy sensor platform.""" coordinator = config_entry.runtime_data - envoy_data = coordinator.envoy.data + envoy = coordinator.envoy + assert envoy is not None + envoy_data = envoy.data assert envoy_data is not None _LOGGER.debug("Envoy data: %s", envoy_data) @@ -1028,39 +1032,57 @@ async def async_setup_entry( EnvoyProductionEntity(coordinator, description) for description in PRODUCTION_SENSORS ] - if envoy_data.system_consumption: + # add unconditionally if TOTAL_CONSUMPTION is available to overcome + # None value at startup caused by envoy fw issues + if envoy.supported_features & SupportedFeatures.TOTAL_CONSUMPTION: entities.extend( EnvoyConsumptionEntity(coordinator, description) for description in CONSUMPTION_SENSORS ) - if envoy_data.system_net_consumption: + # add unconditionally if NET_CONSUMPTION is available to overcome + # None value at startup caused by envoy fw issues + if envoy.supported_features & SupportedFeatures.NET_CONSUMPTION: entities.extend( EnvoyNetConsumptionEntity(coordinator, description) for description in NET_CONSUMPTION_SENSORS ) # For each production phase reported add production entities - if envoy_data.system_production_phases: + # if PRODUCTION is available and phases detected even if None + # to overcome None value at startup caused by envoy fw issues + if envoy.active_phase_count and ( + envoy.supported_features & SupportedFeatures.PRODUCTION + ): entities.extend( EnvoyProductionPhaseEntity(coordinator, description) - for use_phase, phase in envoy_data.system_production_phases.items() + for index, use_phase in enumerate(PHASENAMES) for description in PRODUCTION_PHASE_SENSORS[use_phase] - if phase is not None + if index < (envoy.phase_count if envoy.phase_count > 1 else 0) ) # For each consumption phase reported add consumption entities - if envoy_data.system_consumption_phases: + # if TOTAL_CONSUMPTION is available and phases detected even if None + # to overcome None value at startup caused by envoy fw issues + if ( + envoy.active_phase_count + and envoy.phase_count > 1 + and (envoy.supported_features & SupportedFeatures.TOTAL_CONSUMPTION) + ): entities.extend( EnvoyConsumptionPhaseEntity(coordinator, description) - for use_phase, phase in envoy_data.system_consumption_phases.items() + for index, use_phase in enumerate(PHASENAMES) for description in CONSUMPTION_PHASE_SENSORS[use_phase] - if phase is not None + if index < (envoy.phase_count if envoy.phase_count > 1 else 0) ) # For each net_consumption phase reported add consumption entities - if envoy_data.system_net_consumption_phases: + # if NET_CONSUMPTION is available and phases detected even if None + # to overcome None value at startup caused by envoy fw issues + if envoy.active_phase_count and ( + envoy.supported_features & SupportedFeatures.NET_CONSUMPTION + ): entities.extend( EnvoyNetConsumptionPhaseEntity(coordinator, description) - for use_phase, phase in envoy_data.system_net_consumption_phases.items() + for index, use_phase in enumerate(PHASENAMES) for description in NET_CONSUMPTION_PHASE_SENSORS[use_phase] - if phase is not None + if index < (envoy.phase_count if envoy.phase_count > 1 else 0) ) # Add Current Transformer entities if envoy_data.ctmeters: @@ -1181,8 +1203,8 @@ class EnvoyProductionEntity(EnvoySystemSensorEntity): @override def native_value(self) -> int | None: """Return the state of the sensor.""" - system_production = self.data.system_production - assert system_production is not None + if (system_production := self.data.system_production) is None: + return None return self.entity_description.value_fn(system_production) @@ -1195,8 +1217,8 @@ class EnvoyConsumptionEntity(EnvoySystemSensorEntity): @override def native_value(self) -> int | None: """Return the state of the sensor.""" - system_consumption = self.data.system_consumption - assert system_consumption is not None + if (system_consumption := self.data.system_consumption) is None: + return None return self.entity_description.value_fn(system_consumption) @@ -1209,8 +1231,8 @@ class EnvoyNetConsumptionEntity(EnvoySystemSensorEntity): @override def native_value(self) -> int | None: """Return the state of the sensor.""" - system_net_consumption = self.data.system_net_consumption - assert system_net_consumption is not None + if (system_net_consumption := self.data.system_net_consumption) is None: + return None return self.entity_description.value_fn(system_net_consumption) @@ -1225,8 +1247,11 @@ class EnvoyProductionPhaseEntity(EnvoySystemSensorEntity): """Return the state of the sensor.""" if TYPE_CHECKING: assert self.entity_description.on_phase - assert self.data.system_production_phases + if self.data.system_production_phases is None: + return None + if self.entity_description.on_phase not in self.data.system_production_phases: + return None if ( system_production := self.data.system_production_phases[ self.entity_description.on_phase @@ -1247,8 +1272,11 @@ class EnvoyConsumptionPhaseEntity(EnvoySystemSensorEntity): """Return the state of the sensor.""" if TYPE_CHECKING: assert self.entity_description.on_phase - assert self.data.system_consumption_phases + if self.data.system_consumption_phases is None: + return None + if self.entity_description.on_phase not in self.data.system_consumption_phases: + return None if ( system_consumption := self.data.system_consumption_phases[ self.entity_description.on_phase @@ -1269,8 +1297,14 @@ class EnvoyNetConsumptionPhaseEntity(EnvoySystemSensorEntity): """Return the state of the sensor.""" if TYPE_CHECKING: assert self.entity_description.on_phase - assert self.data.system_net_consumption_phases + if self.data.system_net_consumption_phases is None: + return None + if ( + self.entity_description.on_phase + not in self.data.system_net_consumption_phases + ): + return None if ( system_net_consumption := self.data.system_net_consumption_phases[ self.entity_description.on_phase @@ -1293,6 +1327,8 @@ class EnvoyCTEntity(EnvoySystemSensorEntity): """Return the state of the CT sensor.""" if (cttype := self.entity_description.cttype) not in self.data.ctmeters: return None + if self.data.ctmeters[cttype] is None: + return None return self.entity_description.value_fn(self.data.ctmeters[cttype]) @@ -1315,6 +1351,8 @@ class EnvoyCTPhaseEntity(EnvoySystemSensorEntity): cttype ]: return None + if self.data.ctmeters_phases[cttype][phase] is None: + return None return self.entity_description.value_fn( self.data.ctmeters_phases[cttype][phase] ) diff --git a/homeassistant/components/esphome/entry_data.py b/homeassistant/components/esphome/entry_data.py index 00a56ca6dc05..97cdc0353dfb 100644 --- a/homeassistant/components/esphome/entry_data.py +++ b/homeassistant/components/esphome/entry_data.py @@ -522,7 +522,9 @@ class RuntimeEntryData: """ self.available = False if self.bluetooth_device: - self.bluetooth_device.available = False + # Fails pending BLE slot waiters and clears the dead + # session's allocations in addition to closing the gate. + self.bluetooth_device.async_set_unavailable() # Make a copy since calling the disconnect callbacks # may also try to discard/remove themselves. for disconnect_cb in self.disconnect_callbacks.copy(): diff --git a/homeassistant/components/esphome/manifest.json b/homeassistant/components/esphome/manifest.json index e86f6ce3f1be..5a509cff34d4 100644 --- a/homeassistant/components/esphome/manifest.json +++ b/homeassistant/components/esphome/manifest.json @@ -17,9 +17,9 @@ "mqtt": ["esphome/discover/#"], "quality_scale": "platinum", "requirements": [ - "aioesphomeapi==45.6.1", + "aioesphomeapi==45.12.0", "esphome-dashboard-api==1.4.0", - "bleak-esphome==3.9.7" + "bleak-esphome==4.0.0" ], "zeroconf": ["_esphomelib._tcp.local."] } diff --git a/homeassistant/components/fyta/coordinator.py b/homeassistant/components/fyta/coordinator.py index 71cff99c6adf..bb11e994112e 100644 --- a/homeassistant/components/fyta/coordinator.py +++ b/homeassistant/components/fyta/coordinator.py @@ -1,7 +1,7 @@ """Coordinator for FYTA integration.""" from collections.abc import Callable -from datetime import datetime, timedelta +from datetime import timedelta import logging from typing import override @@ -20,6 +20,7 @@ from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady from homeassistant.helpers import device_registry as dr from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed +from homeassistant.util import dt as dt_util from .const import CONF_EXPIRATION, DOMAIN @@ -54,10 +55,7 @@ class FytaCoordinator(DataUpdateCoordinator[dict[int, Plant]]): ) -> dict[int, Plant]: """Fetch data from API endpoint.""" - if ( - self.fyta.expiration is None - or self.fyta.expiration.timestamp() < datetime.now().timestamp() # pylint: disable=home-assistant-enforce-naive-now - ): + if self.fyta.expiration is None or self.fyta.expiration < dt_util.now(): await self.renew_authentication() try: diff --git a/homeassistant/components/fyta/image.py b/homeassistant/components/fyta/image.py index e1fb27e2ef13..f0ed90b3ff92 100644 --- a/homeassistant/components/fyta/image.py +++ b/homeassistant/components/fyta/image.py @@ -2,7 +2,6 @@ from collections.abc import Callable from dataclasses import dataclass -from datetime import datetime import logging from typing import Final, override @@ -17,6 +16,7 @@ from homeassistant.components.image import ( from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback +from homeassistant.util import dt as dt_util from .coordinator import FytaConfigEntry, FytaCoordinator from .entity import FytaPlantEntity @@ -119,5 +119,5 @@ class FytaPlantImageEntity(FytaPlantEntity, ImageEntity): if url != self._attr_image_url: self._cached_image = None - self._attr_image_last_updated = datetime.now() # pylint: disable=home-assistant-enforce-naive-now + self._attr_image_last_updated = dt_util.utcnow() return url diff --git a/homeassistant/components/gardena_bluetooth/sensor.py b/homeassistant/components/gardena_bluetooth/sensor.py index 6916e4b69147..4281c81b9637 100644 --- a/homeassistant/components/gardena_bluetooth/sensor.py +++ b/homeassistant/components/gardena_bluetooth/sensor.py @@ -214,11 +214,41 @@ DESCRIPTIONS = ( char=EventHistory.error, get=lambda x: ( x.error_code.name.lower() - if x and isinstance(x.error_code, EventHistory.error.enum) + if x is not None and isinstance(x.error_code, EventHistory.error.enum) else None ), options=[member.name.lower() for member in EventHistory.error.enum], ), + GardenaBluetoothSensorEntityDescription( + key="aqua_contour_activation_reason", + translation_key="activation_reason", + entity_category=EntityCategory.DIAGNOSTIC, + device_class=SensorDeviceClass.ENUM, + char=AquaContourWatering.activation_reason, + get=lambda x: ( + x.name.lower() + if isinstance(x, AquaContourWatering.activation_reason.enum) + else None + ), + options=[ + member.name.lower() for member in AquaContourWatering.activation_reason.enum + ], + ), + GardenaBluetoothSensorEntityDescription( + key="aqua_contour_skipped_reason", + translation_key="skipped_reason", + entity_category=EntityCategory.DIAGNOSTIC, + device_class=SensorDeviceClass.ENUM, + char=AquaContourWatering.skipped_reason, + get=lambda x: ( + x.name.lower() + if isinstance(x, AquaContourWatering.skipped_reason.enum) + else None + ), + options=[ + member.name.lower() for member in AquaContourWatering.skipped_reason.enum + ], + ), GardenaBluetoothSensorEntityDescription( key="aqua_contour_error_timestamp", translation_key="error_timestamp", diff --git a/homeassistant/components/gardena_bluetooth/strings.json b/homeassistant/components/gardena_bluetooth/strings.json index ed52daef4cc1..62202a8258bc 100644 --- a/homeassistant/components/gardena_bluetooth/strings.json +++ b/homeassistant/components/gardena_bluetooth/strings.json @@ -162,6 +162,30 @@ "sensor_type": { "name": "Sensor type" }, + "skipped_reason": { + "name": "Skipped reason", + "state": { + "battery_empty": "Battery empty", + "charging_cable_plugged": "Charging cable plugged", + "contour_data_invalid": "Contour data invalid", + "contour_not_active": "Contour not active", + "contour_not_enabled_for_position": "Contour not enabled for position", + "humidity_sensor": "Humidity sensor", + "irrigation_control_changed": "Irrigation control changed", + "manual_mode": "Manual mode", + "no_water": "No water", + "none": "Inactive", + "operational_mode_changed": "Operational mode changed", + "other_schedule_with_same_start_time": "Other schedule with same start time", + "position_changed": "Position changed", + "rain_pause": "Rain pause", + "rain_sensor": "Rain sensor", + "rotation_sensor_error": "Rotation sensor error", + "sprinkler_motor_error": "Sprinkler motor error", + "valve_motor_error": "Valve motor error", + "watering_already_active": "Watering already active" + } + }, "spray_current_distance": { "name": "Current distance" }, diff --git a/homeassistant/components/geofency/device_tracker.py b/homeassistant/components/geofency/device_tracker.py index 8d7c3b24cc42..7cc32c843103 100644 --- a/homeassistant/components/geofency/device_tracker.py +++ b/homeassistant/components/geofency/device_tracker.py @@ -42,9 +42,7 @@ async def async_setup_entry( dev_reg = dr.async_get(hass) dev_ids = { identifier[1] - for device in dev_reg.devices.get_devices_for_config_entry_id( - config_entry.entry_id - ) + for device in dr.async_entries_for_config_entry(dev_reg, config_entry.entry_id) for identifier in device.identifiers } diff --git a/homeassistant/components/go2rtc/__init__.py b/homeassistant/components/go2rtc/__init__.py index 3c736aa03f1d..c15fab8f2de2 100644 --- a/homeassistant/components/go2rtc/__init__.py +++ b/homeassistant/components/go2rtc/__init__.py @@ -261,6 +261,14 @@ async def _get_binary(hass: HomeAssistant) -> str | None: return await hass.async_add_executor_job(shutil.which, "go2rtc") +@dataclass(frozen=True) +class _SessionInfo: + """Session info.""" + + ws_client: Go2RtcWsClient + camera: Camera + + class WebRTCProvider(CameraWebRTCProvider): """WebRTC provider.""" @@ -276,7 +284,7 @@ class WebRTCProvider(CameraWebRTCProvider): self._url = url self._session = session self._rest_client = rest_client - self._sessions: dict[str, Go2RtcWsClient] = {} + self._sessions: dict[str, _SessionInfo] = {} self._supported_schemes: set[str] = set() @property @@ -310,9 +318,13 @@ class WebRTCProvider(CameraWebRTCProvider): send_message(WebRTCError("go2rtc_webrtc_offer_failed", str(err))) return - self._sessions[session_id] = ws_client = Go2RtcWsClient( + ws_client = Go2RtcWsClient( self._session, self._url, source=get_camera_identifier(camera) ) + self._sessions[session_id] = _SessionInfo( + ws_client=ws_client, + camera=camera, + ) @callback def on_messages(message: ReceiveMessages) -> None: @@ -338,8 +350,8 @@ class WebRTCProvider(CameraWebRTCProvider): ) -> None: """Handle the WebRTC candidate.""" - if ws_client := self._sessions.get(session_id): - await ws_client.send(WebRTCCandidate(candidate.candidate)) + if session_info := self._sessions.get(session_id): + await session_info.ws_client.send(WebRTCCandidate(candidate.candidate)) else: _LOGGER.debug("Unknown session %s. Ignoring candidate", session_id) @@ -347,8 +359,8 @@ class WebRTCProvider(CameraWebRTCProvider): @override def async_close_session(self, session_id: str) -> None: """Close the session.""" - ws_client = self._sessions.pop(session_id) - self._hass.async_create_task(ws_client.close()) + if session_info := self._sessions.pop(session_id, None): + self._hass.async_create_task(session_info.ws_client.close()) @override async def async_get_image( @@ -366,7 +378,7 @@ class WebRTCProvider(CameraWebRTCProvider): async def _update_stream_source(self, camera: Camera) -> None: """Update the stream source in go2rtc config if needed.""" if not (stream_source := await camera.stream_source()): - await self.teardown() + await self._close_camera_sessions(camera) raise HomeAssistantError("Camera has no stream source") if camera.platform.platform_name == "generic": @@ -376,7 +388,7 @@ class WebRTCProvider(CameraWebRTCProvider): stream_source = "ffmpeg:" + stream_source if not self.async_is_supported(stream_source): - await self.teardown() + await self._close_camera_sessions(camera) raise HomeAssistantError("Stream source is not supported by go2rtc") camera_prefs = await get_dynamic_camera_stream_settings( @@ -440,11 +452,20 @@ class WebRTCProvider(CameraWebRTCProvider): else: await self._rest_client.preload.disable(identifier) + async def _close_camera_sessions(self, camera: Camera) -> None: + for session_id in list(self._sessions): + session_info = self._sessions.get(session_id) + if session_info is None or session_info.camera != camera: + continue + # Unregister before closing, as closing yields to the event loop + del self._sessions[session_id] + await session_info.ws_client.close() + async def teardown(self) -> None: """Tear down the provider.""" - for ws_client in self._sessions.values(): - await ws_client.close() - self._sessions.clear() + while self._sessions: + _, session_info = self._sessions.popitem() + await session_info.ws_client.close() @override async def async_register_camera( @@ -460,6 +481,7 @@ class WebRTCProvider(CameraWebRTCProvider): camera: Camera, ) -> None: """Will be called when the provider is unregistered for a camera.""" + await self._close_camera_sessions(camera) identifier = get_camera_identifier(camera) if identifier in await self._rest_client.preload.list(): await self._rest_client.preload.disable(identifier) diff --git a/homeassistant/components/gpslogger/device_tracker.py b/homeassistant/components/gpslogger/device_tracker.py index 32e591e099cd..d09d4dde7ef8 100644 --- a/homeassistant/components/gpslogger/device_tracker.py +++ b/homeassistant/components/gpslogger/device_tracker.py @@ -48,7 +48,7 @@ async def async_setup_entry( dev_reg = dr.async_get(hass) dev_ids = { identifier[1] - for device in dev_reg.devices.get_devices_for_config_entry_id(entry.entry_id) + for device in dr.async_entries_for_config_entry(dev_reg, entry.entry_id) for identifier in device.identifiers } if not dev_ids: diff --git a/homeassistant/components/group/__init__.py b/homeassistant/components/group/__init__.py index 2fafaa192806..7cb68028d643 100644 --- a/homeassistant/components/group/__init__.py +++ b/homeassistant/components/group/__init__.py @@ -265,6 +265,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: mode=service.data.get(ATTR_ALL), object_id=object_id, order=None, + context=service.context, ) return @@ -272,6 +273,8 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: _LOGGER.warning("%s:Group '%s' doesn't exist!", service.service, object_id) return + group.async_set_context(service.context) + # update group if service.service == SERVICE_SET: need_update = False diff --git a/homeassistant/components/group/cover.py b/homeassistant/components/group/cover.py index 67e32f6f2e77..3800589fbfe3 100644 --- a/homeassistant/components/group/cover.py +++ b/homeassistant/components/group/cover.py @@ -5,20 +5,18 @@ from typing import Any, override import voluptuous as vol from homeassistant.components.cover import ( - ATTR_CURRENT_POSITION, - ATTR_CURRENT_TILT_POSITION, ATTR_POSITION, ATTR_TILT_POSITION, DOMAIN as COVER_DOMAIN, PLATFORM_SCHEMA as COVER_PLATFORM_SCHEMA, CoverEntity, CoverEntityFeature, + CoverEntityStateAttribute, CoverState, ) from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( ATTR_ENTITY_ID, - ATTR_SUPPORTED_FEATURES, CONF_ENTITIES, CONF_NAME, CONF_UNIQUE_ID, @@ -32,6 +30,7 @@ from homeassistant.const import ( SERVICE_STOP_COVER_TILT, STATE_UNAVAILABLE, STATE_UNKNOWN, + EntityStateAttribute, ) from homeassistant.core import HomeAssistant, State, callback from homeassistant.helpers import config_validation as cv, entity_registry as er @@ -148,7 +147,7 @@ class CoverGroup(GroupEntity, CoverEntity): values.discard(entity_id) return - features = new_state.attributes.get(ATTR_SUPPORTED_FEATURES, 0) + features = new_state.attributes.get(EntityStateAttribute.SUPPORTED_FEATURES, 0) if features & (CoverEntityFeature.OPEN | CoverEntityFeature.CLOSE): self._covers[KEY_OPEN_CLOSE].add(entity_id) @@ -313,14 +312,14 @@ class CoverGroup(GroupEntity, CoverEntity): all_position_states = [self.hass.states.get(x) for x in position_covers] position_states: list[State] = list(filter(None, all_position_states)) self._attr_current_cover_position = reduce_attribute( - position_states, ATTR_CURRENT_POSITION + position_states, CoverEntityStateAttribute.CURRENT_POSITION ) tilt_covers = self._tilts[KEY_POSITION] all_tilt_states = [self.hass.states.get(x) for x in tilt_covers] tilt_states: list[State] = list(filter(None, all_tilt_states)) self._attr_current_cover_tilt_position = reduce_attribute( - tilt_states, ATTR_CURRENT_TILT_POSITION + tilt_states, CoverEntityStateAttribute.CURRENT_TILT_POSITION ) supported_features = CoverEntityFeature(0) diff --git a/homeassistant/components/group/entity.py b/homeassistant/components/group/entity.py index 83874d1f143f..f08438894a8a 100644 --- a/homeassistant/components/group/entity.py +++ b/homeassistant/components/group/entity.py @@ -6,14 +6,15 @@ import logging from typing import Any, override from homeassistant.const import ( - ATTR_ASSUMED_STATE, ATTR_ENTITY_ID, - ATTR_GROUP_ENTITIES, STATE_OFF, STATE_ON, + EntityCapabilityAttribute, + EntityStateAttribute, ) from homeassistant.core import ( CALLBACK_TYPE, + Context, Event, EventStateChangedData, HomeAssistant, @@ -39,7 +40,9 @@ _LOGGER = logging.getLogger(__name__) class GroupEntity(Entity): """Representation of a Group of entities.""" - _unrecorded_attributes = frozenset({ATTR_ENTITY_ID, ATTR_GROUP_ENTITIES}) + _unrecorded_attributes = frozenset( + {ATTR_ENTITY_ID, EntityCapabilityAttribute.GROUP_ENTITIES} + ) _attr_should_poll = False _entity_ids: list[str] @@ -127,7 +130,7 @@ class GroupEntity(Entity): for entity_id in self._entity_ids: if (state := self.hass.states.get(entity_id)) is None: continue - if state.attributes.get(ATTR_ASSUMED_STATE): + if state.attributes.get(EntityStateAttribute.ASSUMED_STATE): self._attr_assumed_state = True return @@ -231,6 +234,7 @@ class Group(Entity): mode: bool | None, object_id: str | None, order: int | None, + context: Context | None, ) -> Group: """Initialize a group. @@ -247,6 +251,9 @@ class Group(Entity): order=order, ) + if context is not None: + group.async_set_context(context) + # If called before the platform async_setup is called (test cases) await async_get_component(hass).async_add_entities([group]) return group @@ -430,7 +437,9 @@ class Group(Entity): domain = new_state.domain state = new_state.state registry = self._registry - self._assumed[entity_id] = bool(new_state.attributes.get(ATTR_ASSUMED_STATE)) + self._assumed[entity_id] = bool( + new_state.attributes.get(EntityStateAttribute.ASSUMED_STATE) + ) if domain not in registry.on_states_by_domain: # Handle the group of a group case @@ -462,11 +471,12 @@ class Group(Entity): return if tr_state is None or ( - self._assumed_state and not tr_state.attributes.get(ATTR_ASSUMED_STATE) + self._assumed_state + and not tr_state.attributes.get(EntityStateAttribute.ASSUMED_STATE) ): self._assumed_state = self.mode(self._assumed.values()) - elif tr_state.attributes.get(ATTR_ASSUMED_STATE): + elif tr_state.attributes.get(EntityStateAttribute.ASSUMED_STATE): self._assumed_state = True num_on_states = len(self._on_states) diff --git a/homeassistant/components/group/event.py b/homeassistant/components/group/event.py index 668715ba5e86..2303242dab4d 100644 --- a/homeassistant/components/group/event.py +++ b/homeassistant/components/group/event.py @@ -6,22 +6,21 @@ from typing import Any, override import voluptuous as vol from homeassistant.components.event import ( - ATTR_EVENT_TYPE, - ATTR_EVENT_TYPES, DOMAIN as EVENT_DOMAIN, PLATFORM_SCHEMA as EVENT_PLATFORM_SCHEMA, EventEntity, + EventEntityCapabilityAttribute, + EventEntityStateAttribute, ) from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( - ATTR_DEVICE_CLASS, ATTR_ENTITY_ID, - ATTR_FRIENDLY_NAME, CONF_ENTITIES, CONF_NAME, CONF_UNIQUE_ID, STATE_UNAVAILABLE, STATE_UNKNOWN, + EntityStateAttribute, ) from homeassistant.core import Event, EventStateChangedData, HomeAssistant, callback from homeassistant.helpers import config_validation as cv, entity_registry as er @@ -142,16 +141,20 @@ class EventGroup(GroupEntity, EventEntity): and old_state.state not in (STATE_UNAVAILABLE, STATE_UNKNOWN) and (new_state := event.data["new_state"]) and new_state.state not in (STATE_UNAVAILABLE, STATE_UNKNOWN) - and (event_type := new_state.attributes.get(ATTR_EVENT_TYPE)) + and ( + event_type := new_state.attributes.get( + EventEntityStateAttribute.EVENT_TYPE + ) + ) ): event_attributes = new_state.attributes.copy() # We should not propagate the event properties as # fired event attributes. - del event_attributes[ATTR_EVENT_TYPE] - del event_attributes[ATTR_EVENT_TYPES] - event_attributes.pop(ATTR_DEVICE_CLASS, None) - event_attributes.pop(ATTR_FRIENDLY_NAME, None) + del event_attributes[EventEntityStateAttribute.EVENT_TYPE] + del event_attributes[EventEntityCapabilityAttribute.EVENT_TYPES] + event_attributes.pop(EntityStateAttribute.DEVICE_CLASS, None) + event_attributes.pop(EntityStateAttribute.FRIENDLY_NAME, None) # Fire the group event self._trigger_event(event_type, event_attributes) @@ -185,7 +188,8 @@ class EventGroup(GroupEntity, EventEntity): self._attr_event_types = list( set( itertools.chain.from_iterable( - state.attributes.get(ATTR_EVENT_TYPES, []) for state in states + state.attributes.get(EventEntityCapabilityAttribute.EVENT_TYPES, []) + for state in states ) ) ) diff --git a/homeassistant/components/group/fan.py b/homeassistant/components/group/fan.py index 7c3d64879a0f..14f1ad6ac347 100644 --- a/homeassistant/components/group/fan.py +++ b/homeassistant/components/group/fan.py @@ -11,7 +11,6 @@ from homeassistant.components.fan import ( ATTR_DIRECTION, ATTR_OSCILLATING, ATTR_PERCENTAGE, - ATTR_PERCENTAGE_STEP, DOMAIN as FAN_DOMAIN, PLATFORM_SCHEMA as FAN_PLATFORM_SCHEMA, SERVICE_OSCILLATE, @@ -21,17 +20,18 @@ from homeassistant.components.fan import ( SERVICE_TURN_ON, FanEntity, FanEntityFeature, + FanEntityStateAttribute, ) from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( ATTR_ENTITY_ID, - ATTR_SUPPORTED_FEATURES, CONF_ENTITIES, CONF_NAME, CONF_UNIQUE_ID, STATE_ON, STATE_UNAVAILABLE, STATE_UNKNOWN, + EntityStateAttribute, ) from homeassistant.core import HomeAssistant, State, callback from homeassistant.helpers import config_validation as cv, entity_registry as er @@ -166,7 +166,9 @@ class FanGroup(GroupEntity, FanEntity): for values in self._fans.values(): values.discard(entity_id) else: - features = new_state.attributes.get(ATTR_SUPPORTED_FEATURES, 0) + features = new_state.attributes.get( + EntityStateAttribute.SUPPORTED_FEATURES, 0 + ) for feature in SUPPORTED_FLAGS: if features & feature: self._fans[feature].add(entity_id) @@ -286,14 +288,25 @@ class FanGroup(GroupEntity, FanEntity): percentage_states = self._async_states_by_support_flag( FanEntityFeature.SET_SPEED ) - self._percentage = reduce_attribute(percentage_states, ATTR_PERCENTAGE) + self._percentage = reduce_attribute( + percentage_states, FanEntityStateAttribute.PERCENTAGE + ) if ( percentage_states - and percentage_states[0].attributes.get(ATTR_PERCENTAGE_STEP) - and attribute_equal(percentage_states, ATTR_PERCENTAGE_STEP) + and percentage_states[0].attributes.get( + FanEntityStateAttribute.PERCENTAGE_STEP + ) + and attribute_equal( + percentage_states, FanEntityStateAttribute.PERCENTAGE_STEP + ) ): self._speed_count = ( - round(100 / percentage_states[0].attributes[ATTR_PERCENTAGE_STEP]) + round( + 100 + / percentage_states[0].attributes[ + FanEntityStateAttribute.PERCENTAGE_STEP + ] + ) or 100 ) else: diff --git a/homeassistant/components/group/light.py b/homeassistant/components/group/light.py index 68f922272b4c..2877d60d8a09 100644 --- a/homeassistant/components/group/light.py +++ b/homeassistant/components/group/light.py @@ -10,31 +10,27 @@ import voluptuous as vol from homeassistant.components import light from homeassistant.components.light import ( ATTR_BRIGHTNESS, - ATTR_COLOR_MODE, ATTR_COLOR_TEMP_KELVIN, ATTR_EFFECT, - ATTR_EFFECT_LIST, ATTR_FLASH, ATTR_HS_COLOR, - ATTR_MAX_COLOR_TEMP_KELVIN, - ATTR_MIN_COLOR_TEMP_KELVIN, ATTR_RGB_COLOR, ATTR_RGBW_COLOR, ATTR_RGBWW_COLOR, - ATTR_SUPPORTED_COLOR_MODES, ATTR_TRANSITION, ATTR_WHITE, ATTR_XY_COLOR, PLATFORM_SCHEMA as LIGHT_PLATFORM_SCHEMA, ColorMode, LightEntity, + LightEntityCapabilityAttribute, LightEntityFeature, + LightEntityStateAttribute, filter_supported_color_modes, ) from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( ATTR_ENTITY_ID, - ATTR_SUPPORTED_FEATURES, CONF_ENTITIES, CONF_NAME, CONF_UNIQUE_ID, @@ -43,6 +39,7 @@ from homeassistant.const import ( STATE_ON, STATE_UNAVAILABLE, STATE_UNKNOWN, + EntityStateAttribute, ) from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import config_validation as cv, entity_registry as er @@ -227,36 +224,46 @@ class LightGroup(GroupEntity, LightEntity): self._attr_is_on = self.mode(state.state == STATE_ON for state in states) self._attr_available = any(state.state != STATE_UNAVAILABLE for state in states) - self._attr_brightness = reduce_attribute(on_states, ATTR_BRIGHTNESS) + self._attr_brightness = reduce_attribute( + on_states, LightEntityStateAttribute.BRIGHTNESS + ) self._attr_hs_color = reduce_attribute( - on_states, ATTR_HS_COLOR, reduce=mean_circle + on_states, LightEntityStateAttribute.HS_COLOR, reduce=mean_circle ) self._attr_rgb_color = reduce_attribute( - on_states, ATTR_RGB_COLOR, reduce=mean_tuple + on_states, LightEntityStateAttribute.RGB_COLOR, reduce=mean_tuple ) self._attr_rgbw_color = reduce_attribute( - on_states, ATTR_RGBW_COLOR, reduce=mean_tuple + on_states, LightEntityStateAttribute.RGBW_COLOR, reduce=mean_tuple ) self._attr_rgbww_color = reduce_attribute( - on_states, ATTR_RGBWW_COLOR, reduce=mean_tuple + on_states, LightEntityStateAttribute.RGBWW_COLOR, reduce=mean_tuple ) self._attr_xy_color = reduce_attribute( - on_states, ATTR_XY_COLOR, reduce=mean_tuple + on_states, LightEntityStateAttribute.XY_COLOR, reduce=mean_tuple ) self._attr_color_temp_kelvin = reduce_attribute( - on_states, ATTR_COLOR_TEMP_KELVIN + on_states, LightEntityStateAttribute.COLOR_TEMP_KELVIN ) self._attr_min_color_temp_kelvin = reduce_attribute( - states, ATTR_MIN_COLOR_TEMP_KELVIN, default=2000, reduce=min + states, + LightEntityCapabilityAttribute.MIN_COLOR_TEMP_KELVIN, + default=2000, + reduce=min, ) self._attr_max_color_temp_kelvin = reduce_attribute( - states, ATTR_MAX_COLOR_TEMP_KELVIN, default=6500, reduce=max + states, + LightEntityCapabilityAttribute.MAX_COLOR_TEMP_KELVIN, + default=6500, + reduce=max, ) self._attr_effect_list = None - all_effect_lists = list(find_state_attributes(states, ATTR_EFFECT_LIST)) + all_effect_lists = list( + find_state_attributes(states, LightEntityCapabilityAttribute.EFFECT_LIST) + ) if all_effect_lists: # Merge all effects from all effect_lists with a union merge. self._attr_effect_list = list(set().union(*all_effect_lists)) @@ -266,7 +273,9 @@ class LightGroup(GroupEntity, LightEntity): self._attr_effect_list.insert(0, "None") self._attr_effect = None - all_effects = list(find_state_attributes(on_states, ATTR_EFFECT)) + all_effects = list( + find_state_attributes(on_states, LightEntityStateAttribute.EFFECT) + ) if all_effects: # Report the most common effect. effects_count = Counter(itertools.chain(all_effects)) @@ -274,7 +283,9 @@ class LightGroup(GroupEntity, LightEntity): supported_color_modes = {ColorMode.ONOFF} all_supported_color_modes = list( - find_state_attributes(states, ATTR_SUPPORTED_COLOR_MODES) + find_state_attributes( + states, LightEntityCapabilityAttribute.SUPPORTED_COLOR_MODES + ) ) if all_supported_color_modes: # Merge all color modes. @@ -284,7 +295,9 @@ class LightGroup(GroupEntity, LightEntity): self._attr_supported_color_modes = supported_color_modes self._attr_color_mode = ColorMode.UNKNOWN - all_color_modes = list(find_state_attributes(on_states, ATTR_COLOR_MODE)) + all_color_modes = list( + find_state_attributes(on_states, LightEntityStateAttribute.COLOR_MODE) + ) if all_color_modes: # Report the most common color mode, select brightness and onoff last color_mode_count = Counter(itertools.chain(all_color_modes)) @@ -304,7 +317,9 @@ class LightGroup(GroupEntity, LightEntity): self._attr_color_mode = next(iter(supported_color_modes)) self._attr_supported_features = LightEntityFeature(0) - for support in find_state_attributes(states, ATTR_SUPPORTED_FEATURES): + for support in find_state_attributes( + states, EntityStateAttribute.SUPPORTED_FEATURES + ): # Merge supported features by emulating support for every feature # we find. self._attr_supported_features |= support diff --git a/homeassistant/components/group/media_player.py b/homeassistant/components/group/media_player.py index 82d8d1533c0e..e554fc4c2d99 100644 --- a/homeassistant/components/group/media_player.py +++ b/homeassistant/components/group/media_player.py @@ -19,13 +19,13 @@ from homeassistant.components.media_player import ( SERVICE_PLAY_MEDIA, MediaPlayerEntity, MediaPlayerEntityFeature, + MediaPlayerEntityStateAttribute, MediaPlayerState, MediaType, ) from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( ATTR_ENTITY_ID, - ATTR_SUPPORTED_FEATURES, CONF_ENTITIES, CONF_NAME, CONF_UNIQUE_ID, @@ -42,6 +42,7 @@ from homeassistant.const import ( SERVICE_VOLUME_SET, STATE_UNAVAILABLE, STATE_UNKNOWN, + EntityStateAttribute, ) from homeassistant.core import ( CALLBACK_TYPE, @@ -174,7 +175,9 @@ class MediaPlayerGroup(MediaPlayerEntity): players.discard(entity_id) return - new_features = new_state.attributes.get(ATTR_SUPPORTED_FEATURES, 0) + new_features = new_state.attributes.get( + EntityStateAttribute.SUPPORTED_FEATURES, 0 + ) if new_features & MediaPlayerEntityFeature.CLEAR_PLAYLIST: self._features[KEY_CLEAR_PLAYLIST].add(entity_id) else: @@ -441,7 +444,9 @@ class MediaPlayerGroup(MediaPlayerEntity): async def async_volume_up(self) -> None: """Turn volume up for media player(s).""" for entity in self._features[KEY_VOLUME]: - volume_level = self.hass.states.get(entity).attributes["volume_level"] # type: ignore[union-attr] + volume_level = self.hass.states.get(entity).attributes[ # type: ignore[union-attr] + MediaPlayerEntityStateAttribute.MEDIA_VOLUME_LEVEL + ] if volume_level < 1: await self.async_set_volume_level(min(1, volume_level + 0.1)) @@ -449,7 +454,9 @@ class MediaPlayerGroup(MediaPlayerEntity): async def async_volume_down(self) -> None: """Turn volume down for media player(s).""" for entity in self._features[KEY_VOLUME]: - volume_level = self.hass.states.get(entity).attributes["volume_level"] # type: ignore[union-attr] + volume_level = self.hass.states.get(entity).attributes[ # type: ignore[union-attr] + MediaPlayerEntityStateAttribute.MEDIA_VOLUME_LEVEL + ] if volume_level > 0: await self.async_set_volume_level(max(0, volume_level - 0.1)) diff --git a/homeassistant/components/group/notify.py b/homeassistant/components/group/notify.py index 3e61157ab8b5..f0fab28356c3 100644 --- a/homeassistant/components/group/notify.py +++ b/homeassistant/components/group/notify.py @@ -21,11 +21,11 @@ from homeassistant.components.notify import ( from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( ATTR_ENTITY_ID, - ATTR_SUPPORTED_FEATURES, CONF_ACTION, CONF_ENTITIES, CONF_SERVICE, STATE_UNAVAILABLE, + EntityStateAttribute, ) from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import config_validation as cv, entity_registry as er @@ -213,7 +213,7 @@ class NotifyGroup(GroupEntity, NotifyEntity): state = self.hass.states.get(entity_id) if ( state is None - or not state.attributes.get(ATTR_SUPPORTED_FEATURES, 0) + or not state.attributes.get(EntityStateAttribute.SUPPORTED_FEATURES, 0) & NotifyEntityFeature.TITLE ): self._attr_supported_features &= ~NotifyEntityFeature.TITLE diff --git a/homeassistant/components/group/sensor.py b/homeassistant/components/group/sensor.py index 24628ad25599..134af6b9953b 100644 --- a/homeassistant/components/group/sensor.py +++ b/homeassistant/components/group/sensor.py @@ -36,6 +36,7 @@ from homeassistant.const import ( CONF_UNIT_OF_MEASUREMENT, STATE_UNAVAILABLE, STATE_UNKNOWN, + EntityStateAttribute, ) from homeassistant.core import HomeAssistant, State, callback from homeassistant.exceptions import HomeAssistantError @@ -401,7 +402,7 @@ class SensorGroup(GroupEntity, SensorEntity): states.append(state.state) try: numeric_state = float(state.state) - uom = state.attributes.get("unit_of_measurement") + uom = state.attributes.get(EntityStateAttribute.UNIT_OF_MEASUREMENT) # Convert the state to the native unit of # measurement when we have valid units @@ -455,7 +456,9 @@ class SensorGroup(GroupEntity, SensorEntity): entity_id, state.state, self.device_class, - state.attributes.get("unit_of_measurement"), + state.attributes.get( + EntityStateAttribute.UNIT_OF_MEASUREMENT + ), self.entity_id, ) else: diff --git a/homeassistant/components/group/valve.py b/homeassistant/components/group/valve.py index 2ed81dfa4fc7..d9524fb46c74 100644 --- a/homeassistant/components/group/valve.py +++ b/homeassistant/components/group/valve.py @@ -5,18 +5,17 @@ from typing import Any, override import voluptuous as vol from homeassistant.components.valve import ( - ATTR_CURRENT_POSITION, ATTR_POSITION, DOMAIN as VALVE_DOMAIN, PLATFORM_SCHEMA as VALVE_PLATFORM_SCHEMA, ValveEntity, ValveEntityFeature, + ValveEntityStateAttribute, ValveState, ) from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( ATTR_ENTITY_ID, - ATTR_SUPPORTED_FEATURES, CONF_ENTITIES, CONF_NAME, CONF_UNIQUE_ID, @@ -26,6 +25,7 @@ from homeassistant.const import ( SERVICE_STOP_VALVE, STATE_UNAVAILABLE, STATE_UNKNOWN, + EntityStateAttribute, ) from homeassistant.core import HomeAssistant, State, callback from homeassistant.helpers import config_validation as cv, entity_registry as er @@ -136,7 +136,7 @@ class ValveGroup(GroupEntity, ValveEntity): values.discard(entity_id) return - features = new_state.attributes.get(ATTR_SUPPORTED_FEATURES, 0) + features = new_state.attributes.get(EntityStateAttribute.SUPPORTED_FEATURES, 0) if features & (ValveEntityFeature.OPEN | ValveEntityFeature.CLOSE): self._valves[KEY_OPEN_CLOSE].add(entity_id) @@ -233,7 +233,10 @@ class ValveGroup(GroupEntity, ValveEntity): self._attr_reports_position = False self._update_assumed_state_from_members() for state in states: - if state.attributes.get(ATTR_CURRENT_POSITION) is not None: + if ( + state.attributes.get(ValveEntityStateAttribute.CURRENT_POSITION) + is not None + ): self._attr_reports_position = True if state.state == ValveState.OPEN: self._attr_is_closed = False @@ -255,7 +258,7 @@ class ValveGroup(GroupEntity, ValveEntity): self._attr_is_closed = None self._attr_current_valve_position = reduce_attribute( - states, ATTR_CURRENT_POSITION + states, ValveEntityStateAttribute.CURRENT_POSITION ) supported_features = ValveEntityFeature(0) diff --git a/homeassistant/components/guntamatic/manifest.json b/homeassistant/components/guntamatic/manifest.json index b59b5251e70e..f377576bd1ee 100644 --- a/homeassistant/components/guntamatic/manifest.json +++ b/homeassistant/components/guntamatic/manifest.json @@ -14,5 +14,5 @@ "integration_type": "device", "iot_class": "local_polling", "quality_scale": "silver", - "requirements": ["guntamatic==1.9.3"] + "requirements": ["guntamatic==1.11.1"] } diff --git a/homeassistant/components/hassio/__init__.py b/homeassistant/components/hassio/__init__.py index 373c0b0c99da..59c14de4460e 100644 --- a/homeassistant/components/hassio/__init__.py +++ b/homeassistant/components/hassio/__init__.py @@ -15,7 +15,7 @@ from aiohasupervisor.models import ( ) from homeassistant.auth.const import GROUP_ID_ADMIN -from homeassistant.auth.models import RefreshToken, User +from homeassistant.auth.models import User from homeassistant.components import frontend from homeassistant.components.homeassistant import async_set_stop_handler from homeassistant.components.onboarding import async_is_onboarded @@ -49,7 +49,7 @@ from . import ( # noqa: F401 update, ) from .addon_manager import AddonError, AddonInfo, AddonManager, AddonState -from .addon_panel import async_setup_addon_panel +from .addon_panel import async_setup_addon_panel, async_setup_addon_panel_coordinator from .auth import async_setup_auth_view from .config import HassioConfigStore, StoredHassioConfig from .config_entry import async_get_hassio_entry @@ -412,11 +412,9 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: translation_key="supervisor_update_pending", ) - # Get or create a refresh token for the Supervisor user - if user.refresh_tokens: - refresh_token = list(user.refresh_tokens.values())[0] - else: - refresh_token = await hass.auth.async_create_refresh_token(user) + # Supervisor authenticates through its dedicated Unix socket. + for refresh_token in list(user.refresh_tokens.values()): + hass.auth.async_remove_refresh_token(refresh_token) # Set up coordinators — these can raise ConfigEntryNotReady. # Register listeners only after all refreshes succeed to avoid accumulation @@ -426,6 +424,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: coordinator = HassioMainDataUpdateCoordinator(hass, entry, dev_reg) await coordinator.async_config_entry_first_refresh() hass.data[MAIN_COORDINATOR] = coordinator + entry.async_on_unload(async_setup_addon_panel_coordinator(hass, coordinator)) jobs_coordinator = SupervisorJobsCoordinator(hass, entry) await jobs_coordinator.async_config_entry_first_refresh() @@ -491,7 +490,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: entry.async_on_unload(hass.bus.async_listen(EVENT_CORE_CONFIG_UPDATE, push_config)) - async def update_hass_api(refresh_token: RefreshToken) -> None: + async def update_hass_api() -> None: """Update Home Assistant API data on Hass.io.""" # hass.config.api is always set here: hassio depends on http, and the # http integration assigns hass.config.api during its async_setup. @@ -499,7 +498,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: options = HomeAssistantOptions( ssl=hass.config.api.use_ssl, port=hass.config.api.port, - refresh_token=refresh_token.token, + refresh_token=None, ) try: @@ -511,7 +510,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: # Push initial config to Supervisor and refresh issues state await asyncio.gather( - update_hass_api(refresh_token), + update_hass_api(), push_config(None), issues_coordinator.async_refresh(), ) diff --git a/homeassistant/components/hassio/addon_panel.py b/homeassistant/components/hassio/addon_panel.py index 314b6ebd6d7f..31b087a64bbc 100644 --- a/homeassistant/components/hassio/addon_panel.py +++ b/homeassistant/components/hassio/addon_panel.py @@ -9,33 +9,52 @@ from aiohttp import web from homeassistant.components import frontend from homeassistant.components.http import HomeAssistantView, require_admin -from homeassistant.const import EVENT_HOMEASSISTANT_START -from homeassistant.core import Event, HomeAssistant +from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback +from .const import MAIN_COORDINATOR +from .coordinator import HassioMainDataUpdateCoordinator from .handler import get_supervisor_client _LOGGER = logging.getLogger(__name__) def async_setup_addon_panel(hass: HomeAssistant) -> None: - """Add-on Ingress Panel setup.""" - hassio_addon_panel = HassIOAddonPanel(hass) - hass.http.register_view(hassio_addon_panel) + """Register the add-on panel push API view.""" + hass.http.register_view(HassIOAddonPanel(hass)) - # Handle existing panels on startup - async def _async_panel_start_handler(event: Event) -> None: - """Process all existing panels on startup.""" - # Check if there are panels to register - if not (panels := await hassio_addon_panel.get_panels()): - return - # Register available panels - for addon, data in panels.items(): - if not data.enable: - continue - _register_panel(hass, addon, data) +@callback +def async_setup_addon_panel_coordinator( + hass: HomeAssistant, coordinator: HassioMainDataUpdateCoordinator +) -> CALLBACK_TYPE: + """Reconcile add-on panels registered with the frontend against coordinator data. - hass.bus.async_listen_once(EVENT_HOMEASSISTANT_START, _async_panel_start_handler) + Registers the panels present after the coordinator's first refresh, then keeps + the frontend in sync with coordinator.data.panels on every following update: + periodic refreshes, a refresh triggered by a Supervisor restart, and a post/ + delete pushed by Supervisor and cached via coordinator.async_push_panel / + coordinator.async_push_panel_removal. + + Returns a function that unsubscribes from the coordinator. + """ + registered: set[str] = set() + + @callback + def _async_reconcile_panels() -> None: + """Register or remove panels to match the coordinator's cached data.""" + panels = coordinator.data.panels + wanted = {addon for addon, panel in panels.items() if panel.enable} + + for addon in wanted - registered: + _register_panel(hass, addon, panels[addon]) + for addon in registered - wanted: + frontend.async_remove_panel(hass, addon, warn_if_unknown=False) + + registered.clear() + registered.update(wanted) + + _async_reconcile_panels() + return coordinator.async_add_listener(_async_reconcile_panels) class HassIOAddonPanel(HomeAssistantView): @@ -52,34 +71,46 @@ class HassIOAddonPanel(HomeAssistantView): @require_admin async def post(self, request: web.Request, addon: str) -> web.Response: """Handle new add-on panel requests.""" - panels = await self.get_panels() + # Supervisor calls this endpoint because an add-on's panel state just + # changed, so fetch it fresh instead of relying on the coordinator's + # cache, which may still hold the value from before this change. + try: + panels = await self.client.ingress.panels() + except SupervisorError as err: + _LOGGER.error("Can't read panel info: %s", err) + return web.Response(status=HTTPStatus.BAD_REQUEST) # Panel exists for add-on slug if addon not in panels or not panels[addon].enable: _LOGGER.error("Panel is not enabled for %s", addon) return web.Response(status=HTTPStatus.BAD_REQUEST) - # Register panel - _register_panel(self.hass, addon, panels[addon]) + if (coordinator := self.hass.data.get(MAIN_COORDINATOR)) is not None: + # Update the cache; the coordinator listener registers it with the frontend. + coordinator.async_push_panel(addon, panels[addon]) + else: + _register_panel(self.hass, addon, panels[addon]) return web.Response() @require_admin async def delete(self, request: web.Request, addon: str) -> web.Response: """Handle remove add-on panel requests.""" - frontend.async_remove_panel(self.hass, addon) + if (coordinator := self.hass.data.get(MAIN_COORDINATOR)) is not None: + # Update the cache; the coordinator listener removes it from the frontend. + coordinator.async_push_panel_removal(addon) + else: + frontend.async_remove_panel(self.hass, addon, warn_if_unknown=False) return web.Response() - async def get_panels(self) -> dict[str, IngressPanel]: - """Return panels add-on info data.""" - try: - return await self.client.ingress.panels() - except SupervisorError as err: - _LOGGER.error("Can't read panel info: %s", err) - return {} +def _register_panel(hass: HomeAssistant, addon: str, data: IngressPanel) -> None: + """Helper to register the panel. -def _register_panel(hass: HomeAssistant, addon: str, data: IngressPanel): - """Helper to register the panel.""" + Uses update=True so this is idempotent: a config entry reload can run this + for a panel the frontend still has registered from before the reload, and + the push API's early-startup fallback can register one before the + coordinator's own reconciliation runs for the first time. + """ frontend.async_register_built_in_panel( hass, "app", @@ -88,4 +119,5 @@ def _register_panel(hass: HomeAssistant, addon: str, data: IngressPanel): sidebar_icon=data.icon, require_admin=data.admin, config={"addon": addon}, + update=True, ) diff --git a/homeassistant/components/hassio/const.py b/homeassistant/components/hassio/const.py index 47ddf0fa50d1..c2526117bde3 100644 --- a/homeassistant/components/hassio/const.py +++ b/homeassistant/components/hassio/const.py @@ -90,6 +90,7 @@ EVENT_SUPPORTED_CHANGED = "supported_changed" EVENT_ISSUE_CHANGED = "issue_changed" EVENT_ISSUE_REMOVED = "issue_removed" EVENT_JOB = "job" +EVENT_STORE_RELOADED = "store_reloaded" UPDATE_KEY_SUPERVISOR = "supervisor" STARTUP_COMPLETE = "complete" diff --git a/homeassistant/components/hassio/coordinator.py b/homeassistant/components/hassio/coordinator.py index 985df7331956..20b57e3d7f8a 100644 --- a/homeassistant/components/hassio/coordinator.py +++ b/homeassistant/components/hassio/coordinator.py @@ -16,6 +16,7 @@ from aiohasupervisor.models import ( HomeAssistantInfo, HomeAssistantStats, HostInfo, + IngressPanel, InstalledAddon, InstalledAddonComplete, Issue as SupervisorIssue, @@ -40,7 +41,7 @@ from homeassistant.core import ( callback, is_callback_check_partial, ) -from homeassistant.helpers import device_registry as dr +from homeassistant.helpers import device_registry as dr, issue_registry as ir from homeassistant.helpers.debounce import Debouncer from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.dispatcher import async_dispatcher_connect @@ -83,6 +84,7 @@ from .const import ( EVENT_ISSUE_CHANGED, EVENT_ISSUE_REMOVED, EVENT_JOB, + EVENT_STORE_RELOADED, EVENT_SUPERVISOR_EVENT, EVENT_SUPERVISOR_UPDATE, EVENT_SUPPORTED_CHANGED, @@ -399,16 +401,28 @@ class SupervisorIssuesCoordinator(DataUpdateCoordinator[SupervisorIssuesData]): current_data: SupervisorIssuesData, ) -> None: """Create/delete issue repairs and notify subscribers based on issue deltas.""" + issue_registry = ir.async_get(self.hass) for issue in current_data.issues.values(): previous_issue = previous_data.issues.get(issue.uuid) - if previous_issue is not None and self._issue_equal(previous_issue, issue): - continue - - self._create_or_update_issue_repair(issue) - self._process_issue_change( - IssueSubscriptionEvent(event="changed", issue=issue) + changed = previous_issue is None or not self._issue_equal( + previous_issue, issue ) + # Update the repair on changes, and re-create it if the registry + # entry went missing: a finished repair flow deletes the entry + # even when applying the suggestion failed in Supervisor and the + # issue is unchanged. + if changed or ( + issue.key in ISSUE_KEYS_FOR_REPAIRS + and not issue_registry.async_get_issue(DOMAIN, issue.uuid.hex) + ): + self._create_or_update_issue_repair(issue) + + if changed: + self._process_issue_change( + IssueSubscriptionEvent(event="changed", issue=issue) + ) + for issue_uuid, issue in previous_data.issues.items(): if issue_uuid not in current_data.issues: self._delete_issue_repair(issue) @@ -764,6 +778,7 @@ class HassioMainData: host: HostInfo mounts: dict[str, CIFSMountResponse | NFSMountResponse] os: OSInfo | None + panels: dict[str, IngressPanel] def to_dict(self) -> dict[str, Any]: """Return a dictionary representation of the data.""" @@ -773,6 +788,7 @@ class HassioMainData: "host": self.host.to_dict(), "mounts": {name: mount.to_dict() for name, mount in self.mounts.items()}, "os": self.os.to_dict() if self.os is not None else None, + "panels": {slug: panel.to_dict() for slug, panel in self.panels.items()}, } @@ -1284,6 +1300,23 @@ class HassioAddOnDataUpdateCoordinator(DataUpdateCoordinator[HassioAddonData]): self.dev_reg = dev_reg self._addon_info_subscriptions: defaultdict[str, set[str]] = defaultdict(set) self.supervisor_client = get_supervisor_client(hass) + self._dispatcher_disconnect = async_dispatcher_connect( + hass, EVENT_SUPERVISOR_EVENT, self._supervisor_event + ) + + @callback + def _supervisor_event(self, event: dict[str, Any]) -> None: + """Refresh add-on data when Supervisor reloads the store.""" + if event.get(ATTR_WS_EVENT) != EVENT_STORE_RELOADED: + return + # Without listeners there are no add-on entities to keep in sync. + # Scheduled polling is paused in that case as well, so don't let + # store reload events trigger refreshes either. + if not self._listeners: + return + self.config_entry.async_create_task( + self.hass, self.async_refresh_after_store_reload() + ) @override async def _async_update_data(self) -> HassioAddonData: @@ -1347,9 +1380,7 @@ class HassioAddOnDataUpdateCoordinator(DataUpdateCoordinator[HassioAddonData]): # Remove add-ons that are no longer installed from device registry supervisor_addon_devices = { list(device.identifiers)[0][1] - for device in self.dev_reg.devices.get_devices_for_config_entry_id( - self.entry_id - ) + for device in dr.async_entries_for_config_entry(self.dev_reg, self.entry_id) if device.model == SupervisorEntityModel.ADDON } if stale_addons := supervisor_addon_devices - set(new_data.addons): @@ -1452,6 +1483,12 @@ class HassioAddOnDataUpdateCoordinator(DataUpdateCoordinator[HassioAddonData]): addon_info_cache = self.hass.data.setdefault(DATA_ADDONS_INFO, {}) addon_info_cache[slug] = info + @override + async def async_shutdown(self) -> None: + """Shut down and clean up when config entry unloaded.""" + await super().async_shutdown() + self._dispatcher_disconnect() + class HassioMainDataUpdateCoordinator(DataUpdateCoordinator[HassioMainData]): """Class to retrieve Hass.io status.""" @@ -1492,6 +1529,25 @@ class HassioMainDataUpdateCoordinator(DataUpdateCoordinator[HassioMainData]): ): self.config_entry.async_create_task(self.hass, self.async_request_refresh()) + @callback + def async_push_panel(self, addon: str, panel: IngressPanel) -> None: + """Apply a Supervisor panel push to cached data without touching refresh state.""" + self.data = replace(self.data, panels={**self.data.panels, addon: panel}) + self.async_update_listeners() + + @callback + def async_push_panel_removal(self, addon: str) -> None: + """Apply a Supervisor panel removal push to cached data.""" + if addon not in self.data.panels: + return + self.data = replace( + self.data, + panels={ + slug: panel for slug, panel in self.data.panels.items() if slug != addon + }, + ) + self.async_update_listeners() + @override async def _async_update_data(self) -> HassioMainData: """Update data via library.""" @@ -1501,7 +1557,7 @@ class HassioMainDataUpdateCoordinator(DataUpdateCoordinator[HassioMainData]): try: # Cast is required here because asyncio.gather only has overloads to # maintain typing for 6 arguments. It falls back to list[] - # after that which is what mypy sees here since we have 7 API calls. + # after that which is what mypy sees here since we have 8 API calls. ( info, core_info, @@ -1510,6 +1566,7 @@ class HassioMainDataUpdateCoordinator(DataUpdateCoordinator[HassioMainData]): host_info, store_info, network_info, + panels_info, ) = cast( tuple[ RootInfo, @@ -1519,6 +1576,7 @@ class HassioMainDataUpdateCoordinator(DataUpdateCoordinator[HassioMainData]): HostInfo, StoreInfo, NetworkInfo, + dict[str, IngressPanel], ], await asyncio.gather( client.info(), @@ -1528,6 +1586,7 @@ class HassioMainDataUpdateCoordinator(DataUpdateCoordinator[HassioMainData]): client.host.info(), client.store.info(), client.network.info(), + client.ingress.panels(), ), ) mounts_info = await client.mounts.info() @@ -1542,6 +1601,7 @@ class HassioMainDataUpdateCoordinator(DataUpdateCoordinator[HassioMainData]): host=host_info, mounts={mount.name: mount for mount in mounts_info.mounts}, os=os_info if self.is_hass_os else None, + panels=panels_info, ) # Update hass.data for legacy accessor functions @@ -1569,9 +1629,7 @@ class HassioMainDataUpdateCoordinator(DataUpdateCoordinator[HassioMainData]): # Remove mounts that no longer exists from device registry supervisor_mount_devices = { device.name - for device in self.dev_reg.devices.get_devices_for_config_entry_id( - self.entry_id - ) + for device in dr.async_entries_for_config_entry(self.dev_reg, self.entry_id) if device.model == SupervisorEntityModel.MOUNT } if stale_mounts := supervisor_mount_devices - set(new_data.mounts): diff --git a/homeassistant/components/hassio/repairs.py b/homeassistant/components/hassio/repairs.py index 55b43f3fa08d..f76ee362de42 100644 --- a/homeassistant/components/hassio/repairs.py +++ b/homeassistant/components/hassio/repairs.py @@ -41,6 +41,7 @@ from .issues import Issue, Suggestion SUGGESTION_CONFIRMATION_REQUIRED = { "addon_execute_remove", + "mount_move_local_data", "system_adopt_data_disk", "system_execute_reboot", } diff --git a/homeassistant/components/hassio/strings.json b/homeassistant/components/hassio/strings.json index 213215822b66..9c05ad0a6e30 100644 --- a/homeassistant/components/hassio/strings.json +++ b/homeassistant/components/hassio/strings.json @@ -152,11 +152,15 @@ }, "step": { "fix_menu": { - "description": "Could not connect to `{reference}`. Check host logs for errors from the mount service for more details.\n\nUse reload to try to connect again. If you need to update `{reference}`, go to [storage]({storage_url}).", + "description": "Could not set up `{reference}`. This can happen when the storage device is unreachable, or when local data is blocking the mount location. Check host logs for errors from the mount service for more details.\n\nUse reload to try again. If you need to update `{reference}`, go to [storage]({storage_url}).", "menu_options": { "mount_execute_reload": "[%key:common::action::reload%]", - "mount_execute_remove": "Remove" + "mount_execute_remove": "Remove", + "mount_move_local_data": "Move blocking local data away" } + }, + "mount_move_local_data": { + "description": "Select **Submit** to move the local data blocking `{reference}` to a `{reference}_local_recovery` folder and set up the mount again.\n\nNo files are deleted. For media and share mounts the recovery folder is created next to the mount, where you can review and remove it. For backup mounts it is placed in local backup storage." } } }, diff --git a/homeassistant/components/hassio/websocket_api.py b/homeassistant/components/hassio/websocket_api.py index 3fec165459e2..3f945f75c321 100644 --- a/homeassistant/components/hassio/websocket_api.py +++ b/homeassistant/components/hassio/websocket_api.py @@ -20,7 +20,6 @@ from homeassistant.helpers.dispatcher import ( from .config_entry import async_get_hassio_entry, async_get_update_options from .const import ( - ADDONS_COORDINATOR, ATTR_DATA, ATTR_ENDPOINT, ATTR_METHOD, @@ -59,10 +58,6 @@ WS_NO_ADMIN_ENDPOINTS = re.compile( r")$" ) -# Endpoint that reloads the add-on store. Afterwards the add-on update -# entities must be refreshed so they don't report stale update information. -STORE_RELOAD_ENDPOINT = "/store/reload" - _LOGGER: logging.Logger = logging.getLogger(__package__) @@ -163,15 +158,6 @@ async def websocket_supervisor_api( # sensitive information and the frontend does not require it for ingress. if not connection.user.is_admin and WS_ADDONS_INFO_ENDPOINT.match(command): data.pop("options", None) - # Await so the frontend only sees the reload finish once the add-on - # update entities reflect the reloaded store. - if ( - command == STORE_RELOAD_ENDPOINT - and msg[ATTR_METHOD] == "post" - and (coordinator := hass.data.get(ADDONS_COORDINATOR)) - ): - await coordinator.async_refresh_after_store_reload() - connection.send_result(msg[WS_ID], data) diff --git a/homeassistant/components/heos/__init__.py b/homeassistant/components/heos/__init__.py index c2d24e79968d..ac39f180cc0d 100644 --- a/homeassistant/components/heos/__init__.py +++ b/homeassistant/components/heos/__init__.py @@ -32,9 +32,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: HeosConfigEntry) -> bool # Migrate non-string device identifiers. device_registry = dr.async_get(hass) - for device in device_registry.devices.get_devices_for_config_entry_id( - entry.entry_id - ): + for device in dr.async_entries_for_config_entry(device_registry, entry.entry_id): for ident in device.identifiers: if ident[0] != DOMAIN or isinstance(ident[1], str): continue diff --git a/homeassistant/components/hive/__init__.py b/homeassistant/components/hive/__init__.py index f9c4cdd88c7e..d50ead9bfaa5 100644 --- a/homeassistant/components/hive/__init__.py +++ b/homeassistant/components/hive/__init__.py @@ -50,7 +50,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: HiveConfigEntry) -> bool connections.add((dr.CONNECTION_NETWORK_MAC, mac)) device_registry = dr.async_get(hass) - hub_device = device_registry.async_get_or_create( + device_registry.async_get_or_create( config_entry_id=entry.entry_id, identifiers={(DOMAIN, hub_data["device_id"])}, connections=connections, @@ -59,11 +59,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: HiveConfigEntry) -> bool sw_version=hub_data["deviceData"]["version"], manufacturer=hub_data["deviceData"]["manufacturer"], ) - if hub_device.via_device_id is not None: - # Older versions linked the hub's own diagnostic sensor to the hub itself; - # clear the stale self-reference since async_get_or_create leaves - # via_device_id untouched when it's not passed. - device_registry.async_update_device(hub_device.id, via_device_id=None) await hass.config_entries.async_forward_entry_setups( entry, diff --git a/homeassistant/components/home_connect/config_flow.py b/homeassistant/components/home_connect/config_flow.py index 897416b156b6..4c852654ebf2 100644 --- a/homeassistant/components/home_connect/config_flow.py +++ b/homeassistant/components/home_connect/config_flow.py @@ -2,7 +2,7 @@ from collections.abc import Mapping import logging -from typing import Any, override +from typing import Any, Final, override import jwt import voluptuous as vol @@ -13,6 +13,8 @@ from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo from .const import DOMAIN +INPUT_IMAGES_SCOPE: Final = "images_scope" + class OAuth2FlowHandler( config_entry_oauth2_flow.AbstractOAuth2FlowHandler, domain=DOMAIN @@ -23,12 +25,49 @@ class OAuth2FlowHandler( MINOR_VERSION = 3 + images_scope: bool | None = None + @property @override def logger(self) -> logging.Logger: """Return logger.""" return logging.getLogger(__name__) + @property + @override + def extra_authorize_data(self) -> dict[str, str]: + return { + "scope": ( + "Control Monitor Settings" + f" IdentifyAppliance{' Images' if self.images_scope else ''}" + ), + } + + @override + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle a flow start.""" + return await self.async_step_scopes(user_input) + + async def async_step_scopes( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Ask for the scopes to use.""" + if user_input is not None: + self.images_scope = user_input[INPUT_IMAGES_SCOPE] + if self.images_scope is not None: + return await self.async_step_pick_implementation(None) + + return self.async_show_form( + step_id="scopes", + data_schema=vol.Schema( + { + vol.Required(INPUT_IMAGES_SCOPE): bool, + } + ), + ) + async def async_step_reauth( self, entry_data: Mapping[str, Any] ) -> ConfigFlowResult: diff --git a/homeassistant/components/home_connect/strings.json b/homeassistant/components/home_connect/strings.json index 2a3e42c602bc..2ff4b99a1712 100644 --- a/homeassistant/components/home_connect/strings.json +++ b/homeassistant/components/home_connect/strings.json @@ -17,6 +17,7 @@ "oauth_timeout": "[%key:common::config_flow::abort::oauth2_timeout%]", "oauth_unauthorized": "[%key:common::config_flow::abort::oauth2_unauthorized%]", "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]", + "user_rejected_authorize": "[%key:common::config_flow::abort::oauth2_user_rejected_authorize%]", "wrong_account": "Please ensure you reconfigure against the same account." }, "create_entry": { @@ -38,6 +39,16 @@ "reauth_confirm": { "description": "The Home Connect integration needs to re-authenticate your account", "title": "[%key:common::config_flow::title::reauth%]" + }, + "scopes": { + "data": { + "images_scope": "Images scope" + }, + "data_description": { + "images_scope": "Allows Home Assistant to access images from your Home Connect devices." + }, + "description": "Select the optional scopes you want to enable for Home Connect authentication.", + "title": "Scopes" } } }, diff --git a/homeassistant/components/homeassistant/triggers/event.py b/homeassistant/components/homeassistant/triggers/event.py index 7f45f19862b5..010f20a0c5a4 100644 --- a/homeassistant/components/homeassistant/triggers/event.py +++ b/homeassistant/components/homeassistant/triggers/event.py @@ -1,16 +1,29 @@ """Offer event listening automation rules.""" from collections.abc import ItemsView, Mapping +import logging from typing import Any import voluptuous as vol -from homeassistant.const import CONF_EVENT_DATA, CONF_PLATFORM, EVENT_STATE_REPORTED +from homeassistant.const import ( + CONF_DEVICE_ID, + CONF_EVENT_DATA, + CONF_PLATFORM, + EVENT_STATE_REPORTED, +) from homeassistant.core import CALLBACK_TYPE, Event, HassJob, HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers import config_validation as cv, template +from homeassistant.helpers import ( + config_validation as cv, + device_registry as dr, + template, +) from homeassistant.helpers.trigger import TriggerActionType, TriggerInfo from homeassistant.helpers.typing import ConfigType +from homeassistant.util import yaml as yaml_util + +_LOGGER = logging.getLogger(__name__) CONF_EVENT_TYPE = "event_type" CONF_EVENT_CONTEXT = "context" @@ -39,6 +52,58 @@ TRIGGER_SCHEMA = cv.TRIGGER_BASE_SCHEMA.extend( ) +async def async_validate_trigger_config( + hass: HomeAssistant, config: ConfigType +) -> ConfigType: + """Validate trigger config. + + Warn if the trigger filters event_data.device_id on a pre-migration composite device + id - a device that was split into one device per config entry. + A templated device id is a Template (not a plain string) and is left alone. + """ + validated_config: ConfigType = TRIGGER_SCHEMA(config) + if ( + CONF_EVENT_DATA in validated_config + and isinstance( + device_id := validated_config[CONF_EVENT_DATA].get(CONF_DEVICE_ID), str + ) + and ( + split_devices := dr.async_get( + hass + ).async_get_devices_for_composite_device_id(device_id) + ) + ): + _log_composite_device_id_warning(hass, config, device_id, split_devices) + return validated_config + + +@callback +def _log_composite_device_id_warning( + hass: HomeAssistant, + config: ConfigType, + device_id: str, + split_devices: list[dr.DeviceEntry], +) -> None: + """Warn that an event trigger filters on a split (pre-migration) device id.""" + + device_summaries: list[str] = [] + for device in split_devices: + entry = hass.config_entries.async_get_entry(device.config_entry_id) + domain = entry.domain if entry else "unknown" + name = device.name_by_user or device.name or device.id + device_summaries.append(f"{name} ({device.id}) from the {domain} integration") + + _LOGGER.warning( + "Event trigger filters on device '%s', which was split into one device per " + "integration and no longer exists, so the trigger can no longer fire. Update the " + "automation, script or template entity to filter on one of these devices instead: " + "%s.\nThe affected trigger is configured as:\n%s", + device_id, + ", ".join(device_summaries), + yaml_util.dump(config), + ) + + def _schema_value(value: Any) -> Any: if isinstance(value, list): return vol.In(value) diff --git a/homeassistant/components/homekit/__init__.py b/homeassistant/components/homekit/__init__.py index 017e9c721fca..e2ce48571a85 100644 --- a/homeassistant/components/homekit/__init__.py +++ b/homeassistant/components/homekit/__init__.py @@ -1008,7 +1008,7 @@ class HomeKit: """Purge bridges that exist from failed pairing or manual resets.""" devices_to_purge = [ entry.id - for entry in dev_reg.devices.get_devices_for_config_entry_id(self._entry_id) + for entry in dr.async_entries_for_config_entry(dev_reg, self._entry_id) if ( identifier not in entry.identifiers # type: ignore[comparison-overlap] or connection not in entry.connections # type: ignore[unreachable] @@ -1071,9 +1071,17 @@ class HomeKit: dev_reg = dr.async_get(self.hass) valid_device_ids = [] for device_id in self._devices: - if dev_reg.async_get(device_id, include_child_devices=False): - valid_device_ids.append(device_id) - elif dev_reg.async_get(device_id, include_main_devices=False): + device = dev_reg.async_get(device_id) + if device is None: + _LOGGER.warning( + ( + "HomeKit %s cannot add device %s because it is missing from the" + " device registry" + ), + self._name, + device_id, + ) + elif isinstance(device, dr.ChildDeviceEntry): _LOGGER.warning( ( "HomeKit %s cannot add device %s because a child device cannot" @@ -1083,14 +1091,8 @@ class HomeKit: device_id, ) else: - _LOGGER.warning( - ( - "HomeKit %s cannot add device %s because it is missing from the" - " device registry" - ), - self._name, - device_id, - ) + # A main or composite device is a valid HomeKit accessory + valid_device_ids.append(device_id) for device_id, device_triggers in ( await device_automation.async_get_device_automations( self.hass, @@ -1231,7 +1233,9 @@ class HomeKit: dev_reg_ent = dev_reg.async_get(ent_reg_ent.device_id) if isinstance(dev_reg_ent, dr.ChildDeviceEntry): # A child device has no hardware info of its own; use the parent's - dev_reg_ent = dev_reg.devices.get(dev_reg_ent.parent_device_id) + dev_reg_ent = dev_reg.async_get( + dev_reg_ent.parent_device_id, include_child_devices=False + ) if dev_reg_ent is not None: self._fill_config_from_device_registry_entry(dev_reg_ent, ent_cfg) if ATTR_MANUFACTURER not in ent_cfg: diff --git a/homeassistant/components/homematicip_cloud/entity.py b/homeassistant/components/homematicip_cloud/entity.py index f5e56c2715a6..4ac44c80c11d 100644 --- a/homeassistant/components/homematicip_cloud/entity.py +++ b/homeassistant/components/homematicip_cloud/entity.py @@ -213,8 +213,9 @@ class HomematicipGenericEntity(Entity): if device_id := self.registry_entry.device_id: # Remove from device registry. device_registry = dr.async_get(self.hass) - if device_id in device_registry.devices: - # This will also remove associated entities from entity registry. + # This will also remove associated entities from entity registry, + # ignore an already removed device. + with contextlib.suppress(KeyError): device_registry.async_remove_device(device_id) else: # noqa: PLR5501 # Remove from entity registry. diff --git a/homeassistant/components/homewizard/config_flow.py b/homeassistant/components/homewizard/config_flow.py index 3d397e26014a..627073d47b04 100644 --- a/homeassistant/components/homewizard/config_flow.py +++ b/homeassistant/components/homewizard/config_flow.py @@ -19,7 +19,7 @@ from homewizard_energy.models import Device import voluptuous as vol from homeassistant.components import onboarding -from homeassistant.config_entries import ConfigFlow, ConfigFlowResult +from homeassistant.config_entries import SOURCE_USER, ConfigFlow, ConfigFlowResult from homeassistant.const import CONF_IP_ADDRESS, CONF_TOKEN from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import AbortFlow @@ -60,7 +60,8 @@ class HomeWizardConfigFlow(ConfigFlow, domain=DOMAIN): return await self.async_step_authorize() else: await self.async_set_unique_id( - f"{device_info.product_type}_{device_info.serial}" + f"{device_info.product_type}_{device_info.serial}", + raise_on_progress=False, ) self._abort_if_unique_id_configured(updates=user_input) return self.async_create_entry( @@ -110,7 +111,8 @@ class HomeWizardConfigFlow(ConfigFlow, domain=DOMAIN): } await self.async_set_unique_id( - f"{device_info.product_type}_{device_info.serial}" + f"{device_info.product_type}_{device_info.serial}", + raise_on_progress=self.source != SOURCE_USER, ) self._abort_if_unique_id_configured(updates=data) return self.async_create_entry( diff --git a/homeassistant/components/homewizard/strings.json b/homeassistant/components/homewizard/strings.json index 475bff8640fc..bbc6521a5f34 100644 --- a/homeassistant/components/homewizard/strings.json +++ b/homeassistant/components/homewizard/strings.json @@ -2,6 +2,7 @@ "config": { "abort": { "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", + "already_in_progress": "[%key:common::config_flow::abort::already_in_progress%]", "device_not_supported": "This device is not supported", "invalid_discovery_parameters": "Invalid discovery parameters", "reauth_enable_api_successful": "Enabling API was successful", diff --git a/homeassistant/components/hotspring/__init__.py b/homeassistant/components/hotspring/__init__.py index 18a49859683f..d6e75e304d8d 100644 --- a/homeassistant/components/hotspring/__init__.py +++ b/homeassistant/components/hotspring/__init__.py @@ -5,7 +5,10 @@ from homeassistant.core import HomeAssistant from .coordinator import HotSpringConfigEntry, HotSpringDataUpdateCoordinator -PLATFORMS = [Platform.NUMBER] +PLATFORMS = [ + Platform.NUMBER, + Platform.SENSOR, +] async def async_setup_entry(hass: HomeAssistant, entry: HotSpringConfigEntry) -> bool: diff --git a/homeassistant/components/hotspring/config_flow.py b/homeassistant/components/hotspring/config_flow.py index d900ea1f10c7..1377c7cf5e63 100644 --- a/homeassistant/components/hotspring/config_flow.py +++ b/homeassistant/components/hotspring/config_flow.py @@ -15,6 +15,7 @@ from homeassistant.const import CONF_HOST from homeassistant.core import HomeAssistant from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.selector import TextSelector +from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo from .const import DOMAIN @@ -38,6 +39,9 @@ class HotSpringConfigFlow(ConfigFlow, domain=DOMAIN): """Handle a config flow for Hot Spring.""" VERSION = 1 + discovered_host: str + discovered_spa: Spa + discovered_title: str @override async def async_step_user( @@ -86,3 +90,37 @@ class HotSpringConfigFlow(ConfigFlow, domain=DOMAIN): ) -> ConfigFlowResult: """Handle reconfiguration of the Hot Spring spa.""" return await self.async_step_user(user_input) + + @override + async def async_step_zeroconf( + self, discovery_info: ZeroconfServiceInfo + ) -> ConfigFlowResult: + """Handle zeroconf discovery.""" + self.discovered_host = discovery_info.host + try: + self.discovered_spa = await validate_input( + self.hass, {CONF_HOST: discovery_info.host} + ) + except HotSpringConnectionError, HotSpringError: + return self.async_abort(reason="cannot_connect") + + await self.async_set_unique_id(self.discovered_spa.info.mac_address) + self._abort_if_unique_id_configured(updates={CONF_HOST: discovery_info.host}) + + self.discovered_title = self.discovered_spa.info.hostname or "Hot Spring Spa" + self.context["title_placeholders"] = {"name": self.discovered_title} + + self._set_confirm_only() + return self.async_show_form( + step_id="zeroconf_confirm", + description_placeholders={"name": self.discovered_title}, + ) + + async def async_step_zeroconf_confirm( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle a flow initiated by zeroconf.""" + return self.async_create_entry( + title=self.discovered_title, + data={CONF_HOST: self.discovered_host}, + ) diff --git a/homeassistant/components/hotspring/diagnostics.py b/homeassistant/components/hotspring/diagnostics.py new file mode 100644 index 000000000000..889fe04fded9 --- /dev/null +++ b/homeassistant/components/hotspring/diagnostics.py @@ -0,0 +1,58 @@ +"""Diagnostics support for Hot Spring.""" + +from dataclasses import asdict +import re +from typing import Any + +from homeassistant.components.diagnostics import REDACTED, async_redact_data +from homeassistant.const import CONF_HOST +from homeassistant.core import HomeAssistant + +from .coordinator import HotSpringConfigEntry + +TO_REDACT = { + CONF_HOST, +} + + +def _redact_mac(value: str, patterns: list[str]) -> str: + """Redact MAC address patterns from a string.""" + for pattern in patterns: + value = re.sub(re.escape(pattern), REDACTED, value, flags=re.IGNORECASE) + return value + + +async def async_get_config_entry_diagnostics( + hass: HomeAssistant, entry: HotSpringConfigEntry +) -> dict[str, Any]: + """Return diagnostics for a config entry.""" + coordinator = entry.runtime_data + spa = coordinator.data + + info = asdict(spa.info) + if mac_address := spa.info.mac_address: + clean_mac = mac_address.replace(":", "") + patterns = [mac_address, clean_mac, clean_mac[-6:]] + info["root_topic"] = _redact_mac(info["root_topic"], patterns) + info["hostname"] = _redact_mac(info["hostname"], patterns) + + return { + "entry": async_redact_data(entry.data, TO_REDACT), + "data": { + "info": info, + "heater": asdict(spa.heater), + "jets": [asdict(jet) for jet in spa.jets], + "blower": asdict(spa.blower), + "light_zones": [asdict(zone) for zone in spa.light_zones], + "logo_light": asdict(spa.logo_light), + "clean_cycle": asdict(spa.clean_cycle), + "spa_lock": asdict(spa.spa_lock), + "water_care": asdict(spa.water_care), + "freshwater_iq": asdict(spa.freshwater_iq), + "energy_savings": [asdict(schedule) for schedule in spa.energy_savings], + "versions": asdict(spa.versions), + "connection_status": asdict(spa.connection_status), + "diagnostics": asdict(spa.diagnostics), + "test_metrics": asdict(spa.test_metrics), + }, + } diff --git a/homeassistant/components/hotspring/manifest.json b/homeassistant/components/hotspring/manifest.json index d6041b7d89e2..58026c901d4f 100644 --- a/homeassistant/components/hotspring/manifest.json +++ b/homeassistant/components/hotspring/manifest.json @@ -8,5 +8,11 @@ "iot_class": "local_polling", "loggers": ["hotspring"], "quality_scale": "silver", - "requirements": ["python-hotspring==1.3.0"] + "requirements": ["python-hotspring==1.3.0"], + "zeroconf": [ + { + "name": "watkins_spa*", + "type": "_ws._tcp.local." + } + ] } diff --git a/homeassistant/components/hotspring/quality_scale.yaml b/homeassistant/components/hotspring/quality_scale.yaml index a783b783b740..fba94802633d 100644 --- a/homeassistant/components/hotspring/quality_scale.yaml +++ b/homeassistant/components/hotspring/quality_scale.yaml @@ -49,9 +49,9 @@ rules: # Gold devices: done - diagnostics: todo - discovery-update-info: todo - discovery: todo + diagnostics: done + discovery-update-info: done + discovery: done docs-data-update: done docs-examples: done docs-known-limitations: done diff --git a/homeassistant/components/hotspring/sensor.py b/homeassistant/components/hotspring/sensor.py new file mode 100644 index 000000000000..d8e7eb2f5e42 --- /dev/null +++ b/homeassistant/components/hotspring/sensor.py @@ -0,0 +1,125 @@ +"""Support for Hot Spring sensors.""" + +from collections.abc import Callable +from dataclasses import dataclass +from typing import override + +from hotspring import Spa + +from homeassistant.components.sensor import ( + SensorDeviceClass, + SensorEntity, + SensorEntityDescription, + SensorStateClass, +) +from homeassistant.const import EntityCategory, UnitOfTemperature, UnitOfTime +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback +from homeassistant.helpers.typing import StateType + +from .coordinator import HotSpringConfigEntry, HotSpringDataUpdateCoordinator +from .entity import HotSpringEntity + +PARALLEL_UPDATES = 0 + + +@dataclass(frozen=True, kw_only=True) +class HotSpringSensorEntityDescription(SensorEntityDescription): + """Describes Hot Spring sensor entity.""" + + exists_fn: Callable[[Spa], bool] = lambda _: True + value_fn: Callable[[Spa], StateType] + + +SENSORS: tuple[HotSpringSensorEntityDescription, ...] = ( + HotSpringSensorEntityDescription( + key="current_temperature", + translation_key="current_temperature", + device_class=SensorDeviceClass.TEMPERATURE, + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=UnitOfTemperature.FAHRENHEIT, + value_fn=lambda spa: spa.heater.current_temperature, + ), + HotSpringSensorEntityDescription( + key="water_care_120_day_timer", + translation_key="water_care_120_day_timer", + device_class=SensorDeviceClass.DURATION, + native_unit_of_measurement=UnitOfTime.DAYS, + value_fn=lambda spa: spa.water_care.one_twenty_day_timer, + exists_fn=lambda spa: spa.water_care.cartridge_installed, + ), + HotSpringSensorEntityDescription( + key="water_care_salt_value", + translation_key="water_care_salt_value", + state_class=SensorStateClass.MEASUREMENT, + value_fn=lambda spa: spa.water_care.salt_value, + exists_fn=lambda spa: spa.water_care.cartridge_installed, + ), + HotSpringSensorEntityDescription( + key="water_care_10_day_timer", + translation_key="water_care_10_day_timer", + device_class=SensorDeviceClass.DURATION, + native_unit_of_measurement=UnitOfTime.DAYS, + value_fn=lambda spa: spa.water_care.ten_day_timer, + exists_fn=lambda spa: spa.water_care.cartridge_installed, + ), + HotSpringSensorEntityDescription( + key="control_box_version", + translation_key="control_box_version", + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + value_fn=lambda spa: spa.versions.control_box, + exists_fn=lambda spa: bool(spa.versions.control_box), + ), + HotSpringSensorEntityDescription( + key="wifi_dongle_version", + translation_key="wifi_dongle_version", + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + value_fn=lambda spa: spa.versions.wifi_dongle, + exists_fn=lambda spa: bool(spa.versions.wifi_dongle), + ), + HotSpringSensorEntityDescription( + key="fwss_version", + translation_key="fwss_version", + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + value_fn=lambda spa: spa.versions.fwss, + exists_fn=lambda spa: bool(spa.versions.fwss), + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, + entry: HotSpringConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up Hot Spring sensor entities.""" + coordinator = entry.runtime_data + async_add_entities( + HotSpringSensorEntity(coordinator, description) + for description in SENSORS + if description.exists_fn(coordinator.data) + ) + + +class HotSpringSensorEntity(HotSpringEntity, SensorEntity): + """Defines a Hot Spring sensor entity.""" + + entity_description: HotSpringSensorEntityDescription + + def __init__( + self, + coordinator: HotSpringDataUpdateCoordinator, + description: HotSpringSensorEntityDescription, + ) -> None: + """Initialize the sensor entity.""" + super().__init__(coordinator, description.key) + self.entity_description = description + + @property + @override + def native_value(self) -> StateType: + """Return the state of the sensor.""" + return self.entity_description.value_fn(self.coordinator.data) diff --git a/homeassistant/components/hotspring/strings.json b/homeassistant/components/hotspring/strings.json index 32ae2b0cd1fa..5fd6d7c75e32 100644 --- a/homeassistant/components/hotspring/strings.json +++ b/homeassistant/components/hotspring/strings.json @@ -18,6 +18,10 @@ "host": "Hostname or IP address of your Hot Spring Home Network Adapter (HNA)." }, "description": "Set up your Hot Spring Home Network Adapter (HNA) to integrate with Home Assistant." + }, + "zeroconf_confirm": { + "description": "Do you want to add the Hot Spring spa named `{name}` to Home Assistant?", + "title": "Discovered Hot Spring spa" } } }, @@ -26,6 +30,29 @@ "target_temperature": { "name": "Target temperature" } + }, + "sensor": { + "control_box_version": { + "name": "Control box version" + }, + "current_temperature": { + "name": "Current temperature" + }, + "fwss_version": { + "name": "FreshWater Salt System version" + }, + "water_care_10_day_timer": { + "name": "Salt 10-day check timer" + }, + "water_care_120_day_timer": { + "name": "Salt cartridge age" + }, + "water_care_salt_value": { + "name": "Salt value" + }, + "wifi_dongle_version": { + "name": "Wi-Fi dongle version" + } } }, "exceptions": { diff --git a/homeassistant/components/http/ban.py b/homeassistant/components/http/ban.py index a21499f613ae..d85ded19b016 100644 --- a/homeassistant/components/http/ban.py +++ b/homeassistant/components/http/ban.py @@ -46,7 +46,7 @@ IP_BANS_FILE: Final = "ip_bans.yaml" ATTR_BANNED_AT: Final = "banned_at" SCHEMA_IP_BAN_ENTRY: Final = vol.Schema( - {vol.Optional("banned_at"): vol.Any(None, cv.datetime)} + {vol.Optional(ATTR_BANNED_AT, default=None): vol.Any(None, cv.datetime)} ) diff --git a/homeassistant/components/hue_ble/manifest.json b/homeassistant/components/hue_ble/manifest.json index fffc31c3e93f..801f29147630 100644 --- a/homeassistant/components/hue_ble/manifest.json +++ b/homeassistant/components/hue_ble/manifest.json @@ -16,5 +16,5 @@ "iot_class": "local_push", "loggers": ["bleak", "HueBLE"], "quality_scale": "bronze", - "requirements": ["HueBLE==2.2.2"] + "requirements": ["HueBLE==2.2.3"] } diff --git a/homeassistant/components/husqvarna_automower/coordinator.py b/homeassistant/components/husqvarna_automower/coordinator.py index e5cddbff7e31..85724a618396 100644 --- a/homeassistant/components/husqvarna_automower/coordinator.py +++ b/homeassistant/components/husqvarna_automower/coordinator.py @@ -218,8 +218,8 @@ class AutomowerDataUpdateCoordinator(DataUpdateCoordinator[MowerDictionary]): registered_devices: set[str] = { str(mower_id) - for device in device_registry.devices.get_devices_for_config_entry_id( - self.config_entry.entry_id + for device in dr.async_entries_for_config_entry( + device_registry, self.config_entry.entry_id ) for domain, mower_id in device.identifiers if domain == DOMAIN diff --git a/homeassistant/components/hydrawise/__init__.py b/homeassistant/components/hydrawise/__init__.py index 1fced69ba704..c5b74f2f2e29 100644 --- a/homeassistant/components/hydrawise/__init__.py +++ b/homeassistant/components/hydrawise/__init__.py @@ -70,9 +70,6 @@ async def async_setup_entry( manufacturer=MANUFACTURER, model=controller.hardware.model.description, name=controller.name, - # Explicitly clear any via_device_id: older versions linked the - # controller device to itself via its rain sensor entity. - via_device_id=None, ) # Register the controllers known at setup before the platforms construct diff --git a/homeassistant/components/ibeacon/coordinator.py b/homeassistant/components/ibeacon/coordinator.py index 826d34a526d3..4477dd3179af 100644 --- a/homeassistant/components/ibeacon/coordinator.py +++ b/homeassistant/components/ibeacon/coordinator.py @@ -16,7 +16,7 @@ from homeassistant.components import bluetooth from homeassistant.components.bluetooth.match import BluetoothCallbackMatcher from homeassistant.config_entries import ConfigEntry from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback -from homeassistant.helpers.device_registry import DeviceRegistry +from homeassistant.helpers import device_registry as dr from homeassistant.helpers.dispatcher import async_dispatcher_send from homeassistant.helpers.event import async_track_time_interval @@ -112,7 +112,7 @@ class IBeaconCoordinator: """Set up the iBeacon Coordinator.""" def __init__( - self, hass: HomeAssistant, entry: ConfigEntry, registry: DeviceRegistry + self, hass: HomeAssistant, entry: ConfigEntry, registry: dr.DeviceRegistry ) -> None: """Initialize the Coordinator.""" self.hass = hass @@ -508,8 +508,8 @@ class IBeaconCoordinator: @callback def _async_restore_from_registry(self) -> None: """Restore the state of the Coordinator from the device registry.""" - for device in self._dev_reg.devices.get_devices_for_config_entry_id( - self._entry.entry_id + for device in dr.async_entries_for_config_entry( + self._dev_reg, self._entry.entry_id ): if not (identifier := next(iter(device.identifiers), None)): continue diff --git a/homeassistant/components/incomfort/coordinator.py b/homeassistant/components/incomfort/coordinator.py index 12f1255bb051..9901cb74a9bd 100644 --- a/homeassistant/components/incomfort/coordinator.py +++ b/homeassistant/components/incomfort/coordinator.py @@ -47,9 +47,7 @@ def async_cleanup_stale_devices( """Cleanup stale heater devices and climates.""" heater_serial_numbers = {heater.serial_no for heater in data.heaters} device_registry = dr.async_get(hass) - device_entries = device_registry.devices.get_devices_for_config_entry_id( - entry.entry_id - ) + device_entries = dr.async_entries_for_config_entry(device_registry, entry.entry_id) stale_heater_serial_numbers: list[str] = [ device_entry.serial_number for device_entry in device_entries diff --git a/homeassistant/components/iqvia/manifest.json b/homeassistant/components/iqvia/manifest.json index 48a89f5a96a4..b4977a5de2cc 100644 --- a/homeassistant/components/iqvia/manifest.json +++ b/homeassistant/components/iqvia/manifest.json @@ -7,5 +7,5 @@ "integration_type": "service", "iot_class": "cloud_polling", "loggers": ["pyiqvia"], - "requirements": ["numpy==2.3.2", "pyiqvia==2022.04.0"] + "requirements": ["numpy==2.5.2", "pyiqvia==2022.04.0"] } diff --git a/homeassistant/components/isy994/helpers.py b/homeassistant/components/isy994/helpers.py index ebd9a38e1347..764f50988e03 100644 --- a/homeassistant/components/isy994/helpers.py +++ b/homeassistant/components/isy994/helpers.py @@ -362,7 +362,7 @@ def _categorize_nodes( isy_data.nodes[ISY_GROUP_PLATFORM].append(node) continue - if node.protocol == PROTO_INSTEON: + if node.protocol in (PROTO_INSTEON, PROTO_ZWAVE): for control in node.aux_properties: if control in SKIP_AUX_PROPS: continue diff --git a/homeassistant/components/jvc_projector/remote.py b/homeassistant/components/jvc_projector/remote.py index ecb2320fa3ce..f063fbd61ea0 100644 --- a/homeassistant/components/jvc_projector/remote.py +++ b/homeassistant/components/jvc_projector/remote.py @@ -15,6 +15,8 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .coordinator import JVCConfigEntry from .entity import JvcProjectorEntity +POWER_SLEEP = 1 + COMMANDS: list[str] = [ cmd.Remote.MENU, cmd.Remote.UP, @@ -92,14 +94,14 @@ class JvcProjectorRemote(JvcProjectorEntity, RemoteEntity): async def async_turn_on(self, **kwargs: Any) -> None: """Turn the device on.""" await self.device.set(cmd.Power, cmd.Power.ON) - await asyncio.sleep(1) + await asyncio.sleep(POWER_SLEEP) await self.coordinator.async_refresh() @override async def async_turn_off(self, **kwargs: Any) -> None: """Turn the device off.""" await self.device.set(cmd.Power, cmd.Power.OFF) - await asyncio.sleep(1) + await asyncio.sleep(POWER_SLEEP) await self.coordinator.async_refresh() @override diff --git a/homeassistant/components/kulersky/__init__.py b/homeassistant/components/kulersky/__init__.py index b123a4cc035e..1dc137d27d90 100644 --- a/homeassistant/components/kulersky/__init__.py +++ b/homeassistant/components/kulersky/__init__.py @@ -45,7 +45,7 @@ async def async_migrate_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> # supports core bluetooth discovery if config_entry.version == 1: dev_reg = dr.async_get(hass) - devices = dev_reg.devices.get_devices_for_config_entry_id(config_entry.entry_id) + devices = dr.async_entries_for_config_entry(dev_reg, config_entry.entry_id) if len(devices) == 0: _LOGGER.error("Unable to migrate; No devices registered") diff --git a/homeassistant/components/ld2410_ble/manifest.json b/homeassistant/components/ld2410_ble/manifest.json index 806d8edecb32..995bdaa09669 100644 --- a/homeassistant/components/ld2410_ble/manifest.json +++ b/homeassistant/components/ld2410_ble/manifest.json @@ -20,5 +20,5 @@ "documentation": "https://www.home-assistant.io/integrations/ld2410_ble", "integration_type": "device", "iot_class": "local_push", - "requirements": ["bluetooth-data-tools==1.29.18", "ld2410-ble==0.1.1"] + "requirements": ["bluetooth-data-tools==1.29.21", "ld2410-ble==0.1.1"] } diff --git a/homeassistant/components/led_ble/manifest.json b/homeassistant/components/led_ble/manifest.json index 6489e7711e3f..1f03d9099426 100644 --- a/homeassistant/components/led_ble/manifest.json +++ b/homeassistant/components/led_ble/manifest.json @@ -36,5 +36,5 @@ "documentation": "https://www.home-assistant.io/integrations/led_ble", "integration_type": "device", "iot_class": "local_polling", - "requirements": ["bluetooth-data-tools==1.29.18", "led-ble==1.1.11"] + "requirements": ["bluetooth-data-tools==1.29.21", "led-ble==1.1.11"] } diff --git a/homeassistant/components/lg_thinq/sensor.py b/homeassistant/components/lg_thinq/sensor.py index c7ed598544cf..54cdfb5903e4 100644 --- a/homeassistant/components/lg_thinq/sensor.py +++ b/homeassistant/components/lg_thinq/sensor.py @@ -747,10 +747,7 @@ class ThinQSensorEntity(ThinQEntity, SensorEntity): value = self.data.value if isinstance(value, time): - # pylint: disable-next=home-assistant-enforce-now - local_now = datetime.now( - tz=dt_util.get_time_zone(self.coordinator.hass.config.time_zone) - ) + local_now = dt_util.now() self._device_state = ( self.coordinator.data[self._device_state_id].value if self._device_state_id in self.coordinator.data @@ -865,10 +862,7 @@ class ThinQEnergySensorEntity(ThinQEntity, SensorEntity): async def _async_update_and_schedule(self) -> None: """Update the state of the sensor.""" - # pylint: disable-next=home-assistant-enforce-now - local_now = datetime.now( - dt_util.get_time_zone(self.coordinator.hass.config.time_zone) - ) + local_now = dt_util.now() next_update = local_now + self.entity_description.update_interval if ( self.coordinator.update_energy_at_time_of_day is not None diff --git a/homeassistant/components/litellm/config_flow.py b/homeassistant/components/litellm/config_flow.py index 0b8df8be1d44..e22fbcce329d 100644 --- a/homeassistant/components/litellm/config_flow.py +++ b/homeassistant/components/litellm/config_flow.py @@ -180,7 +180,7 @@ class ConversationFlowHandler(LiteLLMSubentryFlowHandler): return self.async_abort(reason="entry_not_loaded") if user_input is not None: - if not user_input.get(CONF_LLM_HASS_API): + if user_input.get(CONF_LLM_HASS_API) is None: user_input.pop(CONF_LLM_HASS_API, None) if self._is_new: return self.async_create_entry( diff --git a/homeassistant/components/local_calendar/calendar.py b/homeassistant/components/local_calendar/calendar.py index 76610a984237..cd52e903c016 100644 --- a/homeassistant/components/local_calendar/calendar.py +++ b/homeassistant/components/local_calendar/calendar.py @@ -10,6 +10,7 @@ from ical.calendar_stream import IcsCalendarStream from ical.event import Event from ical.exceptions import CalendarParseError from ical.store import EventStore, EventStoreError +from ical.timeline import Timeline, materialize_timeline from ical.types import Range, Recur import voluptuous as vol @@ -34,6 +35,12 @@ _LOGGER = logging.getLogger(__name__) PRODID = "-//homeassistant.io//local_calendar 1.0//EN" +# Materialize a bounded timeline of upcoming events on every update so the +# state can be recomputed synchronously, without walking recurrence rules in +# the event loop. Mirrors what remote_calendar does. +MAX_LOOKAHEAD_EVENTS = 20 +MAX_LOOKAHEAD_TIME = timedelta(days=365) + async def async_setup_entry( hass: HomeAssistant, @@ -74,7 +81,7 @@ class LocalCalendarEntity(CalendarEntity): self._store = store self._calendar = calendar self._calendar_lock = asyncio.Lock() - self._event: CalendarEvent | None = None + self._timeline: Timeline | None = None self._attr_name = name self._attr_unique_id = unique_id @@ -82,7 +89,12 @@ class LocalCalendarEntity(CalendarEntity): @override def event(self) -> CalendarEvent | None: """Return the next upcoming event.""" - return self._event + if self._timeline is None: + return None + events = self._timeline.active_after(dt_util.now()) + if event := next(events, None): + return _get_calendar_event(event) + return None @override async def async_get_events( @@ -102,14 +114,16 @@ class LocalCalendarEntity(CalendarEntity): async def async_update(self) -> None: """Update entity state with the next upcoming event.""" - def next_event() -> CalendarEvent | None: + def _get_timeline() -> Timeline: now = dt_util.now() - events = self._calendar.timeline_tz(now.tzinfo).active_after(now) - if event := next(events, None): - return _get_calendar_event(event) - return None + return materialize_timeline( + self._calendar.timeline_tz(now.tzinfo), + start=now, + stop=now + MAX_LOOKAHEAD_TIME, + max_number_of_events=MAX_LOOKAHEAD_EVENTS, + ) - self._event = await self.hass.async_add_executor_job(next_event) + self._timeline = await self.hass.async_add_executor_job(_get_timeline) async def _async_store(self) -> None: """Persist the calendar to disk.""" diff --git a/homeassistant/components/local_calendar/diagnostics.py b/homeassistant/components/local_calendar/diagnostics.py index 121da9e65946..0d19acd0e0d9 100644 --- a/homeassistant/components/local_calendar/diagnostics.py +++ b/homeassistant/components/local_calendar/diagnostics.py @@ -1,6 +1,5 @@ """Provides diagnostics for local calendar.""" -import datetime from typing import Any from ical.diagnostics import redact_ics @@ -18,7 +17,7 @@ async def async_get_config_entry_diagnostics( payload: dict[str, Any] = { "now": dt_util.now().isoformat(), "timezone": str(dt_util.get_default_time_zone()), - "system_timezone": str(datetime.datetime.now().astimezone().tzinfo), # pylint: disable=home-assistant-enforce-naive-now + "system_timezone": str(dt_util.naive_now().astimezone().tzinfo), } store = config_entry.runtime_data ics = await store.async_load() diff --git a/homeassistant/components/lunatone/__init__.py b/homeassistant/components/lunatone/__init__.py index dde1f64bb4e3..e6d8d6bde5e2 100644 --- a/homeassistant/components/lunatone/__init__.py +++ b/homeassistant/components/lunatone/__init__.py @@ -81,21 +81,18 @@ async def _update_unique_id( async def async_setup_entry(hass: HomeAssistant, entry: LunatoneConfigEntry) -> bool: """Set up Lunatone from a config entry.""" auth_api = Auth(async_get_clientsession(hass), entry.data[CONF_URL]) - info_api = Info(auth_api) - dali_scan_api = DALIScan(auth_api) - devices_api = Devices(info_api) - sensors_api = Sensors(auth_api) + info_api = Info(auth_api) coordinator_info = LunatoneInfoDataUpdateCoordinator(hass, entry, info_api) await coordinator_info.async_config_entry_first_refresh() - if info_api.data is None or info_api.serial_number is None: + if info_api.data is None: raise ConfigEntryError( translation_domain=DOMAIN, translation_key="missing_device_info" ) - if info_api.uid is not None: - new_unique_id = info_api.uid.replace("-", "") + if info_api.data.uid is not None: + new_unique_id = info_api.data.uid.replace("-", "") if new_unique_id != entry.unique_id: await _update_unique_id(hass, entry, new_unique_id) @@ -105,24 +102,27 @@ async def async_setup_entry(hass: HomeAssistant, entry: LunatoneConfigEntry) -> device_registry.async_get_or_create( config_entry_id=entry.entry_id, identifiers={(DOMAIN, entry.unique_id)}, - name=info_api.name, + name=info_api.data.name, manufacturer=MANUFACTURER, - sw_version=info_api.version, + sw_version=info_api.data.version, hw_version=coordinator_info.data.device.pcb, configuration_url=entry.data[CONF_URL], - serial_number=str(info_api.serial_number), - model=info_api.product_name, + serial_number=str(info_api.data.device.serial), + model=info_api.data.product_name, model_id=( - f"{coordinator_info.data.device.article_number}{coordinator_info.data.device.article_info}" + f"{coordinator_info.data.device.article_number}{coordinator_info.data.article_suffix}" ), ) + devices_api = Devices(auth_api, info_api.data.version) coordinator_devices = LunatoneDevicesDataUpdateCoordinator(hass, entry, devices_api) await coordinator_devices.async_config_entry_first_refresh() + sensors_api = Sensors(auth_api) coordinator_sensors = LunatoneSensorsDataUpdateCoordinator(hass, entry, sensors_api) await coordinator_sensors.async_config_entry_first_refresh() + dali_scan_api = DALIScan(auth_api) coordinator_scan = LunatoneScanDataUpdateCoordinator(hass, entry, dali_scan_api) await coordinator_scan.async_config_entry_first_refresh() diff --git a/homeassistant/components/lunatone/binary_sensor.py b/homeassistant/components/lunatone/binary_sensor.py index c05ef6cc6162..5b9d51bd4415 100644 --- a/homeassistant/components/lunatone/binary_sensor.py +++ b/homeassistant/components/lunatone/binary_sensor.py @@ -62,4 +62,4 @@ class LunatoneDALIScanStatus( @override def is_on(self) -> bool: """Return true if the DALI scan is on.""" - return self.coordinator.dali_scan_api.is_busy + return self.coordinator.data.busy diff --git a/homeassistant/components/lunatone/config_flow.py b/homeassistant/components/lunatone/config_flow.py index 8f74ed828d3e..88f54701e4ef 100644 --- a/homeassistant/components/lunatone/config_flow.py +++ b/homeassistant/components/lunatone/config_flow.py @@ -56,12 +56,12 @@ class LunatoneConfigFlow(ConfigFlow, domain=DOMAIN): except aiohttp.ClientConnectionError: errors["base"] = "cannot_connect" else: - if info_api.serial_number is None: + if info_api.data is None: errors["base"] = "missing_device_info" else: - unique_id = str(info_api.serial_number) - if info_api.uid is not None: - unique_id = info_api.uid.replace("-", "") + unique_id = str(info_api.data.device.serial) + if info_api.data.uid is not None: + unique_id = info_api.data.uid.replace("-", "") await self.async_set_unique_id(unique_id) if self.source == SOURCE_RECONFIGURE: self._abort_if_unique_id_mismatch() diff --git a/homeassistant/components/lunatone/coordinator.py b/homeassistant/components/lunatone/coordinator.py index 6170309d0911..0583e3511a41 100644 --- a/homeassistant/components/lunatone/coordinator.py +++ b/homeassistant/components/lunatone/coordinator.py @@ -113,7 +113,7 @@ class LunatoneDevicesDataUpdateCoordinator(DataUpdateCoordinator[dict[int, Devic if self.devices_api.data is None: raise UpdateFailed("Did not receive devices data from Lunatone REST API") - return {device.id: device for device in self.devices_api.devices} + return {device.data.id: device for device in self.devices_api.devices} class LunatoneSensorsDataUpdateCoordinator(DataUpdateCoordinator[dict[int, Sensor]]): @@ -151,7 +151,7 @@ class LunatoneSensorsDataUpdateCoordinator(DataUpdateCoordinator[dict[int, Senso if self.sensors_api.data is None: raise UpdateFailed("Did not receive sensors data from Lunatone REST API") - return {sensor.id: sensor for sensor in self.sensors_api.sensors} + return {sensor.data.id: sensor for sensor in self.sensors_api.sensors} class LunatoneScanDataUpdateCoordinator(DataUpdateCoordinator[ScanData]): @@ -190,7 +190,7 @@ class LunatoneScanDataUpdateCoordinator(DataUpdateCoordinator[ScanData]): raise UpdateFailed("Did not receive scan data from Lunatone REST API") update_interval = DEFAULT_SCAN_UPDATE_INTERVAL - if self.dali_scan_api.is_busy: + if self.dali_scan_api.data.busy: update_interval = timedelta(seconds=1) self.update_interval = update_interval diff --git a/homeassistant/components/lunatone/light.py b/homeassistant/components/lunatone/light.py index 80f7fedd311e..fefa30226646 100644 --- a/homeassistant/components/lunatone/light.py +++ b/homeassistant/components/lunatone/light.py @@ -97,7 +97,7 @@ class LunatoneLight( assert self.unique_id return DeviceInfo( identifiers={(DOMAIN, self.unique_id)}, - name=self._device.name, + name=self._device.data.name, via_device_id=dr.async_get_device_id_by_identifier( self.hass, ( diff --git a/homeassistant/components/lunatone/manifest.json b/homeassistant/components/lunatone/manifest.json index 3fee384abde5..46fe5cac3105 100644 --- a/homeassistant/components/lunatone/manifest.json +++ b/homeassistant/components/lunatone/manifest.json @@ -7,7 +7,7 @@ "integration_type": "hub", "iot_class": "local_polling", "quality_scale": "silver", - "requirements": ["lunatone-rest-api-client==0.9.2"], + "requirements": ["lunatone-rest-api-client==0.10.0"], "zeroconf": [ { "properties": { diff --git a/homeassistant/components/lunatone/sensor.py b/homeassistant/components/lunatone/sensor.py index 0335a848c1e3..7ec5903e52af 100644 --- a/homeassistant/components/lunatone/sensor.py +++ b/homeassistant/components/lunatone/sensor.py @@ -112,7 +112,7 @@ class LunatoneSensor( self._config_entry_unique_id = config_entry_unique_id self._sensor_id = sensor_id - self._attr_name = self.sensor.name + self._attr_name = self.sensor.data.name self._attr_unique_id = ( f"{config_entry_unique_id}-sensor{sensor_id}-{description.key}" ) diff --git a/homeassistant/components/lutron_caseta/__init__.py b/homeassistant/components/lutron_caseta/__init__.py index fb996fe60014..23f9b809f493 100644 --- a/homeassistant/components/lutron_caseta/__init__.py +++ b/homeassistant/components/lutron_caseta/__init__.py @@ -243,15 +243,7 @@ def _async_register_bridge_device( if area != UNASSIGNED_AREA: device_args["suggested_area"] = area - device = device_registry.async_get_or_create( - **device_args, config_entry_id=config_entry_id - ) - if device.via_device_id is not None: - # Existing installations may still have the bridge device linked to - # itself via via_device_id, from when it was (incorrectly) registered - # as its own via device. Clear it explicitly since async_get_or_create - # above leaves via_device_id untouched when it's not passed. - device_registry.async_update_device(device.id, via_device_id=None) + device_registry.async_get_or_create(**device_args, config_entry_id=config_entry_id) @callback diff --git a/homeassistant/components/lyngdorf/config_flow.py b/homeassistant/components/lyngdorf/config_flow.py index f06633f16cdd..94366cc67b62 100644 --- a/homeassistant/components/lyngdorf/config_flow.py +++ b/homeassistant/components/lyngdorf/config_flow.py @@ -4,6 +4,7 @@ import logging from typing import Any, override from urllib.parse import urlparse +from lyngdorf.const import LyngdorfModel from lyngdorf.device import ( async_find_receiver_model, async_get_device_serial, @@ -54,31 +55,26 @@ class LyngdorfFlowHandler(ConfigFlow, domain=DOMAIN): if user_input is not None: self._host = user_input[CONF_HOST] - try: - model = await async_find_receiver_model(self._host) - except TimeoutError: + model, serial = await self._async_probe(self._host) + except TimeoutConnect: errors["base"] = "timeout_connect" - except OSError: + except CannotConnect: errors["base"] = "cannot_connect" - except Exception: # noqa: BLE001 - errors["base"] = "unknown" - - if not errors and not model: + except UnsupportedModel: errors["base"] = "unsupported_model" - - if not errors and model: + except CannotDetermineId: + errors["base"] = "cannot_determine_id" + except Exception: + _LOGGER.exception("Unexpected exception") + errors["base"] = "unknown" + else: self._device_model = model.model_name self._name = model.model_name - - serial = await async_get_device_serial(self._host) - if not serial: - errors["base"] = "cannot_determine_id" - else: - self._device_serial_number = serial.lower() - await self.async_set_unique_id(self._device_serial_number) - self._abort_if_unique_id_configured() - return await self._create_entry() + self._device_serial_number = serial + await self.async_set_unique_id(serial) + self._abort_if_unique_id_configured() + return await self._create_entry() return self.async_show_form( step_id="user", @@ -90,6 +86,76 @@ class LyngdorfFlowHandler(ConfigFlow, domain=DOMAIN): errors=errors, ) + async def _async_probe(self, host: str) -> tuple[LyngdorfModel, str]: + """Return the model and serial of the device at a host.""" + try: + model = await async_find_receiver_model(host) + except TimeoutError as err: + raise TimeoutConnect from err + except OSError as err: + raise CannotConnect from err + if not model: + raise UnsupportedModel + + try: + serial = await async_get_device_serial(host) + except TimeoutError as err: + raise TimeoutConnect from err + except OSError as err: + raise CannotConnect from err + if not serial: + raise CannotDetermineId + + return model, serial.lower() + + async def async_step_reconfigure( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle reconfiguration of an existing entry. + + SSDP rediscovery only recovers a changed address while the device is + still announcing somewhere Home Assistant can hear it, which a move to + a static address or another subnet can end. + """ + errors: dict[str, str] = {} + reconfigure_entry = self._get_reconfigure_entry() + + if user_input is not None: + host = user_input[CONF_HOST] + try: + model, serial = await self._async_probe(host) + except TimeoutConnect: + errors["base"] = "timeout_connect" + except CannotConnect: + errors["base"] = "cannot_connect" + except UnsupportedModel: + errors["base"] = "unsupported_model" + except CannotDetermineId: + errors["base"] = "cannot_determine_id" + except Exception: + _LOGGER.exception("Unexpected exception") + errors["base"] = "unknown" + else: + await self.async_set_unique_id(serial) + self._abort_if_unique_id_mismatch() + return self.async_update_reload_and_abort( + reconfigure_entry, + data_updates={ + CONF_HOST: host, + CONF_MODEL: model.model_name, + CONF_SERIAL_NUMBER: serial, + }, + ) + + return self.async_show_form( + step_id="reconfigure", + data_schema=self.add_suggested_values_to_schema( + vol.Schema({vol.Required(CONF_HOST): cv.string}), + reconfigure_entry.data, + ), + errors=errors, + ) + @override async def async_step_ssdp( self, discovery_info: SsdpServiceInfo @@ -181,3 +247,19 @@ class LyngdorfFlowHandler(ConfigFlow, domain=DOMAIN): raise AbortFlow("cannot_determine_id") await self.async_set_unique_id(self._device_serial_number) self._abort_if_unique_id_configured(updates={CONF_HOST: self._host}) + + +class CannotConnect(Exception): + """Error to indicate we cannot connect.""" + + +class TimeoutConnect(Exception): + """Error to indicate the device did not answer in time.""" + + +class UnsupportedModel(Exception): + """Error to indicate the device is not a model we support.""" + + +class CannotDetermineId(Exception): + """Error to indicate the device did not report a serial.""" diff --git a/homeassistant/components/lyngdorf/const.py b/homeassistant/components/lyngdorf/const.py index 47a5cd018126..753f8a4560db 100644 --- a/homeassistant/components/lyngdorf/const.py +++ b/homeassistant/components/lyngdorf/const.py @@ -7,6 +7,7 @@ DEFAULT_DEVICE_NAME = "Lyngdorf" PLATFORMS: list[Platform] = [ Platform.MEDIA_PLAYER, + Platform.NUMBER, Platform.SENSOR, ] CONF_SERIAL_NUMBER = "serial_number" diff --git a/homeassistant/components/lyngdorf/diagnostics.py b/homeassistant/components/lyngdorf/diagnostics.py index b46987114525..701bdfa9c6ef 100644 --- a/homeassistant/components/lyngdorf/diagnostics.py +++ b/homeassistant/components/lyngdorf/diagnostics.py @@ -59,7 +59,7 @@ async def async_get_config_entry_diagnostics( state: dict[str, Any] = { "connected": receiver.connected, - "model": receiver.model.name if receiver.model else None, + "model": receiver.model.name, "power_on": receiver.power_on, "volume": receiver.volume, "max_volume": receiver.max_volume, diff --git a/homeassistant/components/lyngdorf/icons.json b/homeassistant/components/lyngdorf/icons.json index 4120b60053eb..5e6ba372381b 100644 --- a/homeassistant/components/lyngdorf/icons.json +++ b/homeassistant/components/lyngdorf/icons.json @@ -1,5 +1,25 @@ { "entity": { + "number": { + "trim_bass": { + "default": "mdi:music-clef-bass" + }, + "trim_centre": { + "default": "mdi:speaker" + }, + "trim_height": { + "default": "mdi:arrow-expand-up" + }, + "trim_lfe": { + "default": "mdi:sine-wave" + }, + "trim_surround": { + "default": "mdi:surround-sound" + }, + "trim_treble": { + "default": "mdi:music-clef-treble" + } + }, "sensor": { "audio_information": { "default": "mdi:surround-sound" diff --git a/homeassistant/components/lyngdorf/manifest.json b/homeassistant/components/lyngdorf/manifest.json index 6d69099b5e8a..91b800d6aeb0 100644 --- a/homeassistant/components/lyngdorf/manifest.json +++ b/homeassistant/components/lyngdorf/manifest.json @@ -9,7 +9,7 @@ "iot_class": "local_push", "loggers": ["lyngdorf", "async_upnp_client"], "quality_scale": "silver", - "requirements": ["lyngdorf==1.8.0"], + "requirements": ["lyngdorf==1.10.0"], "ssdp": [ { "deviceType": "urn:schemas-upnp-org:device:MediaRenderer:2", diff --git a/homeassistant/components/lyngdorf/media_player.py b/homeassistant/components/lyngdorf/media_player.py index fa0315329d47..7bcb955d996d 100644 --- a/homeassistant/components/lyngdorf/media_player.py +++ b/homeassistant/components/lyngdorf/media_player.py @@ -1,16 +1,22 @@ """Media player platform for Lyngdorf integration.""" -from typing import override +from datetime import datetime +from typing import TYPE_CHECKING, override from lyngdorf.device import Receiver +from lyngdorf.models.base import NumericRange +from lyngdorf.states import Control, PlaybackState, Repeat +from lyngdorf.streaming import NowPlaying from homeassistant.components.media_player import ( MediaPlayerDeviceClass, MediaPlayerEntity, MediaPlayerEntityFeature, MediaPlayerState, + MediaType, + RepeatMode, ) -from homeassistant.core import HomeAssistant +from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback @@ -19,10 +25,6 @@ from .models import LyngdorfConfigEntry PARALLEL_UPDATES = 1 -MAX_VOLUME_DB = 18.0 -MIN_VOLUME_DB = -80.0 -VOLUME_RANGE = MAX_VOLUME_DB - MIN_VOLUME_DB - FEATURES_ZONE_B = ( MediaPlayerEntityFeature.VOLUME_STEP | MediaPlayerEntityFeature.VOLUME_SET @@ -42,6 +44,31 @@ FEATURES_MAIN = ( | MediaPlayerEntityFeature.SELECT_SOURCE ) +# The streaming module advertises transport per source and it changes at +# runtime, so these are added to FEATURES_MAIN only while the device offers +# them: AirPlay has no seek, a stopped device offers nothing at all. +CONTROL_FEATURES: tuple[tuple[Control, MediaPlayerEntityFeature], ...] = ( + (Control.PAUSE, MediaPlayerEntityFeature.PAUSE), + (Control.NEXT_TRACK, MediaPlayerEntityFeature.NEXT_TRACK), + (Control.PREVIOUS_TRACK, MediaPlayerEntityFeature.PREVIOUS_TRACK), + (Control.SEEK, MediaPlayerEntityFeature.SEEK), +) + +REPEAT_MODES: dict[Repeat, RepeatMode] = { + Repeat.OFF: RepeatMode.OFF, + Repeat.ONE: RepeatMode.ONE, + Repeat.ALL: RepeatMode.ALL, +} + +LYNGDORF_REPEATS: dict[RepeatMode, Repeat] = {v: k for k, v in REPEAT_MODES.items()} + +PLAYBACK_STATES: dict[PlaybackState, MediaPlayerState] = { + PlaybackState.PLAYING: MediaPlayerState.PLAYING, + PlaybackState.PAUSED: MediaPlayerState.PAUSED, + PlaybackState.STOPPED: MediaPlayerState.IDLE, + PlaybackState.TRANSITIONING: MediaPlayerState.BUFFERING, +} + async def async_setup_entry( hass: HomeAssistant, @@ -66,16 +93,17 @@ async def async_setup_entry( async_add_entities(entities) -def _to_ha_volume(volume_db: float) -> float: +def _to_ha_volume(volume_db: float, volume_range: NumericRange) -> float: """Convert Lyngdorf dB volume to HA 0..1 scale, clamped to 0..1.""" - volume = (volume_db - MIN_VOLUME_DB) / VOLUME_RANGE - return max(0.0, min(volume, 1.0)) + span = volume_range.max - volume_range.min + return max(0.0, min((volume_db - volume_range.min) / span, 1.0)) -def _to_lyngdorf_volume(volume: float) -> float: +def _to_lyngdorf_volume(volume: float, volume_range: NumericRange) -> float: """Convert HA 0..1 volume to Lyngdorf dB scale, clamped to min and max.""" - volume_db = volume * VOLUME_RANGE + MIN_VOLUME_DB - return max(MIN_VOLUME_DB, min(volume_db, MAX_VOLUME_DB)) + span = volume_range.max - volume_range.min + volume_db = volume * span + volume_range.min + return max(volume_range.min, min(volume_db, volume_range.max)) class LyngdorfDevice(LyngdorfEntity, MediaPlayerEntity): @@ -90,19 +118,20 @@ class LyngdorfDevice(LyngdorfEntity, MediaPlayerEntity): device_info: DeviceInfo, translation_key: str | None, entity_id_suffix: str, - features: MediaPlayerEntityFeature = MediaPlayerEntityFeature(0), ) -> None: """Initialize the device.""" super().__init__(receiver, device_info) - assert config_entry.unique_id + if TYPE_CHECKING: + assert config_entry.unique_id self._attr_unique_id = f"{config_entry.unique_id}_{entity_id_suffix}" self._attr_translation_key = translation_key - self._attr_supported_features = features class LyngdorfZoneBDevice(LyngdorfDevice): """Lyngdorf Zone B device.""" + _attr_supported_features = FEATURES_ZONE_B + def __init__( self, receiver: Receiver, @@ -116,7 +145,6 @@ class LyngdorfZoneBDevice(LyngdorfDevice): device_info, None, "zone_b", - FEATURES_ZONE_B, ) @override @@ -133,13 +161,22 @@ class LyngdorfZoneBDevice(LyngdorfDevice): """Return boolean if volume is currently muted.""" return self._receiver.zone_b_mute_enabled + @property + def _volume_range(self) -> NumericRange: + """Return the model's documented Zone B volume range.""" + volume_range = self._receiver.zone_b_volume_range + # This entity is only created for models that have a Zone B. + if TYPE_CHECKING: + assert volume_range is not None + return volume_range + @override @property def volume_level(self) -> float | None: """Volume level of the media player (0..1).""" - if not isinstance(self._receiver.zone_b_volume, float): + if (volume := self._receiver.zone_b_volume) is None: return None - return _to_ha_volume(self._receiver.zone_b_volume) + return _to_ha_volume(volume, self._volume_range) @override async def async_turn_on(self) -> None: @@ -164,7 +201,9 @@ class LyngdorfZoneBDevice(LyngdorfDevice): @override async def async_set_volume_level(self, volume: float) -> None: """Set volume level, range 0..1.""" - self._receiver.zone_b_volume = _to_lyngdorf_volume(volume) + self._receiver.set_zone_b_volume( + _to_lyngdorf_volume(volume, self._volume_range) + ) @override async def async_mute_volume(self, mute: bool) -> None: @@ -205,16 +244,169 @@ class LyngdorfMainDevice(LyngdorfDevice): device_info, "main_zone", "main_zone", - FEATURES_MAIN, ) + @override + async def async_added_to_hass(self) -> None: + """Subscribe to position discontinuities.""" + # The jump callback fires on a seek, track change, play/pause or + # drift, rather than once a second, which is all Home Assistant + # needs: it stores a position and a timestamp and extrapolates. + await super().async_added_to_hass() + if self._has_streamer: + self.async_on_remove( + self._receiver.register_position_jump_callback(self._handle_position) + ) + + @callback + def _handle_position(self, _position_ms: int | None) -> None: + """Handle a position discontinuity.""" + self.async_write_ha_state() + + @property + def _has_streamer(self) -> bool: + """Return whether this model has a streaming module at all.""" + return self._receiver.model.has_streaming_feature() + + @property + def _now_playing(self) -> NowPlaying | None: + """Return the current track, or None if this model has no streamer.""" + if not self._has_streamer: + return None + return self._receiver.now_playing + + @override + @property + def supported_features(self) -> MediaPlayerEntityFeature: + """Return the features the device currently offers.""" + features = FEATURES_MAIN + if (now_playing := self._now_playing) is None: + return features + + for control, feature in CONTROL_FEATURES: + if control in now_playing.controls: + features |= feature + if self._receiver.can_shuffle: + features |= MediaPlayerEntityFeature.SHUFFLE_SET + if self._receiver.available_repeat_modes: + features |= MediaPlayerEntityFeature.REPEAT_SET + return features + @override @property def state(self) -> MediaPlayerState | None: """Return the state of the device.""" - if self._receiver.power_on: - return MediaPlayerState.ON - return MediaPlayerState.OFF + if not self._receiver.power_on: + return MediaPlayerState.OFF + if (now_playing := self._now_playing) is not None: + if (state := PLAYBACK_STATES.get(now_playing.state)) is not None: + return state + return MediaPlayerState.ON + + @override + @property + def media_content_type(self) -> MediaType | None: + """Return the type of media currently playing.""" + if self._now_playing is None: + return None + return MediaType.MUSIC + + @override + @property + def media_title(self) -> str | None: + """Return the title of the current track.""" + return now_playing.title if (now_playing := self._now_playing) else None + + @override + @property + def media_artist(self) -> str | None: + """Return the artist of the current track.""" + return now_playing.artist if (now_playing := self._now_playing) else None + + @override + @property + def media_album_name(self) -> str | None: + """Return the album of the current track.""" + return now_playing.album if (now_playing := self._now_playing) else None + + @override + @property + def media_image_url(self) -> str | None: + """Return the album art of the current track.""" + return now_playing.art_url if (now_playing := self._now_playing) else None + + @override + @property + def media_duration(self) -> int | None: + """Return the duration of the current track, in seconds.""" + if ( + now_playing := self._now_playing + ) is None or now_playing.duration_ms is None: + return None + return round(now_playing.duration_ms / 1000) + + @override + @property + def media_position(self) -> int | None: + """Return the position of the current track, in seconds.""" + if not self._has_streamer or not self._receiver.has_position: + return None + return round(self._receiver.position_ms / 1000) + + @override + @property + def media_position_updated_at(self) -> datetime | None: + """Return when the position was last valid.""" + if not self._has_streamer or not self._receiver.has_position: + return None + return self._receiver.position_updated_at + + @override + @property + def shuffle(self) -> bool | None: + """Return whether shuffle is enabled.""" + return self._receiver.shuffle if self._has_streamer else None + + @override + @property + def repeat(self) -> RepeatMode | None: + """Return the current repeat mode.""" + if not self._has_streamer or (repeat := self._receiver.repeat) is None: + return None + return REPEAT_MODES.get(repeat) + + @override + async def async_media_pause(self) -> None: + """Pause playback.""" + # On a controller-driven source such as AirPlay the device ends the + # session rather than pausing, and only the controlling app can + # start it again. + await self._receiver.async_pause() + + @override + async def async_media_next_track(self) -> None: + """Skip to the next track.""" + await self._receiver.async_next() + + @override + async def async_media_previous_track(self) -> None: + """Skip to the previous track.""" + await self._receiver.async_previous() + + @override + async def async_media_seek(self, position: float) -> None: + """Seek to a position, given in seconds.""" + await self._receiver.async_seek(round(position * 1000)) + + @override + async def async_set_shuffle(self, shuffle: bool) -> None: + """Enable or disable shuffle, leaving the repeat mode alone.""" + await self._receiver.async_set_shuffle(shuffle) + + @override + async def async_set_repeat(self, repeat: RepeatMode) -> None: + """Set the repeat mode, leaving shuffle alone.""" + await self._receiver.async_set_repeat(LYNGDORF_REPEATS[repeat]) @override @property @@ -234,13 +426,22 @@ class LyngdorfMainDevice(LyngdorfDevice): """Return boolean if volume is currently muted.""" return self._receiver.mute_enabled + @property + def _volume_range(self) -> NumericRange: + """Return the model's documented main-zone volume range.""" + volume_range = self._receiver.volume_range + # Every supported model documents a main-zone volume range. + if TYPE_CHECKING: + assert volume_range is not None + return volume_range + @override @property def volume_level(self) -> float | None: """Volume level of the media player (0..1).""" - if not isinstance(self._receiver.volume, float): + if (volume := self._receiver.volume) is None: return None - return _to_ha_volume(self._receiver.volume) + return _to_ha_volume(volume, self._volume_range) @override @property @@ -277,7 +478,7 @@ class LyngdorfMainDevice(LyngdorfDevice): @override async def async_set_volume_level(self, volume: float) -> None: """Set volume level, range 0..1.""" - self._receiver.volume = _to_lyngdorf_volume(volume) + self._receiver.set_volume(_to_lyngdorf_volume(volume, self._volume_range)) @override async def async_mute_volume(self, mute: bool) -> None: diff --git a/homeassistant/components/lyngdorf/number.py b/homeassistant/components/lyngdorf/number.py new file mode 100644 index 000000000000..cdf186a032e7 --- /dev/null +++ b/homeassistant/components/lyngdorf/number.py @@ -0,0 +1,180 @@ +"""Number platform for Lyngdorf integration.""" + +from collections.abc import Callable +from dataclasses import dataclass +from typing import TYPE_CHECKING, override + +from lyngdorf.device import Receiver +from lyngdorf.models.base import NumericRange + +from homeassistant.components.number import ( + NumberDeviceClass, + NumberEntity, + NumberEntityDescription, +) +from homeassistant.const import EntityCategory, UnitOfSoundPressure, UnitOfTime +from homeassistant.core import HomeAssistant +from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from .entity import LyngdorfEntity +from .models import LyngdorfConfigEntry + +PARALLEL_UPDATES = 1 + + +@dataclass(frozen=True, kw_only=True) +class LyngdorfNumberEntityDescription(NumberEntityDescription): + """Describe a Lyngdorf number entity.""" + + value_fn: Callable[[Receiver], float | None] + set_value_fn: Callable[[Receiver, float], None] + range_fn: Callable[[Receiver], NumericRange | None] + + +NUMBER_ENTITIES: tuple[LyngdorfNumberEntityDescription, ...] = ( + LyngdorfNumberEntityDescription( + key="lipsync", + translation_key="lipsync", + device_class=NumberDeviceClass.DURATION, + native_unit_of_measurement=UnitOfTime.MILLISECONDS, + entity_category=EntityCategory.CONFIG, + value_fn=lambda r: r.lipsync, + # The device takes lip sync as whole milliseconds. + set_value_fn=lambda r, v: r.set_lipsync(round(v)), + range_fn=lambda r: r.lipsync_range, + ), + LyngdorfNumberEntityDescription( + key="trim_bass", + translation_key="trim_bass", + native_unit_of_measurement=UnitOfSoundPressure.DECIBEL, + entity_category=EntityCategory.CONFIG, + value_fn=lambda r: r.trim_bass, + set_value_fn=lambda r, v: r.set_trim_bass(v), + range_fn=lambda r: r.trim_bass_range, + ), + LyngdorfNumberEntityDescription( + key="trim_treble", + translation_key="trim_treble", + native_unit_of_measurement=UnitOfSoundPressure.DECIBEL, + entity_category=EntityCategory.CONFIG, + value_fn=lambda r: r.trim_treble, + set_value_fn=lambda r, v: r.set_trim_treble(v), + range_fn=lambda r: r.trim_treble_range, + ), + LyngdorfNumberEntityDescription( + key="trim_centre", + translation_key="trim_centre", + entity_registry_enabled_default=False, + native_unit_of_measurement=UnitOfSoundPressure.DECIBEL, + entity_category=EntityCategory.CONFIG, + value_fn=lambda r: r.trim_centre, + set_value_fn=lambda r, v: r.set_trim_centre(v), + range_fn=lambda r: r.trim_centre_range, + ), + LyngdorfNumberEntityDescription( + key="trim_height", + translation_key="trim_height", + entity_registry_enabled_default=False, + native_unit_of_measurement=UnitOfSoundPressure.DECIBEL, + entity_category=EntityCategory.CONFIG, + value_fn=lambda r: r.trim_height, + set_value_fn=lambda r, v: r.set_trim_height(v), + range_fn=lambda r: r.trim_height_range, + ), + LyngdorfNumberEntityDescription( + key="trim_lfe", + translation_key="trim_lfe", + entity_registry_enabled_default=False, + native_unit_of_measurement=UnitOfSoundPressure.DECIBEL, + entity_category=EntityCategory.CONFIG, + value_fn=lambda r: r.trim_lfe, + set_value_fn=lambda r, v: r.set_trim_lfe(v), + range_fn=lambda r: r.trim_lfe_range, + ), + LyngdorfNumberEntityDescription( + key="trim_surround", + translation_key="trim_surround", + entity_registry_enabled_default=False, + native_unit_of_measurement=UnitOfSoundPressure.DECIBEL, + entity_category=EntityCategory.CONFIG, + value_fn=lambda r: r.trim_surround, + set_value_fn=lambda r, v: r.set_trim_surround(v), + range_fn=lambda r: r.trim_surround_range, + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: LyngdorfConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up Lyngdorf number entities from a config entry.""" + runtime_data = config_entry.runtime_data + receiver = runtime_data.receiver + + # A None range means the model has no such control at all. + async_add_entities( + LyngdorfNumber(receiver, config_entry, runtime_data.device_info, description) + for description in NUMBER_ENTITIES + if description.range_fn(receiver) is not None + ) + + +class LyngdorfNumber(LyngdorfEntity, NumberEntity): + """Lyngdorf number entity.""" + + entity_description: LyngdorfNumberEntityDescription + + def __init__( + self, + receiver: Receiver, + config_entry: LyngdorfConfigEntry, + device_info: DeviceInfo, + description: LyngdorfNumberEntityDescription, + ) -> None: + """Initialize the number entity.""" + super().__init__(receiver, device_info) + if TYPE_CHECKING: + assert config_entry.unique_id + self.entity_description = description + self._attr_unique_id = f"{config_entry.unique_id}_{description.key}" + + @property + def _range(self) -> NumericRange: + """Return the device's range for this setting.""" + device_range = self.entity_description.range_fn(self._receiver) + # Entities are only created for controls the model actually has. + if TYPE_CHECKING: + assert device_range is not None + return device_range + + @override + @property + def native_min_value(self) -> float: + """Return the minimum value the device accepts.""" + return self._range.min + + @override + @property + def native_max_value(self) -> float: + """Return the maximum value the device accepts.""" + return self._range.max + + @override + @property + def native_step(self) -> float: + """Return the step the device resolves.""" + return self._range.step + + @override + @property + def native_value(self) -> float | None: + """Return the current value.""" + return self.entity_description.value_fn(self._receiver) + + @override + async def async_set_native_value(self, value: float) -> None: + """Set the value.""" + self.entity_description.set_value_fn(self._receiver, value) diff --git a/homeassistant/components/lyngdorf/quality_scale.yaml b/homeassistant/components/lyngdorf/quality_scale.yaml index 2974ff508323..c20a592b33a6 100644 --- a/homeassistant/components/lyngdorf/quality_scale.yaml +++ b/homeassistant/components/lyngdorf/quality_scale.yaml @@ -74,7 +74,7 @@ rules: entity-translations: done exception-translations: done icon-translations: done - reconfiguration-flow: todo + reconfiguration-flow: done repair-issues: status: exempt comment: No repair issues needed. diff --git a/homeassistant/components/lyngdorf/strings.json b/homeassistant/components/lyngdorf/strings.json index f28652c7fe23..6d314636f59d 100644 --- a/homeassistant/components/lyngdorf/strings.json +++ b/homeassistant/components/lyngdorf/strings.json @@ -5,6 +5,8 @@ "already_in_progress": "[%key:common::config_flow::abort::already_in_progress%]", "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", "cannot_determine_id": "[%key:component::lyngdorf::config::error::cannot_determine_id%]", + "reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]", + "unique_id_mismatch": "The device at this address is a different Lyngdorf device from the one this entry was set up with.", "unsupported_model": "This Lyngdorf model is not supported" }, "error": { @@ -19,6 +21,16 @@ "confirm": { "description": "Do you want to set up **{name}**?" }, + "reconfigure": { + "data": { + "host": "[%key:common::config_flow::data::host%]" + }, + "data_description": { + "host": "[%key:component::lyngdorf::config::step::user::data_description::host%]" + }, + "description": "Update the address Home Assistant uses to reach this device. It must be the same device; a different one will be rejected.", + "title": "[%key:component::lyngdorf::config::step::user::title%]" + }, "user": { "data": { "host": "[%key:common::config_flow::data::host%]" @@ -41,6 +53,29 @@ "name": "Main zone" } }, + "number": { + "lipsync": { + "name": "Lip sync" + }, + "trim_bass": { + "name": "Trim bass" + }, + "trim_centre": { + "name": "Trim centre" + }, + "trim_height": { + "name": "Trim height" + }, + "trim_lfe": { + "name": "Trim LFE" + }, + "trim_surround": { + "name": "Trim surround" + }, + "trim_treble": { + "name": "Trim treble" + } + }, "sensor": { "audio_information": { "name": "Audio information" diff --git a/homeassistant/components/monzo/sensor.py b/homeassistant/components/monzo/sensor.py index 9f9290157b2a..dbf785f412d3 100644 --- a/homeassistant/components/monzo/sensor.py +++ b/homeassistant/components/monzo/sensor.py @@ -41,6 +41,13 @@ ACCOUNT_SENSORS = ( device_class=SensorDeviceClass.MONETARY, suggested_display_precision=2, ), + MonzoSensorEntityDescription( + key="spend_today", + translation_key="spend_today", + value_fn=lambda data: abs(data["balance"]["spend_today"]) / 100, + device_class=SensorDeviceClass.MONETARY, + suggested_display_precision=2, + ), ) POT_SENSORS = ( diff --git a/homeassistant/components/monzo/strings.json b/homeassistant/components/monzo/strings.json index 9aecc53fa2da..99616320ce00 100644 --- a/homeassistant/components/monzo/strings.json +++ b/homeassistant/components/monzo/strings.json @@ -57,6 +57,9 @@ "pot_balance": { "name": "[%key:component::monzo::entity::sensor::balance::name%]" }, + "spend_today": { + "name": "Spent today" + }, "total_balance": { "name": "Total balance" } diff --git a/homeassistant/components/motion_blinds/const.py b/homeassistant/components/motion_blinds/const.py index 1d151a1e63bc..e95a389c8c9d 100644 --- a/homeassistant/components/motion_blinds/const.py +++ b/homeassistant/components/motion_blinds/const.py @@ -29,5 +29,6 @@ SERVICE_SET_ABSOLUTE_POSITION = "set_absolute_position" UPDATE_INTERVAL = 600 UPDATE_INTERVAL_FAST = 60 UPDATE_DELAY_STOP = 3 +UPDATE_DELAY_BLIND = 1.5 UPDATE_INTERVAL_MOVING = 5 UPDATE_INTERVAL_MOVING_WIFI = 45 diff --git a/homeassistant/components/motion_blinds/coordinator.py b/homeassistant/components/motion_blinds/coordinator.py index abc65871ae00..e1fe61526d55 100644 --- a/homeassistant/components/motion_blinds/coordinator.py +++ b/homeassistant/components/motion_blinds/coordinator.py @@ -16,6 +16,7 @@ from .const import ( CONF_WAIT_FOR_PUSH, DEFAULT_WAIT_FOR_PUSH, KEY_GATEWAY, + UPDATE_DELAY_BLIND, UPDATE_INTERVAL, UPDATE_INTERVAL_FAST, ) @@ -89,7 +90,7 @@ class DataUpdateCoordinatorMotionBlinds(DataUpdateCoordinator): ) for blind in self.gateway.device_list.values(): - await asyncio.sleep(1.5) + await asyncio.sleep(UPDATE_DELAY_BLIND) async with self.api_lock: data[blind.mac] = await self.hass.async_add_executor_job( self.update_blind, blind diff --git a/homeassistant/components/mqtt/util.py b/homeassistant/components/mqtt/util.py index 557301ea9f98..f4f4fe4caf20 100644 --- a/homeassistant/components/mqtt/util.py +++ b/homeassistant/components/mqtt/util.py @@ -441,7 +441,6 @@ async def async_cleanup_device_registry( entity_registry = er.async_get(hass) if ( device_id - and device_id not in device_registry.deleted_devices and config_entry_id and (device := device_registry.async_get(device_id)) is not None # Only remove the device if it is owned by the MQTT config entry diff --git a/homeassistant/components/music_assistant/helpers.py b/homeassistant/components/music_assistant/helpers.py index ab8269d4f4bd..1321c9edf57e 100644 --- a/homeassistant/components/music_assistant/helpers.py +++ b/homeassistant/components/music_assistant/helpers.py @@ -1,11 +1,11 @@ """Helpers for the Music Assistant integration.""" -from collections.abc import Callable, Coroutine +from collections.abc import Callable, Coroutine, Generator +from contextlib import contextmanager import functools from typing import TYPE_CHECKING, Any -from music_assistant_models.auth import UserRole -from music_assistant_models.errors import MusicAssistantError +from music_assistant_models.errors import MusicAssistantError, UserNotFoundError from homeassistant.config_entries import ConfigEntryState from homeassistant.core import HomeAssistant, callback @@ -36,6 +36,22 @@ def catch_musicassistant_error[**_P, _R]( return wrapper +@contextmanager +def catch_user_not_found(username: str | None) -> Generator[None]: + """Convert a server UserNotFoundError into a translated invalid_username error.""" + if username is None: + yield + return + try: + yield + except UserNotFoundError as err: + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="invalid_username", + translation_placeholders={"username": username}, + ) from err + + @callback def get_music_assistant_client( hass: HomeAssistant, config_entry_id: str @@ -47,46 +63,3 @@ def get_music_assistant_client( if entry.state is not ConfigEntryState.LOADED: raise ServiceValidationError("Entry not loaded") return entry.runtime_data.mass - - -async def _async_get_available_mass_usernames(mass: MusicAssistantClient) -> list[str]: - """Get available Music Assistant usernames which can be used in Home Assistant.""" - users = await mass.auth.list_users() - return [ - user.username for user in users if user.enabled and user.role != UserRole.GUEST - ] - - -async def async_resolve_mass_username( - hass: HomeAssistant, mass: MusicAssistantClient, user_id: str -) -> str | None: - """Resolve the Music Assistant username for the Home Assistant user.""" - available_usernames = await _async_get_available_mass_usernames(mass) - if (user := await hass.auth.async_get_user(user_id)) is None: - return None - for cred in user.credentials: - if cred.auth_provider_type == "homeassistant": - username: str = cred.data["username"] - break - else: - return None - username = username.strip().lower() - if username in available_usernames: - return username - return None - - -async def async_verify_mass_username_availability( - mass: MusicAssistantClient, username: str -) -> None: - """Verify Music Assistant username availability for service calls.""" - available_usernames = await _async_get_available_mass_usernames(mass) - if username not in available_usernames: - raise ServiceValidationError( - translation_domain=DOMAIN, - translation_key="invalid_username", - translation_placeholders={ - "username": username, - "available_usernames": ", ".join(available_usernames), - }, - ) diff --git a/homeassistant/components/music_assistant/manifest.json b/homeassistant/components/music_assistant/manifest.json index 9baa8ea1735f..48258e5ea9a2 100644 --- a/homeassistant/components/music_assistant/manifest.json +++ b/homeassistant/components/music_assistant/manifest.json @@ -10,6 +10,6 @@ "iot_class": "local_push", "loggers": ["music_assistant"], "quality_scale": "bronze", - "requirements": ["music-assistant-client==1.4.3"], + "requirements": ["music-assistant-client==1.5.1"], "zeroconf": ["_mass._tcp.local."] } diff --git a/homeassistant/components/music_assistant/media_player.py b/homeassistant/components/music_assistant/media_player.py index 83aa62bc1b21..01a80d163d8a 100644 --- a/homeassistant/components/music_assistant/media_player.py +++ b/homeassistant/components/music_assistant/media_player.py @@ -6,6 +6,8 @@ from contextlib import suppress import os from typing import TYPE_CHECKING, Any, override +from music_assistant_client.helpers import LinkedUser +from music_assistant_models.auth import AuthProviderType from music_assistant_models.constants import PLAYER_CONTROL_NONE from music_assistant_models.enums import ( EventType, @@ -18,7 +20,7 @@ from music_assistant_models.enums import ( ) from music_assistant_models.errors import MediaNotFoundError from music_assistant_models.event import MassEvent -from music_assistant_models.media_items import ItemMapping, MediaItemType, Track +from music_assistant_models.media_items import ItemMapping, MediaItemType from music_assistant_models.player_queue import PlayerQueue from homeassistant.components import media_source @@ -60,11 +62,7 @@ from .const import ( DOMAIN, ) from .entity import MusicAssistantEntity -from .helpers import ( - async_resolve_mass_username, - async_verify_mass_username_availability, - catch_musicassistant_error, -) +from .helpers import catch_musicassistant_error, catch_user_not_found from .media_browser import async_browse_media, async_search_media from .schemas import QUEUE_DETAILS_SCHEMA, queue_item_dict_from_mass_item @@ -141,7 +139,6 @@ class MusicAssistantPlayer(MusicAssistantEntity, MediaPlayerEntity): self._attr_icon = self.player.icon.replace("mdi-", "mdi:") self._set_supported_features() self._attr_device_class = MediaPlayerDeviceClass.SPEAKER - self._prev_time: float = 0 self._source_list_mapping: dict[str, str] = {} self._sound_mode_list_mapping: dict[str, str] = {} @@ -150,23 +147,6 @@ class MusicAssistantPlayer(MusicAssistantEntity, MediaPlayerEntity): """Register callbacks.""" await super().async_added_to_hass() - # we subscribe to player queue time update but we only - # accept a state change on big time jumps (e.g. seeking) - async def queue_time_updated(event: MassEvent) -> None: - if event.object_id != self.player.active_source: - return - if abs((self._prev_time or 0) - event.data) > 5: - await self.async_on_update() - self.async_write_ha_state() - self._prev_time = event.data - - self.async_on_remove( - self.mass.subscribe( - queue_time_updated, - EventType.QUEUE_TIME_UPDATED, - ) - ) - # we subscribe to the player config changed event to update # the supported features of the player async def player_config_changed(event: MassEvent) -> None: @@ -463,89 +443,96 @@ class MusicAssistantPlayer(MusicAssistantEntity, MediaPlayerEntity): username: str | None = None, ) -> None: """Send the play_media command to the media player.""" - # An explicit username is validated strictly; when omitted we default to - # the Home Assistant user that made the call (best-effort, never raises). - user_id = self._context.user_id if self._context is not None else None - if username is not None: - await async_verify_mass_username_availability( - mass=self.mass, username=username + # An explicit username impersonates that Music Assistant user (the server rejects an + # unknown name). When omitted, default playback to the Home Assistant user that made + # the call: the server resolves them by provider link, or plays as the default + # account (required=False) when that Home Assistant user has no linked account. + user: str | LinkedUser | None = username + ha_user_id = self._context.user_id if self._context is not None else None + if username is None and ha_user_id is not None: + user = LinkedUser( + provider=AuthProviderType.HOME_ASSISTANT, + user_id=ha_user_id, + required=False, ) - elif user_id is not None: - username = await async_resolve_mass_username(self.hass, self.mass, user_id) media_uris: list[str] = [] item: MediaItemType | ItemMapping | None = None # work out (all) uri(s) to play - for media_id_str in media_id: - assert self.mass.server_info # for type checking - # pre schema 33: verify_item_uri does not exist as API method - # with schema 33: only local files have to be verified - if self.mass.server_info.schema_version < 33: - # URL or URI string - if "://" in media_id_str: - media_uris.append(media_id_str) - continue - # try content id as library id - if media_type and media_id_str.isnumeric(): - with suppress(MediaNotFoundError): - item = await self.mass.music.get_item( - MediaType(media_type), media_id_str, "library" - ) - if isinstance(item, MediaItemType | ItemMapping) and item.uri: - media_uris.append(item.uri) + with catch_user_not_found(username): + for media_id_str in media_id: + assert self.mass.server_info # for type checking + # pre schema 33: verify_item_uri does not exist as API method + # with schema 33: only local files have to be verified + if self.mass.server_info.schema_version < 33: + # URL or URI string + if "://" in media_id_str: + media_uris.append(media_id_str) continue - # try local accessible filename - elif await asyncio.to_thread(os.path.isfile, media_id_str): - media_uris.append(media_id_str) - continue - else: - media_id_verify_str = media_id_str - if media_type and media_id_str.isnumeric(): - # construct in library uri as replacement for pre 33 isnumeric path - media_id_verify_str = ( - f"library://{MediaType(media_type).value}/{media_id_str}" - ) - if await self.mass.music.verify_item_uri( - uri=media_id_verify_str, username=username + # try content id as library id + if media_type and media_id_str.isnumeric(): + with suppress(MediaNotFoundError): + item = await self.mass.music.get_item( + MediaType(media_type), media_id_str, "library" + ) + if ( + isinstance(item, MediaItemType | ItemMapping) + and item.uri + ): + media_uris.append(item.uri) + continue + # try local accessible filename + elif await asyncio.to_thread(os.path.isfile, media_id_str): + media_uris.append(media_id_str) + continue + else: + media_id_verify_str = media_id_str + if media_type and media_id_str.isnumeric(): + # construct in library uri as replacement for pre 33 isnumeric path + media_id_verify_str = ( + f"library://{MediaType(media_type).value}/{media_id_str}" + ) + if await self.mass.music.verify_item_uri( + uri=media_id_verify_str, user=user + ): + media_uris.append(media_id_verify_str) + continue + if await asyncio.to_thread(os.path.isfile, media_id_str): + media_uris.append(media_id_str) + continue + # last resort: search for media item by name/search + if item := await self.mass.music.get_item_by_name( + name=media_id_str, + artist=artist, + album=album, + media_type=MediaType(media_type) if media_type else None, + user=user, ): - media_uris.append(media_id_verify_str) - continue - if await asyncio.to_thread(os.path.isfile, media_id_str): - media_uris.append(media_id_str) - continue - # last resort: search for media item by name/search - if item := await self.mass.music.get_item_by_name( - name=media_id_str, - artist=artist, - album=album, - media_type=MediaType(media_type) if media_type else None, - username=username, - ): - if TYPE_CHECKING: - assert item.uri is not None - media_uris.append(item.uri) + if TYPE_CHECKING: + assert item.uri is not None + media_uris.append(item.uri) - if not media_uris: - raise HomeAssistantError( - f"Could not resolve {media_id} to playable media item" + if not media_uris: + raise HomeAssistantError( + f"Could not resolve {media_id} to playable media item" + ) + + # determine active queue to send the play request to + if TYPE_CHECKING: + assert self.player.active_source is not None + if queue := self.mass.player_queues.get(self.player.active_source): + queue_id = queue.queue_id + else: + queue_id = self.player_id + + await self.mass.player_queues.play_media( + queue_id, + media=media_uris, + option=self._convert_queueoption_to_media_player_enqueue(enqueue), + radio_mode=radio_mode or False, + user=user, ) - # determine active queue to send the play request to - if TYPE_CHECKING: - assert self.player.active_source is not None - if queue := self.mass.player_queues.get(self.player.active_source): - queue_id = queue.queue_id - else: - queue_id = self.player_id - - await self.mass.player_queues.play_media( - queue_id, - media=media_uris, - option=self._convert_queueoption_to_media_player_enqueue(enqueue), - radio_mode=radio_mode or False, - username=username, - ) - @catch_musicassistant_error async def _async_handle_play_announcement( self, @@ -664,88 +651,49 @@ class MusicAssistantPlayer(MusicAssistantEntity, MediaPlayerEntity): def _update_media_attributes( self, player: Player, queue: PlayerQueue | None ) -> None: - """Update media attributes for the active queue item.""" - self._attr_media_artist = None - self._attr_media_album_artist = None - self._attr_media_album_name = None - self._attr_media_title = None - self._attr_media_content_id = None - self._attr_media_duration = None - self._attr_media_position = None - self._attr_media_position_updated_at = None - - if queue is None and player.current_media: - # player has some external source active - self._attr_media_content_id = player.current_media.uri + """Update media attributes from the player's current media.""" + # shuffle and repeat are queue concepts and not part of current_media + if queue is not None: + self._attr_app_id = DOMAIN + self._attr_shuffle = queue.shuffle_enabled + self._attr_repeat = REPEAT_MODE_MAPPING_TO_HA.get(queue.repeat_mode) + else: self._attr_app_id = player.active_source - self._attr_media_title = player.current_media.title - self._attr_media_artist = player.current_media.artist - self._attr_media_album_name = player.current_media.album - self._attr_media_duration = player.current_media.duration - # shuffle and repeat are not (yet) supported for external sources self._attr_shuffle = None self._attr_repeat = None - self._attr_media_position = int(player.elapsed_time or 0) + + # the server resolves current_media for every playback scenario + current_media = player.current_media + self._attr_media_content_id = ( + current_media.uri if current_media is not None else None + ) + self._attr_media_title = ( + current_media.title if current_media is not None else None + ) + self._attr_media_artist = ( + current_media.artist if current_media is not None else None + ) + self._attr_media_album_name = ( + current_media.album if current_media is not None else None + ) + self._attr_media_album_artist = ( + current_media.album_artist if current_media is not None else None + ) + self._attr_media_duration = ( + current_media.duration if current_media is not None else None + ) + + # the server pushes a fresh position anchor on jumps (e.g. seeking) + if current_media is not None and current_media.elapsed_time is not None: + self._attr_media_position = int(current_media.elapsed_time) self._attr_media_position_updated_at = ( - utc_from_timestamp(player.elapsed_time_last_updated) - if player.elapsed_time_last_updated + utc_from_timestamp(current_media.elapsed_time_last_updated) + if current_media.elapsed_time_last_updated is not None else None ) - self._prev_time = player.elapsed_time or 0 - return - - if queue is None: - # player has no MA queue active - self._attr_source = player.active_source - self._attr_app_id = player.active_source - return - - # player has an MA queue active (either its own queue or some group queue) - self._attr_app_id = DOMAIN - self._attr_shuffle = queue.shuffle_enabled - self._attr_repeat = REPEAT_MODE_MAPPING_TO_HA.get(queue.repeat_mode) - if not (cur_item := queue.current_item): - # queue is empty - return - - self._attr_media_content_id = queue.current_item.uri - self._attr_media_duration = queue.current_item.duration - self._attr_media_position = int(queue.elapsed_time) - self._attr_media_position_updated_at = utc_from_timestamp( - queue.elapsed_time_last_updated - ) - self._prev_time = queue.elapsed_time - - # handle stream title (radio station icy metadata) - if (stream_details := cur_item.streamdetails) and stream_details.stream_title: - self._attr_media_album_name = cur_item.name - if " - " in stream_details.stream_title: - stream_title_parts = stream_details.stream_title.split(" - ", 1) - self._attr_media_title = stream_title_parts[1] - self._attr_media_artist = stream_title_parts[0] - else: - self._attr_media_title = stream_details.stream_title - return - - if not (media_item := cur_item.media_item): - # queue is not playing a regular media item (edge case?!) - self._attr_media_title = cur_item.name - return - - # queue is playing regular media item - self._attr_media_title = media_item.name - # for tracks we can extract more info - if media_item.media_type == MediaType.TRACK: - if TYPE_CHECKING: - assert isinstance(media_item, Track) - self._attr_media_artist = media_item.artist_str - if media_item.version: - self._attr_media_title += f" ({media_item.version})" - if media_item.album: - self._attr_media_album_name = media_item.album.name - self._attr_media_album_artist = getattr( - media_item.album, "artist_str", None - ) + else: + self._attr_media_position = None + self._attr_media_position_updated_at = None def _convert_queueoption_to_media_player_enqueue( self, queue_option: MediaPlayerEnqueue | QueueOption | None diff --git a/homeassistant/components/music_assistant/services.py b/homeassistant/components/music_assistant/services.py index 9a8d1083c061..1bb9c1a8df5b 100644 --- a/homeassistant/components/music_assistant/services.py +++ b/homeassistant/components/music_assistant/services.py @@ -54,7 +54,7 @@ from .const import ( ATTR_USERNAME, DOMAIN, ) -from .helpers import async_verify_mass_username_availability, get_music_assistant_client +from .helpers import catch_user_not_found, get_music_assistant_client from .schemas import ( LIBRARY_RESULTS_SCHEMA, SEARCH_RESULT_SCHEMA, @@ -187,23 +187,20 @@ async def handle_search(call: ServiceCall) -> ServiceResponse: search_artist = call.data.get(ATTR_SEARCH_ARTIST) search_album = call.data.get(ATTR_SEARCH_ALBUM) search_username = call.data.get(ATTR_USERNAME) - if search_username is not None: - await async_verify_mass_username_availability( - mass=mass, username=search_username - ) if search_album and search_artist: search_name = f"{search_artist} - {search_album} - {search_name}" elif search_album: search_name = f"{search_album} - {search_name}" elif search_artist: search_name = f"{search_artist} - {search_name}" - search_results = await mass.music.search( - search_query=search_name, - media_types=call.data.get(ATTR_MEDIA_TYPE, MediaType.ALL), - limit=call.data[ATTR_LIMIT], - library_only=call.data[ATTR_LIBRARY_ONLY], - user=search_username, - ) + with catch_user_not_found(search_username): + search_results = await mass.music.search( + search_query=search_name, + media_types=call.data.get(ATTR_MEDIA_TYPE, MediaType.ALL), + limit=call.data[ATTR_LIMIT], + library_only=call.data[ATTR_LIBRARY_ONLY], + user=search_username, + ) response: ServiceResponse = SEARCH_RESULT_SCHEMA( { ATTR_ARTISTS: [ @@ -247,8 +244,6 @@ async def handle_get_library(call: ServiceCall) -> ServiceResponse: offset = call.data.get(ATTR_OFFSET, DEFAULT_OFFSET) order_by = call.data.get(ATTR_ORDER_BY, DEFAULT_SORT_ORDER) username = call.data.get(ATTR_USERNAME) - if username is not None: - await async_verify_mass_username_availability(mass=mass, username=username) base_params = { "favorite": call.data.get(ATTR_FAVORITE), "search": call.data.get(ATTR_SEARCH), @@ -266,38 +261,39 @@ async def handle_get_library(call: ServiceCall) -> ServiceResponse: | list[Audiobook] | list[Podcast] ) - if media_type == MediaType.ALBUM: - library_result = await mass.music.get_library_albums( - **base_params, - album_types=call.data.get(ATTR_ALBUM_TYPE), - ) - elif media_type == MediaType.ARTIST: - library_result = await mass.music.get_library_artists( - **base_params, - album_artists_only=bool(call.data.get(ATTR_ALBUM_ARTISTS_ONLY)), - ) - elif media_type == MediaType.TRACK: - library_result = await mass.music.get_library_tracks( - **base_params, - ) - elif media_type == MediaType.RADIO: - library_result = await mass.music.get_library_radios( - **base_params, - ) - elif media_type == MediaType.PLAYLIST: - library_result = await mass.music.get_library_playlists( - **base_params, - ) - elif media_type == MediaType.AUDIOBOOK: - library_result = await mass.music.get_library_audiobooks( - **base_params, - ) - elif media_type == MediaType.PODCAST: - library_result = await mass.music.get_library_podcasts( - **base_params, - ) - else: - raise ServiceValidationError(f"Unsupported media type {media_type}") + with catch_user_not_found(username): + if media_type == MediaType.ALBUM: + library_result = await mass.music.get_library_albums( + **base_params, + album_types=call.data.get(ATTR_ALBUM_TYPE), + ) + elif media_type == MediaType.ARTIST: + library_result = await mass.music.get_library_artists( + **base_params, + album_artists_only=bool(call.data.get(ATTR_ALBUM_ARTISTS_ONLY)), + ) + elif media_type == MediaType.TRACK: + library_result = await mass.music.get_library_tracks( + **base_params, + ) + elif media_type == MediaType.RADIO: + library_result = await mass.music.get_library_radios( + **base_params, + ) + elif media_type == MediaType.PLAYLIST: + library_result = await mass.music.get_library_playlists( + **base_params, + ) + elif media_type == MediaType.AUDIOBOOK: + library_result = await mass.music.get_library_audiobooks( + **base_params, + ) + elif media_type == MediaType.PODCAST: + library_result = await mass.music.get_library_podcasts( + **base_params, + ) + else: + raise ServiceValidationError(f"Unsupported media type {media_type}") response: ServiceResponse = LIBRARY_RESULTS_SCHEMA( { diff --git a/homeassistant/components/music_assistant/strings.json b/homeassistant/components/music_assistant/strings.json index d7374f1ce4b7..9f228d3b70a5 100644 --- a/homeassistant/components/music_assistant/strings.json +++ b/homeassistant/components/music_assistant/strings.json @@ -261,7 +261,7 @@ }, "exceptions": { "invalid_username": { - "message": "The username {username} does not exist. Available usernames are {available_usernames}." + "message": "The username {username} does not exist on the Music Assistant server." } }, "issues": { diff --git a/homeassistant/components/mvglive/manifest.json b/homeassistant/components/mvglive/manifest.json index 0229ef35b9c3..da726616f54d 100644 --- a/homeassistant/components/mvglive/manifest.json +++ b/homeassistant/components/mvglive/manifest.json @@ -6,5 +6,5 @@ "iot_class": "cloud_polling", "loggers": ["MVG"], "quality_scale": "legacy", - "requirements": ["mvg==1.4.0"] + "requirements": ["mvg==1.6.0"] } diff --git a/homeassistant/components/netatmo/light.py b/homeassistant/components/netatmo/light.py index 2e84133e1707..1c977cbfaf88 100644 --- a/homeassistant/components/netatmo/light.py +++ b/homeassistant/components/netatmo/light.py @@ -183,6 +183,7 @@ class NetatmoLight(NetatmoReachabilityEntity, LightEntity): await self.device.async_set_brightness( round(kwargs[ATTR_BRIGHTNESS] / 2.55) ) + self._attr_brightness = kwargs[ATTR_BRIGHTNESS] else: await self.device.async_on() diff --git a/homeassistant/components/nfandroidtv/notify.py b/homeassistant/components/nfandroidtv/notify.py index b250df454929..21672e6b9ae6 100644 --- a/homeassistant/components/nfandroidtv/notify.py +++ b/homeassistant/components/nfandroidtv/notify.py @@ -154,11 +154,14 @@ class NFAndroidTVNotificationService(BaseNotificationService): duration = int( data.get(ATTR_DURATION, Notifications.DEFAULT_DURATION) ) - # pylint: disable-next=home-assistant-action-swallowed-exception - except ValueError: - _LOGGER.warning( - "Invalid duration-value: %s", data.get(ATTR_DURATION) - ) + except (OverflowError, TypeError, ValueError) as err: + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="invalid_duration", + translation_placeholders={ + "duration": str(data.get(ATTR_DURATION)) + }, + ) from err if ATTR_FONTSIZE in data: if data.get(ATTR_FONTSIZE) in Notifications.FONTSIZES: fontsize = data.get(ATTR_FONTSIZE) @@ -189,10 +192,14 @@ class NFAndroidTVNotificationService(BaseNotificationService): if ATTR_INTERRUPT in data: try: interrupt = cv.boolean(data.get(ATTR_INTERRUPT)) - except vol.Invalid: - _LOGGER.warning( - "Invalid interrupt-value: %s", data.get(ATTR_INTERRUPT) - ) + except vol.Invalid as err: + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="invalid_interrupt", + translation_placeholders={ + "interrupt": str(data.get(ATTR_INTERRUPT)) + }, + ) from err if imagedata := data.get(ATTR_IMAGE): if isinstance(imagedata, str): image_file = ( diff --git a/homeassistant/components/nfandroidtv/strings.json b/homeassistant/components/nfandroidtv/strings.json index 61a1620dfa2e..ebc75419a9fc 100644 --- a/homeassistant/components/nfandroidtv/strings.json +++ b/homeassistant/components/nfandroidtv/strings.json @@ -34,6 +34,12 @@ "connection_failed": { "message": "Failed to connect to host: {host}" }, + "invalid_duration": { + "message": "Invalid duration value: {duration}" + }, + "invalid_interrupt": { + "message": "Invalid interrupt value: {interrupt}" + }, "invalid_notification_icon": { "message": "Invalid icon data provided. Got {type}" }, diff --git a/homeassistant/components/nice_go/coordinator.py b/homeassistant/components/nice_go/coordinator.py index a680bee39d29..2903091620c9 100644 --- a/homeassistant/components/nice_go/coordinator.py +++ b/homeassistant/components/nice_go/coordinator.py @@ -147,17 +147,8 @@ class NiceGOUpdateCoordinator(DataUpdateCoordinator[dict[str, NiceGODevice]]): async def _async_setup(self) -> None: """Set up the coordinator.""" async with asyncio.timeout(10): - expiry_time = ( - self.refresh_token_creation_time - + REFRESH_TOKEN_EXPIRY_TIME.total_seconds() - ) try: - if datetime.now().timestamp() >= expiry_time: # pylint: disable=home-assistant-enforce-naive-now - await self.update_refresh_token() - else: - await self.api.authenticate_refresh( - self.refresh_token, async_get_clientsession(self.hass) - ) + await self.authenticate() _LOGGER.debug("Authenticated with Nice G.O. API") barriers = await self.api.get_all_barriers() @@ -171,13 +162,31 @@ class NiceGOUpdateCoordinator(DataUpdateCoordinator[dict[str, NiceGODevice]]): barrier.id: barrier for barrier in parsed_barriers if barrier } self.organization_id = await barriers[0].get_attr("organization") - except AuthFailedError as e: - raise ConfigEntryAuthFailed from e except ApiError as e: raise UpdateFailed from e else: self.async_set_updated_data(devices) + async def authenticate(self) -> None: + """Authenticate with the Nice G.O. API.""" + _LOGGER.debug("Authenticating with Nice G.O. API") + expiry_time = ( + self.refresh_token_creation_time + REFRESH_TOKEN_EXPIRY_TIME.total_seconds() + ) + try: + if datetime.now().timestamp() >= expiry_time: # pylint: disable=home-assistant-enforce-naive-now + await self.update_refresh_token() + else: + await self.api.authenticate_refresh( + self.refresh_token, async_get_clientsession(self.hass) + ) + except AuthFailedError as e: + _LOGGER.exception("Authentication failed") + raise ConfigEntryAuthFailed from e + except ApiError as e: + _LOGGER.exception("API error") + raise UpdateFailed from e + async def update_refresh_token(self) -> None: """Update the refresh token with Nice G.O. API.""" _LOGGER.debug("Updating the refresh token with Nice G.O. API") @@ -214,6 +223,12 @@ class NiceGOUpdateCoordinator(DataUpdateCoordinator[dict[str, NiceGODevice]]): try: await self.api.connect(reconnect=True) + except AuthFailedError: + # Try reauthenticating otherwise start reauth flow + _LOGGER.debug( + "Got auth failed when connecting to websocket, trying to reauthenticate" + ) + await self.authenticate() except ApiError: _LOGGER.exception("API error") else: diff --git a/homeassistant/components/nice_go/manifest.json b/homeassistant/components/nice_go/manifest.json index dbf22e25274c..79b64a47302a 100644 --- a/homeassistant/components/nice_go/manifest.json +++ b/homeassistant/components/nice_go/manifest.json @@ -7,5 +7,5 @@ "integration_type": "hub", "iot_class": "cloud_push", "loggers": ["nice_go"], - "requirements": ["nice-go==1.0.2"] + "requirements": ["nice-go==1.0.3"] } diff --git a/homeassistant/components/openevse/__init__.py b/homeassistant/components/openevse/__init__.py index 67c6fcd57806..7597394ac799 100644 --- a/homeassistant/components/openevse/__init__.py +++ b/homeassistant/components/openevse/__init__.py @@ -16,6 +16,7 @@ PLATFORMS = [ Platform.BUTTON, Platform.NUMBER, Platform.SENSOR, + Platform.SWITCH, ] diff --git a/homeassistant/components/openevse/button.py b/homeassistant/components/openevse/button.py index 098f9f398334..738195ce8762 100644 --- a/homeassistant/components/openevse/button.py +++ b/homeassistant/components/openevse/button.py @@ -4,7 +4,7 @@ from collections.abc import Awaitable, Callable from dataclasses import dataclass from typing import Any, override -from openevsehttp.__main__ import OpenEVSE +from openevsehttp import OpenEVSE from homeassistant.components.button import ( ButtonDeviceClass, diff --git a/homeassistant/components/openevse/helpers.py b/homeassistant/components/openevse/helpers.py index b15cdccab93e..32989aa9c511 100644 --- a/homeassistant/components/openevse/helpers.py +++ b/homeassistant/components/openevse/helpers.py @@ -2,6 +2,7 @@ from collections.abc import Iterator from contextlib import contextmanager +from typing import Any from aiohttp import ContentTypeError, ServerTimeoutError from openevsehttp.exceptions import ( @@ -20,7 +21,7 @@ from .const import DOMAIN @contextmanager -def openevse_exception_handler(value: float) -> Iterator[None]: +def openevse_exception_handler(value: Any = None) -> Iterator[None]: """Context manager to handle and translate OpenEVSE exceptions.""" try: yield diff --git a/homeassistant/components/openevse/manifest.json b/homeassistant/components/openevse/manifest.json index ff0e3902d0b8..98279e460572 100644 --- a/homeassistant/components/openevse/manifest.json +++ b/homeassistant/components/openevse/manifest.json @@ -9,6 +9,6 @@ "iot_class": "local_push", "loggers": ["openevsehttp"], "quality_scale": "silver", - "requirements": ["python-openevse-http==1.0.1"], + "requirements": ["python-openevse-http==1.5.0"], "zeroconf": ["_openevse._tcp.local."] } diff --git a/homeassistant/components/openevse/number.py b/homeassistant/components/openevse/number.py index 5cb186409767..47da5f5db267 100644 --- a/homeassistant/components/openevse/number.py +++ b/homeassistant/components/openevse/number.py @@ -4,7 +4,7 @@ from collections.abc import Awaitable, Callable from dataclasses import dataclass from typing import Any, override -from openevsehttp.__main__ import OpenEVSE +from openevsehttp import OpenEVSE from homeassistant.components.number import ( NumberDeviceClass, diff --git a/homeassistant/components/openevse/strings.json b/homeassistant/components/openevse/strings.json index fdc8d77606cc..c2810b37097a 100644 --- a/homeassistant/components/openevse/strings.json +++ b/homeassistant/components/openevse/strings.json @@ -207,6 +207,17 @@ "vehicle_soc": { "name": "Vehicle state of charge" } + }, + "switch": { + "current_shaper": { + "name": "Current shaper" + }, + "manual_override": { + "name": "Manual override" + }, + "solar_pv_divert": { + "name": "Solar PV divert" + } } }, "exceptions": { diff --git a/homeassistant/components/openevse/switch.py b/homeassistant/components/openevse/switch.py new file mode 100644 index 000000000000..a488465f96a7 --- /dev/null +++ b/homeassistant/components/openevse/switch.py @@ -0,0 +1,132 @@ +"""Support for OpenEVSE switch entities.""" + +from collections.abc import Awaitable, Callable +from dataclasses import dataclass +from typing import Any, override + +from openevsehttp import OpenEVSE + +from homeassistant.components.switch import SwitchEntity, SwitchEntityDescription +from homeassistant.const import ATTR_CONNECTIONS, ATTR_SERIAL_NUMBER +from homeassistant.core import HomeAssistant +from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC, DeviceInfo +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from .const import DOMAIN +from .coordinator import OpenEVSEConfigEntry, OpenEVSEDataUpdateCoordinator +from .helpers import openevse_exception_handler + +PARALLEL_UPDATES = 0 + + +@dataclass(frozen=True, kw_only=True) +class OpenEVSESwitchDescription(SwitchEntityDescription): + """Describes an OpenEVSE switch entity.""" + + is_on_fn: Callable[[OpenEVSE], bool | None] + turn_on_fn: Callable[[OpenEVSE], Awaitable[Any]] + turn_off_fn: Callable[[OpenEVSE], Awaitable[Any]] + + +SWITCH_TYPES: tuple[OpenEVSESwitchDescription, ...] = ( + OpenEVSESwitchDescription( + key="solar_pv_divert", + translation_key="solar_pv_divert", + is_on_fn=lambda ev: ( + ev.divertmode == "eco" if ev.divertmode is not None else None + ), + turn_on_fn=lambda ev: ev.set_divert_mode("eco"), + turn_off_fn=lambda ev: ev.set_divert_mode( + "fast" + ), # "fast" disables solar divert + ), + OpenEVSESwitchDescription( + key="current_shaper", + translation_key="current_shaper", + is_on_fn=lambda ev: ev.shaper_active, + turn_on_fn=lambda ev: ev.set_shaper(True), + turn_off_fn=lambda ev: ev.set_shaper(False), + ), + OpenEVSESwitchDescription( + key="manual_override", + translation_key="manual_override", + is_on_fn=lambda ev: ev.manual_override, + turn_on_fn=lambda ev: ev.toggle_override(), + turn_off_fn=lambda ev: ev.toggle_override(), + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, + entry: OpenEVSEConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up OpenEVSE switches based on config entry.""" + coordinator = entry.runtime_data + async_add_entities( + OpenEVSESwitch( + coordinator, + description, + entry.unique_id or entry.entry_id, + entry.unique_id, + ) + for description in SWITCH_TYPES + ) + + +class OpenEVSESwitch(CoordinatorEntity[OpenEVSEDataUpdateCoordinator], SwitchEntity): + """Implementation of an OpenEVSE switch.""" + + _attr_has_entity_name = True + entity_description: OpenEVSESwitchDescription + + def __init__( + self, + coordinator: OpenEVSEDataUpdateCoordinator, + description: OpenEVSESwitchDescription, + identifier: str, + unique_id: str | None, + ) -> None: + """Initialize the switch.""" + super().__init__(coordinator) + self.entity_description = description + self._attr_unique_id = f"{identifier}-{description.key}" + + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, identifier)}, + manufacturer="OpenEVSE", + ) + if unique_id: + self._attr_device_info[ATTR_CONNECTIONS] = { + (CONNECTION_NETWORK_MAC, unique_id) + } + self._attr_device_info[ATTR_SERIAL_NUMBER] = unique_id + + @property + @override + def available(self) -> bool: + """Return True if entity is available.""" + return ( + super().available + and self.entity_description.is_on_fn(self.coordinator.charger) is not None + ) + + @property + @override + def is_on(self) -> bool | None: + """Return True if the switch is on.""" + return self.entity_description.is_on_fn(self.coordinator.charger) + + @override + async def async_turn_on(self, **kwargs: Any) -> None: + """Turn the switch on.""" + with openevse_exception_handler(): + await self.entity_description.turn_on_fn(self.coordinator.charger) + + @override + async def async_turn_off(self, **kwargs: Any) -> None: + """Turn the switch off.""" + with openevse_exception_handler(): + await self.entity_description.turn_off_fn(self.coordinator.charger) diff --git a/homeassistant/components/openrgb/light.py b/homeassistant/components/openrgb/light.py index e88190944edb..c8760ea7d56e 100644 --- a/homeassistant/components/openrgb/light.py +++ b/homeassistant/components/openrgb/light.py @@ -407,7 +407,12 @@ class OpenRGBLight(CoordinatorEntity[OpenRGBCoordinator], LightEntity): if self._supports_off_mode: await self._async_apply_mode(OpenRGBMode.OFF) else: - # If the device does not support Off mode, set color to black + # If the device does not support Off mode, set color to black. + # Color writes are ignored while a mode without PER_LED color + # support (e.g. a firmware effect) is active — switch to the + # preferred no-effect mode first so the black actually lands. + if self._mode not in self._supports_color_modes: + await self._async_apply_mode(self._preferred_no_effect_mode) await self._async_apply_color(OFF_COLOR, 0) await self._async_refresh_data() diff --git a/homeassistant/components/owntracks/device_tracker.py b/homeassistant/components/owntracks/device_tracker.py index 477bf74c4cfa..e7de15866919 100644 --- a/homeassistant/components/owntracks/device_tracker.py +++ b/homeassistant/components/owntracks/device_tracker.py @@ -49,7 +49,7 @@ async def async_setup_entry( dev_reg = dr.async_get(hass) dev_ids = { identifier[1] - for device in dev_reg.devices.get_devices_for_config_entry_id(entry.entry_id) + for device in dr.async_entries_for_config_entry(dev_reg, entry.entry_id) for identifier in device.identifiers } diff --git a/homeassistant/components/portainer/icons.json b/homeassistant/components/portainer/icons.json index c0907a461658..e78c26c996c6 100644 --- a/homeassistant/components/portainer/icons.json +++ b/homeassistant/components/portainer/icons.json @@ -60,12 +60,18 @@ "image": { "default": "mdi:docker" }, + "image_created": { + "default": "mdi:calendar-clock" + }, "image_disk_usage_reclaimable": { "default": "mdi:file-restore" }, "image_disk_usage_total_size": { "default": "mdi:harddisk" }, + "image_version": { + "default": "mdi:tag-outline" + }, "images_count": { "default": "mdi:image-multiple" }, diff --git a/homeassistant/components/portainer/sensor.py b/homeassistant/components/portainer/sensor.py index 7062674e86f2..3c8d15f10024 100644 --- a/homeassistant/components/portainer/sensor.py +++ b/homeassistant/components/portainer/sensor.py @@ -2,6 +2,7 @@ from collections.abc import Callable from dataclasses import dataclass +from datetime import datetime from itertools import chain from typing import TYPE_CHECKING, override @@ -19,6 +20,7 @@ from homeassistant.components.sensor import ( from homeassistant.const import UnitOfInformation, UnitOfRatio from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback +from homeassistant.util import dt as dt_util from .coordinator import ( PortainerConfigEntry, @@ -42,7 +44,7 @@ PARALLEL_UPDATES = 0 class PortainerContainerSensorEntityDescription(SensorEntityDescription): """Class to hold Portainer container sensor description.""" - value_fn: Callable[[PortainerContainerData], StateType] + value_fn: Callable[[PortainerContainerData], StateType | datetime] supported_fn: Callable[[PortainerContainerData], bool] = lambda _: True @@ -80,6 +82,39 @@ CONTAINER_SENSORS: tuple[PortainerContainerSensorEntityDescription, ...] = ( translation_key="image", value_fn=lambda data: data.container.image, ), + PortainerContainerSensorEntityDescription( + key="image_version", + translation_key="image_version", + supported_fn=lambda data: bool( + data.container.labels + and data.container.labels.get("org.opencontainers.image.version") + ), + value_fn=lambda data: ( + data.container.labels.get("org.opencontainers.image.version") + if data.container.labels + else None + ), + ), + PortainerContainerSensorEntityDescription( + key="image_created", + translation_key="image_created", + supported_fn=lambda data: bool( + data.container.labels + and data.container.labels.get("org.opencontainers.image.created") + ), + value_fn=lambda data: ( + parsed + if data.container.labels + and ( + created := data.container.labels.get("org.opencontainers.image.created") + ) + and (parsed := dt_util.parse_datetime(created)) is not None + and parsed.tzinfo is not None + else None + ), + device_class=SensorDeviceClass.TIMESTAMP, + entity_category=EntityCategory.DIAGNOSTIC, + ), PortainerContainerSensorEntityDescription( key="container_state", translation_key="container_state", @@ -488,7 +523,7 @@ class PortainerContainerSensor(PortainerContainerEntity, SensorEntity): @property @override - def native_value(self) -> StateType: + def native_value(self) -> StateType | datetime: """Return the state of the sensor.""" return self.entity_description.value_fn(self.container_data) diff --git a/homeassistant/components/portainer/strings.json b/homeassistant/components/portainer/strings.json index 53f7befbc440..6f748880a8ed 100644 --- a/homeassistant/components/portainer/strings.json +++ b/homeassistant/components/portainer/strings.json @@ -142,12 +142,18 @@ "image": { "name": "Image" }, + "image_created": { + "name": "Image created" + }, "image_disk_usage_reclaimable": { "name": "Image disk usage reclaimable" }, "image_disk_usage_total_size": { "name": "Image disk usage total size" }, + "image_version": { + "name": "Image version" + }, "images_count": { "name": "Image count" }, diff --git a/homeassistant/components/private_ble_device/manifest.json b/homeassistant/components/private_ble_device/manifest.json index 386dcb0ac9b7..46e45a2cc242 100644 --- a/homeassistant/components/private_ble_device/manifest.json +++ b/homeassistant/components/private_ble_device/manifest.json @@ -7,5 +7,5 @@ "documentation": "https://www.home-assistant.io/integrations/private_ble_device", "integration_type": "device", "iot_class": "local_push", - "requirements": ["bluetooth-data-tools==1.29.18"] + "requirements": ["bluetooth-data-tools==1.29.21"] } diff --git a/homeassistant/components/ps4/media_player.py b/homeassistant/components/ps4/media_player.py index 732e31a7011e..cd5dd5de3e3f 100644 --- a/homeassistant/components/ps4/media_player.py +++ b/homeassistant/components/ps4/media_player.py @@ -350,9 +350,7 @@ class PS4Device(MediaPlayerEntity): self._attr_unique_id = entry.unique_id self.entity_id = entry.entity_id break - for device in d_registry.devices.get_devices_for_config_entry_id( - self._entry_id - ): + for device in dr.async_entries_for_config_entry(d_registry, self._entry_id): # Rebuilt from the existing device entry, which already carries # the network MAC connection added by the live-status branch. self._attr_device_info = DeviceInfo( diff --git a/homeassistant/components/purpleair/config_flow.py b/homeassistant/components/purpleair/config_flow.py index 84742a2b188b..518ab03a541b 100644 --- a/homeassistant/components/purpleair/config_flow.py +++ b/homeassistant/components/purpleair/config_flow.py @@ -110,8 +110,8 @@ def async_get_remove_sensor_options( device_registry = dr.async_get(hass) return [ SelectOptionDict(value=device_entry.id, label=cast(str, device_entry.name)) - for device_entry in device_registry.devices.get_devices_for_config_entry_id( - config_entry.entry_id + for device_entry in dr.async_entries_for_config_entry( + device_registry, config_entry.entry_id ) ] diff --git a/homeassistant/components/python_script/__init__.py b/homeassistant/components/python_script/__init__.py index afed00363c9e..dfe8947411aa 100644 --- a/homeassistant/components/python_script/__init__.py +++ b/homeassistant/components/python_script/__init__.py @@ -9,7 +9,7 @@ import operator import os import time import types -from typing import Any +from typing import TYPE_CHECKING, Any from RestrictedPython import ( compile_restricted_exec, @@ -230,6 +230,9 @@ def execute( "Warning loading script %s: %s", filename, ", ".join(compiled.warnings) ) + if TYPE_CHECKING: + assert compiled.code is not None + def protected_getattr(obj: object, name: str, default: Any = None) -> Any: """Restricted method to get attributes.""" if name.startswith("async_"): diff --git a/homeassistant/components/python_script/manifest.json b/homeassistant/components/python_script/manifest.json index f026e3fa220a..3dafd6bf9ed4 100644 --- a/homeassistant/components/python_script/manifest.json +++ b/homeassistant/components/python_script/manifest.json @@ -5,5 +5,5 @@ "documentation": "https://www.home-assistant.io/integrations/python_script", "loggers": ["RestrictedPython"], "quality_scale": "internal", - "requirements": ["RestrictedPython==8.1"] + "requirements": ["RestrictedPython==8.5"] } diff --git a/homeassistant/components/rachio/icons.json b/homeassistant/components/rachio/icons.json index 71c634a6cb9a..84328f5e6548 100644 --- a/homeassistant/components/rachio/icons.json +++ b/homeassistant/components/rachio/icons.json @@ -2,10 +2,10 @@ "entity": { "switch": { "rain_delay": { - "default": "mdi:camera-timer" + "default": "mdi:hours-24" }, "standby": { - "default": "mdi:power" + "default": "mdi:power-sleep" } } }, diff --git a/homeassistant/components/recorder/strings.json b/homeassistant/components/recorder/strings.json index d0afa2d3ddfa..56cfd69de77b 100644 --- a/homeassistant/components/recorder/strings.json +++ b/homeassistant/components/recorder/strings.json @@ -4,6 +4,14 @@ "description": "The database backup stated at {start_time} failed due to lack of resources. The backup cannot be trusted and must be restarted. This can happen if the database is too large or if the system is under heavy load. Consider upgrading the system hardware or reducing the size of the database by decreasing the number of history days to keep or creating a filter.", "title": "Database backup failed due to lack of resources" }, + "database_engine_not_supported_lts": { + "description": "Version {server_version} of {database_engine} is not a supported long-term support (LTS) release. Support for short-term releases and end-of-life LTS releases will be removed; please upgrade to one of the supported LTS versions ({lts_versions}) and restart Home Assistant to continue using the recorder.", + "title": "Update {database_engine} to a supported LTS version to continue using the recorder" + }, + "database_engine_too_old": { + "description": "Support for version {server_version} of {database_engine} is ending; the minimum supported version will be {min_version}. Please upgrade your database software and restart Home Assistant.", + "title": "Update {database_engine} to {min_version} or later to continue using the recorder" + }, "maria_db_range_index_regression": { "description": "Older versions of MariaDB suffer from a significant performance regression when retrieving history data or purging the database. Update to MariaDB version {min_version} or later and restart Home Assistant. If you are using the MariaDB Core app, make sure to update it to the latest version.", "title": "Update MariaDB to {min_version} or later resolve a significant performance issue" diff --git a/homeassistant/components/recorder/util.py b/homeassistant/components/recorder/util.py index 1cfc0a92efbf..f5fb0111ad6b 100644 --- a/homeassistant/components/recorder/util.py +++ b/homeassistant/components/recorder/util.py @@ -8,7 +8,7 @@ import functools import logging import os import time -from typing import TYPE_CHECKING, Any, Concatenate, NoReturn +from typing import TYPE_CHECKING, Any, Concatenate, NamedTuple, NoReturn from awesomeversion import ( AwesomeVersion, @@ -27,6 +27,9 @@ import voluptuous as vol from homeassistant.const import WEEKDAYS from homeassistant.core import HomeAssistant, callback +from homeassistant.generated.recorder_database_versions import ( + SUPPORTED_DATABASE_VERSIONS, +) from homeassistant.helpers import config_validation as cv, issue_registry as ir from homeassistant.helpers.recorder import ( # noqa: F401 DATA_INSTANCE, @@ -89,6 +92,47 @@ MIN_VERSION_MYSQL = _simple_version("8.0.0") MIN_VERSION_PGSQL = _simple_version("12.0") MIN_VERSION_SQLITE = _simple_version("3.40.1") +# PostgreSQL has no LTS/short-term split, so we warn once the version drops +# below this upcoming minimum, as (version, breaks_in_ha_version). +UPCOMING_MIN_VERSION_PGSQL = (_simple_version("15.0"), "2027.3.0") + + +# MariaDB and MySQL ship both long-term support (LTS) releases, supported for +# years, and short-term/innovation releases, supported only until the next +# release (~3 months). We allow versions on a currently-supported (non-EoL) LTS +# series and warn against all others (short-term releases and end-of-life LTS +# series). +# Versions newer than the latest known non-LTS release are assumed supported +# so we don't warn about releases we don't know about yet. +class _LTSVersionSupport(NamedTuple): + """Supported LTS policy for an engine that ships LTS + short-term releases.""" + + supported_series: frozenset[tuple[int, int]] + latest_non_lts_series: tuple[int, int] + breaks_in_ha_version: str + + +def _parse_db_series(cycle: str) -> tuple[int, int]: + """Parse a "." release series into a tuple.""" + major, _, minor = cycle.partition(".") + return int(major), int(minor) + + +def _lts_support(engine: str, breaks_in_ha_version: str) -> _LTSVersionSupport: + """Build the LTS support policy for an engine from the generated version file.""" + versions = SUPPORTED_DATABASE_VERSIONS[engine] + return _LTSVersionSupport( + supported_series=frozenset( + _parse_db_series(cycle) for cycle in versions["supported_lts"] + ), + latest_non_lts_series=_parse_db_series(versions["latest_non_lts"]), + breaks_in_ha_version=breaks_in_ha_version, + ) + + +SUPPORTED_MARIA_DB_LTS = _lts_support("mariadb", "2027.3.0") +SUPPORTED_MYSQL_LTS = _lts_support("mysql", "2027.3.0") + # This is the maximum time after the recorder ends the session # before we no longer consider startup to be a "restart" and we @@ -346,6 +390,122 @@ def _raise_if_version_unsupported( raise UnsupportedDialect +@callback +def _async_delete_issue_deprecated_version(hass: HomeAssistant, issue_id: str) -> None: + """Delete a deprecated database version repair issue.""" + ir.async_delete_issue(hass, DOMAIN, issue_id) + + +@callback +def _async_create_issue_deprecated_version( + hass: HomeAssistant, + server_version: AwesomeVersion, + database_engine: str, + min_version: AwesomeVersion, + breaks_in_ha_version: str, +) -> None: + """Warn about upcoming unsupported database version.""" + ir.async_create_issue( + hass, + DOMAIN, + "database_engine_too_old", + is_fixable=False, + severity=ir.IssueSeverity.WARNING, + translation_key="database_engine_too_old", + translation_placeholders={ + "database_engine": database_engine, + "server_version": str(server_version), + "min_version": str(min_version), + }, + breaks_in_ha_version=breaks_in_ha_version, + ) + + +@callback +def _async_create_issue_not_supported_lts( + hass: HomeAssistant, + server_version: AwesomeVersion, + database_engine: str, + lts_versions: str, + breaks_in_ha_version: str, +) -> None: + """Warn about a database version that is not a supported LTS release.""" + ir.async_create_issue( + hass, + DOMAIN, + "database_engine_not_supported_lts", + is_fixable=False, + severity=ir.IssueSeverity.WARNING, + translation_key="database_engine_not_supported_lts", + translation_placeholders={ + "database_engine": database_engine, + "server_version": str(server_version), + "lts_versions": lts_versions, + }, + breaks_in_ha_version=breaks_in_ha_version, + ) + + +def _check_deprecated_version( + hass: HomeAssistant, + server_version: AwesomeVersion, + database_engine: str, + upcoming_min_version: tuple[AwesomeVersion, str], +) -> None: + """Create or remove the issue about an upcoming unsupported database version.""" + min_version, breaks_in_ha_version = upcoming_min_version + if server_version < min_version: + hass.add_job( + _async_create_issue_deprecated_version, + hass, + server_version, + database_engine, + min_version, + breaks_in_ha_version, + ) + else: + hass.add_job( + _async_delete_issue_deprecated_version, hass, "database_engine_too_old" + ) + + +def _check_lts_version( + hass: HomeAssistant, + server_version: AwesomeVersion, + database_engine: str, + lts_support: _LTSVersionSupport, +) -> None: + """Warn unless the version is on a supported LTS series or newer than we know. + + MariaDB and MySQL only support long-term support (LTS) releases for years; + short-term releases and end-of-life LTS series are deprecated. Versions newer + than the latest known non-LTS release are assumed supported to avoid warning + about releases we don't know about yet. + """ + series = (server_version.section(0), server_version.section(1)) + if ( + series in lts_support.supported_series + or series > lts_support.latest_non_lts_series + ): + hass.add_job( + _async_delete_issue_deprecated_version, + hass, + "database_engine_not_supported_lts", + ) + else: + lts_versions = ", ".join( + f"{major}.{minor}" for major, minor in sorted(lts_support.supported_series) + ) + hass.add_job( + _async_create_issue_not_supported_lts, + hass, + server_version, + database_engine, + lts_versions, + lts_support.breaks_in_ha_version, + ) + + def _extract_version_from_server_response_or_raise( server_response: str, ) -> AwesomeVersion: @@ -490,6 +650,10 @@ def setup_connection_for_dialect( _raise_if_version_unsupported( version or version_string, "MariaDB", MIN_VERSION_MARIA_DB ) + # No elif here since _raise_if_version_unsupported raises + _check_lts_version( + instance.hass, version, "MariaDB", SUPPORTED_MARIA_DB_LTS + ) if version and ( (version < RECOMMENDED_MIN_VERSION_MARIA_DB) or (MARIA_DB_106 <= version < RECOMMENDED_MIN_VERSION_MARIA_DB_106) @@ -516,6 +680,7 @@ def setup_connection_for_dialect( # MySQL # https://github.com/home-assistant/core/issues/137178 slow_dependent_subquery = True + _check_lts_version(instance.hass, version, "MySQL", SUPPORTED_MYSQL_LTS) # Ensure all times are using UTC to avoid issues with daylight savings execute_on_connection(dbapi_connection, "SET time_zone = '+00:00'") @@ -535,6 +700,10 @@ def setup_connection_for_dialect( _raise_if_version_unsupported( version or version_string, "PostgreSQL", MIN_VERSION_PGSQL ) + # No elif here since _raise_if_version_unsupported raises + _check_deprecated_version( + instance.hass, version, "PostgreSQL", UPCOMING_MIN_VERSION_PGSQL + ) else: _fail_unsupported_dialect(dialect_name) diff --git a/homeassistant/components/remember_the_milk/__init__.py b/homeassistant/components/remember_the_milk/__init__.py index df9eec0622f1..1cec425caf77 100644 --- a/homeassistant/components/remember_the_milk/__init__.py +++ b/homeassistant/components/remember_the_milk/__init__.py @@ -1,26 +1,33 @@ -"""Support to interact with Remember The Milk.""" +"""The Remember The Milk integration.""" -from rtmapi import Rtm +from copy import deepcopy +from dataclasses import dataclass +from typing import Any + +from aiortm import AioRTMClient, AioRTMError, Auth, AuthError import voluptuous as vol -from homeassistant.components import configurator -from homeassistant.const import CONF_API_KEY, CONF_ID, CONF_NAME -from homeassistant.core import HomeAssistant +from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry +from homeassistant.const import ( + CONF_API_KEY, + CONF_ID, + CONF_NAME, + CONF_TOKEN, + CONF_USERNAME, +) +from homeassistant.core import DOMAIN as HOMEASSISTANT_DOMAIN, HomeAssistant +from homeassistant.data_entry_flow import FlowResultType +from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady from homeassistant.helpers import config_validation as cv +from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.entity_component import EntityComponent +from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue from homeassistant.helpers.typing import ConfigType -from .const import LOGGER +from .const import CONF_SHARED_SECRET, DOMAIN, LOGGER from .entity import RememberTheMilkEntity from .storage import RememberTheMilkConfiguration -# httplib2 is a transitive dependency from RtmAPI. If this dependency is not -# set explicitly, the library does not work. - -DOMAIN = "remember_the_milk" - -CONF_SHARED_SECRET = "shared_secret" - RTM_SCHEMA = vol.Schema( { vol.Required(CONF_NAME): cv.string, @@ -42,114 +49,161 @@ SERVICE_SCHEMA_CREATE_TASK = vol.Schema( SERVICE_SCHEMA_COMPLETE_TASK = vol.Schema({vol.Required(CONF_ID): cv.string}) +DATA_COMPONENT = "component" +DATA_STORAGE = "storage" -def setup(hass: HomeAssistant, config: ConfigType) -> bool: +type RememberTheMilkConfigEntry = ConfigEntry[RememberTheMilkData] + + +@dataclass +class RememberTheMilkData: + """Runtime data for a Remember The Milk config entry.""" + + entity_id: str + + +async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up the Remember the milk component.""" - component = EntityComponent[RememberTheMilkEntity](LOGGER, DOMAIN, hass) + # pylint: disable-next=home-assistant-use-runtime-data + hass.data[DOMAIN] = {} + # pylint: disable-next=home-assistant-use-runtime-data + hass.data[DOMAIN][DATA_COMPONENT] = EntityComponent[RememberTheMilkEntity]( + LOGGER, DOMAIN, hass + ) + # pylint: disable-next=home-assistant-use-runtime-data + storage = hass.data[DOMAIN][DATA_STORAGE] = RememberTheMilkConfiguration(hass) + await hass.async_add_executor_job(storage.setup) + if DOMAIN not in config: + return True - stored_rtm_config = RememberTheMilkConfiguration(hass) - for rtm_config in config[DOMAIN]: - account_name = rtm_config[CONF_NAME] - LOGGER.debug("Adding Remember the milk account %s", account_name) - api_key = rtm_config[CONF_API_KEY] - shared_secret = rtm_config[CONF_SHARED_SECRET] - token = stored_rtm_config.get_token(account_name) - if token: - LOGGER.debug("found token for account %s", account_name) - _create_instance( - hass, - account_name, - api_key, - shared_secret, - token, - stored_rtm_config, - component, - ) - else: - _register_new_account( - hass, account_name, api_key, shared_secret, stored_rtm_config, component - ) - - LOGGER.debug("Finished adding all Remember the milk accounts") + for rtm_config in deepcopy(config[DOMAIN]): + hass.async_create_task(_async_import(hass, storage, rtm_config)) return True -def _create_instance( +async def _async_import( hass: HomeAssistant, - account_name: str, - api_key: str, - shared_secret: str, - token: str, - stored_rtm_config: RememberTheMilkConfiguration, - component: EntityComponent[RememberTheMilkEntity], + storage: RememberTheMilkConfiguration, + rtm_config: dict[str, Any], ) -> None: - entity = RememberTheMilkEntity( - account_name, api_key, shared_secret, token, stored_rtm_config + """Import a YAML configured account and create a repair issue.""" + name = rtm_config[CONF_NAME] + token = storage.get_token(name) + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_IMPORT}, + data=rtm_config | {CONF_TOKEN: token}, ) - component.add_entities([entity]) - hass.services.register( + if ( + result["type"] is FlowResultType.ABORT + and result["reason"] != "already_configured" + ): + async_create_issue( + hass, + DOMAIN, + f"deprecated_yaml_import_issue_{result['reason']}", + breaks_in_ha_version="2027.3.0", + is_fixable=False, + issue_domain=DOMAIN, + severity=IssueSeverity.WARNING, + translation_key=f"deprecated_yaml_import_issue_{result['reason']}", + translation_placeholders={ + "domain": DOMAIN, + "integration_title": "Remember The Milk", + }, + ) + return + + async_create_issue( + hass, + HOMEASSISTANT_DOMAIN, + f"deprecated_yaml_{DOMAIN}", + breaks_in_ha_version="2027.3.0", + is_fixable=False, + issue_domain=DOMAIN, + severity=IssueSeverity.WARNING, + translation_key="deprecated_yaml", + translation_placeholders={ + "domain": DOMAIN, + "integration_title": "Remember The Milk", + }, + ) + + +async def async_setup_entry( + hass: HomeAssistant, entry: RememberTheMilkConfigEntry +) -> bool: + """Set up Remember The Milk from a config entry.""" + # pylint: disable-next=home-assistant-use-runtime-data + component: EntityComponent[RememberTheMilkEntity] = hass.data[DOMAIN][ + DATA_COMPONENT + ] + # pylint: disable-next=home-assistant-use-runtime-data + storage: RememberTheMilkConfiguration = hass.data[DOMAIN][DATA_STORAGE] + + rtm_config = entry.data + account_name: str = rtm_config[CONF_USERNAME] + LOGGER.debug("Adding Remember the milk account %s", account_name) + api_key: str = rtm_config[CONF_API_KEY] + shared_secret: str = rtm_config[CONF_SHARED_SECRET] + token: str = rtm_config[CONF_TOKEN] + client = AioRTMClient( + Auth( + client_session=async_get_clientsession(hass), + api_key=api_key, + shared_secret=shared_secret, + auth_token=token, + permission="delete", + ) + ) + + token_valid = True + try: + await client.rtm.api.check_token() + except AuthError: + token_valid = False + except AioRTMError as err: + raise ConfigEntryNotReady from err + + # The entity will be deprecated when a todo platform is added. + entity = RememberTheMilkEntity( + name=account_name, + client=client, + config_entry_id=entry.entry_id, + storage=storage, + token_valid=token_valid, + ) + await component.async_add_entities([entity]) + entry.runtime_data = RememberTheMilkData(entity_id=entity.entity_id) + + # The services are registered here for now because they need the account name. + # The services will be deprecated when a todo platform is added. + # pylint: disable=home-assistant-service-registered-in-setup-entry + hass.services.async_register( DOMAIN, f"{account_name}_create_task", entity.create_task, schema=SERVICE_SCHEMA_CREATE_TASK, ) - hass.services.register( + hass.services.async_register( DOMAIN, f"{account_name}_complete_task", entity.complete_task, schema=SERVICE_SCHEMA_COMPLETE_TASK, ) + if not token_valid: + raise ConfigEntryAuthFailed("Invalid token") -def _register_new_account( - hass: HomeAssistant, - account_name: str, - api_key: str, - shared_secret: str, - stored_rtm_config: RememberTheMilkConfiguration, - component: EntityComponent[RememberTheMilkEntity], -) -> None: - api = Rtm(api_key, shared_secret, "write", None) - url, frob = api.authenticate_desktop() - LOGGER.debug("Sent authentication request to server") + return True - def register_account_callback(fields: list[dict[str, str]]) -> None: - """Call for register the configurator.""" - api.retrieve_token(frob) - token = api.token - if api.token is None: - LOGGER.error("Failed to register, please try again") - configurator.notify_errors( - hass, request_id, "Failed to register, please try again." - ) - return - stored_rtm_config.set_token(account_name, token) - LOGGER.debug("Retrieved new token from server") - - _create_instance( - hass, - account_name, - api_key, - shared_secret, - token, - stored_rtm_config, - component, - ) - - configurator.request_done(hass, request_id) - - request_id = configurator.request_config( - hass, - f"{DOMAIN} - {account_name}", - callback=register_account_callback, - description=( - "You need to log in to Remember The Milk to" - "connect your account. \n\n" - "Step 1: Click on the link 'Remember The Milk login'\n\n" - "Step 2: Click on 'login completed'" - ), - link_name="Remember The Milk login", - link_url=url, - submit_caption="login completed", - ) +async def async_unload_entry( + hass: HomeAssistant, entry: RememberTheMilkConfigEntry +) -> bool: + """Unload a config entry.""" + component: EntityComponent[RememberTheMilkEntity] = hass.data[DOMAIN][ + DATA_COMPONENT + ] + await component.async_remove_entity(entry.runtime_data.entity_id) + return True diff --git a/homeassistant/components/remember_the_milk/config_flow.py b/homeassistant/components/remember_the_milk/config_flow.py new file mode 100644 index 000000000000..641262a7ce50 --- /dev/null +++ b/homeassistant/components/remember_the_milk/config_flow.py @@ -0,0 +1,180 @@ +"""Config flow for Remember The Milk integration.""" + +import asyncio +from typing import Any, override + +from aiortm import AioRTMError, Auth, AuthError +import voluptuous as vol + +from homeassistant.config_entries import ConfigFlow, ConfigFlowResult +from homeassistant.const import CONF_API_KEY, CONF_NAME, CONF_TOKEN, CONF_USERNAME +from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.helpers.selector import ( + TextSelector, + TextSelectorConfig, + TextSelectorType, +) + +from .const import CONF_SHARED_SECRET, DOMAIN, LOGGER + +TOKEN_TIMEOUT_SEC = 30 + +STEP_USER_DATA_SCHEMA = vol.Schema( + { + vol.Required(CONF_API_KEY): TextSelector( + TextSelectorConfig(type=TextSelectorType.PASSWORD) + ), + vol.Required(CONF_SHARED_SECRET): TextSelector( + TextSelectorConfig(type=TextSelectorType.PASSWORD) + ), + } +) + + +class RTMConfigFlow(ConfigFlow, domain=DOMAIN): + """Handle a config flow for Remember The Milk.""" + + VERSION = 1 + + def __init__(self) -> None: + """Initialize the config flow.""" + self._auth: Auth | None = None + self._url: str | None = None + self._frob: str | None = None + self._auth_credentials: dict[str, str] | None = None + + def _get_auth( + self, api_key: str, shared_secret: str, token: str | None = None + ) -> Auth: + """Return an Auth client for the given credentials.""" + return Auth( + client_session=async_get_clientsession(self.hass), + api_key=api_key, + shared_secret=shared_secret, + auth_token=token, + permission="delete", + ) + + @override + 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: + self._auth_credentials = user_input + auth = self._auth = self._get_auth( + user_input[CONF_API_KEY], user_input[CONF_SHARED_SECRET] + ) + try: + self._url, self._frob = await auth.authenticate_desktop() + except AuthError: + errors["base"] = "invalid_auth" + except AioRTMError: + errors["base"] = "cannot_connect" + except Exception: # noqa: BLE001 pylint: disable=broad-except + LOGGER.exception("Unexpected exception") + errors["base"] = "unknown" + else: + return await self.async_step_auth() + + return self.async_show_form( + step_id="user", + data_schema=self.add_suggested_values_to_schema( + STEP_USER_DATA_SCHEMA, + user_input, + ), + errors=errors, + ) + + async def async_step_auth( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Authorize the application.""" + assert self._url is not None + if user_input is not None: + return await self._get_token() + + return self.async_show_form( + step_id="auth", description_placeholders={"url": self._url} + ) + + async def _get_token(self) -> ConfigFlowResult: + """Get token and create config entry.""" + assert self._auth is not None + assert self._frob is not None + assert self._auth_credentials is not None + try: + async with asyncio.timeout(TOKEN_TIMEOUT_SEC): + token_data = await self._auth.get_token(self._frob) + except TimeoutError: + return self.async_abort(reason="timeout_token") + except AuthError: + return self.async_abort(reason="invalid_auth") + except AioRTMError: + return self.async_abort(reason="cannot_connect") + except Exception: # noqa: BLE001 pylint: disable=broad-except + LOGGER.exception("Unexpected exception") + return self.async_abort(reason="unknown") + + return await self._async_create_entry( + token_data, + self._auth_credentials[CONF_API_KEY], + self._auth_credentials[CONF_SHARED_SECRET], + ) + + async def _async_create_entry( + self, + token_data: dict[str, Any], + api_key: str, + shared_secret: str, + ) -> ConfigFlowResult: + """Create the config entry from token data. + + The token data has the same structure whether it comes from get_token + or check_token. + """ + await self.async_set_unique_id(token_data["user"]["id"]) + self._abort_if_unique_id_configured() + return self.async_create_entry( + title=token_data["user"]["fullname"], + data={ + CONF_API_KEY: api_key, + CONF_SHARED_SECRET: shared_secret, + CONF_TOKEN: token_data["token"], + CONF_USERNAME: token_data["user"]["username"], + }, + ) + + async def async_step_import(self, import_info: dict[str, Any]) -> ConfigFlowResult: + """Import a config entry from YAML. + + The token, looked up from legacy storage in async_setup, is passed in + the import data. Without a valid token the import is aborted so the user + sets the integration up via the UI. A repair issue is raised in + async_setup for both the success and failure cases. + """ + name = import_info.pop(CONF_NAME) + self._async_abort_entries_match({CONF_USERNAME: name}) + token = import_info.get(CONF_TOKEN) + if token is None: + return self.async_abort(reason="invalid_auth") + auth = self._get_auth( + import_info[CONF_API_KEY], import_info[CONF_SHARED_SECRET], token + ) + try: + token_data = await auth.check_token() + except AuthError: + return self.async_abort(reason="invalid_auth") + except AioRTMError: + return self.async_abort(reason="cannot_connect") + except Exception: # noqa: BLE001 pylint: disable=broad-except + LOGGER.exception("Unexpected exception") + return self.async_abort(reason="unknown") + if token_data["user"]["username"] != name: + return self.async_abort(reason="invalid_auth") + return await self._async_create_entry( + token_data, + import_info[CONF_API_KEY], + import_info[CONF_SHARED_SECRET], + ) diff --git a/homeassistant/components/remember_the_milk/const.py b/homeassistant/components/remember_the_milk/const.py index 2fccbf3ee527..8109b6aa98ee 100644 --- a/homeassistant/components/remember_the_milk/const.py +++ b/homeassistant/components/remember_the_milk/const.py @@ -2,4 +2,6 @@ import logging +CONF_SHARED_SECRET = "shared_secret" +DOMAIN = "remember_the_milk" LOGGER = logging.getLogger(__package__) diff --git a/homeassistant/components/remember_the_milk/entity.py b/homeassistant/components/remember_the_milk/entity.py index 174a69bca91a..deac3b2f5ab7 100644 --- a/homeassistant/components/remember_the_milk/entity.py +++ b/homeassistant/components/remember_the_milk/entity.py @@ -2,10 +2,10 @@ from typing import override -from rtmapi import Rtm, RtmRequestFailedException +from aiortm import AioRTMClient, AioRTMError, AuthError from homeassistant.const import CONF_ID, CONF_NAME, STATE_OK -from homeassistant.core import ServiceCall +from homeassistant.core import ServiceCall, callback from homeassistant.helpers.entity import Entity from .const import LOGGER @@ -17,42 +17,21 @@ class RememberTheMilkEntity(Entity): def __init__( self, + *, name: str, - api_key: str, - shared_secret: str, - token: str, - rtm_config: RememberTheMilkConfiguration, + client: AioRTMClient, + config_entry_id: str, + storage: RememberTheMilkConfiguration, + token_valid: bool, ) -> None: """Create new instance of Remember The Milk component.""" self._name = name - self._api_key = api_key - self._shared_secret = shared_secret - self._token = token - self._rtm_config = rtm_config - self._rtm_api = Rtm(api_key, shared_secret, "delete", token) - self._token_valid = False - self._check_token() - LOGGER.debug("Instance created for account %s", self._name) + self._rtm_config = storage + self._client = client + self._config_entry_id = config_entry_id + self._token_valid = token_valid - def _check_token(self) -> bool: - """Check if the API token is still valid. - - If it is not valid any more, delete it from the configuration. This - will trigger a new authentication process. - """ - valid = self._rtm_api.token_valid() - if not valid: - LOGGER.error( - "Token for account %s is invalid. You need to register again!", - self.name, - ) - self._rtm_config.delete_token(self._name) - self._token_valid = False - else: - self._token_valid = True - return self._token_valid - - def create_task(self, call: ServiceCall) -> None: + async def create_task(self, call: ServiceCall) -> None: """Create a new task on Remember The Milk. You can use the smart syntax to define the attributes of a new task, @@ -60,31 +39,37 @@ class RememberTheMilkEntity(Entity): due date to today. """ try: - task_name = call.data[CONF_NAME] - hass_id = call.data.get(CONF_ID) - rtm_id = None + task_name: str = call.data[CONF_NAME] + hass_id: str | None = call.data.get(CONF_ID) + rtm_id: tuple[int, int, int] | None = None if hass_id is not None: - rtm_id = self._rtm_config.get_rtm_id(self._name, hass_id) - result = self._rtm_api.rtm.timelines.create() - timeline = result.timeline.value + rtm_id = await self.hass.async_add_executor_job( + self._rtm_config.get_rtm_id, self._name, hass_id + ) + timeline_response = await self._client.rtm.timelines.create() + timeline = timeline_response.timeline if rtm_id is None: - result = self._rtm_api.rtm.tasks.add( - timeline=timeline, name=task_name, parse="1" + add_response = await self._client.rtm.tasks.add( + timeline=timeline, name=task_name, parse=True ) LOGGER.debug( "Created new task '%s' in account %s", task_name, self.name ) - if hass_id is not None: - self._rtm_config.set_rtm_id( - self._name, - hass_id, - result.list.id, - result.list.taskseries.id, - result.list.taskseries.task.id, - ) + if hass_id is None: + return + task_list = add_response.task_list + taskseries = task_list.taskseries[0] + await self.hass.async_add_executor_job( + self._rtm_config.set_rtm_id, + self._name, + hass_id, + task_list.id, + taskseries.id, + taskseries.task[0].id, + ) else: - self._rtm_api.rtm.tasks.setName( + await self._client.rtm.tasks.set_name( name=task_name, list_id=rtm_id[0], taskseries_id=rtm_id[1], @@ -97,17 +82,26 @@ class RememberTheMilkEntity(Entity): self.name, task_name, ) - except RtmRequestFailedException as rtm_exception: + except AuthError as err: + LOGGER.error( + "Invalid authentication when creating task for account %s: %s", + self._name, + err, + ) + self._handle_token(False) + except AioRTMError as err: LOGGER.error( "Error creating new Remember The Milk task for account %s: %s", self._name, - rtm_exception, + err, ) - def complete_task(self, call: ServiceCall) -> None: + async def complete_task(self, call: ServiceCall) -> None: """Complete a task that was previously created by this component.""" hass_id = call.data[CONF_ID] - rtm_id = self._rtm_config.get_rtm_id(self._name, hass_id) + rtm_id = await self.hass.async_add_executor_job( + self._rtm_config.get_rtm_id, self._name, hass_id + ) if rtm_id is None: LOGGER.error( ( @@ -119,21 +113,32 @@ class RememberTheMilkEntity(Entity): ) return try: - result = self._rtm_api.rtm.timelines.create() - timeline = result.timeline.value - self._rtm_api.rtm.tasks.complete( + result = await self._client.rtm.timelines.create() + timeline = result.timeline + await self._client.rtm.tasks.complete( list_id=rtm_id[0], taskseries_id=rtm_id[1], task_id=rtm_id[2], timeline=timeline, ) - self._rtm_config.delete_rtm_id(self._name, hass_id) + await self.hass.async_add_executor_job( + self._rtm_config.delete_rtm_id, self._name, hass_id + ) LOGGER.debug("Completed task with id %s in account %s", hass_id, self._name) - except RtmRequestFailedException as rtm_exception: + except AuthError as err: LOGGER.error( - "Error creating new Remember The Milk task for account %s: %s", + "Invalid authentication when completing task with id %s for account %s: %s", + hass_id, self._name, - rtm_exception, + err, + ) + self._handle_token(False) + except AioRTMError as err: + LOGGER.error( + "Error completing task with id %s for account %s: %s", + hass_id, + self._name, + err, ) @property @@ -149,3 +154,11 @@ class RememberTheMilkEntity(Entity): if not self._token_valid: return "API token invalid" return STATE_OK + + @callback + def _handle_token(self, token_valid: bool) -> None: + self._token_valid = token_valid + self.async_write_ha_state() + self.hass.async_create_task( + self.hass.config_entries.async_reload(self._config_entry_id) + ) diff --git a/homeassistant/components/remember_the_milk/manifest.json b/homeassistant/components/remember_the_milk/manifest.json index 13c37d56dba0..69add8e1eb44 100644 --- a/homeassistant/components/remember_the_milk/manifest.json +++ b/homeassistant/components/remember_the_milk/manifest.json @@ -2,10 +2,11 @@ "domain": "remember_the_milk", "name": "Remember The Milk", "codeowners": [], - "dependencies": ["configurator"], + "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/remember_the_milk", + "integration_type": "service", "iot_class": "cloud_push", - "loggers": ["rtmapi"], + "loggers": ["aiortm"], "quality_scale": "legacy", - "requirements": ["RtmAPI==0.7.2", "httplib2==0.20.4"] + "requirements": ["aiortm==0.19.0"] } diff --git a/homeassistant/components/remember_the_milk/storage.py b/homeassistant/components/remember_the_milk/storage.py index 07b04c32b1cf..0d7658403ccd 100644 --- a/homeassistant/components/remember_the_milk/storage.py +++ b/homeassistant/components/remember_the_milk/storage.py @@ -1,8 +1,8 @@ -"""Store RTM configuration in Home Assistant storage.""" +"""Provide storage for Remember The Milk integration.""" import json from pathlib import Path -from typing import cast +from typing import Any, cast from homeassistant.const import CONF_TOKEN from homeassistant.core import HomeAssistant @@ -22,7 +22,10 @@ class RememberTheMilkConfiguration: def __init__(self, hass: HomeAssistant) -> None: """Create new instance of configuration.""" self._config_file_path = hass.config.path(CONFIG_FILE_NAME) - self._config = {} + self._config: dict[str, Any] = {} + + def setup(self) -> None: + """Set up the configuration.""" LOGGER.debug("Loading configuration from file: %s", self._config_file_path) try: self._config = json.loads( @@ -48,24 +51,8 @@ class RememberTheMilkConfiguration: ) def get_token(self, profile_name: str) -> str | None: - """Get the server token for a profile.""" - if profile_name in self._config: - return cast(str, self._config[profile_name][CONF_TOKEN]) - return None - - def set_token(self, profile_name: str, token: str) -> None: - """Store a new server token for a profile.""" - self._initialize_profile(profile_name) - self._config[profile_name][CONF_TOKEN] = token - self._save_config() - - def delete_token(self, profile_name: str) -> None: - """Delete a token for a profile. - - Usually called when the token has expired. - """ - self._config.pop(profile_name, None) - self._save_config() + """Get the stored token for a profile, if any.""" + return cast("str | None", self._config.get(profile_name, {}).get(CONF_TOKEN)) def _initialize_profile(self, profile_name: str) -> None: """Initialize the data structures for a profile.""" @@ -76,7 +63,7 @@ class RememberTheMilkConfiguration: def get_rtm_id( self, profile_name: str, hass_id: str - ) -> tuple[str, str, str] | None: + ) -> tuple[int, int, int] | None: """Get the RTM ids for a Home Assistant task ID. The id of a RTM tasks consists of the tuple: @@ -86,22 +73,28 @@ class RememberTheMilkConfiguration: ids = self._config[profile_name][CONF_ID_MAP].get(hass_id) if ids is None: return None - return ids[CONF_LIST_ID], ids[CONF_TIMESERIES_ID], ids[CONF_TASK_ID] + # Legacy storage stored the ids as strings, so convert to int. + return ( + int(ids[CONF_LIST_ID]), + int(ids[CONF_TIMESERIES_ID]), + int(ids[CONF_TASK_ID]), + ) def set_rtm_id( self, profile_name: str, hass_id: str, - list_id: str, - time_series_id: str, - rtm_task_id: str, + list_id: int, + time_series_id: int, + rtm_task_id: int, ) -> None: - """Add/Update the RTM task ID for a Home Assistant task IS.""" + """Add/Update the RTM task ID for a Home Assistant task ID.""" self._initialize_profile(profile_name) + # Store the ids as strings to keep the legacy storage format. id_tuple = { - CONF_LIST_ID: list_id, - CONF_TIMESERIES_ID: time_series_id, - CONF_TASK_ID: rtm_task_id, + CONF_LIST_ID: str(list_id), + CONF_TIMESERIES_ID: str(time_series_id), + CONF_TASK_ID: str(rtm_task_id), } self._config[profile_name][CONF_ID_MAP][hass_id] = id_tuple self._save_config() diff --git a/homeassistant/components/remember_the_milk/strings.json b/homeassistant/components/remember_the_milk/strings.json index c615e5b6b40a..f50b2deb8884 100644 --- a/homeassistant/components/remember_the_milk/strings.json +++ b/homeassistant/components/remember_the_milk/strings.json @@ -1,4 +1,48 @@ { + "config": { + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_service%]", + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", + "timeout_token": "Timeout getting access token", + "unknown": "[%key:common::config_flow::error::unknown%]" + }, + "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%]" + }, + "step": { + "auth": { + "description": "Follow the link to authorize Home Assistant to access your Remember The Milk account. When done, click on the button below to continue.\n\n[Authorize]({url})" + }, + "user": { + "data": { + "api_key": "[%key:common::config_flow::data::api_key%]", + "shared_secret": "Shared secret" + }, + "data_description": { + "api_key": "The API key of your Remember The Milk API application.", + "shared_secret": "The shared secret of your Remember The Milk API application." + }, + "description": "Enter the API key and shared secret from a Remember The Milk API application. You can request these credentials using your Remember The Milk account." + } + } + }, + "issues": { + "deprecated_yaml_import_issue_cannot_connect": { + "description": "Configuring {integration_title} via YAML is deprecated and will be removed in a future release. While importing your configuration, a connection error occurred. Please restart Home Assistant to try again, or remove the {domain} configuration from your YAML and set the integration up via the UI.", + "title": "The {integration_title} YAML configuration is being removed" + }, + "deprecated_yaml_import_issue_invalid_auth": { + "description": "Configuring {integration_title} via YAML is deprecated and will be removed in a future release. While importing your configuration, a stored authentication token could not be found or was invalid. Please remove the {domain} configuration from your YAML and set the integration up via the UI.", + "title": "The {integration_title} YAML configuration is being removed" + }, + "deprecated_yaml_import_issue_unknown": { + "description": "Configuring {integration_title} via YAML is deprecated and will be removed in a future release. While importing your configuration, an unknown error occurred. Please remove the {domain} configuration from your YAML and set the integration up via the UI.", + "title": "The {integration_title} YAML configuration is being removed" + } + }, "services": { "complete_task": { "description": "Completes a task that was previously created.", diff --git a/homeassistant/components/reolink/manifest.json b/homeassistant/components/reolink/manifest.json index 0416a43ebd84..1ffc8cfdb97c 100644 --- a/homeassistant/components/reolink/manifest.json +++ b/homeassistant/components/reolink/manifest.json @@ -20,5 +20,5 @@ "iot_class": "local_push", "loggers": ["reolink_aio"], "quality_scale": "platinum", - "requirements": ["reolink-aio==0.21.8"] + "requirements": ["reolink-aio==0.21.9"] } diff --git a/homeassistant/components/reolink/number.py b/homeassistant/components/reolink/number.py index 5be6ef25bec5..5dcb0fcb6c1e 100644 --- a/homeassistant/components/reolink/number.py +++ b/homeassistant/components/reolink/number.py @@ -743,6 +743,7 @@ SMART_AI_NUMBER_ENTITIES = ( ReolinkSmartAINumberEntityDescription( key="crossline_sensitivity", smart_type="crossline", + cmd_key="527", cmd_id=527, translation_key="crossline_sensitivity", entity_category=EntityCategory.CONFIG, @@ -761,6 +762,7 @@ SMART_AI_NUMBER_ENTITIES = ( ReolinkSmartAINumberEntityDescription( key="intrusion_sensitivity", smart_type="intrusion", + cmd_key="529", cmd_id=529, translation_key="intrusion_sensitivity", entity_category=EntityCategory.CONFIG, @@ -779,6 +781,7 @@ SMART_AI_NUMBER_ENTITIES = ( ReolinkSmartAINumberEntityDescription( key="linger_sensitivity", smart_type="loitering", + cmd_key="531", cmd_id=531, translation_key="linger_sensitivity", entity_category=EntityCategory.CONFIG, @@ -797,6 +800,7 @@ SMART_AI_NUMBER_ENTITIES = ( ReolinkSmartAINumberEntityDescription( key="forgotten_item_sensitivity", smart_type="legacy", + cmd_key="549", cmd_id=549, translation_key="forgotten_item_sensitivity", entity_registry_enabled_default=False, @@ -813,6 +817,7 @@ SMART_AI_NUMBER_ENTITIES = ( ReolinkSmartAINumberEntityDescription( key="taken_item_sensitivity", smart_type="loss", + cmd_key="551", cmd_id=551, translation_key="taken_item_sensitivity", entity_registry_enabled_default=False, @@ -829,6 +834,7 @@ SMART_AI_NUMBER_ENTITIES = ( ReolinkSmartAINumberEntityDescription( key="intrusion_delay", smart_type="intrusion", + cmd_key="529", cmd_id=529, translation_key="intrusion_delay", entity_registry_enabled_default=False, @@ -847,6 +853,7 @@ SMART_AI_NUMBER_ENTITIES = ( ReolinkSmartAINumberEntityDescription( key="linger_delay", smart_type="loitering", + cmd_key="531", cmd_id=531, translation_key="linger_delay", entity_registry_enabled_default=False, @@ -864,6 +871,7 @@ SMART_AI_NUMBER_ENTITIES = ( ReolinkSmartAINumberEntityDescription( key="forgotten_item_delay", smart_type="legacy", + cmd_key="549", cmd_id=549, translation_key="forgotten_item_delay", entity_registry_enabled_default=False, @@ -882,6 +890,7 @@ SMART_AI_NUMBER_ENTITIES = ( ReolinkSmartAINumberEntityDescription( key="taken_item_delay", smart_type="loss", + cmd_key="551", cmd_id=551, translation_key="taken_item_delay", entity_registry_enabled_default=False, diff --git a/homeassistant/components/repairs/__init__.py b/homeassistant/components/repairs/__init__.py index 99bd11597f8e..73aea9895559 100644 --- a/homeassistant/components/repairs/__init__.py +++ b/homeassistant/components/repairs/__init__.py @@ -5,13 +5,14 @@ from homeassistant.helpers import config_validation as cv from homeassistant.helpers.typing import ConfigType from . import issue_handler, websocket_api -from .const import DOMAIN +from .const import DOMAIN, FlowType from .issue_handler import ConfirmRepairFlow, RepairsFlowManager from .models import RepairsFlow, RepairsFlowResult __all__ = [ "DOMAIN", "ConfirmRepairFlow", + "FlowType", "RepairsFlow", "RepairsFlowManager", "RepairsFlowResult", diff --git a/homeassistant/components/repairs/const.py b/homeassistant/components/repairs/const.py index cddc5edcffd7..49de0db186ad 100644 --- a/homeassistant/components/repairs/const.py +++ b/homeassistant/components/repairs/const.py @@ -1,3 +1,13 @@ """Constants for the Repairs integration.""" +from enum import StrEnum + DOMAIN = "repairs" + + +class FlowType(StrEnum): + """Flow types supported in `next_flow` of RepairsFlowResult.""" + + CONFIG_FLOW = "config_flow" + OPTIONS_FLOW = "options_flow" + CONFIG_SUBENTRIES_FLOW = "config_subentries_flow" diff --git a/homeassistant/components/repairs/issue_handler.py b/homeassistant/components/repairs/issue_handler.py index f816d0c3f211..45da9615b3bb 100644 --- a/homeassistant/components/repairs/issue_handler.py +++ b/homeassistant/components/repairs/issue_handler.py @@ -62,7 +62,9 @@ class RepairsFlowManager( issue_registry = ir.async_get(self.hass) issue = issue_registry.async_get_issue(handler_key, issue_id) if issue is None or not issue.is_fixable: - raise data_entry_flow.UnknownStep + raise data_entry_flow.UnknownStep( + f"issue id {issue_id} is {'not found' if issue is None else 'not fixable'}" + ) platforms: LazyIntegrationPlatforms[RepairsProtocol] = self.hass.data[DOMAIN][ "platforms" diff --git a/homeassistant/components/repairs/models.py b/homeassistant/components/repairs/models.py index bdaee67fe1f8..12d0ca48f7b8 100644 --- a/homeassistant/components/repairs/models.py +++ b/homeassistant/components/repairs/models.py @@ -1,12 +1,26 @@ """Models for Repairs.""" -from typing import Protocol +from collections.abc import Mapping +from typing import Any, Protocol, override from homeassistant import data_entry_flow -from homeassistant.core import HomeAssistant +from homeassistant.config_entries import ( + ConfigEntry, + ConfigFlowResult, + SubentryFlowResult, +) +from homeassistant.core import HomeAssistant, callback -# Placeholder TypeAlias for future TypedDict to handle next_flow. -RepairsFlowResult = data_entry_flow.FlowResult[data_entry_flow.FlowContext, str] +from .const import FlowType + + +class RepairsFlowResult( + data_entry_flow.FlowResult[data_entry_flow.FlowContext, str], total=False +): + """Typed result dict for repair flow.""" + + next_flow: tuple[FlowType, str] + result: ConfigEntry | None class RepairsFlow( @@ -17,6 +31,81 @@ class RepairsFlow( issue_id: str data: dict[str, str | int | float | None] | None + @override + @callback + def async_create_entry( + self, + *, + title: str | None = None, + data: Mapping[str, Any], + description: str | None = None, + description_placeholders: Mapping[str, str] | None = None, + next_flow: tuple[FlowType, str] | None = None, + ) -> RepairsFlowResult: + """Create an entry (fix a flow).""" + result: RepairsFlowResult = super().async_create_entry( + title=title, + data=data, + description=description, + description_placeholders=description_placeholders, + ) + + self._async_set_next_flow_if_valid(result, next_flow) + + return result + + @override + @callback + def async_abort( + self, + *, + reason: str, + description_placeholders: Mapping[str, str] | None = None, + next_flow: tuple[FlowType, str] | None = None, + ) -> RepairsFlowResult: + """Abort the flow (leave the issue unrepaired).""" + result: RepairsFlowResult = super().async_abort( + reason=reason, description_placeholders=description_placeholders + ) + + self._async_set_next_flow_if_valid(result, next_flow) + + return result + + @callback + def _async_set_next_flow_if_valid( + self, + result: RepairsFlowResult, + next_flow: tuple[FlowType, str] | None, + ) -> None: + """Validate and set next_flow in result if provided.""" + if next_flow is None: + return + flow_type, flow_id = next_flow + if flow_type not in FlowType: + raise data_entry_flow.UnknownFlow("Invalid next_flow FlowType") + entry_id: str | None = None + if flow_type == FlowType.CONFIG_FLOW: + config_flow: ConfigFlowResult = self.hass.config_entries.flow.async_get( + flow_id + ) + entry_id = config_flow["context"].get("entry_id") + elif flow_type == FlowType.CONFIG_SUBENTRIES_FLOW: + subentry_flow: SubentryFlowResult = ( + self.hass.config_entries.subentries.async_get(flow_id) + ) + entry_id, _ = subentry_flow["handler"] + else: # FlowType.OPTIONS_FLOW + config_flow = self.hass.config_entries.options.async_get(flow_id) + entry_id = config_flow["handler"] + # entry_id can be None for config flows creating a new config entry + result["result"] = ( + self.hass.config_entries.async_get_known_entry(entry_id) + if entry_id is not None + else None + ) + result["next_flow"] = next_flow + class RepairsProtocol(Protocol): """Define the format of repairs platforms.""" diff --git a/homeassistant/components/repairs/websocket_api.py b/homeassistant/components/repairs/websocket_api.py index 5f926e23095e..5519a270dfc9 100644 --- a/homeassistant/components/repairs/websocket_api.py +++ b/homeassistant/components/repairs/websocket_api.py @@ -1,5 +1,6 @@ """The repairs websocket API.""" +from collections.abc import Callable from http import HTTPStatus from typing import Any, override @@ -11,6 +12,7 @@ from homeassistant.auth.permissions.const import POLICY_EDIT from homeassistant.components import websocket_api from homeassistant.components.http.data_validator import RequestDataValidator from homeassistant.components.http.decorators import require_admin +from homeassistant.config_entries import ConfigEntry, UnknownEntry from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import issue_registry as ir from homeassistant.helpers.data_entry_flow import ( @@ -19,6 +21,7 @@ from homeassistant.helpers.data_entry_flow import ( ) from .const import DOMAIN +from .issue_handler import RepairsFlowManager @callback @@ -105,7 +108,20 @@ def ws_list_issues( connection.send_result(msg["id"], {"issues": issues}) -class RepairsFlowIndexView(FlowManagerIndexView): +def _prepare_repairs_flow_result_json( + result: data_entry_flow.FlowResult, + prepare_result_json: Callable[[data_entry_flow.FlowResult], dict[str, Any]], +) -> dict[str, Any]: + """Convert result to serializable JSON dict.""" + entry: ConfigEntry | None = result.pop("result", None) # type: ignore[typeddict-item] + data = prepare_result_json(result) + if entry is not None: + # Overwrite the ConfigEntry object with its json representation for frontend. + data["result"] = entry.as_json_fragment + return data + + +class RepairsFlowIndexView(FlowManagerIndexView[RepairsFlowManager]): """View to create issue fix flows.""" url = "/api/repairs/issues/fix" @@ -129,19 +145,28 @@ class RepairsFlowIndexView(FlowManagerIndexView): data["handler"], data={"issue_id": data["issue_id"]}, ) - except data_entry_flow.UnknownHandler: - return self.json_message("Invalid handler specified", HTTPStatus.NOT_FOUND) - except data_entry_flow.UnknownStep: + except data_entry_flow.UnknownFlow as ex: return self.json_message( - "Handler does not support user", HTTPStatus.BAD_REQUEST + f"Unknown flow{f': {ex!s}' if str(ex) else ''}", + HTTPStatus.NOT_FOUND, ) + except data_entry_flow.UnknownStep as ex: + return self.json_message(str(ex), HTTPStatus.BAD_REQUEST) + except UnknownEntry as ex: + return self.json_message( + f"Config entry {ex!s} not found in next_flow", HTTPStatus.BAD_REQUEST + ) + return self.json(self._prepare_result_json(result)) - return self.json( - self._prepare_result_json(result), - ) + @override + def _prepare_result_json( + self, result: data_entry_flow.FlowResult + ) -> dict[str, Any]: + """Convert result to JSON serializable dict.""" + return _prepare_repairs_flow_result_json(result, super()._prepare_result_json) -class RepairsFlowResourceView(FlowManagerResourceView): +class RepairsFlowResourceView(FlowManagerResourceView[RepairsFlowManager]): """View to interact with the option flow manager.""" url = "/api/repairs/issues/fix/{flow_id}" @@ -157,4 +182,18 @@ class RepairsFlowResourceView(FlowManagerResourceView): @override async def post(self, request: web.Request, flow_id: str) -> web.Response: """Handle a POST request.""" - return await super().post(request, flow_id) + try: + result = await super().post(request, flow_id) + except UnknownEntry as ex: + # Raised by _async_set_next_flow_if_valid in a RepairsFlow + return self.json_message( + f"Config entry {ex!s} not found in next_flow", HTTPStatus.BAD_REQUEST + ) + return result + + @override + def _prepare_result_json( + self, result: data_entry_flow.FlowResult + ) -> dict[str, Any]: + """Convert result to JSON serializable dict.""" + return _prepare_repairs_flow_result_json(result, super()._prepare_result_json) diff --git a/homeassistant/components/rfxtrx/__init__.py b/homeassistant/components/rfxtrx/__init__.py index d51bf3e75c40..91d54831b29f 100644 --- a/homeassistant/components/rfxtrx/__init__.py +++ b/homeassistant/components/rfxtrx/__init__.py @@ -252,10 +252,10 @@ async def async_setup_internal(hass: HomeAssistant, entry: ConfigEntry) -> None: def _updated_device(event: Event[EventDeviceRegistryUpdatedData]) -> None: if event.data["action"] != "remove": return - device_entry = device_registry.deleted_devices[event.data["device_id"]] - if entry.entry_id not in device_entry.config_entries: + device = event.data["device"] + if device["config_entry_id"] != entry.entry_id: return - device_id = get_device_tuple_from_identifiers(device_entry.identifiers) + device_id = get_device_tuple_from_identifiers(device["identifiers"]) if device_id: _remove_device(device_id) diff --git a/homeassistant/components/roborock/binary_sensor.py b/homeassistant/components/roborock/binary_sensor.py index ca86acc60ddb..b966717276ff 100644 --- a/homeassistant/components/roborock/binary_sensor.py +++ b/homeassistant/components/roborock/binary_sensor.py @@ -14,12 +14,14 @@ from homeassistant.components.binary_sensor import ( BinarySensorEntity, BinarySensorEntityDescription, ) -from homeassistant.const import ATTR_BATTERY_CHARGING, EntityCategory +from homeassistant.const import ATTR_BATTERY_CHARGING, EntityCategory, Platform from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers import entity_registry as er, issue_registry as ir from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.typing import StateType +from .const import DOMAIN from .coordinator import ( RoborockConfigEntry, RoborockCoordinatorType, @@ -29,6 +31,7 @@ from .coordinator import ( ) from .entity import RoborockCoordinatedEntityA01, RoborockCoordinatedEntityV1 from .models import DeviceState +from .util import deprecate_entity PARALLEL_UPDATES = 0 @@ -56,17 +59,6 @@ class RoborockBinarySensorDescriptionA01(BinarySensorEntityDescription): BINARY_SENSOR_DESCRIPTIONS = [ - RoborockBinarySensorDescription( - key="dry_status", - translation_key="mop_drying_status", - device_class=BinarySensorDeviceClass.RUNNING, - entity_category=EntityCategory.DIAGNOSTIC, - value_fn=lambda data: data.status.dry_status, - is_dock_entity=True, - support_fn=lambda api: api.device_features.is_field_supported( - StatusV2, StatusField.DRY_STATUS - ), - ), RoborockBinarySensorDescription( key="water_box_carriage_status", translation_key="mop_attached", @@ -147,6 +139,16 @@ BINARY_SENSOR_DESCRIPTIONS = [ ] +MOP_DRYING_BINARY_SENSOR_DESCRIPTION = RoborockBinarySensorDescription( + key="dry_status", + translation_key="mop_drying_status", + device_class=BinarySensorDeviceClass.RUNNING, + entity_category=EntityCategory.DIAGNOSTIC, + value_fn=lambda data: data.status.dry_status, + is_dock_entity=True, +) + + ZEO_BINARY_SENSOR_DESCRIPTIONS: list[RoborockBinarySensorDescriptionA01] = [ RoborockBinarySensorDescriptionA01( key="detergent_empty", @@ -174,6 +176,7 @@ async def async_setup_entry( ) -> None: """Set up the Roborock vacuum binary sensors.""" coordinators = config_entry.runtime_data + entity_registry = er.async_get(hass) @callback def async_add_coordinator_entities( @@ -187,6 +190,31 @@ async def async_setup_entry( for description in BINARY_SENSOR_DESCRIPTIONS if description.support_fn(coordinator.properties_api) ) + mop_drying_unique_id = ( + f"{MOP_DRYING_BINARY_SENSOR_DESCRIPTION.key}_{coordinator.duid_slug}" + ) + mop_drying_issue_id = f"deprecated_mop_drying_{coordinator.duid_slug}" + if not coordinator.properties_api.device_features.dock_features.is_dryable: + # The sensor was created for every device reporting the drying + # status data point, so a dock that cannot dry always read off. + if entity_id := entity_registry.async_get_entity_id( + Platform.BINARY_SENSOR, DOMAIN, mop_drying_unique_id + ): + entity_registry.async_remove(entity_id) + ir.async_delete_issue(hass, DOMAIN, mop_drying_issue_id) + elif deprecate_entity( + hass, + entity_registry, + platform_domain=Platform.BINARY_SENSOR, + entity_unique_id=mop_drying_unique_id, + issue_id=mop_drying_issue_id, + translation_key="deprecated_mop_drying", + ): + entities.append( + RoborockBinarySensorEntity( + coordinator, MOP_DRYING_BINARY_SENSOR_DESCRIPTION + ) + ) elif isinstance(coordinator, RoborockWashingMachineUpdateCoordinator): entities.extend( RoborockBinarySensorEntityA01(coordinator, description) diff --git a/homeassistant/components/roborock/strings.json b/homeassistant/components/roborock/strings.json index 105adcb40232..b5b4cffb4e99 100644 --- a/homeassistant/components/roborock/strings.json +++ b/homeassistant/components/roborock/strings.json @@ -738,6 +738,14 @@ "cloud_api_used": { "description": "The Roborock integration is unable to connect directly to {device_name} and falling back to the cloud API. This is not recommended as it can lead to rate limiting. Please make your vacuum accessible on the local network by your Home Assistant instance.", "title": "Cloud API used" + }, + "deprecated_mop_drying": { + "description": "The `{entity_id}` ({entity_name}) binary sensor is deprecated and has been replaced by the **Mop drying** switch, which reports the same state and can also start and stop drying.\n\nUpdate any dashboards, templates, automations or scripts to use the new switch entity, then disable `{entity_id}` to have it removed.", + "title": "The Roborock mop drying binary sensor is deprecated" + }, + "deprecated_mop_drying_scripts": { + "description": "The `{entity_id}` ({entity_name}) binary sensor is deprecated and has been replaced by the **Mop drying** switch, which reports the same state and can also start and stop drying.\n\nIt is still used in the following automations or scripts:\n{items}\n\nUpdate them to use the new switch entity, then disable `{entity_id}` to have it removed.", + "title": "[%key:component::roborock::issues::deprecated_mop_drying::title%]" } }, "options": { diff --git a/homeassistant/components/roborock/util.py b/homeassistant/components/roborock/util.py new file mode 100644 index 000000000000..130e367cbe05 --- /dev/null +++ b/homeassistant/components/roborock/util.py @@ -0,0 +1,101 @@ +"""Utility helpers for the Roborock integration.""" + +from homeassistant.components.automation import automations_with_entity +from homeassistant.components.script import scripts_with_entity +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er +from homeassistant.helpers.issue_registry import ( + IssueSeverity, + async_create_issue, + async_delete_issue, +) + +from .const import DOMAIN + +# Version in which deprecated entities will be removed. +DEPRECATED_REMOVAL_VERSION = "2027.3.0" + + +def deprecate_entity( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + *, + platform_domain: str, + entity_unique_id: str, + issue_id: str, + translation_key: str, +) -> bool: + """Handle deprecation of an entity that has been replaced. + + Return True if the deprecated entity should still be set up, which is the + case while it exists in the entity registry. A repair issue informs the user + about the replacement and the removal date; when the entity is still used by + automations or scripts they are listed in the issue. The entity is removed + once the user disables it and nothing references it anymore. New + installations never create the entity. + """ + entity_id = entity_registry.async_get_entity_id( + platform_domain, DOMAIN, entity_unique_id + ) + if entity_id is None: + async_delete_issue(hass, DOMAIN, issue_id) + return False + + entity_entry = entity_registry.async_get(entity_id) + if entity_entry is None: + async_delete_issue(hass, DOMAIN, issue_id) + return False + + items = _automations_and_scripts_using_entity(hass, entity_registry, entity_id) + + if entity_entry.disabled and not items: + entity_registry.async_remove(entity_id) + async_delete_issue(hass, DOMAIN, issue_id) + return False + + placeholders = { + "entity_id": entity_id, + "entity_name": entity_entry.name or entity_entry.original_name or entity_id, + } + if items: + translation_key = f"{translation_key}_scripts" + placeholders["items"] = "\n".join(items) + + async_create_issue( + hass, + DOMAIN, + issue_id, + breaks_in_ha_version=DEPRECATED_REMOVAL_VERSION, + is_fixable=False, + severity=IssueSeverity.WARNING, + translation_key=translation_key, + translation_placeholders=placeholders, + ) + return True + + +def _automations_and_scripts_using_entity( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + entity_id: str, +) -> list[str]: + """Return markdown list items for automations and scripts using an entity.""" + automations = automations_with_entity(hass, entity_id) + scripts = scripts_with_entity(hass, entity_id) + if not automations and not scripts: + return [] + + items: list[str] = [] + for integration, used_entities in ( + ("automation", automations), + ("script", scripts), + ): + for used_entity_id in used_entities: + if entry := entity_registry.async_get(used_entity_id): + items.append( + f"- [{entry.original_name}](/config/{integration}/edit/{entry.unique_id})" + ) + else: + items.append(f"- `{used_entity_id}`") + + return items diff --git a/homeassistant/components/scrape/manifest.json b/homeassistant/components/scrape/manifest.json index 8140d9608100..ad1d61d67a37 100644 --- a/homeassistant/components/scrape/manifest.json +++ b/homeassistant/components/scrape/manifest.json @@ -6,5 +6,5 @@ "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/scrape", "iot_class": "cloud_polling", - "requirements": ["beautifulsoup4==4.13.3", "lxml==6.1.1"] + "requirements": ["beautifulsoup4==4.13.3", "lxml==6.1.2"] } diff --git a/homeassistant/components/shelly/__init__.py b/homeassistant/components/shelly/__init__.py index 650579aaacc8..52aef5522ac4 100644 --- a/homeassistant/components/shelly/__init__.py +++ b/homeassistant/components/shelly/__init__.py @@ -64,6 +64,7 @@ from .repairs import ( async_manage_deprecated_firmware_issue, async_manage_open_wifi_ap_issue, async_manage_outbound_websocket_incorrectly_enabled_issue, + async_manage_rtsp_disabled_issue, ) from .services import async_setup_services from .utils import ( @@ -83,6 +84,7 @@ from .utils import ( PLATFORMS: Final = [ Platform.BINARY_SENSOR, Platform.BUTTON, + Platform.CAMERA, Platform.CLIMATE, Platform.COVER, Platform.EVENT, @@ -392,6 +394,7 @@ async def _async_setup_rpc_entry(hass: HomeAssistant, entry: ShellyConfigEntry) entry, ) async_manage_open_wifi_ap_issue(hass, entry) + async_manage_rtsp_disabled_issue(hass, entry) remove_empty_sub_devices(hass, entry) elif ( sleep_period is None diff --git a/homeassistant/components/shelly/camera.py b/homeassistant/components/shelly/camera.py new file mode 100644 index 000000000000..8e8662d90692 --- /dev/null +++ b/homeassistant/components/shelly/camera.py @@ -0,0 +1,140 @@ +"""Support for Shelly cameras.""" + +from dataclasses import dataclass +from typing import Final, override +from urllib.parse import quote + +from homeassistant.components.camera import ( + Camera, + CameraEntityDescription, + CameraEntityFeature, +) +from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from .coordinator import ShellyConfigEntry, ShellyRpcCoordinator +from .entity import ( + RpcEntityDescription, + ShellyRpcAttributeEntity, + async_setup_entry_rpc, +) +from .utils import get_host + +PARALLEL_UPDATES = 0 + + +@dataclass(frozen=True, kw_only=True) +class RpcCameraEntityDescription(RpcEntityDescription, CameraEntityDescription): + """Class to describe a Shelly RPC camera entity.""" + + stream: int + + +RPC_CAMERA_ENTITIES: Final = { + "stream_0": RpcCameraEntityDescription( + key="camera", + stream=0, + translation_key="stream", + translation_placeholders={"stream_id": "0"}, + removal_condition=lambda config, _, key: not config[key]["rtsp"]["enable"], + ), + "stream_1": RpcCameraEntityDescription( + key="camera", + stream=1, + translation_key="stream", + translation_placeholders={"stream_id": "1"}, + entity_registry_enabled_default=False, + removal_condition=lambda config, _, key: not config[key]["rtsp"]["enable"], + ), +} + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ShellyConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up Shelly camera entities.""" + if not config_entry.runtime_data.rpc: + return + + async_setup_entry_rpc( + hass, + config_entry, + async_add_entities, + RPC_CAMERA_ENTITIES, + ShellyCameraEntity, + ) + + +class ShellyCameraEntity(ShellyRpcAttributeEntity, Camera): + """Shelly camera entity for RPC devices.""" + + _attr_brand = "Shelly" + _attr_supported_features = CameraEntityFeature.STREAM + entity_description: RpcCameraEntityDescription + + def __init__( + self, + coordinator: ShellyRpcCoordinator, + key: str, + attribute: str, + description: RpcCameraEntityDescription, + ) -> None: + """Initialize Shelly camera entity.""" + super().__init__(coordinator, key, attribute, description) + Camera.__init__(self) + + self._attr_model = self.coordinator.model + + @override + @property + def available(self) -> bool: + """Available.""" + available = super().available + if not available: + return False + + return not self.status["privacy"] + + @override + @property + def is_on(self) -> bool: + """Return True if the camera is running.""" + return ( + self.coordinator.device.initialized and self.status["streamer"] == "running" + ) + + @override + @property + def is_recording(self) -> bool: + """Return True if the camera is currently recording.""" + return bool(self.status.get("recordings")) + + @override + @property + def is_streaming(self) -> bool: + """Return True if the camera is currently streaming.""" + return bool(self.status["streams"] > 0) + + @override + async def stream_source(self) -> str | None: + """Return the RTSP stream source for go2rtc.""" + username = self.coordinator.config_entry.data.get(CONF_USERNAME) + password = self.coordinator.config_entry.data.get(CONF_PASSWORD) + host = get_host(self.coordinator.config_entry.data[CONF_HOST]) + + if username and password: + return ( + f"rtsp://{quote(username, safe='')}:{quote(password, safe='')}@{host}" + f"/stream/{self.entity_description.stream}" + ) + + return f"rtsp://{host}/stream/{self.entity_description.stream}" + + @override + @property + def use_stream_for_stills(self) -> bool: + """Use the RTSP stream to generate still images.""" + return True diff --git a/homeassistant/components/shelly/const.py b/homeassistant/components/shelly/const.py index 2a1ca6fee8bd..132d3de2f848 100644 --- a/homeassistant/components/shelly/const.py +++ b/homeassistant/components/shelly/const.py @@ -247,6 +247,7 @@ OUTBOUND_WEBSOCKET_INCORRECTLY_ENABLED_ISSUE_ID = ( ) DEPRECATED_FIRMWARE_ISSUE_ID = "deprecated_firmware_{unique}" OPEN_WIFI_AP_ISSUE_ID = "open_wifi_ap_{unique}" +RTSP_DISABLED_ISSUE_ID = "rtsp_disabled_{unique}" COIOT_UNCONFIGURED_ISSUE_ID = "coiot_unconfigured_{unique}" diff --git a/homeassistant/components/shelly/icons.json b/homeassistant/components/shelly/icons.json index f12ddea711b7..573c5e16fec5 100644 --- a/homeassistant/components/shelly/icons.json +++ b/homeassistant/components/shelly/icons.json @@ -70,6 +70,13 @@ } }, "switch": { + "camera_privacy": { + "default": "mdi:eye-outline", + "state": { + "off": "mdi:eye-outline", + "on": "mdi:eye-off-outline" + } + }, "cury_away_mode": { "default": "mdi:home-outline", "state": { diff --git a/homeassistant/components/shelly/manifest.json b/homeassistant/components/shelly/manifest.json index a2b96fe7ea17..11b656ed4bfe 100644 --- a/homeassistant/components/shelly/manifest.json +++ b/homeassistant/components/shelly/manifest.json @@ -17,7 +17,7 @@ "iot_class": "local_push", "loggers": ["aioshelly"], "quality_scale": "platinum", - "requirements": ["aioshelly==13.30.0"], + "requirements": ["aioshelly==13.31.0"], "zeroconf": [ { "name": "shelly*", diff --git a/homeassistant/components/shelly/repairs.py b/homeassistant/components/shelly/repairs.py index 4dbd94623046..462820da23b3 100644 --- a/homeassistant/components/shelly/repairs.py +++ b/homeassistant/components/shelly/repairs.py @@ -26,6 +26,7 @@ from .const import ( DOMAIN, OPEN_WIFI_AP_ISSUE_ID, OUTBOUND_WEBSOCKET_INCORRECTLY_ENABLED_ISSUE_ID, + RTSP_DISABLED_ISSUE_ID, BLEScannerMode, ) from .coordinator import ShellyConfigEntry @@ -33,6 +34,8 @@ from .utils import ( get_coiot_address, get_coiot_port, get_device_entry_gen, + get_rpc_key_id, + get_rpc_key_instances, get_rpc_ws_url, ) @@ -201,6 +204,53 @@ def async_manage_open_wifi_ap_issue( ir.async_delete_issue(hass, DOMAIN, issue_id) +@callback +def async_manage_rtsp_disabled_issue( + hass: HomeAssistant, + entry: ShellyConfigEntry, +) -> None: + """Manage the RTSP disabled issue.""" + issue_id = RTSP_DISABLED_ISSUE_ID.format(unique=entry.unique_id) + + if TYPE_CHECKING: + assert entry.runtime_data.rpc is not None + + device = entry.runtime_data.rpc.device + + if not device.initialized: + return + + camera_keys = get_rpc_key_instances(device.status, "camera") + if not camera_keys: + ir.async_delete_issue(hass, DOMAIN, issue_id) + return + + disabled = [ + key + for key in camera_keys + if key in device.config and not device.config[key]["rtsp"]["enable"] + ] + + if disabled: + ir.async_create_issue( + hass, + DOMAIN, + issue_id, + is_fixable=True, + is_persistent=False, + severity=ir.IssueSeverity.WARNING, + translation_key="rtsp_disabled", + translation_placeholders={ + "device_name": device.name, + "ip_address": device.ip_address, + }, + data={"entry_id": entry.entry_id}, + ) + return + + ir.async_delete_issue(hass, DOMAIN, issue_id) + + class ShellyBlockRepairsFlow(RepairsFlow): """Handler for an issue fixing flow.""" @@ -375,6 +425,52 @@ class DisableOpenWiFiApFlow(RepairsFlow): return self.async_abort(reason="issue_ignored") +class EnableRtspFlow(RepairsFlow): + """Handler for Enable RTSP flow.""" + + def __init__(self, device: RpcDevice, issue_id: str) -> None: + """Initialize.""" + self._device = device + self.issue_id = issue_id + + async def async_step_init( + self, user_input: dict[str, str] | None = None + ) -> RepairsFlowResult: + """Handle the first step of a fix flow.""" + issue_registry = ir.async_get(self.hass) + description_placeholders = None + if issue := issue_registry.async_get_issue(DOMAIN, self.issue_id): + description_placeholders = issue.translation_placeholders + + return self.async_show_menu( + menu_options=["confirm", "ignore"], + description_placeholders=description_placeholders, + ) + + async def async_step_confirm( + self, user_input: dict[str, str] | None = None + ) -> RepairsFlowResult: + """Handle the confirm step of a fix flow.""" + try: + for key in get_rpc_key_instances(self._device.status, "camera"): + if ( + key in self._device.config + and not self._device.config[key]["rtsp"]["enable"] + ): + await self._device.set_camera_rtsp(get_rpc_key_id(key), True) + except DeviceConnectionError, RpcCallError: + return self.async_abort(reason="cannot_connect") + + return self.async_create_entry(title="", data={}) + + async def async_step_ignore( + self, user_input: dict[str, str] | None = None + ) -> RepairsFlowResult: + """Handle the ignore step of a fix flow.""" + ir.async_ignore_issue(self.hass, DOMAIN, self.issue_id, True) + return self.async_abort(reason="issue_ignored") + + async def async_create_fix_flow( hass: HomeAssistant, issue_id: str, data: dict[str, str] | None ) -> RepairsFlow: @@ -408,4 +504,7 @@ async def async_create_fix_flow( if "open_wifi_ap" in issue_id: return DisableOpenWiFiApFlow(device, issue_id) + if "rtsp_disabled" in issue_id: + return EnableRtspFlow(device, issue_id) + return ConfirmRepairFlow() diff --git a/homeassistant/components/shelly/strings.json b/homeassistant/components/shelly/strings.json index cf6d25176cef..2273091234cf 100644 --- a/homeassistant/components/shelly/strings.json +++ b/homeassistant/components/shelly/strings.json @@ -263,6 +263,11 @@ "name": "Unmute alarm" } }, + "camera": { + "stream": { + "name": "Stream {stream_id}" + } + }, "climate": { "thermostat": { "state_attributes": { @@ -576,6 +581,9 @@ } }, "switch": { + "camera_privacy": { + "name": "Privacy" + }, "charging": { "name": "Charging" }, @@ -757,14 +765,14 @@ "fix_flow": { "abort": { "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", - "issue_ignored": "Issue ignored" + "issue_ignored": "[%key:component::shelly::issues::coiot_unconfigured::fix_flow::abort::issue_ignored%]" }, "step": { "init": { "description": "Your Shelly device {device_name} with IP address {ip_address} has an open Wi-Fi access point enabled without a password. This is a security risk as anyone nearby can connect to the device.\n\nNote: If you disable the access point, the device may need to restart.", "menu_options": { "confirm": "Disable Wi-Fi access point", - "ignore": "Ignore" + "ignore": "[%key:component::shelly::issues::coiot_unconfigured::fix_flow::step::init::menu_options::ignore%]" }, "title": "[%key:component::shelly::issues::open_wifi_ap::title%]" } @@ -790,6 +798,25 @@ "description": "Home Assistant is not receiving push updates from the Shelly device {device_name} with IP address {ip_address}. Check the CoIoT configuration in the web panel of the device and your network configuration.", "title": "Shelly device {device_name} push update failure" }, + "rtsp_disabled": { + "fix_flow": { + "abort": { + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "issue_ignored": "[%key:component::shelly::issues::coiot_unconfigured::fix_flow::abort::issue_ignored%]" + }, + "step": { + "init": { + "description": "Your Shelly device {device_name} with IP address {ip_address} has camera RTSP streams disabled. RTSP must be enabled for camera entities to be created.\n\nSelect **Enable RTSP streams** to enable RTSP for all camera streams.", + "menu_options": { + "confirm": "Enable RTSP streams", + "ignore": "[%key:component::shelly::issues::coiot_unconfigured::fix_flow::step::init::menu_options::ignore%]" + }, + "title": "[%key:component::shelly::issues::rtsp_disabled::title%]" + } + } + }, + "title": "RTSP streams disabled on {device_name}" + }, "unsupported_firmware": { "description": "Your Shelly device {device_name} with IP address {ip_address} is running an unsupported firmware. Please update the firmware.\n\nIf the device does not offer an update, check internet connectivity (gateway, DNS, time) and restart the device.", "title": "Unsupported firmware for device {device_name}" diff --git a/homeassistant/components/shelly/switch.py b/homeassistant/components/shelly/switch.py index 5d7ef241b4dc..452ea2442738 100644 --- a/homeassistant/components/shelly/switch.py +++ b/homeassistant/components/shelly/switch.py @@ -423,6 +423,16 @@ RPC_SWITCHES = { method_off="cury_set_away_mode", method_params_fn=lambda id, value: (id, value), ), + "camera_privacy": RpcSwitchDescription( + key="camera", + sub_key="privacy", + translation_key="camera_privacy", + is_on=lambda status: status["privacy"], + method_on="set_camera_privacy", + method_off="set_camera_privacy", + method_params_fn=lambda id, value: (id, value), + entity_category=EntityCategory.CONFIG, + ), } diff --git a/homeassistant/components/shelly/utils.py b/homeassistant/components/shelly/utils.py index d5515e33fdfa..98908bd93896 100644 --- a/homeassistant/components/shelly/utils.py +++ b/homeassistant/components/shelly/utils.py @@ -917,7 +917,7 @@ def remove_stale_blu_trv_devices( return dev_reg = dr.async_get(hass) - devices = dev_reg.devices.get_devices_for_config_entry_id(entry.entry_id) + devices = dr.async_entries_for_config_entry(dev_reg, entry.entry_id) config = rpc_device.config blutrv_keys = get_rpc_key_ids(config, BLU_TRV_IDENTIFIER) trv_addrs = [config[f"{BLU_TRV_IDENTIFIER}:{key}"]["addr"] for key in blutrv_keys] @@ -943,7 +943,7 @@ def remove_empty_sub_devices(hass: HomeAssistant, entry: ConfigEntry) -> None: dev_reg = dr.async_get(hass) entity_reg = er.async_get(hass) - devices = dev_reg.devices.get_devices_for_config_entry_id(entry.entry_id) + devices = dr.async_entries_for_config_entry(dev_reg, entry.entry_id) for device in devices: if not device.via_device_id: diff --git a/homeassistant/components/simplepush/notify.py b/homeassistant/components/simplepush/notify.py index 4c06f170f572..2c99a99beebe 100644 --- a/homeassistant/components/simplepush/notify.py +++ b/homeassistant/components/simplepush/notify.py @@ -13,9 +13,10 @@ from homeassistant.components.notify import ( ) from homeassistant.const import CONF_EVENT, CONF_PASSWORD from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError, ServiceValidationError from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType -from .const import ATTR_ATTACHMENTS, ATTR_EVENT, CONF_DEVICE_KEY, CONF_SALT +from .const import ATTR_ATTACHMENTS, ATTR_EVENT, CONF_DEVICE_KEY, CONF_SALT, DOMAIN _LOGGER = logging.getLogger(__name__) @@ -97,8 +98,13 @@ class SimplePushNotificationService(BaseNotificationService): event=event, ) - # pylint: disable-next=home-assistant-action-swallowed-exception - except BadRequest: - _LOGGER.error("Bad request. Title or message are too long") - except UnknownError: - _LOGGER.error("Failed to send the notification") + except BadRequest as err: + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="title_or_message_too_long", + ) from err + except UnknownError as err: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="send_message_failed", + ) from err diff --git a/homeassistant/components/simplepush/strings.json b/homeassistant/components/simplepush/strings.json index a0c41ea4b0a6..c52a42ffe8df 100644 --- a/homeassistant/components/simplepush/strings.json +++ b/homeassistant/components/simplepush/strings.json @@ -17,5 +17,13 @@ } } } + }, + "exceptions": { + "send_message_failed": { + "message": "Failed to send the Simplepush notification." + }, + "title_or_message_too_long": { + "message": "The notification title or message is too long." + } } } diff --git a/homeassistant/components/smartthings/climate.py b/homeassistant/components/smartthings/climate.py index 13130a9858c4..63e57b50a8f6 100644 --- a/homeassistant/components/smartthings/climate.py +++ b/homeassistant/components/smartthings/climate.py @@ -64,6 +64,7 @@ OPERATING_STATE_TO_ACTION = { AC_MODE_TO_STATE = { "auto": HVACMode.AUTO, + "aIComfort": HVACMode.AUTO, "cool": HVACMode.COOL, "dry": HVACMode.DRY, "coolClean": HVACMode.COOL, @@ -453,6 +454,20 @@ class SmartThingsAirConditioner(SmartThingsEntity, ClimateEntity): tasks.append(self.async_turn_on()) mode = STATE_TO_AC_MODE[hvac_mode] + + # If new hvac_mode is HVACMode.AUTO and + # AirConditioner doesn't support "auto" + # but supports "aIComfort", change mode to "aIComfort" + if hvac_mode == HVACMode.AUTO: + supported_modes = ( + self.get_attribute_value( + Capability.AIR_CONDITIONER_MODE, Attribute.SUPPORTED_AC_MODES + ) + or [] + ) + if "auto" not in supported_modes and "aIComfort" in supported_modes: + mode = "aIComfort" + # If new hvac_mode is HVAC_MODE_FAN_ONLY and # AirConditioner supports "wind" or "fan" mode, # the AirConditioner new mode has to be "wind" or "fan" diff --git a/homeassistant/components/sonos/__init__.py b/homeassistant/components/sonos/__init__.py index e17f7c8abf2e..e953b4110f43 100644 --- a/homeassistant/components/sonos/__init__.py +++ b/homeassistant/components/sonos/__init__.py @@ -73,6 +73,12 @@ DISCOVERY_IGNORED_MODELS = ["Sonos Boost"] ZGS_SUBSCRIPTION_TIMEOUT = 2 SHUTDOWN_TIMEOUT = 10 + +def _get_soco_uid(soco: SoCo) -> str: + """Get SoCo uid as a typed helper for executor jobs.""" + return soco.uid + + CONFIG_SCHEMA = vol.Schema( { DOMAIN: vol.Schema( @@ -531,9 +537,11 @@ class SonosDiscoveryManager: ), None, ) - if not known_speaker: + if known_speaker: + uid = known_speaker.uid + else: try: - uid = await self.hass.async_add_executor_job(getattr, soco, "uid") + uid = await self.hass.async_add_executor_job(_get_soco_uid, soco) except HTTPError as err: await self._process_http_connection_error(err, ip_addr) continue @@ -545,6 +553,14 @@ class SonosDiscoveryManager: ) as ex: _LOGGER.warning("Could not get Sonos uid from %s: %s", ip_addr, ex) continue + + if self.is_device_disabled(uid): + _LOGGER.debug( + "Skipping manual poll for disabled Sonos device: %s", + uid, + ) + continue + if not known_speaker: try: await self._async_handle_discovery_message( uid, diff --git a/homeassistant/components/squeezebox/media_player.py b/homeassistant/components/squeezebox/media_player.py index 5ad10f7a1ee9..ffa12268d54a 100644 --- a/homeassistant/components/squeezebox/media_player.py +++ b/homeassistant/components/squeezebox/media_player.py @@ -153,8 +153,7 @@ async def async_setup_entry( ) model_id = SERVER_MODEL_ID + "/" + model_id if model_id else SERVER_MODEL_ID # The player shares the server's device (same MAC), so it resolves to - # the server device itself; don't link it to itself. None also clears - # the link for devices from before this was fixed. + # the server device itself; don't link it to itself. via_device_id = None device = device_registry.async_get_or_create( diff --git a/homeassistant/components/stream/core.py b/homeassistant/components/stream/core.py index 3a3d9f9a75cf..1203d8b66563 100644 --- a/homeassistant/components/stream/core.py +++ b/homeassistant/components/stream/core.py @@ -484,7 +484,7 @@ class KeyFrameConverter: @staticmethod def transform_image(image: np.ndarray, orientation: int) -> np.ndarray: """Transform image to a given orientation.""" - return TRANSFORM_IMAGE_FUNCTION[orientation](image) + return TRANSFORM_IMAGE_FUNCTION[orientation](image) # type: ignore[no-any-return] def _generate_image(self, width: int | None, height: int | None) -> None: """Generate the keyframe image. diff --git a/homeassistant/components/stream/manifest.json b/homeassistant/components/stream/manifest.json index b9cc560699d4..664e6097691a 100644 --- a/homeassistant/components/stream/manifest.json +++ b/homeassistant/components/stream/manifest.json @@ -7,5 +7,5 @@ "integration_type": "system", "iot_class": "local_push", "quality_scale": "internal", - "requirements": ["PyTurboJPEG==1.8.3", "av==17.0.1", "numpy==2.3.2"] + "requirements": ["PyTurboJPEG==1.8.3", "av==17.0.1", "numpy==2.5.2"] } diff --git a/homeassistant/components/supla/coordinator.py b/homeassistant/components/supla/coordinator.py index debb78b2590b..107ec4a88316 100644 --- a/homeassistant/components/supla/coordinator.py +++ b/homeassistant/components/supla/coordinator.py @@ -28,6 +28,7 @@ class SuplaCoordinator(DataUpdateCoordinator[dict[int, dict]]): super().__init__( hass, _LOGGER, + config_entry=None, name=f"supla-{server_name}", update_interval=SCAN_INTERVAL, ) diff --git a/homeassistant/components/switchbot_cloud/__init__.py b/homeassistant/components/switchbot_cloud/__init__.py index 30d5d7baec23..1a697a2ed9a6 100644 --- a/homeassistant/components/switchbot_cloud/__init__.py +++ b/homeassistant/components/switchbot_cloud/__init__.py @@ -43,6 +43,7 @@ PLATFORMS: list[Platform] = [ Platform.IMAGE, Platform.LIGHT, Platform.LOCK, + Platform.SELECT, Platform.SENSOR, Platform.SWITCH, Platform.VACUUM, @@ -64,6 +65,7 @@ class SwitchbotDevices: switches: list[tuple[Device | Remote, SwitchBotCoordinator]] = field( default_factory=list ) + selects: list[tuple[Device, SwitchBotCoordinator]] = field(default_factory=list) sensors: list[tuple[Device, SwitchBotCoordinator]] = field(default_factory=list) vacuums: list[tuple[Device, SwitchBotCoordinator]] = field(default_factory=list) locks: list[tuple[Device, SwitchBotCoordinator]] = field(default_factory=list) @@ -261,6 +263,7 @@ async def make_new_device_data( Platform.IMAGE: devices_data.images, Platform.LIGHT: devices_data.lights, Platform.LOCK: devices_data.locks, + Platform.SELECT: devices_data.selects, Platform.SENSOR: devices_data.sensors, Platform.SWITCH: devices_data.switches, Platform.VACUUM: devices_data.vacuums, diff --git a/homeassistant/components/switchbot_cloud/const.py b/homeassistant/components/switchbot_cloud/const.py index 57e86b25e8d2..57a519aef327 100644 --- a/homeassistant/components/switchbot_cloud/const.py +++ b/homeassistant/components/switchbot_cloud/const.py @@ -36,6 +36,25 @@ HUMIDITY_LEVELS = { 100: 103, # High humidity mode } +NIGHT_LIGHT_ON = "on" +NIGHT_LIGHT_OFF = "off" +NIGHT_LIGHT_BRIGHT = "bright" +NIGHT_LIGHT_SOFT = "soft" + +STANDING_FAN_NIGHT_LIGHT_PARAMETERS_MAP = { + NIGHT_LIGHT_ON: "on", + NIGHT_LIGHT_OFF: "off", + NIGHT_LIGHT_BRIGHT: "1", + NIGHT_LIGHT_SOFT: "2", +} + +BATTERY_CIRCULATOR_FAN_2_PRO_NIGHT_LIGHT_PARAMETERS_MAP = { + NIGHT_LIGHT_ON: "on", + NIGHT_LIGHT_OFF: "off", + NIGHT_LIGHT_BRIGHT: "0", + NIGHT_LIGHT_SOFT: "1", +} + @dataclass(frozen=True) class SwitchbotCloudDeviceConfig: @@ -128,13 +147,13 @@ DEVICE_SUPPORT_MAP: Final[dict[str, SwitchbotCloudDeviceConfig]] = { ), "Circulator Fan": SwitchbotCloudDeviceConfig(True, entity_config=(Platform.FAN,)), "Standing Fan": SwitchbotCloudDeviceConfig( - True, entity_config=(Platform.SENSOR, Platform.FAN) + True, entity_config=(Platform.SENSOR, Platform.FAN, Platform.SELECT) ), "Battery Circulator Fan": SwitchbotCloudDeviceConfig( - True, entity_config=(Platform.SENSOR, Platform.FAN) + True, entity_config=(Platform.SENSOR, Platform.FAN, Platform.SELECT) ), "Battery Circulator Fan 2 Pro": SwitchbotCloudDeviceConfig( - True, entity_config=(Platform.SENSOR, Platform.FAN) + True, entity_config=(Platform.SENSOR, Platform.FAN, Platform.SELECT) ), "Water Detector": SwitchbotCloudDeviceConfig( True, entity_config=(Platform.SENSOR, Platform.BINARY_SENSOR) diff --git a/homeassistant/components/switchbot_cloud/icons.json b/homeassistant/components/switchbot_cloud/icons.json index cfd29f54123b..859d0ad11843 100644 --- a/homeassistant/components/switchbot_cloud/icons.json +++ b/homeassistant/components/switchbot_cloud/icons.json @@ -53,6 +53,11 @@ } } }, + "select": { + "night_light_control": { + "default": "mdi:lightbulb-night" + } + }, "sensor": { "light_level": { "default": "mdi:brightness-7", diff --git a/homeassistant/components/switchbot_cloud/select.py b/homeassistant/components/switchbot_cloud/select.py new file mode 100644 index 000000000000..6ab3e70babff --- /dev/null +++ b/homeassistant/components/switchbot_cloud/select.py @@ -0,0 +1,106 @@ +"""SwitchBotCloudSelect entity.""" + +from typing import TYPE_CHECKING, override + +from switchbot_api import BatteryCirculatorFanCommands, Device, Remote, SwitchBotAPI + +from homeassistant.components.select import SelectEntity +from homeassistant.const import EntityCategory +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from . import SwitchbotCloudConfigEntry, SwitchBotCoordinator +from .const import ( + BATTERY_CIRCULATOR_FAN_2_PRO_NIGHT_LIGHT_PARAMETERS_MAP, + NIGHT_LIGHT_BRIGHT, + NIGHT_LIGHT_ON, + NIGHT_LIGHT_SOFT, + STANDING_FAN_NIGHT_LIGHT_PARAMETERS_MAP, +) +from .entity import SwitchBotCloudEntity + + +async def async_setup_entry( + hass: HomeAssistant, + config: SwitchbotCloudConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up SwitchBot Cloud entry.""" + data = config.runtime_data + async_add_entities( + _async_make_entity(data.api, device, coordinator) + for device, coordinator in data.devices.selects + ) + + +class SwitchBotCloudStandingFanNightLight(SwitchBotCloudEntity, SelectEntity): + """SwitchBotCloud Standing Fan Night Light.""" + + _night_light_parameters_map: dict[str, str] = ( + STANDING_FAN_NIGHT_LIGHT_PARAMETERS_MAP + ) + _attr_entity_category = EntityCategory.CONFIG + _attr_current_option: str | None = None + + _attr_translation_key = "night_light_control" + _attr_options = list(_night_light_parameters_map) + + @override + async def async_select_option(self, option: str) -> None: + """Select the night light mode.""" + if option == NIGHT_LIGHT_ON: + para = self._night_light_parameters_map.get( + NIGHT_LIGHT_BRIGHT + ) or self._night_light_parameters_map.get(NIGHT_LIGHT_SOFT) + if TYPE_CHECKING: + assert para is not None + await self.send_api_command( + BatteryCirculatorFanCommands.SET_NIGHT_LIGHT_MODE, + parameters=para, + ) + else: + await self.send_api_command( + BatteryCirculatorFanCommands.SET_NIGHT_LIGHT_MODE, + parameters=self._night_light_parameters_map[option], + ) + self._attr_current_option = option + self.async_write_ha_state() + + @override + def _set_attributes(self) -> None: + """Set attributes from coordinator data.""" + if self.coordinator.data is None: + return + night_status = self.coordinator.data.get("nightStatus") + for key, value in self._night_light_parameters_map.items(): + if value == night_status: + self._attr_current_option = key + return + self._attr_current_option = None + + +class SwitchBotCloudBatteryCirculatorFan2ProNightLight( + SwitchBotCloudStandingFanNightLight +): + """SwitchBotCloud Battery Circulator Fan 2 Pro Night Light.""" + + _night_light_parameters_map: dict[str, str] = ( + BATTERY_CIRCULATOR_FAN_2_PRO_NIGHT_LIGHT_PARAMETERS_MAP + ) + + +@callback +def _async_make_entity( + api: SwitchBotAPI, device: Device | Remote, coordinator: SwitchBotCoordinator +) -> ( + SwitchBotCloudStandingFanNightLight + | SwitchBotCloudBatteryCirculatorFan2ProNightLight +): + """Make a SwitchBotCloudSelect entity.""" + if device.device_type in ["Standing Fan", "Battery Circulator Fan"]: + return SwitchBotCloudStandingFanNightLight(api, device, coordinator) + if device.device_type == "Battery Circulator Fan 2 Pro": + return SwitchBotCloudBatteryCirculatorFan2ProNightLight( + api, device, coordinator + ) + raise NotImplementedError diff --git a/homeassistant/components/switchbot_cloud/strings.json b/homeassistant/components/switchbot_cloud/strings.json index a75a0f008cac..8f500ec9cbcd 100644 --- a/homeassistant/components/switchbot_cloud/strings.json +++ b/homeassistant/components/switchbot_cloud/strings.json @@ -72,7 +72,17 @@ "name": "Display" } }, - + "select": { + "night_light_control": { + "name": "Night light", + "state": { + "bright": "Bright", + "off": "[%key:common::state::off%]", + "on": "[%key:common::state::on%]", + "soft": "Soft" + } + } + }, "sensor": { "light_level": { "name": "Light level" diff --git a/homeassistant/components/template/__init__.py b/homeassistant/components/template/__init__.py index 2052ab8da8da..99225a004265 100644 --- a/homeassistant/components/template/__init__.py +++ b/homeassistant/components/template/__init__.py @@ -102,8 +102,13 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: remove_all_devices=True, ) - if device_id is not None and dr.async_get(hass).async_is_composite_device_id( - device_id + device_registry = dr.async_get(hass) + if ( + device_id is not None + and device_registry.async_get( + device_id, include_main_devices=False, include_child_devices=False + ) + is not None ): # The device was split into one device per config entry; ask the user to # select a device again diff --git a/homeassistant/components/template/entity.py b/homeassistant/components/template/entity.py index e2c2709b84ee..a2020a30a77c 100644 --- a/homeassistant/components/template/entity.py +++ b/homeassistant/components/template/entity.py @@ -88,12 +88,13 @@ class AbstractTemplateEntity(Entity): ) device_registry = dr.async_get(hass) - if ( - device_id := config.get(CONF_DEVICE_ID) - ) is not None and device_registry.async_is_composite_device_id( - device_id - ) is False: - self.device_entry = device_registry.async_get(device_id) + # Allow linking to a main or child device, but not to a composite device. + if (device_id := config.get(CONF_DEVICE_ID)) is not None and ( + device_entry := device_registry.async_get( + device_id, include_composite_devices=False + ) + ) is not None: + self.device_entry = device_entry @property @abstractmethod diff --git a/homeassistant/components/template/repairs.py b/homeassistant/components/template/repairs.py index 3a95eb11a349..3854c36a458d 100644 --- a/homeassistant/components/template/repairs.py +++ b/homeassistant/components/template/repairs.py @@ -43,7 +43,11 @@ class CompositeDeviceIdRepairFlow(RepairsFlow): device_id = user_input.get(CONF_DEVICE_ID) if ( device_id is None - or device_registry.async_is_composite_device_id(device_id) is False + or device_registry.async_get( + device_id, + include_composite_devices=False, + ) + is not None ): options = {**entry.options} if device_id: diff --git a/homeassistant/components/thread/__init__.py b/homeassistant/components/thread/__init__.py index ffbe32e389a1..1f9bef81d763 100644 --- a/homeassistant/components/thread/__init__.py +++ b/homeassistant/components/thread/__init__.py @@ -7,6 +7,7 @@ from homeassistant.helpers.typing import ConfigType from .const import DOMAIN from .dataset_store import ( + DatasetAddResult, DatasetEntry, async_add_dataset, async_get_dataset, @@ -16,6 +17,7 @@ from .websocket_api import async_setup as async_setup_ws_api __all__ = [ "DOMAIN", + "DatasetAddResult", "DatasetEntry", "async_add_dataset", "async_get_dataset", diff --git a/homeassistant/components/thread/dataset_store.py b/homeassistant/components/thread/dataset_store.py index 9f9f132fc8ba..784c17303ca6 100644 --- a/homeassistant/components/thread/dataset_store.py +++ b/homeassistant/components/thread/dataset_store.py @@ -3,6 +3,7 @@ from asyncio import Event, Task, wait import dataclasses from datetime import datetime +from enum import StrEnum import logging from pprint import pformat from typing import Any, cast, override @@ -89,6 +90,24 @@ class DatasetPreferredError(HomeAssistantError): """Raised when attempting to delete the preferred dataset.""" +class DatasetAddResult(StrEnum): + """The outcome of adding a dataset to the store. + + A caller that has already handed the dataset to a border router needs to + know when Home Assistant's copy disagrees with what the mesh will run. + """ + + # The store's dataset for this extended PAN ID is the one that was passed: + # it was written, or an equivalent one was already stored, so the stored + # TLV may differ byte for byte. The preferred border agent is refreshed. + STORED = "stored" + + # The dataset was not stored, because the store holds a different dataset + # with the same or a newer active timestamp for this extended PAN ID. + # Nothing about the entry was changed. + DISCARDED = "discarded" + + @dataclasses.dataclass(frozen=True) class DatasetEntry: """Dataset store entry.""" @@ -264,8 +283,13 @@ class DatasetStore: tlv: str, preferred_border_agent_id: str | None, preferred_extended_address: str | None, - ) -> None: - """Add dataset, does nothing if it already exists.""" + ) -> DatasetAddResult: + """Add a dataset, report whether the store holds it afterwards. + + Datasets are keyed by extended PAN ID and ordered by active timestamp, + the way a Thread mesh orders them itself, so a dataset that is not newer + than the stored one for its network is discarded rather than stored. + """ # Make sure the tlv is valid dataset = tlv_parser.parse_tlv(tlv) @@ -291,7 +315,7 @@ class DatasetStore: self._async_maybe_update_preferred_border_agent( entry, preferred_border_agent_id, preferred_extended_address ) - return + return DatasetAddResult.STORED # Update if dataset with same extended pan id exists and the timestamp # is newer @@ -326,7 +350,7 @@ class DatasetStore: pformat(_format_dataset(entry.dataset)), pformat(_format_dataset(dataset)), ) - return + return DatasetAddResult.DISCARDED elif _LOGGER.isEnabledFor(logging.DEBUG): _LOGGER.debug( "Updating dataset with same extended PAN ID and newer" @@ -341,7 +365,7 @@ class DatasetStore: self._async_maybe_update_preferred_border_agent( entry, preferred_border_agent_id, preferred_extended_address ) - return + return DatasetAddResult.STORED entry = DatasetEntry( preferred_border_agent_id=preferred_border_agent_id, @@ -365,6 +389,8 @@ class DatasetStore: ) ) + return DatasetAddResult.STORED + @callback def async_delete(self, dataset_id: str) -> None: """Delete dataset.""" @@ -551,10 +577,17 @@ async def async_add_dataset( *, preferred_border_agent_id: str | None = None, preferred_extended_address: str | None = None, -) -> None: - """Add a dataset.""" +) -> DatasetAddResult: + """Add a dataset, report whether the store holds it afterwards. + + Returns STORED when the store's dataset for the network is the one that + was passed, DISCARDED when the store kept a same-or-newer dataset for it + and changed nothing. + """ store = await async_get_store(hass) - store.async_add(source, tlv, preferred_border_agent_id, preferred_extended_address) + return store.async_add( + source, tlv, preferred_border_agent_id, preferred_extended_address + ) async def async_get_dataset(hass: HomeAssistant, dataset_id: str) -> str | None: diff --git a/homeassistant/components/thread/websocket_api.py b/homeassistant/components/thread/websocket_api.py index 6e813ba1bbcc..c725bc5d2e68 100644 --- a/homeassistant/components/thread/websocket_api.py +++ b/homeassistant/components/thread/websocket_api.py @@ -40,12 +40,16 @@ async def ws_add_dataset( tlv = msg["tlv"] try: - await dataset_store.async_add_dataset(hass, source, tlv) + result = await dataset_store.async_add_dataset(hass, source, tlv) except TLVError as exc: connection.send_error(msg["id"], websocket_api.ERR_INVALID_FORMAT, str(exc)) return - connection.send_result(msg["id"]) + # The outcome rides in the result payload rather than an error: existing + # callers treat any error as a failed transfer, while a discarded dataset + # means the store already holds this network's dataset in a same-or-newer + # revision. + connection.send_result(msg["id"], {"result": str(result)}) @websocket_api.require_admin diff --git a/homeassistant/components/tplink/entity.py b/homeassistant/components/tplink/entity.py index 12abb7913c0a..02724d4ceeb0 100644 --- a/homeassistant/components/tplink/entity.py +++ b/homeassistant/components/tplink/entity.py @@ -279,7 +279,7 @@ class CoordinatedTPLinkEntity(CoordinatorEntity[TPLinkDataUpdateCoordinator], AB if self._attr_available: _LOGGER.warning( "Unable to read data for %s %s: %s", - self._device, + self._device.host, self.entity_id, ex, ) diff --git a/homeassistant/components/tplink_omada/__init__.py b/homeassistant/components/tplink_omada/__init__.py index a782ae004538..02bd9b525345 100644 --- a/homeassistant/components/tplink_omada/__init__.py +++ b/homeassistant/components/tplink_omada/__init__.py @@ -91,8 +91,8 @@ def _remove_old_devices( ) -> None: device_registry = dr.async_get(hass) - for registered_device in device_registry.devices.get_devices_for_config_entry_id( - entry.entry_id + for registered_device in dr.async_entries_for_config_entry( + device_registry, entry.entry_id ): mac = next( (i[1] for i in registered_device.identifiers if i[0] == DOMAIN), None diff --git a/homeassistant/components/traccar/device_tracker.py b/homeassistant/components/traccar/device_tracker.py index d260410f4338..cb9d67496904 100644 --- a/homeassistant/components/traccar/device_tracker.py +++ b/homeassistant/components/traccar/device_tracker.py @@ -95,7 +95,7 @@ async def async_setup_entry( dev_reg = dr.async_get(hass) dev_ids = { identifier[1] - for device in dev_reg.devices.get_devices_for_config_entry_id(entry.entry_id) + for device in dr.async_entries_for_config_entry(dev_reg, entry.entry_id) for identifier in device.identifiers } if not dev_ids: diff --git a/homeassistant/components/trend/manifest.json b/homeassistant/components/trend/manifest.json index 39ed17a3fbaf..05a248e1527d 100644 --- a/homeassistant/components/trend/manifest.json +++ b/homeassistant/components/trend/manifest.json @@ -8,5 +8,5 @@ "integration_type": "helper", "iot_class": "calculated", "quality_scale": "internal", - "requirements": ["numpy==2.3.2"] + "requirements": ["numpy==2.5.2"] } diff --git a/homeassistant/components/tts/__init__.py b/homeassistant/components/tts/__init__.py index 798c5debab6e..7556369d1ad1 100644 --- a/homeassistant/components/tts/__init__.py +++ b/homeassistant/components/tts/__init__.py @@ -71,6 +71,7 @@ from .models import Voice __all__ = [ "ATTR_AUDIO_OUTPUT", + "ATTR_PREFERRED_BITRATE", "ATTR_PREFERRED_FORMAT", "ATTR_PREFERRED_SAMPLE_BYTES", "ATTR_PREFERRED_SAMPLE_CHANNELS", @@ -99,6 +100,7 @@ ATTR_PREFERRED_FORMAT = "preferred_format" ATTR_PREFERRED_SAMPLE_RATE = "preferred_sample_rate" ATTR_PREFERRED_SAMPLE_CHANNELS = "preferred_sample_channels" ATTR_PREFERRED_SAMPLE_BYTES = "preferred_sample_bytes" +ATTR_PREFERRED_BITRATE = "preferred_bitrate" ATTR_MEDIA_PLAYER_ENTITY_ID = "media_player_entity_id" ATTR_VOICE = "voice" @@ -108,6 +110,7 @@ _PREFFERED_FORMAT_OPTIONS: Final[set[str]] = { ATTR_PREFERRED_SAMPLE_RATE, ATTR_PREFERRED_SAMPLE_CHANNELS, ATTR_PREFERRED_SAMPLE_BYTES, + ATTR_PREFERRED_BITRATE, } CONF_LANG = "language" @@ -317,6 +320,7 @@ async def _async_convert_audio( to_sample_rate: int | None = None, to_sample_channels: int | None = None, to_sample_bytes: int | None = None, + to_bitrate: int | None = None, ) -> AsyncGenerator[bytes]: """Convert audio to a preferred format using ffmpeg.""" ffmpeg_manager = ffmpeg.get_ffmpeg_manager(hass) @@ -345,8 +349,13 @@ async def _async_convert_audio( if to_sample_channels is not None: command.extend(["-ac", str(to_sample_channels)]) if to_extension == "mp3": - # Max quality for MP3. - command.extend(["-q:a", "0"]) + if to_bitrate is not None: + # Constant bitrate. Some hardware decoders cannot handle the + # variable bitrate that -q:a produces. + command.extend(["-b:a", f"{to_bitrate}k"]) + else: + # Max quality for MP3. + command.extend(["-q:a", "0"]) if to_sample_bytes == 2: # 16-bit samples. command.extend(["-sample_fmt", "s16"]) @@ -588,6 +597,7 @@ class ResultStream: ATTR_PREFERRED_SAMPLE_RATE, ATTR_PREFERRED_SAMPLE_CHANNELS, ATTR_PREFERRED_SAMPLE_BYTES, + ATTR_PREFERRED_BITRATE, ) ) @@ -633,6 +643,7 @@ class ResultStream: to_sample_rate=self.options.get(ATTR_PREFERRED_SAMPLE_RATE), to_sample_channels=self.options.get(ATTR_PREFERRED_SAMPLE_CHANNELS), to_sample_bytes=self.options.get(ATTR_PREFERRED_SAMPLE_BYTES), + to_bitrate=self.options.get(ATTR_PREFERRED_BITRATE), ) async for chunk in converted_audio: yield chunk @@ -1082,6 +1093,14 @@ class SpeechManager: if sample_bytes is not None: sample_bytes = int(sample_bytes) + if ATTR_PREFERRED_BITRATE in supported_options: + bitrate = options.get(ATTR_PREFERRED_BITRATE) + else: + bitrate = options.pop(ATTR_PREFERRED_BITRATE, None) + + if bitrate is not None: + bitrate = int(bitrate) + if engine_instance.name is None or engine_instance.name is UNDEFINED: raise HomeAssistantError("TTS engine name is not set.") @@ -1134,6 +1153,7 @@ class SpeechManager: or (sample_rate is not None) or (sample_channels is not None) or (sample_bytes is not None) + or (bitrate is not None) ) if needs_conversion: @@ -1145,6 +1165,7 @@ class SpeechManager: to_sample_rate=sample_rate, to_sample_channels=sample_channels, to_sample_bytes=sample_bytes, + to_bitrate=bitrate, ) async for chunk in data_gen: diff --git a/homeassistant/components/tuya/const.py b/homeassistant/components/tuya/const.py index 78d03c625261..1b2793c02420 100644 --- a/homeassistant/components/tuya/const.py +++ b/homeassistant/components/tuya/const.py @@ -491,6 +491,8 @@ class DeviceCategory(StrEnum): """ FSKG = "fskg" """Fan wall switch (undocumented)""" + HCDD = "hcdd" + """Chasing tape light (undocumented)""" HJJCY = "hjjcy" """Air Quality Monitor diff --git a/homeassistant/components/tuya/light.py b/homeassistant/components/tuya/light.py index 6aeb1b530dea..4ccc1b7164ae 100644 --- a/homeassistant/components/tuya/light.py +++ b/homeassistant/components/tuya/light.py @@ -164,6 +164,16 @@ LIGHTS: dict[DeviceCategory, tuple[TuyaLightEntityDescription, ...]] = { color_data=DPCode.COLOUR_DATA, ), ), + DeviceCategory.HCDD: ( + TuyaLightEntityDescription( + key=DPCode.SWITCH_LED, + name=None, + color_mode=DPCode.WORK_MODE, + brightness=DPCode.BRIGHT_VALUE, + color_temp=DPCode.TEMP_VALUE, + color_data=DPCode.COLOUR_DATA, + ), + ), DeviceCategory.HXD: ( TuyaLightEntityDescription( key=DPCode.SWITCH_LED, diff --git a/homeassistant/components/tuya/manifest.json b/homeassistant/components/tuya/manifest.json index c735db8ced57..0f95516be250 100644 --- a/homeassistant/components/tuya/manifest.json +++ b/homeassistant/components/tuya/manifest.json @@ -45,6 +45,6 @@ "loggers": ["tuya_sharing"], "requirements": [ "tuya-device-handlers==0.0.26", - "tuya-device-sharing-sdk==0.2.14" + "tuya-device-sharing-sdk==0.2.15" ] } diff --git a/homeassistant/components/unifi/manifest.json b/homeassistant/components/unifi/manifest.json index 0b4facb368cb..eda3c5beb904 100644 --- a/homeassistant/components/unifi/manifest.json +++ b/homeassistant/components/unifi/manifest.json @@ -9,5 +9,5 @@ "iot_class": "local_push", "loggers": ["aiounifi"], "quality_scale": "silver", - "requirements": ["aiounifi==92"] + "requirements": ["aiounifi==93"] } diff --git a/homeassistant/components/unifi/sensor.py b/homeassistant/components/unifi/sensor.py index 96d5cfb332a0..cf10b61d0578 100644 --- a/homeassistant/components/unifi/sensor.py +++ b/homeassistant/components/unifi/sensor.py @@ -191,9 +191,7 @@ def async_uptime_value_changed_fn( @callback def async_device_outlet_power_supported_fn(hub: UnifiHub, obj_id: str) -> bool: """Determine if an outlet has the power property.""" - # At this time, an outlet_caps value of 3 is expected to indicate that the outlet - # supports metering - return hub.api.outlets[obj_id].caps == 3 + return hub.api.outlets[obj_id].has_metering is True @callback diff --git a/homeassistant/components/unifi/switch.py b/homeassistant/components/unifi/switch.py index 0173a396fd15..e2cb5f6141da 100644 --- a/homeassistant/components/unifi/switch.py +++ b/homeassistant/components/unifi/switch.py @@ -186,8 +186,7 @@ def async_object_oriented_network_config_supported_fn( @callback def async_outlet_switching_supported_fn(hub: UnifiHub, obj_id: str) -> bool: """Determine if an outlet supports switching.""" - outlet = hub.api.outlets[obj_id] - return outlet.has_relay or outlet.caps in (1, 3) + return hub.api.outlets[obj_id].has_relay is True @callback diff --git a/homeassistant/components/usb/__init__.py b/homeassistant/components/usb/__init__.py index 1d2ab9b8f6c3..0f54b862e3d8 100644 --- a/homeassistant/components/usb/__init__.py +++ b/homeassistant/components/usb/__init__.py @@ -3,7 +3,6 @@ import asyncio from collections.abc import Callable, Coroutine, Sequence from contextlib import suppress -import dataclasses from datetime import datetime, timedelta import logging import os @@ -32,7 +31,8 @@ from homeassistant.loader import USBMatcher, async_get_usb from homeassistant.util.hass_dict import HassKey from .const import DOMAIN -from .models import SerialDevice, USBDevice +from .consumers import UNSCANNABLE_PORT_SCHEMES, async_get_serial_port_consumers +from .models import SerialDevice, SerialPortConsumer, USBDevice from .serial_proxy_stub import register_serialx_transport from .utils import ( scan_serial_ports, @@ -54,8 +54,10 @@ ADD_REMOVE_SCAN_COOLDOWN = 5 # 5 second cooldown to give devices a chance to re __all__ = [ "SerialDevice", + "SerialPortConsumer", "USBCallbackMatcher", "USBDevice", + "async_get_serial_port_consumers", "async_register_port_event_callback", "async_register_scan_request_callback", "async_register_serial_port_scanner", @@ -539,33 +541,111 @@ async def websocket_usb_scan( connection.send_result(msg["id"]) +@hass_callback +def _async_serialize_port( + hass: HomeAssistant, port: USBDevice | SerialDevice, *, present: bool = True +) -> dict[str, Any]: + """Serialize a serial port for the websocket API.""" + entry: dict[str, Any] = { + "device": port.device, + "resolved_device": port.resolved_device, + "serial_number": port.serial_number, + "manufacturer": port.manufacturer, + "description": port.description, + "interface_description": port.interface_description, + "interface_num": port.interface_num, + "matching_integrations": [], + "present": present, + } + + if isinstance(port, USBDevice): + entry["vid"] = port.vid + entry["pid"] = port.pid + entry["bcd_device"] = port.bcd_device + matchers = async_get_usb_matchers_for_device(hass, port) + entry["matching_integrations"] = list( + dict.fromkeys(matcher["domain"] for matcher in matchers) + ) + + return entry + + +@hass_callback +def _async_get_discovery_flows( + hass: HomeAssistant, device: str +) -> list[dict[str, str]]: + """Return the in-progress USB discovery flows for a device path.""" + return [ + {"flow_id": flow["flow_id"], "domain": flow["handler"]} + for flow in hass.config_entries.flow.async_progress_by_init_data_type( + UsbServiceInfo, lambda service_info: service_info.device == device + ) + ] + + +def _serialize_consumer(consumer: SerialPortConsumer) -> dict[str, Any]: + """Serialize a serial port consumer for the websocket API.""" + return { + "kind": consumer.kind, + "title": consumer.title, + "active": consumer.active, + "domain": consumer.domain, + "config_entry_id": consumer.config_entry_id, + "slug": consumer.slug, + } + + @websocket_api.require_admin -@websocket_api.websocket_command({vol.Required("type"): "usb/list_serial_ports"}) +@websocket_api.websocket_command( + { + vol.Required("type"): "usb/list_serial_ports", + vol.Optional("include_usage", default=False): bool, + } +) @websocket_api.async_response async def websocket_usb_list_serial_ports( hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any], ) -> None: - """List available serial ports.""" + """List serial ports, optionally with the integrations and apps using them.""" try: ports = await async_scan_serial_ports(hass) except OSError as err: connection.send_error(msg["id"], websocket_api.ERR_UNKNOWN_ERROR, str(err)) return - result = [] - for port in ports: - entry = dataclasses.asdict(port) + result = [_async_serialize_port(hass, port) for port in ports] - if isinstance(port, USBDevice): - matchers = async_get_usb_matchers_for_device(hass, port) - entry["matching_integrations"] = list( - dict.fromkeys(matcher["domain"] for matcher in matchers) - ) - else: - entry["matching_integrations"] = [] + if not msg["include_usage"]: + connection.send_result(msg["id"], result) + return - result.append(entry) + consumers = await async_get_serial_port_consumers(hass, ports) + + # Configured ports missing from the scan are absent, except for URLs no + # scanner can contribute, which are assumed present while claimed + scanned_devices = {port.device for port in ports} + result.extend( + _async_serialize_port( + hass, + SerialDevice( + device=device, + serial_number=None, + manufacturer=None, + description=None, + ), + present=device.startswith(UNSCANNABLE_PORT_SCHEMES), + ) + for device in consumers + if device not in scanned_devices + ) + + for entry in result: + device = entry["device"] + entry["consumers"] = [ + _serialize_consumer(consumer) for consumer in consumers.get(device, []) + ] + entry["discovery_flows"] = _async_get_discovery_flows(hass, device) connection.send_result(msg["id"], result) diff --git a/homeassistant/components/usb/consumers.py b/homeassistant/components/usb/consumers.py new file mode 100644 index 000000000000..8dd32c97dda5 --- /dev/null +++ b/homeassistant/components/usb/consumers.py @@ -0,0 +1,238 @@ +"""Attribution of serial ports to the integrations and apps using them.""" + +from collections.abc import Mapping, Sequence +import os +import re +from typing import Any + +from homeassistant.components.hassio import HassioNotReadyError, get_addons_info +from homeassistant.config_entries import ConfigEntryState +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.hassio import is_hassio +from homeassistant.loader import async_get_integrations + +from .const import DOMAIN +from .models import SerialDevice, SerialPortConsumer, USBDevice + +# Key paths holding a serial port in the config entry data and options of +# searched integrations. Traversed literally, never recursively. +SERIAL_PORT_KEY_PATHS: tuple[tuple[str, ...], ...] = ( + ("device",), + ("device", "path"), # zha + ("device_path",), # alarmdecoder + ("filename",), # bryant_evolution + ("host",), # elkm1 + ("port",), + ("serial_port",), # edl21, teleinfo + ("socket_path",), # zwave_js + ("usb_path",), # zwave_js, crownstone +) + +# Integrations configured with a serial port but not depending on `usb` +NON_USB_SERIAL_DOMAINS = ("alarmdecoder", "bryant_evolution", "elkm1", "mysensors") + +# States in which the entry claims its configured port, even if the port is not +# open right now: a retrying setup typically failed to open the port, while an +# unloading or failed-to-unload entry may still hold it +ACTIVE_CONFIG_ENTRY_STATES = ( + ConfigEntryState.LOADED, + ConfigEntryState.SETUP_RETRY, + ConfigEntryState.SETUP_IN_PROGRESS, + ConfigEntryState.UNLOAD_IN_PROGRESS, + ConfigEntryState.FAILED_UNLOAD, +) + +# Remote ports contributed by serial port scanners; a configured port missing +# from the scan is absent, e.g. because the providing integration is offline +SCANNED_PORT_SCHEMES = ("esphome-hass://",) + +# Serial port URLs no scanner contributes; they can never be scanned, so a +# claiming consumer is the only evidence such a port exists +UNSCANNABLE_PORT_SCHEMES = ( + "esphome://", + "rfc2217://", + "socket://", + "tcp://", +) + +# upb wraps the port in a URL with an optional baud rate, e.g. +# `serial:///dev/ttyS0:4800`, which upb_lib strips itself when connecting +BAUD_SUFFIX_RE = re.compile(r":\d+$") + +# Supervisor app state, mirrors `aiohasupervisor.models.AddonState.STARTED` +APP_STATE_STARTED = "started" + + +def _resolve_key_path(data: Mapping[str, Any], key_path: tuple[str, ...]) -> Any: + """Return the value at a key path, or `None` if the path does not exist.""" + value: Any = data + + for key in key_path: + if not isinstance(value, Mapping) or key not in value: + return None + value = value[key] + + return value + + +def _serial_port_from_value( + value: Any, known_devices: set[str], domain: str +) -> str | None: + """Return the serial port a config entry value refers to.""" + if not isinstance(value, str): + return None + + if value in known_devices: + return value + + if value.startswith(SCANNED_PORT_SCHEMES): + return value + + if value.startswith(UNSCANNABLE_PORT_SCHEMES): + # zwave_js's esphome:// socket path embeds the noise PSK as `?key=` + return value.partition("?")[0] + + path = value + + if domain in ("elkm1", "upb"): + path = path.removeprefix("serial://").removeprefix("device://") + path = BAUD_SUFFIX_RE.sub("", path) + + if path.startswith("/dev/"): + return path + + return None + + +def _resolve_paths(paths: set[str]) -> dict[str, str]: + """Resolve symlinks of local device paths, passing other values through.""" + return { + path: os.path.realpath(path) if path.startswith("/") else path for path in paths + } + + +async def _async_get_config_entry_consumers( + hass: HomeAssistant, known_devices: set[str] +) -> dict[str, list[SerialPortConsumer]]: + """Return serial ports configured in config entries of `usb` integrations.""" + entries = hass.config_entries.async_entries(include_ignore=False) + integrations = await async_get_integrations( + hass, {entry.domain for entry in entries} + ) + consumers: dict[str, list[SerialPortConsumer]] = {} + + for entry in entries: + integration = integrations[entry.domain] + + if isinstance(integration, Exception): + continue + + if ( + entry.domain not in NON_USB_SERIAL_DOMAINS + and DOMAIN not in integration.dependencies + and DOMAIN not in integration.after_dependencies + ): + continue + + for key_path in SERIAL_PORT_KEY_PATHS: + for data in (entry.data, entry.options): + port = _serial_port_from_value( + _resolve_key_path(data, key_path), known_devices, entry.domain + ) + + if port is None: + continue + + consumers.setdefault(port, []).append( + SerialPortConsumer( + kind="config_entry", + title=entry.title, + active=entry.state in ACTIVE_CONFIG_ENTRY_STATES, + domain=entry.domain, + config_entry_id=entry.entry_id, + ) + ) + + return consumers + + +@callback +def _async_get_app_consumers( + hass: HomeAssistant, +) -> dict[str, list[SerialPortConsumer]]: + """Return devices mapped into apps, either statically or through options. + + Supervisor resolves `device(subsystem=tty)` options into real devices, so device + paths that no longer exist are missing and non-serial devices are included. + """ + if not is_hassio(hass): + return {} + + try: + apps_info = get_addons_info(hass) + except HassioNotReadyError: + return {} + + consumers: dict[str, list[SerialPortConsumer]] = {} + + for slug, info in apps_info.items(): + if info is None: + continue + + for device in info["devices"]: + consumers.setdefault(device, []).append( + SerialPortConsumer( + kind="app", + title=info["name"], + active=info["state"] == APP_STATE_STARTED, + slug=slug, + ) + ) + + return consumers + + +async def async_get_serial_port_consumers( + hass: HomeAssistant, ports: Sequence[USBDevice | SerialDevice] +) -> dict[str, list[SerialPortConsumer]]: + """Return the consumers of every serial port, keyed by device path. + + Scanned ports are keyed by their scanned device path, ports that are configured + but not currently present are keyed by their configured path. + """ + known_devices = {port.device for port in ports} + + entry_consumers = await _async_get_config_entry_consumers(hass, known_devices) + app_consumers = _async_get_app_consumers(hass) + + resolved = await hass.async_add_executor_job( + _resolve_paths, known_devices | set(entry_consumers) | set(app_consumers) + ) + + # A port can be referred to by any of its symlinks, e.g. `/dev/serial/by-id` + aliases: dict[str, str] = {} + + for port in ports: + aliases[resolved[port.device]] = port.device + aliases[port.device] = port.device + + consumers: dict[str, list[SerialPortConsumer]] = {} + + for path, path_consumers in entry_consumers.items(): + # Ports that are configured but missing are kept and shown as absent + device = aliases.get(resolved[path], path) + consumers.setdefault(device, []).extend(path_consumers) + + for path, path_consumers in app_consumers.items(): + # Apps also map non-serial devices, only scanned ports are of interest + resolved_path = resolved[path] + + if resolved_path not in aliases: + continue + + consumers.setdefault(aliases[resolved_path], []).extend(path_consumers) + + return { + device: list(dict.fromkeys(device_consumers)) + for device, device_consumers in consumers.items() + } diff --git a/homeassistant/components/usb/manifest.json b/homeassistant/components/usb/manifest.json index 7f934dc9ee51..daa33746b363 100644 --- a/homeassistant/components/usb/manifest.json +++ b/homeassistant/components/usb/manifest.json @@ -1,6 +1,7 @@ { "domain": "usb", "name": "USB Discovery", + "after_dependencies": ["hassio"], "codeowners": ["@bdraco"], "dependencies": ["websocket_api"], "documentation": "https://www.home-assistant.io/integrations/usb", diff --git a/homeassistant/components/usb/models.py b/homeassistant/components/usb/models.py index 840978e5ea46..912dc7e59d79 100644 --- a/homeassistant/components/usb/models.py +++ b/homeassistant/components/usb/models.py @@ -1,6 +1,7 @@ """Models helper class for the usb integration.""" from dataclasses import dataclass +from typing import Literal @dataclass(slots=True, frozen=True, kw_only=True) @@ -8,6 +9,8 @@ class SerialDevice: """A serial device.""" device: str + resolved_device: str | None = None + serial_number: str | None manufacturer: str | None description: str | None @@ -24,3 +27,15 @@ class USBDevice(SerialDevice): # bcdDevice descriptor, often the firmware revision bcd_device: int | None = None + + +@dataclass(slots=True, frozen=True, kw_only=True) +class SerialPortConsumer: + """An integration or app configured to use a serial port.""" + + kind: Literal["config_entry", "app"] + title: str + active: bool + domain: str | None = None + config_entry_id: str | None = None + slug: str | None = None diff --git a/homeassistant/components/usb/utils.py b/homeassistant/components/usb/utils.py index 3d048a56777f..d7ca251ae8dc 100644 --- a/homeassistant/components/usb/utils.py +++ b/homeassistant/components/usb/utils.py @@ -19,6 +19,7 @@ def usb_device_from_port(port: SerialPortInfo) -> USBDevice: return USBDevice( device=port.device, + resolved_device=port.resolved_device, vid=f"{hex(port.vid)[2:]:0>4}".upper(), pid=f"{hex(port.pid)[2:]:0>4}".upper(), serial_number=port.serial_number, @@ -34,6 +35,7 @@ def serial_device_from_port(port: SerialPortInfo) -> SerialDevice: """Convert serialx SerialPortInfo to SerialDevice.""" return SerialDevice( device=port.device, + resolved_device=port.resolved_device, serial_number=port.serial_number, manufacturer=port.manufacturer, description=port.description, diff --git a/homeassistant/components/vallox/const.py b/homeassistant/components/vallox/const.py index 6c7c3154dada..c452cfef2d6e 100644 --- a/homeassistant/components/vallox/const.py +++ b/homeassistant/components/vallox/const.py @@ -28,6 +28,7 @@ I18N_KEY_TO_VALLOX_PROFILE = { "boost": VALLOX_PROFILE.BOOST, "fireplace": VALLOX_PROFILE.FIREPLACE, "extra": VALLOX_PROFILE.EXTRA, + "auto": VALLOX_PROFILE.AUTO, } VALLOX_PROFILE_TO_PRESET_MODE = { @@ -36,6 +37,7 @@ VALLOX_PROFILE_TO_PRESET_MODE = { VALLOX_PROFILE.BOOST: "Boost", VALLOX_PROFILE.FIREPLACE: "Fireplace", VALLOX_PROFILE.EXTRA: "Extra", + VALLOX_PROFILE.AUTO: "Auto", } PRESET_MODE_TO_VALLOX_PROFILE = { diff --git a/homeassistant/components/vallox/services.yaml b/homeassistant/components/vallox/services.yaml index f2a55032b931..1c821251f04a 100644 --- a/homeassistant/components/vallox/services.yaml +++ b/homeassistant/components/vallox/services.yaml @@ -41,6 +41,7 @@ set_profile: - "boost" - "fireplace" - "extra" + - "auto" duration: required: false selector: diff --git a/homeassistant/components/vallox/strings.json b/homeassistant/components/vallox/strings.json index 0b65834a3dd8..d2a2d81a57bb 100644 --- a/homeassistant/components/vallox/strings.json +++ b/homeassistant/components/vallox/strings.json @@ -117,6 +117,7 @@ "selector": { "profile": { "options": { + "auto": "[%key:common::state::auto%]", "away": "[%key:common::state::not_home%]", "boost": "Boost", "extra": "Extra", diff --git a/homeassistant/components/voip/assist_satellite.py b/homeassistant/components/voip/assist_satellite.py index 31a5c58fd897..f8b84ffeb9af 100644 --- a/homeassistant/components/voip/assist_satellite.py +++ b/homeassistant/components/voip/assist_satellite.py @@ -429,8 +429,6 @@ class VoipAssistSatellite(VoIPEntity, AssistSatelliteEntity, RtpDatagramProtocol """Run a pipeline with STT input and TTS output.""" _LOGGER.debug("Starting pipeline") - self.async_set_context(Context(user_id=self.config_entry.data["user"])) - async def stt_stream(): retry: bool = True while True: @@ -455,6 +453,7 @@ class VoipAssistSatellite(VoIPEntity, AssistSatelliteEntity, RtpDatagramProtocol try: await self.async_accept_pipeline_from_satellite( audio_stream=stt_stream(), + context=Context(user_id=self.config_entry.data["user"]), ) if self._pipeline_had_error: diff --git a/homeassistant/components/volvo/manifest.json b/homeassistant/components/volvo/manifest.json index 9238f4a770bf..8be6c4da16ed 100644 --- a/homeassistant/components/volvo/manifest.json +++ b/homeassistant/components/volvo/manifest.json @@ -9,5 +9,5 @@ "iot_class": "cloud_polling", "loggers": ["volvocarsapi"], "quality_scale": "platinum", - "requirements": ["volvocarsapi==0.4.3"] + "requirements": ["volvocarsapi==0.4.4"] } diff --git a/homeassistant/components/webostv/manifest.json b/homeassistant/components/webostv/manifest.json index 45c5b3375756..674a28b9f6b6 100644 --- a/homeassistant/components/webostv/manifest.json +++ b/homeassistant/components/webostv/manifest.json @@ -8,7 +8,7 @@ "iot_class": "local_push", "loggers": ["aiowebostv"], "quality_scale": "platinum", - "requirements": ["aiowebostv==0.9.1"], + "requirements": ["aiowebostv==0.9.2"], "ssdp": [ { "st": "urn:lge-com:service:webos-second-screen:1" diff --git a/homeassistant/components/webostv/media_player.py b/homeassistant/components/webostv/media_player.py index 5da387f1532e..571080a17b31 100644 --- a/homeassistant/components/webostv/media_player.py +++ b/homeassistant/components/webostv/media_player.py @@ -340,11 +340,14 @@ class LgWebOSMediaPlayerEntity(WebOsTvEntity, RestoreEntity, MediaPlayerEntity): perfect_match_channel_id = None for channel in self._client.tv_state.channels: - if media_id == channel["channelNumber"]: - perfect_match_channel_id = channel["channelId"] - continue - if media_id.lower() == channel["channelName"].lower(): + perfect_match_channel_id = channel["channelId"] + break + + if ( + media_id == channel["channelNumber"] + and perfect_match_channel_id is None + ): perfect_match_channel_id = channel["channelId"] continue diff --git a/homeassistant/components/withings/sensor.py b/homeassistant/components/withings/sensor.py index 43ae2a4e7daf..fb1def96c4f1 100644 --- a/homeassistant/components/withings/sensor.py +++ b/homeassistant/components/withings/sensor.py @@ -866,7 +866,7 @@ async def async_setup_entry( ) ) and config_entry.state is ConfigEntryState.LOADED - for device in device_registry.devices.get_entries( + for device in device_registry.async_get_devices( identifiers={(DOMAIN, device_id)} ) ): diff --git a/homeassistant/components/yardian/manifest.json b/homeassistant/components/yardian/manifest.json index ce074b44646e..2ef8f7afb3d2 100644 --- a/homeassistant/components/yardian/manifest.json +++ b/homeassistant/components/yardian/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/yardian", "integration_type": "device", "iot_class": "local_polling", - "requirements": ["pyyardian==1.4.1"] + "requirements": ["pyyardian==1.4.2"] } diff --git a/homeassistant/components/zha/logbook.py b/homeassistant/components/zha/logbook.py index 8dd7bd1d740f..2d88d60324a6 100644 --- a/homeassistant/components/zha/logbook.py +++ b/homeassistant/components/zha/logbook.py @@ -36,7 +36,9 @@ def async_describe_events( event_subtype: str | None = None try: - device = device_registry.devices[event.data[ATTR_DEVICE_ID]] + device = device_registry.async_get( + event.data[ATTR_DEVICE_ID], include_child_devices=False + ) if device: device_name = device.name_by_user or device.name or "Unknown device" zha_device = async_get_zha_device_proxy( diff --git a/homeassistant/components/zwave_js/__init__.py b/homeassistant/components/zwave_js/__init__.py index bd6930be8c21..fc62275f4286 100644 --- a/homeassistant/components/zwave_js/__init__.py +++ b/homeassistant/components/zwave_js/__init__.py @@ -94,7 +94,6 @@ from .const import ( CONF_ADDON_SOCKET, CONF_DATA_COLLECTION_OPTED_IN, CONF_INTEGRATION_CREATED_ADDON, - CONF_KEEP_OLD_DEVICES, CONF_LR_S2_ACCESS_CONTROL_KEY, CONF_LR_S2_AUTHENTICATED_KEY, CONF_NETWORK_KEY, @@ -391,11 +390,12 @@ class DriverEvents: controller.on("identify", self.controller_events.async_on_identify) ) - if ( + unknown_controller = ( old_unique_id := self.config_entry.unique_id ) is not None and old_unique_id != ( new_unique_id := str(driver.controller.home_id) - ): + ) + if unknown_controller: device_registry = dr.async_get(self.hass) controller_model = "Unknown model" if ( @@ -411,9 +411,6 @@ class DriverEvents: ): controller_model = model - # Do not clean up old stale devices if an unknown controller is connected. - data = {**self.config_entry.data, CONF_KEEP_OLD_DEVICES: True} - self.hass.config_entries.async_update_entry(self.config_entry, data=data) async_create_issue( self.hass, DOMAIN, @@ -430,9 +427,6 @@ class DriverEvents: translation_key="migrate_unique_id", ) else: - data = self.config_entry.data.copy() - data.pop(CONF_KEEP_OLD_DEVICES, None) - self.hass.config_entries.async_update_entry(self.config_entry, data=data) async_delete_issue( self.hass, DOMAIN, f"migrate_unique_id.{self.config_entry.entry_id}" ) @@ -455,8 +449,8 @@ class DriverEvents: ] # Devices that are in the device registry that are not known by the controller - # can be removed - if not self.config_entry.data.get(CONF_KEEP_OLD_DEVICES): + # can be removed, but not while an unknown controller is connected. + if not unknown_controller: for device in stored_devices: if device not in known_devices and device not in provisioned_devices: self.dev_reg.async_remove_device(device.id) diff --git a/homeassistant/components/zwave_js/api.py b/homeassistant/components/zwave_js/api.py index d2c57ccbab6e..5b98bdfd69e9 100644 --- a/homeassistant/components/zwave_js/api.py +++ b/homeassistant/components/zwave_js/api.py @@ -51,6 +51,7 @@ from zwave_js_server.model.node.firmware import ( NodeFirmwareUpdateProgress, NodeFirmwareUpdateResult, ) +from zwave_js_server.model.statistics import RouteStatistics from zwave_js_server.model.utils import ( async_parse_qr_code_string, async_try_parse_dsk_from_qr_code_string, @@ -2835,10 +2836,30 @@ def _get_node_statistics_dict( device = dev_reg.async_get_device_by_identifier( get_device_id(driver, node), entry.entry_id ) - assert device + if device is None: + raise ValueError(f"Device for node {node.node_id} not found") return device.id - data: dict = { + def _get_route_statistics_dict( + route_statistics: RouteStatistics | None, + ) -> dict[str, Any] | None: + """Get dictionary of route statistics.""" + if route_statistics is None: + return None + try: + data: dict[str, Any] = dict(route_statistics.as_dict()) + for key in ("repeaters", "route_failed_between"): + if data[key]: + data[key] = [_convert_node_to_device_id(node) for node in data[key]] + except KeyError, StopIteration, ValueError: + # The route may reference nodes that have been removed from the + # network (KeyError) or that don't have a device entry (ValueError), + # and async_get_config_entry_from_node raises StopIteration when + # the config entry is no longer loaded + return None + return data + + return { "commands_tx": statistics.commands_tx, "commands_rx": statistics.commands_rx, "commands_dropped_tx": statistics.commands_dropped_tx, @@ -2846,20 +2867,9 @@ def _get_node_statistics_dict( "timeout_response": statistics.timeout_response, "rtt": statistics.rtt, "rssi": statistics.rssi, - "lwr": statistics.lwr.as_dict() if statistics.lwr else None, - "nlwr": statistics.nlwr.as_dict() if statistics.nlwr else None, + "lwr": _get_route_statistics_dict(statistics.lwr), + "nlwr": _get_route_statistics_dict(statistics.nlwr), } - for key in ("lwr", "nlwr"): - if not data[key]: - continue - for key_2 in ("repeaters", "route_failed_between"): - if not data[key][key_2]: - continue - data[key][key_2] = [ - _convert_node_to_device_id(node) for node in data[key][key_2] - ] - - return data @websocket_api.require_admin diff --git a/homeassistant/components/zwave_js/config_flow.py b/homeassistant/components/zwave_js/config_flow.py index 773b322f3bfe..034fae1e0157 100644 --- a/homeassistant/components/zwave_js/config_flow.py +++ b/homeassistant/components/zwave_js/config_flow.py @@ -2,6 +2,7 @@ import asyncio import base64 +from collections.abc import Callable from contextlib import suppress import logging from pathlib import Path @@ -10,7 +11,7 @@ from typing import Any, override from awesomeversion import AwesomeVersion import voluptuous as vol from zwave_js_server.client import Client -from zwave_js_server.exceptions import FailedCommand +from zwave_js_server.exceptions import BaseZwaveJSServerError, FailedCommand from zwave_js_server.model.driver import Driver from zwave_js_server.version import VersionInfo @@ -23,6 +24,7 @@ from homeassistant.components.hassio import ( ) from homeassistant.config_entries import ( SOURCE_ESPHOME, + SOURCE_IGNORE, SOURCE_USB, SOURCE_ZEROCONF, ConfigEntry, @@ -34,7 +36,8 @@ from homeassistant.const import CONF_NAME, CONF_URL from homeassistant.core import HomeAssistant, callback from homeassistant.data_entry_flow import AbortFlow from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers import selector +from homeassistant.helpers import device_registry as dr, selector +from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.hassio import is_hassio from homeassistant.helpers.service_info.esphome import ESPHomeServiceInfo from homeassistant.helpers.service_info.hassio import HassioServiceInfo @@ -42,6 +45,7 @@ from homeassistant.helpers.service_info.usb import UsbServiceInfo from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo from homeassistant.util import dt as dt_util +from . import helpers from .addon import get_addon_manager from .const import ( ADDON_SLUG, @@ -55,7 +59,6 @@ from .const import ( CONF_ADDON_S2_UNAUTHENTICATED_KEY, CONF_ADDON_SOCKET, CONF_INTEGRATION_CREATED_ADDON, - CONF_KEEP_OLD_DEVICES, CONF_LR_S2_ACCESS_CONTROL_KEY, CONF_LR_S2_AUTHENTICATED_KEY, CONF_S0_LEGACY_KEY, @@ -70,8 +73,9 @@ from .const import ( from .helpers import ( CannotConnect, async_get_version_info, - async_wait_for_driver_ready_event, format_home_id_for_display, + get_device_id, + get_device_id_ext, ) from .models import ZwaveJSConfigEntry @@ -82,6 +86,7 @@ TITLE = "Z-Wave JS" ADDON_SETUP_TIMEOUT = 5 ADDON_SETUP_TIMEOUT_ROUNDS = 40 +SERVER_CONNECT_TIMEOUT = 60 ADDON_USER_INPUT_MAP = { CONF_ADDON_DEVICE: CONF_USB_PATH, @@ -231,6 +236,9 @@ class ZWaveJSConfigFlow(ConfigFlow, domain=DOMAIN): self._recommended_install = False self._rf_region: str | None = None self._entry_unloaded_by_flow = False + # Set if the flow unique id is a placeholder that must be replaced + # with the home ID before a config entry is created. + self._unique_id_is_placeholder = False async def async_step_install_addon( self, user_input: dict[str, Any] | None = None @@ -567,10 +575,13 @@ class ZWaveJSConfigFlow(ConfigFlow, domain=DOMAIN): await self.async_set_unique_id( f"{vid}:{pid}_{serial_number}_{manufacturer}_{description}" ) - # We don't need to check if the unique_id is already configured - # since we will update the unique_id before finishing the flow. - # The unique_id set above is just a temporary value to avoid - # duplicate discovery flows. + # The unique id set above is a placeholder that is replaced with the + # home ID before an entry is created, so only check ignored entries. + if any( + entry.source == SOURCE_IGNORE and entry.unique_id == self.unique_id + for entry in self._async_current_entries(include_ignore=True) + ): + return self.async_abort(reason="already_configured") dev_path = discovery_info.device self.usb_path = dev_path if manufacturer == "Nabu Casa" and description == "ZWA-2 - Nabu Casa ZWA-2": @@ -762,6 +773,16 @@ class ZWaveJSConfigFlow(ConfigFlow, domain=DOMAIN): self.use_addon = True + if any( + entry.data.get(CONF_USE_ADDON) and entry.unique_id != self.unique_id + for entry in self._async_current_entries(include_ignore=False) + ): + # The add-on can only connect to a single adapter, so abort before + # the flow changes the add-on config of the existing entry. + # A discovery of the existing entry's own adapter passes, so the + # entry can be updated, e.g. from a USB path to a socket. + return self.async_abort(reason="addon_already_configured") + addon_info = await self._async_get_addon_info() if addon_info.state is AddonState.RUNNING: @@ -787,6 +808,19 @@ class ZWaveJSConfigFlow(ConfigFlow, domain=DOMAIN): self.lr_s2_authenticated_key = addon_config.get( CONF_ADDON_LR_S2_AUTHENTICATED_KEY, "" ) + + if self._adapter_discovered: + # Apply the discovered adapter to the add-on config and + # restart the add-on before connecting, so the server + # version info reflects the discovered adapter. + self._addon_config_updates.update( + { + CONF_ADDON_DEVICE: self.usb_path, + CONF_ADDON_SOCKET: self.socket_path, + } + ) + return await self.async_step_start_addon() + return await self.async_step_finish_addon_setup_user() if addon_info.state is AddonState.NOT_RUNNING: @@ -998,7 +1032,11 @@ class ZWaveJSConfigFlow(ConfigFlow, domain=DOMAIN): discovery_info = await self._async_get_addon_discovery_info() self.ws_address = f"ws://{discovery_info['host']}:{discovery_info['port']}" - if not self.unique_id or self.source == SOURCE_USB: + if ( + not self.unique_id + or self.source == SOURCE_USB + or self._unique_id_is_placeholder + ): if not self.version_info: try: self.version_info = await async_get_version_info( @@ -1010,6 +1048,7 @@ class ZWaveJSConfigFlow(ConfigFlow, domain=DOMAIN): await self.async_set_unique_id( str(self.version_info.home_id), raise_on_progress=False ) + self._unique_id_is_placeholder = False if ( existing_entry := next( @@ -1026,24 +1065,6 @@ class ZWaveJSConfigFlow(ConfigFlow, domain=DOMAIN): # with add-on data. return self.async_abort(reason="already_configured") - # When we came from discovery, make sure we update the add-on - if self._adapter_discovered and self.use_addon: - await self._async_set_addon_config( - { - CONF_ADDON_DEVICE: self.usb_path, - CONF_ADDON_SOCKET: self.socket_path, - CONF_ADDON_S0_LEGACY_KEY: self.s0_legacy_key, - CONF_ADDON_S2_ACCESS_CONTROL_KEY: self.s2_access_control_key, - CONF_ADDON_S2_AUTHENTICATED_KEY: self.s2_authenticated_key, - CONF_ADDON_S2_UNAUTHENTICATED_KEY: self.s2_unauthenticated_key, - CONF_ADDON_LR_S2_ACCESS_CONTROL_KEY: self.lr_s2_access_control_key, - CONF_ADDON_LR_S2_AUTHENTICATED_KEY: self.lr_s2_authenticated_key, - } - ) - if self.restart_addon: - manager = get_addon_manager(self.hass) - await manager.async_stop_addon() - self._abort_if_unique_id_configured( updates={ CONF_URL: self.ws_address, @@ -1340,6 +1361,14 @@ class ZWaveJSConfigFlow(ConfigFlow, domain=DOMAIN): raise AbortFlow("addon_stop_failed") from err return await self.async_step_manual_reconfigure() + if any( + entry.data.get(CONF_USE_ADDON) and entry.entry_id != config_entry.entry_id + for entry in self._async_current_entries(include_ignore=False) + ): + # The add-on can only connect to a single adapter, so abort before + # the flow changes the add-on config of the other entry. + return self.async_abort(reason="addon_already_configured") + addon_info = await self._async_get_addon_info() if addon_info.state is AddonState.NOT_INSTALLED: @@ -1563,15 +1592,9 @@ class ZWaveJSConfigFlow(ConfigFlow, domain=DOMAIN): """Prepare info needed to complete the config entry update.""" ws_address = self.ws_address assert ws_address is not None - version_info = self.version_info - assert version_info is not None config_entry = self._reconfigure_config_entry assert config_entry is not None - # We need to wait for the config entry to be reloaded, - # before restoring the backup. - # We will do this in the restore nvm progress task, - # to get a nicer user experience. self.hass.config_entries.async_update_entry( config_entry, data={ @@ -1588,7 +1611,6 @@ class ZWaveJSConfigFlow(ConfigFlow, domain=DOMAIN): CONF_USE_ADDON: True, CONF_INTEGRATION_CREATED_ADDON: self.integration_created_addon, }, - unique_id=str(version_info.home_id), ) return await self.async_step_restore_nvm() @@ -1649,6 +1671,11 @@ class ZWaveJSConfigFlow(ConfigFlow, domain=DOMAIN): if not is_hassio(self.hass): return self.async_abort(reason="not_hassio") + # The adapter may first be discovered without a home ID and get the + # placeholder unique id below, then report a home ID on a later + # discovery. Track the placeholder id so such a discovery can be + # deduplicated against a pending prompt or an ignored entry. + placeholder_unique_id = f"esphome_{discovery_info.name}" if discovery_info.zwave_home_id: existing_entry: ConfigEntry | None = None if ( @@ -1696,6 +1723,11 @@ class ZWaveJSConfigFlow(ConfigFlow, domain=DOMAIN): ) return self.async_abort(reason="already_configured") + if any( + flow["context"].get("unique_id") == placeholder_unique_id + for flow in self._async_in_progress() + ): + return self.async_abort(reason="already_in_progress") # We are not aborting if home ID configured # here, we just want to make sure that it's set # We will update a USB based config entry @@ -1704,6 +1736,19 @@ class ZWaveJSConfigFlow(ConfigFlow, domain=DOMAIN): await self.async_set_unique_id( str(discovery_info.zwave_home_id), raise_on_progress=False ) + else: + # Set a placeholder unique id so the discovery can be ignored + # also when the adapter doesn't report a home ID yet. + # It is replaced with the home ID before an entry is created. + self._unique_id_is_placeholder = True + await self.async_set_unique_id(placeholder_unique_id) + + if any( + entry.source == SOURCE_IGNORE + and entry.unique_id in (self.unique_id, placeholder_unique_id) + for entry in self._async_current_entries(include_ignore=True) + ): + return self.async_abort(reason="already_configured") self.socket_path = discovery_info.socket_path home_id_display = format_home_id_for_display(discovery_info.zwave_home_id) @@ -1712,6 +1757,32 @@ class ZWaveJSConfigFlow(ConfigFlow, domain=DOMAIN): } self._adapter_discovered = True + # A discovered adapter that doesn't belong to an existing add-on based + # entry is a different adapter, so offer to migrate the existing + # network to it instead of repointing the shared add-on config. + discovered_home_id = ( + str(discovery_info.zwave_home_id) if discovery_info.zwave_home_id else None + ) + addon_entries = [ + entry + for entry in self._async_current_entries(include_ignore=False) + if entry.data.get(CONF_USE_ADDON) + ] + if discovered_home_id is None and any( + entry.data.get(CONF_SOCKET_PATH) == discovery_info.socket_path + for entry in addon_entries + ): + # A reconnect of the configured adapter without a home ID is the + # same adapter, not a new one to migrate to. + return self.async_abort(reason="already_configured") + + if addon_entry := next( + (entry for entry in addon_entries if entry.unique_id != discovered_home_id), + None, + ): + self._reconfigure_config_entry = addon_entry + return await self.async_step_confirm_usb_migration() + return await self.async_step_installation_type() async def async_revert_addon_config(self, reason: str) -> ConfigFlowResult: @@ -1775,23 +1846,10 @@ class ZWaveJSConfigFlow(ConfigFlow, domain=DOMAIN): async def _async_restore_network_backup(self) -> None: """Restore the backup.""" assert self.backup_data is not None + assert self.ws_address is not None config_entry = self._reconfigure_config_entry assert config_entry is not None - # Make sure we keep the old devices - # so that user customizations are not lost, - # when loading the config entry. - self.hass.config_entries.async_update_entry( - config_entry, data=config_entry.data | {CONF_KEEP_OLD_DEVICES: True} - ) - - # Reload the config entry to reconnect the client after the addon restart - await self.hass.config_entries.async_reload(config_entry.entry_id) - - data = config_entry.data.copy() - data.pop(CONF_KEEP_OLD_DEVICES, None) - self.hass.config_entries.async_update_entry(config_entry, data=data) - @callback def forward_progress(event: dict) -> None: """Forward progress events to frontend.""" @@ -1804,53 +1862,73 @@ class ZWaveJSConfigFlow(ConfigFlow, domain=DOMAIN): event["bytesWritten"] / event["total"] * 0.5 + 0.5 ) - driver = self._get_driver() - controller = driver.controller - unsubs = [ - controller.on("nvm convert progress", forward_progress), - controller.on("nvm restore progress", forward_progress), - ] - - wait_for_driver_ready = async_wait_for_driver_ready_event(config_entry, driver) - + client = Client(self.ws_address, async_get_clientsession(self.hass)) + driver_ready = asyncio.Event() + listen_task: asyncio.Task[None] | None = None + unsubs: list[Callable[[], None]] = [] try: - await controller.async_restore_nvm( - self.backup_data, {"preserveRoutes": False} - ) - except FailedCommand as err: - raise AbortFlow(f"Failed to restore network: {err}") from err - else: - with suppress(TimeoutError): - await wait_for_driver_ready() try: - version_info = await async_get_version_info( - self.hass, config_entry.data[CONF_URL] - ) - except CannotConnect: - # Just log this error, as there's nothing to do about it here. - # The stale unique id needs to be handled by a repair flow, - # after the config entry has been reloaded. - _LOGGER.error( - "Failed to get server version, cannot update config entry " - "unique id with new home id, after controller reset" - ) - else: - self.hass.config_entries.async_update_entry( - config_entry, unique_id=str(version_info.home_id) - ) + async with asyncio.timeout(SERVER_CONNECT_TIMEOUT): + await client.connect() + listen_task = self.hass.async_create_task( + client.listen(driver_ready), + f"{DOMAIN}_migration_listen", + ) + await driver_ready.wait() + except (TimeoutError, BaseZwaveJSServerError) as err: + raise AbortFlow(f"Failed to restore network: {err}") from err - # The config entry will be also be reloaded when the driver is ready, - # by the listener in the package module, - # and two reloads are needed to clean up the stale controller device entry. - # Since both the old and the new controller have the same node id, - # but different hardware identifiers, the integration - # will create a new device for the new controller, on the first reload, - # but not immediately remove the old device. - await self.hass.config_entries.async_reload(config_entry.entry_id) + driver = client.driver + assert driver is not None + controller = driver.controller + controller_reset = asyncio.Event() + + @callback + def set_controller_reset(event: dict) -> None: + controller_reset.set() + + unsubs = [ + controller.on("nvm convert progress", forward_progress), + controller.on("nvm restore progress", forward_progress), + driver.once("driver ready", set_controller_reset), + ] + try: + await controller.async_restore_nvm( + self.backup_data, {"preserveRoutes": False} + ) + except FailedCommand as err: + raise AbortFlow(f"Failed to restore network: {err}") from err + with suppress(TimeoutError): + async with asyncio.timeout(helpers.DRIVER_READY_EVENT_TIMEOUT): + await controller_reset.wait() + + if own_node := controller.own_node: + device_registry = dr.async_get(self.hass) + if ( + (device_id_ext := get_device_id_ext(driver, own_node)) + and ( + old_device := device_registry.async_get_device_by_identifier( + get_device_id(driver, own_node), config_entry.entry_id + ) + ) + and device_id_ext not in old_device.identifiers + ): + # The old controller device is stale, and unlike the + # integration, the flow knows the controller was replaced. + device_registry.async_remove_device(old_device.id) finally: for unsub in unsubs: unsub() + # Disconnect before awaiting the listen task, + # since disconnect waits for the listen loop to finish. + await client.disconnect() + if listen_task is not None: + listen_task.cancel() + with suppress(asyncio.CancelledError, BaseZwaveJSServerError): + await listen_task + + await self.hass.config_entries.async_reload(config_entry.entry_id) def _get_driver(self) -> Driver: """Get the driver from the config entry.""" diff --git a/homeassistant/components/zwave_js/const.py b/homeassistant/components/zwave_js/const.py index ac0463a9411b..d8b721aa8a95 100644 --- a/homeassistant/components/zwave_js/const.py +++ b/homeassistant/components/zwave_js/const.py @@ -24,7 +24,6 @@ CONF_ADDON_LR_S2_ACCESS_CONTROL_KEY = "lr_s2_access_control_key" CONF_ADDON_LR_S2_AUTHENTICATED_KEY = "lr_s2_authenticated_key" CONF_ADDON_SOCKET = "socket" CONF_INTEGRATION_CREATED_ADDON = "integration_created_addon" -CONF_KEEP_OLD_DEVICES = "keep_old_devices" CONF_NETWORK_KEY = "network_key" CONF_S0_LEGACY_KEY = "s0_legacy_key" CONF_S2_ACCESS_CONTROL_KEY = "s2_access_control_key" diff --git a/homeassistant/components/zwave_js/discovery.py b/homeassistant/components/zwave_js/discovery.py index 3e2fa301aaf4..98039ecfe854 100644 --- a/homeassistant/components/zwave_js/discovery.py +++ b/homeassistant/components/zwave_js/discovery.py @@ -258,6 +258,18 @@ DISCOVERY_SCHEMAS = [ FanValueMapping(speeds=[(1, 25), (26, 50), (51, 75), (76, 99)]), ), ), + # Leviton VRF01 fan controllers using switch multilevel CC + ZWaveDiscoverySchema( + platform=Platform.FAN, + hint="has_fan_value_mapping", + manufacturer_id={0x001D}, + product_id={0x0209, 0x0334}, + product_type={0x1001}, + primary_value=SWITCH_MULTILEVEL_CURRENT_VALUE_SCHEMA, + data_template=FixedFanValueMappingDataTemplate( + FanValueMapping(speeds=[(1, 32), (33, 66), (67, 99)]), + ), + ), # Inovelli LZW36 light / fan controller combo using switch multilevel CC # The fan is endpoint 2, the light is endpoint 1. ZWaveDiscoverySchema( diff --git a/homeassistant/components/zwave_js/logbook.py b/homeassistant/components/zwave_js/logbook.py index 2db0600fd9bb..97b7468c7245 100644 --- a/homeassistant/components/zwave_js/logbook.py +++ b/homeassistant/components/zwave_js/logbook.py @@ -37,10 +37,10 @@ def async_describe_events( event: Event, ) -> dict[str, str]: """Describe Z-Wave JS notification event.""" - device = dev_reg.devices[event.data[ATTR_DEVICE_ID]] - # Z-Wave JS devices always have a name - device_name = device.name_by_user or device.name - assert device_name + device = dev_reg.async_get( + event.data[ATTR_DEVICE_ID], include_child_devices=False + ) + device_name = (device.name_by_user or device.name or "") if device else "" command_class = event.data[ATTR_COMMAND_CLASS] command_class_name = event.data[ATTR_COMMAND_CLASS_NAME] @@ -84,10 +84,10 @@ def async_describe_events( event: Event, ) -> dict[str, str]: """Describe Z-Wave JS value notification event.""" - device = dev_reg.devices[event.data[ATTR_DEVICE_ID]] - # Z-Wave JS devices always have a name - device_name = device.name_by_user or device.name - assert device_name + device = dev_reg.async_get( + event.data[ATTR_DEVICE_ID], include_child_devices=False + ) + device_name = (device.name_by_user or device.name or "") if device else "" command_class = event.data[ATTR_COMMAND_CLASS_NAME] label = event.data[ATTR_LABEL] diff --git a/homeassistant/components/zwave_js/strings.json b/homeassistant/components/zwave_js/strings.json index 49a602d051a4..0f6c78f2ae49 100644 --- a/homeassistant/components/zwave_js/strings.json +++ b/homeassistant/components/zwave_js/strings.json @@ -1,6 +1,7 @@ { "config": { "abort": { + "addon_already_configured": "A configuration entry using the Z-Wave JS app already exists. Reconfigure or migrate that entry instead.", "addon_get_discovery_info_failed": "Failed to get Z-Wave JS app discovery info.", "addon_info_failed": "Failed to get Z-Wave JS app info.", "addon_install_failed": "Failed to install the Z-Wave JS app.", diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index 9d191fd34776..864f89f265fa 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -650,6 +650,7 @@ FLOWS = { "redgtech", "refoss", "rehlko", + "remember_the_milk", "remote_calendar", "renault", "renson", diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index 4f2eda4b5bc2..fff661a17fb3 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -532,6 +532,11 @@ "config_flow": false, "iot_class": "local_polling" }, + "ariston": { + "name": "Ariston", + "integration_type": "virtual", + "supported_by": "midea" + }, "arris_tg2492lg": { "name": "Arris TG2492LG", "integration_type": "hub", @@ -5977,8 +5982,8 @@ }, "remember_the_milk": { "name": "Remember The Milk", - "integration_type": "hub", - "config_flow": false, + "integration_type": "service", + "config_flow": true, "iot_class": "cloud_push" }, "remote_calendar": { diff --git a/homeassistant/generated/recorder_database_versions.py b/homeassistant/generated/recorder_database_versions.py new file mode 100644 index 000000000000..5ff52aa34919 --- /dev/null +++ b/homeassistant/generated/recorder_database_versions.py @@ -0,0 +1,30 @@ +"""Automatically generated file. + +To update, run python3 -m script.gen_recorder_db_versions + +This file is generated from https://endoflife.date. For each of MariaDB and +MySQL, ``supported_lts`` lists the currently supported (non-end-of-life) +long-term support release series, and ``latest_non_lts`` is the newest known +short-term/innovation release series. Both are ``"."`` strings. +""" + +from typing import TypedDict + + +class DatabaseVersions(TypedDict): + """Supported release series for a database engine.""" + + supported_lts: list[str] + latest_non_lts: str + + +SUPPORTED_DATABASE_VERSIONS: dict[str, DatabaseVersions] = { + "mariadb": { + "supported_lts": ["10.11", "11.4", "11.8", "12.3"], + "latest_non_lts": "12.2", + }, + "mysql": { + "supported_lts": ["8.4", "9.7"], + "latest_non_lts": "9.6", + }, +} diff --git a/homeassistant/generated/zeroconf.py b/homeassistant/generated/zeroconf.py index dede930fe343..906453b15212 100644 --- a/homeassistant/generated/zeroconf.py +++ b/homeassistant/generated/zeroconf.py @@ -1067,6 +1067,12 @@ ZEROCONF = { "domain": "wled", }, ], + "_ws._tcp.local.": [ + { + "domain": "hotspring", + "name": "watkins_spa*", + }, + ], "_wyoming._tcp.local.": [ { "domain": "wyoming", diff --git a/homeassistant/helpers/device_registry.py b/homeassistant/helpers/device_registry.py index 73917c3a624b..f7de84233e3f 100644 --- a/homeassistant/helpers/device_registry.py +++ b/homeassistant/helpers/device_registry.py @@ -2,7 +2,7 @@ import asyncio from collections import defaultdict -from collections.abc import Iterable, Mapping, Set as AbstractSet +from collections.abc import Collection, Iterable, Iterator, Mapping, Set as AbstractSet import copy from dataclasses import dataclass from datetime import datetime @@ -131,9 +131,6 @@ class DeviceInfo(TypedDict, total=False): configuration_url: str | URL | None connections: set[tuple[str, str]] created_at: str - default_manufacturer: str - default_model: str - default_name: str entry_type: DeviceEntryType | None identifiers: set[tuple[str, str]] manufacturer: str | None @@ -168,42 +165,6 @@ class ChildDeviceInfo(TypedDict, total=False): translation_placeholders: Mapping[str, str] | None -DEVICE_INFO_TYPES = { - # Device info is categorized by finding the first device info type which has all - # the keys of the device info. The link device info type must be kept first - # to make it preferred over primary. - "link": { - "connections", - "identifiers", - }, - "primary": { - "configuration_url", - "connections", - "entry_type", - "hw_version", - "identifiers", - "manufacturer", - "model", - "model_id", - "name", - "serial_number", - "suggested_area", - "sw_version", - "via_device", - "via_device_id", - }, - "secondary": { - "connections", - "default_manufacturer", - "default_model", - "default_name", - # Used by Fritz - "via_device", - "via_device_id", - }, -} - - class _EventDeviceRegistryUpdatedData_Create(TypedDict): """EventDeviceRegistryUpdated data for action type 'create'.""" @@ -281,40 +242,34 @@ class DeviceConnectionCollisionError(DeviceCollisionError): ) -def _determine_device_info_type( +def _validate_device_info( config_entry: ConfigEntry, device_info: DeviceInfo, -) -> str: - """Determine the type of a device info.""" - keys = set(device_info) - - # If no keys or not enough info to match up, abort +) -> None: + """Validate that a device info has enough information to match up a device.""" if not device_info.get("connections") and not device_info.get("identifiers"): raise DeviceInfoError( config_entry.domain, device_info, "device info must include at least one of identifiers or connections", ) + for field in ("manufacturer", "model", "name"): + if field in device_info and f"default_{field}" in device_info: + raise DeviceInfoError( + config_entry.domain, + device_info, + f"passing both `{field}` and `default_{field}` is not allowed", + ) - device_info_type: str | None = None - # Find the first device info type which has all keys in the device info - for possible_type, allowed_keys in DEVICE_INFO_TYPES.items(): - if keys <= allowed_keys: - device_info_type = possible_type - break - - if device_info_type is None: - raise DeviceInfoError( - config_entry.domain, - device_info, - ( - "device info needs to either describe a device, " - "link to existing device or provide extra information." - ), - ) - - return device_info_type +# Deprecated `async_get_or_create` parameters, mapped to the HA Core version they are +# removed in. +_DEPRECATED_DEVICE_INFO_PARAMETERS = { + "default_manufacturer": ("2027.9.0", "manufacturer"), + "default_model": ("2027.9.0", "model"), + "default_name": ("2027.9.0", "name"), + "via_device": ("2027.8.0", "via_device_id"), +} class _ValidatedDeviceInfoFields(TypedDict): @@ -1573,6 +1528,76 @@ class ActiveDeviceRegistryItems(DeviceRegistryItems[DeviceEntry]): } +class _DeprecatedDeviceRegistryItemsView: + """Backwards-compatible view returned by the `DeviceRegistry.devices` property. + + Can be removed in release 2027.9. + + Iterating this yields the `DeviceEntry` values, which is the supported way to + enumerate the registry (`for entry in registry.devices`, `list(registry.devices)` + and similar). Using it as a mapping - subscription, device-id membership, + `.values()`, `.get()`, `.get_entry()` and the other container methods - is + deprecated: each such access is reported via `report_usage` (raising for core code + and core integrations, warning for custom integrations) and then delegated to the + underlying container. + """ + + __slots__ = ("_devices",) + + def __init__(self, devices: ActiveDeviceRegistryItems) -> None: + """Initialize the view over a device registry.""" + self._devices = devices + + def __iter__(self) -> Iterator[DeviceEntry]: + """Iterate over the device entries.""" + return iter(self._devices.values()) + + def __len__(self) -> int: + """Return the number of device entries.""" + return len(self._devices) + + def _report_deprecated_use(self) -> None: + """Report deprecated use of `DeviceRegistry.devices`.""" + report_usage( + "uses `device_registry.devices` as a mapping or calls its lookup " + "methods, which is deprecated; iterate it to get the device entries, " + "or use `async_get`, `async_entries_for_config_entry` and similar " + "helpers for lookups", + breaks_in_ha_version="2027.9.0", + core_behavior=ReportBehavior.ERROR, + core_integration_behavior=ReportBehavior.ERROR, + custom_integration_behavior=ReportBehavior.LOG, + ) + + def __getitem__(self, key: str) -> DeviceEntry: + """Return the device entry for a device id (deprecated).""" + self._report_deprecated_use() + return self._devices[key] + + def __contains__(self, obj: object) -> bool: + """Return whether a device entry - or, deprecated, a device id - is registered. + + Value membership (`DeviceEntry in registry.devices`) is the supported use and + matches the `Collection[DeviceEntry]` type. Membership by device id (a `str`) + is the old key-based mapping behavior and is deprecated. + """ + # DeviceEntry is never subclassed, a direct type check is safe + if type(obj) is DeviceEntry: + return self._devices.get(obj.id) == obj + if isinstance(obj, str): + self._report_deprecated_use() + return obj in self._devices + return False + + def __getattr__(self, name: str) -> Any: + """Delegate the remaining mapping methods to the container (deprecated).""" + # Private and dunder names are never proxied. + if name.startswith("_"): + raise AttributeError(name) + self._report_deprecated_use() + return getattr(self._devices, name) + + class ChildDeviceRegistryItems(BaseRegistryItems[ChildDeviceEntry]): """Container for child device registry entries, maps child device id -> entry. @@ -1760,9 +1785,11 @@ class DeletedDeviceRegistryItems(DeviceRegistryItems[DeletedDeviceEntry]): class DeviceRegistry(BaseRegistry[dict[str, list[dict[str, Any]]]]): """Class to hold a registry of devices.""" - devices: ActiveDeviceRegistryItems - child_devices: ChildDeviceRegistryItems - deleted_devices: DeletedDeviceRegistryItems + _devices: ActiveDeviceRegistryItems + devices: Collection[DeviceEntry] + _child_devices: ChildDeviceRegistryItems + child_devices: Collection[ChildDeviceEntry] + _deleted_devices: DeletedDeviceRegistryItems _device_data: dict[str, DeviceEntry] _child_device_data: dict[str, ChildDeviceEntry] @@ -1783,14 +1810,21 @@ class DeviceRegistry(BaseRegistry[dict[str, list[dict[str, Any]]]]): serialize_in_event_loop=False, ) - @overload - def async_get( - self, - device_id: str, - *, - include_child_devices: Literal[True] = True, - include_main_devices: Literal[True] = True, - ) -> AnyDeviceEntry | None: ... + @property + def deleted_devices(self) -> DeletedDeviceRegistryItems: + """Return the deleted devices container (deprecated). + + Can be removed in release 2027.9. + """ + report_usage( + "accesses `device_registry.deleted_devices`, which is deprecated and " + "an internal implementation detail of the device registry", + breaks_in_ha_version="2027.9.0", + core_behavior=ReportBehavior.ERROR, + core_integration_behavior=ReportBehavior.ERROR, + custom_integration_behavior=ReportBehavior.LOG, + ) + return self._deleted_devices @overload def async_get( @@ -1798,7 +1832,8 @@ class DeviceRegistry(BaseRegistry[dict[str, list[dict[str, Any]]]]): device_id: str, *, include_child_devices: Literal[False], - include_main_devices: Literal[True] = True, + include_main_devices: bool = True, + include_composite_devices: bool = True, ) -> DeviceEntry | None: ... @overload @@ -1808,8 +1843,29 @@ class DeviceRegistry(BaseRegistry[dict[str, list[dict[str, Any]]]]): *, include_child_devices: Literal[True] = True, include_main_devices: Literal[False], + include_composite_devices: Literal[False], ) -> ChildDeviceEntry | None: ... + @overload + def async_get( + self, + device_id: str, + *, + include_child_devices: Literal[True] = True, + include_main_devices: Literal[False], + include_composite_devices: Literal[True] = True, + ) -> AnyDeviceEntry | None: ... + + @overload + def async_get( + self, + device_id: str, + *, + include_child_devices: Literal[True] = True, + include_main_devices: Literal[True] = True, + include_composite_devices: bool = True, + ) -> AnyDeviceEntry | None: ... + @callback def async_get( self, @@ -1817,6 +1873,7 @@ class DeviceRegistry(BaseRegistry[dict[str, list[dict[str, Any]]]]): *, include_child_devices: bool = True, include_main_devices: bool = True, + include_composite_devices: bool = True, ) -> AnyDeviceEntry | None: """Get device or child device. @@ -1831,8 +1888,8 @@ class DeviceRegistry(BaseRegistry[dict[str, list[dict[str, Any]]]]): With include_child_devices=False a child-device id resolves to None (the child is treated as absent) and the return type excludes children. With - include_main_devices=False a main-device id (including a composite) resolves to - None and the return type excludes main devices. + include_main_devices=False a main-device id resolves to None. With + include_composite_devices=False a composite-device id resolves to None. """ if ( include_main_devices @@ -1844,8 +1901,10 @@ class DeviceRegistry(BaseRegistry[dict[str, list[dict[str, Any]]]]): and (child_device := self._child_device_data.get(device_id)) is not None ): return child_device - if include_main_devices and ( - split_devices := self.devices.get_devices_for_composite_device_id(device_id) + if include_composite_devices and ( + split_devices := self._devices.get_devices_for_composite_device_id( + device_id + ) ): return self._restore_composite_device(device_id, split_devices) return None @@ -1948,7 +2007,7 @@ class DeviceRegistry(BaseRegistry[dict[str, list[dict[str, Any]]]]): Identifiers are unique within a config entry, so unlike async_get_device the lookup cannot be ambiguous. """ - return self.devices.get_entry( + return self._devices.get_entry( identifiers={identifier}, config_entry_id=config_entry_id ) @@ -1961,7 +2020,7 @@ class DeviceRegistry(BaseRegistry[dict[str, list[dict[str, Any]]]]): Identifiers are unique within a config entry, so the lookup cannot be ambiguous. """ - return self.child_devices.get_entry( + return self._child_devices.get_entry( identifiers={identifier}, config_entry_id=config_entry_id ) @@ -1974,7 +2033,7 @@ class DeviceRegistry(BaseRegistry[dict[str, list[dict[str, Any]]]]): Connections are unique within a config entry, so unlike async_get_device the lookup cannot be ambiguous. """ - return self.devices.get_entry( + return self._devices.get_entry( connections={connection}, config_entry_id=config_entry_id ) @@ -1993,7 +2052,7 @@ class DeviceRegistry(BaseRegistry[dict[str, list[dict[str, Any]]]]): If config_entry_id is given, only devices owned by that config entry are returned. """ - return self.devices.get_entries( + return self._devices.get_entries( identifiers, connections, config_entry_id=config_entry_id ) @@ -2014,7 +2073,7 @@ class DeviceRegistry(BaseRegistry[dict[str, list[dict[str, Any]]]]): connections: AbstractSet[tuple[str, str]] | None, ) -> list[DeviceEntry]: """Return devices matching the lookup, narrowed by identifier-domain priority.""" - matches = self.devices.get_entries(identifiers, connections) + matches = self._devices.get_entries(identifiers, connections) if len(matches) > 1 and identifiers: domains = {identifier[0] for identifier in identifiers} preferred = [ @@ -2036,9 +2095,11 @@ class DeviceRegistry(BaseRegistry[dict[str, list[dict[str, Any]]]]): self, device_id: str ) -> list[str] | None: """Return the underlying real device ids if device_id is a composite.""" - if device_id in self.devices: + if device_id in self._devices: return None - if split_devices := self.devices.get_devices_for_composite_device_id(device_id): + if split_devices := self._devices.get_devices_for_composite_device_id( + device_id + ): return [split_device.id for split_device in split_devices] return None @@ -2056,7 +2117,7 @@ class DeviceRegistry(BaseRegistry[dict[str, list[dict[str, Any]]]]): identifier/connection resolved to a single multi-config-entry device. Returns an empty list for a device id which is not a composite device id. """ - return self.devices.get_devices_for_composite_device_id(composite_device_id) + return self._devices.get_devices_for_composite_device_id(composite_device_id) @callback def async_is_composite_device_id(self, device_id: str) -> bool | None: @@ -2066,9 +2127,18 @@ class DeviceRegistry(BaseRegistry[dict[str, list[dict[str, Any]]]]): composite device id no longer refers to a registered device. Returns False for a registered device id, and None for an unknown id. """ - if device_id in self.devices: + report_usage( + "calls `device_registry.async_is_composite_device_id`, which is " + "deprecated; use `async_get` with `include_composite_devices=False` " + "instead - a composite device id resolves with `async_get(device_id)` but " + "not with `async_get(device_id, include_composite_devices=False)`", + core_behavior=ReportBehavior.ERROR, + core_integration_behavior=ReportBehavior.ERROR, + breaks_in_ha_version="2027.9.0", + ) + if device_id in self._devices: return False - if self.devices.get_devices_for_composite_device_id(device_id): + if self._devices.get_devices_for_composite_device_id(device_id): return True return None @@ -2082,9 +2152,9 @@ class DeviceRegistry(BaseRegistry[dict[str, list[dict[str, Any]]]]): it was split into - preferring the split owned by config_entry_id, then one owned by the same domain, then any of them. Returns None for an unknown id. """ - if via_device_id in self.devices: + if via_device_id in self._devices: return via_device_id - if splits := self.devices.get_devices_for_composite_device_id(via_device_id): + if splits := self._devices.get_devices_for_composite_device_id(via_device_id): # The composite resolution can be removed in HA Core 2027.8 report_usage( f"passes the id of a pre-migration composite device {via_device_id} " @@ -2145,9 +2215,6 @@ class DeviceRegistry(BaseRegistry[dict[str, list[dict[str, Any]]]]): configuration_url: str | URL | UndefinedType | None = UNDEFINED, connections: set[tuple[str, str]] | UndefinedType | None = UNDEFINED, created_at: str | datetime | UndefinedType = UNDEFINED, # will be ignored - default_manufacturer: str | UndefinedType | None = UNDEFINED, - default_model: str | UndefinedType | None = UNDEFINED, - default_name: str | UndefinedType | None = UNDEFINED, # To disable a device if it gets created, does not affect existing devices disabled_by: DeviceEntryDisabler | UndefinedType | None = UNDEFINED, entry_type: DeviceEntryType | UndefinedType | None = UNDEFINED, @@ -2163,10 +2230,8 @@ class DeviceRegistry(BaseRegistry[dict[str, list[dict[str, Any]]]]): sw_version: str | UndefinedType | None = UNDEFINED, translation_key: str | None = None, translation_placeholders: Mapping[str, str] | None = None, - # via_device is deprecated and will be removed in HA Core 2027.8, use - # via_device_id instead - via_device: tuple[str, str] | UndefinedType | None = UNDEFINED, via_device_id: str | UndefinedType | None = UNDEFINED, + **kwargs: Any, ) -> DeviceEntry: """Get device. Create if it doesn't exist. @@ -2174,6 +2239,18 @@ class DeviceRegistry(BaseRegistry[dict[str, list[dict[str, Any]]]]): If identifiers overlap with a child device, the method raises. """ + # Extract deprecated parameters, and reject any other unexpected keyword + # argument. + default_manufacturer = kwargs.pop("default_manufacturer", UNDEFINED) + default_model = kwargs.pop("default_model", UNDEFINED) + default_name = kwargs.pop("default_name", UNDEFINED) + via_device = kwargs.pop("via_device", UNDEFINED) + if kwargs: + raise TypeError( + "async_get_or_create() got unexpected keyword arguments " + f"{', '.join(map(repr, kwargs))}" + ) + default_manufacturer = _validate_str( "default_manufacturer", default_manufacturer ) @@ -2207,15 +2284,23 @@ class DeviceRegistry(BaseRegistry[dict[str, list[dict[str, Any]]]]): "Passing both `via_device` and `via_device_id` is not allowed; " "`via_device` is deprecated, pass `via_device_id` only" ) - # Report the deprecated `via_device` here, before any registry mutation. - if via_device is not UNDEFINED: + # Report the deprecated parameters here, before any registry mutation. + deprecated_values = { + "default_manufacturer": default_manufacturer, + "default_model": default_model, + "default_name": default_name, + "via_device": via_device, + } + for parameter, deprecation in _DEPRECATED_DEVICE_INFO_PARAMETERS.items(): + if deprecated_values[parameter] is UNDEFINED: + continue + version, replacement = deprecation report_usage( - "calls `device_registry.async_get_or_create` with a `via_device`, " - "which is deprecated because device identifiers are no longer unique; " - "pass `via_device_id` instead", + "calls `device_registry.async_get_or_create` with a deprecated " + f"`{parameter}` parameter; use `{replacement}` instead", core_behavior=ReportBehavior.ERROR, core_integration_behavior=ReportBehavior.ERROR, - breaks_in_ha_version="2027.8.0", + breaks_in_ha_version=version, ) if ( config_subentry_id is not UNDEFINED @@ -2252,7 +2337,7 @@ class DeviceRegistry(BaseRegistry[dict[str, list[dict[str, Any]]]]): if val is not UNDEFINED } - device_info_type = _determine_device_info_type(config_entry, device_info) + _validate_device_info(config_entry, device_info) if identifiers is None or identifiers is UNDEFINED: identifiers = set() @@ -2265,7 +2350,7 @@ class DeviceRegistry(BaseRegistry[dict[str, list[dict[str, Any]]]]): # We do not allow registering a device without parent_device_id if the # identifiers match an existing child. if ( - matched_child_device := self.child_devices.get_entry( + matched_child_device := self._child_devices.get_entry( identifiers=identifiers, config_entry_id=config_entry_id ) ) is not None: @@ -2277,7 +2362,7 @@ class DeviceRegistry(BaseRegistry[dict[str, list[dict[str, Any]]]]): f"{sorted(matched_child_device.identifiers)}", ) - device = self.devices.get_entry( + device = self._devices.get_entry( connections=connections, identifiers=identifiers, config_entry_id=config_entry_id, @@ -2293,7 +2378,7 @@ class DeviceRegistry(BaseRegistry[dict[str, list[dict[str, Any]]]]): if device is not None: # Collision reconciliation can update the matched device (e.g. detach # its via link) - device = self.devices[device.id] + device = self._devices[device.id] # Resolved after collision reconciliation so a removed stale duplicate can't be # linked @@ -2321,7 +2406,7 @@ class DeviceRegistry(BaseRegistry[dict[str, list[dict[str, Any]]]]): if device is None: is_new = True - deleted_device = self.deleted_devices.get_entry( + deleted_device = self._deleted_devices.get_entry( connections=connections, identifiers=identifiers, config_entry_id=config_entry_id, @@ -2332,7 +2417,7 @@ class DeviceRegistry(BaseRegistry[dict[str, list[dict[str, Any]]]]): # rather than create a fresh device. Matching on the recorded domain keeps # a chance identifier/connection collision from restoring another # integration's device. - deleted_device = self.deleted_devices.get_orphaned_entry( + deleted_device = self._deleted_devices.get_orphaned_entry( identifiers, connections, config_entry.domain ) if deleted_device is None: @@ -2359,7 +2444,7 @@ class DeviceRegistry(BaseRegistry[dict[str, list[dict[str, Any]]]]): ) else: - self.deleted_devices.pop(deleted_device.id) + self._deleted_devices.pop(deleted_device.id) device = deleted_device.to_device_entry( config_entry, # Interpret not specifying a subentry as None @@ -2370,9 +2455,9 @@ class DeviceRegistry(BaseRegistry[dict[str, list[dict[str, Any]]]]): ) disabled_by = UNDEFINED - self.devices[device.id] = device + self._devices[device.id] = device # If creating a new device, default to the config entry name - if device_info_type == "primary" and (not name or name is UNDEFINED): + if not name or name is UNDEFINED: name = config_entry.title elif ( @@ -2414,14 +2499,14 @@ class DeviceRegistry(BaseRegistry[dict[str, list[dict[str, Any]]]]): # config entry (a via device may legitimately belong to a different config # entry). This ambiguity is why via_device is deprecated. via = ( - self.devices.get_entry( + self._devices.get_entry( identifiers={via_device}, config_entry_id=config_entry_id ) or self._first_device_in_domain( - self.devices.get_entries(identifiers={via_device}), + self._devices.get_entries(identifiers={via_device}), config_entry.domain, ) - or self.devices.get_entry(identifiers={via_device}) + or self._devices.get_entry(identifiers={via_device}) ) if via is None: report_usage( @@ -2605,7 +2690,7 @@ class DeviceRegistry(BaseRegistry[dict[str, list[dict[str, Any]]]]): f"parent device {parent.id}", ) - child_device = self.child_devices.get_entry( + child_device = self._child_devices.get_entry( identifiers=identifiers, config_entry_id=config_entry_id ) @@ -2613,7 +2698,7 @@ class DeviceRegistry(BaseRegistry[dict[str, list[dict[str, Any]]]]): # owned by another child device. for identifier in sorted(identifiers): if ( - other_child := self.child_devices.get_entry( + other_child := self._child_devices.get_entry( identifiers={identifier}, config_entry_id=config_entry_id ) ) is not None and ( @@ -2637,7 +2722,7 @@ class DeviceRegistry(BaseRegistry[dict[str, list[dict[str, Any]]]]): matched_device: DeviceEntry | None = None if child_device is None: - matched_device = self.devices.get_entry( + matched_device = self._devices.get_entry( identifiers=identifiers, config_entry_id=config_entry_id ) @@ -2660,7 +2745,7 @@ class DeviceRegistry(BaseRegistry[dict[str, list[dict[str, Any]]]]): # The identifiers are registered by a full device of the config entry: # the integration split the device into child devices, so convert it, # preserving its id. - matched_device = self.devices[matched_device.id] + matched_device = self._devices[matched_device.id] child_device = self._async_convert_device_to_child( matched_device, parent, identifiers ) @@ -2670,14 +2755,14 @@ class DeviceRegistry(BaseRegistry[dict[str, list[dict[str, Any]]]]): if child_device is None: is_new = True - deleted_device = self.deleted_devices.get_entry( + deleted_device = self._deleted_devices.get_entry( identifiers=identifiers, config_entry_id=config_entry_id, ) if deleted_device is None: # Fall back to an orphan (its owning config entry was removed), as # for a full device - deleted_device = self.deleted_devices.get_orphaned_entry( + deleted_device = self._deleted_devices.get_orphaned_entry( identifiers, None, domain ) if deleted_device is None: @@ -2699,7 +2784,7 @@ class DeviceRegistry(BaseRegistry[dict[str, list[dict[str, Any]]]]): parent_device_id=parent.id, ) else: - self.deleted_devices.pop(deleted_device.id) + self._deleted_devices.pop(deleted_device.id) child_device = deleted_device.to_child_device_entry( config_entry, effective_config_subentry_id, @@ -2709,7 +2794,7 @@ class DeviceRegistry(BaseRegistry[dict[str, list[dict[str, Any]]]]): ) disabled_by = UNDEFINED - self.child_devices[child_device.id] = child_device + self._child_devices[child_device.id] = child_device self._async_purge_colliding_deleted_devices(child_device, identifiers, set()) @@ -2746,7 +2831,7 @@ class DeviceRegistry(BaseRegistry[dict[str, list[dict[str, Any]]]]): raise DeviceInfoError( config_entry.domain, device_info, "a device can't be its own parent" ) - if self.child_devices.get_children_for_device_id(device.id): + if self._child_devices.get_children_for_device_id(device.id): raise DeviceInfoError( config_entry.domain, device_info, @@ -2838,13 +2923,13 @@ class DeviceRegistry(BaseRegistry[dict[str, list[dict[str, Any]]]]): name_by_user=device.name_by_user, parent_device_id=parent.id, ) - del self.devices[device.id] - self.child_devices[child_device.id] = child_device + del self._devices[device.id] + self._child_devices[child_device.id] = child_device # A via_device_id must not resolve to a child device; detach inbound via # links to the converted device, as async_remove_device does, before firing # the conversion event. - for other_device in list(self.devices.values()): + for other_device in list(self._devices.values()): if other_device.via_device_id == device.id: self._async_update_device(other_device.id, via_device_id=None) @@ -2902,7 +2987,7 @@ class DeviceRegistry(BaseRegistry[dict[str, list[dict[str, Any]]]]): :param remove_config_subentry_id: Remove the device from a specific subentry of remove_config_entry_id """ - old = self.devices[device_id] + old = self._devices[device_id] new_values: dict[str, Any] = {} # Dict with new key/value pairs old_values: dict[str, Any] = {} # Dict with old key/value pairs @@ -2979,8 +3064,7 @@ class DeviceRegistry(BaseRegistry[dict[str, list[dict[str, Any]]]]): if ( via_device_id is not UNDEFINED and via_device_id is not None - and via_device_id not in self.devices - and not self.devices.get_devices_for_composite_device_id(via_device_id) + and self.async_get(via_device_id, include_child_devices=False) is None ): if via_device_id in self._child_device_data: raise HomeAssistantError( @@ -3079,7 +3163,7 @@ class DeviceRegistry(BaseRegistry[dict[str, list[dict[str, Any]]]]): # A parent with child devices can't move (enforced again below); reject # here before mutating the runtime-only sibling pending moves, so the # rejected move leaves no partial state behind. - if self.child_devices.get_children_for_device_id(device_id): + if self._child_devices.get_children_for_device_id(device_id): raise HomeAssistantError( f"Can't move device {device_id}: it has child devices" ) @@ -3087,14 +3171,14 @@ class DeviceRegistry(BaseRegistry[dict[str, list[dict[str, Any]]]]): # completes the move to the target entry the others must not also move # there and collide; clear their pending moves. if old.composite_device_id is not None: - for sibling in self.devices.get_devices_for_composite_device_id( + for sibling in self._devices.get_devices_for_composite_device_id( old.composite_device_id ): if ( sibling.id != device_id and sibling._pending_move is not None # noqa: SLF001 ): - self.devices[sibling.id] = attr.evolve( + self._devices[sibling.id] = attr.evolve( sibling, pending_move=None ) @@ -3148,7 +3232,7 @@ class DeviceRegistry(BaseRegistry[dict[str, list[dict[str, Any]]]]): # supported. if ( is_move or "config_subentry_id" in new_values - ) and self.child_devices.get_children_for_device_id(device_id): + ) and self._child_devices.get_children_for_device_id(device_id): raise HomeAssistantError( f"Can't move device {device_id}: it has child devices" ) @@ -3303,7 +3387,7 @@ class DeviceRegistry(BaseRegistry[dict[str, list[dict[str, Any]]]]): self.hass.verify_event_loop_thread("device_registry._async_update_device") new = attr.evolve(old, **new_values) - self.devices[device_id] = new + self._devices[device_id] = new # On a move, the device's whole retained identity newly appears in the target # config entry; added_identifiers/added_connections are empty on a retained- @@ -3317,13 +3401,13 @@ class DeviceRegistry(BaseRegistry[dict[str, list[dict[str, Any]]]]): match_identifiers = added_identifiers match_connections = added_connections # A deleted device holding an identity the device now owns can never restore - for deleted_device_id in self.deleted_devices.get_colliding_device_ids( + for deleted_device_id in self._deleted_devices.get_colliding_device_ids( match_identifiers or set(), match_connections or set(), config_entry_id=effective_config_entry_id, exclude_device_id=None, ): - del self.deleted_devices[deleted_device_id] + del self._deleted_devices[deleted_device_id] # If its only run time attributes (suggested_area) # that do not get saved we do not want to write @@ -3350,7 +3434,7 @@ class DeviceRegistry(BaseRegistry[dict[str, list[dict[str, Any]]]]): # async_config_entry_disabled_by_changed, which iterates all the config # entry's devices. if "disabled_by" in old_values and ( - children := self.child_devices.get_children_for_device_id(device_id) + children := self._child_devices.get_children_for_device_id(device_id) ): if new.disabled_by is None: for child in children: @@ -3380,7 +3464,7 @@ class DeviceRegistry(BaseRegistry[dict[str, list[dict[str, Any]]]]): new_identifiers: set[tuple[str, str]] | UndefinedType = UNDEFINED, ) -> ChildDeviceEntry | None: """Private update child device attributes.""" - old = self.child_devices[child_device_id] + old = self._child_devices[child_device_id] new_values: dict[str, Any] = {} # Dict with new key/value pairs old_values: dict[str, Any] = {} # Dict with old key/value pairs @@ -3505,17 +3589,17 @@ class DeviceRegistry(BaseRegistry[dict[str, list[dict[str, Any]]]]): self.hass.verify_event_loop_thread("device_registry._async_update_child_device") new = attr.evolve(old, **new_values) - self.child_devices[child_device_id] = new + self._child_devices[child_device_id] = new # A deleted device holding an identity the child device now owns can never # restore - for deleted_device_id in self.deleted_devices.get_colliding_device_ids( + for deleted_device_id in self._deleted_devices.get_colliding_device_ids( added_identifiers or set(), set(), config_entry_id=old.config_entry_id, exclude_device_id=None, ): - del self.deleted_devices[deleted_device_id] + del self._deleted_devices[deleted_device_id] self.async_schedule_save() @@ -3761,7 +3845,7 @@ class DeviceRegistry(BaseRegistry[dict[str, list[dict[str, Any]]]]): if not matched_device.has_composite_identifiers: identifiers = matched_device.identifiers | identifiers connections = matched_device.connections | connections - colliding = self.devices.get_colliding_device_ids( + colliding = self._devices.get_colliding_device_ids( identifiers, connections, config_entry_id=config_entry.entry_id, @@ -3779,7 +3863,7 @@ class DeviceRegistry(BaseRegistry[dict[str, list[dict[str, Any]]]]): f"registered for device {holder_id} of the same config entry", ) for holder_id, (shared_identifiers, shared_connections) in colliding.items(): - holder = self.devices[holder_id] + holder = self._devices[holder_id] remaining_identifiers = holder.identifiers - shared_identifiers remaining_connections = holder.connections - shared_connections if not remaining_identifiers and not remaining_connections: @@ -3816,7 +3900,7 @@ class DeviceRegistry(BaseRegistry[dict[str, list[dict[str, Any]]]]): elif not device.has_composite_identifiers: identifiers = device.identifiers | identifiers connections = device.connections | connections - colliding = self.deleted_devices.get_colliding_device_ids( + colliding = self._deleted_devices.get_colliding_device_ids( identifiers, connections, config_entry_id=device.config_entry_id, @@ -3831,7 +3915,7 @@ class DeviceRegistry(BaseRegistry[dict[str, list[dict[str, Any]]]]): deleted_device_id, device.id, ) - del self.deleted_devices[deleted_device_id] + del self._deleted_devices[deleted_device_id] self.async_schedule_save() @callback @@ -3856,7 +3940,7 @@ class DeviceRegistry(BaseRegistry[dict[str, list[dict[str, Any]]]]): # conflict, the index will only see the last one and we will not # be able to tell which one caused the conflict if ( - existing_device := self.devices.get_entry( + existing_device := self._devices.get_entry( connections={connection}, config_entry_id=config_entry_id ) ) and existing_device.id != device_id: @@ -3887,13 +3971,13 @@ class DeviceRegistry(BaseRegistry[dict[str, list[dict[str, Any]]]]): # conflict, the index will only see the last one and we will not # be able to tell which one caused the conflict if ( - existing_device := self.devices.get_entry( + existing_device := self._devices.get_entry( identifiers={identifier}, config_entry_id=config_entry_id ) ) and existing_device.id != device_id: raise DeviceIdentifierCollisionError(identifiers, existing_device) if ( - existing_child_device := self.child_devices.get_entry( + existing_child_device := self._child_devices.get_entry( identifiers={identifier}, config_entry_id=config_entry_id ) ) is not None: @@ -3915,13 +3999,13 @@ class DeviceRegistry(BaseRegistry[dict[str, list[dict[str, Any]]]]): """ for identifier in identifiers: if ( - existing_child_device := self.child_devices.get_entry( + existing_child_device := self._child_devices.get_entry( identifiers={identifier}, config_entry_id=config_entry_id ) ) and existing_child_device.id != child_device_id: raise DeviceIdentifierCollisionError(identifiers, existing_child_device) if ( - existing_device := self.devices.get_entry( + existing_device := self._devices.get_entry( identifiers={identifier}, config_entry_id=config_entry_id ) ) is not None: @@ -3961,9 +4045,9 @@ class DeviceRegistry(BaseRegistry[dict[str, list[dict[str, Any]]]]): for underlying_id in underlying_ids: self.async_update_device(underlying_id, **forward) remaining = [ - self.devices[underlying_id] + self._devices[underlying_id] for underlying_id in underlying_ids - if underlying_id in self.devices + if underlying_id in self._devices ] if not remaining: return None @@ -3983,11 +4067,11 @@ class DeviceRegistry(BaseRegistry[dict[str, list[dict[str, Any]]]]): return self.hass.verify_event_loop_thread("device_registry.async_remove_device") # Removing the parent removes its child devices - for child in self.child_devices.get_children_for_device_id(device_id): + for child in self._child_devices.get_children_for_device_id(device_id): self._async_remove_child_device(child) - device = self.devices.pop(device_id) + device = self._devices.pop(device_id) config_entry = self.hass.config_entries.async_get_entry(device.config_entry_id) - self.deleted_devices[device_id] = DeletedDeviceEntry( + self._deleted_devices[device_id] = DeletedDeviceEntry( area_id=device.area_id, config_entry_id=device.config_entry_id, config_subentry_id=device.config_subentry_id, @@ -4002,7 +4086,7 @@ class DeviceRegistry(BaseRegistry[dict[str, list[dict[str, Any]]]]): orphaned_timestamp=None, domain=config_entry.domain if config_entry is not None else None, ) - for other_device in list(self.devices.values()): + for other_device in list(self._devices.values()): if other_device.via_device_id == device_id: self._async_update_device(other_device.id, via_device_id=None) self.hass.bus.async_fire_internal( @@ -4017,11 +4101,11 @@ class DeviceRegistry(BaseRegistry[dict[str, list[dict[str, Any]]]]): def _async_remove_child_device(self, child_device: ChildDeviceEntry) -> None: """Remove a child device from the device registry.""" self.hass.verify_event_loop_thread("device_registry.async_remove_device") - del self.child_devices[child_device.id] + del self._child_devices[child_device.id] config_entry = self.hass.config_entries.async_get_entry( child_device.config_entry_id ) - self.deleted_devices[child_device.id] = DeletedDeviceEntry( + self._deleted_devices[child_device.id] = DeletedDeviceEntry( area_id=child_device.area_id, config_entry_id=child_device.config_entry_id, config_subentry_id=child_device.config_subentry_id, @@ -4194,9 +4278,11 @@ class DeviceRegistry(BaseRegistry[dict[str, list[dict[str, Any]]]]): shadowed_count, ) - self.devices = devices - self.child_devices = child_devices - self.deleted_devices = deleted_devices + self._devices = devices + self.devices = _DeprecatedDeviceRegistryItemsView(self._devices) + self._child_devices = child_devices + self.child_devices = self._child_devices.values() + self._deleted_devices = deleted_devices self._device_data = devices.data self._child_device_data = child_devices.data @@ -4219,14 +4305,15 @@ class DeviceRegistry(BaseRegistry[dict[str, list[dict[str, Any]]]]): # other than the event loop. return { "devices": [ - entry.as_storage_fragment for entry in list(self.devices.values()) + entry.as_storage_fragment for entry in list(self._devices.values()) ], "child_devices": [ - entry.as_storage_fragment for entry in list(self.child_devices.values()) + entry.as_storage_fragment + for entry in list(self._child_devices.values()) ], "deleted_devices": [ entry.as_storage_fragment - for entry in list(self.deleted_devices.values()) + for entry in list(self._deleted_devices.values()) ], } @@ -4254,7 +4341,7 @@ class DeviceRegistry(BaseRegistry[dict[str, list[dict[str, Any]]]]): # device from the same integration is orphaned, drop any existing orphan # it overlaps so the newest one wins deterministically instead of shadowing # it. - for existing in list(self.deleted_devices.values()): + for existing in list(self._deleted_devices.values()): if ( existing.config_entry_id is None and existing.domain == domain @@ -4263,8 +4350,8 @@ class DeviceRegistry(BaseRegistry[dict[str, list[dict[str, Any]]]]): or existing.identifiers & deleted_device.identifiers ) ): - del self.deleted_devices[existing.id] - self.deleted_devices[deleted_device.id] = attr.evolve( + del self._deleted_devices[existing.id] + self._deleted_devices[deleted_device.id] = attr.evolve( deleted_device, config_entry_id=None, config_subentry_id=None, @@ -4286,34 +4373,34 @@ class DeviceRegistry(BaseRegistry[dict[str, list[dict[str, Any]]]]): self._live_device_ids.pop(config_entry_id, None) domain = self._resolve_orphan_domain(config_entry_id, domain) now_time = time.time() - for device in self.devices.get_devices_for_config_entry_id(config_entry_id): + for device in self._devices.get_devices_for_config_entry_id(config_entry_id): self.async_remove_device(device.id) # Child devices share their parent's config entry, so the loop above removes # them through the parent cascade; guard against store corruption anyway. - for child_device in self.child_devices.get_devices_for_config_entry_id( + for child_device in self._child_devices.get_devices_for_config_entry_id( config_entry_id ): self.async_remove_device(child_device.id) # A split device records the composite's former primary config entry; when that # config entry is removed, clear the now-dangling reference so a restored # composite no longer points at a config entry that no longer exists. - for device in list(self.devices.values()): + for device in list(self._devices.values()): if device.composite_primary_config_entry == config_entry_id: - self.devices[device.id] = attr.evolve( + self._devices[device.id] = attr.evolve( device, composite_primary_config_entry=None ) self.async_schedule_save() # A device owned by another config entry may hold a transient pending move # targeting the entry being removed; clear it so a later completion deletes the # device instead of moving it onto the removed entry. - for device in list(self.devices.values()): + for device in list(self._devices.values()): pending_move = device._pending_move # noqa: SLF001 if ( pending_move is not None and pending_move.config_entry_id == config_entry_id ): - self.devices[device.id] = attr.evolve(device, pending_move=None) - for deleted_device in list(self.deleted_devices.values()): + self._devices[device.id] = attr.evolve(device, pending_move=None) + for deleted_device in list(self._deleted_devices.values()): if deleted_device.config_entry_id != config_entry_id: continue self._async_orphan_deleted_device(deleted_device, domain, now_time) @@ -4325,13 +4412,13 @@ class DeviceRegistry(BaseRegistry[dict[str, list[dict[str, Any]]]]): """Clear config subentry from registry entries.""" domain = self._resolve_orphan_domain(config_entry_id, domain) now_time = time.time() - for device in self.devices.get_devices_for_config_entry_id(config_entry_id): + for device in self._devices.get_devices_for_config_entry_id(config_entry_id): if device.config_subentry_id != config_subentry_id: continue self.async_remove_device(device.id) # Child devices share their parent's subentry, so the loop above removes them # through the parent cascade; guard against store corruption anyway. - for child_device in self.child_devices.get_devices_for_config_entry_id( + for child_device in self._child_devices.get_devices_for_config_entry_id( config_entry_id ): if child_device.config_subentry_id != config_subentry_id: @@ -4340,15 +4427,15 @@ class DeviceRegistry(BaseRegistry[dict[str, list[dict[str, Any]]]]): # A device may hold a transient pending move targeting the subentry being removed; # clear it so a later completion deletes the device instead of validating against # the removed subentry. - for device in list(self.devices.values()): + for device in list(self._devices.values()): pending_move = device._pending_move # noqa: SLF001 if ( pending_move is not None and pending_move.config_entry_id == config_entry_id and pending_move.config_subentry_id == config_subentry_id ): - self.devices[device.id] = attr.evolve(device, pending_move=None) - for deleted_device in list(self.deleted_devices.values()): + self._devices[device.id] = attr.evolve(device, pending_move=None) + for deleted_device in list(self._deleted_devices.values()): if ( deleted_device.config_entry_id != config_entry_id or deleted_device.config_subentry_id != config_subentry_id @@ -4364,7 +4451,7 @@ class DeviceRegistry(BaseRegistry[dict[str, list[dict[str, Any]]]]): growing without bound. """ now_time = time.time() - for deleted_device in list(self.deleted_devices.values()): + for deleted_device in list(self._deleted_devices.values()): if deleted_device.orphaned_timestamp is None: continue @@ -4372,19 +4459,19 @@ class DeviceRegistry(BaseRegistry[dict[str, list[dict[str, Any]]]]): deleted_device.orphaned_timestamp + ORPHANED_DEVICE_KEEP_SECONDS < now_time ): - del self.deleted_devices[deleted_device.id] + del self._deleted_devices[deleted_device.id] @callback def async_clear_area_id(self, area_id: str) -> None: """Clear area id from registry entries.""" - for device in self.devices.get_devices_for_area_id(area_id): + for device in self._devices.get_devices_for_area_id(area_id): self._async_update_device(device.id, area_id=None) - for child_device in self.child_devices.get_devices_for_area_id(area_id): + for child_device in self._child_devices.get_devices_for_area_id(area_id): self._async_update_child_device(child_device.id, area_id=None) - for deleted_device in list(self.deleted_devices.values()): + for deleted_device in list(self._deleted_devices.values()): if deleted_device.area_id != area_id: continue - self.deleted_devices[deleted_device.id] = attr.evolve( + self._deleted_devices[deleted_device.id] = attr.evolve( deleted_device, area_id=None ) self.async_schedule_save() @@ -4392,16 +4479,16 @@ class DeviceRegistry(BaseRegistry[dict[str, list[dict[str, Any]]]]): @callback def async_clear_label_id(self, label_id: str) -> None: """Clear label from registry entries.""" - for device in self.devices.get_devices_for_label(label_id): + for device in self._devices.get_devices_for_label(label_id): self._async_update_device(device.id, labels=device.labels - {label_id}) - for child_device in self.child_devices.get_devices_for_label(label_id): + for child_device in self._child_devices.get_devices_for_label(label_id): self._async_update_child_device( child_device.id, labels=child_device.labels - {label_id} ) - for deleted_device in list(self.deleted_devices.values()): + for deleted_device in list(self._deleted_devices.values()): if label_id not in deleted_device.labels: continue - self.deleted_devices[deleted_device.id] = attr.evolve( + self._deleted_devices[deleted_device.id] = attr.evolve( deleted_device, labels=deleted_device.labels - {label_id} ) self.async_schedule_save() @@ -4454,7 +4541,7 @@ def async_get_device_and_config_entry_for_domain( composite is returned as the device. """ registry = async_get(hass) - if (device := registry.devices.get(device_id)) is not None: + if (device := registry._devices.get(device_id)) is not None: # noqa: SLF001 config_entry = hass.config_entries.async_get_entry(device.config_entry_id) if config_entry is not None and config_entry.domain == domain: return device, config_entry @@ -4487,13 +4574,15 @@ def async_entries_for_area( Includes child devices with the area set explicitly, and child devices inheriting the area from their parent device. """ - devices = registry.devices.get_devices_for_area_id(area_id) + devices = registry._devices.get_devices_for_area_id(area_id) # noqa: SLF001 entries: list[AnyDeviceEntry] = list(devices) - entries.extend(registry.child_devices.get_devices_for_area_id(area_id)) + entries.extend( + registry._child_devices.get_devices_for_area_id(area_id) # noqa: SLF001 + ) for device in devices: entries.extend( child_device - for child_device in registry.child_devices.get_children_for_device_id( + for child_device in registry._child_devices.get_children_for_device_id( # noqa: SLF001 device.id ) if child_device.area_id is None @@ -4530,9 +4619,11 @@ def async_entries_for_label( parent, so a child appears here only when the label is set on the child itself. """ entries: list[AnyDeviceEntry] = list( - registry.devices.get_devices_for_label(label_id) + registry._devices.get_devices_for_label(label_id) # noqa: SLF001 + ) + entries.extend( + registry._child_devices.get_devices_for_label(label_id) # noqa: SLF001 ) - entries.extend(registry.child_devices.get_devices_for_label(label_id)) return entries @@ -4541,7 +4632,9 @@ def async_entries_for_config_entry( registry: DeviceRegistry, config_entry_id: str ) -> list[DeviceEntry]: """Return entries that match a config entry.""" - return registry.devices.get_devices_for_config_entry_id(config_entry_id) + return registry._devices.get_devices_for_config_entry_id( # noqa: SLF001 + config_entry_id + ) @callback @@ -4549,7 +4642,9 @@ def async_entries_for_parent_device( registry: DeviceRegistry, parent_device_id: str ) -> list[ChildDeviceEntry]: """Return the child device entries of a parent device.""" - return registry.child_devices.get_children_for_device_id(parent_device_id) + return registry._child_devices.get_children_for_device_id( # noqa: SLF001 + parent_device_id + ) @callback @@ -4557,7 +4652,9 @@ def async_child_entries_for_config_entry( registry: DeviceRegistry, config_entry_id: str ) -> list[ChildDeviceEntry]: """Return child device entries that match a config entry.""" - return registry.child_devices.get_devices_for_config_entry_id(config_entry_id) + return registry._child_devices.get_devices_for_config_entry_id( # noqa: SLF001 + config_entry_id + ) @callback @@ -4634,7 +4731,7 @@ def async_cleanup( config_entry_ids = set(hass.config_entries.async_entry_ids()) references_config_entries = { device.id - for device in dev_reg.devices.values() + for device in dev_reg._devices.values() # noqa: SLF001 if device.config_entry_id in config_entry_ids } @@ -4642,7 +4739,7 @@ def async_cleanup( device_ids_referenced_by_entities = set(ent_reg.entities.get_device_ids()) orphan = ( - set(dev_reg.devices) + set(dev_reg._devices) # noqa: SLF001 - device_ids_referenced_by_entities - references_config_entries ) @@ -4652,7 +4749,7 @@ def async_cleanup( # Find all referenced config entries that no longer exist # This shouldn't happen but have not been able to track down the bug :( - for device in list(dev_reg.devices.values()): + for device in list(dev_reg._devices.values()): # noqa: SLF001 if device.config_entry_id not in config_entry_ids: dev_reg._async_update_device( # noqa: SLF001 device.id, remove_config_entry_id=device.config_entry_id @@ -4660,8 +4757,8 @@ def async_cleanup( # A child device shares its parent's (valid) config entry, and the remove cascade # makes a child without its parent impossible; guard against store corruption anyway. - for child_device in list(dev_reg.child_devices.values()): - if child_device.parent_device_id not in dev_reg.devices: + for child_device in list(dev_reg.child_devices): + if child_device.parent_device_id not in dev_reg._devices: # noqa: SLF001 _LOGGER.error( "Removing child device %s: its parent device %s is not in the " "device registry", diff --git a/homeassistant/helpers/entity.py b/homeassistant/helpers/entity.py index 292d37ebcbc4..f33f26679a4d 100644 --- a/homeassistant/helpers/entity.py +++ b/homeassistant/helpers/entity.py @@ -1497,7 +1497,8 @@ class Entity( # and not self._removed_from_registry ): - # Set the entity's state will to unavailable + ATTR_RESTORED: True + # Set the entity's state will to unavailable and + # EntityStateAttribute.RESTORED: True self.registry_entry.write_unavailable_state(self.hass) else: self.hass.states.async_remove(self.entity_id, context=self._context) diff --git a/homeassistant/helpers/entity_platform.py b/homeassistant/helpers/entity_platform.py index 50700eecb061..1920e11e9b80 100644 --- a/homeassistant/helpers/entity_platform.py +++ b/homeassistant/helpers/entity_platform.py @@ -9,9 +9,9 @@ from typing import TYPE_CHECKING, Any, Protocol, cast, overload, override from homeassistant import config_entries from homeassistant.const import ( - ATTR_RESTORED, DEVICE_DEFAULT_NAME, EVENT_HOMEASSISTANT_STARTED, + EntityStateAttribute, ) from homeassistant.core import ( CALLBACK_TYPE, @@ -827,7 +827,10 @@ class EntityPlatform: if not already_exists and not self.hass.states.async_available(entity_id): existing = self.hass.states.get(entity_id) - if existing is not None and ATTR_RESTORED in existing.attributes: + if ( + existing is not None + and EntityStateAttribute.RESTORED in existing.attributes + ): restored = True else: already_exists = True diff --git a/homeassistant/helpers/entity_registry.py b/homeassistant/helpers/entity_registry.py index b411623ced37..1d9f4dce8341 100644 --- a/homeassistant/helpers/entity_registry.py +++ b/homeassistant/helpers/entity_registry.py @@ -1168,8 +1168,8 @@ def _validate_item( if device_id and device_id is not UNDEFINED: device_registry = dr.async_get(hass) if ( - device_id not in device_registry.devices - and device_id not in device_registry.child_devices + device_registry.async_get(device_id, include_composite_devices=False) + is None ): raise ValueError(f"Device {device_id} does not exist") if ( @@ -1815,7 +1815,12 @@ class EntityRegistry(BaseRegistry): if not device_id or device_id is UNDEFINED: return device_id device_registry = dr.async_get(self.hass) - if not device_registry.async_is_composite_device_id(device_id): + if ( + device_registry.async_get( + device_id, include_main_devices=False, include_child_devices=False + ) + is None + ): # A real device or an unknown id; let _validate_item handle it return device_id report_issue = async_suggest_report_issue( @@ -2176,13 +2181,10 @@ class EntityRegistry(BaseRegistry): config_subentry_id: str | None, ) -> str | None: """Map a device id to the split device matching the entity's config entry.""" - # Note: check container membership, not async_get, which returns a restored - # composite for a composite device id. Child devices are their own container - # and are never composites, so an entity on one keeps its device id. if ( device_id is None - or device_id in device_registry.devices - or device_id in device_registry.child_devices + or device_registry.async_get(device_id, include_composite_devices=False) + is not None ): return device_id successors = device_registry.async_get_devices_for_composite_device_id( diff --git a/homeassistant/helpers/helper_integration.py b/homeassistant/helpers/helper_integration.py index c6ceb6508f68..1aaeaf75f584 100644 --- a/homeassistant/helpers/helper_integration.py +++ b/homeassistant/helpers/helper_integration.py @@ -172,7 +172,7 @@ def async_remove_helper_devices( if source_device_id is not None else None ) - if source_device is None: + if source_device_id is None or source_device is None: # No source device (gone, or none selected). In remove-all mode the helper's devices # are still removed, leaving its entities without a device; targeted mode has no # duplicate to match. @@ -190,8 +190,8 @@ def async_remove_helper_devices( # synthesized composite) or a concrete device - a main device or a child device. A main # device's splits, if any, share this id as their composite_device_id. source_is_concrete = ( - source_device_id in device_registry.devices - or source_device_id in device_registry.child_devices + device_registry.async_get(source_device_id, include_composite_devices=False) + is not None ) composite_device_id = ( ( diff --git a/homeassistant/helpers/restore_state.py b/homeassistant/helpers/restore_state.py index b1c2b9ffde35..814b3328975a 100644 --- a/homeassistant/helpers/restore_state.py +++ b/homeassistant/helpers/restore_state.py @@ -5,7 +5,7 @@ from datetime import datetime, timedelta import logging from typing import Any, Self, cast, override -from homeassistant.const import ATTR_RESTORED, EVENT_HOMEASSISTANT_STOP +from homeassistant.const import EVENT_HOMEASSISTANT_STOP, EntityStateAttribute from homeassistant.core import HomeAssistant, State, callback, valid_entity_id from homeassistant.exceptions import HomeAssistantError, UnsupportedStorageVersionError from homeassistant.util import dt as dt_util @@ -176,7 +176,7 @@ class RestoreStateData: current_states_by_entity_id = { state.entity_id: state for state in all_states - if not state.attributes.get(ATTR_RESTORED) + if not state.attributes.get(EntityStateAttribute.RESTORED) } # Start with the currently registered states diff --git a/homeassistant/helpers/service.py b/homeassistant/helpers/service.py index 192226e7a33c..cb878df223a5 100644 --- a/homeassistant/helpers/service.py +++ b/homeassistant/helpers/service.py @@ -430,8 +430,8 @@ async def async_extract_config_entry_ids( # Some devices may have no entities for device_id in referenced.referenced_devices: - if (device_id in dev_reg.devices or device_id in dev_reg.child_devices) and ( - device := dev_reg.async_get(device_id) + if ( + device := dev_reg.async_get(device_id, include_composite_devices=False) ) is not None: config_entry_ids.update(device.config_entries) diff --git a/homeassistant/helpers/target.py b/homeassistant/helpers/target.py index 348b2da7bd94..e4150b8c624d 100644 --- a/homeassistant/helpers/target.py +++ b/homeassistant/helpers/target.py @@ -161,15 +161,11 @@ def _resolve_referenced_devices( ) -> None: """Resolve targeted device ids into referenced device ids.""" for device_id in device_ids: - if device_id in dev_reg.devices: + device = dev_reg.async_get(device_id) + if device is None: + selected.missing_devices.add(device_id) selected.referenced_devices.add(device_id) - selected.referenced_devices.update( - child_device.id - for child_device in dev_reg.child_devices.get_children_for_device_id( - device_id - ) - ) - elif device_id in dev_reg.child_devices: + elif isinstance(device, dr.ChildDeviceEntry): selected.referenced_devices.add(device_id) elif split_devices := dev_reg.async_get_devices_for_composite_device_id( device_id @@ -183,15 +179,18 @@ def _resolve_referenced_devices( selected.referenced_devices.add(split_device.id) selected.referenced_devices.update( child_device.id - for child_device in ( - dev_reg.child_devices.get_children_for_device_id( - split_device.id - ) + for child_device in dr.async_entries_for_parent_device( + dev_reg, split_device.id ) ) else: - selected.missing_devices.add(device_id) selected.referenced_devices.add(device_id) + selected.referenced_devices.update( + child_device.id + for child_device in dr.async_entries_for_parent_device( + dev_reg, device_id + ) + ) def async_extract_referenced_entity_ids( diff --git a/homeassistant/helpers/template/extensions/devices.py b/homeassistant/helpers/template/extensions/devices.py index 17f7b521337e..91a4a3d887d3 100644 --- a/homeassistant/helpers/template/extensions/devices.py +++ b/homeassistant/helpers/template/extensions/devices.py @@ -84,9 +84,8 @@ class DeviceExtension(BaseTemplateExtension): dev_reg = dr.async_get(self.hass) return next( ( - device_id - for container in (dev_reg.devices, dev_reg.child_devices) - for device_id, device in container.items() + device.id + for device in (*dev_reg.devices, *dev_reg.child_devices) if (name := device.name_by_user or device.name) and (str(entity_id_or_device_name) == name) ), diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 39c8c2046bba..68f693f3f8f8 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -24,7 +24,7 @@ bleak-retry-connector==4.6.3 bleak==3.0.2 bluetooth-adapters==2.4.0 bluetooth-auto-recovery==1.6.4 -bluetooth-data-tools==1.29.18 +bluetooth-data-tools==1.29.21 cached-ipaddress==1.1.2 certifi>=2021.5.30 ciso8601==2.3.3 @@ -35,8 +35,8 @@ file-read-backwards==2.0.0 fnv-hash-fast==2.0.3 go2rtc-client==0.4.0 ha-ffmpeg==3.2.2 -habluetooth==6.26.5 -hass-nabucasa==2.2.0 +habluetooth==6.26.7 +hass-nabucasa==2.3.0 hassil==3.11.0 home-assistant-bluetooth==2.0.0 home-assistant-frontend==20260729.7 @@ -129,7 +129,7 @@ httpcore==1.0.9 hyperframe>=5.2.0 # Ensure we run compatible with musllinux build env -numpy==2.3.2 +numpy==2.5.2 pandas==2.3.3 # Constrain multidict to avoid typing issues diff --git a/pyproject.toml b/pyproject.toml index 1bceeee6dc09..b26831cc259b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -47,7 +47,7 @@ dependencies = [ "fnv-hash-fast==2.0.3", # hass-nabucasa is imported by helpers which don't depend on the cloud # integration - "hass-nabucasa==2.2.0", + "hass-nabucasa==2.3.0", # When bumping httpx, please check the version pins of # httpcore, anyio, and h11 in gen_requirements_all "httpx==0.28.1", diff --git a/requirements.txt b/requirements.txt index 47f2f8af0949..40e8e15d2501 100644 --- a/requirements.txt +++ b/requirements.txt @@ -24,7 +24,7 @@ cronsim==2.7 cryptography==48.0.1 fnv-hash-fast==2.0.3 ha-ffmpeg==3.2.2 -hass-nabucasa==2.2.0 +hass-nabucasa==2.3.0 hassil==3.11.0 home-assistant-bluetooth==2.0.0 home-assistant-intents==2026.7.30 diff --git a/requirements_all.txt b/requirements_all.txt index 32dbda8f6cb6..17194718ce2d 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -22,7 +22,7 @@ HAP-python==5.0.0 HATasmota==0.10.1 # homeassistant.components.hue_ble -HueBLE==2.2.2 +HueBLE==2.2.3 # homeassistant.components.mastodon Mastodon.py==2.2.1 @@ -108,10 +108,7 @@ PyXiaomiGateway==0.14.3 RachioPy==1.1.0 # homeassistant.components.python_script -RestrictedPython==8.1 - -# homeassistant.components.remember_the_milk -RtmAPI==0.7.2 +RestrictedPython==8.5 # homeassistant.components.recorder # homeassistant.components.sql @@ -133,7 +130,7 @@ WSDiscovery==2.1.2 accuweather==5.1.0 # homeassistant.components.actron_air -actron-neo-api==0.5.13 +actron-neo-api==0.5.14 # homeassistant.components.adax adax==0.4.0 @@ -260,7 +257,7 @@ aioelectricitymaps==1.1.1 aioemonitor==1.0.5 # homeassistant.components.esphome -aioesphomeapi==45.6.1 +aioesphomeapi==45.12.0 # homeassistant.components.matrix # homeassistant.components.slack @@ -409,6 +406,9 @@ aiorecollect==2023.09.0 # homeassistant.components.ridwell aioridwell==2025.09.0 +# homeassistant.components.remember_the_milk +aiortm==0.19.0 + # homeassistant.components.ruckus_unleashed aioruckus==0.46.3 @@ -420,7 +420,7 @@ aiorussound==5.0.2 aioruuvigateway==0.1.0 # homeassistant.components.shelly -aioshelly==13.30.0 +aioshelly==13.31.0 # homeassistant.components.skybell aioskybell==22.7.0 @@ -453,7 +453,7 @@ aiotedee==0.3.0 aiotractive==1.0.3 # homeassistant.components.unifi -aiounifi==92 +aiounifi==93 # homeassistant.components.usb aiousbwatcher==1.1.2 @@ -474,7 +474,7 @@ aiowatttime==0.1.1 aiowebdav2==0.6.2 # homeassistant.components.webostv -aiowebostv==0.9.1 +aiowebostv==0.9.2 # homeassistant.components.withings aiowithings==3.1.6 @@ -657,7 +657,7 @@ beautifulsoup4==4.13.3 bizkaibus==0.1.1 # homeassistant.components.esphome -bleak-esphome==3.9.7 +bleak-esphome==4.0.0 # homeassistant.components.bluetooth bleak-retry-connector==4.6.3 @@ -693,7 +693,7 @@ bluetooth-auto-recovery==1.6.4 # homeassistant.components.ld2410_ble # homeassistant.components.led_ble # homeassistant.components.private_ble_device -bluetooth-data-tools==1.29.18 +bluetooth-data-tools==1.29.21 # homeassistant.components.bond bond-async==0.2.1 @@ -1201,7 +1201,7 @@ growattServer==2.1.0 gspread==5.5.0 # homeassistant.components.guntamatic -guntamatic==1.9.3 +guntamatic==1.11.1 # homeassistant.components.profiler guppy3==3.1.7 @@ -1228,7 +1228,7 @@ ha-xthings-cloud==1.0.5 habiticalib==0.4.7 # homeassistant.components.bluetooth -habluetooth==6.26.5 +habluetooth==6.26.7 # homeassistant.components.hanna hanna-cloud==0.0.7 @@ -1237,7 +1237,7 @@ hanna-cloud==0.0.7 harbor-python==1.5.0 # homeassistant.components.cloud -hass-nabucasa==2.2.0 +hass-nabucasa==2.3.0 # homeassistant.components.splunk hass-splunk==0.1.4 @@ -1304,9 +1304,6 @@ homevolt==0.5.0 # homeassistant.components.horizon horimote==0.4.1 -# homeassistant.components.remember_the_milk -httplib2==0.20.4 - # homeassistant.components.huawei_lte huawei-lte-api==1.11.0 @@ -1525,7 +1522,7 @@ loqedAPI==2.1.11 luftdaten==0.7.4 # homeassistant.components.lunatone -lunatone-rest-api-client==0.9.2 +lunatone-rest-api-client==0.10.0 # homeassistant.components.lupusec lupupy==0.3.2 @@ -1534,10 +1531,10 @@ lupupy==0.3.2 lw12==0.9.2 # homeassistant.components.scrape -lxml==6.1.1 +lxml==6.1.2 # homeassistant.components.lyngdorf -lyngdorf==1.8.0 +lyngdorf==1.10.0 # homeassistant.components.matrix matrix-nio==0.26.0 @@ -1640,7 +1637,7 @@ mozart-api==6.2.0.44.0 mullvad-api==1.0.0 # homeassistant.components.music_assistant -music-assistant-client==1.4.3 +music-assistant-client==1.5.1 # homeassistant.components.tts mutagen==1.48.1 @@ -1649,7 +1646,7 @@ mutagen==1.48.1 mutesync==0.0.1 # homeassistant.components.mvglive -mvg==1.4.0 +mvg==1.6.0 # homeassistant.components.myuplink myuplink==0.7.0 @@ -1697,7 +1694,7 @@ nhc==0.8.0 nibe==2.24.0 # homeassistant.components.nice_go -nice-go==1.0.2 +nice-go==1.0.3 # homeassistant.components.nilu niluclient==0.1.2 @@ -1730,7 +1727,7 @@ numato-gpio==0.13.0 # homeassistant.components.iqvia # homeassistant.components.stream # homeassistant.components.trend -numpy==2.3.2 +numpy==2.5.2 # homeassistant.components.nyt_games nyt_games==0.5.0 @@ -2177,7 +2174,7 @@ pyegps==0.2.5 pyemoncms==0.1.3 # homeassistant.components.enphase_envoy -pyenphase==3.2.1 +pyenphase==4.0.0 # homeassistant.components.envertech_evt800 pyenvertechevt800==0.2.4 @@ -2737,7 +2734,7 @@ python-open-router==0.4.0 python-opendata-transport==0.5.0 # homeassistant.components.openevse -python-openevse-http==1.0.1 +python-openevse-http==1.5.0 # homeassistant.components.opensky python-opensky==1.0.1 @@ -2874,7 +2871,7 @@ pyws66i==1.1 pyxeoma==1.4.2 # homeassistant.components.yardian -pyyardian==1.4.1 +pyyardian==1.4.2 # homeassistant.components.qrcode pyzbar==0.1.9 @@ -2928,7 +2925,7 @@ renault-api==0.5.12 renson-endura-delta==1.7.2 # homeassistant.components.reolink -reolink-aio==0.21.8 +reolink-aio==0.21.9 # homeassistant.components.radio_frequency rf-protocols==4.3.0 @@ -3250,7 +3247,7 @@ ttn_client==1.3.0 tuya-device-handlers==0.0.26 # homeassistant.components.tuya -tuya-device-sharing-sdk==0.2.14 +tuya-device-sharing-sdk==0.2.15 # homeassistant.components.twentemilieu twentemilieu==3.0.0 @@ -3345,7 +3342,7 @@ voip-utils==0.4.0 volkszaehler==0.4.0 # homeassistant.components.volvo -volvocarsapi==0.4.3 +volvocarsapi==0.4.4 # homeassistant.components.verisure vsure==2.10.0 diff --git a/script/gen_recorder_db_versions.py b/script/gen_recorder_db_versions.py new file mode 100644 index 000000000000..ecd358e925fc --- /dev/null +++ b/script/gen_recorder_db_versions.py @@ -0,0 +1,166 @@ +"""Generate the recorder's supported database versions file from endoflife.date. + +Usage: + python3 -m script.gen_recorder_db_versions # regenerate the file + python3 -m script.gen_recorder_db_versions validate # fail if out of date + +For MariaDB and MySQL we track the currently supported (non-end-of-life) LTS +release series and the newest known short-term/innovation release series. + +A CI job on the dev branch runs the ``validate`` mode, so it fails whenever a new +(non-patch) MariaDB or MySQL release means the committed file is out of date. + +Accessing the network here is a deliberate exception to the general policy of +not doing so in tests/CI; only this maintenance job talks to endoflife.date, and +the recorder itself only reads the committed file. ``validate`` skips (instead of +failing) when endoflife.date cannot be reached, so an outage does not fail +unrelated pull requests. +""" + +from __future__ import annotations + +from datetime import UTC, date, datetime +import importlib.util +import json +from pathlib import Path +import sys +import time +import urllib.error +import urllib.request + +SOURCES = { + "mariadb": "https://endoflife.date/api/mariadb.json", + "mysql": "https://endoflife.date/api/mysql.json", +} +FETCH_TIMEOUT = 30 +FETCH_RETRIES = 3 +FETCH_RETRY_WAIT = 2 +# Errors that mean we could not get usable data from endoflife.date +FETCH_ERRORS = (urllib.error.URLError, TimeoutError, json.JSONDecodeError) +OUTPUT_FILE = ( + Path(__file__).parent.parent + / "homeassistant" + / "generated" + / "recorder_database_versions.py" +) +HEADER = '''"""Automatically generated file. + +To update, run python3 -m script.gen_recorder_db_versions + +This file is generated from https://endoflife.date. For each of MariaDB and +MySQL, ``supported_lts`` lists the currently supported (non-end-of-life) +long-term support release series, and ``latest_non_lts`` is the newest known +short-term/innovation release series. Both are ``"."`` strings. +""" + +from typing import TypedDict + + +class DatabaseVersions(TypedDict): + """Supported release series for a database engine.""" + + supported_lts: list[str] + latest_non_lts: str + + +SUPPORTED_DATABASE_VERSIONS: dict[str, DatabaseVersions] = {''' + + +def _series_key(cycle: str) -> tuple[int, int]: + """Return a sortable (major, minor) key for a "." cycle.""" + major, _, minor = cycle.partition(".") + return int(major), int(minor) + + +def _eol(cycle: dict) -> date: + """Return the end-of-life date for a cycle (date.max when none is set).""" + eol = cycle["eol"] + return date.max if isinstance(eol, bool) else date.fromisoformat(eol) + + +def _engine_versions(cycles: list[dict], today: date) -> dict: + """Compute the supported LTS series and latest non-LTS series for an engine.""" + supported_lts = sorted( + ( + cycle["cycle"] + for cycle in cycles + if cycle.get("lts") and _eol(cycle) > today + ), + key=_series_key, + ) + latest_non_lts = max( + (cycle["cycle"] for cycle in cycles if not cycle.get("lts")), + key=_series_key, + ) + return {"supported_lts": supported_lts, "latest_non_lts": latest_non_lts} + + +def _fetch(url: str) -> list[dict]: + """Fetch and parse an endoflife.date API response, retrying transient errors.""" + for attempt in range(FETCH_RETRIES): + try: + with urllib.request.urlopen(url, timeout=FETCH_TIMEOUT) as response: + cycles: list[dict] = json.load(response) + return cycles + except FETCH_ERRORS: + if attempt == FETCH_RETRIES - 1: + raise + time.sleep(FETCH_RETRY_WAIT) + raise RuntimeError # pragma: no cover + + +def fetch_versions() -> dict: + """Fetch and compute the supported version data for all engines.""" + today = datetime.now(UTC).date() + return { + engine: _engine_versions(_fetch(url), today) for engine, url in SOURCES.items() + } + + +def render(versions: dict) -> str: + """Render the generated recorder_database_versions.py content.""" + lines = [HEADER] + for engine, data in versions.items(): + supported = ", ".join(f'"{cycle}"' for cycle in data["supported_lts"]) + lines.append(f' "{engine}": {{') + lines.append(f' "supported_lts": [{supported}],') + lines.append(f' "latest_non_lts": "{data["latest_non_lts"]}",') + lines.append(" },") + lines.append("}") + return "\n".join(lines) + "\n" + + +def load_committed() -> dict: + """Load the committed SUPPORTED_DATABASE_VERSIONS without importing recorder.""" + spec = importlib.util.spec_from_file_location("_database_versions", OUTPUT_FILE) + assert spec is not None and spec.loader is not None + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + committed: dict = module.SUPPORTED_DATABASE_VERSIONS + return committed + + +def main() -> int: + """Generate the file or validate that the committed one is up to date.""" + if len(sys.argv) > 1 and sys.argv[1] == "validate": + try: + versions = fetch_versions() + except FETCH_ERRORS as err: + print(f"Skipping validation, could not reach endoflife.date: {err}") + return 0 + if versions != load_committed(): + relative_path = OUTPUT_FILE.relative_to(Path(__file__).parent.parent) + print( + f"{relative_path} is out of date with the latest MariaDB or MySQL " + "release data from endoflife.date (a new release or an LTS series " + "reaching end of life).\n" + "Run: python3 -m script.gen_recorder_db_versions" + ) + return 1 + return 0 + OUTPUT_FILE.write_text(render(fetch_versions())) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/script/gen_requirements_all.py b/script/gen_requirements_all.py index 5304b2be7fbe..e532f8f1e2fd 100755 --- a/script/gen_requirements_all.py +++ b/script/gen_requirements_all.py @@ -114,7 +114,7 @@ httpcore==1.0.9 hyperframe>=5.2.0 # Ensure we run compatible with musllinux build env -numpy==2.3.2 +numpy==2.5.2 pandas==2.3.3 # Constrain multidict to avoid typing issues diff --git a/script/hassfest/mdi_icons.py b/script/hassfest/mdi_icons.py index 3f8b2882942c..d24f20532c00 100644 --- a/script/hassfest/mdi_icons.py +++ b/script/hassfest/mdi_icons.py @@ -8,6 +8,7 @@ from .model import Config, Integration from .serializer import format_python_namespace _TARGET = "pylint/plugins/pylint_home_assistant/generated/mdi_icons.py" +_REQUIREMENT_PREFIX = "home-assistant-frontend==" def _get_frontend_version() -> str | None: @@ -18,6 +19,14 @@ def _get_frontend_version() -> str | None: return None +def _get_pinned_frontend_version(integrations: dict[str, Integration]) -> str | None: + """Get the home-assistant-frontend version pinned in the frontend manifest.""" + for requirement in integrations["frontend"].requirements: + if requirement.startswith(_REQUIREMENT_PREFIX): + return requirement.removeprefix(_REQUIREMENT_PREFIX) + return None + + def _load_mdi_icons() -> set[str]: """Load the MDI icon names from the frontend package.""" try: @@ -35,6 +44,10 @@ def validate(integrations: dict[str, Integration], config: Config) -> None: if frontend_version is None: return + pinned_version = _get_pinned_frontend_version(integrations) + if pinned_version is not None and pinned_version != frontend_version: + return + icons = _load_mdi_icons() if not icons: config.add_error( diff --git a/script/licenses.py b/script/licenses.py index 6603b643e01c..e21e44adf8a6 100644 --- a/script/licenses.py +++ b/script/licenses.py @@ -192,6 +192,8 @@ EXCEPTIONS = { "ld2410-ble", # https://github.com/930913/ld2410-ble/pull/7 "maxcube-api", # https://github.com/uebelack/python-maxcube-api/pull/48 "neurio", # https://github.com/jordanh/neurio-python/pull/13 + # numpy: BSD-3-Clause AND 0BSD AND MIT AND Zlib AND CC0-1.0 + "numpy", # CC0-1.0 is not OSI approved "nsw-fuel-api-client", # https://github.com/nickw444/nsw-fuel-api-client/pull/14 "pigpio", # https://github.com/joan2937/pigpio/pull/608 "pymitv", # MIT diff --git a/tests/auth/permissions/test_entities.py b/tests/auth/permissions/test_entities.py index 20f883fc870d..097fe91b667d 100644 --- a/tests/auth/permissions/test_entities.py +++ b/tests/auth/permissions/test_entities.py @@ -249,7 +249,7 @@ def test_entities_areas_area_inherited_from_parent(hass: HomeAssistant) -> None: }, ) # The child has no area of its own and inherits the parent's area. - device_registry.child_devices["mock-child-id"] = ChildDeviceEntry( + device_registry._child_devices["mock-child-id"] = ChildDeviceEntry( config_entry_id="mock-config-entry", id="mock-child-id", parent_device_id="mock-parent-id", diff --git a/tests/common.py b/tests/common.py index 9966c166cca9..db50800b2563 100644 --- a/tests/common.py +++ b/tests/common.py @@ -759,15 +759,17 @@ def mock_device_registry( fixture instead. """ registry = dr.DeviceRegistry(hass) - registry.devices = dr.ActiveDeviceRegistryItems() - registry._device_data = registry.devices.data - registry.child_devices = dr.ChildDeviceRegistryItems() - registry._child_device_data = registry.child_devices.data + registry._devices = dr.ActiveDeviceRegistryItems() + registry.devices = registry._devices.values() + registry._device_data = registry._devices.data + registry._child_devices = dr.ChildDeviceRegistryItems() + registry.child_devices = registry._child_devices.values() + registry._child_device_data = registry._child_devices.data if mock_entries is None: mock_entries = {} for key, entry in mock_entries.items(): - registry.devices[key] = entry - registry.deleted_devices = dr.DeletedDeviceRegistryItems() + registry._devices[key] = entry + registry._deleted_devices = dr.DeletedDeviceRegistryItems() hass.data[dr.DATA_REGISTRY] = registry return registry diff --git a/tests/components/accuweather/test_config_flow.py b/tests/components/accuweather/test_config_flow.py index 62822db4d2e5..0b8ff8ad3c0e 100644 --- a/tests/components/accuweather/test_config_flow.py +++ b/tests/components/accuweather/test_config_flow.py @@ -41,9 +41,14 @@ async def test_invalid_api_key( ) result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": SOURCE_USER}, - data=VALID_CONFIG, + 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=VALID_CONFIG ) assert result["errors"] == {CONF_API_KEY: "invalid_api_key"} @@ -58,9 +63,14 @@ async def test_api_error( ) result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": SOURCE_USER}, - data=VALID_CONFIG, + 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=VALID_CONFIG ) assert result["errors"] == {"base": "cannot_connect"} @@ -75,9 +85,14 @@ async def test_requests_exceeded_error( ) result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": SOURCE_USER}, - data=VALID_CONFIG, + 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=VALID_CONFIG ) assert result["errors"] == {CONF_API_KEY: "requests_exceeded"} @@ -94,9 +109,14 @@ async def test_integration_already_exists( ).add_to_hass(hass) result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": SOURCE_USER}, - data=VALID_CONFIG, + 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=VALID_CONFIG ) assert result["type"] is FlowResultType.ABORT @@ -108,9 +128,14 @@ async def test_create_entry( ) -> None: """Test that the user step works.""" result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": SOURCE_USER}, - data=VALID_CONFIG, + 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=VALID_CONFIG ) assert result["type"] is FlowResultType.CREATE_ENTRY diff --git a/tests/components/actron_air/conftest.py b/tests/components/actron_air/conftest.py index 9eca84ab07b9..7b6ef48d9037 100644 --- a/tests/components/actron_air/conftest.py +++ b/tests/components/actron_air/conftest.py @@ -21,7 +21,7 @@ from tests.common import MockConfigEntry, load_fixture @pytest.fixture -def mock_actron_api() -> Generator[AsyncMock]: +def mock_actron_api_class() -> Generator[MagicMock]: """Mock the Actron Air API class.""" with ( patch( @@ -32,6 +32,14 @@ def mock_actron_api() -> Generator[AsyncMock]: "homeassistant.components.actron_air.config_flow.ActronAirAPI", new=mock_api, ), + ): + yield mock_api + + +@pytest.fixture +def mock_actron_api(mock_actron_api_class: MagicMock) -> Generator[AsyncMock]: + """Mock the Actron Air API instance.""" + with ( patch.object(ActronAirACSystem, "set_system_mode", new_callable=AsyncMock), patch.object( ActronAirUserAirconSettings, "set_away_mode", new_callable=AsyncMock @@ -54,7 +62,7 @@ def mock_actron_api() -> Generator[AsyncMock]: ActronAirUserAirconSettings, "set_fan_mode", new_callable=AsyncMock ), ): - api = mock_api.return_value + api = mock_actron_api_class.return_value # Mock device code request api.request_device_code.return_value = ActronAirDeviceCode( diff --git a/tests/components/actron_air/test_config_flow.py b/tests/components/actron_air/test_config_flow.py index 701f66274911..c4554ed84578 100644 --- a/tests/components/actron_air/test_config_flow.py +++ b/tests/components/actron_air/test_config_flow.py @@ -1,7 +1,7 @@ """Config flow tests for the Actron Air Integration.""" import asyncio -from unittest.mock import AsyncMock +from unittest.mock import AsyncMock, MagicMock from actron_neo_api import ActronAirAuthError from actron_neo_api.models.auth import ActronAirUserInfo @@ -12,10 +12,28 @@ from homeassistant.components.actron_air.const import DOMAIN from homeassistant.const import CONF_API_TOKEN from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType +from homeassistant.helpers.aiohttp_client import async_get_clientsession from tests.common import MockConfigEntry +@pytest.mark.usefixtures("mock_setup_entry", "mock_actron_api") +async def test_user_flow_uses_shared_session( + hass: HomeAssistant, mock_actron_api_class: MagicMock +) -> None: + """Test the API is created with Home Assistant's shared client session.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + assert mock_actron_api_class.call_args.kwargs["session"] is async_get_clientsession( + hass + ) + + await hass.async_block_till_done() + await hass.config_entries.flow.async_configure(result["flow_id"]) + + @pytest.mark.usefixtures("mock_setup_entry") async def test_user_flow_oauth2_success( hass: HomeAssistant, mock_actron_api: AsyncMock diff --git a/tests/components/actron_air/test_init.py b/tests/components/actron_air/test_init.py index 65ca2d90d1b5..792cec36bf71 100644 --- a/tests/components/actron_air/test_init.py +++ b/tests/components/actron_air/test_init.py @@ -10,12 +10,28 @@ from homeassistant.config_entries import ConfigEntryState from homeassistant.const import Platform from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr +from homeassistant.helpers.aiohttp_client import async_get_clientsession from . import setup_integration from tests.common import MockConfigEntry +@pytest.mark.usefixtures("mock_actron_api") +async def test_setup_entry_uses_shared_session( + hass: HomeAssistant, + mock_actron_api_class: MagicMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test the API is created with Home Assistant's shared client session.""" + await setup_integration(hass, mock_config_entry) + + assert mock_config_entry.state is ConfigEntryState.LOADED + assert mock_actron_api_class.call_args.kwargs["session"] is async_get_clientsession( + hass + ) + + async def test_setup_entry_auth_error( hass: HomeAssistant, mock_actron_api: AsyncMock, diff --git a/tests/components/airly/__init__.py b/tests/components/airly/__init__.py index 199c7a268702..05d742603da6 100644 --- a/tests/components/airly/__init__.py +++ b/tests/components/airly/__init__.py @@ -6,9 +6,9 @@ from homeassistant.core import HomeAssistant from tests.common import MockConfigEntry, async_load_fixture from tests.test_util.aiohttp import AiohttpClientMocker -API_NEAREST_URL = "https://airapi.airly.eu/v2/measurements/nearest?lat=123.000000&lng=456.000000&maxDistanceKM=5.000000" +API_NEAREST_URL = "https://airapi.airly.eu/v2/measurements/nearest?lat=12.300000&lng=45.600000&maxDistanceKM=5.000000" API_POINT_URL = ( - "https://airapi.airly.eu/v2/measurements/point?lat=123.000000&lng=456.000000" + "https://airapi.airly.eu/v2/measurements/point?lat=12.300000&lng=45.600000" ) HEADERS = { "X-RateLimit-Limit-day": "100", @@ -24,11 +24,11 @@ async def init_integration( domain=DOMAIN, title="Home", entry_id="3bd2acb0e4f0476d40865546d0d91921", - unique_id="123-456", + unique_id="12.3-45.6", data={ "api_key": "foo", - "latitude": 123, - "longitude": 456, + "latitude": 12.3, + "longitude": 45.6, }, ) diff --git a/tests/components/airly/snapshots/test_sensor.ambr b/tests/components/airly/snapshots/test_sensor.ambr index ea5a82da6402..74a0d6a29788 100644 --- a/tests/components/airly/snapshots/test_sensor.ambr +++ b/tests/components/airly/snapshots/test_sensor.ambr @@ -37,7 +37,7 @@ 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, - 'unique_id': '123-456-co', + 'unique_id': '12.3-45.6-co', 'unit_of_measurement': , }) # --- @@ -96,7 +96,7 @@ 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'caqi', - 'unique_id': '123-456-caqi', + 'unique_id': '12.3-45.6-caqi', 'unit_of_measurement': 'CAQI', }) # --- @@ -156,7 +156,7 @@ 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, - 'unique_id': '123-456-humidity', + 'unique_id': '12.3-45.6-humidity', 'unit_of_measurement': , }) # --- @@ -215,7 +215,7 @@ 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, - 'unique_id': '123-456-no2', + 'unique_id': '12.3-45.6-no2', 'unit_of_measurement': , }) # --- @@ -276,7 +276,7 @@ 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, - 'unique_id': '123-456-o3', + 'unique_id': '12.3-45.6-o3', 'unit_of_measurement': , }) # --- @@ -337,7 +337,7 @@ 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, - 'unique_id': '123-456-pm1', + 'unique_id': '12.3-45.6-pm1', 'unit_of_measurement': , }) # --- @@ -396,7 +396,7 @@ 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, - 'unique_id': '123-456-pm10', + 'unique_id': '12.3-45.6-pm10', 'unit_of_measurement': , }) # --- @@ -457,7 +457,7 @@ 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, - 'unique_id': '123-456-pm25', + 'unique_id': '12.3-45.6-pm25', 'unit_of_measurement': , }) # --- @@ -518,7 +518,7 @@ 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, - 'unique_id': '123-456-pressure', + 'unique_id': '12.3-45.6-pressure', 'unit_of_measurement': , }) # --- @@ -577,7 +577,7 @@ 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, - 'unique_id': '123-456-so2', + 'unique_id': '12.3-45.6-so2', 'unit_of_measurement': , }) # --- @@ -638,7 +638,7 @@ 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, - 'unique_id': '123-456-temperature', + 'unique_id': '12.3-45.6-temperature', 'unit_of_measurement': , }) # --- diff --git a/tests/components/airly/test_config_flow.py b/tests/components/airly/test_config_flow.py index f6687f787492..6da67ebf321f 100644 --- a/tests/components/airly/test_config_flow.py +++ b/tests/components/airly/test_config_flow.py @@ -17,8 +17,8 @@ from tests.test_util.aiohttp import AiohttpClientMocker CONFIG = { CONF_API_KEY: "foo", - CONF_LATITUDE: 123, - CONF_LONGITUDE: 456, + CONF_LATITUDE: 12.3, + CONF_LONGITUDE: 45.6, } @@ -44,7 +44,14 @@ async def test_invalid_api_key( ) result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": SOURCE_USER}, data=CONFIG + 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=CONFIG ) assert result["errors"] == {"base": "invalid_api_key"} @@ -64,7 +71,14 @@ async def test_invalid_location( ) result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": SOURCE_USER}, data=CONFIG + 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=CONFIG ) assert result["errors"] == {"base": "wrong_location"} @@ -85,7 +99,14 @@ async def test_invalid_location_for_point_and_nearest( with patch("homeassistant.components.airly.async_setup_entry", return_value=True): result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": SOURCE_USER}, data=CONFIG + 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=CONFIG ) assert result["type"] is FlowResultType.ABORT @@ -99,10 +120,17 @@ async def test_duplicate_error( aioclient_mock.get( API_POINT_URL, text=await async_load_fixture(hass, "valid_station.json", DOMAIN) ) - MockConfigEntry(domain=DOMAIN, unique_id="123-456", data=CONFIG).add_to_hass(hass) + MockConfigEntry(domain=DOMAIN, unique_id="12.3-45.6", data=CONFIG).add_to_hass(hass) result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": SOURCE_USER}, data=CONFIG + 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=CONFIG ) assert result["type"] is FlowResultType.ABORT @@ -119,7 +147,14 @@ async def test_create_entry( with patch("homeassistant.components.airly.async_setup_entry", return_value=True): result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": SOURCE_USER}, data=CONFIG + 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=CONFIG ) assert result["type"] is FlowResultType.CREATE_ENTRY @@ -146,7 +181,14 @@ async def test_create_entry_with_nearest_method( with patch("homeassistant.components.airly.async_setup_entry", return_value=True): result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": SOURCE_USER}, data=CONFIG + 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=CONFIG ) assert result["type"] is FlowResultType.CREATE_ENTRY diff --git a/tests/components/airly/test_init.py b/tests/components/airly/test_init.py index da606d718a3b..8058f751c787 100644 --- a/tests/components/airly/test_init.py +++ b/tests/components/airly/test_init.py @@ -38,11 +38,11 @@ async def test_config_not_ready( entry = MockConfigEntry( domain=DOMAIN, title="Home", - unique_id="123-456", + unique_id="12.3-45.6", data={ "api_key": "foo", - "latitude": 123, - "longitude": 456, + "latitude": 12.3, + "longitude": 45.6, "use_nearest": True, }, ) @@ -62,8 +62,8 @@ async def test_config_without_unique_id( title="Home", data={ "api_key": "foo", - "latitude": 123, - "longitude": 456, + "latitude": 12.3, + "longitude": 45.6, }, ) @@ -73,7 +73,7 @@ async def test_config_without_unique_id( entry.add_to_hass(hass) await hass.config_entries.async_setup(entry.entry_id) assert entry.state is ConfigEntryState.LOADED - assert entry.unique_id == "123-456" + assert entry.unique_id == "12.3-45.6" async def test_config_with_turned_off_station( @@ -83,11 +83,11 @@ async def test_config_with_turned_off_station( entry = MockConfigEntry( domain=DOMAIN, title="Home", - unique_id="123-456", + unique_id="12.3-45.6", data={ "api_key": "foo", - "latitude": 123, - "longitude": 456, + "latitude": 12.3, + "longitude": 45.6, }, ) @@ -114,11 +114,11 @@ async def test_update_interval( entry = MockConfigEntry( domain=DOMAIN, title="Home", - unique_id="123-456", + unique_id="12.3-45.6", data={ "api_key": "foo", - "latitude": 123, - "longitude": 456, + "latitude": 12.3, + "longitude": 45.6, }, ) @@ -241,7 +241,7 @@ async def test_remove_air_quality_entities( entity_registry.async_get_or_create( AIR_QUALITY_DOMAIN, DOMAIN, - "123-456", + "12.3-45.6", suggested_object_id="home", disabled_by=None, ) diff --git a/tests/components/alarm_control_panel/test_device_action.py b/tests/components/alarm_control_panel/test_device_action.py index 0774353f5f36..d6cc46077743 100644 --- a/tests/components/alarm_control_panel/test_device_action.py +++ b/tests/components/alarm_control_panel/test_device_action.py @@ -101,7 +101,9 @@ async def test_get_actions( ) if set_state: hass.states.async_set( - f"{DOMAIN}.test_5678", "attributes", {"supported_features": features_state} + entity_entry.entity_id, + "attributes", + {"supported_features": features_state}, ) expected_actions = [ { @@ -184,7 +186,7 @@ async def test_get_actions_arm_night_only( DOMAIN, "test", "5678", device_id=device_entry.id ) hass.states.async_set( - "alarm_control_panel.test_5678", "attributes", {"supported_features": 4} + entity_entry.entity_id, "attributes", {"supported_features": 4} ) expected_actions = [ { diff --git a/tests/components/alarm_control_panel/test_device_condition.py b/tests/components/alarm_control_panel/test_device_condition.py index 9d098a9b30b8..00f29bf014d5 100644 --- a/tests/components/alarm_control_panel/test_device_condition.py +++ b/tests/components/alarm_control_panel/test_device_condition.py @@ -80,7 +80,7 @@ async def test_get_conditions( ) if set_state: hass.states.async_set( - "alarm_control_panel.test_5678", + entity_entry.entity_id, "attributes", {"supported_features": features_state}, ) diff --git a/tests/components/alarm_control_panel/test_device_trigger.py b/tests/components/alarm_control_panel/test_device_trigger.py index f7c9e2a8a5f7..89ac6db9022e 100644 --- a/tests/components/alarm_control_panel/test_device_trigger.py +++ b/tests/components/alarm_control_panel/test_device_trigger.py @@ -169,11 +169,11 @@ async def test_get_trigger_capabilities( config_entry_id=config_entry.entry_id, connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, ) - entity_registry.async_get_or_create( + entity_entry = entity_registry.async_get_or_create( DOMAIN, "test", "5678", device_id=device_entry.id ) hass.states.async_set( - "alarm_control_panel.test_5678", "attributes", {"supported_features": 15} + entity_entry.entity_id, "attributes", {"supported_features": 15} ) triggers = await async_get_device_automations( @@ -208,11 +208,11 @@ async def test_get_trigger_capabilities_legacy( config_entry_id=config_entry.entry_id, connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, ) - entity_registry.async_get_or_create( + entity_entry = entity_registry.async_get_or_create( DOMAIN, "test", "5678", device_id=device_entry.id ) hass.states.async_set( - "alarm_control_panel.test_5678", "attributes", {"supported_features": 15} + entity_entry.entity_id, "attributes", {"supported_features": 15} ) triggers = await async_get_device_automations( diff --git a/tests/components/alexa_devices/test_diagnostics.py b/tests/components/alexa_devices/test_diagnostics.py index effdae585ed2..00a63db8ca8f 100644 --- a/tests/components/alexa_devices/test_diagnostics.py +++ b/tests/components/alexa_devices/test_diagnostics.py @@ -55,7 +55,7 @@ async def test_device_diagnostics( device = device_registry.async_get_device_by_identifier( (DOMAIN, TEST_DEVICE_1_SN), mock_config_entry.entry_id ) - assert device, repr(device_registry.devices) + assert device, repr(device_registry._devices) assert await get_diagnostics_for_device( hass, hass_client, mock_config_entry, device diff --git a/tests/components/androidtv_remote/test_remote.py b/tests/components/androidtv_remote/test_remote.py index 9bd86bb3d856..2e006c4514aa 100644 --- a/tests/components/androidtv_remote/test_remote.py +++ b/tests/components/androidtv_remote/test_remote.py @@ -8,7 +8,7 @@ import pytest from homeassistant.config_entries import ConfigEntryState from homeassistant.const import STATE_OFF, STATE_ON, STATE_UNAVAILABLE from homeassistant.core import HomeAssistant -from homeassistant.exceptions import HomeAssistantError +from homeassistant.exceptions import HomeAssistantError, ServiceValidationError from tests.common import MockConfigEntry @@ -174,6 +174,171 @@ async def test_remote_send_command_with_hold_secs( ] +@pytest.mark.parametrize( + ("command", "expected_call"), + [ + ("start_long:DPAD_DOWN", call("DPAD_DOWN", "START_LONG")), + ("end_long:DPAD_DOWN", call("DPAD_DOWN", "END_LONG")), + ("short:DPAD_DOWN", call("DPAD_DOWN", "SHORT")), + ("START_LONG:DPAD_DOWN", call("DPAD_DOWN", "START_LONG")), + ], + ids=["start", "end", "short", "uppercase_prefix"], +) +async def test_remote_send_command_with_direction_prefix( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_api: MagicMock, + command: str, + expected_call: object, +) -> None: + """Test remote.send_command emits a single directional event for prefixed commands.""" + mock_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + assert mock_config_entry.state is ConfigEntryState.LOADED + + await hass.services.async_call( + "remote", + "send_command", + { + "entity_id": REMOTE_ENTITY, + "command": command, + "delay_secs": 0.01, + }, + blocking=True, + ) + assert mock_api.send_key_command.mock_calls == [expected_call] + + +@pytest.mark.parametrize( + "command", + [ + "text:hello world", + "voice:something", + "DPAD_DOWN:WITH_COLON", + ":leading_colon", + ], + ids=["text_prefix", "unknown_prefix", "embedded_colon", "leading_colon"], +) +async def test_remote_send_command_unknown_prefix_passes_through( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_api: MagicMock, + command: str, +) -> None: + """Test that commands with non-direction colon prefixes are forwarded verbatim. + + The integration only strips prefixes that match the allowlist; + other colon-using conventions (notably the lib's own ``text:`` prefix for + keyboard text) must reach the underlying library unchanged so it can apply + its own routing. + """ + mock_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + assert mock_config_entry.state is ConfigEntryState.LOADED + + await hass.services.async_call( + "remote", + "send_command", + { + "entity_id": REMOTE_ENTITY, + "command": command, + "delay_secs": 0.01, + }, + blocking=True, + ) + assert mock_api.send_key_command.mock_calls == [call(command, "SHORT")] + + +async def test_remote_send_command_direction_prefix_pair( + hass: HomeAssistant, mock_config_entry: MockConfigEntry, mock_api: MagicMock +) -> None: + """Test that a press-down/release-up pair produces exactly two events. + + This is the live-press scenario: a UI sends START_LONG on pointerdown and + END_LONG on pointerup as separate service calls. Together they must produce + no extra SHORT or sleep-driven events. + """ + mock_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + assert mock_config_entry.state is ConfigEntryState.LOADED + + await hass.services.async_call( + "remote", + "send_command", + { + "entity_id": REMOTE_ENTITY, + "command": "start_long:DPAD_CENTER", + "delay_secs": 0.01, + }, + blocking=True, + ) + await hass.services.async_call( + "remote", + "send_command", + { + "entity_id": REMOTE_ENTITY, + "command": "end_long:DPAD_CENTER", + "delay_secs": 0.01, + }, + blocking=True, + ) + assert mock_api.send_key_command.mock_calls == [ + call("DPAD_CENTER", "START_LONG"), + call("DPAD_CENTER", "END_LONG"), + ] + + +async def test_remote_send_command_direction_prefix_with_hold_secs_raises( + hass: HomeAssistant, mock_config_entry: MockConfigEntry, mock_api: MagicMock +) -> None: + """Test that combining a direction prefix with hold_secs raises.""" + mock_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + assert mock_config_entry.state is ConfigEntryState.LOADED + + with pytest.raises( + ServiceValidationError, + match='Command "start_long:DPAD_RIGHT" combines a direction prefix with hold_secs', + ): + await hass.services.async_call( + "remote", + "send_command", + { + "entity_id": REMOTE_ENTITY, + "command": "start_long:DPAD_RIGHT", + "delay_secs": 0.01, + "hold_secs": 0.01, + }, + blocking=True, + ) + assert mock_api.send_key_command.mock_calls == [] + + +async def test_remote_send_command_empty_key_code_raises( + hass: HomeAssistant, mock_config_entry: MockConfigEntry, mock_api: MagicMock +) -> None: + """Test that a direction prefix without a key code raises.""" + mock_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + assert mock_config_entry.state is ConfigEntryState.LOADED + + with pytest.raises( + ServiceValidationError, + match='Command "SHORT:" is missing a key code after the direction prefix', + ): + await hass.services.async_call( + "remote", + "send_command", + { + "entity_id": REMOTE_ENTITY, + "command": "SHORT:", + "delay_secs": 0.01, + }, + blocking=True, + ) + assert mock_api.send_key_command.mock_calls == [] + + async def test_remote_connection_closed( hass: HomeAssistant, mock_config_entry: MockConfigEntry, mock_api: MagicMock ) -> None: diff --git a/tests/components/anova/test_sensor.py b/tests/components/anova/test_sensor.py index d1e083744ecf..10005711befe 100644 --- a/tests/components/anova/test_sensor.py +++ b/tests/components/anova/test_sensor.py @@ -18,9 +18,15 @@ async def test_sensors(hass: HomeAssistant, anova_api: AnovaApi) -> None: assert len(hass.states.async_all("sensor")) == 8 assert ( hass.states.get("sensor.anova_precision_cooker_cook_time_remaining").state - == "0" + == "0.0" + ) + assert hass.states.get("sensor.anova_precision_cooker_cook_time").state == "0.0" + assert ( + hass.states.get("sensor.anova_precision_cooker_cook_time").attributes[ + "unit_of_measurement" + ] + == "h" ) - assert hass.states.get("sensor.anova_precision_cooker_cook_time").state == "0" assert ( hass.states.get("sensor.anova_precision_cooker_heater_temperature").state == "22.37" diff --git a/tests/components/anthropic/test_init.py b/tests/components/anthropic/test_init.py index bf8159eb1be7..b4ce16a0b0d5 100644 --- a/tests/components/anthropic/test_init.py +++ b/tests/components/anthropic/test_init.py @@ -957,7 +957,7 @@ async def test_migrate_entry_to_v2_3( conversation_device = attr.evolve( conversation_device, disabled_by=device_disabled_by ) - device_registry.devices[conversation_device.id] = conversation_device + device_registry._devices[conversation_device.id] = conversation_device conversation_entity = entity_registry.async_get_or_create( "conversation", DOMAIN, diff --git a/tests/components/assist_pipeline/test_pipeline.py b/tests/components/assist_pipeline/test_pipeline.py index d6d03c5bfa9b..4d231d1c292f 100644 --- a/tests/components/assist_pipeline/test_pipeline.py +++ b/tests/components/assist_pipeline/test_pipeline.py @@ -1991,7 +1991,7 @@ async def test_acknowledge( device_registry.async_update_device(light_device.id, area_id=area_2.id) _reset() - await _run("turn on light 2") + await _run("turn on Mock Title light 2") # Acknowledgment sound should be not played (different device area) text_to_speech.assert_called_once() diff --git a/tests/components/assist_pipeline/test_select.py b/tests/components/assist_pipeline/test_select.py index a15ec167d67d..6d7df5a3d39b 100644 --- a/tests/components/assist_pipeline/test_select.py +++ b/tests/components/assist_pipeline/test_select.py @@ -16,7 +16,7 @@ from homeassistant.components.assist_pipeline.vad import VadSensitivity from homeassistant.config_entries import ConfigEntry, ConfigEntryState from homeassistant.const import Platform from homeassistant.core import HomeAssistant -from homeassistant.helpers import device_registry as dr +from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback @@ -126,6 +126,7 @@ async def test_select_entity_registering_device( async def test_select_entity_changing_pipelines( hass: HomeAssistant, + entity_registry: er.EntityRegistry, init_select: MockConfigEntry, pipeline_1: Pipeline, pipeline_2: Pipeline, @@ -135,7 +136,12 @@ async def test_select_entity_changing_pipelines( config_entry = init_select # nicer naming config_entry.mock_state(hass, ConfigEntryState.LOADED) - state = hass.states.get("select.assist_pipeline_test_prefix_pipeline") + pipeline_entity_id = entity_registry.async_get_entity_id( + Platform.SELECT, DOMAIN, "test-prefix-pipeline" + ) + assert pipeline_entity_id is not None + + state = hass.states.get(pipeline_entity_id) assert state is not None assert state.state == "preferred" assert state.attributes["options"] == [ @@ -150,13 +156,13 @@ async def test_select_entity_changing_pipelines( "select", "select_option", { - "entity_id": "select.assist_pipeline_test_prefix_pipeline", + "entity_id": pipeline_entity_id, "option": pipeline_2.name, }, blocking=True, ) - state = hass.states.get("select.assist_pipeline_test_prefix_pipeline") + state = hass.states.get(pipeline_entity_id) assert state is not None assert state.state == pipeline_2.name @@ -168,14 +174,14 @@ async def test_select_entity_changing_pipelines( config_entry, [Platform.SELECT] ) - state = hass.states.get("select.assist_pipeline_test_prefix_pipeline") + state = hass.states.get(pipeline_entity_id) assert state is not None assert state.state == pipeline_2.name # Remove selected pipeline await pipeline_storage.async_delete_item(pipeline_2.id) - state = hass.states.get("select.assist_pipeline_test_prefix_pipeline") + state = hass.states.get(pipeline_entity_id) assert state is not None assert state.state == "preferred" assert state.attributes["options"] == [ @@ -187,13 +193,19 @@ async def test_select_entity_changing_pipelines( async def test_select_entity_changing_vad_sensitivity( hass: HomeAssistant, + entity_registry: er.EntityRegistry, init_select: MockConfigEntry, ) -> None: """Test entity tracking vad sensitivity changes.""" config_entry = init_select # nicer naming config_entry.mock_state(hass, ConfigEntryState.LOADED) - state = hass.states.get("select.assist_pipeline_test_vad_sensitivity") + vad_entity_id = entity_registry.async_get_entity_id( + Platform.SELECT, DOMAIN, "test-vad_sensitivity" + ) + assert vad_entity_id is not None + + state = hass.states.get(vad_entity_id) assert state is not None assert state.state == VadSensitivity.DEFAULT.value @@ -202,13 +214,13 @@ async def test_select_entity_changing_vad_sensitivity( "select", "select_option", { - "entity_id": "select.assist_pipeline_test_vad_sensitivity", + "entity_id": vad_entity_id, "option": VadSensitivity.AGGRESSIVE.value, }, blocking=True, ) - state = hass.states.get("select.assist_pipeline_test_vad_sensitivity") + state = hass.states.get(vad_entity_id) assert state is not None assert state.state == VadSensitivity.AGGRESSIVE.value @@ -220,6 +232,6 @@ async def test_select_entity_changing_vad_sensitivity( config_entry, [Platform.SELECT] ) - state = hass.states.get("select.assist_pipeline_test_vad_sensitivity") + state = hass.states.get(vad_entity_id) assert state is not None assert state.state == VadSensitivity.AGGRESSIVE.value diff --git a/tests/components/assist_satellite/test_entity.py b/tests/components/assist_satellite/test_entity.py index 59a7a9fb5098..07c0ffc2ab26 100644 --- a/tests/components/assist_satellite/test_entity.py +++ b/tests/components/assist_satellite/test_entity.py @@ -71,12 +71,10 @@ async def test_entity_state( context = Context() audio_stream = object() - entity.async_set_context(context) - with patch( "homeassistant.components.assist_satellite.entity.async_pipeline_from_audio_stream" ) as mock_start_pipeline: - await entity.async_accept_pipeline_from_satellite(audio_stream) + await entity.async_accept_pipeline_from_satellite(audio_stream, context=context) assert mock_start_pipeline.called kwargs = mock_start_pipeline.call_args[1] @@ -466,22 +464,28 @@ async def test_announce_default_preannounce( ) -async def test_context_refresh( +async def test_context_not_inherited( hass: HomeAssistant, init_components: ConfigEntry, entity: MockAssistSatellite ) -> None: - """Test that the context will be automatically refreshed.""" + """Test that audio from the satellite does not inherit an existing context.""" audio_stream = object() - # Remove context - entity._context = None + # A previous action targeting the entity, such as an announce service call + previous_context = Context(user_id="12345") + entity.async_set_context(previous_context) with patch( "homeassistant.components.assist_satellite.entity.async_pipeline_from_audio_stream" - ): + ) as mock_start_pipeline: await entity.async_accept_pipeline_from_satellite(audio_stream) - # Context should have been refreshed - assert entity._context is not None + # The speaker is unknown, so the pipeline must not run as the previous user + context = mock_start_pipeline.call_args[1]["context"] + assert context is not previous_context + assert context.user_id is None + + # The pipeline drives the entity state from here, so it owns the context + assert entity._context is context async def test_pipeline_entity( @@ -916,6 +920,7 @@ async def test_ask_question( """Test asking a question on a device and matching an answer.""" entity_id = "assist_satellite.test_entity" question_text = "What kind of music would you like to listen to?" + context = Context() await async_update_pipeline( hass, async_get_pipeline(hass), stt_engine="test-stt-engine", stt_language="en" @@ -935,6 +940,8 @@ async def test_ask_question( async def async_start_conversation(start_announcement): # Verify state change assert entity.state == AssistSatelliteState.RESPONDING + # The question is asked on behalf of the caller + assert hass.states.get(entity_id).context is context assert ( start_announcement.preannounce_media_id is not None ) is should_preannounce @@ -982,6 +989,7 @@ async def test_ask_question( {"entity_id": entity_id, "question": question_text, **service_data}, blocking=True, return_response=True, + context=context, ) assert entity.state == AssistSatelliteState.IDLE assert response == asdict(expected_answer) diff --git a/tests/components/aurora_abb_powerone/test_init.py b/tests/components/aurora_abb_powerone/test_init.py index 2797e7bd9840..211b01436ce1 100644 --- a/tests/components/aurora_abb_powerone/test_init.py +++ b/tests/components/aurora_abb_powerone/test_init.py @@ -3,10 +3,13 @@ from unittest.mock import patch from homeassistant.components.aurora_abb_powerone.const import ATTR_FIRMWARE, DOMAIN +from homeassistant.config_entries import ConfigEntryState from homeassistant.const import ATTR_MODEL, ATTR_SERIAL_NUMBER, CONF_ADDRESS, CONF_PORT from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component +from .test_sensor import _simulated_returns + from tests.common import MockConfigEntry @@ -15,6 +18,15 @@ async def test_unload_entry(hass: HomeAssistant) -> None: with ( patch("aurorapy.client.AuroraSerialClient.connect", return_value=None), + patch( + "aurorapy.client.AuroraSerialClient.measure", + side_effect=_simulated_returns, + ), + patch("aurorapy.client.AuroraSerialClient.alarms", return_value=["No alarm"]), + patch( + "aurorapy.client.AuroraSerialClient.cumulated_energy", + side_effect=_simulated_returns, + ), patch( "aurorapy.client.AuroraSerialClient.serial_number", return_value="9876543", @@ -45,5 +57,8 @@ async def test_unload_entry(hass: HomeAssistant) -> None: mock_entry.add_to_hass(hass) assert await async_setup_component(hass, DOMAIN, {}) await hass.async_block_till_done() + assert mock_entry.state is ConfigEntryState.LOADED + assert await hass.config_entries.async_unload(mock_entry.entry_id) await hass.async_block_till_done() + assert mock_entry.state is ConfigEntryState.NOT_LOADED diff --git a/tests/components/aurora_abb_powerone/test_sensor.py b/tests/components/aurora_abb_powerone/test_sensor.py index 2fe1f0c62920..e3abf9ef0f51 100644 --- a/tests/components/aurora_abb_powerone/test_sensor.py +++ b/tests/components/aurora_abb_powerone/test_sensor.py @@ -278,6 +278,7 @@ async def test_sensor_unknown_error( await hass.async_block_till_done() with ( + patch("homeassistant.components.aurora_abb_powerone.coordinator.sleep"), patch("aurorapy.client.AuroraSerialClient.connect", return_value=None), patch( "aurorapy.client.AuroraSerialClient.measure", diff --git a/tests/components/auth/test_indieauth.py b/tests/components/auth/test_indieauth.py index 2a8d6894dc63..653a6b60f3fd 100644 --- a/tests/components/auth/test_indieauth.py +++ b/tests/components/auth/test_indieauth.py @@ -1,8 +1,10 @@ """Tests for the client validator.""" import asyncio +import json from unittest.mock import patch +import aiohttp import pytest from homeassistant.components.auth import indieauth @@ -167,6 +169,440 @@ async def test_find_link_tag_max_size(hass: HomeAssistant, mock_session) -> None assert redirect_uris == ["http://127.0.0.1:8000/wine"] +async def test_find_link_tag_without_href( + hass: HomeAssistant, mock_session: AiohttpClientMocker +) -> None: + """Test a redirect_uri link tag without a usable href is skipped.""" + mock_session.get( + "http://127.0.0.1:8000", + text=""" + + + + + + + + +""", + ) + redirect_uris = await indieauth.fetch_redirect_uris(hass, "http://127.0.0.1:8000") + + assert redirect_uris == ["https://example.com/cb"] + + +async def test_fetch_redirect_uris_metadata_document( + hass: HomeAssistant, mock_session: AiohttpClientMocker +) -> None: + """Test fetching redirect uris from a client id metadata document.""" + mock_session.get( + "https://example.com/client", + text=json.dumps( + { + "client_id": "https://example.com/client", + "redirect_uris": [ + "https://example.com/callback", + "https://other.com/callback", + ], + } + ), + headers={"Content-Type": "application/json"}, + ) + redirect_uris = await indieauth.fetch_redirect_uris( + hass, "https://example.com/client" + ) + + assert redirect_uris == [ + "https://example.com/callback", + "https://other.com/callback", + ] + + +async def test_fetch_redirect_uris_metadata_document_text_plain( + hass: HomeAssistant, mock_session: AiohttpClientMocker +) -> None: + """Test the metadata document is parsed regardless of content type.""" + mock_session.get( + "https://example.com/client", + text=json.dumps( + { + "client_id": "https://example.com/client", + "redirect_uris": ["https://example.com/callback"], + } + ), + headers={"Content-Type": "text/plain"}, + ) + redirect_uris = await indieauth.fetch_redirect_uris( + hass, "https://example.com/client" + ) + + assert redirect_uris == ["https://example.com/callback"] + + +async def test_fetch_redirect_uris_link_tag_precedence( + hass: HomeAssistant, mock_session: AiohttpClientMocker +) -> None: + """Test link tags take precedence over metadata document parsing.""" + mock_session.get( + "http://127.0.0.1:8000", + text=""" + + + + + + + {"redirect_uris": ["https://example.com/should-be-ignored"]} + + +""", + ) + redirect_uris = await indieauth.fetch_redirect_uris(hass, "http://127.0.0.1:8000") + + assert redirect_uris == ["hass://oauth2_redirect"] + + +@pytest.mark.parametrize( + "text", + [ + pytest.param("this is neither json nor html", id="not-json-not-html"), + pytest.param('["https://example.com/callback"]', id="json-array"), + pytest.param("42", id="json-scalar"), + pytest.param( + json.dumps({"redirect_uris": ["https://example.com/callback"]}), + id="missing-client-id", + ), + pytest.param( + json.dumps({"client_id": "https://example.com/client"}), + id="missing-redirect-uris", + ), + pytest.param( + json.dumps( + { + "client_id": "https://example.com/client", + "redirect_uris": [], + } + ), + id="empty-redirect-uris", + ), + pytest.param( + json.dumps( + { + "client_id": "https://other.example/client", + "redirect_uris": ["https://example.com/callback"], + } + ), + id="client-id-mismatch", + ), + pytest.param( + json.dumps( + { + "client_id": "https://example.com/client", + "redirect_uris": "https://example.com/callback", + } + ), + id="redirect-uris-not-list", + ), + pytest.param( + json.dumps( + { + "client_id": "https://example.com/client", + "redirect_uris": ["https://example.com/callback", 123], + } + ), + id="redirect-uris-non-string-entry", + ), + pytest.param( + json.dumps( + { + "client_id": "https://example.com/client", + "redirect_uris": ["/callback"], + } + ), + id="redirect-uris-relative-entry", + ), + pytest.param( + json.dumps( + { + "client_id": "https://example.com/client", + "redirect_uris": ["https://example.com/callback#fragment"], + } + ), + id="redirect-uris-fragment-entry", + ), + pytest.param( + json.dumps( + { + "client_id": "https://example.com/client", + "redirect_uris": ["https://["], + } + ), + id="redirect-uris-unparsable-entry", + ), + pytest.param( + json.dumps( + { + "client_id": "https://example.com/client", + "redirect_uris": ["https://example.com/callback#"], + } + ), + id="redirect-uris-empty-fragment-entry", + ), + pytest.param( + json.dumps( + { + "client_id": "https://example.com/client", + "redirect_uris": ["https://example.com:not-a-port/callback"], + } + ), + id="redirect-uris-invalid-port-entry", + ), + pytest.param( + '{"client_id": "https://example.com/client",' + ' "redirect_uris": ["https://example.com/callback"], "x": NaN}', + id="json-nan-constant", + ), + ], +) +async def test_fetch_redirect_uris_metadata_document_invalid( + hass: HomeAssistant, mock_session: AiohttpClientMocker, text: str +) -> None: + """Test that invalid metadata documents yield no redirect uris.""" + mock_session.get( + "https://example.com/client", + text=text, + headers={"Content-Type": "application/json"}, + ) + + assert await indieauth.fetch_redirect_uris(hass, "https://example.com/client") == [] + assert not await indieauth.verify_redirect_uri( + hass, "https://example.com/client", "https://other.com/callback" + ) + + +async def test_verify_redirect_uri_metadata_document( + hass: HomeAssistant, mock_session: AiohttpClientMocker +) -> None: + """Test verifying a cross-origin redirect uri from a metadata document.""" + client_id = "https://example.com/client" + mock_session.get( + client_id, + text=json.dumps( + { + "client_id": client_id, + "redirect_uris": ["https://other.com/callback"], + } + ), + headers={"Content-Type": "application/json"}, + ) + + assert await indieauth.verify_redirect_uri( + hass, client_id, "https://other.com/callback" + ) + + assert not await indieauth.verify_redirect_uri( + hass, client_id, "https://other.com/not-listed" + ) + + +async def test_verify_redirect_uri_unparsable(hass: HomeAssistant) -> None: + """Test an unparsable requested redirect uri is rejected without raising.""" + assert not await indieauth.verify_redirect_uri( + hass, "https://example.com/client", "https://[" + ) + + +async def test_fetch_redirect_uris_metadata_document_invalid_utf8( + hass: HomeAssistant, mock_session: AiohttpClientMocker +) -> None: + """Test a metadata document with invalid UTF-8 is rejected.""" + mock_session.get( + "https://example.com/client", + content=( + b'{"client_id": "https://example.com/client",' + b' "redirect_uris": ["https://other.com/callback"], "note": "\xff"}' + ), + headers={"Content-Type": "application/json"}, + ) + + assert await indieauth.fetch_redirect_uris(hass, "https://example.com/client") == [] + + +@pytest.mark.parametrize( + "client_id", + [ + pytest.param("https://example.com", id="no-path"), + pytest.param("https://example.com/client#", id="empty-fragment"), + ], +) +async def test_fetch_redirect_uris_metadata_document_invalid_client_id( + hass: HomeAssistant, mock_session: AiohttpClientMocker, client_id: str +) -> None: + """Test client ids violating the metadata document URL rules are ignored.""" + mock_session.get( + client_id, + text=json.dumps( + { + "client_id": client_id, + "redirect_uris": ["https://other.com/callback"], + } + ), + headers={"Content-Type": "application/json"}, + ) + + assert await indieauth.fetch_redirect_uris(hass, client_id) == [] + + +async def test_fetch_redirect_uris_metadata_document_not_ok( + hass: HomeAssistant, mock_session: AiohttpClientMocker +) -> None: + """Test a metadata document not served with 200 OK is ignored.""" + mock_session.get( + "https://example.com/client", + text=json.dumps( + { + "client_id": "https://example.com/client", + "redirect_uris": ["https://example.com/callback"], + } + ), + status=404, + headers={"Content-Type": "application/json"}, + ) + + assert await indieauth.fetch_redirect_uris(hass, "https://example.com/client") == [] + + +async def test_fetch_redirect_uris_metadata_document_http_scheme( + hass: HomeAssistant, mock_session: AiohttpClientMocker +) -> None: + """Test a metadata document served over http is ignored.""" + client_id = "http://example.com/client" + mock_session.get( + client_id, + text=json.dumps( + { + "client_id": client_id, + "redirect_uris": ["https://other.com/callback"], + } + ), + headers={"Content-Type": "application/json"}, + ) + + assert await indieauth.fetch_redirect_uris(hass, client_id) == [] + assert not await indieauth.verify_redirect_uri( + hass, client_id, "https://other.com/callback" + ) + + +async def test_fetch_redirect_uris_metadata_document_redirected( + hass: HomeAssistant, mock_session: AiohttpClientMocker +) -> None: + """Test a metadata document reached via a redirect is ignored.""" + mock_session.get( + "https://example.com/client", + text=json.dumps( + { + "client_id": "https://example.com/client", + "redirect_uris": ["https://example.com/callback"], + } + ), + headers={"Content-Type": "application/json"}, + history=(object(),), + ) + + assert await indieauth.fetch_redirect_uris(hass, "https://example.com/client") == [] + + +async def test_fetch_redirect_uris_metadata_document_private_use_scheme( + hass: HomeAssistant, mock_session: AiohttpClientMocker +) -> None: + """Test a private-use scheme redirect uri is accepted as an absolute URI.""" + mock_session.get( + "https://example.com/client", + text=json.dumps( + { + "client_id": "https://example.com/client", + "redirect_uris": ["app:/oauth-callback"], + } + ), + headers={"Content-Type": "application/json"}, + ) + + assert await indieauth.fetch_redirect_uris(hass, "https://example.com/client") == [ + "app:/oauth-callback" + ] + + +async def test_fetch_redirect_uris_metadata_document_oversized( + hass: HomeAssistant, mock_session: AiohttpClientMocker +) -> None: + """Test a document past the 10kB cap is rejected as an incomplete read.""" + mock_session.get( + "https://example.com/client", + text=json.dumps( + { + "client_id": "https://example.com/client", + "redirect_uris": ["https://example.com/callback"], + "padding": "x" * 11000, + } + ), + headers={"Content-Type": "application/json"}, + ) + + assert await indieauth.fetch_redirect_uris(hass, "https://example.com/client") == [] + + +async def test_fetch_redirect_uris_metadata_document_exactly_at_cap( + hass: HomeAssistant, mock_session: AiohttpClientMocker +) -> None: + """Test a document of exactly the read cap is rejected as possibly truncated.""" + document = { + "client_id": "https://example.com/client", + "redirect_uris": ["https://other.com/callback"], + "padding": "", + } + document["padding"] = "x" * (10240 - len(json.dumps(document))) + text = json.dumps(document) + assert len(text) == 10240 + + mock_session.get( + "https://example.com/client", + text=text, + headers={"Content-Type": "application/json"}, + ) + + assert await indieauth.fetch_redirect_uris(hass, "https://example.com/client") == [] + + +async def test_fetch_redirect_uris_metadata_document_at_cap_ineligible( + hass: HomeAssistant, mock_session: AiohttpClientMocker +) -> None: + """Test a valid document that reaches the 10kB cap is ineligible.""" + mock_session.get( + "https://example.com/client", + text=json.dumps( + { + "client_id": "https://example.com/client", + "redirect_uris": [ + f"https://example.com/callback/{index}" for index in range(400) + ], + } + ), + headers={"Content-Type": "application/json"}, + ) + + assert await indieauth.fetch_redirect_uris(hass, "https://example.com/client") == [] + + +async def test_fetch_redirect_uris_network_error( + hass: HomeAssistant, mock_session: AiohttpClientMocker +) -> None: + """Test a network error yields no redirect uris without raising.""" + mock_session.get("https://example.com/client", exc=aiohttp.ClientError()) + + assert await indieauth.fetch_redirect_uris(hass, "https://example.com/client") == [] + + @pytest.mark.parametrize( "client_id", ["https://home-assistant.io/android", "https://home-assistant.io/iOS"], diff --git a/tests/components/blue_current/test_init.py b/tests/components/blue_current/test_init.py index 3b2dec064c99..4225177cfd5e 100644 --- a/tests/components/blue_current/test_init.py +++ b/tests/components/blue_current/test_init.py @@ -119,7 +119,7 @@ async def test_start_charging_action( DOMAIN, SERVICE_START_CHARGE_SESSION, { - CONF_DEVICE_ID: list(device_registry.devices)[0], + CONF_DEVICE_ID: list(device_registry._devices)[0], CHARGING_CARD_ID: "TEST_CARD", }, blocking=True, @@ -139,7 +139,7 @@ async def test_start_charging_action_without_card( DOMAIN, SERVICE_START_CHARGE_SESSION, { - CONF_DEVICE_ID: list(device_registry.devices)[0], + CONF_DEVICE_ID: list(device_registry._devices)[0], }, blocking=True, ) @@ -187,7 +187,7 @@ async def test_start_charging_action_errors( DOMAIN, SERVICE_START_CHARGE_SESSION, { - CONF_DEVICE_ID: list(device_registry.devices)[0], + CONF_DEVICE_ID: list(device_registry._devices)[0], }, blocking=True, ) @@ -207,7 +207,7 @@ async def test_start_charging_action_errors( DOMAIN, SERVICE_START_CHARGE_SESSION, { - CONF_DEVICE_ID: list(device_registry.devices)[0], + CONF_DEVICE_ID: list(device_registry._devices)[0], }, blocking=True, ) diff --git a/tests/components/bluetooth/test_diagnostics.py b/tests/components/bluetooth/test_diagnostics.py index a0748faf9d37..a76ee7ee3283 100644 --- a/tests/components/bluetooth/test_diagnostics.py +++ b/tests/components/bluetooth/test_diagnostics.py @@ -565,6 +565,14 @@ async def test_diagnostics_remote_adapter( "slots": 5, "source": "00:00:00:00:00:01", }, + # Registering a connectable scanner seeds a zeroed + # entry (slots=0 means no slot info reported yet). + "esp32": { + "allocated": [], + "free": 0, + "slots": 0, + "source": "esp32", + }, }, "adapters": { "hci0": { diff --git a/tests/components/braviatv/test_config_flow.py b/tests/components/braviatv/test_config_flow.py index 68dd31af6f70..8ef581e86816 100644 --- a/tests/components/braviatv/test_config_flow.py +++ b/tests/components/braviatv/test_config_flow.py @@ -195,7 +195,14 @@ async def test_ssdp_discovery_exist(hass: HomeAssistant) -> None: async def test_user_invalid_host(hass: HomeAssistant) -> None: """Test that errors are shown when the host is invalid.""" result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": SOURCE_USER}, data={CONF_HOST: "invalid/host"} + 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_HOST: "invalid/host"} ) assert result["errors"] == {CONF_HOST: "invalid_host"} @@ -219,7 +226,13 @@ async def test_pin_form_error(hass: HomeAssistant, side_effect, error_message) - patch("pybravia.BraviaClient.pair"), ): result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": SOURCE_USER}, data={CONF_HOST: "bravia-host"} + 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_HOST: "bravia-host"} ) result = await hass.config_entries.flow.async_configure( result["flow_id"], user_input={CONF_USE_PSK: False} @@ -246,7 +259,13 @@ async def test_psk_form_error(hass: HomeAssistant, side_effect, error_message) - side_effect=side_effect, ): result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": SOURCE_USER}, data={CONF_HOST: "bravia-host"} + 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_HOST: "bravia-host"} ) result = await hass.config_entries.flow.async_configure( result["flow_id"], user_input={CONF_USE_PSK: True} @@ -262,7 +281,13 @@ async def test_no_ip_control(hass: HomeAssistant) -> None: """Test that error are shown when IP Control is disabled on the TV.""" with patch("pybravia.BraviaClient.pair", side_effect=BraviaError): result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": SOURCE_USER}, data={CONF_HOST: "bravia-host"} + 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_HOST: "bravia-host"} ) result = await hass.config_entries.flow.async_configure( result["flow_id"], user_input={CONF_USE_PSK: False} @@ -296,7 +321,13 @@ async def test_duplicate_error(hass: HomeAssistant) -> None: ), ): result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": SOURCE_USER}, data={CONF_HOST: "bravia-host"} + 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_HOST: "bravia-host"} ) result = await hass.config_entries.flow.async_configure( result["flow_id"], user_input={CONF_USE_PSK: False} @@ -332,7 +363,13 @@ async def test_create_entry(hass: HomeAssistant, use_psk, use_ssl) -> None: ), ): result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": SOURCE_USER}, data={CONF_HOST: "bravia-host"} + 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_HOST: "bravia-host"} ) assert result["type"] is FlowResultType.FORM diff --git a/tests/components/braviatv/test_coordinator.py b/tests/components/braviatv/test_coordinator.py new file mode 100644 index 000000000000..4af24a8802ac --- /dev/null +++ b/tests/components/braviatv/test_coordinator.py @@ -0,0 +1,48 @@ +"""Test the BraviaTV coordinator.""" + +from datetime import UTC, datetime +from unittest.mock import AsyncMock + +import pytest + +from homeassistant.components.braviatv.const import CONF_USE_PSK, DOMAIN +from homeassistant.components.braviatv.coordinator import BraviaTVCoordinator +from homeassistant.const import CONF_HOST, CONF_MAC, CONF_PIN +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry + + +@pytest.mark.parametrize( + "start_datetime", + [ + "2026-08-22T12:00:00", # naive, treated as local time (CEST UTC+2) + "2026-08-22T12:00:00+02:00", # aware + ], +) +@pytest.mark.freeze_time("2026-08-22T12:00:00+00:00") +async def test_async_update_playing( + hass: HomeAssistant, + start_datetime: str, +) -> None: + """Test updating playing info with a start datetime.""" + await hass.config.async_set_time_zone("Europe/Warsaw") + config_entry = MockConfigEntry( + domain=DOMAIN, + data={ + CONF_HOST: "localhost", + CONF_MAC: "AA:BB:CC:DD:EE:FF", + CONF_USE_PSK: True, + CONF_PIN: "12345qwerty", + }, + ) + client = AsyncMock() + client.get_playing_info.return_value = {"startDateTime": start_datetime} + coordinator = BraviaTVCoordinator(hass, config_entry, client) + + await coordinator.async_update_playing() + + assert coordinator.media_position == 7200 + assert coordinator.media_position_updated_at == datetime( + 2026, 8, 22, 12, 0, 0, tzinfo=UTC + ) diff --git a/tests/components/brother/test_config_flow.py b/tests/components/brother/test_config_flow.py index 41d2743263a4..475321176fb2 100644 --- a/tests/components/brother/test_config_flow.py +++ b/tests/components/brother/test_config_flow.py @@ -149,7 +149,15 @@ async def test_unsupported_model_error( """Test unsupported printer model error.""" mock_brother.create.side_effect = UnsupportedModelError("error") result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": SOURCE_USER}, data=CONFIG + 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"], + CONFIG, ) assert result["type"] is FlowResultType.ABORT @@ -165,7 +173,15 @@ async def test_device_exists_abort( await init_integration(hass, mock_config_entry) result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": SOURCE_USER}, data=CONFIG + 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"], + CONFIG, ) assert result["type"] is FlowResultType.ABORT diff --git a/tests/components/bryant_evolution/test_init.py b/tests/components/bryant_evolution/test_init.py index 4c0246a7c0ad..6b03382bb8b2 100644 --- a/tests/components/bryant_evolution/test_init.py +++ b/tests/components/bryant_evolution/test_init.py @@ -95,7 +95,7 @@ async def test_setup_multiple_systems_zones( device_registry = dr.async_get(hass) # pylint: disable=home-assistant-tests-registry-fixtures def find_device(name): - return next(filter(lambda x: x.name == name, device_registry.devices.values())) + return next(filter(lambda x: x.name == name, device_registry.devices)) sam = find_device("System Access Module") s1 = find_device("System 1") diff --git a/tests/components/buienradar/test_util.py b/tests/components/buienradar/test_util.py new file mode 100644 index 000000000000..9997173310f4 --- /dev/null +++ b/tests/components/buienradar/test_util.py @@ -0,0 +1,95 @@ +"""Tests for the Buienradar utilities.""" + +import datetime +from http import HTTPStatus +from unittest.mock import patch + +from buienradar.constants import MESSAGE, SUCCESS +from freezegun.api import FrozenDateTimeFactory +import pytest + +from homeassistant.components.buienradar.const import DOMAIN +from homeassistant.const import CONF_LATITUDE, CONF_LONGITUDE +from homeassistant.core import HomeAssistant +from homeassistant.util import dt as dt_util + +from tests.common import MockConfigEntry, async_fire_time_changed +from tests.test_util.aiohttp import AiohttpClientMocker + +TEST_LATITUDE = 51.5 +TEST_LONGITUDE = 5.5 +TEST_CFG_DATA = {CONF_LATITUDE: TEST_LATITUDE, CONF_LONGITUDE: TEST_LONGITUDE} + +WARNING = "Unable to parse data from Buienradar" + + +@pytest.mark.parametrize( + ("update_at", "expect_warning"), + [ + ("2026-01-14T23:00:00+00:00", False), + ("2026-01-14T23:59:59+00:00", False), + ("2026-01-15T00:00:00+00:00", True), + ("2026-07-14T22:30:00+00:00", False), + ("2026-01-15T06:30:00+00:00", True), + ], + ids=[ + "cet_0000_start_of_quiet_hour", + "cet_0059_end_of_quiet_hour", + "cet_0100_just_after", + "cest_0030_quiet_hour_in_dst", + "cet_0730_quiet_only_where_user_lives", + ], +) +async def test_unparsable_data_is_quiet_during_the_midnight_hour( + hass: HomeAssistant, + aioclient_mock: AiohttpClientMocker, + freezer: FrozenDateTimeFactory, + caplog: pytest.LogCaptureFixture, + update_at: str, + expect_warning: bool, +) -> None: + """Test the parse failure warning is suppressed in the midnight hour. + + buienradar.nl serves no data while it updates its forecast between 00:00 and + 01:00 CE(S)T, so the warning is only interesting outside that hour. The hour + that decides this belongs to the service, so the configured time zone here is + deliberately somewhere else: America/Regina is UTC-6 with no DST, which puts + every case below in a different hour locally than in Amsterdam. + + The times are UTC. The first three pin the edges of the quiet hour in CET, + the fourth repeats it in CEST so the offset is not assumed, and the last one + is the quiet hour in America/Regina rather than in Amsterdam, so it must + still warn. + """ + await hass.config.async_set_time_zone("America/Regina") + aioclient_mock.get( + "https://data.buienradar.nl/2.0/feed/json", status=HTTPStatus.OK, text="{}" + ) + aioclient_mock.get( + f"https://gps.buienradar.nl/getrr.php?lat={TEST_LATITUDE}&lon={TEST_LONGITUDE}", + status=HTTPStatus.OK, + text="", + ) + + update = dt_util.parse_datetime(update_at) + assert update is not None + # A failed update reschedules itself two minutes later, which is the update + # the assertion below is about. + freezer.move_to(update - datetime.timedelta(minutes=2)) + + entry = MockConfigEntry(domain=DOMAIN, unique_id="TEST_ID", data=TEST_CFG_DATA) + entry.add_to_hass(hass) + + with patch( + "homeassistant.components.buienradar.util.parse_data", + return_value={SUCCESS: False, MESSAGE: "no data"}, + ): + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + caplog.clear() + freezer.move_to(update) + async_fire_time_changed(hass, dt_util.utcnow()) + await hass.async_block_till_done() + + assert (WARNING in caplog.text) is expect_warning diff --git a/tests/components/caldav/test_init.py b/tests/components/caldav/test_init.py index 543446b146f9..aefe6a73ecf3 100644 --- a/tests/components/caldav/test_init.py +++ b/tests/components/caldav/test_init.py @@ -1,12 +1,14 @@ """Unit tests for the CalDav integration.""" -from unittest.mock import patch +import logging +from unittest.mock import MagicMock, Mock, patch from caldav.lib.error import AuthorizationError, DAVError import pytest import requests from homeassistant.config_entries import ConfigEntryState +from homeassistant.const import Platform from homeassistant.core import HomeAssistant from tests.common import MockConfigEntry @@ -71,3 +73,40 @@ async def test_client_failure( flows = hass.config_entries.flow.async_progress() assert [flow.get("step_id") for flow in flows] == expected_flows + + +@pytest.fixture(name="calendars") +def mock_unsupported_calendar() -> list[Mock]: + """Fixture for a calendar that does not report its supported components.""" + calendar = Mock() + calendar.name = "Example" + calendar.search = MagicMock(return_value=[]) + calendar.get_supported_components = MagicMock(side_effect=KeyError()) + return [calendar] + + +@pytest.mark.parametrize("platforms", [[Platform.CALENDAR]]) +async def test_supported_components_warning_survives_reload( + hass: HomeAssistant, + config_entry: MockConfigEntry, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test the unsupported-components warning is not repeated after a reload. + + The de-duplication cache is per CalDAV server rather than per config entry, + so reloading the entry must not warn about the same calendar again. + """ + caplog.set_level(logging.WARNING, logger="homeassistant.components.caldav.api") + + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + assert config_entry.state is ConfigEntryState.LOADED + assert "does not report supported components" in caplog.text + + caplog.clear() + await hass.config_entries.async_reload(config_entry.entry_id) + await hass.async_block_till_done() + + assert config_entry.state is ConfigEntryState.LOADED + assert "does not report supported components" not in caplog.text diff --git a/tests/components/centriconnect/test_init.py b/tests/components/centriconnect/test_init.py new file mode 100644 index 000000000000..02b413bd967b --- /dev/null +++ b/tests/components/centriconnect/test_init.py @@ -0,0 +1,27 @@ +"""Tests for the CentriConnect/MyPropane configuration initialization.""" + +from unittest.mock import AsyncMock + +from aiocentriconnect.exceptions import CentriConnectConnectionError + +from homeassistant.config_entries import ConfigEntryState +from homeassistant.core import HomeAssistant + +from . import setup_integration + +from tests.common import MockConfigEntry + + +async def test_config_entry_not_ready( + hass: HomeAssistant, + mock_centriconnect_client: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test config entry not ready.""" + mock_centriconnect_client.async_get_tank_data.side_effect = ( + CentriConnectConnectionError + ) + await setup_integration(hass, mock_config_entry) + + assert mock_config_entry.state is ConfigEntryState.SETUP_RETRY + mock_centriconnect_client.async_get_tank_data.side_effect = None diff --git a/tests/components/chacon_dio/test_config_flow.py b/tests/components/chacon_dio/test_config_flow.py index cd6d1939008d..9ed646092bc5 100644 --- a/tests/components/chacon_dio/test_config_flow.py +++ b/tests/components/chacon_dio/test_config_flow.py @@ -28,9 +28,15 @@ async def test_full_flow( assert not result["errors"] result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": SOURCE_USER}, - data={ + 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_USERNAME: "dummylogin", CONF_PASSWORD: "dummypass", }, @@ -64,9 +70,15 @@ async def test_errors( mock_dio_chacon_client.get_user_id.side_effect = exception result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": SOURCE_USER}, - data={ + 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_USERNAME: "nada", CONF_PASSWORD: "nadap", }, diff --git a/tests/components/cielo_home/test_config_flow.py b/tests/components/cielo_home/test_config_flow.py index d678b4ff9d10..053a61b9f31d 100644 --- a/tests/components/cielo_home/test_config_flow.py +++ b/tests/components/cielo_home/test_config_flow.py @@ -27,6 +27,7 @@ def _devices_payload(parsed: dict | None) -> MagicMock: return payload +@pytest.mark.usefixtures("mock_setup_entry") async def test_full_config_flow_success(hass: HomeAssistant) -> None: """Test successful config flow with valid API key.""" mock_client = MagicMock() @@ -89,6 +90,7 @@ async def test_full_config_flow_abort_already_configured( (Exception, "unknown"), ], ) +@pytest.mark.usefixtures("mock_setup_entry") async def test_form_error_mapping( hass: HomeAssistant, api_error: type[Exception], flow_error_key: str ) -> None: @@ -127,6 +129,7 @@ async def test_form_error_mapping( assert result3["type"] is FlowResultType.CREATE_ENTRY +@pytest.mark.usefixtures("mock_setup_entry") async def test_form_error_mapping_invalid_auth(hass: HomeAssistant) -> None: """Test AuthenticationError maps to invalid_auth.""" diff --git a/tests/components/climate/test_device_action.py b/tests/components/climate/test_device_action.py index 9c22e3b6b4d8..852b0a6e2528 100644 --- a/tests/components/climate/test_device_action.py +++ b/tests/components/climate/test_device_action.py @@ -68,7 +68,9 @@ async def test_get_actions( ) if set_state: hass.states.async_set( - f"{DOMAIN}.test_5678", "attributes", {"supported_features": features_state} + entity_entry.entity_id, + "attributes", + {"supported_features": features_state}, ) expected_actions = [] @@ -380,7 +382,7 @@ async def test_capabilities( ) if set_state: hass.states.async_set( - f"{DOMAIN}.test_5678", + entity_entry.entity_id, HVACMode.COOL, capabilities_state, ) @@ -498,7 +500,7 @@ async def test_capabilities_legacy( ) if set_state: hass.states.async_set( - f"{DOMAIN}.test_5678", + entity_entry.entity_id, HVACMode.COOL, capabilities_state, ) diff --git a/tests/components/climate/test_device_condition.py b/tests/components/climate/test_device_condition.py index fedf22a90f51..bd1f5f080834 100644 --- a/tests/components/climate/test_device_condition.py +++ b/tests/components/climate/test_device_condition.py @@ -64,7 +64,9 @@ async def test_get_conditions( ) if set_state: hass.states.async_set( - f"{DOMAIN}.test_5678", "attributes", {"supported_features": features_state} + entity_entry.entity_id, + "attributes", + {"supported_features": features_state}, ) expected_conditions = [] expected_conditions += [ diff --git a/tests/components/cloud/test_alexa_config.py b/tests/components/cloud/test_alexa_config.py index 8ab862eef952..19e3741d1df5 100644 --- a/tests/components/cloud/test_alexa_config.py +++ b/tests/components/cloud/test_alexa_config.py @@ -905,3 +905,56 @@ async def test_alexa_config_migrate_expose_entity_prefs_default( assert async_get_entity_settings(hass, water_heater.entity_id) == { "cloud.alexa": {"should_expose": False} } + + +@pytest.mark.parametrize( + "lib_exception", + [ + pytest.param( + AlexaApiNeedsRelinkError("RefreshTokenNotFound"), id="needs_relink" + ), + pytest.param(AlexaApiNoTokenError("OtherReason"), id="no_token"), + ], +) +async def test_alexa_config_prefs_update_without_linked_skill( + hass: HomeAssistant, + cloud_prefs: CloudPreferences, + entity_registry: er.EntityRegistry, + caplog: pytest.LogCaptureFixture, + lib_exception: Exception, +) -> None: + """Test updating prefs when the Alexa skill was never linked. + + A freshly registered account has no Alexa refresh token, so syncing + entities must not raise out of the preferences listener. + """ + assert await async_setup_component(hass, "homeassistant", {}) + expose_new(hass, True) + entity_entry = entity_registry.async_get_or_create( + "fan", "test", "unique", suggested_object_id="test_fan" + ) + hass.states.async_set(entity_entry.entity_id, "off") + + await cloud_prefs.async_update(alexa_enabled=False, alexa_report_state=False) + conf = alexa_config.CloudAlexaConfig( + hass, + ALEXA_SCHEMA({}), + "mock-user-id", + cloud_prefs, + Mock( + servicehandlers_server="example", + auth=Mock(async_check_token=AsyncMock()), + websession=async_get_clientsession(hass), + alexa_api=Mock(access_token=AsyncMock(side_effect=lib_exception)), + ), + ) + await conf.async_initialize() + await conf.set_authorized(True) + assert conf.authorized is True + + await cloud_prefs.async_update(alexa_enabled=True) + await hass.async_block_till_done() + + # The sync could not authenticate, so the skill is marked as needing a relink. + assert conf.authorized is False + assert "RequireRelink" not in caplog.text diff --git a/tests/components/cloud/test_http_api.py b/tests/components/cloud/test_http_api.py index 6d3d588027ae..94bead299507 100644 --- a/tests/components/cloud/test_http_api.py +++ b/tests/components/cloud/test_http_api.py @@ -10,7 +10,7 @@ from unittest.mock import AsyncMock, MagicMock, Mock, PropertyMock, patch import aiohttp from freezegun.api import FrozenDateTimeFactory -from hass_nabucasa import AlreadyConnectedError +from hass_nabucasa import AlreadyConnectedError, AuthTimeoutError from hass_nabucasa.auth import ( InvalidTotpCode, MFARequired, @@ -388,6 +388,24 @@ async def test_login_view_request_timeout( assert req.status == HTTPStatus.BAD_GATEWAY +async def test_login_view_request_auth_timeout( + cloud: MagicMock, + setup_cloud: None, + hass_client: ClientSessionGenerator, +) -> None: + """Test authentication timeout while trying to log in.""" + cloud_client = await hass_client() + cloud.login.side_effect = AuthTimeoutError + + req = await cloud_client.post( + "/api/cloud/login", json={"email": "my_username", "password": "my_password"} + ) + + assert cloud.login.call_args[1]["check_connection"] is False + + assert req.status == HTTPStatus.GATEWAY_TIMEOUT + + async def test_login_view_with_already_existing_connection( cloud: MagicMock, setup_cloud: None, diff --git a/tests/components/concord232/test_alarm_control_panel.py b/tests/components/concord232/test_alarm_control_panel.py index d10e13070a80..4af0fd882ee5 100644 --- a/tests/components/concord232/test_alarm_control_panel.py +++ b/tests/components/concord232/test_alarm_control_panel.py @@ -237,7 +237,7 @@ async def test_update_state_armed( # Trigger update freezer.tick(10) async_fire_time_changed(hass) - await hass.async_block_till_done() + await hass.async_block_till_done(wait_background_tasks=True) state = hass.states.get("alarm_control_panel.test_alarm") assert state.state == expected_state @@ -259,7 +259,7 @@ async def test_update_connection_error( freezer.tick(10) async_fire_time_changed(hass) - await hass.async_block_till_done() + await hass.async_block_till_done(wait_background_tasks=True) assert "Unable to connect to" in caplog.text diff --git a/tests/components/concord232/test_binary_sensor.py b/tests/components/concord232/test_binary_sensor.py index 8a226fd41e09..f5e9e47ccbb4 100644 --- a/tests/components/concord232/test_binary_sensor.py +++ b/tests/components/concord232/test_binary_sensor.py @@ -153,11 +153,11 @@ async def test_zone_update_refresh( freezer.tick(datetime.timedelta(seconds=10)) async_fire_time_changed(hass) - await hass.async_block_till_done() + await hass.async_block_till_done(wait_background_tasks=True) freezer.tick(datetime.timedelta(seconds=10)) async_fire_time_changed(hass) - await hass.async_block_till_done() + await hass.async_block_till_done(wait_background_tasks=True) state = hass.states.get("binary_sensor.zone_1") assert state.state == "on" diff --git a/tests/components/config/test_device_registry.py b/tests/components/config/test_device_registry.py index 02040d1b49ea..a3a5227bc9f9 100644 --- a/tests/components/config/test_device_registry.py +++ b/tests/components/config/test_device_registry.py @@ -388,6 +388,82 @@ async def test_update_device_labels( assert getattr(device, key) == value +async def test_update_device_unknown_device( + hass: HomeAssistant, + client: MockHAClientWebSocket, +) -> None: + """Test updating an unknown device returns an error.""" + await client.send_json_auto_id( + { + "type": "config/device_registry/update", + "device_id": "does_not_exist", + "name_by_user": "Test Friendly Name", + } + ) + msg = await client.receive_json() + + assert not msg["success"] + assert msg["error"]["code"] == "not_found" + assert msg["error"]["message"] == "Device not found" + + +@pytest.mark.parametrize("load_registries", [False]) +async def test_update_device_composite( + hass: HomeAssistant, + client: MockHAClientWebSocket, + hass_storage: dict[str, Any], +) -> None: + """Test updating a pre-migration composite device id is rejected.""" + entry_1 = MockConfigEntry() + entry_1.add_to_hass(hass) + entry_2 = MockConfigEntry() + entry_2.add_to_hass(hass) + + composite_id = "compositea000000000000000000000" + hass_storage[dr.STORAGE_KEY] = { + "version": 1, + "minor_version": 12, + "key": dr.STORAGE_KEY, + "data": { + "devices": [ + # Composite spanning two config entries; splitting it on load removes + # the composite device, so composite_id no longer refers to a device + _storage_device_v1_12( + composite_id, + [entry_1.entry_id, entry_2.entry_id], + entry_1.entry_id, + "a", + ), + ], + "deleted_devices": [], + }, + } + + dr.async_setup(hass) + await dr.async_load(hass) + # pylint: disable-next=home-assistant-tests-registry-fixtures + registry = dr.async_get(hass) + assert registry.async_get(composite_id) is not None + assert registry.async_get(composite_id, include_composite_devices=False) is None + + await client.send_json_auto_id( + { + "type": "config/device_registry/update", + "device_id": composite_id, + "name_by_user": "Test Friendly Name", + } + ) + msg = await client.receive_json() + + assert not msg["success"] + assert msg["error"]["code"] == "not_allowed" + assert msg["error"]["message"] == "Cannot update a composite device" + + # The update was not fanned out to the underlying split devices + for split in registry.async_get_devices_for_composite_device_id(composite_id): + assert split.name_by_user is None + + _DEPRECATION_WARNING = ( "The websocket command config/device_registry/remove_config_entry is " "deprecated and will be removed in Home Assistant 2027.9" @@ -737,7 +813,8 @@ async def test_remove_device_composite( await dr.async_load(hass) # pylint: disable-next=home-assistant-tests-registry-fixtures registry = dr.async_get(hass) - assert registry.async_is_composite_device_id(composite_id) is True + assert registry.async_get(composite_id) is not None + assert registry.async_get(composite_id, include_composite_devices=False) is None response = await _send_remove_device( client, command, composite_id, entry_1.entry_id diff --git a/tests/components/cover/test_device_condition.py b/tests/components/cover/test_device_condition.py index ebb893835e89..96d101bae9de 100644 --- a/tests/components/cover/test_device_condition.py +++ b/tests/components/cover/test_device_condition.py @@ -82,7 +82,7 @@ async def test_get_conditions( ) if set_state: hass.states.async_set( - f"{DOMAIN}.test_5678", "attributes", {"supported_features": features_state} + entity_entry.entity_id, "attributes", {"supported_features": features_state} ) await hass.async_block_till_done() diff --git a/tests/components/deconz/test_services.py b/tests/components/deconz/test_services.py index 0ebb4bb191d3..66d284d0418e 100644 --- a/tests/components/deconz/test_services.py +++ b/tests/components/deconz/test_services.py @@ -363,7 +363,7 @@ async def test_remove_orphaned_entries_service( len( [ entry - for entry in device_registry.devices.values() + for entry in device_registry.devices if config_entry_setup.entry_id in entry.config_entries ] ) @@ -399,7 +399,7 @@ async def test_remove_orphaned_entries_service( len( [ entry - for entry in device_registry.devices.values() + for entry in device_registry.devices if config_entry_setup.entry_id in entry.config_entries ] ) diff --git a/tests/components/derivative/test_diagnostics.py b/tests/components/derivative/test_diagnostics.py index 98ceaba1c55d..258affe48711 100644 --- a/tests/components/derivative/test_diagnostics.py +++ b/tests/components/derivative/test_diagnostics.py @@ -21,4 +21,4 @@ async def test_diagnostics( assert isinstance(result, dict) assert result["config_entry"]["domain"] == "derivative" assert result["config_entry"]["options"]["name"] == "My derivative" - assert result["entity"][0]["entity_id"] == "sensor.my_derivative" + assert result["entity"][0]["entity_id"] == "sensor.mock_title_my_derivative" diff --git a/tests/components/derivative/test_init.py b/tests/components/derivative/test_init.py index b852340f48c4..3161e612382b 100644 --- a/tests/components/derivative/test_init.py +++ b/tests/components/derivative/test_init.py @@ -97,7 +97,9 @@ async def test_async_handle_source_entity_changes_source_entity_removed( assert await hass.config_entries.async_setup(derivative_config_entry.entry_id) await hass.async_block_till_done() - derivative_entity_entry = entity_registry.async_get("sensor.my_derivative") + derivative_entity_entry = entity_registry.async_get( + "sensor.mock_title_my_derivative" + ) assert derivative_entity_entry.device_id == sensor_entity_entry.device_id sensor_device = device_registry.async_get(sensor_device.id) @@ -116,7 +118,9 @@ async def test_async_handle_source_entity_changes_source_entity_removed( mock_unload_entry.assert_not_called() # Check that the entity is no longer linked to the source device - derivative_entity_entry = entity_registry.async_get("sensor.my_derivative") + derivative_entity_entry = entity_registry.async_get( + "sensor.mock_title_my_derivative" + ) assert derivative_entity_entry.device_id is None # Check that the device is removed @@ -141,7 +145,9 @@ async def test_async_handle_source_entity_changes_source_entity_removed_shared_d assert await hass.config_entries.async_setup(derivative_config_entry.entry_id) await hass.async_block_till_done() - derivative_entity_entry = entity_registry.async_get("sensor.my_derivative") + derivative_entity_entry = entity_registry.async_get( + "sensor.mock_title_my_derivative" + ) assert derivative_entity_entry.device_id == sensor_entity_entry.device_id sensor_device = device_registry.async_get(sensor_device.id) @@ -160,7 +166,9 @@ async def test_async_handle_source_entity_changes_source_entity_removed_shared_d mock_unload_entry.assert_not_called() # Check that the entity is no longer linked to the source device - derivative_entity_entry = entity_registry.async_get("sensor.my_derivative") + derivative_entity_entry = entity_registry.async_get( + "sensor.mock_title_my_derivative" + ) assert derivative_entity_entry.device_id is None # Check that the source device is not removed @@ -187,7 +195,9 @@ async def test_async_handle_source_entity_changes_source_entity_removed_from_dev assert await hass.config_entries.async_setup(derivative_config_entry.entry_id) await hass.async_block_till_done() - derivative_entity_entry = entity_registry.async_get("sensor.my_derivative") + derivative_entity_entry = entity_registry.async_get( + "sensor.mock_title_my_derivative" + ) assert derivative_entity_entry.device_id == sensor_entity_entry.device_id sensor_device = device_registry.async_get(sensor_device.id) @@ -207,7 +217,9 @@ async def test_async_handle_source_entity_changes_source_entity_removed_from_dev mock_unload_entry.assert_called_once() # Check that the entity is no longer linked to the source device - derivative_entity_entry = entity_registry.async_get("sensor.my_derivative") + derivative_entity_entry = entity_registry.async_get( + "sensor.mock_title_my_derivative" + ) assert derivative_entity_entry.device_id is None # Check that the derivative config entry is not in the device @@ -239,7 +251,9 @@ async def test_async_handle_source_entity_changes_source_entity_moved_other_devi assert await hass.config_entries.async_setup(derivative_config_entry.entry_id) await hass.async_block_till_done() - derivative_entity_entry = entity_registry.async_get("sensor.my_derivative") + derivative_entity_entry = entity_registry.async_get( + "sensor.mock_title_my_derivative" + ) assert derivative_entity_entry.device_id == sensor_entity_entry.device_id sensor_device = device_registry.async_get(sensor_device.id) @@ -261,7 +275,9 @@ async def test_async_handle_source_entity_changes_source_entity_moved_other_devi mock_unload_entry.assert_called_once() # Check that the entity is linked to the other device - derivative_entity_entry = entity_registry.async_get("sensor.my_derivative") + derivative_entity_entry = entity_registry.async_get( + "sensor.mock_title_my_derivative" + ) assert derivative_entity_entry.device_id == sensor_device_2.id # Check that the derivative config entry is not in any of the devices @@ -289,7 +305,9 @@ async def test_async_handle_source_entity_new_entity_id( assert await hass.config_entries.async_setup(derivative_config_entry.entry_id) await hass.async_block_till_done() - derivative_entity_entry = entity_registry.async_get("sensor.my_derivative") + derivative_entity_entry = entity_registry.async_get( + "sensor.mock_title_my_derivative" + ) assert derivative_entity_entry.device_id == sensor_entity_entry.device_id sensor_device = device_registry.async_get(sensor_device.id) @@ -375,7 +393,7 @@ async def test_migration_1_2( options={ "name": "My derivative", "round": 1.0, - "source": "sensor.test_unique", + "source": sensor_entity_entry.entity_id, "time_window": {"seconds": 0.0}, "unit_prefix": "k", "unit_time": "min", @@ -395,7 +413,9 @@ async def test_migration_1_2( # derivative entity is linked to the source device sensor_device = device_registry.async_get(sensor_device.id) assert derivative_config_entry.entry_id not in sensor_device.config_entries - derivative_entity_entry = entity_registry.async_get("sensor.my_derivative") + derivative_entity_entry = entity_registry.async_get( + "sensor.mock_title_my_derivative" + ) assert derivative_entity_entry.device_id == sensor_entity_entry.device_id assert derivative_config_entry.version == 1 diff --git a/tests/components/derivative/test_sensor.py b/tests/components/derivative/test_sensor.py index dc816bc881f0..5c54176ce778 100644 --- a/tests/components/derivative/test_sensor.py +++ b/tests/components/derivative/test_sensor.py @@ -894,6 +894,58 @@ async def test_total_increasing_reset(hass: HomeAssistant) -> None: assert actual_values == expected_values +@pytest.mark.parametrize("bad_state", [STATE_UNAVAILABLE, STATE_UNKNOWN]) +@pytest.mark.parametrize( + ("extra_config", "active_value", "recovered_value"), + [ + pytest.param({}, "5.00", "1.00", id="no_time_window"), + pytest.param( + {"time_window": {"seconds": 60}}, "0.83", "0.17", id="time_window" + ), + ], +) +async def test_total_increasing_reset_while_unavailable( + hass: HomeAssistant, + bad_state: str, + extra_config: dict[str, Any], + active_value: str, + recovered_value: str, +) -> None: + """Test derivative recovers when a total_increasing source resets while unavailable. + + Regression test for a total_increasing source (e.g. a daily energy sensor) + that briefly goes unavailable/unknown around midnight and returns with its + value reset to 0. The derivative must report a zero rate of change on the + reset sample and must not stay stuck in the unavailable/unknown state until + the next state change is received, regardless of the configured time window. + The first normal sample after the reset must produce a sensible positive + rate again, proving the source value was re-baselined to the post-reset + value rather than the stale pre-reset one. + """ + times = [0, 10, 20, 30, 40] + values = [0, 50, bad_state, 0, 10] + expected_states = ["0.00", active_value, bad_state, "0.00", recovered_value] + + _config, entity_id = await _setup_sensor( + hass, {"unit_time": UnitOfTime.SECONDS} | extra_config + ) + + base_time = dt_util.utcnow() + with freeze_time(base_time) as freezer: + for time, value, expected in zip(times, values, expected_states, strict=True): + freezer.move_to(base_time + timedelta(seconds=time)) + hass.states.async_set( + entity_id, + value, + {ATTR_STATE_CLASS: SensorStateClass.TOTAL_INCREASING}, + ) + await hass.async_block_till_done() + + state = hass.states.get("sensor.power") + assert state is not None + assert state.state == expected + + async def test_device_id( hass: HomeAssistant, entity_registry: er.EntityRegistry, @@ -915,7 +967,7 @@ async def test_device_id( device_id=source_device_entry.id, ) await hass.async_block_till_done() - assert entity_registry.async_get("sensor.test_source") is not None + assert entity_registry.async_get(source_entity.entity_id) is not None derivative_config_entry = MockConfigEntry( data={}, @@ -923,7 +975,7 @@ async def test_device_id( options={ "name": "Derivative", "round": 1.0, - "source": "sensor.test_source", + "source": source_entity.entity_id, "time_window": {"seconds": 0.0}, "unit_prefix": "k", "unit_time": "min", @@ -936,7 +988,7 @@ async def test_device_id( assert await hass.config_entries.async_setup(derivative_config_entry.entry_id) await hass.async_block_till_done() - derivative_entity = entity_registry.async_get("sensor.derivative") + derivative_entity = entity_registry.async_get("sensor.mock_title_derivative") assert derivative_entity is not None assert derivative_entity.device_id == source_entity.device_id diff --git a/tests/components/device_automation/test_init.py b/tests/components/device_automation/test_init.py index a1bc0a7513cb..7fb533b9f4ba 100644 --- a/tests/components/device_automation/test_init.py +++ b/tests/components/device_automation/test_init.py @@ -1865,13 +1865,13 @@ async def test_validate_config_rewrites_composite_device_id( ) old_id = "composite00000000000000000000ab" # Simulate a migration split: both devices carry the pre-migration composite id - device_registry.devices[device_fake.id] = attr.evolve( + device_registry._devices[device_fake.id] = attr.evolve( device_fake, composite_device_id=old_id ) - device_registry.devices[device_other.id] = attr.evolve( + device_registry._devices[device_other.id] = attr.evolve( device_other, composite_device_id=old_id ) - assert old_id not in device_registry.devices + assert old_id not in device_registry._devices validated = await async_validate_device_automation_config( hass, diff --git a/tests/components/device_sun_light_trigger/test_init.py b/tests/components/device_sun_light_trigger/test_init.py index 249964829160..62492a1b19e0 100644 --- a/tests/components/device_sun_light_trigger/test_init.py +++ b/tests/components/device_sun_light_trigger/test_init.py @@ -216,6 +216,7 @@ async def test_lights_turn_on_when_coming_home_after_sun_set_person( mode=None, object_id=None, order=None, + context=None, ) assert await async_setup_component( diff --git a/tests/components/device_tracker/test_entity.py b/tests/components/device_tracker/test_entity.py index 9f7c3a2cc23b..861d5f7de2a6 100644 --- a/tests/components/device_tracker/test_entity.py +++ b/tests/components/device_tracker/test_entity.py @@ -1711,10 +1711,10 @@ async def test_scanner_entity_attaches_to_split_of_composite_device( identifiers={("other", "x")}, ) # Simulate a migration split: both devices share the pre-migration composite id - device_registry.devices[own_split.id] = attr.evolve( + device_registry._devices[own_split.id] = attr.evolve( own_split, composite_device_id=old_id ) - device_registry.devices[other_split.id] = attr.evolve( + device_registry._devices[other_split.id] = attr.evolve( other_split, composite_device_id=old_id ) # async_get_device now resolves the shared MAC to the synthesized composite @@ -1723,7 +1723,7 @@ async def test_scanner_entity_attaches_to_split_of_composite_device( ) assert composite is not None assert composite.id == old_id - assert old_id not in device_registry.devices + assert old_id not in device_registry._devices scanner_entity = MockScannerEntity(mac_address=mac, unique_id=f"{mac}_scanner") scanner_entity.entity_id = "device_tracker.composite_scanner" @@ -1760,7 +1760,7 @@ async def test_scanner_entity_composite_device_without_own_split( connections={(dr.CONNECTION_NETWORK_MAC, mac)}, identifiers={("other", identifier)}, ) - device_registry.devices[split.id] = attr.evolve( + device_registry._devices[split.id] = attr.evolve( split, composite_device_id=old_id ) composite = device_registry.async_get_device( @@ -1768,7 +1768,7 @@ async def test_scanner_entity_composite_device_without_own_split( ) assert composite is not None assert composite.id == old_id - assert old_id not in device_registry.devices + assert old_id not in device_registry._devices scanner_entity = MockScannerEntity(mac_address=mac, unique_id=f"{mac}_scanner") scanner_entity.entity_id = "device_tracker.composite_scanner" @@ -1921,7 +1921,7 @@ async def test_scanner_entity_prunes_composite_identifiers( connections={(dr.CONNECTION_NETWORK_MAC, mac)}, identifiers={("other", "copied-identifier")}, ) - device_registry.devices[own_split.id] = attr.evolve( + device_registry._devices[own_split.id] = attr.evolve( own_split, composite_device_id="composite00000000000000000000000", has_composite_identifiers=True, diff --git a/tests/components/devolo_home_network/test_device_tracker.py b/tests/components/devolo_home_network/test_device_tracker.py index 0e88be1b9c3a..86ce0122c2e6 100644 --- a/tests/components/devolo_home_network/test_device_tracker.py +++ b/tests/components/devolo_home_network/test_device_tracker.py @@ -10,7 +10,7 @@ from syrupy.assertion import SnapshotAssertion from homeassistant.components.device_tracker import DOMAIN as DEVICE_TRACKER_DOMAIN from homeassistant.components.devolo_home_network.const import ( DOMAIN, - LONG_UPDATE_INTERVAL, + SHORT_UPDATE_INTERVAL, ) from homeassistant.const import STATE_NOT_HOME, STATE_UNAVAILABLE from homeassistant.core import HomeAssistant @@ -40,16 +40,13 @@ async def test_device_tracker( entry = configure_integration(hass) await hass.config_entries.async_setup(entry.entry_id) await hass.async_block_till_done() - freezer.tick(LONG_UPDATE_INTERVAL) - async_fire_time_changed(hass) - await hass.async_block_till_done() assert hass.states.get(entity_id) == snapshot # Emulate state change mock_device.device.async_get_wifi_connected_station = AsyncMock( return_value=NO_CONNECTED_STATIONS ) - freezer.tick(LONG_UPDATE_INTERVAL) + freezer.tick(SHORT_UPDATE_INTERVAL) async_fire_time_changed(hass) await hass.async_block_till_done() @@ -61,7 +58,7 @@ async def test_device_tracker( mock_device.device.async_get_wifi_connected_station = AsyncMock( side_effect=DeviceUnavailable ) - freezer.tick(LONG_UPDATE_INTERVAL) + freezer.tick(SHORT_UPDATE_INTERVAL) async_fire_time_changed(hass) await hass.async_block_till_done() diff --git a/tests/components/dlna_dmr/test_media_player.py b/tests/components/dlna_dmr/test_media_player.py index bef58fb753ef..ac5334e8e5b6 100644 --- a/tests/components/dlna_dmr/test_media_player.py +++ b/tests/components/dlna_dmr/test_media_player.py @@ -1359,12 +1359,13 @@ async def test_unavailable_device( blocking=True, ) - # Check hass device information has not been filled in yet + # The device is named after the config entry until it can be connected to; + # detailed information such as manufacturer is filled in once connected. device = device_registry.async_get_device_by_connection( (dr.CONNECTION_UPNP, MOCK_DEVICE_UDN), config_entry_mock.entry_id ) assert device is not None - assert device.name is None + assert device.name == MOCK_DEVICE_NAME assert device.manufacturer is None # Unload config entry to clean up diff --git a/tests/components/ecobee/test_climate.py b/tests/components/ecobee/test_climate.py index 972bb4dfd9c4..ff8fe064d065 100644 --- a/tests/components/ecobee/test_climate.py +++ b/tests/components/ecobee/test_climate.py @@ -464,7 +464,7 @@ async def test_remote_sensor_devices( async_fire_time_changed(hass) state = hass.states.get(ENTITY_ID) device_registry = dr.async_get(hass) # pylint: disable=home-assistant-tests-registry-fixtures - for device in device_registry.devices.values(): + for device in device_registry.devices: if device.name == "Remote Sensor 1": remote_sensor_1_id = device.id if device.name == "ecobee": @@ -582,7 +582,7 @@ async def test_set_sensors_used_in_climate(hass: HomeAssistant) -> None: # Get device_id of remote sensor from the device registry. await setup_platform(hass, [const.Platform.CLIMATE, const.Platform.SENSOR]) device_registry = dr.async_get(hass) # pylint: disable=home-assistant-tests-registry-fixtures - for device in device_registry.devices.values(): + for device in device_registry.devices: if device.name == "Remote Sensor 1": remote_sensor_1_id = device.id if device.name == "ecobee": diff --git a/tests/components/ekeybionyx/test_config_flow.py b/tests/components/ekeybionyx/test_config_flow.py index 5c387a0398d1..9e94dbbd1700 100644 --- a/tests/components/ekeybionyx/test_config_flow.py +++ b/tests/components/ekeybionyx/test_config_flow.py @@ -250,6 +250,7 @@ async def test_no_available_webhooks( @pytest.mark.usefixtures("current_request_with_host") +@patch("homeassistant.components.ekeybionyx.config_flow.DELETION_POLL_INTERVAL", 0) async def test_cleanup( hass: HomeAssistant, hass_client_no_auth: ClientSessionGenerator, diff --git a/tests/components/enphase_envoy/conftest.py b/tests/components/enphase_envoy/conftest.py index dde581c43f94..30b7694a8a2f 100644 --- a/tests/components/enphase_envoy/conftest.py +++ b/tests/components/enphase_envoy/conftest.py @@ -206,8 +206,8 @@ def _load_json_2_production_data( if item := json_fixture["data"].get("system_consumption_phases"): mocked_data.system_consumption_phases = {} for sub_item, item_data in item.items(): - mocked_data.system_consumption_phases[sub_item] = EnvoySystemConsumption( - **item_data + mocked_data.system_consumption_phases[sub_item] = ( + None if not item_data else EnvoySystemConsumption(**item_data) ) if item := json_fixture["data"].get("system_net_consumption_phases"): mocked_data.system_net_consumption_phases = {} @@ -218,8 +218,8 @@ def _load_json_2_production_data( if item := json_fixture["data"].get("system_production_phases"): mocked_data.system_production_phases = {} for sub_item, item_data in item.items(): - mocked_data.system_production_phases[sub_item] = EnvoySystemProduction( - **item_data + mocked_data.system_production_phases[sub_item] = ( + None if not item_data else EnvoySystemProduction(**item_data) ) if item := json_fixture["data"].get("acb_power"): mocked_data.acb_power = EnvoyACBPower(**item) @@ -232,15 +232,19 @@ def _load_json_2_meter_data( if meters := json_fixture["data"].get("ctmeters"): mocked_data.ctmeters = {} [ - mocked_data.ctmeters.update({meter: EnvoyMeterData(**meter_data)}) + mocked_data.ctmeters.update( + {meter: None if not meter_data else EnvoyMeterData(**meter_data)} + ) for meter, meter_data in meters.items() ] if meters := json_fixture["data"].get("ctmeters_phases"): mocked_data.ctmeters_phases = {} for meter, meter_data in meters.items(): - meter_phase_data: dict[str, EnvoyMeterData] = {} + meter_phase_data: dict[str, EnvoyMeterData | None] = {} [ - meter_phase_data.update({phase: EnvoyMeterData(**phase_data)}) + meter_phase_data.update( + {phase: None if not phase_data else EnvoyMeterData(**phase_data)} + ) for phase, phase_data in meter_data.items() ] mocked_data.ctmeters_phases.update({meter: meter_phase_data}) diff --git a/tests/components/enphase_envoy/fixtures/envoy_metered_batt_relay.json b/tests/components/enphase_envoy/fixtures/envoy_metered_batt_relay.json index 32cde3bf04b0..748ebfec099b 100644 --- a/tests/components/enphase_envoy/fixtures/envoy_metered_batt_relay.json +++ b/tests/components/enphase_envoy/fixtures/envoy_metered_batt_relay.json @@ -3,7 +3,7 @@ "firmware": "7.1.2", "part_number": "123456789", "envoy_model": "Envoy, phases: 3, phase mode: split, net-consumption CT, production CT, storage CT", - "supported_features": 1659, + "supported_features": 1663, "phase_mode": "three", "phase_count": 3, "active_phase_count": 3, diff --git a/tests/components/enphase_envoy/fixtures/envoy_metered_batt_relay_none.json b/tests/components/enphase_envoy/fixtures/envoy_metered_batt_relay_none.json new file mode 100644 index 000000000000..c8e994b7987d --- /dev/null +++ b/tests/components/enphase_envoy/fixtures/envoy_metered_batt_relay_none.json @@ -0,0 +1,639 @@ +{ + "serial_number": "1234", + "firmware": "7.1.2", + "part_number": "123456789", + "envoy_model": "Envoy, phases: 3, phase mode: split, net-consumption CT, production CT, storage CT", + "supported_features": 1663, + "phase_mode": "three", + "phase_count": 3, + "active_phase_count": 3, + "ct_meter_count": 2, + "consumption_meter_type": "net-consumption", + "production_meter_type": "production", + "storage_meter_type": "storage", + "data": { + "encharge_inventory": { + "123456": { + "admin_state": 6, + "admin_state_str": "ENCHG_STATE_READY", + "bmu_firmware_version": "2.1.34", + "comm_level_2_4_ghz": 4, + "comm_level_sub_ghz": 4, + "communicating": true, + "dc_switch_off": false, + "encharge_capacity": 3500, + "encharge_revision": 2, + "firmware_loaded_date": 1695330323, + "firmware_version": "2.6.5973_rel/22.11", + "installed_date": 1695330323, + "last_report_date": 1695769447, + "led_status": 17, + "max_cell_temp": 30, + "operating": true, + "part_number": "830-01760-r37", + "percent_full": 15, + "serial_number": "123456", + "temperature": 29, + "temperature_unit": "C", + "zigbee_dongle_fw_version": "100F" + } + }, + "encharge_power": { + "123456": { + "apparent_power_mva": 0, + "real_power_mw": 0, + "soc": 15 + } + }, + "encharge_aggregate": { + "available_energy": 525, + "backup_reserve": 526, + "state_of_charge": 15, + "reserve_state_of_charge": 15, + "configured_reserve_state_of_charge": 15, + "max_available_capacity": 3500 + }, + "enpower": { + "grid_mode": "multimode-ongrid", + "admin_state": 24, + "admin_state_str": "ENPWR_STATE_OPER_CLOSED", + "comm_level_2_4_ghz": 5, + "comm_level_sub_ghz": 5, + "communicating": true, + "firmware_loaded_date": 1695330323, + "firmware_version": "1.2.2064_release/20.34", + "installed_date": 1695330323, + "last_report_date": 1695769447, + "mains_admin_state": "closed", + "mains_oper_state": "closed", + "operating": true, + "part_number": "830-01760-r37", + "serial_number": "654321", + "temperature": 79, + "temperature_unit": "F", + "zigbee_dongle_fw_version": "1009" + }, + "system_consumption": null, + "system_net_consumption": { + "watt_hours_lifetime": 4321, + "watt_hours_last_7_days": -1, + "watt_hours_today": -1, + "watts_now": 2341 + }, + "system_production": null, + "system_consumption_phases": { + "L1": null, + "L2": null, + "L3": null + }, + "system_net_consumption_phases": { + "L1": { + "watt_hours_lifetime": 1321, + "watt_hours_last_7_days": -1, + "watt_hours_today": -1, + "watts_now": 12341 + }, + "L2": { + "watt_hours_lifetime": 2321, + "watt_hours_last_7_days": -1, + "watt_hours_today": -1, + "watts_now": 22341 + }, + "L3": { + "watt_hours_lifetime": 3321, + "watt_hours_last_7_days": -1, + "watt_hours_today": -1, + "watts_now": 32341 + } + }, + "system_production_phases": { + "L1": null, + "L2": null, + "L3": null + }, + "ctmeters": { + "production": { + "eid": "100000010", + "timestamp": 1708006110, + "energy_delivered": 11234, + "energy_received": 12345, + "active_power": 100, + "power_factor": 0.11, + "voltage": 111, + "current": 0.2, + "frequency": 50.1, + "state": "enabled", + "measurement_type": "production", + "metering_status": "normal", + "status_flags": ["production-imbalance", "power-on-unused-phase"] + }, + "net-consumption": { + "eid": "100000020", + "timestamp": 1708006120, + "energy_delivered": 21234, + "energy_received": 22345, + "active_power": 101, + "power_factor": 0.21, + "voltage": 112, + "current": 0.3, + "frequency": 50.2, + "state": "enabled", + "measurement_type": "net-consumption", + "metering_status": "normal", + "status_flags": [] + }, + "storage": null, + "backfeed": null, + "load": { + "eid": "100000050", + "timestamp": 1708006120, + "energy_delivered": 51234, + "energy_received": 52345, + "active_power": 105, + "power_factor": 0.25, + "voltage": 115, + "current": 0.6, + "frequency": 50.6, + "state": "enabled", + "measurement_type": "load", + "metering_status": "normal", + "status_flags": [] + }, + "evse": { + "eid": "100000060", + "timestamp": 1708006120, + "energy_delivered": 61234, + "energy_received": 62345, + "active_power": 106, + "power_factor": 0.26, + "voltage": 116, + "current": 0.7, + "frequency": 50.7, + "state": "enabled", + "measurement_type": "evse", + "metering_status": "normal", + "status_flags": [] + }, + "pv3p": { + "eid": "100000070", + "timestamp": 1708006120, + "energy_delivered": 71234, + "energy_received": 72345, + "active_power": 107, + "power_factor": 0.27, + "voltage": 117, + "current": 0.8, + "frequency": 50.8, + "state": "enabled", + "measurement_type": "pv3p", + "metering_status": "normal", + "status_flags": [] + } + }, + "ctmeters_phases": { + "production": { + "L1": { + "eid": "100000011", + "timestamp": 1708006111, + "energy_delivered": 112341, + "energy_received": 123451, + "active_power": 20, + "power_factor": 0.12, + "voltage": 111, + "current": 0.2, + "frequency": 50.1, + "state": "enabled", + "measurement_type": "production", + "metering_status": "normal", + "status_flags": ["production-imbalance"] + }, + "L2": { + "eid": "100000012", + "timestamp": 1708006112, + "energy_delivered": 112342, + "energy_received": 123452, + "active_power": 30, + "power_factor": 0.13, + "voltage": 111, + "current": 0.2, + "frequency": 50.1, + "state": "enabled", + "measurement_type": "production", + "metering_status": "normal", + "status_flags": ["power-on-unused-phase"] + }, + "L3": { + "eid": "100000013", + "timestamp": 1708006113, + "energy_delivered": 112343, + "energy_received": 123453, + "active_power": 50, + "power_factor": 0.14, + "voltage": 111, + "current": 0.2, + "frequency": 50.1, + "state": "enabled", + "measurement_type": "production", + "metering_status": "normal", + "status_flags": [] + } + }, + "net-consumption": { + "L1": { + "eid": "100000021", + "timestamp": 1708006121, + "energy_delivered": 212341, + "energy_received": 223451, + "active_power": 21, + "power_factor": 0.22, + "voltage": 112, + "current": 0.3, + "frequency": 50.2, + "state": "enabled", + "measurement_type": "net-consumption", + "metering_status": "normal", + "status_flags": [] + }, + "L2": { + "eid": "100000022", + "timestamp": 1708006122, + "energy_delivered": 212342, + "energy_received": 223452, + "active_power": 31, + "power_factor": 0.23, + "voltage": 112, + "current": 0.3, + "frequency": 50.2, + "state": "enabled", + "measurement_type": "net-consumption", + "metering_status": "normal", + "status_flags": [] + }, + "L3": { + "eid": "100000023", + "timestamp": 1708006123, + "energy_delivered": 212343, + "energy_received": 223453, + "active_power": 51, + "power_factor": 0.24, + "voltage": 112, + "current": 0.3, + "frequency": 50.2, + "state": "enabled", + "measurement_type": "net-consumption", + "metering_status": "normal", + "status_flags": [] + } + }, + "storage": { + "L1": null, + "L2": { + "eid": "100000032", + "timestamp": 1708006122, + "energy_delivered": 312342, + "energy_received": 323452, + "active_power": 33, + "power_factor": 0.23, + "voltage": 112, + "current": 0.3, + "frequency": 50.2, + "state": "enabled", + "measurement_type": "storage", + "metering_status": "normal", + "status_flags": [] + }, + "L3": { + "eid": "100000033", + "timestamp": 1708006123, + "energy_delivered": 312343, + "energy_received": 323453, + "active_power": 53, + "power_factor": 0.24, + "voltage": 112, + "current": 0.3, + "frequency": 50.2, + "state": "enabled", + "measurement_type": "storage", + "metering_status": "normal", + "status_flags": [] + } + }, + "backfeed": { + "L1": null, + "L2": null, + "L3": null + }, + "load": { + "L1": { + "eid": "100000051", + "timestamp": 1708006121, + "energy_delivered": 512341, + "energy_received": 523451, + "active_power": 115, + "power_factor": 0.25, + "voltage": 115, + "current": 5.1, + "frequency": 50.6, + "state": "enabled", + "measurement_type": "load", + "metering_status": "normal", + "status_flags": [] + }, + "L2": { + "eid": "100000052", + "timestamp": 1708006122, + "energy_delivered": 512342, + "energy_received": 523452, + "active_power": 125, + "power_factor": 0.25, + "voltage": 115, + "current": 5.2, + "frequency": 50.6, + "state": "enabled", + "measurement_type": "load", + "metering_status": "normal", + "status_flags": [] + }, + "L3": { + "eid": "100000052", + "timestamp": 1708006123, + "energy_delivered": 512343, + "energy_received": 523453, + "active_power": 135, + "power_factor": 0.25, + "voltage": 115, + "current": 5.3, + "frequency": 50.6, + "state": "enabled", + "measurement_type": "load", + "metering_status": "normal", + "status_flags": [] + } + }, + "evse": { + "L1": { + "eid": "100000061", + "timestamp": 1708006121, + "energy_delivered": 612341, + "energy_received": 623451, + "active_power": 116, + "power_factor": 0.26, + "voltage": 116, + "current": 6.1, + "frequency": 50.6, + "state": "enabled", + "measurement_type": "evse", + "metering_status": "normal", + "status_flags": [] + }, + "L2": { + "eid": "100000062", + "timestamp": 1708006122, + "energy_delivered": 612342, + "energy_received": 623452, + "active_power": 126, + "power_factor": 0.26, + "voltage": 116, + "current": 6.2, + "frequency": 50.6, + "state": "enabled", + "measurement_type": "evse", + "metering_status": "normal", + "status_flags": [] + }, + "L3": { + "eid": "100000063", + "timestamp": 1708006123, + "energy_delivered": 612343, + "energy_received": 623453, + "active_power": 136, + "power_factor": 0.26, + "voltage": 116, + "current": 6.3, + "frequency": 50.6, + "state": "enabled", + "measurement_type": "evse", + "metering_status": "normal", + "status_flags": [] + } + }, + "pv3p": { + "L1": { + "eid": "100000071", + "timestamp": 1708006127, + "energy_delivered": 712341, + "energy_received": 723451, + "active_power": 117, + "power_factor": 0.27, + "voltage": 117, + "current": 7.1, + "frequency": 50.7, + "state": "enabled", + "measurement_type": "pv3p", + "metering_status": "normal", + "status_flags": [] + }, + "L2": { + "eid": "100000072", + "timestamp": 1708006122, + "energy_delivered": 712342, + "energy_received": 723452, + "active_power": 127, + "power_factor": 0.27, + "voltage": 117, + "current": 7.2, + "frequency": 50.7, + "state": "enabled", + "measurement_type": "pv3p", + "metering_status": "normal", + "status_flags": [] + }, + "L3": { + "eid": "100000073", + "timestamp": 1708006123, + "energy_delivered": 712343, + "energy_received": 723453, + "active_power": 137, + "power_factor": 0.27, + "voltage": 117, + "current": 7.3, + "frequency": 50.7, + "state": "enabled", + "measurement_type": "pv3p", + "metering_status": "normal", + "status_flags": [] + } + } + }, + "dry_contact_status": { + "NC1": { + "id": "NC1", + "status": "open" + }, + "NC2": { + "id": "NC2", + "status": "closed" + }, + "NC3": { + "id": "NC3", + "status": "open" + } + }, + "dry_contact_settings": { + "NC1": { + "id": "NC1", + "black_start": 5.0, + "essential_end_time": 32400.0, + "essential_start_time": 57600.0, + "generator_action": "shed", + "grid_action": "shed", + "load_name": "NC1 Fixture", + "manual_override": true, + "micro_grid_action": "shed", + "mode": "manual", + "override": true, + "priority": 1.0, + "pv_serial_nb": [], + "soc_high": 70.0, + "soc_low": 25.0, + "type": "LOAD" + }, + "NC2": { + "id": "NC2", + "black_start": 5.0, + "essential_end_time": 57600.0, + "essential_start_time": 32400.0, + "generator_action": "shed", + "grid_action": "apply", + "load_name": "NC2 Fixture", + "manual_override": true, + "micro_grid_action": "shed", + "mode": "manual", + "override": true, + "priority": 2.0, + "pv_serial_nb": [], + "soc_high": 70.0, + "soc_low": 30.0, + "type": "LOAD" + }, + "NC3": { + "id": "NC3", + "black_start": 5.0, + "essential_end_time": 57600.0, + "essential_start_time": 32400.0, + "generator_action": "apply", + "grid_action": "shed", + "load_name": "NC3 Fixture", + "manual_override": true, + "micro_grid_action": "apply", + "mode": "manual", + "override": true, + "priority": 3.0, + "pv_serial_nb": [], + "soc_high": 70.0, + "soc_low": 30.0, + "type": "NONE" + } + }, + "collar": { + "admin_state": 88, + "admin_state_str": "ENCMN_MDE_ON_GRID", + "firmware_loaded_date": 1752939759, + "firmware_version": "3.0.6-D0", + "installed_date": 1752939759, + "last_report_date": 1752939759, + "communicating": true, + "mid_state": "close", + "grid_state": "on_grid", + "part_number": "865-00400-r22", + "serial_number": "482520020939", + "temperature": 42, + "temperature_unit": "C", + "control_error": 0, + "collar_state": "Installed" + }, + "c6cc": { + "admin_state": 82, + "admin_state_str": "ENCMN_C6_CC_READY", + "firmware_loaded_date": 1752945451, + "firmware_version": "0.1.20-D1", + "installed_date": 1752945451, + "last_report_date": 1752945451, + "communicating": true, + "part_number": "800-02403-r08", + "serial_number": "482523040549", + "dmir_version": "0.1.20-D1" + }, + "inverters": { + "1": { + "serial_number": "1", + "last_report_date": 1, + "last_report_watts": 1, + "max_report_watts": 1, + "dc_voltage": null, + "dc_current": null, + "ac_voltage": null, + "ac_current": null, + "ac_frequency": null, + "temperature": null, + "energy_produced": null, + "energy_today": null, + "lifetime_energy": null, + "last_report_duration": null + } + }, + "tariff": { + "currency": { + "code": "EUR" + }, + "logger": "mylogger", + "date": "1695744220", + "storage_settings": { + "mode": "self-consumption", + "operation_mode_sub_type": "", + "reserved_soc": 15.0, + "very_low_soc": 5, + "charge_from_grid": true, + "date": "1695598084", + "opt_schedules": true + }, + "single_rate": { + "rate": 0.0, + "sell": 0.0 + }, + "seasons": [ + { + "id": "season_1", + "start": "1/1", + "days": [ + { + "id": "all_days", + "days": "Mon,Tue,Wed,Thu,Fri,Sat,Sun", + "must_charge_start": 444, + "must_charge_duration": 35, + "must_charge_mode": "CG", + "enable_discharge_to_grid": true, + "periods": [ + { + "id": "period_1", + "start": 480, + "rate": 0.1898 + }, + { + "id": "filler", + "start": 1320, + "rate": 0.1034 + } + ] + } + ], + "tiers": [] + } + ], + "seasons_sell": [] + }, + "raw": { + "varies_by": "firmware_version" + } + } +} diff --git a/tests/components/enphase_envoy/fixtures/envoy_tot_cons_metered.json b/tests/components/enphase_envoy/fixtures/envoy_tot_cons_metered.json index 0d0d1957c193..125f789326c4 100644 --- a/tests/components/enphase_envoy/fixtures/envoy_tot_cons_metered.json +++ b/tests/components/enphase_envoy/fixtures/envoy_tot_cons_metered.json @@ -3,7 +3,7 @@ "firmware": "7.6.175", "part_number": "123456789", "envoy_model": "Envoy, phases: 1, phase mode: three, total-consumption CT, production CT", - "supported_features": 1217, + "supported_features": 1231, "phase_mode": "three", "phase_count": 1, "active_phase_count": 0, diff --git a/tests/components/enphase_envoy/snapshots/test_diagnostics.ambr b/tests/components/enphase_envoy/snapshots/test_diagnostics.ambr index dee465efea12..23f7fb3655bd 100644 --- a/tests/components/enphase_envoy/snapshots/test_diagnostics.ambr +++ b/tests/components/enphase_envoy/snapshots/test_diagnostics.ambr @@ -19923,6 +19923,7 @@ 'supported_features': list([ 'INVERTERS', 'METERING', + 'TOTAL_CONSUMPTION', 'NET_CONSUMPTION', 'ENCHARGE', 'ENPOWER', diff --git a/tests/components/enphase_envoy/snapshots/test_sensor.ambr b/tests/components/enphase_envoy/snapshots/test_sensor.ambr index 355c82ae6ad2..1adb7ff38b1a 100644 --- a/tests/components/enphase_envoy/snapshots/test_sensor.ambr +++ b/tests/components/enphase_envoy/snapshots/test_sensor.ambr @@ -43540,6 +43540,67 @@ 'state': '2.341', }) # --- +# name: test_sensor[envoy_tot_cons_metered][sensor.envoy_1234_current_power_consumption-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.envoy_1234_current_power_consumption', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Current power consumption', + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 3, + }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Current power consumption', + 'platform': 'enphase_envoy', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'current_power_consumption', + 'unique_id': '1234_consumption', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[envoy_tot_cons_metered][sensor.envoy_1234_current_power_consumption-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'power', + : 'Envoy 1234 Current power consumption', + : , + : , + }), + 'context': , + 'entity_id': 'sensor.envoy_1234_current_power_consumption', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- # name: test_sensor[envoy_tot_cons_metered][sensor.envoy_1234_current_power_production-entry] EntityRegistryEntrySnapshot({ 'aliases': list([ @@ -43601,6 +43662,125 @@ 'state': '1.234', }) # --- +# name: test_sensor[envoy_tot_cons_metered][sensor.envoy_1234_energy_consumption_last_seven_days-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.envoy_1234_energy_consumption_last_seven_days', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Energy consumption last seven days', + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Energy consumption last seven days', + 'platform': 'enphase_envoy', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'seven_days_consumption', + 'unique_id': '1234_seven_days_consumption', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[envoy_tot_cons_metered][sensor.envoy_1234_energy_consumption_last_seven_days-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'energy', + : 'Envoy 1234 Energy consumption last seven days', + : , + }), + 'context': , + 'entity_id': 'sensor.envoy_1234_energy_consumption_last_seven_days', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_sensor[envoy_tot_cons_metered][sensor.envoy_1234_energy_consumption_today-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.envoy_1234_energy_consumption_today', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Energy consumption today', + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Energy consumption today', + 'platform': 'enphase_envoy', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'daily_consumption', + 'unique_id': '1234_daily_consumption', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[envoy_tot_cons_metered][sensor.envoy_1234_energy_consumption_today-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'energy', + : 'Envoy 1234 Energy consumption today', + : , + : , + }), + 'context': , + 'entity_id': 'sensor.envoy_1234_energy_consumption_today', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- # name: test_sensor[envoy_tot_cons_metered][sensor.envoy_1234_energy_production_last_seven_days-entry] EntityRegistryEntrySnapshot({ 'aliases': list([ @@ -43897,6 +44077,67 @@ 'state': '4.321', }) # --- +# name: test_sensor[envoy_tot_cons_metered][sensor.envoy_1234_lifetime_energy_consumption-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.envoy_1234_lifetime_energy_consumption', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Lifetime energy consumption', + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 3, + }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Lifetime energy consumption', + 'platform': 'enphase_envoy', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'lifetime_consumption', + 'unique_id': '1234_lifetime_consumption', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[envoy_tot_cons_metered][sensor.envoy_1234_lifetime_energy_consumption-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'energy', + : 'Envoy 1234 Lifetime energy consumption', + : , + : , + }), + 'context': , + 'entity_id': 'sensor.envoy_1234_lifetime_energy_consumption', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- # name: test_sensor[envoy_tot_cons_metered][sensor.envoy_1234_lifetime_energy_production-entry] EntityRegistryEntrySnapshot({ 'aliases': list([ diff --git a/tests/components/enphase_envoy/test_diagnostics.py b/tests/components/enphase_envoy/test_diagnostics.py index fa3f2db5a77b..d43e34a924dd 100644 --- a/tests/components/enphase_envoy/test_diagnostics.py +++ b/tests/components/enphase_envoy/test_diagnostics.py @@ -2,6 +2,8 @@ from unittest.mock import AsyncMock +from aiohttp import ClientConnectionError, ClientResponseError +from aiohttp.client import RequestInfo from freezegun.api import FrozenDateTimeFactory from pyenphase.exceptions import EnvoyError from pyenphase.models.meters import CtType @@ -95,6 +97,49 @@ async def test_entry_diagnostics_with_fixtures_with_error( ) == snapshot(exclude=limit_diagnostic_attrs) +async def test_entry_diagnostics_with_fixtures_with_clientresponse_error( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + config_entry_options: MockConfigEntry, + snapshot: SnapshotAssertion, + mock_envoy: AsyncMock, +) -> None: + """Test diagnostics test fixtures with client errors.""" + await setup_integration(hass, config_entry_options) + mock_envoy.request.side_effect = ClientResponseError( + RequestInfo( + url="http://example.com", + method="GET", + headers={ + "Host": "www.example.com", + "Connection": "keep-alive", + "secret": "very secret secret", + }, + real_url="http://example.com", + ), + None, + status=0, + ) + diagnostics = await get_diagnostics_for_config_entry( + hass, hass_client, config_entry_options + ) + assert diagnostics["fixtures"]["/info_log"] == {"Error": "Aiohttp Client error 0"} + + mock_envoy.request.side_effect = EnvoyError("Test") + diagnostics = await get_diagnostics_for_config_entry( + hass, hass_client, config_entry_options + ) + assert diagnostics["fixtures"]["/info_log"] == {"Error": "EnvoyError('Test')"} + + mock_envoy.request.side_effect = ClientConnectionError + diagnostics = await get_diagnostics_for_config_entry( + hass, hass_client, config_entry_options + ) + assert diagnostics["fixtures"]["/info_log"] == { + "Error": "Aiohttp Client error ClientConnectionError" + } + + @pytest.mark.parametrize( ("mock_envoy"), [ diff --git a/tests/components/enphase_envoy/test_sensor.py b/tests/components/enphase_envoy/test_sensor.py index 6d7da94560b0..af0fdfdd7578 100644 --- a/tests/components/enphase_envoy/test_sensor.py +++ b/tests/components/enphase_envoy/test_sensor.py @@ -6,13 +6,14 @@ from typing import Any from unittest.mock import AsyncMock, patch from freezegun.api import FrozenDateTimeFactory +from pyenphase import EnvoyData from pyenphase.const import PHASENAMES, PhaseNames from pyenphase.models.acb import ACBChargeStatus, EnvoyACB from pyenphase.models.meters import CtType import pytest from syrupy.assertion import SnapshotAssertion -from homeassistant.components.enphase_envoy.const import Platform +from homeassistant.components.enphase_envoy.const import DOMAIN, Platform from homeassistant.components.enphase_envoy.coordinator import SCAN_INTERVAL from homeassistant.components.enphase_envoy.sensor import aggregate_acb_sleep_state from homeassistant.components.sensor import SensorStateClass @@ -23,8 +24,14 @@ from homeassistant.util import dt as dt_util from homeassistant.util.unit_conversion import TemperatureConverter from . import setup_integration +from .conftest import _load_json_2_meter_data, _load_json_2_production_data -from tests.common import MockConfigEntry, async_fire_time_changed, snapshot_platform +from tests.common import ( + MockConfigEntry, + async_fire_time_changed, + load_json_object_fixture, + snapshot_platform, +) @pytest.mark.parametrize( @@ -1413,6 +1420,264 @@ async def test_sensor_missing_data( assert entity_state.state == STATE_UNKNOWN +def reference_fixture(fixture: str) -> EnvoyData: + """Load reference fixture in envoy data model.""" + reference_data = EnvoyData() + json_fixture: dict[str, Any] = load_json_object_fixture(f"{fixture}.json", DOMAIN) + _load_json_2_production_data(reference_data, json_fixture) + _load_json_2_meter_data(reference_data, json_fixture) + return reference_data + + +@pytest.mark.parametrize( + ("mock_envoy", "ref_fixture"), + [ + ( + "envoy_metered_batt_relay_none", + "envoy_metered_batt_relay", + ) + ], + indirect=["mock_envoy"], +) +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +async def test_sensor_load_none_data( + hass: HomeAssistant, + config_entry: MockConfigEntry, + mock_envoy: AsyncMock, + ref_fixture: str, + freezer: FrozenDateTimeFactory, +) -> None: + """Test enphase_envoy sensor platform load None data handling.""" + with patch("homeassistant.components.enphase_envoy.PLATFORMS", [Platform.SENSOR]): + await setup_integration(hass, config_entry) + + ENTITY_BASE = f"{Platform.SENSOR}.envoy_{mock_envoy.serial_number}" + + # these have None data and should show up as unknown + for entity in ( + "lifetime_energy_production", + "lifetime_energy_consumption", + "current_battery_discharge", + "backfeed_ct_energy_delivered", + "lifetime_energy_production_l1", + "lifetime_energy_consumption_l1", + "backfeed_ct_energy_delivered_l1", + "current_battery_discharge_l1", + ): + assert (entity_state := hass.states.get(f"{ENTITY_BASE}_{entity}")) + assert entity_state.state == STATE_UNKNOWN + + # restore None data to operational state + + reference_data = reference_fixture(ref_fixture) + mock_envoy.data.system_production = reference_data.system_production + mock_envoy.data.system_consumption = reference_data.system_consumption + mock_envoy.data.ctmeters[CtType.BACKFEED] = reference_data.ctmeters[CtType.BACKFEED] + mock_envoy.data.ctmeters[CtType.STORAGE] = reference_data.ctmeters[CtType.STORAGE] + + mock_envoy.data.system_production_phases = reference_data.system_production_phases + mock_envoy.data.system_consumption_phases = reference_data.system_consumption_phases + mock_envoy.data.ctmeters_phases[CtType.BACKFEED] = reference_data.ctmeters_phases[ + CtType.BACKFEED + ] + mock_envoy.data.ctmeters_phases[CtType.STORAGE][PhaseNames.PHASE_1] = ( + reference_data.ctmeters_phases[CtType.STORAGE][PhaseNames.PHASE_1] + ) + + # force HA to detect changed data by changing raw + mock_envoy.data.raw = {"I": "am changed"} + + # Move time to next update + freezer.tick(SCAN_INTERVAL) + async_fire_time_changed(hass) + await hass.async_block_till_done(wait_background_tasks=True) + + # all these should now no longer be in unknown state + for entity in ( + "lifetime_energy_production", + "lifetime_energy_consumption", + "current_battery_discharge", + "backfeed_ct_energy_delivered", + "lifetime_energy_production_l1", + "lifetime_energy_consumption_l1", + "backfeed_ct_energy_delivered_l1", + "current_battery_discharge_l1", + ): + assert (entity_state := hass.states.get(f"{ENTITY_BASE}_{entity}")) + assert entity_state.state != STATE_UNKNOWN + + +@pytest.mark.parametrize( + ("mock_envoy"), + [ + "envoy_metered_batt_relay", + ], + indirect=["mock_envoy"], +) +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +async def test_sensor_none_data( + hass: HomeAssistant, + config_entry: MockConfigEntry, + mock_envoy: AsyncMock, + entity_registry: er.EntityRegistry, + freezer: FrozenDateTimeFactory, +) -> None: + """Test enphase_envoy sensor platform None data handling.""" + with patch("homeassistant.components.enphase_envoy.PLATFORMS", [Platform.SENSOR]): + await setup_integration(hass, config_entry) + + ENTITY_BASE = f"{Platform.SENSOR}.envoy_{mock_envoy.serial_number}" + + for entity in ( + "lifetime_energy_production", + "lifetime_energy_consumption", + "lifetime_balanced_net_energy_consumption", + "backfeed_ct_energy_delivered", + "lifetime_energy_production_l1", + "lifetime_energy_consumption_l1", + "lifetime_balanced_net_energy_consumption_l1", + ): + assert (entity_state := hass.states.get(f"{ENTITY_BASE}_{entity}")) + + # force None data to test 'if == none' code sections + mock_envoy.data.system_production = None + mock_envoy.data.system_consumption = None + mock_envoy.data.system_net_consumption = None + mock_envoy.data.ctmeters[CtType.BACKFEED] = None + + mock_envoy.data.system_production_phases = None + mock_envoy.data.system_consumption_phases = None + mock_envoy.data.system_net_consumption_phases = None + + # force HA to detect changed data by changing raw + mock_envoy.data.raw = {"I": "am changed"} + + # Move time to next update + freezer.tick(SCAN_INTERVAL) + async_fire_time_changed(hass) + await hass.async_block_till_done(wait_background_tasks=True) + + # all these should now be in unknown state + for entity in ( + "lifetime_energy_production", + "lifetime_energy_consumption", + "lifetime_balanced_net_energy_consumption", + "backfeed_ct_energy_delivered", + "lifetime_energy_production_l1", + "lifetime_energy_consumption_l1", + "lifetime_balanced_net_energy_consumption_l1", + ): + assert (entity_state := hass.states.get(f"{ENTITY_BASE}_{entity}")) + assert entity_state.state == STATE_UNKNOWN + + +@pytest.mark.parametrize( + ("mock_envoy"), + [ + "envoy_metered_batt_relay", + ], + indirect=["mock_envoy"], +) +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +async def test_sensor_phase_values_none_data( + hass: HomeAssistant, + config_entry: MockConfigEntry, + mock_envoy: AsyncMock, + entity_registry: er.EntityRegistry, + freezer: FrozenDateTimeFactory, +) -> None: + """Test enphase_envoy sensor platform phase None data handling.""" + with patch("homeassistant.components.enphase_envoy.PLATFORMS", [Platform.SENSOR]): + await setup_integration(hass, config_entry) + + ENTITY_BASE = f"{Platform.SENSOR}.envoy_{mock_envoy.serial_number}" + + for entity in ( + "lifetime_energy_production_l1", + "lifetime_energy_consumption_l1", + "lifetime_balanced_net_energy_consumption_l1", + "backfeed_ct_energy_delivered_l1", + ): + assert (entity_state := hass.states.get(f"{ENTITY_BASE}_{entity}")) + + # force None data to test 'if == none' code sections + mock_envoy.data.system_production_phases[PhaseNames.PHASE_1] = None + mock_envoy.data.system_consumption_phases[PhaseNames.PHASE_1] = None + mock_envoy.data.system_net_consumption_phases[PhaseNames.PHASE_1] = None + mock_envoy.data.ctmeters_phases[CtType.BACKFEED][PhaseNames.PHASE_1] = None + + # force HA to detect changed data by changing raw + mock_envoy.data.raw = {"I": "am changed"} + + # Move time to next update + freezer.tick(SCAN_INTERVAL) + async_fire_time_changed(hass) + await hass.async_block_till_done(wait_background_tasks=True) + + # all these should now be in unknown state + for entity in ( + "lifetime_energy_production_l1", + "lifetime_energy_consumption_l1", + "lifetime_balanced_net_energy_consumption_l1", + "backfeed_ct_energy_delivered_l1", + ): + assert (entity_state := hass.states.get(f"{ENTITY_BASE}_{entity}")) + assert entity_state.state == STATE_UNKNOWN + + +@pytest.mark.parametrize( + ("mock_envoy"), + [ + "envoy_metered_batt_relay", + ], + indirect=["mock_envoy"], +) +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +async def test_sensor_phase_values_missing_data( + hass: HomeAssistant, + config_entry: MockConfigEntry, + mock_envoy: AsyncMock, + freezer: FrozenDateTimeFactory, +) -> None: + """Test enphase_envoy sensor platform missing phase data handling.""" + with patch("homeassistant.components.enphase_envoy.PLATFORMS", [Platform.SENSOR]): + await setup_integration(hass, config_entry) + + ENTITY_BASE = f"{Platform.SENSOR}.envoy_{mock_envoy.serial_number}" + + for entity in ( + "lifetime_energy_production_l1", + "lifetime_energy_consumption_l1", + "lifetime_balanced_net_energy_consumption_l1", + "backfeed_ct_energy_delivered_l1", + ): + assert (entity_state := hass.states.get(f"{ENTITY_BASE}_{entity}")) + + # test handling of missing phase data + del mock_envoy.data.system_production_phases[PhaseNames.PHASE_1] + del mock_envoy.data.system_consumption_phases[PhaseNames.PHASE_1] + del mock_envoy.data.system_net_consumption_phases[PhaseNames.PHASE_1] + del mock_envoy.data.ctmeters_phases[CtType.BACKFEED][PhaseNames.PHASE_1] + + # force HA to detect changed data by changing raw + mock_envoy.data.raw = {"I": "am changed"} + + # Move time to next update + freezer.tick(SCAN_INTERVAL) + async_fire_time_changed(hass) + await hass.async_block_till_done(wait_background_tasks=True) + + # all these should now be in unknown state + for entity in ( + "lifetime_energy_production_l1", + "lifetime_energy_consumption_l1", + "lifetime_balanced_net_energy_consumption_l1", + "backfeed_ct_energy_delivered_l1", + ): + assert (entity_state := hass.states.get(f"{ENTITY_BASE}_{entity}")) + assert entity_state.state == STATE_UNKNOWN + + @pytest.mark.parametrize( ("mock_envoy"), [ diff --git a/tests/components/esphome/test_bluetooth.py b/tests/components/esphome/test_bluetooth.py index ded9a776a8ad..9e8947049c15 100644 --- a/tests/components/esphome/test_bluetooth.py +++ b/tests/components/esphome/test_bluetooth.py @@ -1,5 +1,6 @@ """Test the ESPHome bluetooth integration.""" +import asyncio from collections.abc import Callable from typing import Any from unittest.mock import MagicMock, patch @@ -10,6 +11,7 @@ from aioesphomeapi import ( BluetoothScannerState, BluetoothScannerStateResponse, ) +import pytest from homeassistant.components import bluetooth from homeassistant.components.bluetooth import BluetoothScanningMode @@ -343,3 +345,22 @@ async def test_scanning_mode_default_pinned_before_register( # habluetooth auto-mode worker is spawned at registration time. set_mode_mock.assert_called_once_with(BluetoothScannerMode.PASSIVE) assert requested_at_register == [BluetoothScanningMode.AUTO] + + +async def test_bluetooth_disconnect_fails_parked_slot_waiter( + hass: HomeAssistant, mock_bluetooth_entry_with_raw_adv: MockESPHomeDevice +) -> None: + """Test a parked BLE slot waiter fails fast when the entry disconnects.""" + entry_data = mock_bluetooth_entry_with_raw_adv.entry.runtime_data + bluetooth_device = entry_data.bluetooth_device + assert bluetooth_device is not None + task = hass.async_create_task(bluetooth_device.wait_for_ble_connections_free(60.0)) + await asyncio.sleep(0) + assert not task.done() + + await mock_bluetooth_entry_with_raw_adv.mock_disconnect(True) + await hass.async_block_till_done() + + with pytest.raises(TimeoutError, match="Proxy became unavailable"): + await task + assert bluetooth_device.available is False diff --git a/tests/components/flipr/test_config_flow.py b/tests/components/flipr/test_config_flow.py index c578156a265d..0ac3428e2303 100644 --- a/tests/components/flipr/test_config_flow.py +++ b/tests/components/flipr/test_config_flow.py @@ -26,7 +26,14 @@ async def test_full_flow(hass: HomeAssistant, mock_flipr_client: AsyncMock) -> N result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_USER}, - data={ + ) + + 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: "dummylogin", CONF_PASSWORD: "dummypass", }, @@ -63,7 +70,14 @@ async def test_errors( result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_USER}, - data={ + ) + + 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: "nada", CONF_PASSWORD: "nadap", }, @@ -102,11 +116,19 @@ async def test_no_flipr_found( result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_USER}, - data={ + ) + + 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: "nada", CONF_PASSWORD: "nadap", }, ) + assert result["type"] is FlowResultType.FORM assert result["step_id"] == "user" assert result["errors"] == {"base": "no_flipr_id_found"} @@ -117,7 +139,14 @@ async def test_no_flipr_found( result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_USER}, - data={ + ) + + 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: "dummylogin", CONF_PASSWORD: "dummypass", }, diff --git a/tests/components/fritzbox/test_config_flow.py b/tests/components/fritzbox/test_config_flow.py index 0c8a7996898c..1f51b4310daf 100644 --- a/tests/components/fritzbox/test_config_flow.py +++ b/tests/components/fritzbox/test_config_flow.py @@ -90,8 +90,15 @@ async def test_user_auth_failed(hass: HomeAssistant, fritz: Mock) -> None: fritz().login.side_effect = [LoginError("Boom"), mock.DEFAULT] result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": SOURCE_USER}, data=MOCK_USER_DATA + 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=MOCK_USER_DATA + ) + assert result["type"] is FlowResultType.FORM assert result["step_id"] == "user" assert result["errors"]["base"] == "invalid_auth" @@ -102,7 +109,13 @@ async def test_user_not_successful(hass: HomeAssistant, fritz: Mock) -> None: fritz().login.side_effect = OSError("Boom") result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": SOURCE_USER}, data=MOCK_USER_DATA + 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=MOCK_USER_DATA ) assert result["type"] is FlowResultType.ABORT assert result["reason"] == "no_devices_found" @@ -110,14 +123,17 @@ async def test_user_not_successful(hass: HomeAssistant, fritz: Mock) -> None: async def test_user_already_configured(hass: HomeAssistant, fritz: Mock) -> None: """Test starting a flow by user when already configured.""" - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": SOURCE_USER}, data=MOCK_USER_DATA - ) - assert result["type"] is FlowResultType.CREATE_ENTRY - assert not result["result"].unique_id + mock_config = MockConfigEntry(domain=DOMAIN, data=MOCK_USER_DATA) + mock_config.add_to_hass(hass) result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": SOURCE_USER}, data=MOCK_USER_DATA + 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=MOCK_USER_DATA ) assert result["type"] is FlowResultType.ABORT assert result["reason"] == "already_configured" @@ -409,15 +425,13 @@ async def test_ssdp_already_in_progress_host(hass: HomeAssistant, fritz: Mock) - async def test_ssdp_already_configured(hass: HomeAssistant, fritz: Mock) -> None: """Test starting a flow from discovery when already configured.""" - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": SOURCE_USER}, data=MOCK_USER_DATA - ) - assert result["type"] is FlowResultType.CREATE_ENTRY - assert not result["result"].unique_id + mock_config = MockConfigEntry(domain=DOMAIN, data=MOCK_USER_DATA) + mock_config.add_to_hass(hass) + assert not mock_config.unique_id result2 = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_SSDP}, data=MOCK_SSDP_DATA["ip4_valid"] ) assert result2["type"] is FlowResultType.ABORT assert result2["reason"] == "already_configured" - assert result["result"].unique_id == "only-a-test" + assert mock_config.unique_id == "only-a-test" diff --git a/tests/components/fyta/test_image.py b/tests/components/fyta/test_image.py index 82d2e2237445..8580f156247c 100644 --- a/tests/components/fyta/test_image.py +++ b/tests/components/fyta/test_image.py @@ -15,6 +15,7 @@ from homeassistant.components.image import ImageEntity from homeassistant.const import STATE_UNAVAILABLE, Platform from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er +from homeassistant.util import dt as dt_util from . import setup_platform @@ -148,6 +149,9 @@ async def test_update_image( assert image_entity.image_url == "http://www.plant_picture.com/picture1" assert image_state_1 != image_state_2 + # The state is image_last_updated serialized, so it has to carry a timezone + assert dt_util.parse_datetime(image_state_2.state).tzinfo is not None + async def test_update_user_image_error( freezer: FrozenDateTimeFactory, diff --git a/tests/components/gardena_bluetooth/snapshots/test_sensor.ambr b/tests/components/gardena_bluetooth/snapshots/test_sensor.ambr index 43cc42f665bc..c98a9e250104 100644 --- a/tests/components/gardena_bluetooth/snapshots/test_sensor.ambr +++ b/tests/components/gardena_bluetooth/snapshots/test_sensor.ambr @@ -31,6 +31,72 @@ 'state': '45', }) # --- +# name: test_sensors[aqua_contour][sensor.mock_title_activation_reason-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : list([ + 'none', + 'manual', + 'schedule', + 'external', + 'setup', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.mock_title_activation_reason', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Activation reason', + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Activation reason', + 'platform': 'gardena_bluetooth', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'activation_reason', + 'unique_id': '00000000-0000-0000-0000-000000000003-aqua_contour_activation_reason', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensors[aqua_contour][sensor.mock_title_activation_reason-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'enum', + : 'Mock Title Activation reason', + : list([ + 'none', + 'manual', + 'schedule', + 'external', + 'setup', + ]), + }), + 'context': , + 'entity_id': 'sensor.mock_title_activation_reason', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'schedule', + }) +# --- # name: test_sensors[aqua_contour][sensor.mock_title_battery-entry] EntityRegistryEntrySnapshot({ 'aliases': list([ @@ -433,6 +499,100 @@ 'state': '111', }) # --- +# name: test_sensors[aqua_contour][sensor.mock_title_skipped_reason-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : list([ + 'none', + 'rain_pause', + 'humidity_sensor', + 'rain_sensor', + 'watering_already_active', + 'battery_empty', + 'other_schedule_with_same_start_time', + 'contour_not_active', + 'contour_not_enabled_for_position', + 'contour_data_invalid', + 'position_changed', + 'charging_cable_plugged', + 'manual_mode', + 'no_water', + 'valve_motor_error', + 'sprinkler_motor_error', + 'rotation_sensor_error', + 'operational_mode_changed', + 'irrigation_control_changed', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.mock_title_skipped_reason', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Skipped reason', + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Skipped reason', + 'platform': 'gardena_bluetooth', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'skipped_reason', + 'unique_id': '00000000-0000-0000-0000-000000000003-aqua_contour_skipped_reason', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensors[aqua_contour][sensor.mock_title_skipped_reason-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'enum', + : 'Mock Title Skipped reason', + : list([ + 'none', + 'rain_pause', + 'humidity_sensor', + 'rain_sensor', + 'watering_already_active', + 'battery_empty', + 'other_schedule_with_same_start_time', + 'contour_not_active', + 'contour_not_enabled_for_position', + 'contour_data_invalid', + 'position_changed', + 'charging_cable_plugged', + 'manual_mode', + 'no_water', + 'valve_motor_error', + 'sprinkler_motor_error', + 'rotation_sensor_error', + 'operational_mode_changed', + 'irrigation_control_changed', + ]), + }), + 'context': , + 'entity_id': 'sensor.mock_title_skipped_reason', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'rain_sensor', + }) +# --- # name: test_sensors[aqua_contour][sensor.mock_title_watering_finished-entry] EntityRegistryEntrySnapshot({ 'aliases': list([ diff --git a/tests/components/gardena_bluetooth/test_sensor.py b/tests/components/gardena_bluetooth/test_sensor.py index 6cb2f625b720..f35fcb55df50 100644 --- a/tests/components/gardena_bluetooth/test_sensor.py +++ b/tests/components/gardena_bluetooth/test_sensor.py @@ -15,7 +15,7 @@ from gardena_bluetooth.const import ( Spray, Valve, ) -from gardena_bluetooth.parse import ActivationReason, ErrorData +from gardena_bluetooth.parse import ActivationReason, ErrorData, SkipReason from habluetooth import BluetoothServiceInfo import pytest from syrupy.assertion import SnapshotAssertion @@ -114,6 +114,12 @@ async def test_setup( AquaContourWatering.remaining_watering_time.unique_id: ( AquaContourWatering.remaining_watering_time.encode(100) ), + AquaContourWatering.activation_reason.unique_id: AquaContourWatering.activation_reason.encode( + ActivationReason.SCHEDULE + ), + AquaContourWatering.skipped_reason.unique_id: AquaContourWatering.skipped_reason.encode( + SkipReason.RAIN_SENSOR + ), }, id="aqua_contour", ), diff --git a/tests/components/gdacs/test_config_flow.py b/tests/components/gdacs/test_config_flow.py index da9b2f7c9bff..11f09f316e31 100644 --- a/tests/components/gdacs/test_config_flow.py +++ b/tests/components/gdacs/test_config_flow.py @@ -25,12 +25,17 @@ def gdacs_setup_fixture(): async def test_duplicate_error(hass: HomeAssistant, config_entry) -> None: """Test that errors are shown when duplicates are added.""" - conf = {CONF_LATITUDE: -41.2, CONF_LONGITUDE: 174.7, CONF_RADIUS: 25} + hass.config.latitude = -41.2 + hass.config.longitude = 174.7 + conf = {CONF_RADIUS: 25} config_entry.add_to_hass(hass) result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_USER}, data=conf + DOMAIN, context={"source": config_entries.SOURCE_USER} ) + assert result["type"] is FlowResultType.FORM + + result = await hass.config_entries.flow.async_configure(result["flow_id"], conf) assert result["type"] is FlowResultType.ABORT assert result["reason"] == "already_configured" @@ -51,8 +56,11 @@ async def test_step_user(hass: HomeAssistant) -> None: conf = {CONF_RADIUS: 25} result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_USER}, data=conf + DOMAIN, context={"source": config_entries.SOURCE_USER} ) + assert result["type"] is FlowResultType.FORM + + result = await hass.config_entries.flow.async_configure(result["flow_id"], conf) assert result["type"] is FlowResultType.CREATE_ENTRY assert result["title"] == "-41.2, 174.7" assert result["data"] == { diff --git a/tests/components/generic_hygrostat/test_humidifier.py b/tests/components/generic_hygrostat/test_humidifier.py index c088a3fbefb0..d268b14a155e 100644 --- a/tests/components/generic_hygrostat/test_humidifier.py +++ b/tests/components/generic_hygrostat/test_humidifier.py @@ -1856,7 +1856,7 @@ async def test_device_id( device_id=source_device_entry.id, ) await hass.async_block_till_done() - assert entity_registry.async_get("switch.test_source") is not None + assert entity_registry.async_get(source_entity.entity_id) is not None helper_config_entry = MockConfigEntry( data={}, @@ -1864,7 +1864,7 @@ async def test_device_id( options={ "device_class": "humidifier", "dry_tolerance": 2.0, - "humidifier": "switch.test_source", + "humidifier": source_entity.entity_id, "name": "Test", "target_sensor": ENT_SENSOR, "wet_tolerance": 4.0, @@ -1876,7 +1876,7 @@ async def test_device_id( assert await hass.config_entries.async_setup(helper_config_entry.entry_id) await hass.async_block_till_done() - helper_entity = entity_registry.async_get("humidifier.test") + helper_entity = entity_registry.async_get("humidifier.mock_title_test") assert helper_entity is not None assert helper_entity.device_id == source_entity.device_id @@ -1895,7 +1895,7 @@ async def test_device_id_yaml( identifiers={("switch", "identifier_test")}, connections={("mac", "30:31:32:33:34:35")}, ) - entity_registry.async_get_or_create( + source_entity = entity_registry.async_get_or_create( "switch", "test", "source", @@ -1911,7 +1911,7 @@ async def test_device_id_yaml( "humidifier": { "platform": "generic_hygrostat", "name": "test", - "humidifier": "switch.test_source", + "humidifier": source_entity.entity_id, "target_sensor": ENT_SENSOR, "unique_id": "generic_hygrostat_yaml", } diff --git a/tests/components/generic_hygrostat/test_init.py b/tests/components/generic_hygrostat/test_init.py index 1562f3be6c1a..2631be29d5eb 100644 --- a/tests/components/generic_hygrostat/test_init.py +++ b/tests/components/generic_hygrostat/test_init.py @@ -148,8 +148,8 @@ def track_entity_registry_actions(hass: HomeAssistant, entity_id: str) -> list[s @pytest.mark.parametrize( ("source_entity_id", "expected_helper_device_id", "expected_events"), [ - ("switch.test_unique", None, ["update"]), - ("sensor.test_unique", "switch_device_id", []), + ("switch.mock_title", None, ["update"]), + ("sensor.mock_title", "switch_device_id", []), ], indirect=["expected_helper_device_id"], ) @@ -172,7 +172,7 @@ async def test_async_handle_source_entity_changes_source_entity_removed( await hass.async_block_till_done() generic_hygrostat_entity_entry = entity_registry.async_get( - "humidifier.my_generic_hygrostat" + "humidifier.mock_title_my_generic_hygrostat" ) assert generic_hygrostat_entity_entry.device_id == switch_entity_entry.device_id @@ -195,7 +195,7 @@ async def test_async_handle_source_entity_changes_source_entity_removed( # Check that the helper entity is linked to the expected source device generic_hygrostat_entity_entry = entity_registry.async_get( - "humidifier.my_generic_hygrostat" + "humidifier.mock_title_my_generic_hygrostat" ) assert generic_hygrostat_entity_entry.device_id == expected_helper_device_id @@ -221,8 +221,8 @@ async def test_async_handle_source_entity_changes_source_entity_removed( @pytest.mark.parametrize( ("source_entity_id", "expected_helper_device_id", "expected_events"), [ - ("switch.test_unique", None, ["update"]), - ("sensor.test_unique", "switch_device_id", []), + ("switch.mock_title", None, ["update"]), + ("sensor.mock_title", "switch_device_id", []), ], indirect=["expected_helper_device_id"], ) @@ -245,7 +245,7 @@ async def test_async_handle_source_entity_changes_source_entity_removed_shared_d await hass.async_block_till_done() generic_hygrostat_entity_entry = entity_registry.async_get( - "humidifier.my_generic_hygrostat" + "humidifier.mock_title_my_generic_hygrostat" ) assert generic_hygrostat_entity_entry.device_id == switch_entity_entry.device_id @@ -268,7 +268,7 @@ async def test_async_handle_source_entity_changes_source_entity_removed_shared_d # Check that the helper entity is linked to the expected source device generic_hygrostat_entity_entry = entity_registry.async_get( - "humidifier.my_generic_hygrostat" + "humidifier.mock_title_my_generic_hygrostat" ) assert generic_hygrostat_entity_entry.device_id == expected_helper_device_id @@ -302,8 +302,8 @@ async def test_async_handle_source_entity_changes_source_entity_removed_shared_d "expected_events", ), [ - ("switch.test_unique", 1, None, ["update"]), - ("sensor.test_unique", 0, "switch_device_id", []), + ("switch.mock_title", 1, None, ["update"]), + ("sensor.mock_title", 0, "switch_device_id", []), ], indirect=["expected_helper_device_id"], ) @@ -327,7 +327,7 @@ async def test_async_handle_source_entity_changes_source_entity_removed_from_dev await hass.async_block_till_done() generic_hygrostat_entity_entry = entity_registry.async_get( - "humidifier.my_generic_hygrostat" + "humidifier.mock_title_my_generic_hygrostat" ) assert generic_hygrostat_entity_entry.device_id == switch_entity_entry.device_id @@ -351,7 +351,7 @@ async def test_async_handle_source_entity_changes_source_entity_removed_from_dev # Check that the helper entity is linked to the expected source device generic_hygrostat_entity_entry = entity_registry.async_get( - "humidifier.my_generic_hygrostat" + "humidifier.mock_title_my_generic_hygrostat" ) assert generic_hygrostat_entity_entry.device_id == expected_helper_device_id @@ -377,7 +377,7 @@ async def test_async_handle_source_entity_changes_source_entity_removed_from_dev ) @pytest.mark.parametrize( ("source_entity_id", "unload_entry_calls", "expected_events"), - [("switch.test_unique", 1, ["update"]), ("sensor.test_unique", 0, [])], + [("switch.mock_title", 1, ["update"]), ("sensor.mock_title", 0, [])], ) async def test_async_handle_source_entity_changes_source_entity_moved_other_device( hass: HomeAssistant, @@ -403,7 +403,7 @@ async def test_async_handle_source_entity_changes_source_entity_moved_other_devi await hass.async_block_till_done() generic_hygrostat_entity_entry = entity_registry.async_get( - "humidifier.my_generic_hygrostat" + "humidifier.mock_title_my_generic_hygrostat" ) assert generic_hygrostat_entity_entry.device_id == switch_entity_entry.device_id @@ -430,7 +430,7 @@ async def test_async_handle_source_entity_changes_source_entity_moved_other_devi # Check that the helper entity is linked to the expected source device switch_entity_entry = entity_registry.async_get(switch_entity_entry.entity_id) generic_hygrostat_entity_entry = entity_registry.async_get( - "humidifier.my_generic_hygrostat" + "humidifier.mock_title_my_generic_hygrostat" ) assert generic_hygrostat_entity_entry.device_id == switch_entity_entry.device_id @@ -459,8 +459,8 @@ async def test_async_handle_source_entity_changes_source_entity_moved_other_devi @pytest.mark.parametrize( ("source_entity_id", "new_entity_id", "config_key"), [ - ("switch.test_unique", "switch.new_entity_id", "humidifier"), - ("sensor.test_unique", "sensor.new_entity_id", "target_sensor"), + ("switch.mock_title", "switch.new_entity_id", "humidifier"), + ("sensor.mock_title", "sensor.new_entity_id", "target_sensor"), ], ) async def test_async_handle_source_entity_new_entity_id( @@ -482,7 +482,7 @@ async def test_async_handle_source_entity_new_entity_id( await hass.async_block_till_done() generic_hygrostat_entity_entry = entity_registry.async_get( - "humidifier.my_generic_hygrostat" + "humidifier.mock_title_my_generic_hygrostat" ) assert generic_hygrostat_entity_entry.device_id == switch_entity_entry.device_id @@ -558,7 +558,7 @@ async def test_migration_1_1( switch_device = device_registry.async_get(switch_device.id) assert generic_hygrostat_config_entry.entry_id not in switch_device.config_entries generic_hygrostat_entity_entry = entity_registry.async_get( - "humidifier.my_generic_hygrostat" + "humidifier.mock_title_my_generic_hygrostat" ) assert generic_hygrostat_entity_entry.device_id == switch_entity_entry.device_id diff --git a/tests/components/generic_thermostat/test_climate.py b/tests/components/generic_thermostat/test_climate.py index 8b5072931bca..8f5c9fa580e0 100644 --- a/tests/components/generic_thermostat/test_climate.py +++ b/tests/components/generic_thermostat/test_climate.py @@ -1848,14 +1848,14 @@ async def test_device_id( device_id=source_device_entry.id, ) await hass.async_block_till_done() - assert entity_registry.async_get("switch.test_source") is not None + assert entity_registry.async_get(source_entity.entity_id) is not None helper_config_entry = MockConfigEntry( data={}, domain=DOMAIN, options={ "name": "Test", - "heater": "switch.test_source", + "heater": source_entity.entity_id, "target_sensor": ENT_SENSOR, "ac_mode": False, "cold_tolerance": 0.3, @@ -1868,7 +1868,7 @@ async def test_device_id( assert await hass.config_entries.async_setup(helper_config_entry.entry_id) await hass.async_block_till_done() - helper_entity = entity_registry.async_get("climate.test") + helper_entity = entity_registry.async_get("climate.mock_title_test") assert helper_entity is not None assert helper_entity.device_id == source_entity.device_id diff --git a/tests/components/generic_thermostat/test_init.py b/tests/components/generic_thermostat/test_init.py index 240a82199094..c0d5f359caa8 100644 --- a/tests/components/generic_thermostat/test_init.py +++ b/tests/components/generic_thermostat/test_init.py @@ -152,8 +152,8 @@ def track_entity_registry_actions(hass: HomeAssistant, entity_id: str) -> list[s @pytest.mark.parametrize( ("source_entity_id", "expected_helper_device_id", "expected_events"), [ - ("switch.test_unique", None, ["update"]), - ("sensor.test_unique", "switch_device_id", []), + ("switch.mock_title", None, ["update"]), + ("sensor.mock_title", "switch_device_id", []), ], indirect=["expected_helper_device_id"], ) @@ -176,7 +176,7 @@ async def test_async_handle_source_entity_changes_source_entity_removed( await hass.async_block_till_done() generic_thermostat_entity_entry = entity_registry.async_get( - "climate.my_generic_thermostat" + "climate.mock_title_my_generic_thermostat" ) assert generic_thermostat_entity_entry.device_id == switch_entity_entry.device_id @@ -199,7 +199,7 @@ async def test_async_handle_source_entity_changes_source_entity_removed( # Check that the helper entity is linked to the expected source device generic_thermostat_entity_entry = entity_registry.async_get( - "climate.my_generic_thermostat" + "climate.mock_title_my_generic_thermostat" ) assert generic_thermostat_entity_entry.device_id == expected_helper_device_id @@ -226,8 +226,8 @@ async def test_async_handle_source_entity_changes_source_entity_removed( @pytest.mark.parametrize( ("source_entity_id", "expected_helper_device_id", "expected_events"), [ - ("switch.test_unique", None, ["update"]), - ("sensor.test_unique", "switch_device_id", []), + ("switch.mock_title", None, ["update"]), + ("sensor.mock_title", "switch_device_id", []), ], indirect=["expected_helper_device_id"], ) @@ -250,7 +250,7 @@ async def test_async_handle_source_entity_changes_source_entity_removed_shared_d await hass.async_block_till_done() generic_thermostat_entity_entry = entity_registry.async_get( - "climate.my_generic_thermostat" + "climate.mock_title_my_generic_thermostat" ) assert generic_thermostat_entity_entry.device_id == switch_entity_entry.device_id @@ -273,7 +273,7 @@ async def test_async_handle_source_entity_changes_source_entity_removed_shared_d # Check that the helper entity is linked to the expected source device generic_thermostat_entity_entry = entity_registry.async_get( - "climate.my_generic_thermostat" + "climate.mock_title_my_generic_thermostat" ) assert generic_thermostat_entity_entry.device_id == expected_helper_device_id @@ -308,8 +308,8 @@ async def test_async_handle_source_entity_changes_source_entity_removed_shared_d "expected_events", ), [ - ("switch.test_unique", 1, None, ["update"]), - ("sensor.test_unique", 0, "switch_device_id", []), + ("switch.mock_title", 1, None, ["update"]), + ("sensor.mock_title", 0, "switch_device_id", []), ], indirect=["expected_helper_device_id"], ) @@ -333,7 +333,7 @@ async def test_async_handle_source_entity_changes_source_entity_removed_from_dev await hass.async_block_till_done() generic_thermostat_entity_entry = entity_registry.async_get( - "climate.my_generic_thermostat" + "climate.mock_title_my_generic_thermostat" ) assert generic_thermostat_entity_entry.device_id == switch_entity_entry.device_id @@ -357,7 +357,7 @@ async def test_async_handle_source_entity_changes_source_entity_removed_from_dev # Check that the helper entity is linked to the expected source device generic_thermostat_entity_entry = entity_registry.async_get( - "climate.my_generic_thermostat" + "climate.mock_title_my_generic_thermostat" ) assert generic_thermostat_entity_entry.device_id == expected_helper_device_id @@ -384,7 +384,7 @@ async def test_async_handle_source_entity_changes_source_entity_removed_from_dev ) @pytest.mark.parametrize( ("source_entity_id", "unload_entry_calls", "expected_events"), - [("switch.test_unique", 1, ["update"]), ("sensor.test_unique", 0, [])], + [("switch.mock_title", 1, ["update"]), ("sensor.mock_title", 0, [])], ) async def test_async_handle_source_entity_changes_source_entity_moved_other_device( hass: HomeAssistant, @@ -410,7 +410,7 @@ async def test_async_handle_source_entity_changes_source_entity_moved_other_devi await hass.async_block_till_done() generic_thermostat_entity_entry = entity_registry.async_get( - "climate.my_generic_thermostat" + "climate.mock_title_my_generic_thermostat" ) assert generic_thermostat_entity_entry.device_id == switch_entity_entry.device_id @@ -439,7 +439,7 @@ async def test_async_handle_source_entity_changes_source_entity_moved_other_devi # Check that the helper entity is linked to the expected source device switch_entity_entry = entity_registry.async_get(switch_entity_entry.entity_id) generic_thermostat_entity_entry = entity_registry.async_get( - "climate.my_generic_thermostat" + "climate.mock_title_my_generic_thermostat" ) assert generic_thermostat_entity_entry.device_id == switch_entity_entry.device_id @@ -471,8 +471,8 @@ async def test_async_handle_source_entity_changes_source_entity_moved_other_devi @pytest.mark.parametrize( ("source_entity_id", "new_entity_id", "config_key"), [ - ("switch.test_unique", "switch.new_entity_id", "heater"), - ("sensor.test_unique", "sensor.new_entity_id", "target_sensor"), + ("switch.mock_title", "switch.new_entity_id", "heater"), + ("sensor.mock_title", "sensor.new_entity_id", "target_sensor"), ], ) async def test_async_handle_source_entity_new_entity_id( @@ -494,7 +494,7 @@ async def test_async_handle_source_entity_new_entity_id( await hass.async_block_till_done() generic_thermostat_entity_entry = entity_registry.async_get( - "climate.my_generic_thermostat" + "climate.mock_title_my_generic_thermostat" ) assert generic_thermostat_entity_entry.device_id == switch_entity_entry.device_id @@ -571,7 +571,7 @@ async def test_migration_1_1( switch_device = device_registry.async_get(switch_device.id) assert generic_thermostat_config_entry.entry_id not in switch_device.config_entries generic_thermostat_entity_entry = entity_registry.async_get( - "climate.my_generic_thermostat" + "climate.mock_title_my_generic_thermostat" ) assert generic_thermostat_entity_entry.device_id == switch_entity_entry.device_id diff --git a/tests/components/go2rtc/__init__.py b/tests/components/go2rtc/__init__.py index c7c07be1f77c..1944368b29a7 100644 --- a/tests/components/go2rtc/__init__.py +++ b/tests/components/go2rtc/__init__.py @@ -6,14 +6,14 @@ from homeassistant.components.camera import Camera, CameraEntityFeature class MockCamera(Camera): """Mock Camera Entity.""" - _attr_name = "Test" _attr_supported_features: CameraEntityFeature = CameraEntityFeature.STREAM - def __init__(self, unique_id: str | None) -> None: + def __init__(self, unique_id: str | None, name: str = "Test") -> None: """Initialize the mock entity.""" super().__init__() self._stream_source: str | None = "rtsp://stream" self._attr_unique_id = unique_id + self._attr_name = name def set_stream_source(self, stream_source: str | None) -> None: """Set the stream source.""" diff --git a/tests/components/go2rtc/conftest.py b/tests/components/go2rtc/conftest.py index 41d2f03031f1..c94fa8bee0c4 100644 --- a/tests/components/go2rtc/conftest.py +++ b/tests/components/go2rtc/conftest.py @@ -2,7 +2,8 @@ from collections.abc import Generator from pathlib import Path -from unittest.mock import AsyncMock, Mock, patch +from typing import Any +from unittest.mock import AsyncMock, Mock, create_autospec, patch from awesomeversion import AwesomeVersion from go2rtc_client.rest import ( @@ -11,6 +12,7 @@ from go2rtc_client.rest import ( _StreamClient, _WebRTCClient, ) +from go2rtc_client.ws import Go2RtcWsClient import pytest from homeassistant.components.camera import DOMAIN as CAMERA_DOMAIN @@ -82,6 +84,19 @@ def ws_client() -> Generator[Mock]: yield ws_client_mock.return_value +@pytest.fixture +def ws_clients() -> Generator[list[Mock]]: + """Mock go2rtc websocket clients with a separate mock per created client.""" + clients: list[Mock] = [] + + def create_client(*args: Any, **kwargs: Any) -> Mock: + clients.append(client := create_autospec(Go2RtcWsClient, instance=True)) + return client + + with patch(f"{GO2RTC_PATH}.Go2RtcWsClient", side_effect=create_client): + yield clients + + @pytest.fixture def server_stdout() -> list[str]: """Server stdout lines.""" @@ -198,13 +213,12 @@ def camera_unique_id() -> str | None: return "camera_unique_id" -@pytest.fixture -async def init_test_integration( +async def _setup_test_integration( hass: HomeAssistant, integration_config_entry: ConfigEntry, - camera_unique_id: str | None, -) -> MockCamera: - """Initialize components.""" + cameras: list[MockCamera], +) -> None: + """Set up the test integration with the given cameras.""" async def async_setup_entry_init( hass: HomeAssistant, config_entry: ConfigEntry @@ -232,17 +246,38 @@ async def init_test_integration( async_unload_entry=async_unload_entry_init, ), ) - test_camera = MockCamera(camera_unique_id) - setup_test_component_platform( - hass, CAMERA_DOMAIN, [test_camera], from_config_entry=True - ) + setup_test_component_platform(hass, CAMERA_DOMAIN, cameras, from_config_entry=True) mock_platform(hass, f"{TEST_DOMAIN}.config_flow", Mock()) with mock_config_flow(TEST_DOMAIN, ConfigFlow): assert await hass.config_entries.async_setup(integration_config_entry.entry_id) await hass.async_block_till_done() - return test_camera + +@pytest.fixture +async def init_test_integration( + hass: HomeAssistant, + integration_config_entry: ConfigEntry, + camera_unique_id: str | None, +) -> MockCamera: + """Initialize components.""" + camera = MockCamera(camera_unique_id) + await _setup_test_integration(hass, integration_config_entry, [camera]) + return camera + + +@pytest.fixture +async def init_test_integration_two_cameras( + hass: HomeAssistant, + integration_config_entry: ConfigEntry, +) -> tuple[MockCamera, MockCamera]: + """Initialize components with two cameras.""" + cameras = ( + MockCamera("camera_unique_id_1"), + MockCamera("camera_unique_id_2", "Test 2"), + ) + await _setup_test_integration(hass, integration_config_entry, list(cameras)) + return cameras @pytest.fixture diff --git a/tests/components/go2rtc/test_init.py b/tests/components/go2rtc/test_init.py index 4a3208321438..6d073c3c2887 100644 --- a/tests/components/go2rtc/test_init.py +++ b/tests/components/go2rtc/test_init.py @@ -1,5 +1,6 @@ """The tests for the go2rtc component.""" +import asyncio from collections.abc import Awaitable, Callable import logging from pathlib import Path @@ -194,14 +195,16 @@ async def _test_setup_and_signaling( receive_message_callback.assert_called_once_with( WebRTCError("go2rtc_webrtc_offer_failed", "Camera has no stream source") ) - teardown.assert_called_once() + # Only the sessions of the failing camera are closed, the provider stays up + teardown.assert_not_called() # We use one ws_client mock for all sessions assert ws_client.close.call_count == len(sessions) + assert not provider._sessions await hass.config_entries.async_unload(config_entry.entry_id) await hass.async_block_till_done() assert config_entry.state is ConfigEntryState.NOT_LOADED - assert teardown.call_count == 2 + teardown.assert_called_once() @pytest.mark.usefixtures( @@ -466,8 +469,7 @@ async def test_close_session( session_id = "session_id" # Session doesn't exist - with pytest.raises(KeyError): - camera.close_webrtc_session(session_id) + camera.close_webrtc_session(session_id) ws_client.close.assert_not_called() # Store session @@ -485,13 +487,183 @@ async def test_close_session( camera.close_webrtc_session(session_id) ws_client.close.assert_called_once() - # Close again should raise an error + # Closing an already closed session is a no-op ws_client.reset_mock() - with pytest.raises(KeyError): - camera.close_webrtc_session(session_id) + camera.close_webrtc_session(session_id) ws_client.close.assert_not_called() +async def _fail_with_offer(hass: HomeAssistant, camera: MockCamera, error: str) -> None: + """Update the stream source via a new WebRTC offer, expecting an error.""" + send_message = Mock(spec_set=WebRTCSendMessage) + await camera.async_handle_async_webrtc_offer(OFFER_SDP, "new_session", send_message) + send_message.assert_called_once_with( + WebRTCError("go2rtc_webrtc_offer_failed", error) + ) + + +async def _fail_with_image_request( + hass: HomeAssistant, camera: MockCamera, error: str +) -> None: + """Update the stream source via a snapshot request, expecting an error.""" + with pytest.raises(HomeAssistantError, match=error): + await async_get_image(hass, camera.entity_id) + + +@pytest.mark.parametrize( + ("stream_source", "error"), + [ + ( + None, + "Camera has no stream source", + ), + ( + "invalid://not_supported", + "Stream source is not supported by go2rtc", + ), + ], + ids=["no_stream_source", "unsupported_stream_source"], +) +@pytest.mark.parametrize( + "trigger", + [ + _fail_with_offer, + _fail_with_image_request, + ], + ids=["offer", "image_request"], +) +@pytest.mark.usefixtures("init_integration") +async def test_invalid_stream_source_closes_only_sessions_of_that_camera( + hass: HomeAssistant, + ws_clients: list[Mock], + init_test_integration_two_cameras: tuple[MockCamera, MockCamera], + caplog: pytest.LogCaptureFixture, + trigger: Callable[[HomeAssistant, MockCamera, str], Awaitable[None]], + stream_source: str | None, + error: str, +) -> None: + """Test an invalid stream source only closes the sessions of that camera.""" + camera_1, camera_2 = init_test_integration_two_cameras + + await camera_1.async_handle_async_webrtc_offer(OFFER_SDP, "session_1", Mock()) + await camera_2.async_handle_async_webrtc_offer(OFFER_SDP, "session_2", Mock()) + ws_client_1, ws_client_2 = ws_clients + ws_client_1.reset_mock() + ws_client_2.reset_mock() + caplog.clear() + + camera_1.set_stream_source(stream_source) + await trigger(hass, camera_1, error) + + ws_client_1.close.assert_called_once() + ws_client_2.close.assert_not_called() + + # The session of camera 1 is gone + await camera_1.async_on_webrtc_candidate( + "session_1", RTCIceCandidateInit("candidate") + ) + assert ( + "homeassistant.components.go2rtc", + logging.DEBUG, + "Unknown session session_1. Ignoring candidate", + ) in caplog.record_tuples + ws_client_1.send.assert_not_called() + + # Closing the already closed session, e.g. by the frontend, is a no-op + camera_1.close_webrtc_session("session_1") + ws_client_1.close.assert_called_once() + + # The session of camera 2 is untouched + await camera_2.async_on_webrtc_candidate( + "session_2", RTCIceCandidateInit("candidate") + ) + ws_client_2.send.assert_called_once_with(WebRTCCandidate("candidate")) + camera_2.close_webrtc_session("session_2") + ws_client_2.close.assert_called_once() + + +@pytest.mark.usefixtures("init_integration") +async def test_unregister_camera_closes_only_sessions_of_that_camera( + ws_clients: list[Mock], + init_test_integration_two_cameras: tuple[MockCamera, MockCamera], +) -> None: + """Test removing a camera closes only the sessions of that camera.""" + camera_1, camera_2 = init_test_integration_two_cameras + + await camera_1.async_handle_async_webrtc_offer(OFFER_SDP, "session_1", Mock()) + await camera_2.async_handle_async_webrtc_offer(OFFER_SDP, "session_2", Mock()) + ws_client_1, ws_client_2 = ws_clients + ws_client_1.reset_mock() + ws_client_2.reset_mock() + + await camera_1.async_remove() + + ws_client_1.close.assert_called_once() + ws_client_2.close.assert_not_called() + + # The session of camera 2 is untouched + await camera_2.async_on_webrtc_candidate( + "session_2", RTCIceCandidateInit("candidate") + ) + ws_client_2.send.assert_called_once_with(WebRTCCandidate("candidate")) + + +@pytest.mark.usefixtures("init_integration") +async def test_teardown_while_a_camera_is_removed( + ws_clients: list[Mock], + init_test_integration_two_cameras: tuple[MockCamera, MockCamera], +) -> None: + """Test tearing down the provider while a camera is removed.""" + camera_1, camera_2 = init_test_integration_two_cameras + + await camera_1.async_handle_async_webrtc_offer(OFFER_SDP, "session_1", Mock()) + await camera_2.async_handle_async_webrtc_offer(OFFER_SDP, "session_2", Mock()) + ws_client_1, ws_client_2 = ws_clients + assert isinstance(camera_1.webrtc_provider, WebRTCProvider) + provider = camera_1.webrtc_provider + + async def yield_control() -> None: + """Let the camera removal run while the teardown is in progress.""" + await asyncio.sleep(0) + + ws_client_1.close.side_effect = yield_control + ws_client_2.close.side_effect = yield_control + + await asyncio.gather(provider.teardown(), camera_2.async_remove()) + + ws_client_1.close.assert_called_once() + ws_client_2.close.assert_called_once() + assert not provider._sessions + + +@pytest.mark.usefixtures("init_integration") +async def test_camera_removed_while_a_snapshot_fails( + hass: HomeAssistant, + ws_clients: list[Mock], + init_test_integration: MockCamera, +) -> None: + """Test a camera being removed while a snapshot closes the same session.""" + camera = init_test_integration + + await camera.async_handle_async_webrtc_offer(OFFER_SDP, "session_1", Mock()) + (ws_client,) = ws_clients + + async def yield_control() -> None: + """Let the camera removal run while the snapshot is still failing.""" + await asyncio.sleep(0) + + ws_client.close.side_effect = yield_control + camera.set_stream_source(None) + + async def failing_snapshot() -> None: + with pytest.raises(HomeAssistantError, match="Camera has no stream source"): + await async_get_image(hass, camera.entity_id) + + await asyncio.gather(failing_snapshot(), camera.async_remove()) + + ws_client.close.assert_called_once() + + ERR_BINARY_NOT_FOUND = "Could not find go2rtc docker binary" ERR_CONNECT = "Could not connect to go2rtc instance" ERR_CONNECT_RETRY = ( diff --git a/tests/components/google/test_calendar.py b/tests/components/google/test_calendar.py index b28afe71b86f..ba7f18882897 100644 --- a/tests/components/google/test_calendar.py +++ b/tests/components/google/test_calendar.py @@ -388,10 +388,7 @@ async def test_update_error( with patch("homeassistant.util.utcnow", return_value=now): async_fire_time_changed(hass, now) - await hass.async_block_till_done() - # Ensure coordinator update completes - await hass.async_block_till_done() - await hass.async_block_till_done() + await hass.async_block_till_done(wait_background_tasks=True) # Entity is marked uanvailable due to API failure state = hass.states.get(TEST_ENTITY) @@ -420,10 +417,7 @@ async def test_update_error( with patch("homeassistant.util.utcnow", return_value=now): async_fire_time_changed(hass, now) - await hass.async_block_till_done() - # Ensure coordinator update completes - await hass.async_block_till_done() - await hass.async_block_till_done() + await hass.async_block_till_done(wait_background_tasks=True) # State updated with new API response state = hass.states.get(TEST_ENTITY) @@ -671,10 +665,7 @@ async def test_future_event_update_behavior( now += datetime.timedelta(minutes=60) freezer.move_to(now) async_fire_time_changed(hass, now) - await hass.async_block_till_done() - # Ensure coordinator update completes - await hass.async_block_till_done() - await hass.async_block_till_done() + await hass.async_block_till_done(wait_background_tasks=True) # Event has started state = hass.states.get(TEST_ENTITY) @@ -711,10 +702,7 @@ async def test_future_event_offset_update_behavior( now += datetime.timedelta(minutes=45) freezer.move_to(now) async_fire_time_changed(hass, now) - await hass.async_block_till_done() - # Ensure coordinator update completes - await hass.async_block_till_done() - await hass.async_block_till_done() + await hass.async_block_till_done(wait_background_tasks=True) # Event has not started, but the offset was reached state = hass.states.get(TEST_ENTITY) diff --git a/tests/components/google_generative_ai_conversation/test_init.py b/tests/components/google_generative_ai_conversation/test_init.py index bc139d00c903..03fd39fd62f1 100644 --- a/tests/components/google_generative_ai_conversation/test_init.py +++ b/tests/components/google_generative_ai_conversation/test_init.py @@ -1202,7 +1202,7 @@ async def test_migrate_entry_from_v2_3( conversation_device = attr.evolve( conversation_device, disabled_by=device_disabled_by ) - device_registry.devices[conversation_device.id] = conversation_device + device_registry._devices[conversation_device.id] = conversation_device conversation_entity = entity_registry.async_get_or_create( "conversation", DOMAIN, diff --git a/tests/components/group/test_init.py b/tests/components/group/test_init.py index a04e2ddc55f6..22b590e9477f 100644 --- a/tests/components/group/test_init.py +++ b/tests/components/group/test_init.py @@ -16,6 +16,7 @@ from homeassistant.const import ( ATTR_FRIENDLY_NAME, ATTR_ICON, EVENT_HOMEASSISTANT_START, + EVENT_STATE_CHANGED, SERVICE_RELOAD, STATE_CLOSED, STATE_HOME, @@ -24,7 +25,7 @@ from homeassistant.const import ( STATE_ON, STATE_UNKNOWN, ) -from homeassistant.core import CoreState, HomeAssistant +from homeassistant.core import Context, CoreState, HomeAssistant from homeassistant.helpers import entity_registry as er from homeassistant.setup import async_setup_component @@ -35,6 +36,7 @@ from tests.common import ( MockModule, MockPlatform, assert_setup_component, + async_capture_events, mock_integration, mock_platform, ) @@ -159,6 +161,7 @@ async def test_setup_group_with_mixed_groupable_states(hass: HomeAssistant) -> N mode=None, object_id=None, order=None, + context=None, ) await hass.async_block_till_done() @@ -181,6 +184,7 @@ async def test_setup_group_with_a_non_existing_state(hass: HomeAssistant) -> Non mode=None, object_id=None, order=None, + context=None, ) assert grp.state == STATE_ON @@ -202,6 +206,7 @@ async def test_setup_group_with_non_groupable_states(hass: HomeAssistant) -> Non mode=None, object_id=None, order=None, + context=None, ) assert grp.state is None @@ -218,6 +223,7 @@ async def test_setup_empty_group(hass: HomeAssistant) -> None: mode=None, object_id=None, order=None, + context=None, ) assert grp.state is None @@ -239,6 +245,7 @@ async def test_monitor_group(hass: HomeAssistant) -> None: mode=None, object_id=None, order=None, + context=None, ) # Test if group setup in our init mode is ok @@ -265,6 +272,7 @@ async def test_group_turns_off_if_all_off(hass: HomeAssistant) -> None: mode=None, object_id=None, order=None, + context=None, ) await hass.async_block_till_done() @@ -291,6 +299,7 @@ async def test_group_turns_on_if_all_are_off_and_one_turns_on( mode=None, object_id=None, order=None, + context=None, ) # Turn one on @@ -319,6 +328,7 @@ async def test_allgroup_stays_off_if_all_are_off_and_one_turns_on( mode=True, object_id=None, order=None, + context=None, ) # Turn one on @@ -345,6 +355,7 @@ async def test_allgroup_turn_on_if_last_turns_on(hass: HomeAssistant) -> None: mode=True, object_id=None, order=None, + context=None, ) # Turn one on @@ -371,6 +382,7 @@ async def test_expand_entity_ids(hass: HomeAssistant) -> None: mode=None, object_id=None, order=None, + context=None, ) assert sorted(["light.ceiling", "light.bowl"]) == sorted( @@ -396,6 +408,7 @@ async def test_expand_entity_ids_does_not_return_duplicates( mode=None, object_id=None, order=None, + context=None, ) assert sorted( @@ -423,6 +436,7 @@ async def test_expand_entity_ids_recursive(hass: HomeAssistant) -> None: mode=None, object_id=None, order=None, + context=None, ) assert sorted(["light.ceiling", "light.bowl"]) == sorted( @@ -451,6 +465,7 @@ async def test_get_entity_ids(hass: HomeAssistant) -> None: mode=None, object_id=None, order=None, + context=None, ) assert sorted(group.get_entity_ids(hass, test_group.entity_id)) == [ @@ -474,6 +489,7 @@ async def test_get_entity_ids_with_domain_filter(hass: HomeAssistant) -> None: mode=None, object_id=None, order=None, + context=None, ) assert group.get_entity_ids( @@ -511,6 +527,7 @@ async def test_group_being_init_before_first_tracked_state_is_set_to_on( mode=None, object_id=None, order=None, + context=None, ) hass.states.async_set("light.not_there_1", STATE_ON) @@ -539,6 +556,7 @@ async def test_group_being_init_before_first_tracked_state_is_set_to_off( mode=None, object_id=None, order=None, + context=None, ) hass.states.async_set("light.not_there_1", STATE_OFF) @@ -563,6 +581,7 @@ async def test_groups_get_unique_names(hass: HomeAssistant) -> None: mode=None, object_id=None, order=None, + context=None, ) grp2 = await group.Group.async_create_group( hass, @@ -573,6 +592,7 @@ async def test_groups_get_unique_names(hass: HomeAssistant) -> None: mode=None, object_id=None, order=None, + context=None, ) assert grp1.entity_id != grp2.entity_id @@ -592,6 +612,7 @@ async def test_expand_entity_ids_expands_nested_groups(hass: HomeAssistant) -> N mode=None, object_id=None, order=None, + context=None, ) await group.Group.async_create_group( hass, @@ -602,6 +623,7 @@ async def test_expand_entity_ids_expands_nested_groups(hass: HomeAssistant) -> N mode=None, object_id=None, order=None, + context=None, ) await group.Group.async_create_group( hass, @@ -612,6 +634,7 @@ async def test_expand_entity_ids_expands_nested_groups(hass: HomeAssistant) -> N mode=None, object_id=None, order=None, + context=None, ) assert sorted(group.expand_entity_ids(hass, ["group.group_of_groups"])) == [ @@ -638,6 +661,7 @@ async def test_set_assumed_state_based_on_tracked(hass: HomeAssistant) -> None: mode=None, object_id=None, order=None, + context=None, ) state = hass.states.get(test_group.entity_id) @@ -677,6 +701,7 @@ async def test_group_updated_after_device_tracker_zone_change( mode=None, object_id=None, order=None, + context=None, ) hass.states.async_set("device_tracker.Adam", "cool_state_not_home") @@ -703,6 +728,7 @@ async def test_is_on(hass: HomeAssistant) -> None: mode=None, object_id=None, order=None, + context=None, ) await hass.async_block_till_done() @@ -840,6 +866,7 @@ async def test_is_on_and_state_mixed_domains( mode=None, object_id=None, order=None, + context=None, ) await hass.async_block_till_done() @@ -883,6 +910,7 @@ async def test_reloading_groups(hass: HomeAssistant) -> None: mode=None, object_id=None, order=None, + context=None, ) await hass.async_block_till_done() @@ -960,6 +988,7 @@ async def test_setup(hass: HomeAssistant) -> None: mode=None, object_id=None, order=None, + context=None, ) await group.Group.async_create_group( hass, @@ -970,6 +999,7 @@ async def test_setup(hass: HomeAssistant) -> None: mode=None, object_id=None, order=None, + context=None, ) await hass.async_block_till_done() @@ -1013,6 +1043,8 @@ async def test_service_group_services_add_remove_entities(hass: HomeAssistant) - assert hass.services.has_service("group", group.SERVICE_SET) + create_context = Context() + created_events = async_capture_events(hass, EVENT_STATE_CHANGED) await hass.services.async_call( group.DOMAIN, group.SERVICE_SET, @@ -1021,6 +1053,7 @@ async def test_service_group_services_add_remove_entities(hass: HomeAssistant) - "name": "New Group", "entities": ["person.one", "person.two"], }, + context=create_context, ) await hass.async_block_till_done() @@ -1029,6 +1062,17 @@ async def test_service_group_services_add_remove_entities(hass: HomeAssistant) - assert group_state.attributes["friendly_name"] == "New Group" assert list(group_state.attributes["entity_id"]) == ["person.one", "person.two"] + # The group recomputes from its members right after, so assert on the state + # written when the entity was added rather than on the current state + created_event = next( + event + for event in created_events + if event.data["entity_id"] == "group.new_group" + and event.data["old_state"] is None + ) + assert created_event.context is create_context + + context = Context() await hass.services.async_call( group.DOMAIN, group.SERVICE_SET, @@ -1036,11 +1080,13 @@ async def test_service_group_services_add_remove_entities(hass: HomeAssistant) - "object_id": "new_group", "add_entities": "person.three", }, + context=context, ) await hass.async_block_till_done() group_state = hass.states.get("group.new_group") assert group_state.state == "home" assert "person.three" in list(group_state.attributes["entity_id"]) + assert group_state.context is context await hass.services.async_call( group.DOMAIN, @@ -1096,11 +1142,22 @@ async def test_service_group_set_group_remove_group(hass: HomeAssistant) -> None ["test.entity_bla1", "test.entity_id2"] ) - common.async_remove(hass, "user_test_group") + removed_events = async_capture_events(hass, EVENT_STATE_CHANGED) + context = Context() + await hass.services.async_call( + group.DOMAIN, + group.SERVICE_REMOVE, + {"object_id": "user_test_group"}, + blocking=True, + context=context, + ) await hass.async_block_till_done() group_state = hass.states.get("group.user_test_group") assert group_state is None + assert removed_events[-1].data["entity_id"] == "group.user_test_group" + assert removed_events[-1].data["new_state"] is None + assert removed_events[-1].context is context async def test_group_order(hass: HomeAssistant) -> None: diff --git a/tests/components/hassio/conftest.py b/tests/components/hassio/conftest.py index a1439058057d..7e20a621a8b4 100644 --- a/tests/components/hassio/conftest.py +++ b/tests/components/hassio/conftest.py @@ -10,7 +10,6 @@ from aiohasupervisor.models import AddonsStats, AddonState, InstalledAddonComple from aiohttp.test_utils import TestClient import pytest -from homeassistant.components.hassio.const import DATA_HASSIO_SUPERVISOR_USER from homeassistant.components.hassio.handler import HassIO from homeassistant.components.http.config import _DEFAULT_CONFIG as HTTP_DEFAULT_CONFIG from homeassistant.components.http.const import CONF_SERVER_PORT @@ -71,16 +70,23 @@ async def hassio_client_supervisor( hass: HomeAssistant, aiohttp_client: ClientSessionGenerator, hassio_stubs: None, -) -> TestClient: +) -> AsyncGenerator[TestClient]: """Return an authenticated HTTP client.""" - hassio_user = hass.data[DATA_HASSIO_SUPERVISOR_USER] - assert hassio_user.refresh_tokens - refresh_token = next(iter(hassio_user.refresh_tokens.values())) - access_token = hass.auth.async_create_access_token(refresh_token) - return await aiohttp_client( - hass.http.app, - headers={"Authorization": f"Bearer {access_token}"}, - ) + with ( + patch( + "homeassistant.components.hassio.auth.is_supervisor_unix_socket_request", + return_value=True, + ), + patch( + "homeassistant.components.http.auth.is_supervisor_unix_socket_request", + return_value=True, + ), + patch( + "homeassistant.components.http.ban.is_supervisor_unix_socket_request", + return_value=True, + ), + ): + yield await aiohttp_client(hass.http.app) @pytest.fixture @@ -91,11 +97,21 @@ def hass_supervisor_ws_client( """Return a websocket client authenticated as the Supervisor user.""" async def create_client() -> WebSocketGenerator: - hassio_user = hass.data[DATA_HASSIO_SUPERVISOR_USER] - assert hassio_user.refresh_tokens - refresh_token = next(iter(hassio_user.refresh_tokens.values())) - access_token = hass.auth.async_create_access_token(refresh_token) - return await hass_ws_client(hass, access_token=access_token) + with ( + patch( + "homeassistant.components.http.auth.is_supervisor_unix_socket_request", + return_value=True, + ), + patch( + "homeassistant.components.http.ban.is_supervisor_unix_socket_request", + return_value=True, + ), + patch( + "homeassistant.components.websocket_api.http.is_supervisor_unix_socket_request", + return_value=True, + ), + ): + return await hass_ws_client(hass, supervisor_unix_socket=True) return create_client diff --git a/tests/components/hassio/test_addon_panel.py b/tests/components/hassio/test_addon_panel.py index ca5fde3bc58a..5930b48f2394 100644 --- a/tests/components/hassio/test_addon_panel.py +++ b/tests/components/hassio/test_addon_panel.py @@ -1,19 +1,26 @@ """Test add-on panel.""" +from datetime import timedelta from http import HTTPStatus import os from unittest.mock import AsyncMock, patch +from aiohasupervisor import SupervisorError from aiohasupervisor.models import IngressPanel import pytest from homeassistant.components.hassio import DOMAIN -from homeassistant.const import EVENT_HOMEASSISTANT_START, EVENT_HOMEASSISTANT_STARTED +from homeassistant.components.hassio.const import ( + MAIN_COORDINATOR, + REQUEST_REFRESH_DELAY, +) +from homeassistant.config_entries import ConfigEntryState from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component +from homeassistant.util import dt as dt_util -from tests.common import MockUser -from tests.typing import ClientSessionGenerator +from tests.common import MockUser, async_fire_time_changed +from tests.typing import ClientSessionGenerator, WebSocketGenerator MOCK_ENVIRON = {"SUPERVISOR": "127.0.0.1", "SUPERVISOR_TOKEN": "abcdefgh"} @@ -24,10 +31,15 @@ def mock_all(all_setup_requests: None) -> None: @pytest.mark.usefixtures("supervisor_client") -async def test_hassio_addon_panel_startup( +async def test_hassio_addon_panel_registered_on_setup( hass: HomeAssistant, ingress_panels: AsyncMock ) -> None: - """Test startup and panel setup after event.""" + """Test enabled panels are registered as part of config entry setup. + + Regression test for https://github.com/home-assistant/supervisor/issues/7015: + registration must not depend on the one-shot EVENT_HOMEASSISTANT_START handler + that used to swallow Supervisor timeouts and never retry. + """ ingress_panels.return_value = { "test1": IngressPanel(enable=True, title="Test", icon="mdi:test", admin=False), "test2": IngressPanel( @@ -35,24 +47,145 @@ async def test_hassio_addon_panel_startup( ), } + with ( + patch( + "homeassistant.components.hassio.addon_panel._register_panel" + ) as mock_panel, + patch.dict(os.environ, MOCK_ENVIRON), + ): + await async_setup_component(hass, DOMAIN, {}) + await hass.async_block_till_done() + + mock_panel.assert_called_once_with( + hass, + "test1", + IngressPanel(enable=True, title="Test", icon="mdi:test", admin=False), + ) + + +@pytest.mark.usefixtures("supervisor_client") +async def test_hassio_addon_panel_registration( + hass: HomeAssistant, ingress_panels: AsyncMock +) -> None: + """Test panel registration calls frontend.async_register_built_in_panel.""" + ingress_panels.return_value = { + "test_addon": IngressPanel( + enable=True, title="Test Addon", icon="mdi:test-tube", admin=True + ), + } + + with ( + patch( + "homeassistant.components.hassio.addon_panel.frontend.async_register_built_in_panel" + ) as mock_register, + patch.dict(os.environ, MOCK_ENVIRON), + ): + await async_setup_component(hass, DOMAIN, {}) + await hass.async_block_till_done() + + mock_register.assert_any_call( + hass, + "app", + frontend_url_path="test_addon", + sidebar_title="Test Addon", + sidebar_icon="mdi:test-tube", + require_admin=True, + config={"addon": "test_addon"}, + update=True, + ) + + +async def test_hassio_addon_panel_setup_retries_after_transient_error( + hass: HomeAssistant, ingress_panels: AsyncMock +) -> None: + """Test a transient Supervisor error fetching panels causes setup to retry. + + Regression test for https://github.com/home-assistant/supervisor/issues/7015: + previously a timeout fetching panels at startup was logged and swallowed, + leaving panels missing forever with no retry. Panel data is now fetched as + part of the main coordinator's first refresh, so a transient failure causes + the whole config entry setup to retry until Supervisor is reachable again. + """ + ingress_panels.side_effect = SupervisorError("Timeout connecting to Supervisor") + + with patch.dict(os.environ, MOCK_ENVIRON): + result = await async_setup_component(hass, DOMAIN, {}) + await hass.async_block_till_done() + + assert result + entry = hass.config_entries.async_entries(DOMAIN)[0] + assert entry.state is ConfigEntryState.SETUP_RETRY + + ingress_panels.side_effect = None + ingress_panels.return_value = { + "test1": IngressPanel(enable=True, title="Test", icon="mdi:test", admin=False), + } + with patch( - "homeassistant.components.hassio.addon_panel._register_panel", + "homeassistant.components.hassio.addon_panel._register_panel" ) as mock_panel: - with patch.dict(os.environ, MOCK_ENVIRON): - await async_setup_component(hass, DOMAIN, {}) - await hass.async_block_till_done() + await hass.config_entries.async_reload(entry.entry_id) + await hass.async_block_till_done() + + assert entry.state is ConfigEntryState.LOADED + mock_panel.assert_called_once_with( + hass, + "test1", + IngressPanel(enable=True, title="Test", icon="mdi:test", admin=False), + ) + + +async def test_hassio_addon_panel_recovers_after_supervisor_restart( + hass: HomeAssistant, + hass_supervisor_ws_client: WebSocketGenerator, + ingress_panels: AsyncMock, +) -> None: + """Test panels are refreshed when Supervisor reports it has restarted. + + Regression test for the "Supervisor restarts while Core keeps running" + scenario: Supervisor fires a supervisor_update/startup:complete event on + every one of its own (re)starts, which the main coordinator already listens + for and uses to trigger a refresh. + """ + ingress_panels.return_value = {} + + with ( + patch( + "homeassistant.components.hassio.addon_panel._register_panel" + ) as mock_panel, + patch.dict(os.environ, MOCK_ENVIRON), + ): + await async_setup_component(hass, DOMAIN, {}) + await hass.async_block_till_done() - ingress_panels.assert_not_called() mock_panel.assert_not_called() - hass.bus.async_fire(EVENT_HOMEASSISTANT_START) - await hass.async_block_till_done() - hass.bus.async_fire(EVENT_HOMEASSISTANT_STARTED) + ingress_panels.return_value = { + "test1": IngressPanel( + enable=True, title="Test", icon="mdi:test", admin=False + ), + } + + client = await hass_supervisor_ws_client() + await client.send_json( + { + "id": 1, + "type": "supervisor/event", + "data": { + "event": "supervisor_update", + "update_key": "supervisor", + "data": {"startup": "complete"}, + }, + } + ) + await client.receive_json() + + async_fire_time_changed( + hass, dt_util.utcnow() + timedelta(seconds=REQUEST_REFRESH_DELAY + 1) + ) await hass.async_block_till_done() - ingress_panels.assert_called_once() - assert mock_panel.called - mock_panel.assert_called_with( + mock_panel.assert_called_once_with( hass, "test1", IngressPanel(enable=True, title="Test", icon="mdi:test", admin=False), @@ -60,10 +193,10 @@ async def test_hassio_addon_panel_startup( @pytest.mark.usefixtures("supervisor_client") -async def test_hassio_addon_panel_api( +async def test_hassio_addon_panel_api_post( hass: HomeAssistant, hass_client: ClientSessionGenerator, ingress_panels: AsyncMock ) -> None: - """Test panel api after event.""" + """Test posting a panel push registers it via the coordinator cache.""" ingress_panels.return_value = { "test1": IngressPanel(enable=True, title="Test", icon="mdi:test", admin=False), "test2": IngressPanel( @@ -75,37 +208,77 @@ async def test_hassio_addon_panel_api( await async_setup_component(hass, DOMAIN, {}) await hass.async_block_till_done() + hass_client = await hass_client() + with patch( - "homeassistant.components.hassio.addon_panel._register_panel", + "homeassistant.components.hassio.addon_panel._register_panel" ) as mock_panel: - hass.bus.async_fire(EVENT_HOMEASSISTANT_START) - await hass.async_block_till_done() - hass.bus.async_fire(EVENT_HOMEASSISTANT_STARTED) - await hass.async_block_till_done() - - ingress_panels.assert_called_once() - assert mock_panel.called - mock_panel.assert_called_with( - hass, - "test1", - IngressPanel(enable=True, title="Test", icon="mdi:test", admin=False), - ) - - hass_client = await hass_client() - + # Panel is not enabled yet according to Supervisor resp = await hass_client.post("/api/hassio_push/panel/test2") assert resp.status == HTTPStatus.BAD_REQUEST + mock_panel.assert_not_called() + # Supervisor enables the panel and pushes the change + ingress_panels.return_value["test2"] = IngressPanel( + enable=True, title="Test 2", icon="mdi:test2", admin=True + ) + resp = await hass_client.post("/api/hassio_push/panel/test2") + assert resp.status == HTTPStatus.OK + mock_panel.assert_called_once_with( + hass, + "test2", + IngressPanel(enable=True, title="Test 2", icon="mdi:test2", admin=True), + ) + + # Posting again for an already-registered, unchanged panel is a no-op + mock_panel.reset_mock() resp = await hass_client.post("/api/hassio_push/panel/test1") assert resp.status == HTTPStatus.OK - assert mock_panel.call_count == 2 + mock_panel.assert_not_called() - mock_panel.assert_called_with( + +@pytest.mark.usefixtures("supervisor_client") +async def test_hassio_addon_panel_api_before_coordinator_ready( + hass: HomeAssistant, hass_client: ClientSessionGenerator, ingress_panels: AsyncMock +) -> None: + """Test panel push api falls back to a fresh Supervisor call before setup completes. + + Other callers besides Supervisor may rely on this API before the config + entry (and its main coordinator) finishes setting up, so it must keep + working via a direct Supervisor call and frontend registration instead of + failing with a 503. + """ + ingress_panels.return_value = { + "test1": IngressPanel(enable=True, title="Test", icon="mdi:test", admin=False), + } + + with patch.dict(os.environ, MOCK_ENVIRON): + await async_setup_component(hass, DOMAIN, {}) + await hass.async_block_till_done() + + hass_client = await hass_client() + + # Simulate the main coordinator not being ready yet + del hass.data[MAIN_COORDINATOR] + + with patch( + "homeassistant.components.hassio.addon_panel._register_panel" + ) as mock_panel: + resp = await hass_client.post("/api/hassio_push/panel/test1") + assert resp.status == HTTPStatus.OK + mock_panel.assert_called_once_with( hass, "test1", IngressPanel(enable=True, title="Test", icon="mdi:test", admin=False), ) + with patch( + "homeassistant.components.hassio.addon_panel.frontend.async_remove_panel" + ) as mock_remove: + resp = await hass_client.delete("/api/hassio_push/panel/test1") + assert resp.status == HTTPStatus.OK + mock_remove.assert_called_once_with(hass, "test1", warn_if_unknown=False) + @pytest.mark.usefixtures("supervisor_client") async def test_hassio_addon_panel_api_non_admin( @@ -123,21 +296,12 @@ async def test_hassio_addon_panel_api_non_admin( await async_setup_component(hass, DOMAIN, {}) await hass.async_block_till_done() + hass_admin_user.groups = [] + hass_client = await hass_client() + with patch( - "homeassistant.components.hassio.addon_panel._register_panel", + "homeassistant.components.hassio.addon_panel._register_panel" ) as mock_panel: - hass.bus.async_fire(EVENT_HOMEASSISTANT_START) - await hass.async_block_till_done() - hass.bus.async_fire(EVENT_HOMEASSISTANT_STARTED) - await hass.async_block_till_done() - - ingress_panels.assert_called_once() - mock_panel.assert_called_once() - - mock_panel.reset_mock() - hass_admin_user.groups = [] - hass_client = await hass_client() - # Both should return unauthorized regardless of enabled as the endpoint requires # admin and the user is not admin resp = await hass_client.post("/api/hassio_push/panel/test2") @@ -149,47 +313,11 @@ async def test_hassio_addon_panel_api_non_admin( mock_panel.assert_not_called() -@pytest.mark.usefixtures("supervisor_client") -async def test_hassio_addon_panel_registration( - hass: HomeAssistant, ingress_panels: AsyncMock -) -> None: - """Test panel registration calls frontend.async_register_built_in_panel.""" - ingress_panels.return_value = { - "test_addon": IngressPanel( - enable=True, title="Test Addon", icon="mdi:test-tube", admin=True - ), - } - - with patch.dict(os.environ, MOCK_ENVIRON): - await async_setup_component(hass, DOMAIN, {}) - await hass.async_block_till_done() - - with patch( - "homeassistant.components.hassio.addon_panel.frontend.async_register_built_in_panel" - ) as mock_register: - hass.bus.async_fire(EVENT_HOMEASSISTANT_START) - await hass.async_block_till_done() - hass.bus.async_fire(EVENT_HOMEASSISTANT_STARTED) - await hass.async_block_till_done() - - # Verify that async_register_built_in_panel was called with correct arguments - # for our test addon - mock_register.assert_any_call( - hass, - "app", - frontend_url_path="test_addon", - sidebar_title="Test Addon", - sidebar_icon="mdi:test-tube", - require_admin=True, - config={"addon": "test_addon"}, - ) - - @pytest.mark.usefixtures("supervisor_client") async def test_hassio_addon_panel_api_delete( hass: HomeAssistant, hass_client: ClientSessionGenerator, ingress_panels: AsyncMock ) -> None: - """Test panel api delete.""" + """Test panel api delete removes it via the coordinator cache.""" ingress_panels.return_value = { "test1": IngressPanel(enable=True, title="Test", icon="mdi:test", admin=False), } @@ -204,7 +332,7 @@ async def test_hassio_addon_panel_api_delete( ) as mock_remove: resp = await hass_client.delete("/api/hassio_push/panel/test1") assert resp.status == HTTPStatus.OK - mock_remove.assert_called_once_with(hass, "test1") + mock_remove.assert_called_once_with(hass, "test1", warn_if_unknown=False) @pytest.mark.usefixtures("supervisor_client") diff --git a/tests/components/hassio/test_init.py b/tests/components/hassio/test_init.py index 26b293b778d2..220f104f64e6 100644 --- a/tests/components/hassio/test_init.py +++ b/tests/components/hassio/test_init.py @@ -5,7 +5,7 @@ from datetime import timedelta import os from pathlib import PurePath from typing import Any -from unittest.mock import ANY, AsyncMock, Mock, call, patch +from unittest.mock import AsyncMock, Mock, call, patch from uuid import uuid4 from aiohasupervisor import SupervisorBadRequestError, SupervisorError @@ -174,7 +174,7 @@ async def test_setup_api_ping( await hass.async_block_till_done() assert result - assert len(supervisor_client.mock_calls) == 16 + assert len(supervisor_client.mock_calls) == 17 assert get_core_info(hass)["version_latest"] == "1.0.0" assert is_hassio(hass) @@ -310,9 +310,9 @@ async def test_setup_api_push_api_data( await hass.async_block_till_done() assert result - assert len(supervisor_client.mock_calls) == 16 + assert len(supervisor_client.mock_calls) == 17 supervisor_client.homeassistant.set_options.assert_called_once_with( - HomeAssistantOptions(ssl=False, port=9999, refresh_token=ANY) + HomeAssistantOptions(ssl=False, port=9999, refresh_token=None) ) @@ -326,7 +326,7 @@ async def test_setup_api_push_api_data_error( await hass.async_block_till_done() assert result - assert len(supervisor_client.mock_calls) == 16 + assert len(supervisor_client.mock_calls) == 17 assert "Failed to update Home Assistant options in Supervisor: boom" in caplog.text @@ -347,9 +347,9 @@ async def test_setup_api_push_api_data_server_host( await hass.async_block_till_done() assert result - assert len(supervisor_client.mock_calls) == 16 + assert len(supervisor_client.mock_calls) == 17 supervisor_client.homeassistant.set_options.assert_called_once_with( - HomeAssistantOptions(ssl=False, port=9999, refresh_token=ANY) + HomeAssistantOptions(ssl=False, port=9999, refresh_token=None) ) @@ -362,23 +362,16 @@ async def test_setup_api_push_api_data_default( await hass.async_block_till_done() assert result - assert len(supervisor_client.mock_calls) == 16 + assert len(supervisor_client.mock_calls) == 17 supervisor_client.homeassistant.set_options.assert_called_once_with( - HomeAssistantOptions(ssl=False, port=80, refresh_token=ANY) - ) - refresh_token = ( - supervisor_client.homeassistant.set_options.mock_calls[0].args[0].refresh_token + HomeAssistantOptions(ssl=False, port=80, refresh_token=None) ) hassio_user = hass.data[DATA_HASSIO_SUPERVISOR_USER] assert hassio_user.system_generated assert len(hassio_user.groups) == 1 assert hassio_user.groups[0].id == GROUP_ID_ADMIN assert hassio_user.name == "Supervisor" - for token in hassio_user.refresh_tokens.values(): - if token.token == refresh_token: - break - else: - pytest.fail("refresh token not found") + assert not hassio_user.refresh_tokens async def test_setup_adds_admin_group_to_user(hass: HomeAssistant) -> None: @@ -399,6 +392,7 @@ async def test_setup_adds_admin_group_to_user(hass: HomeAssistant) -> None: assert result assert user.is_admin + assert not user.refresh_tokens async def test_setup_migrate_user_name(hass: HomeAssistant) -> None: @@ -418,6 +412,7 @@ async def test_setup_migrate_user_name(hass: HomeAssistant) -> None: assert result assert user.name == "Supervisor" + assert not user.refresh_tokens async def test_setup_api_existing_hassio_user( @@ -425,7 +420,10 @@ async def test_setup_api_existing_hassio_user( ) -> None: """Test setup uses the user from config entry data.""" user = await hass.auth.async_create_system_user("Hass.io test") - token = await hass.auth.async_create_refresh_token(user) + refresh_tokens = [ + await hass.auth.async_create_refresh_token(user) for _ in range(2) + ] + access_token = hass.auth.async_create_access_token(refresh_tokens[0]) config_entry = MockConfigEntry( domain=DOMAIN, data={ENTRY_DATA_USER: user.id}, @@ -438,10 +436,12 @@ async def test_setup_api_existing_hassio_user( await hass.async_block_till_done() assert result - assert len(supervisor_client.mock_calls) == 16 + assert len(supervisor_client.mock_calls) == 17 supervisor_client.homeassistant.set_options.assert_called_once_with( - HomeAssistantOptions(ssl=False, port=80, refresh_token=token.token) + HomeAssistantOptions(ssl=False, port=80, refresh_token=None) ) + assert not user.refresh_tokens + assert hass.auth.async_validate_access_token(access_token) is None async def test_setup_migrates_legacy_hassio_store_to_config_entry( @@ -451,7 +451,7 @@ async def test_setup_migrates_legacy_hassio_store_to_config_entry( ) -> None: """Test setup migrates legacy hassio store user/options into config entry.""" user = await hass.auth.async_create_system_user("Hass.io test") - token = await hass.auth.async_create_refresh_token(user) + await hass.auth.async_create_refresh_token(user) config_entry = MockConfigEntry(domain=DOMAIN, data={}, options={}, unique_id=DOMAIN) config_entry.add_to_hass(hass) @@ -483,10 +483,11 @@ async def test_setup_migrates_legacy_hassio_store_to_config_entry( assert entry.options[OPTION_ADD_ON_BACKUP_RETAIN_COPIES] == 2 assert entry.options[OPTION_CORE_BACKUP_BEFORE_UPDATE] is True - assert len(supervisor_client.mock_calls) == 16 + assert len(supervisor_client.mock_calls) == 17 supervisor_client.homeassistant.set_options.assert_called_once_with( - HomeAssistantOptions(ssl=False, port=80, refresh_token=token.token) + HomeAssistantOptions(ssl=False, port=80, refresh_token=None) ) + assert not user.refresh_tokens async def test_setup_migrates_legacy_options_over_default_entry_options( @@ -496,7 +497,7 @@ async def test_setup_migrates_legacy_options_over_default_entry_options( ) -> None: """Test legacy update options override default config entry options.""" user = await hass.auth.async_create_system_user("Hass.io test") - token = await hass.auth.async_create_refresh_token(user) + await hass.auth.async_create_refresh_token(user) config_entry = MockConfigEntry( domain=DOMAIN, @@ -533,8 +534,9 @@ async def test_setup_migrates_legacy_options_over_default_entry_options( assert entry.options[OPTION_CORE_BACKUP_BEFORE_UPDATE] is True supervisor_client.homeassistant.set_options.assert_called_once_with( - HomeAssistantOptions(ssl=False, port=80, refresh_token=token.token) + HomeAssistantOptions(ssl=False, port=80, refresh_token=None) ) + assert not user.refresh_tokens async def test_setup_core_push_config( @@ -548,7 +550,7 @@ async def test_setup_core_push_config( await hass.async_block_till_done() assert result - assert len(supervisor_client.mock_calls) == 16 + assert len(supervisor_client.mock_calls) == 17 supervisor_client.supervisor.set_options.assert_called_once_with( SupervisorOptions(timezone="testzone") ) @@ -573,7 +575,7 @@ async def test_setup_core_push_config_error( await hass.async_block_till_done() assert result - assert len(supervisor_client.mock_calls) == 16 + assert len(supervisor_client.mock_calls) == 17 assert "Failed to update Supervisor options: boom" in caplog.text @@ -589,7 +591,7 @@ async def test_setup_hassio_no_additional_data( await hass.async_block_till_done() assert result - assert len(supervisor_client.mock_calls) == 16 + assert len(supervisor_client.mock_calls) == 17 async def test_fail_setup_without_environ_var(hass: HomeAssistant) -> None: @@ -1320,7 +1322,7 @@ async def test_setup_hardware_integration( await hass.async_block_till_done(wait_background_tasks=True) assert result - assert len(supervisor_client.mock_calls) == 16 + assert len(supervisor_client.mock_calls) == 17 assert len(mock_setup_entry.mock_calls) == 1 @@ -2040,6 +2042,15 @@ async def test_supervisor_issues_not_set_on_coordinator_failure( If a coordinator first-refresh raises ConfigEntryNotReady the issues listener must not be registered, preventing accumulation across retries. """ + user = await hass.auth.async_create_system_user("Hass.io test") + refresh_token = await hass.auth.async_create_refresh_token(user) + access_token = hass.auth.async_create_access_token(refresh_token) + config_entry = MockConfigEntry( + domain=DOMAIN, + data={ENTRY_DATA_USER: user.id}, + unique_id=DOMAIN, + ) + config_entry.add_to_hass(hass) supervisor_root_info.side_effect = SupervisorError() with patch.dict(os.environ, MOCK_ENVIRON): result = await async_setup_component(hass, DOMAIN, {}) @@ -2048,3 +2059,5 @@ async def test_supervisor_issues_not_set_on_coordinator_failure( entry = hass.config_entries.async_entries("hassio")[0] assert entry.state is ConfigEntryState.SETUP_RETRY assert DATA_KEY_SUPERVISOR_ISSUES not in hass.data + assert not user.refresh_tokens + assert hass.auth.async_validate_access_token(access_token) is None diff --git a/tests/components/hassio/test_issues.py b/tests/components/hassio/test_issues.py index a241e700cf5c..f4a1f0b50c7d 100644 --- a/tests/components/hassio/test_issues.py +++ b/tests/components/hassio/test_issues.py @@ -33,6 +33,7 @@ from homeassistant.components.hassio.coordinator import ( ) from homeassistant.components.repairs import DOMAIN as REPAIRS_DOMAIN from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers import issue_registry as ir from homeassistant.setup import async_setup_component from homeassistant.util import dt as dt_util @@ -1436,6 +1437,59 @@ async def test_supervisor_issues_periodic_refresh_backstop( supervisor_client.resolution.info.assert_called_once() +@pytest.mark.usefixtures("all_setup_requests") +async def test_issue_repair_recreated_when_registry_entry_missing( + hass: HomeAssistant, + supervisor_client: AsyncMock, + issue_registry: ir.IssueRegistry, +) -> None: + """Test a repair deleted from the registry is re-created for an unchanged issue. + + A finished repair flow deletes the issue registry entry even when applying + the suggestion failed in Supervisor. The issue then comes back unchanged on + the next refresh and must be re-created instead of skipped as known. + """ + mock_resolution_info( + supervisor_client, + issues=[ + Issue( + type=IssueType.MOUNT_FAILED, + context=ContextType.MOUNT, + reference="m1", + uuid=(issue_uuid := uuid4()), + reference_extra=None, + ) + ], + suggestions_by_issue={ + issue_uuid: [ + Suggestion( + SuggestionType.EXECUTE_RELOAD, + context=ContextType.MOUNT, + reference="m1", + uuid=uuid4(), + auto=False, + reference_extra=None, + ) + ] + }, + ) + + result = await async_setup_component(hass, DOMAIN, {}) + assert result + + assert issue_registry.async_get_issue(domain=DOMAIN, issue_id=issue_uuid.hex) + + # Simulate a finished repair flow whose suggestion failed to apply in + # Supervisor: the registry entry is gone, the supervisor issue unchanged + ir.async_delete_issue(hass, DOMAIN, issue_uuid.hex) + assert not issue_registry.async_get_issue(domain=DOMAIN, issue_id=issue_uuid.hex) + + async_fire_time_changed(hass, dt_util.utcnow() + HASSIO_ISSUES_UPDATE_INTERVAL) + await hass.async_block_till_done() + + assert issue_registry.async_get_issue(domain=DOMAIN, issue_id=issue_uuid.hex) + + @pytest.mark.usefixtures("all_setup_requests") async def test_supervisor_issues_suggestions_change_updates_fixable_state( hass: HomeAssistant, diff --git a/tests/components/hassio/test_repairs.py b/tests/components/hassio/test_repairs.py index 93caa5a5e028..52c8e4c9b7b6 100644 --- a/tests/components/hassio/test_repairs.py +++ b/tests/components/hassio/test_repairs.py @@ -749,6 +749,106 @@ async def test_mount_failed_repair_flow( supervisor_client.resolution.apply_suggestion.assert_called_once_with(sugg_uuid) +@pytest.mark.usefixtures("all_setup_requests") +async def test_mount_failed_move_local_data_repair_flow( + hass: HomeAssistant, + supervisor_client: AsyncMock, + hass_client: ClientSessionGenerator, + issue_registry: ir.IssueRegistry, +) -> None: + """Test moving blocking local data from the mount_failed repair.""" + mock_resolution_info( + supervisor_client, + issues=[ + Issue( + type=IssueType.MOUNT_FAILED, + context=ContextType.MOUNT, + reference="media_share", + uuid=(issue_uuid := uuid4()), + reference_extra=None, + ), + ], + suggestions_by_issue={ + issue_uuid: [ + Suggestion( + # Not in aiohasupervisor's SuggestionType enum yet, arrives + # as a plain string like any newer supervisor suggestion + type="move_local_data", + context=ContextType.MOUNT, + reference="media_share", + uuid=(sugg_uuid := uuid4()), + auto=False, + reference_extra=None, + ), + Suggestion( + type=SuggestionType.EXECUTE_RELOAD, + context=ContextType.MOUNT, + reference="media_share", + uuid=uuid4(), + auto=False, + reference_extra=None, + ), + Suggestion( + type=SuggestionType.EXECUTE_REMOVE, + context=ContextType.MOUNT, + reference="media_share", + uuid=uuid4(), + auto=False, + reference_extra=None, + ), + ] + }, + ) + + assert await async_setup_component(hass, DOMAIN, {}) + + repair_issue = issue_registry.async_get_issue( + domain="hassio", issue_id=issue_uuid.hex + ) + assert repair_issue + + client = await hass_client() + + resp = await client.post( + "/api/repairs/issues/fix", + json={"handler": "hassio", "issue_id": repair_issue.issue_id}, + ) + + assert resp.status == HTTPStatus.OK + data = await resp.json() + + flow_id = data["flow_id"] + assert data["type"] == "menu" + assert data["menu_options"] == [ + "mount_move_local_data", + "mount_execute_reload", + "mount_execute_remove", + ] + + # Moving data aside requires a confirmation step + resp = await client.post( + f"/api/repairs/issues/fix/{flow_id}", + json={"next_step_id": "mount_move_local_data"}, + ) + + assert resp.status == HTTPStatus.OK + data = await resp.json() + + flow_id = data["flow_id"] + assert data["type"] == "form" + assert data["step_id"] == "mount_move_local_data" + supervisor_client.resolution.apply_suggestion.assert_not_called() + + resp = await client.post(f"/api/repairs/issues/fix/{flow_id}", json={}) + + assert resp.status == HTTPStatus.OK + data = await resp.json() + + assert data["type"] == "create_entry" + assert not issue_registry.async_get_issue(domain="hassio", issue_id=issue_uuid.hex) + supervisor_client.resolution.apply_suggestion.assert_called_once_with(sugg_uuid) + + @pytest.mark.parametrize( "all_setup_requests", [{"include_addons": True}], indirect=True ) diff --git a/tests/components/hassio/test_websocket_api.py b/tests/components/hassio/test_websocket_api.py index 972634199f15..7ba8de4a9308 100644 --- a/tests/components/hassio/test_websocket_api.py +++ b/tests/components/hassio/test_websocket_api.py @@ -375,14 +375,12 @@ async def test_websocket_non_admin_user( assert msg["error"]["message"] == "Unauthorized" -async def test_websocket_store_reload_refreshes_update_entities( +async def test_store_reloaded_event_refreshes_update_entities( hass: HomeAssistant, - hass_ws_client: WebSocketGenerator, - aioclient_mock: AiohttpClientMocker, supervisor_client: AsyncMock, addons_list: AsyncMock, ) -> None: - """Test add-on update entities refresh after a store reload via the API proxy.""" + """Test add-on update entities refresh on a Supervisor store_reloaded event.""" addons_list.return_value = [ replace( addons_list.return_value[0], @@ -406,25 +404,45 @@ async def test_websocket_store_reload_refreshes_update_entities( version_latest="2.0.1", ) ] - aioclient_mock.post( - "http://127.0.0.1/store/reload", json={"result": "ok", "data": {}} - ) - websocket_client = await hass_ws_client(hass) - await websocket_client.send_json_auto_id( - { - WS_TYPE: WS_TYPE_API, - ATTR_ENDPOINT: "/store/reload", - ATTR_METHOD: "post", - } + async_dispatcher_send( + hass, + EVENT_SUPERVISOR_EVENT, + {"event": "store_reloaded", "data": {"repositories": ["core"]}}, ) - msg = await websocket_client.receive_json() - assert msg["success"] + await hass.async_block_till_done() assert hass.states.get("update.test_update").state == "on" + # Supervisor already reloaded the store, so we must not reload it again. supervisor_client.store.reload.assert_not_called() +async def test_store_reloaded_event_ignored_without_listeners( + hass: HomeAssistant, + addons_list: AsyncMock, +) -> None: + """Test a store_reloaded event does not refresh without add-on entities.""" + addons_list.return_value = [] + config_entry = MockConfigEntry(domain=DOMAIN, data={}, unique_id=DOMAIN) + config_entry.add_to_hass(hass) + + with patch.dict(os.environ, MOCK_ENVIRON): + assert await async_setup_component(hass, DOMAIN, {"hassio": {}}) + await hass.async_block_till_done() + + # Without add-on entities the coordinator has no listeners, + # so the event must not trigger an add-on data fetch. + addons_list.reset_mock() + async_dispatcher_send( + hass, + EVENT_SUPERVISOR_EVENT, + {"event": "store_reloaded", "data": {"repositories": ["core"]}}, + ) + await hass.async_block_till_done() + + addons_list.assert_not_called() + + async def test_update_addon( hass: HomeAssistant, hass_ws_client: WebSocketGenerator, diff --git a/tests/components/history_stats/test_init.py b/tests/components/history_stats/test_init.py index 49550428d924..b7e0655669fc 100644 --- a/tests/components/history_stats/test_init.py +++ b/tests/components/history_stats/test_init.py @@ -127,7 +127,9 @@ async def test_async_handle_source_entity_changes_source_entity_removed( assert await hass.config_entries.async_setup(history_stats_config_entry.entry_id) await hass.async_block_till_done() - history_stats_entity_entry = entity_registry.async_get("sensor.my_history_stats") + history_stats_entity_entry = entity_registry.async_get( + "sensor.mock_title_my_history_stats" + ) assert history_stats_entity_entry.device_id == sensor_entity_entry.device_id sensor_device = device_registry.async_get(sensor_device.id) @@ -146,7 +148,7 @@ async def test_async_handle_source_entity_changes_source_entity_removed( mock_unload_entry.assert_called_once() # Check that the helper entity is removed - assert not entity_registry.async_get("sensor.my_history_stats") + assert not entity_registry.async_get(history_stats_entity_entry.entity_id) # Check that the device is removed assert not device_registry.async_get(sensor_device.id) @@ -177,7 +179,9 @@ async def test_async_handle_source_entity_changes_source_entity_removed_shared_d assert await hass.config_entries.async_setup(history_stats_config_entry.entry_id) await hass.async_block_till_done() - history_stats_entity_entry = entity_registry.async_get("sensor.my_history_stats") + history_stats_entity_entry = entity_registry.async_get( + "sensor.mock_title_my_history_stats" + ) assert history_stats_entity_entry.device_id == sensor_entity_entry.device_id sensor_device = device_registry.async_get(sensor_device.id) @@ -196,7 +200,7 @@ async def test_async_handle_source_entity_changes_source_entity_removed_shared_d mock_unload_entry.assert_called_once() # Check that the helper entity is removed - assert not entity_registry.async_get("sensor.my_history_stats") + assert not entity_registry.async_get(history_stats_entity_entry.entity_id) # Check that the source device is not removed sensor_device = device_registry.async_get(sensor_device.id) @@ -225,7 +229,9 @@ async def test_async_handle_source_entity_changes_source_entity_removed_from_dev assert await hass.config_entries.async_setup(history_stats_config_entry.entry_id) await hass.async_block_till_done() - history_stats_entity_entry = entity_registry.async_get("sensor.my_history_stats") + history_stats_entity_entry = entity_registry.async_get( + "sensor.mock_title_my_history_stats" + ) assert history_stats_entity_entry.device_id == sensor_entity_entry.device_id sensor_device = device_registry.async_get(sensor_device.id) @@ -245,7 +251,9 @@ async def test_async_handle_source_entity_changes_source_entity_removed_from_dev mock_unload_entry.assert_called_once() # Check that the entity is no longer linked to the source device - history_stats_entity_entry = entity_registry.async_get("sensor.my_history_stats") + history_stats_entity_entry = entity_registry.async_get( + history_stats_entity_entry.entity_id + ) assert history_stats_entity_entry.device_id is None # Check that the history_stats config entry is not in the device @@ -278,7 +286,9 @@ async def test_async_handle_source_entity_changes_source_entity_moved_other_devi assert await hass.config_entries.async_setup(history_stats_config_entry.entry_id) await hass.async_block_till_done() - history_stats_entity_entry = entity_registry.async_get("sensor.my_history_stats") + history_stats_entity_entry = entity_registry.async_get( + "sensor.mock_title_my_history_stats" + ) assert history_stats_entity_entry.device_id == sensor_entity_entry.device_id sensor_device = device_registry.async_get(sensor_device.id) @@ -300,7 +310,9 @@ async def test_async_handle_source_entity_changes_source_entity_moved_other_devi mock_unload_entry.assert_called_once() # Check that the entity is linked to the other device - history_stats_entity_entry = entity_registry.async_get("sensor.my_history_stats") + history_stats_entity_entry = entity_registry.async_get( + history_stats_entity_entry.entity_id + ) assert history_stats_entity_entry.device_id == sensor_device_2.id # Check that the history_stats config entry is not in any of the devices @@ -329,7 +341,9 @@ async def test_async_handle_source_entity_new_entity_id( assert await hass.config_entries.async_setup(history_stats_config_entry.entry_id) await hass.async_block_till_done() - history_stats_entity_entry = entity_registry.async_get("sensor.my_history_stats") + history_stats_entity_entry = entity_registry.async_get( + "sensor.mock_title_my_history_stats" + ) assert history_stats_entity_entry.device_id == sensor_entity_entry.device_id sensor_device = device_registry.async_get(sensor_device.id) @@ -398,7 +412,9 @@ async def test_migration_1_1( # entity is linked to the source device sensor_device = device_registry.async_get(sensor_device.id) assert history_stats_config_entry.entry_id not in sensor_device.config_entries - history_stats_entity_entry = entity_registry.async_get("sensor.my_history_stats") + history_stats_entity_entry = entity_registry.async_get( + "sensor.mock_title_my_history_stats" + ) assert history_stats_entity_entry.device_id == sensor_entity_entry.device_id assert history_stats_config_entry.version == 2 @@ -449,9 +465,11 @@ async def test_migration_1_2( == HistoryStatsConfigFlowHandler.MINOR_VERSION ) - assert hass.states.get("sensor.my_history_stats") is not None + assert hass.states.get("sensor.mock_title_my_history_stats") is not None assert ( - hass.states.get("sensor.my_history_stats").attributes.get(CONF_STATE_CLASS) + hass.states.get("sensor.mock_title_my_history_stats").attributes.get( + CONF_STATE_CLASS + ) == SensorStateClass.MEASUREMENT ) diff --git a/tests/components/history_stats/test_sensor.py b/tests/components/history_stats/test_sensor.py index 608b9da923e8..f5144ee86e57 100644 --- a/tests/components/history_stats/test_sensor.py +++ b/tests/components/history_stats/test_sensor.py @@ -2162,14 +2162,14 @@ async def test_device_id( device_id=source_device_entry.id, ) await hass.async_block_till_done() - assert entity_registry.async_get("binary_sensor.test_source") is not None + assert entity_registry.async_get(source_entity.entity_id) is not None history_stats_config_entry = MockConfigEntry( data={}, domain=DOMAIN, options={ CONF_NAME: DEFAULT_NAME, - CONF_ENTITY_ID: "binary_sensor.test_source", + CONF_ENTITY_ID: source_entity.entity_id, CONF_STATE: ["on"], CONF_TYPE: "count", CONF_START: "{{ as_timestamp(utcnow()) - 3600 }}", @@ -2182,7 +2182,7 @@ async def test_device_id( assert await hass.config_entries.async_setup(history_stats_config_entry.entry_id) await hass.async_block_till_done() - history_stats_entity = entity_registry.async_get("sensor.history_stats") + history_stats_entity = entity_registry.async_get("sensor.mock_title_history_stats") assert history_stats_entity is not None assert history_stats_entity.device_id == source_entity.device_id diff --git a/tests/components/home_connect/test_config_flow.py b/tests/components/home_connect/test_config_flow.py index 29afa27bea06..1464be1b580c 100644 --- a/tests/components/home_connect/test_config_flow.py +++ b/tests/components/home_connect/test_config_flow.py @@ -3,6 +3,7 @@ from collections.abc import Awaitable, Callable from http import HTTPStatus from unittest.mock import MagicMock, patch +from urllib.parse import parse_qsl, urlsplit from aiohomeconnect.const import OAUTH2_AUTHORIZE, OAUTH2_TOKEN from aiohomeconnect.model import HomeAppliance @@ -25,6 +26,23 @@ from tests.typing import ClientSessionGenerator CLIENT_ID = "1234" CLIENT_SECRET = "5678" + +def assert_authorize_url(url: str, state: str, images_scope: bool | None) -> None: + """Assert the generated OAuth authorize URL.""" + split_url = urlsplit(url) + + assert ( + f"{split_url.scheme}://{split_url.netloc}{split_url.path}" == OAUTH2_AUTHORIZE + ) + assert dict(parse_qsl(split_url.query)) == { + "response_type": "code", + "client_id": CLIENT_ID, + "redirect_uri": "https://example.com/auth/external/callback", + "state": state, + "scope": f"Control Monitor Settings IdentifyAppliance{' Images' if images_scope else ''}", + } + + DHCP_DISCOVERY = ( DhcpServiceInfo( ip="1.1.1.1", @@ -95,10 +113,14 @@ DHCP_DISCOVERY = ( @pytest.mark.usefixtures("current_request_with_host") +@pytest.mark.parametrize( + "images_scope", [True, False], ids=["images_scope", "no_images_scope"] +) async def test_full_flow( hass: HomeAssistant, hass_client_no_auth: ClientSessionGenerator, aioclient_mock: AiohttpClientMocker, + images_scope: bool, ) -> None: """Check full flow.""" assert await setup.async_setup_component(hass, "home_connect", {}) @@ -106,6 +128,13 @@ async def test_full_flow( result = await hass.config_entries.flow.async_init( DOMAIN, context=ConfigFlowContext(source=config_entries.SOURCE_USER) ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "scopes" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={"images_scope": images_scope} + ) state = config_entry_oauth2_flow._encode_jwt( hass, { @@ -115,11 +144,7 @@ async def test_full_flow( ) assert result["type"] is FlowResultType.EXTERNAL_STEP - assert result["url"] == ( - f"{OAUTH2_AUTHORIZE}?response_type=code&client_id={CLIENT_ID}" - "&redirect_uri=https://example.com/auth/external/callback" - f"&state={state}" - ) + assert_authorize_url(result["url"], state, images_scope) client = await hass_client_no_auth() resp = await client.get(f"/auth/external/callback?code=abcd&state={state}") @@ -161,6 +186,13 @@ async def test_prevent_reconfiguring_same_account( result = await hass.config_entries.flow.async_init( DOMAIN, context=ConfigFlowContext(source=config_entries.SOURCE_USER) ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "scopes" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={"images_scope": True} + ) state = config_entry_oauth2_flow._encode_jwt( hass, { @@ -170,11 +202,7 @@ async def test_prevent_reconfiguring_same_account( ) assert result["type"] is FlowResultType.EXTERNAL_STEP - assert result["url"] == ( - f"{OAUTH2_AUTHORIZE}?response_type=code&client_id={CLIENT_ID}" - "&redirect_uri=https://example.com/auth/external/callback" - f"&state={state}" - ) + assert_authorize_url(result["url"], state, True) client = await hass_client_no_auth() resp = await client.get(f"/auth/external/callback?code=abcd&state={state}") @@ -214,6 +242,13 @@ async def test_reauth_flow( assert result["step_id"] == "reauth_confirm" result = await hass.config_entries.flow.async_configure(result["flow_id"], {}) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "scopes" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={"images_scope": False} + ) state = config_entry_oauth2_flow._encode_jwt( hass, { @@ -268,6 +303,13 @@ async def test_reauth_flow_with_different_account( assert result["step_id"] == "reauth_confirm" result = await hass.config_entries.flow.async_configure(result["flow_id"], {}) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "scopes" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={"images_scope": True} + ) state = config_entry_oauth2_flow._encode_jwt( hass, { @@ -323,6 +365,13 @@ async def test_zeroconf_flow( result["flow_id"], {}, ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "scopes" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={"images_scope": True} + ) state = config_entry_oauth2_flow._encode_jwt( hass, { @@ -332,11 +381,7 @@ async def test_zeroconf_flow( ) assert result["type"] is FlowResultType.EXTERNAL_STEP - assert result["url"] == ( - f"{OAUTH2_AUTHORIZE}?response_type=code&client_id={CLIENT_ID}" - "&redirect_uri=https://example.com/auth/external/callback" - f"&state={state}" - ) + assert_authorize_url(result["url"], state, True) client = await hass_client_no_auth() resp = await client.get(f"/auth/external/callback?code=abcd&state={state}") @@ -406,6 +451,13 @@ async def test_dhcp_flow( result["flow_id"], {}, ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "scopes" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={"images_scope": True} + ) state = config_entry_oauth2_flow._encode_jwt( hass, { @@ -414,11 +466,7 @@ async def test_dhcp_flow( }, ) assert result["type"] is FlowResultType.EXTERNAL_STEP - assert result["url"] == ( - f"{OAUTH2_AUTHORIZE}?response_type=code&client_id={CLIENT_ID}" - "&redirect_uri=https://example.com/auth/external/callback" - f"&state={state}" - ) + assert_authorize_url(result["url"], state, True) client = await hass_client_no_auth() resp = await client.get(f"/auth/external/callback?code=abcd&state={state}") diff --git a/tests/components/homeassistant/triggers/test_event.py b/tests/components/homeassistant/triggers/test_event.py index ad643f1f45ca..73ef9efe48d4 100644 --- a/tests/components/homeassistant/triggers/test_event.py +++ b/tests/components/homeassistant/triggers/test_event.py @@ -1,13 +1,17 @@ """The tests for the Event automation.""" +import logging + +import attr import pytest from homeassistant.components import automation from homeassistant.const import ATTR_ENTITY_ID, ENTITY_MATCH_ALL, SERVICE_TURN_OFF from homeassistant.core import Context, HomeAssistant, ServiceCall +from homeassistant.helpers import device_registry as dr, script, trigger from homeassistant.setup import async_setup_component -from tests.common import mock_component +from tests.common import MockConfigEntry, mock_component @pytest.fixture @@ -629,3 +633,109 @@ async def test_templated_state_reported_event( "Got error 'Can't listen to state_reported in event trigger' " "when setting up triggers for automation 0" in caplog.text ) + + +COMPOSITE_ID = "composite00000000000000000000ab" + + +@pytest.fixture +def split_devices( + hass: HomeAssistant, device_registry: dr.DeviceRegistry +) -> tuple[dr.DeviceEntry, dr.DeviceEntry]: + """Create two devices which are splits of a pre-migration composite device.""" + entry_1 = MockConfigEntry(domain="itg1") + entry_1.add_to_hass(hass) + entry_2 = MockConfigEntry(domain="itg2") + entry_2.add_to_hass(hass) + device_1 = device_registry.async_get_or_create( + config_entry_id=entry_1.entry_id, + identifiers={("itg1", "1")}, + name="Split device 1", + ) + device_2 = device_registry.async_get_or_create( + config_entry_id=entry_2.entry_id, + identifiers={("itg2", "1")}, + name="Split device 2", + ) + device_registry._devices[device_1.id] = attr.evolve( + device_1, composite_device_id=COMPOSITE_ID + ) + device_registry._devices[device_2.id] = attr.evolve( + device_2, composite_device_id=COMPOSITE_ID + ) + return device_registry._devices[device_1.id], device_registry._devices[device_2.id] + + +_EVENT_TRIGGER = { + "platform": "event", + "event_type": "my_event", + "event_data": {"device_id": COMPOSITE_ID}, +} + + +def _expected_composite_warning( + device_1: dr.DeviceEntry, device_2: dr.DeviceEntry +) -> str: + """Return the exact warning the event validator logs for a composite device id.""" + return ( + f"Event trigger filters on device '{COMPOSITE_ID}', which was split into one " + "device per integration and no longer exists, so the trigger can no longer fire. " + "Update the automation, script or template entity to filter on one of these " + "devices instead: " + f"Split device 1 ({device_1.id}) from the itg1 integration, " + f"Split device 2 ({device_2.id}) from the itg2 integration.\n" + "The affected trigger is configured as:\n" + "platform: event\n" + "event_type: my_event\n" + "event_data:\n" + f" device_id: {COMPOSITE_ID}\n" + ) + + +async def test_composite_device_id_logs_warning( + hass: HomeAssistant, + split_devices: tuple[dr.DeviceEntry, dr.DeviceEntry], + caplog: pytest.LogCaptureFixture, +) -> None: + """Test a composite event_data.device_id filter logs the full warning.""" + with caplog.at_level(logging.WARNING): + await trigger.async_validate_trigger_config(hass, [_EVENT_TRIGGER]) + assert caplog.messages == [_expected_composite_warning(*split_devices)] + + +async def test_live_device_id_no_warning( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test a live event_data.device_id filter does not warn.""" + entry = MockConfigEntry(domain="itg") + entry.add_to_hass(hass) + live_device = device_registry.async_get_or_create( + config_entry_id=entry.entry_id, identifiers={("itg", "1")} + ) + with caplog.at_level(logging.WARNING): + await trigger.async_validate_trigger_config( + hass, + [ + { + "platform": "event", + "event_type": "my_event", + "event_data": {"device_id": live_device.id}, + } + ], + ) + assert caplog.messages == [] + + +async def test_wait_for_trigger_composite_device_id_logs_warning( + hass: HomeAssistant, + split_devices: tuple[dr.DeviceEntry, dr.DeviceEntry], + caplog: pytest.LogCaptureFixture, +) -> None: + """Test a composite device_id in a wait_for_trigger event trigger warns too.""" + with caplog.at_level(logging.WARNING): + await script.async_validate_actions_config( + hass, [{"wait_for_trigger": [_EVENT_TRIGGER]}] + ) + assert caplog.messages == [_expected_composite_warning(*split_devices)] diff --git a/tests/components/homekit_controller/test_connection.py b/tests/components/homekit_controller/test_connection.py index c723c4e01059..198c641eb178 100644 --- a/tests/components/homekit_controller/test_connection.py +++ b/tests/components/homekit_controller/test_connection.py @@ -251,8 +251,10 @@ async def test_migrate_device_id_shared_identifier_only_migrates_own( name="Other", ) old_id = "composite00000000000000000000ab" - device_registry.devices[device.id] = attr.evolve(device, composite_device_id=old_id) - device_registry.devices[other_device.id] = attr.evolve( + device_registry._devices[device.id] = attr.evolve( + device, composite_device_id=old_id + ) + device_registry._devices[other_device.id] = attr.evolve( other_device, composite_device_id=old_id ) # The shared identifier now resolves to the read-only composite diff --git a/tests/components/homewizard/test_config_flow.py b/tests/components/homewizard/test_config_flow.py index 09f10b8e9edb..e02a53901e23 100644 --- a/tests/components/homewizard/test_config_flow.py +++ b/tests/components/homewizard/test_config_flow.py @@ -379,6 +379,49 @@ async def test_discovery_flow_updates_new_ip( assert mock_config_entry.data[CONF_IP_ADDRESS] == "1.0.0.127" +@pytest.mark.usefixtures("mock_homewizardenergy", "mock_setup_entry") +async def test_manual_flow_ignores_pending_discovery_for_same_device( + hass: HomeAssistant, +) -> None: + """Test the user flow is not blocked by a stale discovery flow for the same device.""" + discovery_result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_ZEROCONF}, + data=ZeroconfServiceInfo( + ip_address=ip_address("1.0.0.127"), + ip_addresses=[ip_address("1.0.0.127")], + port=80, + hostname="p1meter-ddeeff.local.", + type="", + name="", + properties={ + "api_enabled": "1", + "path": "/api/v1", + "product_name": "P1 Meter", + "product_type": "HWE-P1", + "serial": "5c2fafabcdef", + }, + ), + ) + + assert discovery_result["type"] is FlowResultType.FORM + assert discovery_result["step_id"] == "discovery_confirm" + assert len(hass.config_entries.flow.async_progress()) == 1 + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {CONF_IP_ADDRESS: "2.2.2.2"} + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["data"][CONF_IP_ADDRESS] == "2.2.2.2" + + # The stale discovery flow is cleaned up once the manual flow succeeds + assert len(hass.config_entries.flow.async_progress()) == 0 + + @pytest.mark.usefixtures("mock_setup_entry") @pytest.mark.parametrize( ("exception", "reason"), diff --git a/tests/components/hotspring/conftest.py b/tests/components/hotspring/conftest.py index 167c1404b548..ccfb454f6a7d 100644 --- a/tests/components/hotspring/conftest.py +++ b/tests/components/hotspring/conftest.py @@ -3,7 +3,32 @@ from collections.abc import Generator from unittest.mock import AsyncMock, MagicMock, patch -from hotspring import Heater, Spa, SpaBrand, SpaInfo, Versions +from hotspring import ( + Blower, + BrightnessLevel, + CleanCycle, + ConnectionStatus, + Diagnostics, + EnergySaving, + FreshWaterIQ, + Heater, + HeatingMode, + Jet, + JetSpeed, + LightColor, + LightWheelMode, + LightZone, + LogoLight, + Spa, + SpaBrand, + SpaFailureState, + SpaInfo, + SpaLock, + TemperatureUnit, + Versions, + WaterCare, +) +from hotspring.models import SpaTestData import pytest from homeassistant.components.hotspring.const import DOMAIN @@ -52,7 +77,7 @@ def device_fixture() -> Spa: spa.versions = Versions( control_box="3.0.0", control_panel="2.0.0", - fwss="", + fwss="1.0.0", fwiq="", btxr="", cool_zone="", @@ -61,11 +86,81 @@ def device_fixture() -> Spa: dosing="", logolight="", ) - heater = MagicMock(spec=Heater) - heater.current_temperature = 102.0 - heater.set_temperature = 104.0 - heater.is_on = True - spa.heater = heater + spa.heater = Heater( + is_on=True, + heater_lock=False, + heatpump_installed=False, + heating_mode=HeatingMode.HEAT_SAVER, + heater_current=5.0, + heater_on_seconds=3600, + set_temperature=104.0, + current_temperature=102.0, + temperature_unit=TemperatureUnit.FAHRENHEIT, + ) + spa.water_care = WaterCare( + cartridge_installed=True, + ten_day_timer=0, + one_twenty_day_timer=117, + level=2, + system_enabled=True, + ace_mode="inactive", + boost_active=False, + salt_value=12, + ) + spa.jets = [ + Jet(jet_id=1, speed=JetSpeed.OFF, is_enabled=True, on_seconds=0), + Jet(jet_id=2, speed=JetSpeed.OFF, is_enabled=True, on_seconds=0), + ] + spa.blower = Blower(is_enabled=False, is_on=False) + spa.light_zones = [ + LightZone( + zone_id=1, + is_enabled=True, + is_on=False, + color=LightColor.OFF, + light_wheel=LightWheelMode.OFF, + intensity=0, + loop_speed=0, + ), + ] + spa.logo_light = LogoLight(brightness=BrightnessLevel.LEVEL_1) + spa.clean_cycle = CleanCycle(is_enabled=False, vanishing_act=False) + spa.spa_lock = SpaLock(is_locked=False) + spa.freshwater_iq = FreshWaterIQ( + conductivity=0, + orp=0, + chlorine=0.0, + ph=7.2, + sensor_life_percentage=100.0, + installed=False, + ) + spa.energy_savings = [ + EnergySaving(schedule_id=1, mode=0, start_hour=0, start_minute=0, duration=0), + ] + spa.connection_status = ConnectionStatus(spa_connected=True) + spa.diagnostics = Diagnostics( + spa_failure_state=SpaFailureState.OK, + heater_error="0", + power_frequency="60", + pressure_switch_status="0", + l1_n_volts=120.0, + l2_n_volts=120.0, + heater_volts=240.0, + jet3_volts=0.0, + jet1_jet2_blower_power="0", + small_loads_power="0", + heater_power="0", + jet3_power="0", + ) + spa.test_metrics = SpaTestData( + heater_test_status="off", + temp_offset=0.0, + vsense_cal=0.0, + jet1_jet2_blower_current=0.0, + small_loads_current=0.0, + heater_current=0.0, + jet3_current=0.0, + ) return spa diff --git a/tests/components/hotspring/snapshots/test_diagnostics.ambr b/tests/components/hotspring/snapshots/test_diagnostics.ambr new file mode 100644 index 000000000000..7cbf41189e48 --- /dev/null +++ b/tests/components/hotspring/snapshots/test_diagnostics.ambr @@ -0,0 +1,329 @@ +# serializer version: 1 +# name: test_diagnostics + dict({ + 'data': dict({ + 'blower': dict({ + 'is_enabled': False, + 'is_on': False, + }), + 'clean_cycle': dict({ + 'is_enabled': False, + 'vanishing_act': False, + }), + 'connection_status': dict({ + 'spa_connected': True, + }), + 'diagnostics': dict({ + 'heater_error': '0', + 'heater_power': '0', + 'heater_volts': 240.0, + 'jet1_jet2_blower_power': '0', + 'jet3_power': '0', + 'jet3_volts': 0.0, + 'l1_n_volts': 120.0, + 'l2_n_volts': 120.0, + 'power_frequency': '60', + 'pressure_switch_status': '0', + 'small_loads_power': '0', + 'spa_failure_state': dict({ + '__type': "", + 'repr': "", + }), + }), + 'energy_savings': list([ + dict({ + 'duration': 0, + 'mode': 0, + 'schedule_id': 1, + 'start_hour': 0, + 'start_minute': 0, + }), + ]), + 'freshwater_iq': dict({ + 'chlorine': 0.0, + 'conductivity': 0, + 'installed': False, + 'orp': 0, + 'ph': 7.2, + 'sensor_life_percentage': 100.0, + }), + 'heater': dict({ + 'current_temperature': 102.0, + 'heater_current': 5.0, + 'heater_lock': False, + 'heater_on_seconds': 3600, + 'heating_mode': dict({ + '__type': "", + 'repr': "", + }), + 'heatpump_installed': False, + 'is_on': True, + 'set_temperature': 104.0, + 'temperature_unit': dict({ + '__type': "", + 'repr': "", + }), + }), + 'info': dict({ + 'brand': dict({ + '__type': "", + 'repr': "", + }), + 'brand_id': '1', + 'brand_name': 'Hot Spring', + 'collection': 'Highlife', + 'collection_id': '1', + 'hostname': 'ConnectedSpa_**REDACTED**', + 'model_id': '1', + 'model_name': 'Relay', + 'root_topic': 'mySpa**REDACTED**', + 'sna_ready': True, + 'volume': 335, + }), + 'jets': list([ + dict({ + 'is_enabled': True, + 'jet_id': 1, + 'on_seconds': 0, + 'speed': dict({ + '__type': "", + 'repr': "", + }), + }), + dict({ + 'is_enabled': True, + 'jet_id': 2, + 'on_seconds': 0, + 'speed': dict({ + '__type': "", + 'repr': "", + }), + }), + ]), + 'light_zones': list([ + dict({ + 'color': dict({ + '__type': "", + 'repr': "", + }), + 'intensity': 0, + 'is_enabled': True, + 'is_on': False, + 'light_wheel': dict({ + '__type': "", + 'repr': "", + }), + 'loop_speed': 0, + 'zone_id': 1, + }), + ]), + 'logo_light': dict({ + 'brightness': dict({ + '__type': "", + 'repr': "", + }), + }), + 'spa_lock': dict({ + 'is_locked': False, + }), + 'test_metrics': dict({ + 'heater_current': 0.0, + 'heater_test_status': 'off', + 'jet1_jet2_blower_current': 0.0, + 'jet3_current': 0.0, + 'small_loads_current': 0.0, + 'temp_offset': 0.0, + 'vsense_cal': 0.0, + }), + 'versions': dict({ + 'amp': '', + 'btxr': '', + 'control_box': '3.0.0', + 'control_panel': '2.0.0', + 'cool_zone': '', + 'dosing': '', + 'fwiq': '', + 'fwss': '1.0.0', + 'logolight': '', + 'wifi_dongle': '1.0.0', + }), + 'water_care': dict({ + 'ace_mode': 'inactive', + 'boost_active': False, + 'cartridge_installed': True, + 'level': 2, + 'one_twenty_day_timer': 117, + 'salt_value': 12, + 'system_enabled': True, + 'ten_day_timer': 0, + }), + }), + 'entry': dict({ + 'host': '**REDACTED**', + }), + }) +# --- +# name: test_diagnostics_custom_topic + dict({ + 'data': dict({ + 'blower': dict({ + 'is_enabled': False, + 'is_on': False, + }), + 'clean_cycle': dict({ + 'is_enabled': False, + 'vanishing_act': False, + }), + 'connection_status': dict({ + 'spa_connected': True, + }), + 'diagnostics': dict({ + 'heater_error': '0', + 'heater_power': '0', + 'heater_volts': 240.0, + 'jet1_jet2_blower_power': '0', + 'jet3_power': '0', + 'jet3_volts': 0.0, + 'l1_n_volts': 120.0, + 'l2_n_volts': 120.0, + 'power_frequency': '60', + 'pressure_switch_status': '0', + 'small_loads_power': '0', + 'spa_failure_state': dict({ + '__type': "", + 'repr': "", + }), + }), + 'energy_savings': list([ + dict({ + 'duration': 0, + 'mode': 0, + 'schedule_id': 1, + 'start_hour': 0, + 'start_minute': 0, + }), + ]), + 'freshwater_iq': dict({ + 'chlorine': 0.0, + 'conductivity': 0, + 'installed': False, + 'orp': 0, + 'ph': 7.2, + 'sensor_life_percentage': 100.0, + }), + 'heater': dict({ + 'current_temperature': 102.0, + 'heater_current': 5.0, + 'heater_lock': False, + 'heater_on_seconds': 3600, + 'heating_mode': dict({ + '__type': "", + 'repr': "", + }), + 'heatpump_installed': False, + 'is_on': True, + 'set_temperature': 104.0, + 'temperature_unit': dict({ + '__type': "", + 'repr': "", + }), + }), + 'info': dict({ + 'brand': dict({ + '__type': "", + 'repr': "", + }), + 'brand_id': '1', + 'brand_name': 'Hot Spring', + 'collection': 'Highlife', + 'collection_id': '1', + 'hostname': 'customHost', + 'model_id': '1', + 'model_name': 'Relay', + 'root_topic': 'customTopic', + 'sna_ready': True, + 'volume': 335, + }), + 'jets': list([ + dict({ + 'is_enabled': True, + 'jet_id': 1, + 'on_seconds': 0, + 'speed': dict({ + '__type': "", + 'repr': "", + }), + }), + dict({ + 'is_enabled': True, + 'jet_id': 2, + 'on_seconds': 0, + 'speed': dict({ + '__type': "", + 'repr': "", + }), + }), + ]), + 'light_zones': list([ + dict({ + 'color': dict({ + '__type': "", + 'repr': "", + }), + 'intensity': 0, + 'is_enabled': True, + 'is_on': False, + 'light_wheel': dict({ + '__type': "", + 'repr': "", + }), + 'loop_speed': 0, + 'zone_id': 1, + }), + ]), + 'logo_light': dict({ + 'brightness': dict({ + '__type': "", + 'repr': "", + }), + }), + 'spa_lock': dict({ + 'is_locked': False, + }), + 'test_metrics': dict({ + 'heater_current': 0.0, + 'heater_test_status': 'off', + 'jet1_jet2_blower_current': 0.0, + 'jet3_current': 0.0, + 'small_loads_current': 0.0, + 'temp_offset': 0.0, + 'vsense_cal': 0.0, + }), + 'versions': dict({ + 'amp': '', + 'btxr': '', + 'control_box': '3.0.0', + 'control_panel': '2.0.0', + 'cool_zone': '', + 'dosing': '', + 'fwiq': '', + 'fwss': '1.0.0', + 'logolight': '', + 'wifi_dongle': '1.0.0', + }), + 'water_care': dict({ + 'ace_mode': 'inactive', + 'boost_active': False, + 'cartridge_installed': True, + 'level': 2, + 'one_twenty_day_timer': 117, + 'salt_value': 12, + 'system_enabled': True, + 'ten_day_timer': 0, + }), + }), + 'entry': dict({ + 'host': '**REDACTED**', + }), + }) +# --- diff --git a/tests/components/hotspring/snapshots/test_sensor.ambr b/tests/components/hotspring/snapshots/test_sensor.ambr new file mode 100644 index 000000000000..15e8bcbbc99a --- /dev/null +++ b/tests/components/hotspring/snapshots/test_sensor.ambr @@ -0,0 +1,372 @@ +# serializer version: 1 +# name: test_sensors[sensor.connectedspa_ddeeff_control_box_version-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.connectedspa_ddeeff_control_box_version', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Control box version', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Control box version', + 'platform': 'hotspring', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'control_box_version', + 'unique_id': 'AA:BB:CC:DD:EE:FF_control_box_version', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensors[sensor.connectedspa_ddeeff_control_box_version-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'ConnectedSpa_DDEEFF Control box version', + }), + 'context': , + 'entity_id': 'sensor.connectedspa_ddeeff_control_box_version', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '3.0.0', + }) +# --- +# name: test_sensors[sensor.connectedspa_ddeeff_current_temperature-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.connectedspa_ddeeff_current_temperature', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Current temperature', + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Current temperature', + 'platform': 'hotspring', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'current_temperature', + 'unique_id': 'AA:BB:CC:DD:EE:FF_current_temperature', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.connectedspa_ddeeff_current_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'temperature', + : 'ConnectedSpa_DDEEFF Current temperature', + : , + : , + }), + 'context': , + 'entity_id': 'sensor.connectedspa_ddeeff_current_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '38.8888888888889', + }) +# --- +# name: test_sensors[sensor.connectedspa_ddeeff_freshwater_salt_system_version-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.connectedspa_ddeeff_freshwater_salt_system_version', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'FreshWater Salt System version', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'FreshWater Salt System version', + 'platform': 'hotspring', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'fwss_version', + 'unique_id': 'AA:BB:CC:DD:EE:FF_fwss_version', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensors[sensor.connectedspa_ddeeff_freshwater_salt_system_version-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'ConnectedSpa_DDEEFF FreshWater Salt System version', + }), + 'context': , + 'entity_id': 'sensor.connectedspa_ddeeff_freshwater_salt_system_version', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '1.0.0', + }) +# --- +# name: test_sensors[sensor.connectedspa_ddeeff_salt_10_day_check_timer-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.connectedspa_ddeeff_salt_10_day_check_timer', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Salt 10-day check timer', + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Salt 10-day check timer', + 'platform': 'hotspring', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'water_care_10_day_timer', + 'unique_id': 'AA:BB:CC:DD:EE:FF_water_care_10_day_timer', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.connectedspa_ddeeff_salt_10_day_check_timer-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'duration', + : 'ConnectedSpa_DDEEFF Salt 10-day check timer', + : , + }), + 'context': , + 'entity_id': 'sensor.connectedspa_ddeeff_salt_10_day_check_timer', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0', + }) +# --- +# name: test_sensors[sensor.connectedspa_ddeeff_salt_cartridge_age-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.connectedspa_ddeeff_salt_cartridge_age', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Salt cartridge age', + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Salt cartridge age', + 'platform': 'hotspring', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'water_care_120_day_timer', + 'unique_id': 'AA:BB:CC:DD:EE:FF_water_care_120_day_timer', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.connectedspa_ddeeff_salt_cartridge_age-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'duration', + : 'ConnectedSpa_DDEEFF Salt cartridge age', + : , + }), + 'context': , + 'entity_id': 'sensor.connectedspa_ddeeff_salt_cartridge_age', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '117', + }) +# --- +# name: test_sensors[sensor.connectedspa_ddeeff_salt_value-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.connectedspa_ddeeff_salt_value', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Salt value', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Salt value', + 'platform': 'hotspring', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'water_care_salt_value', + 'unique_id': 'AA:BB:CC:DD:EE:FF_water_care_salt_value', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensors[sensor.connectedspa_ddeeff_salt_value-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'ConnectedSpa_DDEEFF Salt value', + : , + }), + 'context': , + 'entity_id': 'sensor.connectedspa_ddeeff_salt_value', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '12', + }) +# --- +# name: test_sensors[sensor.connectedspa_ddeeff_wi_fi_dongle_version-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.connectedspa_ddeeff_wi_fi_dongle_version', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Wi-Fi dongle version', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Wi-Fi dongle version', + 'platform': 'hotspring', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'wifi_dongle_version', + 'unique_id': 'AA:BB:CC:DD:EE:FF_wifi_dongle_version', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensors[sensor.connectedspa_ddeeff_wi_fi_dongle_version-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'ConnectedSpa_DDEEFF Wi-Fi dongle version', + }), + 'context': , + 'entity_id': 'sensor.connectedspa_ddeeff_wi_fi_dongle_version', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '1.0.0', + }) +# --- diff --git a/tests/components/hotspring/test_config_flow.py b/tests/components/hotspring/test_config_flow.py index e8cad058d888..677da008b306 100644 --- a/tests/components/hotspring/test_config_flow.py +++ b/tests/components/hotspring/test_config_flow.py @@ -1,18 +1,31 @@ """Tests for the Hot Spring config flow.""" +import dataclasses +from ipaddress import ip_address from unittest.mock import MagicMock from hotspring import HotSpringConnectionError, HotSpringError, Spa import pytest from homeassistant.components.hotspring.const import DOMAIN -from homeassistant.config_entries import SOURCE_USER +from homeassistant.config_entries import SOURCE_USER, SOURCE_ZEROCONF from homeassistant.const import CONF_HOST from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType +from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo from tests.common import MockConfigEntry, get_schema_suggested_value +MOCK_ZEROCONF_DATA = ZeroconfServiceInfo( + ip_address=ip_address("192.168.1.100"), + ip_addresses=[ip_address("192.168.1.100")], + hostname="Watkins_SpaAABBCCDDEEFF.local.", + name="Watkins_SpaAABBCCDDEEFF._ws._tcp.local.", + port=80, + properties={}, + type="_ws._tcp.local.", +) + @pytest.mark.usefixtures("mock_setup_entry", "mock_hotspring") async def test_full_user_flow_implementation(hass: HomeAssistant) -> None: @@ -121,6 +134,71 @@ async def test_form_no_mac_address( assert result["result"].unique_id == "AA:BB:CC:DD:EE:FF" +@pytest.mark.usefixtures("mock_setup_entry", "mock_hotspring") +async def test_full_zeroconf_flow_implementation(hass: HomeAssistant) -> None: + """Test the full zeroconf flow from start to finish.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_ZEROCONF}, + data=MOCK_ZEROCONF_DATA, + ) + + assert result["step_id"] == "zeroconf_confirm" + assert result["type"] is FlowResultType.FORM + assert result["description_placeholders"] == {"name": "ConnectedSpa_DDEEFF"} + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={} + ) + + assert result["title"] == "ConnectedSpa_DDEEFF" + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["data"] == {CONF_HOST: "192.168.1.100"} + assert result["result"].unique_id == "AA:BB:CC:DD:EE:FF" + + +@pytest.mark.parametrize( + "exception", + [HotSpringConnectionError, HotSpringError], +) +async def test_zeroconf_connection_error( + hass: HomeAssistant, mock_hotspring: MagicMock, exception: type[Exception] +) -> None: + """Test we abort zeroconf flow on Hot Spring connection error.""" + mock_hotspring.update.side_effect = exception + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_ZEROCONF}, + data=MOCK_ZEROCONF_DATA, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "cannot_connect" + + +@pytest.mark.usefixtures("mock_hotspring") +async def test_zeroconf_device_already_configured( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, +) -> None: + """Test we abort zeroconf flow and update host if already configured.""" + mock_config_entry.add_to_hass(hass) + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_ZEROCONF}, + data=dataclasses.replace( + MOCK_ZEROCONF_DATA, + ip_address=ip_address("192.168.1.200"), + ip_addresses=[ip_address("192.168.1.200")], + ), + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" + assert mock_config_entry.data[CONF_HOST] == "192.168.1.200" + + @pytest.mark.usefixtures("mock_setup_entry") async def test_full_reconfigure_flow_success( hass: HomeAssistant, diff --git a/tests/components/hotspring/test_diagnostics.py b/tests/components/hotspring/test_diagnostics.py new file mode 100644 index 000000000000..ea2742869a83 --- /dev/null +++ b/tests/components/hotspring/test_diagnostics.py @@ -0,0 +1,42 @@ +"""Tests for the diagnostics data provided by the Hot Spring integration.""" + +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 + + +async def test_diagnostics( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + init_integration: MockConfigEntry, + snapshot: SnapshotAssertion, +) -> None: + """Test diagnostics.""" + assert ( + await get_diagnostics_for_config_entry(hass, hass_client, init_integration) + == snapshot + ) + + +async def test_diagnostics_custom_topic( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + init_integration: MockConfigEntry, + snapshot: SnapshotAssertion, +) -> None: + """Test diagnostics to ensure root_topic without MAC address is not redacted. + + This preserves diagnosing capabilities in case a spa model acts differently than expected. + """ + coordinator = init_integration.runtime_data + coordinator.data.info.root_topic = "customTopic" + coordinator.data.info.hostname = "customHost" + + assert ( + await get_diagnostics_for_config_entry(hass, hass_client, init_integration) + == snapshot + ) diff --git a/tests/components/hotspring/test_init.py b/tests/components/hotspring/test_init.py index e87c42851cf7..99230fc06a33 100644 --- a/tests/components/hotspring/test_init.py +++ b/tests/components/hotspring/test_init.py @@ -1,5 +1,6 @@ """Tests for the Hot Spring integration.""" +from typing import cast from unittest.mock import MagicMock from hotspring import HotSpringConnectionError, HotSpringError, Spa @@ -22,7 +23,7 @@ async def test_async_setup_entry( assert await hass.config_entries.async_unload(init_integration.entry_id) await hass.async_block_till_done() - assert init_integration.state is ConfigEntryState.NOT_LOADED + assert cast(ConfigEntryState, init_integration.state) is ConfigEntryState.NOT_LOADED async def test_device_info( diff --git a/tests/components/hotspring/test_sensor.py b/tests/components/hotspring/test_sensor.py new file mode 100644 index 000000000000..970913b19a8a --- /dev/null +++ b/tests/components/hotspring/test_sensor.py @@ -0,0 +1,24 @@ +"""Tests for the Hot Spring sensor platform.""" + +import pytest +from syrupy.assertion import SnapshotAssertion + +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from . import setup_with_selected_platforms + +from tests.common import MockConfigEntry, snapshot_platform + + +@pytest.mark.usefixtures("entity_registry_enabled_by_default", "mock_hotspring") +async def test_sensors( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, +) -> None: + """Test the sensor platform state.""" + await setup_with_selected_platforms(hass, mock_config_entry, [Platform.SENSOR]) + await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) diff --git a/tests/components/http/test_ban.py b/tests/components/http/test_ban.py index 5f5fd1660f4f..ff6465e91f87 100644 --- a/tests/components/http/test_ban.py +++ b/tests/components/http/test_ban.py @@ -83,18 +83,19 @@ async def test_access_from_banned_ip_with_partially_broken_yaml_file( aiohttp_client: ClientSessionGenerator, caplog: pytest.LogCaptureFixture, ) -> None: - """Test accessing to server from banned IP. Both trusted and not. - - We inject some garbage into the yaml file to make sure it can - still load the bans. - """ + """Test loading IP bans from a partially broken YAML file.""" app = web.Application() app[KEY_HASS] = hass setup_bans(hass, app, 5) set_real_ip = mock_real_ip(app) - data = {banned_ip: {"banned_at": "2016-11-16T19:20:03"} for banned_ip in BANNED_IPS} - data["5.3.3.3"] = {"banned_at": "garbage"} + data = { + BANNED_IPS[0]: {"banned_at": "2016-11-16T19:20:03"}, + "5.3.3.3": {"banned_at": "garbage"}, + "5.3.3.4": {}, + "5.3.3.5": None, + BANNED_IPS[1]: {"banned_at": "2016-11-16T19:20:03"}, + } with patch( "homeassistant.components.http.ban.load_yaml_config_file", @@ -102,17 +103,18 @@ async def test_access_from_banned_ip_with_partially_broken_yaml_file( ): client = await aiohttp_client(app) - for remote_addr in BANNED_IPS: + for remote_addr in (*BANNED_IPS, "5.3.3.4"): set_real_ip(remote_addr) resp = await client.get("/") assert resp.status == HTTPStatus.FORBIDDEN - # Ensure garbage data is ignored - set_real_ip("5.3.3.3") - resp = await client.get("/") - assert resp.status == HTTPStatus.NOT_FOUND + # Ensure malformed data is ignored + for remote_addr in ("5.3.3.3", "5.3.3.5"): + set_real_ip(remote_addr) + resp = await client.get("/") + assert resp.status == HTTPStatus.NOT_FOUND - assert "Failed to load IP ban" in caplog.text + assert caplog.text.count("Failed to load IP ban") == 2 async def test_access_from_banned_ip_with_invalid_ip_entry( diff --git a/tests/components/hue/fixtures/v2_resources.json b/tests/components/hue/fixtures/v2_resources.json index 831a499bd593..b52ab82b5be8 100644 --- a/tests/components/hue/fixtures/v2_resources.json +++ b/tests/components/hue/fixtures/v2_resources.json @@ -1509,8 +1509,8 @@ "on": true }, "owner": { - "rid": "7cee478d-6455-483a-9e32-9f9fdcbcc4f6", - "rtype": "zone" + "rid": "a3fbc86a-bf4c-4c69-899d-d6eafc37e288", + "rtype": "bridge_home" }, "type": "grouped_light" }, diff --git a/tests/components/humidifier/test_device_action.py b/tests/components/humidifier/test_device_action.py index 3e308a82c54e..6080d89d7b20 100644 --- a/tests/components/humidifier/test_device_action.py +++ b/tests/components/humidifier/test_device_action.py @@ -58,7 +58,9 @@ async def test_get_actions( ) if set_state: hass.states.async_set( - f"{DOMAIN}.test_5678", "attributes", {"supported_features": features_state} + entity_entry.entity_id, + "attributes", + {"supported_features": features_state}, ) expected_actions = [] basic_action_types = ["set_humidity", "turn_on", "turn_off", "toggle"] @@ -471,7 +473,7 @@ async def test_capabilities( ) if set_state: hass.states.async_set( - f"{DOMAIN}.test_5678", + entity_entry.entity_id, STATE_ON, capabilities_state, ) @@ -615,7 +617,7 @@ async def test_capabilities_legacy( ) if set_state: hass.states.async_set( - f"{DOMAIN}.test_5678", + entity_entry.entity_id, STATE_ON, capabilities_state, ) diff --git a/tests/components/humidifier/test_device_condition.py b/tests/components/humidifier/test_device_condition.py index cb6ddfd936cc..80824d70cd7a 100644 --- a/tests/components/humidifier/test_device_condition.py +++ b/tests/components/humidifier/test_device_condition.py @@ -54,7 +54,9 @@ async def test_get_conditions( ) if set_state: hass.states.async_set( - f"{DOMAIN}.test_5678", "attributes", {"supported_features": features_state} + entity_entry.entity_id, + "attributes", + {"supported_features": features_state}, ) expected_conditions = [] basic_condition_types = ["is_on", "is_off"] diff --git a/tests/components/humidifier/test_device_trigger.py b/tests/components/humidifier/test_device_trigger.py index 40d88ae1e409..2b4f646c7fd9 100644 --- a/tests/components/humidifier/test_device_trigger.py +++ b/tests/components/humidifier/test_device_trigger.py @@ -395,8 +395,8 @@ async def test_if_fires_on_state_change( await hass.async_block_till_done() assert len(service_calls) == 8 assert {service_calls[6].data["some"], service_calls[7].data["some"]} == { - "turn_off device - humidifier.test_5678 - on - off - None", - "turn_on_or_off device - humidifier.test_5678 - on - off - None", + f"turn_off device - {entry.entity_id} - on - off - None", + f"turn_on_or_off device - {entry.entity_id} - on - off - None", } # Fake turn on @@ -408,8 +408,8 @@ async def test_if_fires_on_state_change( await hass.async_block_till_done() assert len(service_calls) == 10 assert {service_calls[8].data["some"], service_calls[9].data["some"]} == { - "turn_on device - humidifier.test_5678 - off - on - None", - "turn_on_or_off device - humidifier.test_5678 - off - on - None", + f"turn_on device - {entry.entity_id} - off - on - None", + f"turn_on_or_off device - {entry.entity_id} - off - on - None", } diff --git a/tests/components/incomfort/test_config_flow.py b/tests/components/incomfort/test_config_flow.py index ce74b966fca6..e413087dd3f3 100644 --- a/tests/components/incomfort/test_config_flow.py +++ b/tests/components/incomfort/test_config_flow.py @@ -164,7 +164,7 @@ async def test_dhcp_flow_simple( assert gateway_device.manufacturer == "Intergas" assert gateway_device.connections == {("mac", "00:04:a3:de:ad:ff")} - devices = device_registry.devices.get_devices_for_config_entry_id(entry_id) + devices = dr.async_entries_for_config_entry(device_registry, entry_id) assert len(devices) == 3 boiler_device = device_registry.async_get_device_by_identifier( (DOMAIN, "c0ffeec0ffee"), entry_id @@ -212,8 +212,8 @@ async def test_dhcp_flow_migrates_existing_entry_without_unique_id( assert gateway_device.manufacturer == "Intergas" assert gateway_device.connections == {("mac", "00:04:a3:de:ad:ff")} - devices = device_registry.devices.get_devices_for_config_entry_id( - mock_config_entry.entry_id + devices = dr.async_entries_for_config_entry( + device_registry, mock_config_entry.entry_id ) assert len(devices) == 3 boiler_device = device_registry.async_get_device_by_identifier( diff --git a/tests/components/incomfort/test_init.py b/tests/components/incomfort/test_init.py index f619219d2f0e..c6d478674c82 100644 --- a/tests/components/incomfort/test_init.py +++ b/tests/components/incomfort/test_init.py @@ -14,7 +14,7 @@ from homeassistant.components.incomfort.coordinator import UPDATE_INTERVAL from homeassistant.config_entries import ConfigEntry, ConfigEntryState from homeassistant.const import STATE_UNAVAILABLE from homeassistant.core import HomeAssistant -from homeassistant.helpers import entity_registry as er +from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.helpers.device_registry import DeviceRegistry from .conftest import MOCK_HEATER_STATUS @@ -81,8 +81,8 @@ async def test_stale_devices_cleanup( await hass.config_entries.async_setup(mock_config_entry.entry_id) assert mock_config_entry.state is ConfigEntryState.LOADED await hass.config_entries.async_unload(mock_config_entry.entry_id) - old_entries = device_registry.devices.get_devices_for_config_entry_id( - mock_config_entry.entry_id + old_entries = dr.async_entries_for_config_entry( + device_registry, mock_config_entry.entry_id ) assert len(old_entries) == 3 old_heater = device_registry.async_get_device_by_identifier( @@ -103,8 +103,8 @@ async def test_stale_devices_cleanup( await hass.config_entries.async_setup(mock_config_entry.entry_id) assert mock_config_entry.state is ConfigEntryState.LOADED - new_entries = device_registry.devices.get_devices_for_config_entry_id( - mock_config_entry.entry_id + new_entries = dr.async_entries_for_config_entry( + device_registry, mock_config_entry.entry_id ) assert len(new_entries) == 3 new_heater = device_registry.async_get_device_by_identifier( diff --git a/tests/components/insteon/test_api_properties.py b/tests/components/insteon/test_api_properties.py index 2d15132e5ffb..793933564456 100644 --- a/tests/components/insteon/test_api_properties.py +++ b/tests/components/insteon/test_api_properties.py @@ -1,6 +1,5 @@ """Test the Insteon properties APIs.""" -import asyncio import json from typing import Any from unittest.mock import AsyncMock, patch @@ -157,7 +156,6 @@ async def test_get_read_only_properties( msg = await ws_client.receive_json() assert msg["success"] assert len(msg["result"]["properties"]) == 15 - await asyncio.sleep(1) async def test_get_unknown_properties( diff --git a/tests/components/integration/test_init.py b/tests/components/integration/test_init.py index d422ac541860..8328c82bf4a8 100644 --- a/tests/components/integration/test_init.py +++ b/tests/components/integration/test_init.py @@ -197,7 +197,7 @@ async def test_entry_changed(hass: HomeAssistant, platform) -> None: assert config_entry.entry_id not in _get_device_config_entries(input_entry) assert config_entry.entry_id not in _get_device_config_entries(valid_entry) - integration_entity_entry = entity_registry.async_get("sensor.my_integration") + integration_entity_entry = entity_registry.async_get("sensor.input_my_integration") assert integration_entity_entry.device_id == input_entry.device_id hass.config_entries.async_update_entry( @@ -209,7 +209,7 @@ async def test_entry_changed(hass: HomeAssistant, platform) -> None: # Check that the device association has updated assert config_entry.entry_id not in _get_device_config_entries(input_entry) assert config_entry.entry_id not in _get_device_config_entries(valid_entry) - integration_entity_entry = entity_registry.async_get("sensor.my_integration") + integration_entity_entry = entity_registry.async_get("sensor.input_my_integration") assert integration_entity_entry.device_id == valid_entry.device_id @@ -226,7 +226,9 @@ async def test_async_handle_source_entity_changes_source_entity_removed( assert await hass.config_entries.async_setup(integration_config_entry.entry_id) await hass.async_block_till_done() - integration_entity_entry = entity_registry.async_get("sensor.my_integration") + integration_entity_entry = entity_registry.async_get( + "sensor.mock_title_my_integration" + ) assert integration_entity_entry.device_id == sensor_entity_entry.device_id sensor_device = device_registry.async_get(sensor_device.id) @@ -245,7 +247,9 @@ async def test_async_handle_source_entity_changes_source_entity_removed( mock_unload_entry.assert_not_called() # Check that the entity is no longer linked to the source device - integration_entity_entry = entity_registry.async_get("sensor.my_integration") + integration_entity_entry = entity_registry.async_get( + "sensor.mock_title_my_integration" + ) assert integration_entity_entry.device_id is None # Check that the device is removed @@ -270,7 +274,9 @@ async def test_async_handle_source_entity_changes_source_entity_removed_shared_d assert await hass.config_entries.async_setup(integration_config_entry.entry_id) await hass.async_block_till_done() - integration_entity_entry = entity_registry.async_get("sensor.my_integration") + integration_entity_entry = entity_registry.async_get( + "sensor.mock_title_my_integration" + ) assert integration_entity_entry.device_id == sensor_entity_entry.device_id sensor_device = device_registry.async_get(sensor_device.id) @@ -289,7 +295,9 @@ async def test_async_handle_source_entity_changes_source_entity_removed_shared_d mock_unload_entry.assert_not_called() # Check that the entity is no longer linked to the source device - integration_entity_entry = entity_registry.async_get("sensor.my_integration") + integration_entity_entry = entity_registry.async_get( + "sensor.mock_title_my_integration" + ) assert integration_entity_entry.device_id is None # Check that the source device is not removed @@ -318,7 +326,9 @@ async def test_async_handle_source_entity_changes_source_entity_removed_from_dev assert await hass.config_entries.async_setup(integration_config_entry.entry_id) await hass.async_block_till_done() - integration_entity_entry = entity_registry.async_get("sensor.my_integration") + integration_entity_entry = entity_registry.async_get( + "sensor.mock_title_my_integration" + ) assert integration_entity_entry.device_id == sensor_entity_entry.device_id sensor_device = device_registry.async_get(sensor_device.id) @@ -338,7 +348,9 @@ async def test_async_handle_source_entity_changes_source_entity_removed_from_dev mock_unload_entry.assert_called_once() # Check that the entity is no longer linked to the source device - integration_entity_entry = entity_registry.async_get("sensor.my_integration") + integration_entity_entry = entity_registry.async_get( + "sensor.mock_title_my_integration" + ) assert integration_entity_entry.device_id is None # Check that the integration config entry is not in the device @@ -370,7 +382,9 @@ async def test_async_handle_source_entity_changes_source_entity_moved_other_devi assert await hass.config_entries.async_setup(integration_config_entry.entry_id) await hass.async_block_till_done() - integration_entity_entry = entity_registry.async_get("sensor.my_integration") + integration_entity_entry = entity_registry.async_get( + "sensor.mock_title_my_integration" + ) assert integration_entity_entry.device_id == sensor_entity_entry.device_id sensor_device = device_registry.async_get(sensor_device.id) @@ -392,7 +406,9 @@ async def test_async_handle_source_entity_changes_source_entity_moved_other_devi mock_unload_entry.assert_called_once() # Check that the entity is linked to the other device - integration_entity_entry = entity_registry.async_get("sensor.my_integration") + integration_entity_entry = entity_registry.async_get( + "sensor.mock_title_my_integration" + ) assert integration_entity_entry.device_id == sensor_device_2.id # Check that the derivative config entry is not in any of the devices @@ -420,7 +436,9 @@ async def test_async_handle_source_entity_new_entity_id( assert await hass.config_entries.async_setup(integration_config_entry.entry_id) await hass.async_block_till_done() - integration_entity_entry = entity_registry.async_get("sensor.my_integration") + integration_entity_entry = entity_registry.async_get( + "sensor.mock_title_my_integration" + ) assert integration_entity_entry.device_id == sensor_entity_entry.device_id sensor_device = device_registry.async_get(sensor_device.id) @@ -489,7 +507,9 @@ async def test_migration_1_1( # is linked to the source device sensor_device = device_registry.async_get(sensor_device.id) assert integration_config_entry.entry_id not in sensor_device.config_entries - integration_entity_entry = entity_registry.async_get("sensor.my_integration") + integration_entity_entry = entity_registry.async_get( + "sensor.mock_title_my_integration" + ) assert integration_entity_entry.device_id == sensor_entity_entry.device_id assert integration_config_entry.version == 1 diff --git a/tests/components/integration/test_sensor.py b/tests/components/integration/test_sensor.py index b8c8b0270abd..fdd3a50cefed 100644 --- a/tests/components/integration/test_sensor.py +++ b/tests/components/integration/test_sensor.py @@ -892,7 +892,7 @@ async def test_device_id( device_id=source_device_entry.id, ) await hass.async_block_till_done() - assert entity_registry.async_get("sensor.test_source") is not None + assert entity_registry.async_get("sensor.mock_title") is not None integration_config_entry = MockConfigEntry( data={}, @@ -901,7 +901,7 @@ async def test_device_id( "method": "trapezoidal", "name": "integration", "round": 1.0, - "source": "sensor.test_source", + "source": "sensor.mock_title", "unit_prefix": "k", "unit_time": "min", }, @@ -913,7 +913,7 @@ async def test_device_id( assert await hass.config_entries.async_setup(integration_config_entry.entry_id) await hass.async_block_till_done() - integration_entity = entity_registry.async_get("sensor.integration") + integration_entity = entity_registry.async_get("sensor.mock_title_integration") assert integration_entity is not None assert integration_entity.device_id == source_entity.device_id diff --git a/tests/components/jvc_projector/test_remote.py b/tests/components/jvc_projector/test_remote.py index 0ba2f18fe1ac..b70501ab79da 100644 --- a/tests/components/jvc_projector/test_remote.py +++ b/tests/components/jvc_projector/test_remote.py @@ -1,6 +1,6 @@ """Tests for JVC Projector remote platform.""" -from unittest.mock import MagicMock +from unittest.mock import MagicMock, patch import pytest @@ -31,6 +31,7 @@ async def test_entity_state( assert entity_registry.async_get(entity.entity_id) +@patch("homeassistant.components.jvc_projector.remote.POWER_SLEEP", 0) async def test_commands( hass: HomeAssistant, mock_device: MagicMock, diff --git a/tests/components/knx/test_interface_device.py b/tests/components/knx/test_interface_device.py index 2c74d00fb395..851a57c11f81 100644 --- a/tests/components/knx/test_interface_device.py +++ b/tests/components/knx/test_interface_device.py @@ -124,8 +124,8 @@ async def test_remove_interface_device( assert await async_setup_component(hass, "config", {}) await knx.setup_integration() client = await hass_ws_client(hass) - knx_devices = device_registry.devices.get_devices_for_config_entry_id( - knx.mock_config_entry.entry_id + knx_devices = dr.async_entries_for_config_entry( + device_registry, knx.mock_config_entry.entry_id ) assert len(knx_devices) == 1 assert knx_devices[0].name == "KNX Interface" diff --git a/tests/components/knx/test_switch.py b/tests/components/knx/test_switch.py index b214efea0d52..69961da5b4e7 100644 --- a/tests/components/knx/test_switch.py +++ b/tests/components/knx/test_switch.py @@ -238,6 +238,6 @@ async def test_switch_ui_load(knx: KNXTestKit) -> None: # unrelated light in config store await knx.assert_read("1/0/21", response=True, ignore_order=True) knx.assert_state( - "switch.test", # has_entity_name with unregistered device + "switch.knx_test", # has_entity_name with device named after config entry STATE_ON, ) diff --git a/tests/components/lcn/test_init.py b/tests/components/lcn/test_init.py index b1b2eedafb6a..eb46429748ae 100644 --- a/tests/components/lcn/test_init.py +++ b/tests/components/lcn/test_init.py @@ -87,7 +87,7 @@ async def test_async_setup_entry_update( ) assert dummy_entity in entity_registry.entities.values() - assert dummy_device in device_registry.devices.values() + assert dummy_device in device_registry.devices @pytest.mark.parametrize( diff --git a/tests/components/lg_netcast/test_trigger.py b/tests/components/lg_netcast/test_trigger.py index d6959f792379..08cc411e500e 100644 --- a/tests/components/lg_netcast/test_trigger.py +++ b/tests/components/lg_netcast/test_trigger.py @@ -36,7 +36,7 @@ async def test_lg_netcast_turn_on_trigger_device_id( device = device_registry.async_get_device_by_identifier( (DOMAIN, UNIQUE_ID), config_entry.entry_id ) - assert device, repr(device_registry.devices) + assert device, repr(device_registry._devices) assert await async_setup_component( hass, diff --git a/tests/components/lg_thinq/test_sensor.py b/tests/components/lg_thinq/test_sensor.py index 4bd59cb1b529..a59171589b9d 100644 --- a/tests/components/lg_thinq/test_sensor.py +++ b/tests/components/lg_thinq/test_sensor.py @@ -34,7 +34,7 @@ async def test_sensor_entities( entity_registry: er.EntityRegistry, ) -> None: """Test all entities.""" - hass.config.time_zone = "UTC" + await hass.config.async_set_time_zone("UTC") with patch("homeassistant.components.lg_thinq.PLATFORMS", [Platform.SENSOR]): await setup_integration(hass, mock_config_entry) @@ -61,7 +61,7 @@ async def test_update_energy_entity( freezer: FrozenDateTimeFactory, ) -> None: """Test update energy entity.""" - hass.config.time_zone = "UTC" + await hass.config.async_set_time_zone("UTC") with patch( "homeassistant.components.lg_thinq.sensor.random.randint", return_value=1 ): @@ -94,7 +94,7 @@ async def test_energy_today_updates_hourly( freezer: FrozenDateTimeFactory, ) -> None: """Test that energy_today sensor updates every hour, not once per day.""" - hass.config.time_zone = "UTC" + await hass.config.async_set_time_zone("UTC") await setup_integration(hass, mock_config_entry) entity_id = "sensor.test_air_conditioner_energy_today" @@ -126,7 +126,7 @@ async def test_energy_today_last_reset_set_on_first_fetch( freezer: FrozenDateTimeFactory, ) -> None: """Test last_reset is set to midnight of the fetched day after first successful fetch.""" - hass.config.time_zone = "UTC" + await hass.config.async_set_time_zone("UTC") await setup_integration(hass, mock_config_entry) entity_id = "sensor.test_air_conditioner_energy_today" @@ -160,7 +160,7 @@ async def test_energy_today_last_reset_advances_on_new_day_fetch( freezer: FrozenDateTimeFactory, ) -> None: """Test last_reset advances to the new day's midnight only when its data is fetched.""" - hass.config.time_zone = "UTC" + await hass.config.async_set_time_zone("UTC") await setup_integration(hass, mock_config_entry) entity_id = "sensor.test_air_conditioner_energy_today" diff --git a/tests/components/litellm/test_config_flow.py b/tests/components/litellm/test_config_flow.py index ac3a1675ab11..57edc21d4382 100644 --- a/tests/components/litellm/test_config_flow.py +++ b/tests/components/litellm/test_config_flow.py @@ -17,6 +17,7 @@ from homeassistant.config_entries import SOURCE_USER from homeassistant.const import CONF_API_KEY, CONF_LLM_HASS_API, CONF_MODEL, CONF_URL from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType +from homeassistant.helpers import llm from . import get_subentry_id, setup_integration from .conftest import TEST_URL, models_response @@ -191,10 +192,10 @@ async def test_create_conversation_agent( ) assert result["type"] is FlowResultType.FORM assert result["step_id"] == "init" - assert ( - result["data_schema"].schema["model"].config["options"] - == CONVERSATION_MODEL_OPTIONS - ) + schema = result["data_schema"].schema + assert schema["model"].config["options"] == CONVERSATION_MODEL_OPTIONS + key = next(k for k in schema if k == CONF_LLM_HASS_API) + assert key.default() == [llm.LLM_API_ASSIST] result = await hass.config_entries.subentries.async_configure( result["flow_id"], @@ -241,6 +242,7 @@ async def test_create_conversation_agent_no_control( assert result["data"] == { CONF_MODEL: "gpt-3.5-turbo", CONF_PROMPT: "you are an assistant", + CONF_LLM_HASS_API: [], } @@ -337,6 +339,36 @@ async def test_reconfigure_conversation_agent( assert subentry.data[CONF_LLM_HASS_API] == ["assist"] +@pytest.mark.usefixtures("mock_models") +async def test_reconfigure_conversation_agent_disable_llm_api( + hass: HomeAssistant, + mock_openai_client: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test unchecking all LLM APIs is remembered when reopening the form.""" + await setup_integration(hass, mock_config_entry) + + subentry_id = get_subentry_id(mock_config_entry, "conversation") + + result = await mock_config_entry.start_subentry_reconfigure_flow(hass, subentry_id) + result = await hass.config_entries.subentries.async_configure( + result["flow_id"], + { + CONF_MODEL: "gpt-4", + CONF_PROMPT: "updated prompt", + CONF_LLM_HASS_API: [], + }, + ) + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reconfigure_successful" + assert mock_config_entry.subentries[subentry_id].data[CONF_LLM_HASS_API] == [] + + result = await mock_config_entry.start_subentry_reconfigure_flow(hass, subentry_id) + schema = result["data_schema"].schema + key = next(k for k in schema if k == CONF_LLM_HASS_API) + assert key.default() == [] + + async def test_reconfigure_entry_not_loaded( hass: HomeAssistant, mock_config_entry: MockConfigEntry, diff --git a/tests/components/local_calendar/test_calendar.py b/tests/components/local_calendar/test_calendar.py index cc2a1385a4fb..0cf1bc138cd6 100644 --- a/tests/components/local_calendar/test_calendar.py +++ b/tests/components/local_calendar/test_calendar.py @@ -1,13 +1,18 @@ """Tests for calendar platform of local calendar.""" import datetime +from datetime import timedelta import textwrap +from unittest.mock import patch +from freezegun.api import FrozenDateTimeFactory import pytest +from homeassistant.components.local_calendar.const import DOMAIN from homeassistant.const import STATE_OFF, STATE_ON from homeassistant.core import HomeAssistant from homeassistant.helpers.template import DATE_STR_FORMAT +from homeassistant.setup import async_setup_component from homeassistant.util import dt as dt_util from .conftest import ( @@ -18,7 +23,7 @@ from .conftest import ( event_fields, ) -from tests.common import MockConfigEntry +from tests.common import MockConfigEntry, async_fire_time_changed async def test_empty_calendar( @@ -1158,3 +1163,56 @@ async def test_invalid_event_duration( "end": {"dateTime": "1997-07-14T11:30:00-06:00"}, } ] + + +ADJACENT_EVENTS_ICS = """BEGIN:VCALENDAR +PRODID:-//homeassistant.io//local_calendar 1.0//EN +VERSION:2.0 +BEGIN:VEVENT +DTSTART:20260729T014500 +DTEND:20260729T020000 +SUMMARY:First +UID:first +END:VEVENT +BEGIN:VEVENT +DTSTART:20260729T020000 +DTEND:20260729T021500 +SUMMARY:Second +UID:second +END:VEVENT +END:VCALENDAR +""" + + +@pytest.mark.parametrize("ics_content", [ADJACENT_EVENTS_ICS]) +async def test_adjacent_events_stay_on( + hass: HomeAssistant, + freezer: FrozenDateTimeFactory, + config_entry: MockConfigEntry, +) -> None: + """Test the state stays on when one event ends as the next one begins. + + The scan interval is widened so the platform poll cannot reach the boundary + first: what is under test is the alarm scheduled for the end of the current + event, which has to be able to pick up the next one on its own. + """ + freezer.move_to("2026-07-29 07:50:20+00:00") # 01:50:20 in America/Regina + + config_entry.add_to_hass(hass) + with patch("homeassistant.components.calendar.SCAN_INTERVAL", timedelta(hours=1)): + assert await async_setup_component(hass, DOMAIN, {}) + await hass.async_block_till_done() + + state = hass.states.get(TEST_ENTITY) + assert state.state == STATE_ON + assert state.attributes["message"] == "First" + + # 02:00:00 in America/Regina, the moment the first event ends and the + # second begins. + freezer.move_to("2026-07-29 08:00:00+00:00") + async_fire_time_changed(hass, dt_util.utcnow()) + await hass.async_block_till_done() + + state = hass.states.get(TEST_ENTITY) + assert state.state == STATE_ON + assert state.attributes["message"] == "Second" diff --git a/tests/components/lock/test_device_action.py b/tests/components/lock/test_device_action.py index 24053bdce46b..eec5a023e9dc 100644 --- a/tests/components/lock/test_device_action.py +++ b/tests/components/lock/test_device_action.py @@ -53,7 +53,7 @@ async def test_get_actions( ) if set_state: hass.states.async_set( - f"{DOMAIN}.test_5678", "attributes", {"supported_features": features_state} + entity_entry.entity_id, "attributes", {"supported_features": features_state} ) expected_actions = [] basic_action_types = ["lock", "unlock"] diff --git a/tests/components/lunatone/__init__.py b/tests/components/lunatone/__init__.py index e88b23f7cefe..98d0d302dde9 100644 --- a/tests/components/lunatone/__init__.py +++ b/tests/components/lunatone/__init__.py @@ -26,7 +26,7 @@ from tests.common import MockConfigEntry BASE_IP: Final = "10.0.0.131" BASE_URL: Final = URL.build(scheme="http", host=BASE_IP).human_repr()[:-1] MANUFACTURER: Final = "Lunatone Industrielle Elektronik GmbH" -PRODUCT_NAME: Final = "Test Product" +PRODUCT_NAME: Final = "DALI-2 Display 7''" SERIAL_NUMBER: Final = 12345 UUID: Final = "be37ca9c-47c2-4498-a38b-c62c7c711840" VERSION: Final = "v1.14.1/1.4.3" @@ -36,7 +36,7 @@ DEVICE_INFO_DATA: Final[DeviceInfoData] = DeviceInfoData( serial=12345, gtin=192837465, pcb="2a", - articleNumber=87654321, + articleNumber=86456840, productionYear=20, productionWeek=1, ) diff --git a/tests/components/lunatone/conftest.py b/tests/components/lunatone/conftest.py index 82813f857cc6..a039a8116a59 100644 --- a/tests/components/lunatone/conftest.py +++ b/tests/components/lunatone/conftest.py @@ -1,17 +1,18 @@ """Fixtures for Lunatone tests.""" from collections.abc import Generator +from copy import deepcopy from unittest.mock import AsyncMock, PropertyMock, patch from lunatone_rest_api_client import Device, Devices, Info, Sensor, Sensors -from lunatone_rest_api_client.models import InfoData, ScanData, ScanState, SensorsData +from lunatone_rest_api_client.models import InfoData, ScanData, SensorsData import pytest from homeassistant.components.lunatone.config_flow import LunatoneConfigFlow from homeassistant.components.lunatone.const import DOMAIN from homeassistant.const import CONF_URL -from . import BASE_URL, INFO_DATA, PRODUCT_NAME, SENSORS_DATA, UUID, build_devices_data +from . import BASE_URL, INFO_DATA, SENSORS_DATA, UUID, build_devices_data from tests.common import MockConfigEntry @@ -104,15 +105,10 @@ def mock_lunatone_info() -> Generator[AsyncMock]: def _set_data(data: InfoData) -> Info: info.data = data - info.name = info.data.name - info.product_name = PRODUCT_NAME - info.serial_number = info.data.device.serial - info.uid = info.data.uid - info.version = info.data.version return info info.set_data = _set_data - info.set_data(INFO_DATA) + info.set_data(deepcopy(INFO_DATA)) yield info @@ -139,8 +135,6 @@ def mock_lunatone_sensors() -> Generator[AsyncMock]: for sensor_data in sensors.data.sensors: sensor = AsyncMock(spec=Sensor) sensor.data = sensor_data - sensor.id = sensor.data.id - sensor.name = sensor.data.name sensor_list.append(sensor) return sensor_list @@ -176,11 +170,6 @@ def mock_lunatone_scan() -> Generator[AsyncMock]: ): scan = mock_dali_scan.return_value scan.data = ScanData() - type(scan).is_busy = PropertyMock( - side_effect=lambda: ( - scan.data.status in {ScanState.ADDRESSING, ScanState.IN_PROGRESS} - ) - ) yield scan diff --git a/tests/components/lunatone/snapshots/test_diagnostics.ambr b/tests/components/lunatone/snapshots/test_diagnostics.ambr index d5f6d6136391..4ffe5e96c820 100644 --- a/tests/components/lunatone/snapshots/test_diagnostics.ambr +++ b/tests/components/lunatone/snapshots/test_diagnostics.ambr @@ -321,7 +321,7 @@ }), 'device': dict({ 'article_info': '', - 'article_number': 87654321, + 'article_number': 86456840, 'gtin': 192837465, 'pcb': '2a', 'production_week': 1, @@ -335,7 +335,7 @@ '0': dict({ 'device': dict({ 'article_info': '', - 'article_number': 87654321, + 'article_number': 86456840, 'gtin': 192837465, 'pcb': '2a', 'production_week': 1, diff --git a/tests/components/lunatone/test_config_flow.py b/tests/components/lunatone/test_config_flow.py index 16fc432f531f..58bb2cb6578b 100644 --- a/tests/components/lunatone/test_config_flow.py +++ b/tests/components/lunatone/test_config_flow.py @@ -72,7 +72,7 @@ async def test_full_flow_fail_because_of_missing_device_infos( hass: HomeAssistant, mock_lunatone_info: AsyncMock ) -> None: """Test full flow.""" - mock_lunatone_info.serial_number = None + mock_lunatone_info.data = None result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_USER} diff --git a/tests/components/lunatone/test_init.py b/tests/components/lunatone/test_init.py index 9c0b5275b3fc..6a9c30c78f22 100644 --- a/tests/components/lunatone/test_init.py +++ b/tests/components/lunatone/test_init.py @@ -1,6 +1,6 @@ """Tests for the Lunatone integration.""" -from unittest.mock import AsyncMock +from unittest.mock import AsyncMock, PropertyMock import aiohttp @@ -10,7 +10,15 @@ from homeassistant.const import CONF_URL from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr, entity_registry as er -from . import BASE_URL, PRODUCT_NAME, SERIAL_NUMBER, UUID, VERSION, setup_integration +from . import ( + BASE_URL, + INFO_DATA, + PRODUCT_NAME, + SERIAL_NUMBER, + UUID, + VERSION, + setup_integration, +) from tests.common import MockConfigEntry @@ -173,6 +181,23 @@ async def test_config_entry_not_ready_no_info_data( assert mock_config_entry.state is ConfigEntryState.SETUP_RETRY +async def test_config_entry_setup_error_no_info_data( + hass: HomeAssistant, + mock_lunatone_info: AsyncMock, + mock_lunatone_devices: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test the Lunatone config entry setup error due to missing info data.""" + type(mock_lunatone_info).data = PropertyMock( + side_effect=[INFO_DATA, INFO_DATA, None] + ) + + await setup_integration(hass, mock_config_entry) + + mock_lunatone_info.async_update.assert_called_once() + assert mock_config_entry.state is ConfigEntryState.SETUP_ERROR + + async def test_config_entry_not_ready_no_devices_data( hass: HomeAssistant, mock_lunatone_info: AsyncMock, @@ -227,21 +252,6 @@ async def test_config_entry_not_ready_no_dali_scan_data( assert mock_config_entry.state is ConfigEntryState.SETUP_RETRY -async def test_config_entry_not_ready_no_serial_number( - hass: HomeAssistant, - mock_lunatone_info: AsyncMock, - mock_lunatone_devices: AsyncMock, - mock_config_entry: MockConfigEntry, -) -> None: - """Test config entry not ready due to missing serial number.""" - mock_lunatone_info.serial_number = None - - await setup_integration(hass, mock_config_entry) - - mock_lunatone_info.async_update.assert_called_once() - assert mock_config_entry.state is ConfigEntryState.SETUP_ERROR - - async def test_config_entry_unique_id_update( hass: HomeAssistant, mock_lunatone_info: AsyncMock, @@ -261,7 +271,7 @@ async def test_config_entry_unique_id_update( ) expected_unique_id = str(SERIAL_NUMBER) - mock_lunatone_info.uid = None + mock_lunatone_info.data.uid = None await setup_integration(hass, config_entry) @@ -278,7 +288,7 @@ async def test_config_entry_unique_id_update( assert entity.unique_id.startswith(expected_unique_id) expected_unique_id = UUID.replace("-", "") - mock_lunatone_info.uid = UUID + mock_lunatone_info.data.uid = UUID await hass.config_entries.async_reload(config_entry.entry_id) await hass.async_block_till_done() diff --git a/tests/components/lutron_caseta/test_logbook.py b/tests/components/lutron_caseta/test_logbook.py index e0b9bcc2d000..e73ecde8294a 100644 --- a/tests/components/lutron_caseta/test_logbook.py +++ b/tests/components/lutron_caseta/test_logbook.py @@ -101,7 +101,7 @@ async def test_humanify_lutron_caseta_button_event_integration_not_loaded( await hass.config_entries.async_unload(config_entry.entry_id) await hass.async_block_till_done() - for device in device_registry.devices.values(): + for device in device_registry.devices: if device.config_entries == {config_entry.entry_id}: dr_device_id = device.id break diff --git a/tests/components/lyngdorf/conftest.py b/tests/components/lyngdorf/conftest.py index fabe3f63134b..0b9fcd0aca4d 100644 --- a/tests/components/lyngdorf/conftest.py +++ b/tests/components/lyngdorf/conftest.py @@ -63,6 +63,7 @@ def mock_receiver() -> Generator[MagicMock]: receiver = MagicMock(spec=Receiver) receiver.name = "Mock Lyngdorf" receiver.connected = True + receiver.model = LyngdorfModel.MP_60 # Diagnostics reports the whole receiver, so every property it reads # needs a value here; an unset one is a mock the response cannot encode. @@ -81,6 +82,9 @@ def mock_receiver() -> Generator[MagicMock]: setattr(receiver, f"trim_{_t}", None) setattr(receiver, f"trim_{_t}_range", NumericRange(-10.0, 10.0, 0.1)) + receiver.volume_range = NumericRange(-99.9, 24.0, 0.1) + receiver.zone_b_volume_range = NumericRange(-99.9, 24.0, 0.1) + receiver.power_on = False receiver.volume = -40.0 receiver.mute_enabled = False @@ -98,6 +102,28 @@ def mock_receiver() -> Generator[MagicMock]: receiver.available_video_inputs = ["hdmi"] receiver.available_stream_types = ["AirPlay", "DLNA"] + receiver.now_playing = None + receiver.has_position = False + receiver.position_ms = None + receiver.position_updated_at = None + receiver.shuffle = None + receiver.repeat = None + receiver.can_shuffle = False + receiver.available_repeat_modes = frozenset() + + receiver.lipsync = 50 + receiver.lipsync_range = NumericRange(0, 500, 1) + receiver.trim_bass = 3.0 + receiver.trim_treble = 0.0 + receiver.trim_centre = 0.0 + receiver.trim_height = 4.0 + receiver.trim_lfe = 3.0 + receiver.trim_surround = 0.0 + receiver.trim_bass_range = NumericRange(-12.0, 12.0, 0.1) + receiver.trim_treble_range = NumericRange(-12.0, 12.0, 0.1) + for _trim in ("centre", "height", "lfe", "surround"): + setattr(receiver, f"trim_{_trim}_range", NumericRange(-10.0, 10.0, 0.1)) + receiver.zone_b_power_on = False receiver.zone_b_volume = -40.0 receiver.zone_b_mute_enabled = False @@ -136,6 +162,12 @@ def notify_receiver_update(receiver: MagicMock) -> None: call.args[0]() +def notify_position_jump(receiver: MagicMock, position_ms: int | None) -> None: + """Fire every position jump callback the entities registered.""" + for call in receiver.register_position_jump_callback.call_args_list: + call.args[0](position_ms) + + @pytest.fixture def platforms() -> list[Platform]: """Platforms to load; override per module to isolate a single platform.""" diff --git a/tests/components/lyngdorf/snapshots/test_diagnostics.ambr b/tests/components/lyngdorf/snapshots/test_diagnostics.ambr index 729cffe09087..3750c32a457f 100644 --- a/tests/components/lyngdorf/snapshots/test_diagnostics.ambr +++ b/tests/components/lyngdorf/snapshots/test_diagnostics.ambr @@ -84,7 +84,7 @@ 'available_voicings': list([ ]), 'connected': True, - 'lipsync': None, + 'lipsync': 50, 'max_volume': 0.0, 'model': 'MP_60', 'mute_enabled': False, @@ -93,12 +93,12 @@ 'sound_mode': None, 'source': None, 'streaming_source': 'AirPlay', - 'trim_bass': None, - 'trim_centre': None, - 'trim_height': None, - 'trim_lfe': None, - 'trim_surround': None, - 'trim_treble': None, + 'trim_bass': 3.0, + 'trim_centre': 0.0, + 'trim_height': 4.0, + 'trim_lfe': 3.0, + 'trim_surround': 0.0, + 'trim_treble': 0.0, 'video_information': '4K HDR', 'video_input': 'hdmi', 'voicing': None, diff --git a/tests/components/lyngdorf/snapshots/test_media_player.ambr b/tests/components/lyngdorf/snapshots/test_media_player.ambr index fd9a7f661b94..7123e2b1db0f 100644 --- a/tests/components/lyngdorf/snapshots/test_media_player.ambr +++ b/tests/components/lyngdorf/snapshots/test_media_player.ambr @@ -105,3 +105,121 @@ 'state': 'off', }) # --- +# name: test_now_playing[media_player.mock_lyngdorf_main_zone-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'media_player', + 'entity_category': None, + 'entity_id': 'media_player.mock_lyngdorf_main_zone', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Main zone', + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Main zone', + 'platform': 'lyngdorf', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': , + 'translation_key': 'main_zone', + 'unique_id': '0050c27c76b2_main_zone', + 'unit_of_measurement': None, + }) +# --- +# name: test_now_playing[media_player.mock_lyngdorf_main_zone-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'receiver', + : '/api/media_player_proxy/media_player.mock_lyngdorf_main_zone?token=mock_token&cache=88df577ff5b5b90f', + : 'Mock Lyngdorf Main zone', + : False, + : 'Songs to Learn & Sing', + : 'Echo & the Bunnymen', + : , + : 346, + : 319, + : datetime.datetime(2026, 8, 17, 13, 0, tzinfo=datetime.timezone.utc), + : 'The Killing Moon', + : , + : False, + : , + : 0.48345439870863605, + }), + 'context': , + 'entity_id': 'media_player.mock_lyngdorf_main_zone', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'playing', + }) +# --- +# name: test_now_playing[media_player.mock_lyngdorf_zone_b-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'media_player', + 'entity_category': None, + 'entity_id': 'media_player.mock_lyngdorf_zone_b', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': None, + 'platform': 'lyngdorf', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': , + 'translation_key': None, + 'unique_id': '0050c27c76b2_zone_b', + 'unit_of_measurement': None, + }) +# --- +# name: test_now_playing[media_player.mock_lyngdorf_zone_b-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'receiver', + : 'Mock Lyngdorf Zone B', + : , + }), + 'context': , + 'entity_id': 'media_player.mock_lyngdorf_zone_b', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- diff --git a/tests/components/lyngdorf/snapshots/test_number.ambr b/tests/components/lyngdorf/snapshots/test_number.ambr new file mode 100644 index 000000000000..cb9740837e01 --- /dev/null +++ b/tests/components/lyngdorf/snapshots/test_number.ambr @@ -0,0 +1,422 @@ +# serializer version: 1 +# name: test_entities[number.mock_lyngdorf_lip_sync-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : 500, + : 0, + : , + : 1, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': , + 'entity_id': 'number.mock_lyngdorf_lip_sync', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Lip sync', + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Lip sync', + 'platform': 'lyngdorf', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'lipsync', + 'unique_id': '0050c27c76b2_lipsync', + 'unit_of_measurement': , + }) +# --- +# name: test_entities[number.mock_lyngdorf_lip_sync-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'duration', + : 'Mock Lyngdorf Lip sync', + : 500, + : 0, + : , + : 1, + : , + }), + 'context': , + 'entity_id': 'number.mock_lyngdorf_lip_sync', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '50', + }) +# --- +# name: test_entities[number.mock_lyngdorf_trim_bass-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : 12.0, + : -12.0, + : , + : 0.1, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': , + 'entity_id': 'number.mock_lyngdorf_trim_bass', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Trim bass', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Trim bass', + 'platform': 'lyngdorf', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'trim_bass', + 'unique_id': '0050c27c76b2_trim_bass', + 'unit_of_measurement': , + }) +# --- +# name: test_entities[number.mock_lyngdorf_trim_bass-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'Mock Lyngdorf Trim bass', + : 12.0, + : -12.0, + : , + : 0.1, + : , + }), + 'context': , + 'entity_id': 'number.mock_lyngdorf_trim_bass', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '3.0', + }) +# --- +# name: test_entities[number.mock_lyngdorf_trim_centre-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : 10.0, + : -10.0, + : , + : 0.1, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': , + 'entity_id': 'number.mock_lyngdorf_trim_centre', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Trim centre', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Trim centre', + 'platform': 'lyngdorf', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'trim_centre', + 'unique_id': '0050c27c76b2_trim_centre', + 'unit_of_measurement': , + }) +# --- +# name: test_entities[number.mock_lyngdorf_trim_centre-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'Mock Lyngdorf Trim centre', + : 10.0, + : -10.0, + : , + : 0.1, + : , + }), + 'context': , + 'entity_id': 'number.mock_lyngdorf_trim_centre', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0', + }) +# --- +# name: test_entities[number.mock_lyngdorf_trim_height-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : 10.0, + : -10.0, + : , + : 0.1, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': , + 'entity_id': 'number.mock_lyngdorf_trim_height', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Trim height', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Trim height', + 'platform': 'lyngdorf', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'trim_height', + 'unique_id': '0050c27c76b2_trim_height', + 'unit_of_measurement': , + }) +# --- +# name: test_entities[number.mock_lyngdorf_trim_height-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'Mock Lyngdorf Trim height', + : 10.0, + : -10.0, + : , + : 0.1, + : , + }), + 'context': , + 'entity_id': 'number.mock_lyngdorf_trim_height', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '4.0', + }) +# --- +# name: test_entities[number.mock_lyngdorf_trim_lfe-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : 10.0, + : -10.0, + : , + : 0.1, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': , + 'entity_id': 'number.mock_lyngdorf_trim_lfe', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Trim LFE', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Trim LFE', + 'platform': 'lyngdorf', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'trim_lfe', + 'unique_id': '0050c27c76b2_trim_lfe', + 'unit_of_measurement': , + }) +# --- +# name: test_entities[number.mock_lyngdorf_trim_lfe-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'Mock Lyngdorf Trim LFE', + : 10.0, + : -10.0, + : , + : 0.1, + : , + }), + 'context': , + 'entity_id': 'number.mock_lyngdorf_trim_lfe', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '3.0', + }) +# --- +# name: test_entities[number.mock_lyngdorf_trim_surround-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : 10.0, + : -10.0, + : , + : 0.1, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': , + 'entity_id': 'number.mock_lyngdorf_trim_surround', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Trim surround', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Trim surround', + 'platform': 'lyngdorf', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'trim_surround', + 'unique_id': '0050c27c76b2_trim_surround', + 'unit_of_measurement': , + }) +# --- +# name: test_entities[number.mock_lyngdorf_trim_surround-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'Mock Lyngdorf Trim surround', + : 10.0, + : -10.0, + : , + : 0.1, + : , + }), + 'context': , + 'entity_id': 'number.mock_lyngdorf_trim_surround', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0', + }) +# --- +# name: test_entities[number.mock_lyngdorf_trim_treble-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : 12.0, + : -12.0, + : , + : 0.1, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': , + 'entity_id': 'number.mock_lyngdorf_trim_treble', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Trim treble', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Trim treble', + 'platform': 'lyngdorf', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'trim_treble', + 'unique_id': '0050c27c76b2_trim_treble', + 'unit_of_measurement': , + }) +# --- +# name: test_entities[number.mock_lyngdorf_trim_treble-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'Mock Lyngdorf Trim treble', + : 12.0, + : -12.0, + : , + : 0.1, + : , + }), + 'context': , + 'entity_id': 'number.mock_lyngdorf_trim_treble', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0', + }) +# --- diff --git a/tests/components/lyngdorf/test_config_flow.py b/tests/components/lyngdorf/test_config_flow.py index ac001aa7d6e6..7dabfb3a8ea4 100644 --- a/tests/components/lyngdorf/test_config_flow.py +++ b/tests/components/lyngdorf/test_config_flow.py @@ -368,3 +368,162 @@ async def test_ssdp_discovery_connectivity_check_aborts( assert result["type"] is FlowResultType.ABORT assert result["reason"] == expected_reason + + +@pytest.mark.usefixtures("mock_find_receiver_model", "mock_get_device_serial") +async def test_reconfigure( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, +) -> None: + """Test reconfiguring an entry updates the host.""" + mock_config_entry.add_to_hass(hass) + + result = await mock_config_entry.start_reconfigure_flow(hass) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "reconfigure" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_HOST: "192.168.1.50"}, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reconfigure_successful" + assert mock_config_entry.data[CONF_HOST] == "192.168.1.50" + + +@pytest.mark.usefixtures("mock_find_receiver_model") +async def test_reconfigure_different_device( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_get_device_serial: AsyncMock, +) -> None: + """Test an entry cannot be pointed at a different device.""" + mock_config_entry.add_to_hass(hass) + mock_get_device_serial.return_value = "aabbccddeeff" + original_host = mock_config_entry.data[CONF_HOST] + + result = await mock_config_entry.start_reconfigure_flow(hass) + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_HOST: "192.168.1.50"}, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "unique_id_mismatch" + assert mock_config_entry.data[CONF_HOST] == original_host + + +@pytest.mark.parametrize( + ("side_effect", "error"), + [ + (TimeoutError, "timeout_connect"), + (OSError, "cannot_connect"), + (Exception, "unknown"), + ], +) +@pytest.mark.usefixtures("mock_get_device_serial") +async def test_reconfigure_errors( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_find_receiver_model: AsyncMock, + side_effect: type[Exception], + error: str, +) -> None: + """Test reconfigure surfaces connection errors and recovers.""" + mock_config_entry.add_to_hass(hass) + mock_find_receiver_model.side_effect = side_effect + + result = await mock_config_entry.start_reconfigure_flow(hass) + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_HOST: "192.168.1.50"}, + ) + + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {"base": error} + + mock_find_receiver_model.side_effect = None + mock_find_receiver_model.return_value = LyngdorfModel.MP_60 + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_HOST: "192.168.1.50"}, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reconfigure_successful" + + +@pytest.mark.parametrize( + ("side_effect", "error"), + [ + (TimeoutError, "timeout_connect"), + (OSError, "cannot_connect"), + ], +) +@pytest.mark.usefixtures("mock_find_receiver_model") +async def test_user_flow_serial_errors( + hass: HomeAssistant, + mock_get_device_serial: AsyncMock, + side_effect: type[Exception], + error: str, +) -> None: + """Test a failure to read the serial is surfaced on the form.""" + mock_get_device_serial.side_effect = side_effect + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {CONF_HOST: "192.168.1.50"} + ) + + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {"base": error} + + mock_get_device_serial.side_effect = None + mock_get_device_serial.return_value = "0050c27c76b2" + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {CONF_HOST: "192.168.1.50"} + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + + +@pytest.mark.parametrize( + ("model", "serial", "error"), + [ + pytest.param(None, "0050c27c76b2", "unsupported_model", id="unsupported"), + pytest.param(LyngdorfModel.MP_60, None, "cannot_determine_id", id="no_serial"), + ], +) +async def test_reconfigure_device_errors( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_find_receiver_model: AsyncMock, + mock_get_device_serial: AsyncMock, + model: LyngdorfModel | None, + serial: str | None, + error: str, +) -> None: + """Test reconfigure surfaces a device it cannot identify.""" + mock_config_entry.add_to_hass(hass) + mock_find_receiver_model.return_value = model + mock_get_device_serial.return_value = serial + + result = await mock_config_entry.start_reconfigure_flow(hass) + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {CONF_HOST: "192.168.1.50"} + ) + + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {"base": error} + + mock_find_receiver_model.return_value = LyngdorfModel.MP_60 + mock_get_device_serial.return_value = "0050c27c76b2" + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {CONF_HOST: "192.168.1.50"} + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reconfigure_successful" diff --git a/tests/components/lyngdorf/test_media_player.py b/tests/components/lyngdorf/test_media_player.py index 658db3733ca6..db6f39e8cf0a 100644 --- a/tests/components/lyngdorf/test_media_player.py +++ b/tests/components/lyngdorf/test_media_player.py @@ -1,25 +1,45 @@ """Tests for the Lyngdorf media player platform.""" +from collections.abc import Generator +from datetime import UTC, datetime +from typing import Any from unittest.mock import MagicMock, patch from lyngdorf.const import LyngdorfModel +from lyngdorf.states import Control, PlaybackState, Repeat +from lyngdorf.streaming import NowPlaying import pytest from syrupy.assertion import SnapshotAssertion from homeassistant.components.media_player import ( ATTR_INPUT_SOURCE, ATTR_INPUT_SOURCE_LIST, + ATTR_MEDIA_POSITION, + ATTR_MEDIA_POSITION_UPDATED_AT, + ATTR_MEDIA_REPEAT, + ATTR_MEDIA_SEEK_POSITION, + ATTR_MEDIA_SHUFFLE, + ATTR_MEDIA_TITLE, ATTR_MEDIA_VOLUME_LEVEL, ATTR_MEDIA_VOLUME_MUTED, ATTR_SOUND_MODE, ATTR_SOUND_MODE_LIST, DOMAIN as MEDIA_PLAYER_DOMAIN, + SERVICE_MEDIA_NEXT_TRACK, + SERVICE_MEDIA_PAUSE, + SERVICE_MEDIA_PREVIOUS_TRACK, + SERVICE_MEDIA_SEEK, SERVICE_SELECT_SOUND_MODE, SERVICE_SELECT_SOURCE, + MediaPlayerEntityFeature, MediaPlayerState, + RepeatMode, ) from homeassistant.const import ( ATTR_ENTITY_ID, + ATTR_SUPPORTED_FEATURES, + SERVICE_REPEAT_SET, + SERVICE_SHUFFLE_SET, SERVICE_TURN_OFF, SERVICE_TURN_ON, SERVICE_VOLUME_DOWN, @@ -32,8 +52,12 @@ from homeassistant.const import ( from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er +from .conftest import notify_position_jump, notify_receiver_update + from tests.common import MockConfigEntry, snapshot_platform +POSITION_UPDATED_AT = datetime(2026, 8, 17, 13, tzinfo=UTC) + MAIN_ZONE = "media_player.mock_lyngdorf_main_zone" ZONE_B = "media_player.mock_lyngdorf_zone_b" @@ -44,6 +68,45 @@ def platforms() -> list[Platform]: return [Platform.MEDIA_PLAYER] +@pytest.fixture(autouse=True) +def media_proxy_token() -> Generator[None]: + """Freeze the media proxy token, which otherwise varies per run.""" + with patch("secrets.token_hex", return_value="mock_token"): + yield + + +@pytest.fixture +def playing_receiver(mock_receiver: MagicMock) -> MagicMock: + """Return a receiver that is streaming a track.""" + mock_receiver.power_on = True + mock_receiver.now_playing = NowPlaying( + state=PlaybackState.PLAYING, + title="The Killing Moon", + artist="Echo & the Bunnymen", + album="Songs to Learn & Sing", + source="Total Solar Eclipse Playlist", + art_url="https://example.test/art.jpg", + duration_ms=346280, + controls=frozenset( + { + Control.PAUSE, + Control.NEXT_TRACK, + Control.PREVIOUS_TRACK, + Control.SEEK, + } + ), + play_modes=frozenset(), + ) + mock_receiver.has_position = True + mock_receiver.position_ms = 318544 + mock_receiver.position_updated_at = POSITION_UPDATED_AT + mock_receiver.shuffle = False + mock_receiver.repeat = Repeat.OFF + mock_receiver.can_shuffle = True + mock_receiver.available_repeat_modes = frozenset({Repeat.OFF, Repeat.ALL}) + return mock_receiver + + async def test_entities( hass: HomeAssistant, init_integration: MockConfigEntry, @@ -131,11 +194,11 @@ async def test_volume_step( @pytest.mark.parametrize( - ("entity_id", "level", "attr", "expected_db"), + ("entity_id", "level", "method", "expected_db"), [ - (MAIN_ZONE, 0.5, "volume", -31.0), - (MAIN_ZONE, 1.0, "volume", 18.0), - (ZONE_B, 0.3, "zone_b_volume", -50.6), + (MAIN_ZONE, 0.5, "set_volume", -37.95), + (MAIN_ZONE, 1.0, "set_volume", 24.0), + (ZONE_B, 0.3, "set_zone_b_volume", -62.73), ], ) async def test_volume_set( @@ -144,7 +207,7 @@ async def test_volume_set( mock_receiver: MagicMock, entity_id: str, level: float, - attr: str, + method: str, expected_db: float, ) -> None: """Test setting and clamping volume on both zones.""" @@ -154,7 +217,7 @@ async def test_volume_set( {ATTR_ENTITY_ID: entity_id, ATTR_MEDIA_VOLUME_LEVEL: level}, blocking=True, ) - assert getattr(mock_receiver, attr) == pytest.approx(expected_db) + getattr(mock_receiver, method).assert_called_once_with(pytest.approx(expected_db)) @pytest.mark.parametrize( @@ -226,23 +289,15 @@ async def test_availability( mock_receiver: MagicMock, ) -> None: """Test availability when device disconnects and reconnects.""" - callbacks = [ - call.args[0] - for call in mock_receiver.register_notification_callback.call_args_list - ] - assert callbacks - mock_receiver.connected = False - for cb in callbacks: - cb() + notify_receiver_update(mock_receiver) await hass.async_block_till_done() assert hass.states.get(MAIN_ZONE).state == STATE_UNAVAILABLE assert hass.states.get(ZONE_B).state == STATE_UNAVAILABLE mock_receiver.connected = True - for cb in callbacks: - cb() + notify_receiver_update(mock_receiver) await hass.async_block_till_done() assert hass.states.get(MAIN_ZONE).state != STATE_UNAVAILABLE @@ -255,11 +310,6 @@ async def test_main_zone_state_properties( mock_receiver: MagicMock, ) -> None: """Test main zone state properties are reported correctly.""" - callbacks = [ - call.args[0] - for call in mock_receiver.register_notification_callback.call_args_list - ] - mock_receiver.power_on = True mock_receiver.volume = -40.0 mock_receiver.mute_enabled = False @@ -267,13 +317,12 @@ async def test_main_zone_state_properties( mock_receiver.sound_mode = "Movie" mock_receiver.available_sources = ["HDMI", "Optical"] mock_receiver.available_sound_modes = ["Movie", "Stereo"] - for cb in callbacks: - cb() + notify_receiver_update(mock_receiver) await hass.async_block_till_done() state = hass.states.get(MAIN_ZONE) assert state.state == MediaPlayerState.ON - assert state.attributes[ATTR_MEDIA_VOLUME_LEVEL] == pytest.approx(0.408, abs=0.01) + assert state.attributes[ATTR_MEDIA_VOLUME_LEVEL] == pytest.approx(0.484, abs=0.01) assert state.attributes[ATTR_MEDIA_VOLUME_MUTED] is False assert state.attributes[ATTR_INPUT_SOURCE] == "HDMI" assert state.attributes[ATTR_SOUND_MODE] == "Movie" @@ -281,15 +330,13 @@ async def test_main_zone_state_properties( assert state.attributes[ATTR_SOUND_MODE_LIST] == ["Movie", "Stereo"] mock_receiver.volume = None - for cb in callbacks: - cb() + notify_receiver_update(mock_receiver) await hass.async_block_till_done() state = hass.states.get(MAIN_ZONE) assert state.attributes.get(ATTR_MEDIA_VOLUME_LEVEL) is None mock_receiver.power_on = False - for cb in callbacks: - cb() + notify_receiver_update(mock_receiver) await hass.async_block_till_done() state = hass.states.get(MAIN_ZONE) assert state.state == MediaPlayerState.OFF @@ -301,30 +348,153 @@ async def test_zone_b_state_properties( mock_receiver: MagicMock, ) -> None: """Test zone B state properties are reported correctly.""" - callbacks = [ - call.args[0] - for call in mock_receiver.register_notification_callback.call_args_list - ] - mock_receiver.zone_b_power_on = True mock_receiver.zone_b_volume = -30.0 mock_receiver.zone_b_mute_enabled = True mock_receiver.zone_b_source = "Optical" mock_receiver.zone_b_available_sources = ["HDMI", "Optical"] - for cb in callbacks: - cb() + notify_receiver_update(mock_receiver) await hass.async_block_till_done() state = hass.states.get(ZONE_B) assert state.state == MediaPlayerState.ON - assert state.attributes[ATTR_MEDIA_VOLUME_LEVEL] == pytest.approx(0.510, abs=0.01) + assert state.attributes[ATTR_MEDIA_VOLUME_LEVEL] == pytest.approx(0.564, abs=0.01) assert state.attributes[ATTR_MEDIA_VOLUME_MUTED] is True assert state.attributes[ATTR_INPUT_SOURCE] == "Optical" assert state.attributes[ATTR_INPUT_SOURCE_LIST] == ["HDMI", "Optical"] - mock_receiver.zone_b_volume = "invalid" - for cb in callbacks: - cb() + +async def test_now_playing( + hass: HomeAssistant, + init_integration: MockConfigEntry, + playing_receiver: MagicMock, + snapshot: SnapshotAssertion, + entity_registry: er.EntityRegistry, +) -> None: + """Test now-playing metadata, position and transport features while playing.""" + notify_receiver_update(playing_receiver) await hass.async_block_till_done() - state = hass.states.get(ZONE_B) - assert state.attributes.get(ATTR_MEDIA_VOLUME_LEVEL) is None + + await snapshot_platform(hass, entity_registry, snapshot, init_integration.entry_id) + + +@pytest.mark.usefixtures("init_integration") +@pytest.mark.usefixtures("mock_receiver") +async def test_transport_features_absent_when_idle( + hass: HomeAssistant, +) -> None: + """Test no transport is offered when nothing is playing.""" + features = hass.states.get(MAIN_ZONE).attributes[ATTR_SUPPORTED_FEATURES] + assert not features & MediaPlayerEntityFeature.PAUSE + assert not features & MediaPlayerEntityFeature.SEEK + + +@pytest.mark.parametrize( + ("service", "method"), + [ + pytest.param(SERVICE_MEDIA_PAUSE, "async_pause", id="pause"), + pytest.param(SERVICE_MEDIA_NEXT_TRACK, "async_next", id="next"), + pytest.param(SERVICE_MEDIA_PREVIOUS_TRACK, "async_previous", id="previous"), + ], +) +@pytest.mark.usefixtures("init_integration") +async def test_transport_actions( + hass: HomeAssistant, + playing_receiver: MagicMock, + service: str, + method: str, +) -> None: + """Test transport actions reach the receiver.""" + await hass.services.async_call( + MEDIA_PLAYER_DOMAIN, + service, + {ATTR_ENTITY_ID: MAIN_ZONE}, + blocking=True, + ) + getattr(playing_receiver, method).assert_awaited_once() + + +@pytest.mark.usefixtures("init_integration") +async def test_seek_converts_to_milliseconds( + hass: HomeAssistant, + playing_receiver: MagicMock, +) -> None: + """Test seek converts the position Home Assistant gives in seconds.""" + await hass.services.async_call( + MEDIA_PLAYER_DOMAIN, + SERVICE_MEDIA_SEEK, + {ATTR_ENTITY_ID: MAIN_ZONE, ATTR_MEDIA_SEEK_POSITION: 42.5}, + blocking=True, + ) + playing_receiver.async_seek.assert_awaited_once_with(42500) + + +@pytest.mark.usefixtures("init_integration") +@pytest.mark.parametrize( + ("service", "payload", "method", "expected"), + [ + pytest.param( + SERVICE_SHUFFLE_SET, + {ATTR_MEDIA_SHUFFLE: True}, + "async_set_shuffle", + True, + id="shuffle", + ), + pytest.param( + SERVICE_REPEAT_SET, + {ATTR_MEDIA_REPEAT: RepeatMode.ALL}, + "async_set_repeat", + Repeat.ALL, + id="repeat", + ), + ], +) +@pytest.mark.usefixtures("init_integration") +async def test_set_play_mode( + hass: HomeAssistant, + playing_receiver: MagicMock, + service: str, + payload: dict[str, Any], + method: str, + expected: bool | Repeat, +) -> None: + """Test shuffle and repeat are set on their own axes.""" + await hass.services.async_call( + MEDIA_PLAYER_DOMAIN, + service, + {ATTR_ENTITY_ID: MAIN_ZONE} | payload, + blocking=True, + ) + getattr(playing_receiver, method).assert_awaited_once_with(expected) + + +@pytest.mark.usefixtures("init_integration") +async def test_no_streaming_features_on_model_without_streamer( + hass: HomeAssistant, + playing_receiver: MagicMock, +) -> None: + """Test a model with no streaming module offers no transport.""" + playing_receiver.model = LyngdorfModel.TDAI_2170 + notify_receiver_update(playing_receiver) + await hass.async_block_till_done() + + state = hass.states.get(MAIN_ZONE) + assert ( + not state.attributes[ATTR_SUPPORTED_FEATURES] & MediaPlayerEntityFeature.PAUSE + ) + assert state.attributes.get(ATTR_MEDIA_TITLE) is None + + +@pytest.mark.usefixtures("init_integration") +async def test_position_jump_updates_state( + hass: HomeAssistant, + playing_receiver: MagicMock, +) -> None: + """Test a position discontinuity refreshes the reported position.""" + playing_receiver.position_ms = 1000 + notify_position_jump(playing_receiver, 1000) + await hass.async_block_till_done() + + state = hass.states.get(MAIN_ZONE) + assert state.attributes[ATTR_MEDIA_POSITION] == 1 + assert state.attributes[ATTR_MEDIA_POSITION_UPDATED_AT] == POSITION_UPDATED_AT diff --git a/tests/components/lyngdorf/test_number.py b/tests/components/lyngdorf/test_number.py new file mode 100644 index 000000000000..303c3f6a1f98 --- /dev/null +++ b/tests/components/lyngdorf/test_number.py @@ -0,0 +1,175 @@ +"""Tests for the Lyngdorf number platform.""" + +from unittest.mock import MagicMock, patch + +from lyngdorf.const import LyngdorfModel +import pytest +from syrupy.assertion import SnapshotAssertion + +from homeassistant.components.number import ( + ATTR_VALUE, + DOMAIN as NUMBER_DOMAIN, + SERVICE_SET_VALUE, +) +from homeassistant.const import ATTR_ENTITY_ID, STATE_UNKNOWN, Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from .conftest import notify_receiver_update + +from tests.common import MockConfigEntry, snapshot_platform + + +@pytest.fixture +def platforms() -> list[Platform]: + """Only load the number platform.""" + return [Platform.NUMBER] + + +LIPSYNC_ENTITY_ID = "number.mock_lyngdorf_lip_sync" +TRIM_BASS_ENTITY_ID = "number.mock_lyngdorf_trim_bass" +TRIM_TREBLE_ENTITY_ID = "number.mock_lyngdorf_trim_treble" +TRIM_CENTRE_ENTITY_ID = "number.mock_lyngdorf_trim_centre" +TRIM_HEIGHT_ENTITY_ID = "number.mock_lyngdorf_trim_height" +TRIM_LFE_ENTITY_ID = "number.mock_lyngdorf_trim_lfe" +TRIM_SURROUND_ENTITY_ID = "number.mock_lyngdorf_trim_surround" + + +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +async def test_entities( + hass: HomeAssistant, + init_integration: MockConfigEntry, + snapshot: SnapshotAssertion, + entity_registry: er.EntityRegistry, +) -> None: + """Test the number entities.""" + await snapshot_platform(hass, entity_registry, snapshot, init_integration.entry_id) + + +async def test_set_lipsync( + hass: HomeAssistant, + init_integration: MockConfigEntry, + mock_receiver: MagicMock, +) -> None: + """Test setting the lipsync value.""" + mock_receiver.lipsync = 0 + + notify_receiver_update(mock_receiver) + await hass.async_block_till_done() + + await hass.services.async_call( + NUMBER_DOMAIN, + SERVICE_SET_VALUE, + { + ATTR_ENTITY_ID: LIPSYNC_ENTITY_ID, + ATTR_VALUE: 75, + }, + blocking=True, + ) + + mock_receiver.set_lipsync.assert_called_once_with(75) + + +@pytest.mark.parametrize( + ("entity_id", "attribute", "method"), + [ + pytest.param(TRIM_BASS_ENTITY_ID, "trim_bass", "set_trim_bass", id="bass"), + pytest.param( + TRIM_TREBLE_ENTITY_ID, "trim_treble", "set_trim_treble", id="treble" + ), + pytest.param( + TRIM_CENTRE_ENTITY_ID, "trim_centre", "set_trim_centre", id="centre" + ), + pytest.param( + TRIM_HEIGHT_ENTITY_ID, "trim_height", "set_trim_height", id="height" + ), + pytest.param(TRIM_LFE_ENTITY_ID, "trim_lfe", "set_trim_lfe", id="lfe"), + pytest.param( + TRIM_SURROUND_ENTITY_ID, "trim_surround", "set_trim_surround", id="surround" + ), + ], +) +@pytest.mark.usefixtures("entity_registry_enabled_by_default", "init_integration") +async def test_set_trim( + hass: HomeAssistant, + mock_receiver: MagicMock, + entity_id: str, + attribute: str, + method: str, +) -> None: + """Test setting each trim value.""" + setattr(mock_receiver, attribute, 0.0) + + notify_receiver_update(mock_receiver) + await hass.async_block_till_done() + + await hass.services.async_call( + NUMBER_DOMAIN, + SERVICE_SET_VALUE, + { + ATTR_ENTITY_ID: entity_id, + ATTR_VALUE: -6.0, + }, + blocking=True, + ) + + getattr(mock_receiver, method).assert_called_once_with(-6.0) + + +async def test_number_none_values( + hass: HomeAssistant, + init_integration: MockConfigEntry, + mock_receiver: MagicMock, +) -> None: + """Test a number shows unknown when the device reports nothing.""" + mock_receiver.lipsync = None + mock_receiver.trim_bass = None + notify_receiver_update(mock_receiver) + await hass.async_block_till_done() + + assert hass.states.get(LIPSYNC_ENTITY_ID).state == STATE_UNKNOWN + assert hass.states.get(TRIM_BASS_ENTITY_ID).state == STATE_UNKNOWN + + +@pytest.mark.usefixtures("entity_registry_enabled_by_default", "mock_receiver") +async def test_entities_absent_for_controls_the_model_lacks( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_receiver: MagicMock, +) -> None: + """Test no entity is created where the model has no such control.""" + mock_receiver.lipsync_range = None + mock_receiver.trim_surround_range = None + mock_config_entry.add_to_hass(hass) + + with ( + patch( + "homeassistant.components.lyngdorf.lookup_receiver_model", + return_value=LyngdorfModel.MP_60, + ), + patch("homeassistant.components.lyngdorf.PLATFORMS", [Platform.NUMBER]), + ): + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + assert hass.states.get(LIPSYNC_ENTITY_ID) is None + assert hass.states.get(TRIM_SURROUND_ENTITY_ID) is None + assert hass.states.get(TRIM_BASS_ENTITY_ID) is not None + + +@pytest.mark.usefixtures("init_integration") +async def test_channel_trims_disabled_by_default( + entity_registry: er.EntityRegistry, +) -> None: + """Test only the commonly used trims are enabled by default.""" + for entity_id in (LIPSYNC_ENTITY_ID, TRIM_BASS_ENTITY_ID, TRIM_TREBLE_ENTITY_ID): + assert entity_registry.async_get(entity_id).disabled_by is None + + for entity_id in ( + TRIM_CENTRE_ENTITY_ID, + TRIM_HEIGHT_ENTITY_ID, + TRIM_LFE_ENTITY_ID, + TRIM_SURROUND_ENTITY_ID, + ): + entry = entity_registry.async_get(entity_id) + assert entry.disabled_by is er.RegistryEntryDisabler.INTEGRATION diff --git a/tests/components/media_player/test_device_trigger.py b/tests/components/media_player/test_device_trigger.py index 7618d0a474b1..7488dad82a62 100644 --- a/tests/components/media_player/test_device_trigger.py +++ b/tests/components/media_player/test_device_trigger.py @@ -265,8 +265,8 @@ async def test_if_fires_on_state_change( await hass.async_block_till_done() assert len(service_calls) == 2 assert {service_calls[0].data["some"], service_calls[1].data["some"]} == { - "turned_on - device - media_player.test_5678 - off - on - None", - "changed_states - device - media_player.test_5678 - off - on - None", + f"turned_on - device - {entry.entity_id} - off - on - None", + f"changed_states - device - {entry.entity_id} - off - on - None", } # Fake that the entity is turning off. @@ -274,8 +274,8 @@ async def test_if_fires_on_state_change( await hass.async_block_till_done() assert len(service_calls) == 4 assert {service_calls[2].data["some"], service_calls[3].data["some"]} == { - "turned_off - device - media_player.test_5678 - on - off - None", - "changed_states - device - media_player.test_5678 - on - off - None", + f"turned_off - device - {entry.entity_id} - on - off - None", + f"changed_states - device - {entry.entity_id} - on - off - None", } # Fake that the entity becomes idle. @@ -283,8 +283,8 @@ async def test_if_fires_on_state_change( await hass.async_block_till_done() assert len(service_calls) == 6 assert {service_calls[4].data["some"], service_calls[5].data["some"]} == { - "idle - device - media_player.test_5678 - off - idle - None", - "changed_states - device - media_player.test_5678 - off - idle - None", + f"idle - device - {entry.entity_id} - off - idle - None", + f"changed_states - device - {entry.entity_id} - off - idle - None", } # Fake that the entity starts playing. @@ -292,8 +292,8 @@ async def test_if_fires_on_state_change( await hass.async_block_till_done() assert len(service_calls) == 8 assert {service_calls[6].data["some"], service_calls[7].data["some"]} == { - "playing - device - media_player.test_5678 - idle - playing - None", - "changed_states - device - media_player.test_5678 - idle - playing - None", + f"playing - device - {entry.entity_id} - idle - playing - None", + f"changed_states - device - {entry.entity_id} - idle - playing - None", } # Fake that the entity is paused. @@ -301,8 +301,8 @@ async def test_if_fires_on_state_change( await hass.async_block_till_done() assert len(service_calls) == 10 assert {service_calls[8].data["some"], service_calls[9].data["some"]} == { - "paused - device - media_player.test_5678 - playing - paused - None", - "changed_states - device - media_player.test_5678 - playing - paused - None", + f"paused - device - {entry.entity_id} - playing - paused - None", + f"changed_states - device - {entry.entity_id} - playing - paused - None", } # Fake that the entity is buffering. @@ -310,8 +310,8 @@ async def test_if_fires_on_state_change( await hass.async_block_till_done() assert len(service_calls) == 12 assert {service_calls[10].data["some"], service_calls[11].data["some"]} == { - "buffering - device - media_player.test_5678 - paused - buffering - None", - "changed_states - device - media_player.test_5678 - paused - buffering - None", + f"buffering - device - {entry.entity_id} - paused - buffering - None", + f"changed_states - device - {entry.entity_id} - paused - buffering - None", } @@ -370,7 +370,7 @@ async def test_if_fires_on_state_change_legacy( assert len(service_calls) == 1 assert ( service_calls[0].data["some"] - == "turned_on - device - media_player.test_5678 - off - on - None" + == f"turned_on - device - {entry.entity_id} - off - on - None" ) diff --git a/tests/components/miele/snapshots/test_sensor.ambr b/tests/components/miele/snapshots/test_sensor.ambr index 960b9af0121d..a80757fa90e6 100644 --- a/tests/components/miele/snapshots/test_sensor.ambr +++ b/tests/components/miele/snapshots/test_sensor.ambr @@ -520,7 +520,7 @@ 'state': 'own_program', }) # --- -# name: test_coffee_system_sensor_states[platforms0-coffee_system.json][sensor.powerdisk_level-entry] +# name: test_coffee_system_sensor_states[platforms0-coffee_system.json][sensor.miele_test_powerdisk_level-entry] EntityRegistryEntrySnapshot({ 'aliases': list([ None, @@ -534,7 +534,7 @@ 'disabled_by': None, 'domain': 'sensor', 'entity_category': , - 'entity_id': 'sensor.powerdisk_level', + 'entity_id': 'sensor.miele_test_powerdisk_level', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -557,21 +557,21 @@ 'unit_of_measurement': '%', }) # --- -# name: test_coffee_system_sensor_states[platforms0-coffee_system.json][sensor.powerdisk_level-state] +# name: test_coffee_system_sensor_states[platforms0-coffee_system.json][sensor.miele_test_powerdisk_level-state] StateSnapshot({ 'attributes': ReadOnlyDict({ - : 'PowerDisk level', + : 'Miele test PowerDisk level', : '%', }), 'context': , - 'entity_id': 'sensor.powerdisk_level', + 'entity_id': 'sensor.miele_test_powerdisk_level', 'last_changed': , 'last_reported': , 'last_updated': , 'state': '80.5', }) # --- -# name: test_coffee_system_sensor_states[platforms0-coffee_system.json][sensor.rinse_aid_level-entry] +# name: test_coffee_system_sensor_states[platforms0-coffee_system.json][sensor.miele_test_rinse_aid_level-entry] EntityRegistryEntrySnapshot({ 'aliases': list([ None, @@ -585,7 +585,7 @@ 'disabled_by': None, 'domain': 'sensor', 'entity_category': , - 'entity_id': 'sensor.rinse_aid_level', + 'entity_id': 'sensor.miele_test_rinse_aid_level', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -608,21 +608,21 @@ 'unit_of_measurement': '%', }) # --- -# name: test_coffee_system_sensor_states[platforms0-coffee_system.json][sensor.rinse_aid_level-state] +# name: test_coffee_system_sensor_states[platforms0-coffee_system.json][sensor.miele_test_rinse_aid_level-state] StateSnapshot({ 'attributes': ReadOnlyDict({ - : 'Rinse aid level', + : 'Miele test Rinse aid level', : '%', }), 'context': , - 'entity_id': 'sensor.rinse_aid_level', + 'entity_id': 'sensor.miele_test_rinse_aid_level', 'last_changed': , 'last_reported': , 'last_updated': , 'state': '25', }) # --- -# name: test_coffee_system_sensor_states[platforms0-coffee_system.json][sensor.salt_level-entry] +# name: test_coffee_system_sensor_states[platforms0-coffee_system.json][sensor.miele_test_salt_level-entry] EntityRegistryEntrySnapshot({ 'aliases': list([ None, @@ -636,7 +636,7 @@ 'disabled_by': None, 'domain': 'sensor', 'entity_category': , - 'entity_id': 'sensor.salt_level', + 'entity_id': 'sensor.miele_test_salt_level', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -659,21 +659,21 @@ 'unit_of_measurement': '%', }) # --- -# name: test_coffee_system_sensor_states[platforms0-coffee_system.json][sensor.salt_level-state] +# name: test_coffee_system_sensor_states[platforms0-coffee_system.json][sensor.miele_test_salt_level-state] StateSnapshot({ 'attributes': ReadOnlyDict({ - : 'Salt level', + : 'Miele test Salt level', : '%', }), 'context': , - 'entity_id': 'sensor.salt_level', + 'entity_id': 'sensor.miele_test_salt_level', 'last_changed': , 'last_reported': , 'last_updated': , 'state': '75', }) # --- -# name: test_coffee_system_sensor_states[platforms0-coffee_system.json][sensor.twindos_1_level-entry] +# name: test_coffee_system_sensor_states[platforms0-coffee_system.json][sensor.miele_test_twindos_1_level-entry] EntityRegistryEntrySnapshot({ 'aliases': list([ None, @@ -687,7 +687,7 @@ 'disabled_by': None, 'domain': 'sensor', 'entity_category': , - 'entity_id': 'sensor.twindos_1_level', + 'entity_id': 'sensor.miele_test_twindos_1_level', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -710,21 +710,21 @@ 'unit_of_measurement': '%', }) # --- -# name: test_coffee_system_sensor_states[platforms0-coffee_system.json][sensor.twindos_1_level-state] +# name: test_coffee_system_sensor_states[platforms0-coffee_system.json][sensor.miele_test_twindos_1_level-state] StateSnapshot({ 'attributes': ReadOnlyDict({ - : 'TwinDos 1 level', + : 'Miele test TwinDos 1 level', : '%', }), 'context': , - 'entity_id': 'sensor.twindos_1_level', + 'entity_id': 'sensor.miele_test_twindos_1_level', 'last_changed': , 'last_reported': , 'last_updated': , 'state': '63', }) # --- -# name: test_coffee_system_sensor_states[platforms0-coffee_system.json][sensor.twindos_1_level_2-entry] +# name: test_coffee_system_sensor_states[platforms0-coffee_system.json][sensor.miele_test_twindos_1_level_2-entry] EntityRegistryEntrySnapshot({ 'aliases': list([ None, @@ -738,7 +738,7 @@ 'disabled_by': None, 'domain': 'sensor', 'entity_category': , - 'entity_id': 'sensor.twindos_1_level_2', + 'entity_id': 'sensor.miele_test_twindos_1_level_2', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -761,21 +761,21 @@ 'unit_of_measurement': '%', }) # --- -# name: test_coffee_system_sensor_states[platforms0-coffee_system.json][sensor.twindos_1_level_2-state] +# name: test_coffee_system_sensor_states[platforms0-coffee_system.json][sensor.miele_test_twindos_1_level_2-state] StateSnapshot({ 'attributes': ReadOnlyDict({ - : 'TwinDos 1 level', + : 'Miele test TwinDos 1 level', : '%', }), 'context': , - 'entity_id': 'sensor.twindos_1_level_2', + 'entity_id': 'sensor.miele_test_twindos_1_level_2', 'last_changed': , 'last_reported': , 'last_updated': , 'state': '63', }) # --- -# name: test_coffee_system_sensor_states[platforms0-coffee_system.json][sensor.twindos_2_level-entry] +# name: test_coffee_system_sensor_states[platforms0-coffee_system.json][sensor.miele_test_twindos_2_level-entry] EntityRegistryEntrySnapshot({ 'aliases': list([ None, @@ -789,7 +789,7 @@ 'disabled_by': None, 'domain': 'sensor', 'entity_category': , - 'entity_id': 'sensor.twindos_2_level', + 'entity_id': 'sensor.miele_test_twindos_2_level', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -812,21 +812,21 @@ 'unit_of_measurement': '%', }) # --- -# name: test_coffee_system_sensor_states[platforms0-coffee_system.json][sensor.twindos_2_level-state] +# name: test_coffee_system_sensor_states[platforms0-coffee_system.json][sensor.miele_test_twindos_2_level-state] StateSnapshot({ 'attributes': ReadOnlyDict({ - : 'TwinDos 2 level', + : 'Miele test TwinDos 2 level', : '%', }), 'context': , - 'entity_id': 'sensor.twindos_2_level', + 'entity_id': 'sensor.miele_test_twindos_2_level', 'last_changed': , 'last_reported': , 'last_updated': , 'state': '20', }) # --- -# name: test_coffee_system_sensor_states[platforms0-coffee_system.json][sensor.twindos_2_level_2-entry] +# name: test_coffee_system_sensor_states[platforms0-coffee_system.json][sensor.miele_test_twindos_2_level_2-entry] EntityRegistryEntrySnapshot({ 'aliases': list([ None, @@ -840,7 +840,7 @@ 'disabled_by': None, 'domain': 'sensor', 'entity_category': , - 'entity_id': 'sensor.twindos_2_level_2', + 'entity_id': 'sensor.miele_test_twindos_2_level_2', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -863,21 +863,21 @@ 'unit_of_measurement': '%', }) # --- -# name: test_coffee_system_sensor_states[platforms0-coffee_system.json][sensor.twindos_2_level_2-state] +# name: test_coffee_system_sensor_states[platforms0-coffee_system.json][sensor.miele_test_twindos_2_level_2-state] StateSnapshot({ 'attributes': ReadOnlyDict({ - : 'TwinDos 2 level', + : 'Miele test TwinDos 2 level', : '%', }), 'context': , - 'entity_id': 'sensor.twindos_2_level_2', + 'entity_id': 'sensor.miele_test_twindos_2_level_2', 'last_changed': , 'last_reported': , 'last_updated': , 'state': '20', }) # --- -# name: test_fan_hob_sensor_states[platforms0-fan_devices.json][sensor.degreasing_cycles-entry] +# name: test_fan_hob_sensor_states[platforms0-fan_devices.json][sensor.miele_test_degreasing_cycles-entry] EntityRegistryEntrySnapshot({ 'aliases': list([ None, @@ -893,7 +893,7 @@ 'disabled_by': None, 'domain': 'sensor', 'entity_category': , - 'entity_id': 'sensor.degreasing_cycles', + 'entity_id': 'sensor.miele_test_degreasing_cycles', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -916,21 +916,21 @@ 'unit_of_measurement': None, }) # --- -# name: test_fan_hob_sensor_states[platforms0-fan_devices.json][sensor.degreasing_cycles-state] +# name: test_fan_hob_sensor_states[platforms0-fan_devices.json][sensor.miele_test_degreasing_cycles-state] StateSnapshot({ 'attributes': ReadOnlyDict({ - : 'Degreasing cycles', + : 'Miele test Degreasing cycles', : , }), 'context': , - 'entity_id': 'sensor.degreasing_cycles', + 'entity_id': 'sensor.miele_test_degreasing_cycles', 'last_changed': , 'last_reported': , 'last_updated': , 'state': '2', }) # --- -# name: test_fan_hob_sensor_states[platforms0-fan_devices.json][sensor.descaling_cycles-entry] +# name: test_fan_hob_sensor_states[platforms0-fan_devices.json][sensor.miele_test_descaling_cycles-entry] EntityRegistryEntrySnapshot({ 'aliases': list([ None, @@ -946,7 +946,7 @@ 'disabled_by': None, 'domain': 'sensor', 'entity_category': , - 'entity_id': 'sensor.descaling_cycles', + 'entity_id': 'sensor.miele_test_descaling_cycles', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -969,14 +969,14 @@ 'unit_of_measurement': None, }) # --- -# name: test_fan_hob_sensor_states[platforms0-fan_devices.json][sensor.descaling_cycles-state] +# name: test_fan_hob_sensor_states[platforms0-fan_devices.json][sensor.miele_test_descaling_cycles-state] StateSnapshot({ 'attributes': ReadOnlyDict({ - : 'Descaling cycles', + : 'Miele test Descaling cycles', : , }), 'context': , - 'entity_id': 'sensor.descaling_cycles', + 'entity_id': 'sensor.miele_test_descaling_cycles', 'last_changed': , 'last_reported': , 'last_updated': , @@ -2168,7 +2168,7 @@ 'state': 'off', }) # --- -# name: test_fan_hob_sensor_states[platforms0-fan_devices.json][sensor.milk_pipework_cleaning_cycles-entry] +# name: test_fan_hob_sensor_states[platforms0-fan_devices.json][sensor.miele_test_milk_pipework_cleaning_cycles-entry] EntityRegistryEntrySnapshot({ 'aliases': list([ None, @@ -2184,7 +2184,7 @@ 'disabled_by': None, 'domain': 'sensor', 'entity_category': , - 'entity_id': 'sensor.milk_pipework_cleaning_cycles', + 'entity_id': 'sensor.miele_test_milk_pipework_cleaning_cycles', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -2207,21 +2207,21 @@ 'unit_of_measurement': None, }) # --- -# name: test_fan_hob_sensor_states[platforms0-fan_devices.json][sensor.milk_pipework_cleaning_cycles-state] +# name: test_fan_hob_sensor_states[platforms0-fan_devices.json][sensor.miele_test_milk_pipework_cleaning_cycles-state] StateSnapshot({ 'attributes': ReadOnlyDict({ - : 'Milk pipework cleaning cycles', + : 'Miele test Milk pipework cleaning cycles', : , }), 'context': , - 'entity_id': 'sensor.milk_pipework_cleaning_cycles', + 'entity_id': 'sensor.miele_test_milk_pipework_cleaning_cycles', 'last_changed': , 'last_reported': , 'last_updated': , 'state': '3', }) # --- -# name: test_fan_hob_sensor_states[platforms0-fan_devices.json][sensor.powerdisk_level-entry] +# name: test_fan_hob_sensor_states[platforms0-fan_devices.json][sensor.miele_test_powerdisk_level-entry] EntityRegistryEntrySnapshot({ 'aliases': list([ None, @@ -2235,7 +2235,7 @@ 'disabled_by': None, 'domain': 'sensor', 'entity_category': , - 'entity_id': 'sensor.powerdisk_level', + 'entity_id': 'sensor.miele_test_powerdisk_level', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -2258,21 +2258,21 @@ 'unit_of_measurement': '%', }) # --- -# name: test_fan_hob_sensor_states[platforms0-fan_devices.json][sensor.powerdisk_level-state] +# name: test_fan_hob_sensor_states[platforms0-fan_devices.json][sensor.miele_test_powerdisk_level-state] StateSnapshot({ 'attributes': ReadOnlyDict({ - : 'PowerDisk level', + : 'Miele test PowerDisk level', : '%', }), 'context': , - 'entity_id': 'sensor.powerdisk_level', + 'entity_id': 'sensor.miele_test_powerdisk_level', 'last_changed': , 'last_reported': , 'last_updated': , 'state': '80.5', }) # --- -# name: test_fan_hob_sensor_states[platforms0-fan_devices.json][sensor.rinse_aid_level-entry] +# name: test_fan_hob_sensor_states[platforms0-fan_devices.json][sensor.miele_test_rinse_aid_level-entry] EntityRegistryEntrySnapshot({ 'aliases': list([ None, @@ -2286,7 +2286,7 @@ 'disabled_by': None, 'domain': 'sensor', 'entity_category': , - 'entity_id': 'sensor.rinse_aid_level', + 'entity_id': 'sensor.miele_test_rinse_aid_level', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -2309,21 +2309,21 @@ 'unit_of_measurement': '%', }) # --- -# name: test_fan_hob_sensor_states[platforms0-fan_devices.json][sensor.rinse_aid_level-state] +# name: test_fan_hob_sensor_states[platforms0-fan_devices.json][sensor.miele_test_rinse_aid_level-state] StateSnapshot({ 'attributes': ReadOnlyDict({ - : 'Rinse aid level', + : 'Miele test Rinse aid level', : '%', }), 'context': , - 'entity_id': 'sensor.rinse_aid_level', + 'entity_id': 'sensor.miele_test_rinse_aid_level', 'last_changed': , 'last_reported': , 'last_updated': , 'state': '25', }) # --- -# name: test_fan_hob_sensor_states[platforms0-fan_devices.json][sensor.salt_level-entry] +# name: test_fan_hob_sensor_states[platforms0-fan_devices.json][sensor.miele_test_salt_level-entry] EntityRegistryEntrySnapshot({ 'aliases': list([ None, @@ -2337,7 +2337,7 @@ 'disabled_by': None, 'domain': 'sensor', 'entity_category': , - 'entity_id': 'sensor.salt_level', + 'entity_id': 'sensor.miele_test_salt_level', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -2360,21 +2360,21 @@ 'unit_of_measurement': '%', }) # --- -# name: test_fan_hob_sensor_states[platforms0-fan_devices.json][sensor.salt_level-state] +# name: test_fan_hob_sensor_states[platforms0-fan_devices.json][sensor.miele_test_salt_level-state] StateSnapshot({ 'attributes': ReadOnlyDict({ - : 'Salt level', + : 'Miele test Salt level', : '%', }), 'context': , - 'entity_id': 'sensor.salt_level', + 'entity_id': 'sensor.miele_test_salt_level', 'last_changed': , 'last_reported': , 'last_updated': , 'state': '75', }) # --- -# name: test_fan_hob_sensor_states[platforms0-fan_devices.json][sensor.twindos_1_level-entry] +# name: test_fan_hob_sensor_states[platforms0-fan_devices.json][sensor.miele_test_twindos_1_level-entry] EntityRegistryEntrySnapshot({ 'aliases': list([ None, @@ -2388,7 +2388,7 @@ 'disabled_by': None, 'domain': 'sensor', 'entity_category': , - 'entity_id': 'sensor.twindos_1_level', + 'entity_id': 'sensor.miele_test_twindos_1_level', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -2411,21 +2411,21 @@ 'unit_of_measurement': '%', }) # --- -# name: test_fan_hob_sensor_states[platforms0-fan_devices.json][sensor.twindos_1_level-state] +# name: test_fan_hob_sensor_states[platforms0-fan_devices.json][sensor.miele_test_twindos_1_level-state] StateSnapshot({ 'attributes': ReadOnlyDict({ - : 'TwinDos 1 level', + : 'Miele test TwinDos 1 level', : '%', }), 'context': , - 'entity_id': 'sensor.twindos_1_level', + 'entity_id': 'sensor.miele_test_twindos_1_level', 'last_changed': , 'last_reported': , 'last_updated': , 'state': '63', }) # --- -# name: test_fan_hob_sensor_states[platforms0-fan_devices.json][sensor.twindos_1_level_2-entry] +# name: test_fan_hob_sensor_states[platforms0-fan_devices.json][sensor.miele_test_twindos_1_level_2-entry] EntityRegistryEntrySnapshot({ 'aliases': list([ None, @@ -2439,7 +2439,7 @@ 'disabled_by': None, 'domain': 'sensor', 'entity_category': , - 'entity_id': 'sensor.twindos_1_level_2', + 'entity_id': 'sensor.miele_test_twindos_1_level_2', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -2462,21 +2462,21 @@ 'unit_of_measurement': '%', }) # --- -# name: test_fan_hob_sensor_states[platforms0-fan_devices.json][sensor.twindos_1_level_2-state] +# name: test_fan_hob_sensor_states[platforms0-fan_devices.json][sensor.miele_test_twindos_1_level_2-state] StateSnapshot({ 'attributes': ReadOnlyDict({ - : 'TwinDos 1 level', + : 'Miele test TwinDos 1 level', : '%', }), 'context': , - 'entity_id': 'sensor.twindos_1_level_2', + 'entity_id': 'sensor.miele_test_twindos_1_level_2', 'last_changed': , 'last_reported': , 'last_updated': , 'state': '63', }) # --- -# name: test_fan_hob_sensor_states[platforms0-fan_devices.json][sensor.twindos_2_level-entry] +# name: test_fan_hob_sensor_states[platforms0-fan_devices.json][sensor.miele_test_twindos_2_level-entry] EntityRegistryEntrySnapshot({ 'aliases': list([ None, @@ -2490,7 +2490,7 @@ 'disabled_by': None, 'domain': 'sensor', 'entity_category': , - 'entity_id': 'sensor.twindos_2_level', + 'entity_id': 'sensor.miele_test_twindos_2_level', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -2513,21 +2513,21 @@ 'unit_of_measurement': '%', }) # --- -# name: test_fan_hob_sensor_states[platforms0-fan_devices.json][sensor.twindos_2_level-state] +# name: test_fan_hob_sensor_states[platforms0-fan_devices.json][sensor.miele_test_twindos_2_level-state] StateSnapshot({ 'attributes': ReadOnlyDict({ - : 'TwinDos 2 level', + : 'Miele test TwinDos 2 level', : '%', }), 'context': , - 'entity_id': 'sensor.twindos_2_level', + 'entity_id': 'sensor.miele_test_twindos_2_level', 'last_changed': , 'last_reported': , 'last_updated': , 'state': '20', }) # --- -# name: test_fan_hob_sensor_states[platforms0-fan_devices.json][sensor.twindos_2_level_2-entry] +# name: test_fan_hob_sensor_states[platforms0-fan_devices.json][sensor.miele_test_twindos_2_level_2-entry] EntityRegistryEntrySnapshot({ 'aliases': list([ None, @@ -2541,7 +2541,7 @@ 'disabled_by': None, 'domain': 'sensor', 'entity_category': , - 'entity_id': 'sensor.twindos_2_level_2', + 'entity_id': 'sensor.miele_test_twindos_2_level_2', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -2564,21 +2564,21 @@ 'unit_of_measurement': '%', }) # --- -# name: test_fan_hob_sensor_states[platforms0-fan_devices.json][sensor.twindos_2_level_2-state] +# name: test_fan_hob_sensor_states[platforms0-fan_devices.json][sensor.miele_test_twindos_2_level_2-state] StateSnapshot({ 'attributes': ReadOnlyDict({ - : 'TwinDos 2 level', + : 'Miele test TwinDos 2 level', : '%', }), 'context': , - 'entity_id': 'sensor.twindos_2_level_2', + 'entity_id': 'sensor.miele_test_twindos_2_level_2', 'last_changed': , 'last_reported': , 'last_updated': , 'state': '20', }) # --- -# name: test_fridge_freezer_sensor_states[platforms0-fridge_freezer.json][sensor.degreasing_cycles-entry] +# name: test_fridge_freezer_sensor_states[platforms0-fridge_freezer.json][sensor.miele_test_degreasing_cycles-entry] EntityRegistryEntrySnapshot({ 'aliases': list([ None, @@ -2594,7 +2594,7 @@ 'disabled_by': None, 'domain': 'sensor', 'entity_category': , - 'entity_id': 'sensor.degreasing_cycles', + 'entity_id': 'sensor.miele_test_degreasing_cycles', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -2617,21 +2617,21 @@ 'unit_of_measurement': None, }) # --- -# name: test_fridge_freezer_sensor_states[platforms0-fridge_freezer.json][sensor.degreasing_cycles-state] +# name: test_fridge_freezer_sensor_states[platforms0-fridge_freezer.json][sensor.miele_test_degreasing_cycles-state] StateSnapshot({ 'attributes': ReadOnlyDict({ - : 'Degreasing cycles', + : 'Miele test Degreasing cycles', : , }), 'context': , - 'entity_id': 'sensor.degreasing_cycles', + 'entity_id': 'sensor.miele_test_degreasing_cycles', 'last_changed': , 'last_reported': , 'last_updated': , 'state': '2', }) # --- -# name: test_fridge_freezer_sensor_states[platforms0-fridge_freezer.json][sensor.descaling_cycles-entry] +# name: test_fridge_freezer_sensor_states[platforms0-fridge_freezer.json][sensor.miele_test_descaling_cycles-entry] EntityRegistryEntrySnapshot({ 'aliases': list([ None, @@ -2647,7 +2647,7 @@ 'disabled_by': None, 'domain': 'sensor', 'entity_category': , - 'entity_id': 'sensor.descaling_cycles', + 'entity_id': 'sensor.miele_test_descaling_cycles', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -2670,14 +2670,14 @@ 'unit_of_measurement': None, }) # --- -# name: test_fridge_freezer_sensor_states[platforms0-fridge_freezer.json][sensor.descaling_cycles-state] +# name: test_fridge_freezer_sensor_states[platforms0-fridge_freezer.json][sensor.miele_test_descaling_cycles-state] StateSnapshot({ 'attributes': ReadOnlyDict({ - : 'Descaling cycles', + : 'Miele test Descaling cycles', : , }), 'context': , - 'entity_id': 'sensor.descaling_cycles', + 'entity_id': 'sensor.miele_test_descaling_cycles', 'last_changed': , 'last_reported': , 'last_updated': , @@ -3048,7 +3048,7 @@ 'state': '-18.0', }) # --- -# name: test_fridge_freezer_sensor_states[platforms0-fridge_freezer.json][sensor.milk_pipework_cleaning_cycles-entry] +# name: test_fridge_freezer_sensor_states[platforms0-fridge_freezer.json][sensor.miele_test_milk_pipework_cleaning_cycles-entry] EntityRegistryEntrySnapshot({ 'aliases': list([ None, @@ -3064,7 +3064,7 @@ 'disabled_by': None, 'domain': 'sensor', 'entity_category': , - 'entity_id': 'sensor.milk_pipework_cleaning_cycles', + 'entity_id': 'sensor.miele_test_milk_pipework_cleaning_cycles', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -3087,21 +3087,21 @@ 'unit_of_measurement': None, }) # --- -# name: test_fridge_freezer_sensor_states[platforms0-fridge_freezer.json][sensor.milk_pipework_cleaning_cycles-state] +# name: test_fridge_freezer_sensor_states[platforms0-fridge_freezer.json][sensor.miele_test_milk_pipework_cleaning_cycles-state] StateSnapshot({ 'attributes': ReadOnlyDict({ - : 'Milk pipework cleaning cycles', + : 'Miele test Milk pipework cleaning cycles', : , }), 'context': , - 'entity_id': 'sensor.milk_pipework_cleaning_cycles', + 'entity_id': 'sensor.miele_test_milk_pipework_cleaning_cycles', 'last_changed': , 'last_reported': , 'last_updated': , 'state': '3', }) # --- -# name: test_fridge_freezer_sensor_states[platforms0-fridge_freezer.json][sensor.powerdisk_level-entry] +# name: test_fridge_freezer_sensor_states[platforms0-fridge_freezer.json][sensor.miele_test_powerdisk_level-entry] EntityRegistryEntrySnapshot({ 'aliases': list([ None, @@ -3115,7 +3115,7 @@ 'disabled_by': None, 'domain': 'sensor', 'entity_category': , - 'entity_id': 'sensor.powerdisk_level', + 'entity_id': 'sensor.miele_test_powerdisk_level', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -3138,21 +3138,21 @@ 'unit_of_measurement': '%', }) # --- -# name: test_fridge_freezer_sensor_states[platforms0-fridge_freezer.json][sensor.powerdisk_level-state] +# name: test_fridge_freezer_sensor_states[platforms0-fridge_freezer.json][sensor.miele_test_powerdisk_level-state] StateSnapshot({ 'attributes': ReadOnlyDict({ - : 'PowerDisk level', + : 'Miele test PowerDisk level', : '%', }), 'context': , - 'entity_id': 'sensor.powerdisk_level', + 'entity_id': 'sensor.miele_test_powerdisk_level', 'last_changed': , 'last_reported': , 'last_updated': , 'state': '80.5', }) # --- -# name: test_fridge_freezer_sensor_states[platforms0-fridge_freezer.json][sensor.rinse_aid_level-entry] +# name: test_fridge_freezer_sensor_states[platforms0-fridge_freezer.json][sensor.miele_test_rinse_aid_level-entry] EntityRegistryEntrySnapshot({ 'aliases': list([ None, @@ -3166,7 +3166,7 @@ 'disabled_by': None, 'domain': 'sensor', 'entity_category': , - 'entity_id': 'sensor.rinse_aid_level', + 'entity_id': 'sensor.miele_test_rinse_aid_level', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -3189,21 +3189,21 @@ 'unit_of_measurement': '%', }) # --- -# name: test_fridge_freezer_sensor_states[platforms0-fridge_freezer.json][sensor.rinse_aid_level-state] +# name: test_fridge_freezer_sensor_states[platforms0-fridge_freezer.json][sensor.miele_test_rinse_aid_level-state] StateSnapshot({ 'attributes': ReadOnlyDict({ - : 'Rinse aid level', + : 'Miele test Rinse aid level', : '%', }), 'context': , - 'entity_id': 'sensor.rinse_aid_level', + 'entity_id': 'sensor.miele_test_rinse_aid_level', 'last_changed': , 'last_reported': , 'last_updated': , 'state': '25', }) # --- -# name: test_fridge_freezer_sensor_states[platforms0-fridge_freezer.json][sensor.salt_level-entry] +# name: test_fridge_freezer_sensor_states[platforms0-fridge_freezer.json][sensor.miele_test_salt_level-entry] EntityRegistryEntrySnapshot({ 'aliases': list([ None, @@ -3217,7 +3217,7 @@ 'disabled_by': None, 'domain': 'sensor', 'entity_category': , - 'entity_id': 'sensor.salt_level', + 'entity_id': 'sensor.miele_test_salt_level', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -3240,21 +3240,21 @@ 'unit_of_measurement': '%', }) # --- -# name: test_fridge_freezer_sensor_states[platforms0-fridge_freezer.json][sensor.salt_level-state] +# name: test_fridge_freezer_sensor_states[platforms0-fridge_freezer.json][sensor.miele_test_salt_level-state] StateSnapshot({ 'attributes': ReadOnlyDict({ - : 'Salt level', + : 'Miele test Salt level', : '%', }), 'context': , - 'entity_id': 'sensor.salt_level', + 'entity_id': 'sensor.miele_test_salt_level', 'last_changed': , 'last_reported': , 'last_updated': , 'state': '75', }) # --- -# name: test_fridge_freezer_sensor_states[platforms0-fridge_freezer.json][sensor.twindos_1_level-entry] +# name: test_fridge_freezer_sensor_states[platforms0-fridge_freezer.json][sensor.miele_test_twindos_1_level-entry] EntityRegistryEntrySnapshot({ 'aliases': list([ None, @@ -3268,7 +3268,7 @@ 'disabled_by': None, 'domain': 'sensor', 'entity_category': , - 'entity_id': 'sensor.twindos_1_level', + 'entity_id': 'sensor.miele_test_twindos_1_level', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -3291,21 +3291,21 @@ 'unit_of_measurement': '%', }) # --- -# name: test_fridge_freezer_sensor_states[platforms0-fridge_freezer.json][sensor.twindos_1_level-state] +# name: test_fridge_freezer_sensor_states[platforms0-fridge_freezer.json][sensor.miele_test_twindos_1_level-state] StateSnapshot({ 'attributes': ReadOnlyDict({ - : 'TwinDos 1 level', + : 'Miele test TwinDos 1 level', : '%', }), 'context': , - 'entity_id': 'sensor.twindos_1_level', + 'entity_id': 'sensor.miele_test_twindos_1_level', 'last_changed': , 'last_reported': , 'last_updated': , 'state': '63', }) # --- -# name: test_fridge_freezer_sensor_states[platforms0-fridge_freezer.json][sensor.twindos_1_level_2-entry] +# name: test_fridge_freezer_sensor_states[platforms0-fridge_freezer.json][sensor.miele_test_twindos_1_level_2-entry] EntityRegistryEntrySnapshot({ 'aliases': list([ None, @@ -3319,7 +3319,7 @@ 'disabled_by': None, 'domain': 'sensor', 'entity_category': , - 'entity_id': 'sensor.twindos_1_level_2', + 'entity_id': 'sensor.miele_test_twindos_1_level_2', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -3342,21 +3342,21 @@ 'unit_of_measurement': '%', }) # --- -# name: test_fridge_freezer_sensor_states[platforms0-fridge_freezer.json][sensor.twindos_1_level_2-state] +# name: test_fridge_freezer_sensor_states[platforms0-fridge_freezer.json][sensor.miele_test_twindos_1_level_2-state] StateSnapshot({ 'attributes': ReadOnlyDict({ - : 'TwinDos 1 level', + : 'Miele test TwinDos 1 level', : '%', }), 'context': , - 'entity_id': 'sensor.twindos_1_level_2', + 'entity_id': 'sensor.miele_test_twindos_1_level_2', 'last_changed': , 'last_reported': , 'last_updated': , 'state': '63', }) # --- -# name: test_fridge_freezer_sensor_states[platforms0-fridge_freezer.json][sensor.twindos_2_level-entry] +# name: test_fridge_freezer_sensor_states[platforms0-fridge_freezer.json][sensor.miele_test_twindos_2_level-entry] EntityRegistryEntrySnapshot({ 'aliases': list([ None, @@ -3370,7 +3370,7 @@ 'disabled_by': None, 'domain': 'sensor', 'entity_category': , - 'entity_id': 'sensor.twindos_2_level', + 'entity_id': 'sensor.miele_test_twindos_2_level', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -3393,21 +3393,21 @@ 'unit_of_measurement': '%', }) # --- -# name: test_fridge_freezer_sensor_states[platforms0-fridge_freezer.json][sensor.twindos_2_level-state] +# name: test_fridge_freezer_sensor_states[platforms0-fridge_freezer.json][sensor.miele_test_twindos_2_level-state] StateSnapshot({ 'attributes': ReadOnlyDict({ - : 'TwinDos 2 level', + : 'Miele test TwinDos 2 level', : '%', }), 'context': , - 'entity_id': 'sensor.twindos_2_level', + 'entity_id': 'sensor.miele_test_twindos_2_level', 'last_changed': , 'last_reported': , 'last_updated': , 'state': '20', }) # --- -# name: test_fridge_freezer_sensor_states[platforms0-fridge_freezer.json][sensor.twindos_2_level_2-entry] +# name: test_fridge_freezer_sensor_states[platforms0-fridge_freezer.json][sensor.miele_test_twindos_2_level_2-entry] EntityRegistryEntrySnapshot({ 'aliases': list([ None, @@ -3421,7 +3421,7 @@ 'disabled_by': None, 'domain': 'sensor', 'entity_category': , - 'entity_id': 'sensor.twindos_2_level_2', + 'entity_id': 'sensor.miele_test_twindos_2_level_2', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -3444,21 +3444,21 @@ 'unit_of_measurement': '%', }) # --- -# name: test_fridge_freezer_sensor_states[platforms0-fridge_freezer.json][sensor.twindos_2_level_2-state] +# name: test_fridge_freezer_sensor_states[platforms0-fridge_freezer.json][sensor.miele_test_twindos_2_level_2-state] StateSnapshot({ 'attributes': ReadOnlyDict({ - : 'TwinDos 2 level', + : 'Miele test TwinDos 2 level', : '%', }), 'context': , - 'entity_id': 'sensor.twindos_2_level_2', + 'entity_id': 'sensor.miele_test_twindos_2_level_2', 'last_changed': , 'last_reported': , 'last_updated': , 'state': '20', }) # --- -# name: test_hob_sensor_states[platforms0-hob.json][sensor.degreasing_cycles-entry] +# name: test_hob_sensor_states[platforms0-hob.json][sensor.miele_test_degreasing_cycles-entry] EntityRegistryEntrySnapshot({ 'aliases': list([ None, @@ -3474,7 +3474,7 @@ 'disabled_by': None, 'domain': 'sensor', 'entity_category': , - 'entity_id': 'sensor.degreasing_cycles', + 'entity_id': 'sensor.miele_test_degreasing_cycles', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -3497,21 +3497,21 @@ 'unit_of_measurement': None, }) # --- -# name: test_hob_sensor_states[platforms0-hob.json][sensor.degreasing_cycles-state] +# name: test_hob_sensor_states[platforms0-hob.json][sensor.miele_test_degreasing_cycles-state] StateSnapshot({ 'attributes': ReadOnlyDict({ - : 'Degreasing cycles', + : 'Miele test Degreasing cycles', : , }), 'context': , - 'entity_id': 'sensor.degreasing_cycles', + 'entity_id': 'sensor.miele_test_degreasing_cycles', 'last_changed': , 'last_reported': , 'last_updated': , 'state': '2', }) # --- -# name: test_hob_sensor_states[platforms0-hob.json][sensor.descaling_cycles-entry] +# name: test_hob_sensor_states[platforms0-hob.json][sensor.miele_test_descaling_cycles-entry] EntityRegistryEntrySnapshot({ 'aliases': list([ None, @@ -3527,7 +3527,7 @@ 'disabled_by': None, 'domain': 'sensor', 'entity_category': , - 'entity_id': 'sensor.descaling_cycles', + 'entity_id': 'sensor.miele_test_descaling_cycles', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -3550,14 +3550,14 @@ 'unit_of_measurement': None, }) # --- -# name: test_hob_sensor_states[platforms0-hob.json][sensor.descaling_cycles-state] +# name: test_hob_sensor_states[platforms0-hob.json][sensor.miele_test_descaling_cycles-state] StateSnapshot({ 'attributes': ReadOnlyDict({ - : 'Descaling cycles', + : 'Miele test Descaling cycles', : , }), 'context': , - 'entity_id': 'sensor.descaling_cycles', + 'entity_id': 'sensor.miele_test_descaling_cycles', 'last_changed': , 'last_reported': , 'last_updated': , @@ -4159,7 +4159,7 @@ 'state': 'plate_step_boost', }) # --- -# name: test_hob_sensor_states[platforms0-hob.json][sensor.milk_pipework_cleaning_cycles-entry] +# name: test_hob_sensor_states[platforms0-hob.json][sensor.miele_test_milk_pipework_cleaning_cycles-entry] EntityRegistryEntrySnapshot({ 'aliases': list([ None, @@ -4175,7 +4175,7 @@ 'disabled_by': None, 'domain': 'sensor', 'entity_category': , - 'entity_id': 'sensor.milk_pipework_cleaning_cycles', + 'entity_id': 'sensor.miele_test_milk_pipework_cleaning_cycles', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -4198,21 +4198,21 @@ 'unit_of_measurement': None, }) # --- -# name: test_hob_sensor_states[platforms0-hob.json][sensor.milk_pipework_cleaning_cycles-state] +# name: test_hob_sensor_states[platforms0-hob.json][sensor.miele_test_milk_pipework_cleaning_cycles-state] StateSnapshot({ 'attributes': ReadOnlyDict({ - : 'Milk pipework cleaning cycles', + : 'Miele test Milk pipework cleaning cycles', : , }), 'context': , - 'entity_id': 'sensor.milk_pipework_cleaning_cycles', + 'entity_id': 'sensor.miele_test_milk_pipework_cleaning_cycles', 'last_changed': , 'last_reported': , 'last_updated': , 'state': '3', }) # --- -# name: test_hob_sensor_states[platforms0-hob.json][sensor.powerdisk_level-entry] +# name: test_hob_sensor_states[platforms0-hob.json][sensor.miele_test_powerdisk_level-entry] EntityRegistryEntrySnapshot({ 'aliases': list([ None, @@ -4226,7 +4226,7 @@ 'disabled_by': None, 'domain': 'sensor', 'entity_category': , - 'entity_id': 'sensor.powerdisk_level', + 'entity_id': 'sensor.miele_test_powerdisk_level', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -4249,21 +4249,21 @@ 'unit_of_measurement': '%', }) # --- -# name: test_hob_sensor_states[platforms0-hob.json][sensor.powerdisk_level-state] +# name: test_hob_sensor_states[platforms0-hob.json][sensor.miele_test_powerdisk_level-state] StateSnapshot({ 'attributes': ReadOnlyDict({ - : 'PowerDisk level', + : 'Miele test PowerDisk level', : '%', }), 'context': , - 'entity_id': 'sensor.powerdisk_level', + 'entity_id': 'sensor.miele_test_powerdisk_level', 'last_changed': , 'last_reported': , 'last_updated': , 'state': '80.5', }) # --- -# name: test_hob_sensor_states[platforms0-hob.json][sensor.rinse_aid_level-entry] +# name: test_hob_sensor_states[platforms0-hob.json][sensor.miele_test_rinse_aid_level-entry] EntityRegistryEntrySnapshot({ 'aliases': list([ None, @@ -4277,7 +4277,7 @@ 'disabled_by': None, 'domain': 'sensor', 'entity_category': , - 'entity_id': 'sensor.rinse_aid_level', + 'entity_id': 'sensor.miele_test_rinse_aid_level', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -4300,21 +4300,21 @@ 'unit_of_measurement': '%', }) # --- -# name: test_hob_sensor_states[platforms0-hob.json][sensor.rinse_aid_level-state] +# name: test_hob_sensor_states[platforms0-hob.json][sensor.miele_test_rinse_aid_level-state] StateSnapshot({ 'attributes': ReadOnlyDict({ - : 'Rinse aid level', + : 'Miele test Rinse aid level', : '%', }), 'context': , - 'entity_id': 'sensor.rinse_aid_level', + 'entity_id': 'sensor.miele_test_rinse_aid_level', 'last_changed': , 'last_reported': , 'last_updated': , 'state': '25', }) # --- -# name: test_hob_sensor_states[platforms0-hob.json][sensor.salt_level-entry] +# name: test_hob_sensor_states[platforms0-hob.json][sensor.miele_test_salt_level-entry] EntityRegistryEntrySnapshot({ 'aliases': list([ None, @@ -4328,7 +4328,7 @@ 'disabled_by': None, 'domain': 'sensor', 'entity_category': , - 'entity_id': 'sensor.salt_level', + 'entity_id': 'sensor.miele_test_salt_level', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -4351,21 +4351,21 @@ 'unit_of_measurement': '%', }) # --- -# name: test_hob_sensor_states[platforms0-hob.json][sensor.salt_level-state] +# name: test_hob_sensor_states[platforms0-hob.json][sensor.miele_test_salt_level-state] StateSnapshot({ 'attributes': ReadOnlyDict({ - : 'Salt level', + : 'Miele test Salt level', : '%', }), 'context': , - 'entity_id': 'sensor.salt_level', + 'entity_id': 'sensor.miele_test_salt_level', 'last_changed': , 'last_reported': , 'last_updated': , 'state': '75', }) # --- -# name: test_hob_sensor_states[platforms0-hob.json][sensor.twindos_1_level-entry] +# name: test_hob_sensor_states[platforms0-hob.json][sensor.miele_test_twindos_1_level-entry] EntityRegistryEntrySnapshot({ 'aliases': list([ None, @@ -4379,7 +4379,7 @@ 'disabled_by': None, 'domain': 'sensor', 'entity_category': , - 'entity_id': 'sensor.twindos_1_level', + 'entity_id': 'sensor.miele_test_twindos_1_level', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -4402,21 +4402,21 @@ 'unit_of_measurement': '%', }) # --- -# name: test_hob_sensor_states[platforms0-hob.json][sensor.twindos_1_level-state] +# name: test_hob_sensor_states[platforms0-hob.json][sensor.miele_test_twindos_1_level-state] StateSnapshot({ 'attributes': ReadOnlyDict({ - : 'TwinDos 1 level', + : 'Miele test TwinDos 1 level', : '%', }), 'context': , - 'entity_id': 'sensor.twindos_1_level', + 'entity_id': 'sensor.miele_test_twindos_1_level', 'last_changed': , 'last_reported': , 'last_updated': , 'state': '63', }) # --- -# name: test_hob_sensor_states[platforms0-hob.json][sensor.twindos_1_level_2-entry] +# name: test_hob_sensor_states[platforms0-hob.json][sensor.miele_test_twindos_1_level_2-entry] EntityRegistryEntrySnapshot({ 'aliases': list([ None, @@ -4430,7 +4430,7 @@ 'disabled_by': None, 'domain': 'sensor', 'entity_category': , - 'entity_id': 'sensor.twindos_1_level_2', + 'entity_id': 'sensor.miele_test_twindos_1_level_2', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -4453,21 +4453,21 @@ 'unit_of_measurement': '%', }) # --- -# name: test_hob_sensor_states[platforms0-hob.json][sensor.twindos_1_level_2-state] +# name: test_hob_sensor_states[platforms0-hob.json][sensor.miele_test_twindos_1_level_2-state] StateSnapshot({ 'attributes': ReadOnlyDict({ - : 'TwinDos 1 level', + : 'Miele test TwinDos 1 level', : '%', }), 'context': , - 'entity_id': 'sensor.twindos_1_level_2', + 'entity_id': 'sensor.miele_test_twindos_1_level_2', 'last_changed': , 'last_reported': , 'last_updated': , 'state': '63', }) # --- -# name: test_hob_sensor_states[platforms0-hob.json][sensor.twindos_2_level-entry] +# name: test_hob_sensor_states[platforms0-hob.json][sensor.miele_test_twindos_2_level-entry] EntityRegistryEntrySnapshot({ 'aliases': list([ None, @@ -4481,7 +4481,7 @@ 'disabled_by': None, 'domain': 'sensor', 'entity_category': , - 'entity_id': 'sensor.twindos_2_level', + 'entity_id': 'sensor.miele_test_twindos_2_level', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -4504,21 +4504,21 @@ 'unit_of_measurement': '%', }) # --- -# name: test_hob_sensor_states[platforms0-hob.json][sensor.twindos_2_level-state] +# name: test_hob_sensor_states[platforms0-hob.json][sensor.miele_test_twindos_2_level-state] StateSnapshot({ 'attributes': ReadOnlyDict({ - : 'TwinDos 2 level', + : 'Miele test TwinDos 2 level', : '%', }), 'context': , - 'entity_id': 'sensor.twindos_2_level', + 'entity_id': 'sensor.miele_test_twindos_2_level', 'last_changed': , 'last_reported': , 'last_updated': , 'state': '20', }) # --- -# name: test_hob_sensor_states[platforms0-hob.json][sensor.twindos_2_level_2-entry] +# name: test_hob_sensor_states[platforms0-hob.json][sensor.miele_test_twindos_2_level_2-entry] EntityRegistryEntrySnapshot({ 'aliases': list([ None, @@ -4532,7 +4532,7 @@ 'disabled_by': None, 'domain': 'sensor', 'entity_category': , - 'entity_id': 'sensor.twindos_2_level_2', + 'entity_id': 'sensor.miele_test_twindos_2_level_2', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -4555,21 +4555,21 @@ 'unit_of_measurement': '%', }) # --- -# name: test_hob_sensor_states[platforms0-hob.json][sensor.twindos_2_level_2-state] +# name: test_hob_sensor_states[platforms0-hob.json][sensor.miele_test_twindos_2_level_2-state] StateSnapshot({ 'attributes': ReadOnlyDict({ - : 'TwinDos 2 level', + : 'Miele test TwinDos 2 level', : '%', }), 'context': , - 'entity_id': 'sensor.twindos_2_level_2', + 'entity_id': 'sensor.miele_test_twindos_2_level_2', 'last_changed': , 'last_reported': , 'last_updated': , 'state': '20', }) # --- -# name: test_sensor_states[platforms0][sensor.degreasing_cycles-entry] +# name: test_sensor_states[platforms0][sensor.miele_test_degreasing_cycles-entry] EntityRegistryEntrySnapshot({ 'aliases': list([ None, @@ -4585,7 +4585,7 @@ 'disabled_by': None, 'domain': 'sensor', 'entity_category': , - 'entity_id': 'sensor.degreasing_cycles', + 'entity_id': 'sensor.miele_test_degreasing_cycles', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -4608,21 +4608,21 @@ 'unit_of_measurement': None, }) # --- -# name: test_sensor_states[platforms0][sensor.degreasing_cycles-state] +# name: test_sensor_states[platforms0][sensor.miele_test_degreasing_cycles-state] StateSnapshot({ 'attributes': ReadOnlyDict({ - : 'Degreasing cycles', + : 'Miele test Degreasing cycles', : , }), 'context': , - 'entity_id': 'sensor.degreasing_cycles', + 'entity_id': 'sensor.miele_test_degreasing_cycles', 'last_changed': , 'last_reported': , 'last_updated': , 'state': '2', }) # --- -# name: test_sensor_states[platforms0][sensor.descaling_cycles-entry] +# name: test_sensor_states[platforms0][sensor.miele_test_descaling_cycles-entry] EntityRegistryEntrySnapshot({ 'aliases': list([ None, @@ -4638,7 +4638,7 @@ 'disabled_by': None, 'domain': 'sensor', 'entity_category': , - 'entity_id': 'sensor.descaling_cycles', + 'entity_id': 'sensor.miele_test_descaling_cycles', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -4661,14 +4661,14 @@ 'unit_of_measurement': None, }) # --- -# name: test_sensor_states[platforms0][sensor.descaling_cycles-state] +# name: test_sensor_states[platforms0][sensor.miele_test_descaling_cycles-state] StateSnapshot({ 'attributes': ReadOnlyDict({ - : 'Descaling cycles', + : 'Miele test Descaling cycles', : , }), 'context': , - 'entity_id': 'sensor.descaling_cycles', + 'entity_id': 'sensor.miele_test_descaling_cycles', 'last_changed': , 'last_reported': , 'last_updated': , @@ -4923,7 +4923,7 @@ 'state': 'off', }) # --- -# name: test_sensor_states[platforms0][sensor.milk_pipework_cleaning_cycles-entry] +# name: test_sensor_states[platforms0][sensor.miele_test_milk_pipework_cleaning_cycles-entry] EntityRegistryEntrySnapshot({ 'aliases': list([ None, @@ -4939,7 +4939,7 @@ 'disabled_by': None, 'domain': 'sensor', 'entity_category': , - 'entity_id': 'sensor.milk_pipework_cleaning_cycles', + 'entity_id': 'sensor.miele_test_milk_pipework_cleaning_cycles', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -4962,14 +4962,14 @@ 'unit_of_measurement': None, }) # --- -# name: test_sensor_states[platforms0][sensor.milk_pipework_cleaning_cycles-state] +# name: test_sensor_states[platforms0][sensor.miele_test_milk_pipework_cleaning_cycles-state] StateSnapshot({ 'attributes': ReadOnlyDict({ - : 'Milk pipework cleaning cycles', + : 'Miele test Milk pipework cleaning cycles', : , }), 'context': , - 'entity_id': 'sensor.milk_pipework_cleaning_cycles', + 'entity_id': 'sensor.miele_test_milk_pipework_cleaning_cycles', 'last_changed': , 'last_reported': , 'last_updated': , @@ -6863,7 +6863,7 @@ 'state': '19.54', }) # --- -# name: test_sensor_states[platforms0][sensor.powerdisk_level-entry] +# name: test_sensor_states[platforms0][sensor.miele_test_powerdisk_level-entry] EntityRegistryEntrySnapshot({ 'aliases': list([ None, @@ -6877,7 +6877,7 @@ 'disabled_by': None, 'domain': 'sensor', 'entity_category': , - 'entity_id': 'sensor.powerdisk_level', + 'entity_id': 'sensor.miele_test_powerdisk_level', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -6900,14 +6900,14 @@ 'unit_of_measurement': '%', }) # --- -# name: test_sensor_states[platforms0][sensor.powerdisk_level-state] +# name: test_sensor_states[platforms0][sensor.miele_test_powerdisk_level-state] StateSnapshot({ 'attributes': ReadOnlyDict({ - : 'PowerDisk level', + : 'Miele test PowerDisk level', : '%', }), 'context': , - 'entity_id': 'sensor.powerdisk_level', + 'entity_id': 'sensor.miele_test_powerdisk_level', 'last_changed': , 'last_reported': , 'last_updated': , @@ -7067,7 +7067,7 @@ 'state': '4.0', }) # --- -# name: test_sensor_states[platforms0][sensor.rinse_aid_level-entry] +# name: test_sensor_states[platforms0][sensor.miele_test_rinse_aid_level-entry] EntityRegistryEntrySnapshot({ 'aliases': list([ None, @@ -7081,7 +7081,7 @@ 'disabled_by': None, 'domain': 'sensor', 'entity_category': , - 'entity_id': 'sensor.rinse_aid_level', + 'entity_id': 'sensor.miele_test_rinse_aid_level', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -7104,21 +7104,21 @@ 'unit_of_measurement': '%', }) # --- -# name: test_sensor_states[platforms0][sensor.rinse_aid_level-state] +# name: test_sensor_states[platforms0][sensor.miele_test_rinse_aid_level-state] StateSnapshot({ 'attributes': ReadOnlyDict({ - : 'Rinse aid level', + : 'Miele test Rinse aid level', : '%', }), 'context': , - 'entity_id': 'sensor.rinse_aid_level', + 'entity_id': 'sensor.miele_test_rinse_aid_level', 'last_changed': , 'last_reported': , 'last_updated': , 'state': '25', }) # --- -# name: test_sensor_states[platforms0][sensor.salt_level-entry] +# name: test_sensor_states[platforms0][sensor.miele_test_salt_level-entry] EntityRegistryEntrySnapshot({ 'aliases': list([ None, @@ -7132,7 +7132,7 @@ 'disabled_by': None, 'domain': 'sensor', 'entity_category': , - 'entity_id': 'sensor.salt_level', + 'entity_id': 'sensor.miele_test_salt_level', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -7155,21 +7155,21 @@ 'unit_of_measurement': '%', }) # --- -# name: test_sensor_states[platforms0][sensor.salt_level-state] +# name: test_sensor_states[platforms0][sensor.miele_test_salt_level-state] StateSnapshot({ 'attributes': ReadOnlyDict({ - : 'Salt level', + : 'Miele test Salt level', : '%', }), 'context': , - 'entity_id': 'sensor.salt_level', + 'entity_id': 'sensor.miele_test_salt_level', 'last_changed': , 'last_reported': , 'last_updated': , 'state': '75', }) # --- -# name: test_sensor_states[platforms0][sensor.twindos_1_level-entry] +# name: test_sensor_states[platforms0][sensor.miele_test_twindos_1_level-entry] EntityRegistryEntrySnapshot({ 'aliases': list([ None, @@ -7183,7 +7183,7 @@ 'disabled_by': None, 'domain': 'sensor', 'entity_category': , - 'entity_id': 'sensor.twindos_1_level', + 'entity_id': 'sensor.miele_test_twindos_1_level', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -7206,21 +7206,21 @@ 'unit_of_measurement': '%', }) # --- -# name: test_sensor_states[platforms0][sensor.twindos_1_level-state] +# name: test_sensor_states[platforms0][sensor.miele_test_twindos_1_level-state] StateSnapshot({ 'attributes': ReadOnlyDict({ - : 'TwinDos 1 level', + : 'Miele test TwinDos 1 level', : '%', }), 'context': , - 'entity_id': 'sensor.twindos_1_level', + 'entity_id': 'sensor.miele_test_twindos_1_level', 'last_changed': , 'last_reported': , 'last_updated': , 'state': '63', }) # --- -# name: test_sensor_states[platforms0][sensor.twindos_2_level-entry] +# name: test_sensor_states[platforms0][sensor.miele_test_twindos_2_level-entry] EntityRegistryEntrySnapshot({ 'aliases': list([ None, @@ -7234,7 +7234,7 @@ 'disabled_by': None, 'domain': 'sensor', 'entity_category': , - 'entity_id': 'sensor.twindos_2_level', + 'entity_id': 'sensor.miele_test_twindos_2_level', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -7257,14 +7257,14 @@ 'unit_of_measurement': '%', }) # --- -# name: test_sensor_states[platforms0][sensor.twindos_2_level-state] +# name: test_sensor_states[platforms0][sensor.miele_test_twindos_2_level-state] StateSnapshot({ 'attributes': ReadOnlyDict({ - : 'TwinDos 2 level', + : 'Miele test TwinDos 2 level', : '%', }), 'context': , - 'entity_id': 'sensor.twindos_2_level', + 'entity_id': 'sensor.miele_test_twindos_2_level', 'last_changed': , 'last_reported': , 'last_updated': , @@ -8383,7 +8383,7 @@ 'state': '0.0', }) # --- -# name: test_sensor_states_api_push[platforms0][sensor.degreasing_cycles-entry] +# name: test_sensor_states_api_push[platforms0][sensor.miele_test_degreasing_cycles-entry] EntityRegistryEntrySnapshot({ 'aliases': list([ None, @@ -8399,7 +8399,7 @@ 'disabled_by': None, 'domain': 'sensor', 'entity_category': , - 'entity_id': 'sensor.degreasing_cycles', + 'entity_id': 'sensor.miele_test_degreasing_cycles', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -8422,21 +8422,21 @@ 'unit_of_measurement': None, }) # --- -# name: test_sensor_states_api_push[platforms0][sensor.degreasing_cycles-state] +# name: test_sensor_states_api_push[platforms0][sensor.miele_test_degreasing_cycles-state] StateSnapshot({ 'attributes': ReadOnlyDict({ - : 'Degreasing cycles', + : 'Miele test Degreasing cycles', : , }), 'context': , - 'entity_id': 'sensor.degreasing_cycles', + 'entity_id': 'sensor.miele_test_degreasing_cycles', 'last_changed': , 'last_reported': , 'last_updated': , 'state': '2', }) # --- -# name: test_sensor_states_api_push[platforms0][sensor.descaling_cycles-entry] +# name: test_sensor_states_api_push[platforms0][sensor.miele_test_descaling_cycles-entry] EntityRegistryEntrySnapshot({ 'aliases': list([ None, @@ -8452,7 +8452,7 @@ 'disabled_by': None, 'domain': 'sensor', 'entity_category': , - 'entity_id': 'sensor.descaling_cycles', + 'entity_id': 'sensor.miele_test_descaling_cycles', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -8475,14 +8475,14 @@ 'unit_of_measurement': None, }) # --- -# name: test_sensor_states_api_push[platforms0][sensor.descaling_cycles-state] +# name: test_sensor_states_api_push[platforms0][sensor.miele_test_descaling_cycles-state] StateSnapshot({ 'attributes': ReadOnlyDict({ - : 'Descaling cycles', + : 'Miele test Descaling cycles', : , }), 'context': , - 'entity_id': 'sensor.descaling_cycles', + 'entity_id': 'sensor.miele_test_descaling_cycles', 'last_changed': , 'last_reported': , 'last_updated': , @@ -8737,7 +8737,7 @@ 'state': 'off', }) # --- -# name: test_sensor_states_api_push[platforms0][sensor.milk_pipework_cleaning_cycles-entry] +# name: test_sensor_states_api_push[platforms0][sensor.miele_test_milk_pipework_cleaning_cycles-entry] EntityRegistryEntrySnapshot({ 'aliases': list([ None, @@ -8753,7 +8753,7 @@ 'disabled_by': None, 'domain': 'sensor', 'entity_category': , - 'entity_id': 'sensor.milk_pipework_cleaning_cycles', + 'entity_id': 'sensor.miele_test_milk_pipework_cleaning_cycles', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -8776,14 +8776,14 @@ 'unit_of_measurement': None, }) # --- -# name: test_sensor_states_api_push[platforms0][sensor.milk_pipework_cleaning_cycles-state] +# name: test_sensor_states_api_push[platforms0][sensor.miele_test_milk_pipework_cleaning_cycles-state] StateSnapshot({ 'attributes': ReadOnlyDict({ - : 'Milk pipework cleaning cycles', + : 'Miele test Milk pipework cleaning cycles', : , }), 'context': , - 'entity_id': 'sensor.milk_pipework_cleaning_cycles', + 'entity_id': 'sensor.miele_test_milk_pipework_cleaning_cycles', 'last_changed': , 'last_reported': , 'last_updated': , @@ -10677,7 +10677,7 @@ 'state': '19.54', }) # --- -# name: test_sensor_states_api_push[platforms0][sensor.powerdisk_level-entry] +# name: test_sensor_states_api_push[platforms0][sensor.miele_test_powerdisk_level-entry] EntityRegistryEntrySnapshot({ 'aliases': list([ None, @@ -10691,7 +10691,7 @@ 'disabled_by': None, 'domain': 'sensor', 'entity_category': , - 'entity_id': 'sensor.powerdisk_level', + 'entity_id': 'sensor.miele_test_powerdisk_level', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -10714,14 +10714,14 @@ 'unit_of_measurement': '%', }) # --- -# name: test_sensor_states_api_push[platforms0][sensor.powerdisk_level-state] +# name: test_sensor_states_api_push[platforms0][sensor.miele_test_powerdisk_level-state] StateSnapshot({ 'attributes': ReadOnlyDict({ - : 'PowerDisk level', + : 'Miele test PowerDisk level', : '%', }), 'context': , - 'entity_id': 'sensor.powerdisk_level', + 'entity_id': 'sensor.miele_test_powerdisk_level', 'last_changed': , 'last_reported': , 'last_updated': , @@ -10881,7 +10881,7 @@ 'state': '4.0', }) # --- -# name: test_sensor_states_api_push[platforms0][sensor.rinse_aid_level-entry] +# name: test_sensor_states_api_push[platforms0][sensor.miele_test_rinse_aid_level-entry] EntityRegistryEntrySnapshot({ 'aliases': list([ None, @@ -10895,7 +10895,7 @@ 'disabled_by': None, 'domain': 'sensor', 'entity_category': , - 'entity_id': 'sensor.rinse_aid_level', + 'entity_id': 'sensor.miele_test_rinse_aid_level', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -10918,21 +10918,21 @@ 'unit_of_measurement': '%', }) # --- -# name: test_sensor_states_api_push[platforms0][sensor.rinse_aid_level-state] +# name: test_sensor_states_api_push[platforms0][sensor.miele_test_rinse_aid_level-state] StateSnapshot({ 'attributes': ReadOnlyDict({ - : 'Rinse aid level', + : 'Miele test Rinse aid level', : '%', }), 'context': , - 'entity_id': 'sensor.rinse_aid_level', + 'entity_id': 'sensor.miele_test_rinse_aid_level', 'last_changed': , 'last_reported': , 'last_updated': , 'state': '25', }) # --- -# name: test_sensor_states_api_push[platforms0][sensor.salt_level-entry] +# name: test_sensor_states_api_push[platforms0][sensor.miele_test_salt_level-entry] EntityRegistryEntrySnapshot({ 'aliases': list([ None, @@ -10946,7 +10946,7 @@ 'disabled_by': None, 'domain': 'sensor', 'entity_category': , - 'entity_id': 'sensor.salt_level', + 'entity_id': 'sensor.miele_test_salt_level', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -10969,21 +10969,21 @@ 'unit_of_measurement': '%', }) # --- -# name: test_sensor_states_api_push[platforms0][sensor.salt_level-state] +# name: test_sensor_states_api_push[platforms0][sensor.miele_test_salt_level-state] StateSnapshot({ 'attributes': ReadOnlyDict({ - : 'Salt level', + : 'Miele test Salt level', : '%', }), 'context': , - 'entity_id': 'sensor.salt_level', + 'entity_id': 'sensor.miele_test_salt_level', 'last_changed': , 'last_reported': , 'last_updated': , 'state': '75', }) # --- -# name: test_sensor_states_api_push[platforms0][sensor.twindos_1_level-entry] +# name: test_sensor_states_api_push[platforms0][sensor.miele_test_twindos_1_level-entry] EntityRegistryEntrySnapshot({ 'aliases': list([ None, @@ -10997,7 +10997,7 @@ 'disabled_by': None, 'domain': 'sensor', 'entity_category': , - 'entity_id': 'sensor.twindos_1_level', + 'entity_id': 'sensor.miele_test_twindos_1_level', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -11020,21 +11020,21 @@ 'unit_of_measurement': '%', }) # --- -# name: test_sensor_states_api_push[platforms0][sensor.twindos_1_level-state] +# name: test_sensor_states_api_push[platforms0][sensor.miele_test_twindos_1_level-state] StateSnapshot({ 'attributes': ReadOnlyDict({ - : 'TwinDos 1 level', + : 'Miele test TwinDos 1 level', : '%', }), 'context': , - 'entity_id': 'sensor.twindos_1_level', + 'entity_id': 'sensor.miele_test_twindos_1_level', 'last_changed': , 'last_reported': , 'last_updated': , 'state': '63', }) # --- -# name: test_sensor_states_api_push[platforms0][sensor.twindos_2_level-entry] +# name: test_sensor_states_api_push[platforms0][sensor.miele_test_twindos_2_level-entry] EntityRegistryEntrySnapshot({ 'aliases': list([ None, @@ -11048,7 +11048,7 @@ 'disabled_by': None, 'domain': 'sensor', 'entity_category': , - 'entity_id': 'sensor.twindos_2_level', + 'entity_id': 'sensor.miele_test_twindos_2_level', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -11071,14 +11071,14 @@ 'unit_of_measurement': '%', }) # --- -# name: test_sensor_states_api_push[platforms0][sensor.twindos_2_level-state] +# name: test_sensor_states_api_push[platforms0][sensor.miele_test_twindos_2_level-state] StateSnapshot({ 'attributes': ReadOnlyDict({ - : 'TwinDos 2 level', + : 'Miele test TwinDos 2 level', : '%', }), 'context': , - 'entity_id': 'sensor.twindos_2_level', + 'entity_id': 'sensor.miele_test_twindos_2_level', 'last_changed': , 'last_reported': , 'last_updated': , @@ -12197,7 +12197,7 @@ 'state': '0.0', }) # --- -# name: test_sensor_states_api_push_one_device[platforms0][sensor.degreasing_cycles-entry] +# name: test_sensor_states_api_push_one_device[platforms0][sensor.miele_test_degreasing_cycles-entry] EntityRegistryEntrySnapshot({ 'aliases': list([ None, @@ -12213,7 +12213,7 @@ 'disabled_by': None, 'domain': 'sensor', 'entity_category': , - 'entity_id': 'sensor.degreasing_cycles', + 'entity_id': 'sensor.miele_test_degreasing_cycles', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -12236,21 +12236,21 @@ 'unit_of_measurement': None, }) # --- -# name: test_sensor_states_api_push_one_device[platforms0][sensor.degreasing_cycles-state] +# name: test_sensor_states_api_push_one_device[platforms0][sensor.miele_test_degreasing_cycles-state] StateSnapshot({ 'attributes': ReadOnlyDict({ - : 'Degreasing cycles', + : 'Miele test Degreasing cycles', : , }), 'context': , - 'entity_id': 'sensor.degreasing_cycles', + 'entity_id': 'sensor.miele_test_degreasing_cycles', 'last_changed': , 'last_reported': , 'last_updated': , 'state': '2', }) # --- -# name: test_sensor_states_api_push_one_device[platforms0][sensor.descaling_cycles-entry] +# name: test_sensor_states_api_push_one_device[platforms0][sensor.miele_test_descaling_cycles-entry] EntityRegistryEntrySnapshot({ 'aliases': list([ None, @@ -12266,7 +12266,7 @@ 'disabled_by': None, 'domain': 'sensor', 'entity_category': , - 'entity_id': 'sensor.descaling_cycles', + 'entity_id': 'sensor.miele_test_descaling_cycles', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -12289,14 +12289,14 @@ 'unit_of_measurement': None, }) # --- -# name: test_sensor_states_api_push_one_device[platforms0][sensor.descaling_cycles-state] +# name: test_sensor_states_api_push_one_device[platforms0][sensor.miele_test_descaling_cycles-state] StateSnapshot({ 'attributes': ReadOnlyDict({ - : 'Descaling cycles', + : 'Miele test Descaling cycles', : , }), 'context': , - 'entity_id': 'sensor.descaling_cycles', + 'entity_id': 'sensor.miele_test_descaling_cycles', 'last_changed': , 'last_reported': , 'last_updated': , @@ -12551,7 +12551,7 @@ 'state': 'off', }) # --- -# name: test_sensor_states_api_push_one_device[platforms0][sensor.milk_pipework_cleaning_cycles-entry] +# name: test_sensor_states_api_push_one_device[platforms0][sensor.miele_test_milk_pipework_cleaning_cycles-entry] EntityRegistryEntrySnapshot({ 'aliases': list([ None, @@ -12567,7 +12567,7 @@ 'disabled_by': None, 'domain': 'sensor', 'entity_category': , - 'entity_id': 'sensor.milk_pipework_cleaning_cycles', + 'entity_id': 'sensor.miele_test_milk_pipework_cleaning_cycles', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -12590,14 +12590,14 @@ 'unit_of_measurement': None, }) # --- -# name: test_sensor_states_api_push_one_device[platforms0][sensor.milk_pipework_cleaning_cycles-state] +# name: test_sensor_states_api_push_one_device[platforms0][sensor.miele_test_milk_pipework_cleaning_cycles-state] StateSnapshot({ 'attributes': ReadOnlyDict({ - : 'Milk pipework cleaning cycles', + : 'Miele test Milk pipework cleaning cycles', : , }), 'context': , - 'entity_id': 'sensor.milk_pipework_cleaning_cycles', + 'entity_id': 'sensor.miele_test_milk_pipework_cleaning_cycles', 'last_changed': , 'last_reported': , 'last_updated': , @@ -14491,7 +14491,7 @@ 'state': '19.54', }) # --- -# name: test_sensor_states_api_push_one_device[platforms0][sensor.powerdisk_level-entry] +# name: test_sensor_states_api_push_one_device[platforms0][sensor.miele_test_powerdisk_level-entry] EntityRegistryEntrySnapshot({ 'aliases': list([ None, @@ -14505,7 +14505,7 @@ 'disabled_by': None, 'domain': 'sensor', 'entity_category': , - 'entity_id': 'sensor.powerdisk_level', + 'entity_id': 'sensor.miele_test_powerdisk_level', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -14528,14 +14528,14 @@ 'unit_of_measurement': '%', }) # --- -# name: test_sensor_states_api_push_one_device[platforms0][sensor.powerdisk_level-state] +# name: test_sensor_states_api_push_one_device[platforms0][sensor.miele_test_powerdisk_level-state] StateSnapshot({ 'attributes': ReadOnlyDict({ - : 'PowerDisk level', + : 'Miele test PowerDisk level', : '%', }), 'context': , - 'entity_id': 'sensor.powerdisk_level', + 'entity_id': 'sensor.miele_test_powerdisk_level', 'last_changed': , 'last_reported': , 'last_updated': , @@ -14695,7 +14695,7 @@ 'state': '4.0', }) # --- -# name: test_sensor_states_api_push_one_device[platforms0][sensor.rinse_aid_level-entry] +# name: test_sensor_states_api_push_one_device[platforms0][sensor.miele_test_rinse_aid_level-entry] EntityRegistryEntrySnapshot({ 'aliases': list([ None, @@ -14709,7 +14709,7 @@ 'disabled_by': None, 'domain': 'sensor', 'entity_category': , - 'entity_id': 'sensor.rinse_aid_level', + 'entity_id': 'sensor.miele_test_rinse_aid_level', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -14732,21 +14732,21 @@ 'unit_of_measurement': '%', }) # --- -# name: test_sensor_states_api_push_one_device[platforms0][sensor.rinse_aid_level-state] +# name: test_sensor_states_api_push_one_device[platforms0][sensor.miele_test_rinse_aid_level-state] StateSnapshot({ 'attributes': ReadOnlyDict({ - : 'Rinse aid level', + : 'Miele test Rinse aid level', : '%', }), 'context': , - 'entity_id': 'sensor.rinse_aid_level', + 'entity_id': 'sensor.miele_test_rinse_aid_level', 'last_changed': , 'last_reported': , 'last_updated': , 'state': '25', }) # --- -# name: test_sensor_states_api_push_one_device[platforms0][sensor.salt_level-entry] +# name: test_sensor_states_api_push_one_device[platforms0][sensor.miele_test_salt_level-entry] EntityRegistryEntrySnapshot({ 'aliases': list([ None, @@ -14760,7 +14760,7 @@ 'disabled_by': None, 'domain': 'sensor', 'entity_category': , - 'entity_id': 'sensor.salt_level', + 'entity_id': 'sensor.miele_test_salt_level', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -14783,21 +14783,21 @@ 'unit_of_measurement': '%', }) # --- -# name: test_sensor_states_api_push_one_device[platforms0][sensor.salt_level-state] +# name: test_sensor_states_api_push_one_device[platforms0][sensor.miele_test_salt_level-state] StateSnapshot({ 'attributes': ReadOnlyDict({ - : 'Salt level', + : 'Miele test Salt level', : '%', }), 'context': , - 'entity_id': 'sensor.salt_level', + 'entity_id': 'sensor.miele_test_salt_level', 'last_changed': , 'last_reported': , 'last_updated': , 'state': '75', }) # --- -# name: test_sensor_states_api_push_one_device[platforms0][sensor.twindos_1_level-entry] +# name: test_sensor_states_api_push_one_device[platforms0][sensor.miele_test_twindos_1_level-entry] EntityRegistryEntrySnapshot({ 'aliases': list([ None, @@ -14811,7 +14811,7 @@ 'disabled_by': None, 'domain': 'sensor', 'entity_category': , - 'entity_id': 'sensor.twindos_1_level', + 'entity_id': 'sensor.miele_test_twindos_1_level', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -14834,21 +14834,21 @@ 'unit_of_measurement': '%', }) # --- -# name: test_sensor_states_api_push_one_device[platforms0][sensor.twindos_1_level-state] +# name: test_sensor_states_api_push_one_device[platforms0][sensor.miele_test_twindos_1_level-state] StateSnapshot({ 'attributes': ReadOnlyDict({ - : 'TwinDos 1 level', + : 'Miele test TwinDos 1 level', : '%', }), 'context': , - 'entity_id': 'sensor.twindos_1_level', + 'entity_id': 'sensor.miele_test_twindos_1_level', 'last_changed': , 'last_reported': , 'last_updated': , 'state': '63', }) # --- -# name: test_sensor_states_api_push_one_device[platforms0][sensor.twindos_2_level-entry] +# name: test_sensor_states_api_push_one_device[platforms0][sensor.miele_test_twindos_2_level-entry] EntityRegistryEntrySnapshot({ 'aliases': list([ None, @@ -14862,7 +14862,7 @@ 'disabled_by': None, 'domain': 'sensor', 'entity_category': , - 'entity_id': 'sensor.twindos_2_level', + 'entity_id': 'sensor.miele_test_twindos_2_level', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -14885,14 +14885,14 @@ 'unit_of_measurement': '%', }) # --- -# name: test_sensor_states_api_push_one_device[platforms0][sensor.twindos_2_level-state] +# name: test_sensor_states_api_push_one_device[platforms0][sensor.miele_test_twindos_2_level-state] StateSnapshot({ 'attributes': ReadOnlyDict({ - : 'TwinDos 2 level', + : 'Miele test TwinDos 2 level', : '%', }), 'context': , - 'entity_id': 'sensor.twindos_2_level', + 'entity_id': 'sensor.miele_test_twindos_2_level', 'last_changed': , 'last_reported': , 'last_updated': , @@ -16011,7 +16011,7 @@ 'state': '0.0', }) # --- -# name: test_vacuum_sensor_states[platforms0-vacuum_device.json][sensor.degreasing_cycles-entry] +# name: test_vacuum_sensor_states[platforms0-vacuum_device.json][sensor.miele_test_degreasing_cycles-entry] EntityRegistryEntrySnapshot({ 'aliases': list([ None, @@ -16027,7 +16027,7 @@ 'disabled_by': None, 'domain': 'sensor', 'entity_category': , - 'entity_id': 'sensor.degreasing_cycles', + 'entity_id': 'sensor.miele_test_degreasing_cycles', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -16050,21 +16050,21 @@ 'unit_of_measurement': None, }) # --- -# name: test_vacuum_sensor_states[platforms0-vacuum_device.json][sensor.degreasing_cycles-state] +# name: test_vacuum_sensor_states[platforms0-vacuum_device.json][sensor.miele_test_degreasing_cycles-state] StateSnapshot({ 'attributes': ReadOnlyDict({ - : 'Degreasing cycles', + : 'Miele test Degreasing cycles', : , }), 'context': , - 'entity_id': 'sensor.degreasing_cycles', + 'entity_id': 'sensor.miele_test_degreasing_cycles', 'last_changed': , 'last_reported': , 'last_updated': , 'state': '2', }) # --- -# name: test_vacuum_sensor_states[platforms0-vacuum_device.json][sensor.descaling_cycles-entry] +# name: test_vacuum_sensor_states[platforms0-vacuum_device.json][sensor.miele_test_descaling_cycles-entry] EntityRegistryEntrySnapshot({ 'aliases': list([ None, @@ -16080,7 +16080,7 @@ 'disabled_by': None, 'domain': 'sensor', 'entity_category': , - 'entity_id': 'sensor.descaling_cycles', + 'entity_id': 'sensor.miele_test_descaling_cycles', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -16103,21 +16103,21 @@ 'unit_of_measurement': None, }) # --- -# name: test_vacuum_sensor_states[platforms0-vacuum_device.json][sensor.descaling_cycles-state] +# name: test_vacuum_sensor_states[platforms0-vacuum_device.json][sensor.miele_test_descaling_cycles-state] StateSnapshot({ 'attributes': ReadOnlyDict({ - : 'Descaling cycles', + : 'Miele test Descaling cycles', : , }), 'context': , - 'entity_id': 'sensor.descaling_cycles', + 'entity_id': 'sensor.miele_test_descaling_cycles', 'last_changed': , 'last_reported': , 'last_updated': , 'state': '1', }) # --- -# name: test_vacuum_sensor_states[platforms0-vacuum_device.json][sensor.milk_pipework_cleaning_cycles-entry] +# name: test_vacuum_sensor_states[platforms0-vacuum_device.json][sensor.miele_test_milk_pipework_cleaning_cycles-entry] EntityRegistryEntrySnapshot({ 'aliases': list([ None, @@ -16133,7 +16133,7 @@ 'disabled_by': None, 'domain': 'sensor', 'entity_category': , - 'entity_id': 'sensor.milk_pipework_cleaning_cycles', + 'entity_id': 'sensor.miele_test_milk_pipework_cleaning_cycles', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -16156,21 +16156,21 @@ 'unit_of_measurement': None, }) # --- -# name: test_vacuum_sensor_states[platforms0-vacuum_device.json][sensor.milk_pipework_cleaning_cycles-state] +# name: test_vacuum_sensor_states[platforms0-vacuum_device.json][sensor.miele_test_milk_pipework_cleaning_cycles-state] StateSnapshot({ 'attributes': ReadOnlyDict({ - : 'Milk pipework cleaning cycles', + : 'Miele test Milk pipework cleaning cycles', : , }), 'context': , - 'entity_id': 'sensor.milk_pipework_cleaning_cycles', + 'entity_id': 'sensor.miele_test_milk_pipework_cleaning_cycles', 'last_changed': , 'last_reported': , 'last_updated': , 'state': '3', }) # --- -# name: test_vacuum_sensor_states[platforms0-vacuum_device.json][sensor.powerdisk_level-entry] +# name: test_vacuum_sensor_states[platforms0-vacuum_device.json][sensor.miele_test_powerdisk_level-entry] EntityRegistryEntrySnapshot({ 'aliases': list([ None, @@ -16184,7 +16184,7 @@ 'disabled_by': None, 'domain': 'sensor', 'entity_category': , - 'entity_id': 'sensor.powerdisk_level', + 'entity_id': 'sensor.miele_test_powerdisk_level', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -16207,21 +16207,21 @@ 'unit_of_measurement': '%', }) # --- -# name: test_vacuum_sensor_states[platforms0-vacuum_device.json][sensor.powerdisk_level-state] +# name: test_vacuum_sensor_states[platforms0-vacuum_device.json][sensor.miele_test_powerdisk_level-state] StateSnapshot({ 'attributes': ReadOnlyDict({ - : 'PowerDisk level', + : 'Miele test PowerDisk level', : '%', }), 'context': , - 'entity_id': 'sensor.powerdisk_level', + 'entity_id': 'sensor.miele_test_powerdisk_level', 'last_changed': , 'last_reported': , 'last_updated': , 'state': '80.5', }) # --- -# name: test_vacuum_sensor_states[platforms0-vacuum_device.json][sensor.rinse_aid_level-entry] +# name: test_vacuum_sensor_states[platforms0-vacuum_device.json][sensor.miele_test_rinse_aid_level-entry] EntityRegistryEntrySnapshot({ 'aliases': list([ None, @@ -16235,7 +16235,7 @@ 'disabled_by': None, 'domain': 'sensor', 'entity_category': , - 'entity_id': 'sensor.rinse_aid_level', + 'entity_id': 'sensor.miele_test_rinse_aid_level', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -16258,14 +16258,14 @@ 'unit_of_measurement': '%', }) # --- -# name: test_vacuum_sensor_states[platforms0-vacuum_device.json][sensor.rinse_aid_level-state] +# name: test_vacuum_sensor_states[platforms0-vacuum_device.json][sensor.miele_test_rinse_aid_level-state] StateSnapshot({ 'attributes': ReadOnlyDict({ - : 'Rinse aid level', + : 'Miele test Rinse aid level', : '%', }), 'context': , - 'entity_id': 'sensor.rinse_aid_level', + 'entity_id': 'sensor.miele_test_rinse_aid_level', 'last_changed': , 'last_reported': , 'last_updated': , @@ -16761,7 +16761,7 @@ 'state': 'unknown', }) # --- -# name: test_vacuum_sensor_states[platforms0-vacuum_device.json][sensor.salt_level-entry] +# name: test_vacuum_sensor_states[platforms0-vacuum_device.json][sensor.miele_test_salt_level-entry] EntityRegistryEntrySnapshot({ 'aliases': list([ None, @@ -16775,7 +16775,7 @@ 'disabled_by': None, 'domain': 'sensor', 'entity_category': , - 'entity_id': 'sensor.salt_level', + 'entity_id': 'sensor.miele_test_salt_level', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -16798,21 +16798,21 @@ 'unit_of_measurement': '%', }) # --- -# name: test_vacuum_sensor_states[platforms0-vacuum_device.json][sensor.salt_level-state] +# name: test_vacuum_sensor_states[platforms0-vacuum_device.json][sensor.miele_test_salt_level-state] StateSnapshot({ 'attributes': ReadOnlyDict({ - : 'Salt level', + : 'Miele test Salt level', : '%', }), 'context': , - 'entity_id': 'sensor.salt_level', + 'entity_id': 'sensor.miele_test_salt_level', 'last_changed': , 'last_reported': , 'last_updated': , 'state': '75', }) # --- -# name: test_vacuum_sensor_states[platforms0-vacuum_device.json][sensor.twindos_1_level-entry] +# name: test_vacuum_sensor_states[platforms0-vacuum_device.json][sensor.miele_test_twindos_1_level-entry] EntityRegistryEntrySnapshot({ 'aliases': list([ None, @@ -16826,7 +16826,7 @@ 'disabled_by': None, 'domain': 'sensor', 'entity_category': , - 'entity_id': 'sensor.twindos_1_level', + 'entity_id': 'sensor.miele_test_twindos_1_level', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -16849,21 +16849,21 @@ 'unit_of_measurement': '%', }) # --- -# name: test_vacuum_sensor_states[platforms0-vacuum_device.json][sensor.twindos_1_level-state] +# name: test_vacuum_sensor_states[platforms0-vacuum_device.json][sensor.miele_test_twindos_1_level-state] StateSnapshot({ 'attributes': ReadOnlyDict({ - : 'TwinDos 1 level', + : 'Miele test TwinDos 1 level', : '%', }), 'context': , - 'entity_id': 'sensor.twindos_1_level', + 'entity_id': 'sensor.miele_test_twindos_1_level', 'last_changed': , 'last_reported': , 'last_updated': , 'state': '63', }) # --- -# name: test_vacuum_sensor_states[platforms0-vacuum_device.json][sensor.twindos_1_level_2-entry] +# name: test_vacuum_sensor_states[platforms0-vacuum_device.json][sensor.miele_test_twindos_1_level_2-entry] EntityRegistryEntrySnapshot({ 'aliases': list([ None, @@ -16877,7 +16877,7 @@ 'disabled_by': None, 'domain': 'sensor', 'entity_category': , - 'entity_id': 'sensor.twindos_1_level_2', + 'entity_id': 'sensor.miele_test_twindos_1_level_2', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -16900,21 +16900,21 @@ 'unit_of_measurement': '%', }) # --- -# name: test_vacuum_sensor_states[platforms0-vacuum_device.json][sensor.twindos_1_level_2-state] +# name: test_vacuum_sensor_states[platforms0-vacuum_device.json][sensor.miele_test_twindos_1_level_2-state] StateSnapshot({ 'attributes': ReadOnlyDict({ - : 'TwinDos 1 level', + : 'Miele test TwinDos 1 level', : '%', }), 'context': , - 'entity_id': 'sensor.twindos_1_level_2', + 'entity_id': 'sensor.miele_test_twindos_1_level_2', 'last_changed': , 'last_reported': , 'last_updated': , 'state': '63', }) # --- -# name: test_vacuum_sensor_states[platforms0-vacuum_device.json][sensor.twindos_2_level-entry] +# name: test_vacuum_sensor_states[platforms0-vacuum_device.json][sensor.miele_test_twindos_2_level-entry] EntityRegistryEntrySnapshot({ 'aliases': list([ None, @@ -16928,7 +16928,7 @@ 'disabled_by': None, 'domain': 'sensor', 'entity_category': , - 'entity_id': 'sensor.twindos_2_level', + 'entity_id': 'sensor.miele_test_twindos_2_level', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -16951,21 +16951,21 @@ 'unit_of_measurement': '%', }) # --- -# name: test_vacuum_sensor_states[platforms0-vacuum_device.json][sensor.twindos_2_level-state] +# name: test_vacuum_sensor_states[platforms0-vacuum_device.json][sensor.miele_test_twindos_2_level-state] StateSnapshot({ 'attributes': ReadOnlyDict({ - : 'TwinDos 2 level', + : 'Miele test TwinDos 2 level', : '%', }), 'context': , - 'entity_id': 'sensor.twindos_2_level', + 'entity_id': 'sensor.miele_test_twindos_2_level', 'last_changed': , 'last_reported': , 'last_updated': , 'state': '20', }) # --- -# name: test_vacuum_sensor_states[platforms0-vacuum_device.json][sensor.twindos_2_level_2-entry] +# name: test_vacuum_sensor_states[platforms0-vacuum_device.json][sensor.miele_test_twindos_2_level_2-entry] EntityRegistryEntrySnapshot({ 'aliases': list([ None, @@ -16979,7 +16979,7 @@ 'disabled_by': None, 'domain': 'sensor', 'entity_category': , - 'entity_id': 'sensor.twindos_2_level_2', + 'entity_id': 'sensor.miele_test_twindos_2_level_2', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -17002,14 +17002,14 @@ 'unit_of_measurement': '%', }) # --- -# name: test_vacuum_sensor_states[platforms0-vacuum_device.json][sensor.twindos_2_level_2-state] +# name: test_vacuum_sensor_states[platforms0-vacuum_device.json][sensor.miele_test_twindos_2_level_2-state] StateSnapshot({ 'attributes': ReadOnlyDict({ - : 'TwinDos 2 level', + : 'Miele test TwinDos 2 level', : '%', }), 'context': , - 'entity_id': 'sensor.twindos_2_level_2', + 'entity_id': 'sensor.miele_test_twindos_2_level_2', 'last_changed': , 'last_reported': , 'last_updated': , diff --git a/tests/components/mold_indicator/test_init.py b/tests/components/mold_indicator/test_init.py index e87fc42145b3..8d77547e7f26 100644 --- a/tests/components/mold_indicator/test_init.py +++ b/tests/components/mold_indicator/test_init.py @@ -196,9 +196,9 @@ async def test_unload_entry(hass: HomeAssistant, loaded_entry: MockConfigEntry) @pytest.mark.parametrize( ("source_entity_id", "expected_helper_device_id", "expected_events"), [ - ("sensor.test_unique_indoor_humidity", None, ["update"]), - ("sensor.test_unique_indoor_temperature", "humidity_device_id", []), - ("sensor.test_unique_outdoor_temperature", "humidity_device_id", []), + ("sensor.mock_title", None, ["update"]), + ("sensor.mock_title_2", "humidity_device_id", []), + ("sensor.mock_title_3", "humidity_device_id", []), ], indirect=["expected_helper_device_id"], ) @@ -218,7 +218,9 @@ async def test_async_handle_source_entity_changes_source_entity_removed( assert await hass.config_entries.async_setup(mold_indicator_config_entry.entry_id) await hass.async_block_till_done() - mold_indicator_entity_entry = entity_registry.async_get("sensor.my_mold_indicator") + mold_indicator_entity_entry = entity_registry.async_get( + "sensor.mock_title_my_mold_indicator" + ) assert ( mold_indicator_entity_entry.device_id == indoor_humidity_entity_entry.device_id ) @@ -239,7 +241,9 @@ async def test_async_handle_source_entity_changes_source_entity_removed( mock_unload_entry.assert_not_called() # Check that the helper entity is linked to the expected source device - mold_indicator_entity_entry = entity_registry.async_get("sensor.my_mold_indicator") + mold_indicator_entity_entry = entity_registry.async_get( + "sensor.mock_title_my_mold_indicator" + ) assert mold_indicator_entity_entry.device_id == expected_helper_device_id # Check that the device is removed @@ -255,9 +259,9 @@ async def test_async_handle_source_entity_changes_source_entity_removed( @pytest.mark.parametrize( ("source_entity_id", "expected_helper_device_id", "expected_events"), [ - ("sensor.test_unique_indoor_humidity", None, ["update"]), - ("sensor.test_unique_indoor_temperature", "humidity_device_id", []), - ("sensor.test_unique_outdoor_temperature", "humidity_device_id", []), + ("sensor.mock_title", None, ["update"]), + ("sensor.mock_title_2", "humidity_device_id", []), + ("sensor.mock_title_3", "humidity_device_id", []), ], indirect=["expected_helper_device_id"], ) @@ -277,7 +281,9 @@ async def test_async_handle_source_entity_changes_source_entity_removed_shared_d assert await hass.config_entries.async_setup(mold_indicator_config_entry.entry_id) await hass.async_block_till_done() - mold_indicator_entity_entry = entity_registry.async_get("sensor.my_mold_indicator") + mold_indicator_entity_entry = entity_registry.async_get( + "sensor.mock_title_my_mold_indicator" + ) assert ( mold_indicator_entity_entry.device_id == indoor_humidity_entity_entry.device_id ) @@ -298,7 +304,9 @@ async def test_async_handle_source_entity_changes_source_entity_removed_shared_d mock_unload_entry.assert_not_called() # Check that the helper entity is linked to the expected source device - mold_indicator_entity_entry = entity_registry.async_get("sensor.my_mold_indicator") + mold_indicator_entity_entry = entity_registry.async_get( + "sensor.mock_title_my_mold_indicator" + ) assert mold_indicator_entity_entry.device_id == expected_helper_device_id # Check that the source device is not removed @@ -323,9 +331,9 @@ async def test_async_handle_source_entity_changes_source_entity_removed_shared_d "expected_events", ), [ - ("sensor.test_unique_indoor_humidity", 1, None, ["update"]), - ("sensor.test_unique_indoor_temperature", 0, "humidity_device_id", []), - ("sensor.test_unique_outdoor_temperature", 0, "humidity_device_id", []), + ("sensor.mock_title", 1, None, ["update"]), + ("sensor.mock_title_2", 0, "humidity_device_id", []), + ("sensor.mock_title_3", 0, "humidity_device_id", []), ], indirect=["expected_helper_device_id"], ) @@ -346,7 +354,9 @@ async def test_async_handle_source_entity_changes_source_entity_removed_from_dev assert await hass.config_entries.async_setup(mold_indicator_config_entry.entry_id) await hass.async_block_till_done() - mold_indicator_entity_entry = entity_registry.async_get("sensor.my_mold_indicator") + mold_indicator_entity_entry = entity_registry.async_get( + "sensor.mock_title_my_mold_indicator" + ) assert ( mold_indicator_entity_entry.device_id == indoor_humidity_entity_entry.device_id ) @@ -368,7 +378,9 @@ async def test_async_handle_source_entity_changes_source_entity_removed_from_dev assert len(mock_unload_entry.mock_calls) == unload_entry_calls # Check that the helper entity is linked to the expected source device - mold_indicator_entity_entry = entity_registry.async_get("sensor.my_mold_indicator") + mold_indicator_entity_entry = entity_registry.async_get( + "sensor.mock_title_my_mold_indicator" + ) assert mold_indicator_entity_entry.device_id == expected_helper_device_id # Check that the mold_indicator config entry is not in the device @@ -385,9 +397,9 @@ async def test_async_handle_source_entity_changes_source_entity_removed_from_dev @pytest.mark.parametrize( ("source_entity_id", "unload_entry_calls", "expected_events"), [ - ("sensor.test_unique_indoor_humidity", 1, ["update"]), - ("sensor.test_unique_indoor_temperature", 0, []), - ("sensor.test_unique_outdoor_temperature", 0, []), + ("sensor.mock_title", 1, ["update"]), + ("sensor.mock_title_2", 0, []), + ("sensor.mock_title_3", 0, []), ], ) async def test_async_handle_source_entity_changes_source_entity_moved_other_device( @@ -411,7 +423,9 @@ async def test_async_handle_source_entity_changes_source_entity_moved_other_devi assert await hass.config_entries.async_setup(mold_indicator_config_entry.entry_id) await hass.async_block_till_done() - mold_indicator_entity_entry = entity_registry.async_get("sensor.my_mold_indicator") + mold_indicator_entity_entry = entity_registry.async_get( + "sensor.mock_title_my_mold_indicator" + ) assert ( mold_indicator_entity_entry.device_id == indoor_humidity_entity_entry.device_id ) @@ -438,7 +452,9 @@ async def test_async_handle_source_entity_changes_source_entity_moved_other_devi indoor_humidity_entity_entry = entity_registry.async_get( indoor_humidity_entity_entry.entity_id ) - mold_indicator_entity_entry = entity_registry.async_get("sensor.my_mold_indicator") + mold_indicator_entity_entry = entity_registry.async_get( + "sensor.mock_title_my_mold_indicator" + ) assert ( mold_indicator_entity_entry.device_id == indoor_humidity_entity_entry.device_id ) @@ -459,9 +475,9 @@ async def test_async_handle_source_entity_changes_source_entity_moved_other_devi @pytest.mark.parametrize( ("source_entity_id", "config_key"), [ - ("sensor.test_unique_indoor_humidity", CONF_INDOOR_HUMIDITY), - ("sensor.test_unique_indoor_temperature", CONF_INDOOR_TEMP), - ("sensor.test_unique_outdoor_temperature", CONF_OUTDOOR_TEMP), + ("sensor.mock_title", CONF_INDOOR_HUMIDITY), + ("sensor.mock_title_2", CONF_INDOOR_TEMP), + ("sensor.mock_title_3", CONF_OUTDOOR_TEMP), ], ) async def test_async_handle_source_entity_new_entity_id( @@ -479,7 +495,9 @@ async def test_async_handle_source_entity_new_entity_id( assert await hass.config_entries.async_setup(mold_indicator_config_entry.entry_id) await hass.async_block_till_done() - mold_indicator_entity_entry = entity_registry.async_get("sensor.my_mold_indicator") + mold_indicator_entity_entry = entity_registry.async_get( + "sensor.mock_title_my_mold_indicator" + ) assert ( mold_indicator_entity_entry.device_id == indoor_humidity_entity_entry.device_id ) @@ -550,7 +568,9 @@ async def test_migration_1_1( # is linked to the source device source_device = device_registry.async_get(indoor_humidity_device.id) assert mold_indicator_config_entry.entry_id not in source_device.config_entries - mold_indicator_entity_entry = entity_registry.async_get("sensor.my_mold_indicator") + mold_indicator_entity_entry = entity_registry.async_get( + "sensor.mock_title_my_mold_indicator" + ) assert ( mold_indicator_entity_entry.device_id == indoor_humidity_entity_entry.device_id ) diff --git a/tests/components/monzo/conftest.py b/tests/components/monzo/conftest.py index 1d23206f58c4..db12e5d392d5 100644 --- a/tests/components/monzo/conftest.py +++ b/tests/components/monzo/conftest.py @@ -27,13 +27,23 @@ TEST_ACCOUNTS = [ "id": "acc_curr", "name": "Current Account", "type": "uk_retail", - "balance": {"balance": 123, "total_balance": 321, "currency": "GBP"}, + "balance": { + "balance": 123, + "total_balance": 321, + "spend_today": -45, + "currency": "GBP", + }, }, { "id": "acc_flex", "name": "Flex", "type": "uk_monzo_flex", - "balance": {"balance": 123, "total_balance": 321, "currency": "EUR"}, + "balance": { + "balance": 123, + "total_balance": 321, + "spend_today": -67, + "currency": "EUR", + }, }, ] TEST_POTS = [ diff --git a/tests/components/monzo/snapshots/test_sensor.ambr b/tests/components/monzo/snapshots/test_sensor.ambr index c7e686ec1586..25aab02720f1 100644 --- a/tests/components/monzo/snapshots/test_sensor.ambr +++ b/tests/components/monzo/snapshots/test_sensor.ambr @@ -55,6 +55,62 @@ 'state': '1.23', }) # --- +# name: test_all_entities[sensor.current_account_spent_today-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.current_account_spent_today', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Spent today', + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Spent today', + 'platform': 'monzo', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'spend_today', + 'unique_id': 'acc_curr_spend_today', + 'unit_of_measurement': 'GBP', + }) +# --- +# name: test_all_entities[sensor.current_account_spent_today-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'Data provided by Monzo', + : 'monetary', + : 'Current Account Spent today', + : 'GBP', + }), + 'context': , + 'entity_id': 'sensor.current_account_spent_today', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.45', + }) +# --- # name: test_all_entities[sensor.current_account_total_balance-entry] EntityRegistryEntrySnapshot({ 'aliases': list([ @@ -167,6 +223,62 @@ 'state': '1.23', }) # --- +# name: test_all_entities[sensor.flex_spent_today-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.flex_spent_today', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Spent today', + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Spent today', + 'platform': 'monzo', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'spend_today', + 'unique_id': 'acc_flex_spend_today', + 'unit_of_measurement': 'EUR', + }) +# --- +# name: test_all_entities[sensor.flex_spent_today-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'Data provided by Monzo', + : 'monetary', + : 'Flex Spent today', + : 'EUR', + }), + 'context': , + 'entity_id': 'sensor.flex_spent_today', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.67', + }) +# --- # name: test_all_entities[sensor.flex_total_balance-entry] EntityRegistryEntrySnapshot({ 'aliases': list([ diff --git a/tests/components/monzo/test_sensor.py b/tests/components/monzo/test_sensor.py index 2699685bba7d..d1204f011d57 100644 --- a/tests/components/monzo/test_sensor.py +++ b/tests/components/monzo/test_sensor.py @@ -29,6 +29,7 @@ from tests.typing import ClientSessionGenerator EXPECTED_VALUE_GETTERS = { "balance": lambda x: x["balance"]["balance"] / 100, "total_balance": lambda x: x["balance"]["total_balance"] / 100, + "spend_today": lambda x: abs(x["balance"]["spend_today"]) / 100, "pot_balance": lambda x: x["balance"] / 100, } diff --git a/tests/components/motion_blinds/test_init.py b/tests/components/motion_blinds/test_init.py index 8a49ad305f5d..03052507f175 100644 --- a/tests/components/motion_blinds/test_init.py +++ b/tests/components/motion_blinds/test_init.py @@ -51,6 +51,9 @@ def mock_gateway_fixture() -> Mock: def mock_connect_fixture(mock_gateway: Mock) -> Generator[None]: """Mock the connection to the Motion gateway.""" with ( + patch( + "homeassistant.components.motion_blinds.coordinator.UPDATE_DELAY_BLIND", 0 + ), patch( "homeassistant.components.motion_blinds.AsyncMotionMulticast" ) as multicast_class, diff --git a/tests/components/mqtt/test_device_tracker.py b/tests/components/mqtt/test_device_tracker.py index 82afd5411bb6..b07d86eaeae9 100644 --- a/tests/components/mqtt/test_device_tracker.py +++ b/tests/components/mqtt/test_device_tracker.py @@ -272,10 +272,10 @@ async def test_cleanup_device_tracker( ("mqtt", "0AFFD2"), mqtt_config_entry.entry_id ) assert device_entry is not None - entity_entry = entity_registry.async_get("device_tracker.mqtt_unique") + entity_entry = entity_registry.async_get("device_tracker.mqtt") assert entity_entry is not None - state = hass.states.get("device_tracker.mqtt_unique") + state = hass.states.get("device_tracker.mqtt") assert state is not None # Remove MQTT from the device @@ -289,11 +289,11 @@ async def test_cleanup_device_tracker( ("mqtt", "0AFFD2"), mqtt_config_entry.entry_id ) assert device_entry is None - entity_entry = entity_registry.async_get("device_tracker.mqtt_unique") + entity_entry = entity_registry.async_get("device_tracker.mqtt") assert entity_entry is None # Verify state is removed - state = hass.states.get("device_tracker.mqtt_unique") + state = hass.states.get("device_tracker.mqtt") assert state is None await hass.async_block_till_done() diff --git a/tests/components/mqtt/test_diagnostics.py b/tests/components/mqtt/test_diagnostics.py index bb6319b27a63..5bc5048cf6fa 100644 --- a/tests/components/mqtt/test_diagnostics.py +++ b/tests/components/mqtt/test_diagnostics.py @@ -72,7 +72,7 @@ async def test_entry_diagnostics( expected_debug_info = { "entities": [ { - "entity_id": "sensor.mqtt_sensor", + "entity_id": "sensor.mqtt_mqtt_sensor", "subscriptions": [{"topic": "foobar/sensor", "messages": []}], "discovery_data": { "payload": config_sensor, @@ -101,13 +101,13 @@ async def test_entry_diagnostics( "disabled": False, "disabled_by": None, "entity_category": None, - "entity_id": "sensor.mqtt_sensor", + "entity_id": "sensor.mqtt_mqtt_sensor", "icon": None, "original_device_class": None, "original_icon": None, "state": { - "attributes": {"friendly_name": "MQTT Sensor"}, - "entity_id": "sensor.mqtt_sensor", + "attributes": {"friendly_name": "MQTT MQTT Sensor"}, + "entity_id": "sensor.mqtt_mqtt_sensor", "last_changed": ANY, "last_reported": ANY, "last_updated": ANY, @@ -117,7 +117,7 @@ async def test_entry_diagnostics( } ], "id": device_entry.id, - "name": None, + "name": "MQTT", "name_by_user": None, } @@ -199,7 +199,7 @@ async def test_redact_diagnostics( expected_debug_info = { "entities": [ { - "entity_id": "device_tracker.mqtt_unique", + "entity_id": "device_tracker.mqtt", "subscriptions": [ { "topic": "attributes-topic", @@ -234,12 +234,13 @@ async def test_redact_diagnostics( "disabled": False, "disabled_by": None, "entity_category": None, - "entity_id": "device_tracker.mqtt_unique", + "entity_id": "device_tracker.mqtt", "icon": None, "original_device_class": None, "original_icon": None, "state": { "attributes": { + "friendly_name": "MQTT", "gps_accuracy": 1.5, "in_zones": ["zone.home"], "latitude": "**REDACTED**", @@ -247,7 +248,7 @@ async def test_redact_diagnostics( "source_type": "gps", "tracking_type": "position", }, - "entity_id": "device_tracker.mqtt_unique", + "entity_id": "device_tracker.mqtt", "last_changed": ANY, "last_reported": ANY, "last_updated": ANY, @@ -257,7 +258,7 @@ async def test_redact_diagnostics( } ], "id": device_entry.id, - "name": None, + "name": "MQTT", "name_by_user": None, } @@ -294,7 +295,7 @@ async def test_redact_diagnostics( "connected": True, "device": { "id": device_entry.id, - "name": None, + "name": "MQTT", "name_by_user": None, "disabled": False, "disabled_by": None, diff --git a/tests/components/mqtt/test_discovery.py b/tests/components/mqtt/test_discovery.py index 73621ab549f0..53b91cd24adb 100644 --- a/tests/components/mqtt/test_discovery.py +++ b/tests/components/mqtt/test_discovery.py @@ -84,7 +84,9 @@ def _get_device_for_config_entry( connections: set[tuple[str, str]] | None = None, ) -> dr.DeviceEntry | None: """Return the device for a config entry matching identifiers or connections.""" - for device in device_registry.devices.get_entries(identifiers, connections): + for device in device_registry.async_get_devices( + identifiers=identifiers, connections=connections + ): if device.config_entry_id == config_entry_id: return device return None @@ -1197,9 +1199,9 @@ async def test_discovery_component_availability_overridden( payload, ) await hass.async_block_till_done() - state = hass.states.get("binary_sensor.beer") + state = hass.states.get("binary_sensor.mqtt_beer") assert state is not None - assert state.name == "Beer" + assert state.name == "MQTT Beer" assert state.state == STATE_UNAVAILABLE async_fire_mqtt_message( @@ -1208,7 +1210,7 @@ async def test_discovery_component_availability_overridden( "online", ) await hass.async_block_till_done() - state = hass.states.get("binary_sensor.beer") + state = hass.states.get("binary_sensor.mqtt_beer") assert state is not None assert state.state == STATE_UNAVAILABLE @@ -1218,7 +1220,7 @@ async def test_discovery_component_availability_overridden( "online", ) await hass.async_block_till_done() - state = hass.states.get("binary_sensor.beer") + state = hass.states.get("binary_sensor.mqtt_beer") assert state is not None assert state.state == STATE_UNKNOWN @@ -1228,7 +1230,7 @@ async def test_discovery_component_availability_overridden( "ON", ) await hass.async_block_till_done() - state = hass.states.get("binary_sensor.beer") + state = hass.states.get("binary_sensor.mqtt_beer") assert state is not None assert state.state == STATE_ON @@ -1740,7 +1742,7 @@ async def test_duplicate_removal( '"name": "sensor2"' "}", }, - ["sensor.sensor1", "sensor.sensor2"], + ["sensor.mqtt_sensor1", "sensor.mqtt_sensor2"], ), ( { @@ -1759,7 +1761,7 @@ async def test_duplicate_removal( '"unique_id": "unique2"' "}}}" }, - ["sensor.sensor1", "sensor.sensor2"], + ["sensor.mqtt_sensor1", "sensor.mqtt_sensor2"], ), ], ) @@ -1835,7 +1837,7 @@ async def test_cleanup_device_manual( '{ "device":{"identifiers":["0AFFD2"]},' ' "state_topic": "foobar/sensor",' ' "unique_id": "unique" }', - ["sensor.mqtt_sensor"], + ["sensor.mqtt_mqtt_sensor"], ), ( "homeassistant/device/bla/config", @@ -1852,7 +1854,7 @@ async def test_cleanup_device_manual( ' "state_topic": "foobar/sensor2",' ' "unique_id": "unique2"' "}}}", - ["sensor.sensor1", "sensor.sensor2"], + ["sensor.mqtt_sensor1", "sensor.mqtt_sensor2"], ), ], ) @@ -1876,7 +1878,7 @@ async def test_cleanup_device_mqtt( ' "unique_id": "unique_base" }' ) base_discovery_topic = "homeassistant/sensor/bla_base/config" - base_entity_id = "sensor.sensor_base" + base_entity_id = "sensor.mqtt_sensor_base" async_fire_mqtt_message(hass, base_discovery_topic, data) await hass.async_block_till_done() @@ -1964,7 +1966,7 @@ async def test_cleanup_device_mqtt_device_discovery( ' "unique_id": "unique2"' "}}}" ) - entity_ids = ["sensor.sensor1", "sensor.sensor2"] + entity_ids = ["sensor.mqtt_sensor1", "sensor.mqtt_sensor2"] async_fire_mqtt_message(hass, discovery_topic, discovery_payload) await hass.async_block_till_done() @@ -2115,10 +2117,10 @@ async def test_cleanup_device_multiple_config_entries( ) is not None ) - entity_entry = entity_registry.async_get("sensor.mqtt_sensor") + entity_entry = entity_registry.async_get("sensor.mqtt_mqtt_sensor") assert entity_entry is not None - state = hass.states.get("sensor.mqtt_sensor") + state = hass.states.get("sensor.mqtt_mqtt_sensor") assert state is not None # Remove MQTT from the device @@ -2134,12 +2136,12 @@ async def test_cleanup_device_multiple_config_entries( ("mac", "12:34:56:AB:CD:EF"), config_entry.entry_id ) assert device_entry is not None - entity_entry = entity_registry.async_get("sensor.mqtt_sensor") + entity_entry = entity_registry.async_get("sensor.mqtt_mqtt_sensor") assert device_entry.config_entries == {config_entry.entry_id} assert entity_entry is None # Verify state is removed - state = hass.states.get("sensor.mqtt_sensor") + state = hass.states.get("sensor.mqtt_mqtt_sensor") assert state is None await hass.async_block_till_done() @@ -2240,10 +2242,10 @@ async def test_cleanup_device_multiple_config_entries_mqtt( ) is not None ) - entity_entry = entity_registry.async_get("sensor.mqtt_sensor") + entity_entry = entity_registry.async_get("sensor.mqtt_mqtt_sensor") assert entity_entry is not None - state = hass.states.get("sensor.mqtt_sensor") + state = hass.states.get("sensor.mqtt_mqtt_sensor") assert state is not None # Send MQTT messages to remove @@ -2259,12 +2261,12 @@ async def test_cleanup_device_multiple_config_entries_mqtt( ("mac", "12:34:56:AB:CD:EF"), config_entry.entry_id ) assert device_entry is not None - entity_entry = entity_registry.async_get("sensor.mqtt_sensor") + entity_entry = entity_registry.async_get("sensor.mqtt_mqtt_sensor") assert device_entry.config_entries == {config_entry.entry_id} assert entity_entry is None # Verify state is removed - state = hass.states.get("sensor.mqtt_sensor") + state = hass.states.get("sensor.mqtt_mqtt_sensor") assert state is None await hass.async_block_till_done() @@ -3193,7 +3195,7 @@ async def test_discovery_dispatcher_signal_type_messages( ' "state_topic": "foobar/sensor3",' ' "unique_id": "unique3"' "}}}", - ["sensor.sensor1", "sensor.sensor2", "sensor.sensor3"], + ["sensor.mqtt_sensor1", "sensor.mqtt_sensor2", "sensor.mqtt_sensor3"], ), ], ) @@ -3280,7 +3282,7 @@ async def test_discovery_with_late_via_device_discovery( hass.config_entries.async_entries("mqtt")[0].entry_id, ) assert via_device_entry is not None - assert via_device_entry.name is None + assert via_device_entry.name == "MQTT" await hass.async_block_till_done() @@ -3375,7 +3377,7 @@ async def test_discovery_with_late_via_device_update( hass.config_entries.async_entries("mqtt")[0].entry_id, ) assert via_device_entry is not None - assert via_device_entry.name is None + assert via_device_entry.name == "MQTT" await hass.async_block_till_done() await hass.async_block_till_done() diff --git a/tests/components/mqtt/test_init.py b/tests/components/mqtt/test_init.py index 4d202d0f8a58..804b433f7264 100644 --- a/tests/components/mqtt/test_init.py +++ b/tests/components/mqtt/test_init.py @@ -1197,7 +1197,7 @@ async def test_mqtt_ws_get_device_debug_info( expected_result = { "entities": [ { - "entity_id": "sensor.mqtt_sensor", + "entity_id": "sensor.mqtt_mqtt_sensor", "subscriptions": [{"topic": "foobar/sensor", "messages": []}], "discovery_data": { "payload": config_sensor, @@ -1260,7 +1260,7 @@ async def test_mqtt_ws_get_device_debug_info_binary( expected_result = { "entities": [ { - "entity_id": "camera.mqtt_camera", + "entity_id": "camera.mqtt_mqtt_camera", "subscriptions": [ { "topic": "foobar/image", diff --git a/tests/components/mqtt/test_mixins.py b/tests/components/mqtt/test_mixins.py index 93bb1017f785..360837a69e58 100644 --- a/tests/components/mqtt/test_mixins.py +++ b/tests/components/mqtt/test_mixins.py @@ -113,9 +113,9 @@ async def test_availability_with_shared_state_topic( } } }, - "sensor.mqtt_sensor", - DEFAULT_SENSOR_NAME, - None, + "sensor.mock_title_mqtt_sensor", + f"Mock Title {DEFAULT_SENSOR_NAME}", + "Mock Title", True, ), ( # default_entity_name_with_device_name @@ -160,9 +160,9 @@ async def test_availability_with_shared_state_topic( } } }, - "sensor.humidity", - "Humidity", - None, + "sensor.mock_title_humidity", + "Mock Title Humidity", + "Mock Title", True, ), ( # name_overrides_device_class @@ -194,9 +194,9 @@ async def test_availability_with_shared_state_topic( } } }, - "sensor.mysensor", - "MySensor", - None, + "sensor.mock_title_mysensor", + "Mock Title MySensor", + "Mock Title", True, ), ( # none_entity_name_with_device_name @@ -228,9 +228,9 @@ async def test_availability_with_shared_state_topic( } } }, - "sensor.mqtt_veryunique", - "mqtt veryunique", - None, + "sensor.mock_title", + "Mock Title", + "Mock Title", True, ), ( # entity_name_and_device_name_the_same diff --git a/tests/components/mqtt/test_tag.py b/tests/components/mqtt/test_tag.py index 9ca2228c2d7a..06b48ab2360b 100644 --- a/tests/components/mqtt/test_tag.py +++ b/tests/components/mqtt/test_tag.py @@ -54,7 +54,9 @@ def _get_device_for_config_entry( connections: set[tuple[str, str]] | None = None, ) -> dr.DeviceEntry | None: """Return the device for a config entry matching identifiers or connections.""" - for device in device_registry.devices.get_entries(identifiers, connections): + for device in device_registry.async_get_devices( + identifiers=identifiers, connections=connections + ): if device.config_entry_id == config_entry_id: return device return None diff --git a/tests/components/music_assistant/common.py b/tests/components/music_assistant/common.py index 3cd5c2b57537..3a648f22637d 100644 --- a/tests/components/music_assistant/common.py +++ b/tests/components/music_assistant/common.py @@ -5,7 +5,6 @@ from typing import Any from unittest.mock import AsyncMock, MagicMock from music_assistant_models.api import MassEvent -from music_assistant_models.auth import User from music_assistant_models.enums import EventType from music_assistant_models.media_items import ( Album, @@ -75,9 +74,6 @@ async def setup_integration_from_fixtures( music.get_library_podcasts = AsyncMock(return_value=library_podcasts) music.get_item_by_uri = AsyncMock() - users = create_users_from_fixture() - music_assistant_client.auth.list_users = AsyncMock(return_value=users) - config_entry.add_to_hass(hass) assert await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() @@ -158,12 +154,6 @@ def create_library_podcasts_from_fixture() -> list[Podcast]: return [Podcast.from_dict(radio_data) for radio_data in fixture_data] -def create_users_from_fixture() -> list[User]: - """Create MA Users from fixture.""" - fixture_data = load_and_parse_fixture("users") - return [User.from_dict(user_data) for user_data in fixture_data] - - async def trigger_subscription_callback( hass: HomeAssistant, client: MagicMock, diff --git a/tests/components/music_assistant/fixtures/players.json b/tests/components/music_assistant/fixtures/players.json index 2ddf5a718553..70a8b18d354e 100644 --- a/tests/components/music_assistant/fixtures/players.json +++ b/tests/components/music_assistant/fixtures/players.json @@ -374,16 +374,19 @@ "active_source": "test_group_player_1", "active_group": null, "current_media": { - "uri": "http://192.168.1.1:8097/single/test_group_player_1/5d95dc5be77e4f7eb4939f62cfef527b.flac?ts=1730313038", - "media_type": "unknown", - "title": null, - "artist": null, - "album": null, + "uri": "spotify://track/3YRCqOhFifThpSRFJ1VWFM", + "media_type": "track", + "title": "November Rain", + "artist": "Guns N' Roses", + "album": "Use Your Illusion I", + "album_artist": "Guns N' Roses", "image_url": null, - "duration": null, + "duration": 536, "queue_id": "test_group_player_1", "queue_item_id": "5d95dc5be77e4f7eb4939f62cfef527b", - "custom_data": null + "custom_data": null, + "elapsed_time": 232, + "elapsed_time_last_updated": 1730313109.5659513 }, "synced_to": null, "enabled_by_default": true, diff --git a/tests/components/music_assistant/fixtures/users.json b/tests/components/music_assistant/fixtures/users.json deleted file mode 100644 index 59e2fab175cb..000000000000 --- a/tests/components/music_assistant/fixtures/users.json +++ /dev/null @@ -1,52 +0,0 @@ -{ - "users": [ - { - "user_id": "1jKp69KqXH3HlkOLUNgnrvYL_NjwxqtsPhCuVe6Mnpc", - "username": "party_guest", - "role": "guest", - "enabled": true, - "created_at": "2026-03-25T19:42:38.316796+00:00", - "display_name": "Party Guest", - "avatar_url": null, - "preferences": {}, - "provider_filter": [], - "player_filter": [] - }, - { - "user_id": "W4SJjiCfzHeAHPjzy1IsXkdnIJHkzhLXu9Vp-V9u730", - "username": "user_admin", - "role": "admin", - "enabled": true, - "created_at": "2026-05-04T19:29:13.853819+00:00", - "display_name": "Admin", - "avatar_url": null, - "preferences": {}, - "provider_filter": [], - "player_filter": [] - }, - { - "user_id": "aTn3wTtZi-Lznf_WxQQn4NKElrfpa00LV8ZDzIIF_uU", - "username": "user_user", - "role": "user", - "enabled": true, - "created_at": "2026-05-04T19:29:13.853819+00:00", - "display_name": "User", - "avatar_url": null, - "preferences": {}, - "provider_filter": [], - "player_filter": [] - }, - { - "user_id": "bZc3wTtZi-Lznf_WxQRn4NRElrfra00LV9ZDzIIF_uU", - "username": "user_disabled", - "role": "user", - "enabled": false, - "created_at": "2026-05-03T19:29:13.853819+00:00", - "display_name": "Disabled user", - "avatar_url": null, - "preferences": {}, - "provider_filter": [], - "player_filter": [] - } - ] -} diff --git a/tests/components/music_assistant/snapshots/test_media_player.ambr b/tests/components/music_assistant/snapshots/test_media_player.ambr index 95810a6612e0..b6c985dd8c38 100644 --- a/tests/components/music_assistant/snapshots/test_media_player.ambr +++ b/tests/components/music_assistant/snapshots/test_media_player.ambr @@ -55,7 +55,6 @@ : 'spotify://track/5d95dc5be77e4f7eb4939f62cfef527b', : , : 300, - : 0, : 'Test Track', : 'Spotify Connect', : , @@ -122,6 +121,7 @@ : 'mdi:speaker-multiple', : False, 'mass_player_type': 'group', + : "Guns N' Roses", : 'Use Your Illusion I', : "Guns N' Roses", : 'spotify://track/3YRCqOhFifThpSRFJ1VWFM', diff --git a/tests/components/music_assistant/test_media_player.py b/tests/components/music_assistant/test_media_player.py index 00225be7b2ff..5de1b9dad5cc 100644 --- a/tests/components/music_assistant/test_media_player.py +++ b/tests/components/music_assistant/test_media_player.py @@ -9,13 +9,13 @@ from music_assistant_models.enums import ( PlayerFeature, QueueOption, ) +from music_assistant_models.errors import UserNotFoundError from music_assistant_models.media_items import Track from music_assistant_models.player import PlayerMedia import pytest from syrupy.assertion import SnapshotAssertion from syrupy.filters import paths -from homeassistant.auth.models import Credentials from homeassistant.components.media_player import ( ATTR_GROUP_MEMBERS, ATTR_INPUT_SOURCE, @@ -523,7 +523,7 @@ async def test_media_player_play_media_action_legacy( artist="artist", album="album", media_type=None, - username=None, + user=None, ) assert music_assistant_client.send_command.call_count == 1 assert music_assistant_client.send_command.call_args == call( @@ -562,48 +562,6 @@ async def test_media_player_play_media_action_legacy( sort_by=None, username="user_user", ) - # invalid username - music_assistant_client.send_command.reset_mock() - with pytest.raises(ServiceValidationError): - await hass.services.async_call( - DOMAIN, - SERVICE_PLAY_MEDIA_ADVANCED, - { - ATTR_ENTITY_ID: entity_id, - ATTR_MEDIA_ID: "spotify://track/1234", - ATTR_MEDIA_ENQUEUE: "add", - ATTR_USERNAME: "non_existing_username", - }, - blocking=True, - ) - # disabled username - music_assistant_client.send_command.reset_mock() - with pytest.raises(ServiceValidationError): - await hass.services.async_call( - DOMAIN, - SERVICE_PLAY_MEDIA_ADVANCED, - { - ATTR_ENTITY_ID: entity_id, - ATTR_MEDIA_ID: "spotify://track/1234", - ATTR_MEDIA_ENQUEUE: "add", - ATTR_USERNAME: "user_disabled", - }, - blocking=True, - ) - # guest username - music_assistant_client.send_command.reset_mock() - with pytest.raises(ServiceValidationError): - await hass.services.async_call( - DOMAIN, - SERVICE_PLAY_MEDIA_ADVANCED, - { - ATTR_ENTITY_ID: entity_id, - ATTR_MEDIA_ID: "spotify://track/1234", - ATTR_MEDIA_ENQUEUE: "add", - ATTR_USERNAME: "party_guest", - }, - blocking=True, - ) async def test_media_player_play_media_action( @@ -744,7 +702,7 @@ async def test_media_player_play_media_action( artist="artist", album="album", media_type=None, - username=None, + user=None, ) assert music_assistant_client.send_command.call_count == 1 assert music_assistant_client.send_command.call_args == call( @@ -784,101 +742,25 @@ async def test_media_player_play_media_action( sort_by=None, username="user_user", ) - # invalid username - music_assistant_client.send_command.reset_mock() - with pytest.raises(ServiceValidationError): - await hass.services.async_call( - DOMAIN, - SERVICE_PLAY_MEDIA_ADVANCED, - { - ATTR_ENTITY_ID: entity_id, - ATTR_MEDIA_ID: "spotify://track/1234", - ATTR_MEDIA_ENQUEUE: "add", - ATTR_USERNAME: "non_existing_username", - }, - blocking=True, - ) - # disabled username - music_assistant_client.send_command.reset_mock() - with pytest.raises(ServiceValidationError): - await hass.services.async_call( - DOMAIN, - SERVICE_PLAY_MEDIA_ADVANCED, - { - ATTR_ENTITY_ID: entity_id, - ATTR_MEDIA_ID: "spotify://track/1234", - ATTR_MEDIA_ENQUEUE: "add", - ATTR_USERNAME: "user_disabled", - }, - blocking=True, - ) - # guest username - music_assistant_client.send_command.reset_mock() - with pytest.raises(ServiceValidationError): - await hass.services.async_call( - DOMAIN, - SERVICE_PLAY_MEDIA_ADVANCED, - { - ATTR_ENTITY_ID: entity_id, - ATTR_MEDIA_ID: "spotify://track/1234", - ATTR_MEDIA_ENQUEUE: "add", - ATTR_USERNAME: "party_guest", - }, - blocking=True, - ) -async def _add_ha_user(hass: HomeAssistant, login_username: str | None) -> MockUser: - """Add a Home Assistant user, optionally with a local login username.""" - user = MockUser(is_owner=True).add_to_hass(hass) - if login_username is not None: - await hass.auth.async_link_user( - user, - Credentials( - auth_provider_type="homeassistant", - auth_provider_id=None, - data={"username": login_username}, - is_new=False, - ), - ) - return user - - -@pytest.mark.parametrize( - ("login_username", "expected_username"), - [ - ("user_user", "user_user"), - ("USER_USER", "user_user"), - ("user_admin", "user_admin"), - ("does_not_exist", None), - ("user_disabled", None), - ("party_guest", None), - (None, None), - ], - ids=[ - "match", - "match_case_insensitive", - "match_admin", - "no_match", - "disabled_user", - "guest_user", - "no_login_username", - ], -) -async def test_media_player_play_media_default_username( +async def test_media_player_play_media_default_user( hass: HomeAssistant, music_assistant_client: MagicMock, - login_username: str | None, - expected_username: str | None, ) -> None: - """Test that play media defaults to the username of the calling user.""" - music_assistant_client.server_info.schema_version = 33 + """Test that play media defaults to the calling Home Assistant user. + + The calling user is forwarded as a soft (required=False) provider-link user + reference; the server resolves it to a Music Assistant user by provider link + (or plays as the default account). + """ + music_assistant_client.server_info.schema_version = 44 music_assistant_client.music.verify_item_uri = AsyncMock(return_value=True) await setup_integration_from_fixtures(hass, music_assistant_client) entity_id = "media_player.test_player_1" mass_player_id = "00:00:00:00:00:01" - user = await _add_ha_user(hass, login_username) + user = MockUser(is_owner=True).add_to_hass(hass) await hass.services.async_call( DOMAIN, SERVICE_PLAY_MEDIA_ADVANCED, @@ -896,23 +778,58 @@ async def test_media_player_play_media_default_username( option=None, radio_mode=False, start_item=None, - username=expected_username, sort_by=None, + user={"provider": "homeassistant", "user_id": user.id, "required": False}, ) -async def test_media_player_play_media_default_username_explicit_override( +async def test_media_player_play_media_default_user_older_server( hass: HomeAssistant, music_assistant_client: MagicMock, ) -> None: - """Test that an explicit username takes precedence over the calling user.""" - music_assistant_client.server_info.schema_version = 33 + """Test that older servers (no provider-link support) simply do not impersonate.""" + # the provider-link user reference is only sent to schema >= 44 servers; being + # soft (required=False), it gracefully degrades to no impersonation at all + music_assistant_client.server_info.schema_version = 35 music_assistant_client.music.verify_item_uri = AsyncMock(return_value=True) await setup_integration_from_fixtures(hass, music_assistant_client) entity_id = "media_player.test_player_1" mass_player_id = "00:00:00:00:00:01" - user = await _add_ha_user(hass, "user_user") + user = MockUser(is_owner=True).add_to_hass(hass) + await hass.services.async_call( + DOMAIN, + SERVICE_PLAY_MEDIA_ADVANCED, + { + ATTR_ENTITY_ID: entity_id, + ATTR_MEDIA_ID: "spotify://track/1234", + }, + blocking=True, + context=Context(user_id=user.id), + ) + assert music_assistant_client.send_command.call_args == call( + "player_queues/play_media", + queue_id=mass_player_id, + media=["spotify://track/1234"], + option=None, + radio_mode=False, + start_item=None, + sort_by=None, + ) + + +async def test_media_player_play_media_explicit_user_override( + hass: HomeAssistant, + music_assistant_client: MagicMock, +) -> None: + """Test that an explicit username takes precedence over the calling user.""" + music_assistant_client.server_info.schema_version = 44 + music_assistant_client.music.verify_item_uri = AsyncMock(return_value=True) + await setup_integration_from_fixtures(hass, music_assistant_client) + entity_id = "media_player.test_player_1" + mass_player_id = "00:00:00:00:00:01" + + user = MockUser(is_owner=True).add_to_hass(hass) await hass.services.async_call( DOMAIN, SERVICE_PLAY_MEDIA_ADVANCED, @@ -931,23 +848,79 @@ async def test_media_player_play_media_default_username_explicit_override( option=None, radio_mode=False, start_item=None, - username="user_admin", sort_by=None, + user="user_admin", ) -async def test_media_player_standard_play_media_default_username( +async def test_media_player_play_media_unknown_username( + hass: HomeAssistant, + music_assistant_client: MagicMock, +) -> None: + """Test that a username the server does not know raises a translated error.""" + music_assistant_client.server_info.schema_version = 44 + music_assistant_client.music.verify_item_uri = AsyncMock( + side_effect=UserNotFoundError( + "A user with user id or name nobody is not available." + ) + ) + await setup_integration_from_fixtures(hass, music_assistant_client) + entity_id = "media_player.test_player_1" + + with pytest.raises(ServiceValidationError) as err: + await hass.services.async_call( + DOMAIN, + SERVICE_PLAY_MEDIA_ADVANCED, + { + ATTR_ENTITY_ID: entity_id, + ATTR_MEDIA_ID: "spotify://track/1234", + ATTR_USERNAME: "nobody", + }, + blocking=True, + ) + assert err.value.translation_key == "invalid_username" + assert err.value.translation_placeholders == {"username": "nobody"} + + +async def test_media_player_play_media_user_not_found_without_username( + hass: HomeAssistant, + music_assistant_client: MagicMock, +) -> None: + """Test that a UserNotFoundError without an explicit username is not mislabeled.""" + music_assistant_client.server_info.schema_version = 44 + music_assistant_client.music.verify_item_uri = AsyncMock( + side_effect=UserNotFoundError( + "A user with user id or name nobody is not available." + ) + ) + await setup_integration_from_fixtures(hass, music_assistant_client) + entity_id = "media_player.test_player_1" + + with pytest.raises(HomeAssistantError) as err: + await hass.services.async_call( + DOMAIN, + SERVICE_PLAY_MEDIA_ADVANCED, + { + ATTR_ENTITY_ID: entity_id, + ATTR_MEDIA_ID: "spotify://track/1234", + }, + blocking=True, + ) + assert not isinstance(err.value, ServiceValidationError) + + +async def test_media_player_standard_play_media_default_user( hass: HomeAssistant, music_assistant_client: MagicMock, ) -> None: """Test that the standard play_media action also defaults to the calling user.""" - music_assistant_client.server_info.schema_version = 33 + music_assistant_client.server_info.schema_version = 44 music_assistant_client.music.verify_item_uri = AsyncMock(return_value=True) await setup_integration_from_fixtures(hass, music_assistant_client) entity_id = "media_player.test_player_1" mass_player_id = "00:00:00:00:00:01" - user = await _add_ha_user(hass, "user_user") + user = MockUser(is_owner=True).add_to_hass(hass) await hass.services.async_call( MEDIA_PLAYER_DOMAIN, SERVICE_PLAY_MEDIA, @@ -966,8 +939,8 @@ async def test_media_player_standard_play_media_default_username( option=None, radio_mode=False, start_item=None, - username="user_user", sort_by=None, + user={"provider": "homeassistant", "user_id": user.id, "required": False}, ) @@ -996,11 +969,14 @@ async def test_media_player_play_announcement_action( assert music_assistant_client.send_command.call_count == 1 assert music_assistant_client.send_command.call_args == call( "players/cmd/play_announcement", + require_schema=None, player_id=mass_player_id, url="http://blah.com/announcement.mp3", pre_announce=True, volume_level=50, pre_announce_url="http://blah.com/chime.mp3", + message=None, + tts_engine=None, ) diff --git a/tests/components/music_assistant/test_services.py b/tests/components/music_assistant/test_services.py index bed611b830fe..b17902fcb389 100644 --- a/tests/components/music_assistant/test_services.py +++ b/tests/components/music_assistant/test_services.py @@ -3,6 +3,7 @@ from unittest.mock import AsyncMock, MagicMock, call from music_assistant_models.enums import MediaType +from music_assistant_models.errors import UserNotFoundError from music_assistant_models.media_items import SearchResults import pytest from syrupy.assertion import SnapshotAssertion @@ -87,21 +88,33 @@ async def test_search_action_with_username( require_schema=35, ) - # not valid because of name, disabled or guest - for username in ("non_existing_user", "party_guest", "user_disabled"): - with pytest.raises(ServiceValidationError) as exc: - await hass.services.async_call( - DOMAIN, - SERVICE_SEARCH, - { - ATTR_CONFIG_ENTRY_ID: entry.entry_id, - ATTR_SEARCH_NAME: "test", - ATTR_USERNAME: username, - }, - blocking=True, - return_response=True, - ) - assert exc.value.translation_key == "invalid_username" + +async def test_search_action_with_unknown_username( + hass: HomeAssistant, + music_assistant_client: MagicMock, +) -> None: + """Test that a username the server does not know raises a translated error.""" + entry = await setup_integration_from_fixtures(hass, music_assistant_client) + music_assistant_client.music.search = AsyncMock( + side_effect=UserNotFoundError( + "A user with user id or name nobody is not available." + ) + ) + + with pytest.raises(ServiceValidationError) as err: + await hass.services.async_call( + DOMAIN, + SERVICE_SEARCH, + { + ATTR_CONFIG_ENTRY_ID: entry.entry_id, + ATTR_SEARCH_NAME: "test", + ATTR_USERNAME: "nobody", + }, + blocking=True, + return_response=True, + ) + assert err.value.translation_key == "invalid_username" + assert err.value.translation_placeholders == {"username": "nobody"} @pytest.mark.parametrize( @@ -160,22 +173,7 @@ async def test_get_library_action_with_username( # username supported from schema 35 and above music_assistant_client.server_info.schema_version = 35 - # invalid users - for username in ("non_existing_user", "party_guest", "user_disabled"): - with pytest.raises(ServiceValidationError): - await hass.services.async_call( - DOMAIN, - SERVICE_GET_LIBRARY, - { - ATTR_CONFIG_ENTRY_ID: entry.entry_id, - ATTR_FAVORITE: False, - ATTR_MEDIA_TYPE: media_type, - ATTR_USERNAME: username, - }, - blocking=True, - return_response=True, - ) - # valid user + # an explicit username is forwarded to the server (which validates it) await hass.services.async_call( DOMAIN, SERVICE_GET_LIBRARY, @@ -188,3 +186,31 @@ async def test_get_library_action_with_username( blocking=True, return_response=True, ) + + +async def test_get_library_action_with_unknown_username( + hass: HomeAssistant, + music_assistant_client: MagicMock, +) -> None: + """Test that a username the server does not know raises a translated error.""" + entry = await setup_integration_from_fixtures(hass, music_assistant_client) + music_assistant_client.music.get_library_tracks = AsyncMock( + side_effect=UserNotFoundError( + "A user with user id or name nobody is not available." + ) + ) + + with pytest.raises(ServiceValidationError) as err: + await hass.services.async_call( + DOMAIN, + SERVICE_GET_LIBRARY, + { + ATTR_CONFIG_ENTRY_ID: entry.entry_id, + ATTR_MEDIA_TYPE: "track", + ATTR_USERNAME: "nobody", + }, + blocking=True, + return_response=True, + ) + assert err.value.translation_key == "invalid_username" + assert err.value.translation_placeholders == {"username": "nobody"} diff --git a/tests/components/nam/test_config_flow.py b/tests/components/nam/test_config_flow.py index 7c44f6c9bdec..323d08b353ee 100644 --- a/tests/components/nam/test_config_flow.py +++ b/tests/components/nam/test_config_flow.py @@ -168,14 +168,19 @@ async def test_reauth_unsuccessful(hass: HomeAssistant) -> None: async def test_form_with_auth_errors(hass: HomeAssistant, error) -> None: """Test we handle errors when auth is required.""" exc, base_error = error + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + with patch( "homeassistant.components.nam.NettigoAirMonitor.async_get_mac_address", side_effect=AuthFailedError("Authorization has failed"), ): - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": SOURCE_USER}, - data=VALID_CONFIG, + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input=VALID_CONFIG, ) assert result["type"] is FlowResultType.FORM @@ -204,14 +209,19 @@ async def test_form_with_auth_errors(hass: HomeAssistant, error) -> None: async def test_form_errors(hass: HomeAssistant, error) -> None: """Test we handle errors.""" exc, base_error = error + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + with patch( "homeassistant.components.nam.NettigoAirMonitor.initialize", side_effect=exc, ): - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": SOURCE_USER}, - data=VALID_CONFIG, + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input=VALID_CONFIG, ) assert result["errors"] == {"base": base_error} @@ -219,16 +229,19 @@ async def test_form_errors(hass: HomeAssistant, error) -> None: async def test_form_abort(hass: HomeAssistant) -> None: """Test we handle abort after error.""" - with ( - patch( - "homeassistant.components.nam.NettigoAirMonitor.async_get_mac_address", - side_effect=CannotGetMacError("Cannot get MAC address from device"), - ), + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + + with patch( + "homeassistant.components.nam.NettigoAirMonitor.async_get_mac_address", + side_effect=CannotGetMacError("Cannot get MAC address from device"), ): - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": SOURCE_USER}, - data=VALID_CONFIG, + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input=VALID_CONFIG, ) assert result["type"] is FlowResultType.ABORT diff --git a/tests/components/netatmo/test_light.py b/tests/components/netatmo/test_light.py index 83fe5a54607a..db8ebea1b936 100644 --- a/tests/components/netatmo/test_light.py +++ b/tests/components/netatmo/test_light.py @@ -211,3 +211,44 @@ async def test_light_setup_and_services( ] } ) + + +async def test_dimmable_light_turn_on_updates_brightness_optimistically( + hass: HomeAssistant, config_entry: MockConfigEntry, netatmo_auth: AsyncMock +) -> None: + """Test that turning on a dimmable light with a brightness updates state immediately. + + The Netatmo API doesn't push brightness changes back until the next poll, + which can be several minutes away, so the new brightness must be reflected + optimistically instead of waiting for that poll. + """ + with selected_platforms(["light"]): + assert await hass.config_entries.async_setup(config_entry.entry_id) + + await hass.async_block_till_done() + + light_entity = "light.unknown_00_11_22_33_00_11_45_fe" + + with patch("pyatmo.home.Home.async_set_state") as mock_set_state: + await hass.services.async_call( + LIGHT_DOMAIN, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: light_entity, "brightness": 128}, + blocking=True, + ) + await hass.async_block_till_done() + mock_set_state.assert_called_once_with( + { + "modules": [ + { + "id": "00:11:22:33:00:11:45:fe", + "brightness": round(128 / 2.55), + "bridge": "12:34:56:80:60:40", + } + ] + } + ) + + state = hass.states.get(light_entity) + assert state.state == "on" + assert state.attributes["brightness"] == 128 diff --git a/tests/components/nfandroidtv/test_notify.py b/tests/components/nfandroidtv/test_notify.py index fd0d82b7e1b3..8b30a24c6e21 100644 --- a/tests/components/nfandroidtv/test_notify.py +++ b/tests/components/nfandroidtv/test_notify.py @@ -1,7 +1,7 @@ """Tests for the Notifications for Android TV / Fire TV notify platform.""" -from collections.abc import AsyncGenerator -from unittest.mock import patch +from collections.abc import AsyncGenerator, Generator +from unittest.mock import MagicMock, patch from notifications_android_tv.notifications import ConnectError import pytest @@ -16,13 +16,15 @@ from homeassistant.components.notify import ( from homeassistant.config_entries import ConfigEntryState from homeassistant.const import ATTR_ENTITY_ID, CONF_NAME, STATE_UNKNOWN, Platform from homeassistant.core import HomeAssistant -from homeassistant.exceptions import HomeAssistantError +from homeassistant.exceptions import HomeAssistantError, ServiceValidationError from homeassistant.helpers import entity_registry as er from . import NAME from tests.common import AsyncMock, MockConfigEntry, snapshot_platform +LEGACY_SERVICE_NAME = "android_tv_fire_tv_1_2_3_4" + @pytest.fixture(autouse=True) async def notify_only() -> AsyncGenerator[None]: @@ -123,3 +125,48 @@ async def test_send_message_exception( mock_notifications_android_tv.send.assert_called_once_with( message="Hello", title="World" ) + + +@pytest.fixture +def mock_legacy_notifications() -> Generator[MagicMock]: + """Mock the client used by the legacy notify service.""" + with patch( + "homeassistant.components.nfandroidtv.notify.Notifications", + autospec=True, + ) as mock_client: + yield mock_client.return_value + + +@pytest.mark.usefixtures("mock_notifications_android_tv") +@pytest.mark.parametrize( + ("service_data", "translation_key"), + [ + pytest.param({"duration": "invalid"}, "invalid_duration", id="duration"), + pytest.param({"duration": None}, "invalid_duration", id="duration_none"), + pytest.param({"interrupt": "invalid"}, "invalid_interrupt", id="interrupt"), + ], +) +async def test_legacy_send_message_invalid_data( + hass: HomeAssistant, + config_entry: MockConfigEntry, + mock_legacy_notifications: MagicMock, + service_data: dict[str, str | None], + translation_key: str, +) -> None: + """Test that invalid service data raises and sends nothing.""" + config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + assert hass.services.has_service(NOTIFY_DOMAIN, LEGACY_SERVICE_NAME) + + with pytest.raises(ServiceValidationError) as err: + await hass.services.async_call( + NOTIFY_DOMAIN, + LEGACY_SERVICE_NAME, + {"message": "Hello", "data": service_data}, + blocking=True, + ) + + assert err.value.translation_key == translation_key + mock_legacy_notifications.send.assert_not_called() diff --git a/tests/components/nice_go/test_init.py b/tests/components/nice_go/test_init.py index b1aa01ee3604..c2d69167cba1 100644 --- a/tests/components/nice_go/test_init.py +++ b/tests/components/nice_go/test_init.py @@ -40,6 +40,19 @@ async def test_setup_failure_api_error( ) -> None: """Test reauth trigger setup.""" + mock_nice_go.get_all_barriers.side_effect = ApiError() + + await setup_integration(hass, mock_config_entry, []) + assert mock_config_entry.state is ConfigEntryState.SETUP_RETRY + + +async def test_auth_api_error( + hass: HomeAssistant, + mock_nice_go: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test reauth trigger setup.""" + mock_nice_go.authenticate_refresh.side_effect = ApiError() await setup_integration(hass, mock_config_entry, []) @@ -206,6 +219,26 @@ async def test_client_listen_api_error( assert mock_nice_go.connect.call_count == 2 +async def test_client_listen_auth_failed( + hass: HomeAssistant, + mock_nice_go: AsyncMock, + mock_config_entry: MockConfigEntry, + caplog: pytest.LogCaptureFixture, + freezer: FrozenDateTimeFactory, +) -> None: + """Test client listen with error.""" + + mock_nice_go.connect.side_effect = AuthFailedError + + await setup_integration(hass, mock_config_entry, [Platform.COVER]) + + assert ( + "Got auth failed when connecting to websocket, trying to reauthenticate" + in caplog.text + ) + assert mock_nice_go.authenticate_refresh.call_count == 2 + + async def test_on_data_none_parsed( hass: HomeAssistant, mock_nice_go: AsyncMock, diff --git a/tests/components/nut/test_device_action.py b/tests/components/nut/test_device_action.py index 3f48d073f9fa..7d77b9eb9b7f 100644 --- a/tests/components/nut/test_device_action.py +++ b/tests/components/nut/test_device_action.py @@ -41,7 +41,7 @@ async def test_get_all_actions_for_specified_user( list_vars={"ups.status": "OL"}, list_commands_return_value=list_commands_return_value, ) - device_entry = next(device for device in device_registry.devices.values()) + device_entry = next(device for device in device_registry.devices) expected_actions = [ { "domain": DOMAIN, @@ -71,7 +71,7 @@ async def test_no_actions_for_anonymous_user( list_vars={"ups.status": "OL"}, list_commands_return_value=list_commands_return_value, ) - device_entry = next(device for device in device_registry.devices.values()) + device_entry = next(device for device in device_registry.devices) actions = await async_get_device_automations( hass, DeviceAutomationType.ACTION, device_entry.id ) @@ -110,7 +110,7 @@ async def test_no_actions_device_invalid( list_vars={"ups.status": "OL"}, list_commands_return_value=list_commands_return_value, ) - device_entry = next(device for device in device_registry.devices.values()) + device_entry = next(device for device in device_registry.devices) assert await hass.config_entries.async_unload(entry.entry_id) await hass.async_block_till_done() @@ -131,7 +131,7 @@ async def test_list_commands_exception( hass, list_vars={"ups.status": "OL"}, list_commands_side_effect=NUTError ) - device_entry = next(device for device in device_registry.devices.values()) + device_entry = next(device for device in device_registry.devices) actions = await async_get_device_automations( hass, DeviceAutomationType.ACTION, device_entry.id ) @@ -152,7 +152,7 @@ async def test_unsupported_command( list_vars={"ups.status": "OL"}, list_commands_return_value=list_commands_return_value, ) - device_entry = next(device for device in device_registry.devices.values()) + device_entry = next(device for device in device_registry.devices) actions = await async_get_device_automations( hass, DeviceAutomationType.ACTION, device_entry.id ) @@ -174,7 +174,7 @@ async def test_action(hass: HomeAssistant, device_registry: dr.DeviceRegistry) - list_commands_return_value=list_commands_return_value, run_command=run_command, ) - device_entry = next(device for device in device_registry.devices.values()) + device_entry = next(device for device in device_registry.devices) assert await async_setup_component( hass, @@ -232,7 +232,7 @@ async def test_run_command_exception( list_commands_return_value={command_name: None}, run_command=run_command, ) - device_entry = next(device for device in device_registry.devices.values()) + device_entry = next(device for device in device_registry.devices) platform = await device_automation.async_get_device_automation_platform( hass, DOMAIN, DeviceAutomationType.ACTION @@ -315,7 +315,7 @@ async def test_action_exception_device_invalid( list_vars={"ups.status": "OL"}, list_commands_return_value=list_commands_return_value, ) - device_entry = next(device for device in device_registry.devices.values()) + device_entry = next(device for device in device_registry.devices) assert await hass.config_entries.async_unload(entry.entry_id) await hass.async_block_till_done() diff --git a/tests/components/ollama/test_init.py b/tests/components/ollama/test_init.py index 460ce25337fe..9fddcc99335d 100644 --- a/tests/components/ollama/test_init.py +++ b/tests/components/ollama/test_init.py @@ -1051,7 +1051,7 @@ async def test_migrate_entry_from_v3_2( conversation_device = attr.evolve( conversation_device, disabled_by=device_disabled_by ) - device_registry.devices[conversation_device.id] = conversation_device + device_registry._devices[conversation_device.id] = conversation_device conversation_entity = entity_registry.async_get_or_create( "conversation", DOMAIN, diff --git a/tests/components/openai_conversation/test_init.py b/tests/components/openai_conversation/test_init.py index dcd6d492ddc8..9a09697af7a9 100644 --- a/tests/components/openai_conversation/test_init.py +++ b/tests/components/openai_conversation/test_init.py @@ -1636,7 +1636,7 @@ async def test_migrate_entry_from_v2_3( conversation_device = attr.evolve( conversation_device, disabled_by=device_disabled_by ) - device_registry.devices[conversation_device.id] = conversation_device + device_registry._devices[conversation_device.id] = conversation_device conversation_entity = entity_registry.async_get_or_create( "conversation", DOMAIN, diff --git a/tests/components/openevse/snapshots/test_switch.ambr b/tests/components/openevse/snapshots/test_switch.ambr new file mode 100644 index 000000000000..f71771ff4f1b --- /dev/null +++ b/tests/components/openevse/snapshots/test_switch.ambr @@ -0,0 +1,151 @@ +# serializer version: 1 +# name: test_entities[switch.openevse_mock_config_current_shaper-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': None, + 'entity_id': 'switch.openevse_mock_config_current_shaper', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Current shaper', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Current shaper', + 'platform': 'openevse', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'current_shaper', + 'unique_id': 'deadbeeffeed-current_shaper', + 'unit_of_measurement': None, + }) +# --- +# name: test_entities[switch.openevse_mock_config_current_shaper-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'openevse_mock_config Current shaper', + }), + 'context': , + 'entity_id': 'switch.openevse_mock_config_current_shaper', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_entities[switch.openevse_mock_config_manual_override-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': None, + 'entity_id': 'switch.openevse_mock_config_manual_override', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Manual override', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Manual override', + 'platform': 'openevse', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'manual_override', + 'unique_id': 'deadbeeffeed-manual_override', + 'unit_of_measurement': None, + }) +# --- +# name: test_entities[switch.openevse_mock_config_manual_override-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'openevse_mock_config Manual override', + }), + 'context': , + 'entity_id': 'switch.openevse_mock_config_manual_override', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_entities[switch.openevse_mock_config_solar_pv_divert-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': None, + 'entity_id': 'switch.openevse_mock_config_solar_pv_divert', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Solar PV divert', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Solar PV divert', + 'platform': 'openevse', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'solar_pv_divert', + 'unique_id': 'deadbeeffeed-solar_pv_divert', + 'unit_of_measurement': None, + }) +# --- +# name: test_entities[switch.openevse_mock_config_solar_pv_divert-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'openevse_mock_config Solar PV divert', + }), + 'context': , + 'entity_id': 'switch.openevse_mock_config_solar_pv_divert', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- diff --git a/tests/components/openevse/test_switch.py b/tests/components/openevse/test_switch.py new file mode 100644 index 000000000000..a17c5e407c3c --- /dev/null +++ b/tests/components/openevse/test_switch.py @@ -0,0 +1,222 @@ +"""Tests for the OpenEVSE switch platform.""" + +from unittest.mock import MagicMock, patch + +from aiohttp import ContentTypeError, ServerTimeoutError +from openevsehttp.exceptions import ( + AuthenticationError, + ParseJSONError, + UnsupportedFeature, +) +import pytest +from syrupy.assertion import SnapshotAssertion + +from homeassistant.components.openevse.const import DOMAIN +from homeassistant.components.switch import ( + DOMAIN as SWITCH_DOMAIN, + SERVICE_TURN_OFF, + SERVICE_TURN_ON, +) +from homeassistant.const import ATTR_ENTITY_ID, STATE_ON, STATE_UNAVAILABLE, Platform +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ( + ConfigEntryAuthFailed, + HomeAssistantError, + ServiceValidationError, +) +from homeassistant.helpers import entity_registry as er + +from tests.common import MockConfigEntry, snapshot_platform + + +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +async def test_entities( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, + mock_config_entry: MockConfigEntry, + mock_charger: MagicMock, +) -> None: + """Test the switch entities.""" + with patch("homeassistant.components.openevse.PLATFORMS", [Platform.SWITCH]): + mock_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + + await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) + + +@pytest.mark.parametrize( + ("entity_id", "service", "method_name", "args"), + [ + pytest.param( + "switch.openevse_mock_config_solar_pv_divert", + SERVICE_TURN_ON, + "set_divert_mode", + ("eco",), + id="solar_pv_divert_on", + ), + pytest.param( + "switch.openevse_mock_config_solar_pv_divert", + SERVICE_TURN_OFF, + "set_divert_mode", + ("fast",), + id="solar_pv_divert_off", + ), + pytest.param( + "switch.openevse_mock_config_current_shaper", + SERVICE_TURN_ON, + "set_shaper", + (True,), + id="current_shaper_on", + ), + pytest.param( + "switch.openevse_mock_config_current_shaper", + SERVICE_TURN_OFF, + "set_shaper", + (False,), + id="current_shaper_off", + ), + pytest.param( + "switch.openevse_mock_config_manual_override", + SERVICE_TURN_ON, + "toggle_override", + (), + id="manual_override_on", + ), + pytest.param( + "switch.openevse_mock_config_manual_override", + SERVICE_TURN_OFF, + "toggle_override", + (), + id="manual_override_off", + ), + ], +) +async def test_switch_turn_on_off( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_charger: MagicMock, + entity_id: str, + service: str, + method_name: str, + args: tuple[object, ...], +) -> None: + """Test turning on and off the switch entities.""" + mock_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + await hass.services.async_call( + SWITCH_DOMAIN, + service, + {ATTR_ENTITY_ID: entity_id}, + blocking=True, + ) + getattr(mock_charger, method_name).assert_called_once_with(*args) + + +@pytest.mark.parametrize( + ("raised", "expected", "translation_key", "translation_placeholders"), + [ + pytest.param( + ValueError("invalid mode"), + ServiceValidationError, + "invalid_value", + {"value": "None"}, + id="value_error", + ), + pytest.param( + AuthenticationError("bad creds"), + ConfigEntryAuthFailed, + "authentication_error", + None, + id="auth_error", + ), + pytest.param( + TimeoutError("timed out"), + HomeAssistantError, + "communication_error", + None, + id="timeout_error", + ), + pytest.param( + ServerTimeoutError("timed out"), + HomeAssistantError, + "communication_error", + None, + id="server_timeout_error", + ), + pytest.param( + ParseJSONError("bad json"), + HomeAssistantError, + "communication_error", + None, + id="parse_json_error", + ), + pytest.param( + UnsupportedFeature("old firmware"), + HomeAssistantError, + "unsupported_feature", + None, + id="unsupported_feature", + ), + pytest.param( + ContentTypeError(MagicMock(), (), message="bad content"), + HomeAssistantError, + "communication_error", + None, + id="content_type_error", + ), + ], +) +async def test_switch_raises( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_charger: MagicMock, + raised: Exception, + expected: type[Exception], + translation_key: str, + translation_placeholders: dict[str, str] | None, +) -> None: + """Test that errors from the charger are translated to HA exceptions.""" + mock_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + mock_charger.set_shaper.side_effect = raised + + with pytest.raises(expected) as exc_info: + await hass.services.async_call( + SWITCH_DOMAIN, + SERVICE_TURN_ON, + { + ATTR_ENTITY_ID: "switch.openevse_mock_config_current_shaper", + }, + blocking=True, + ) + + assert exc_info.value.translation_key == translation_key + assert exc_info.value.translation_domain == DOMAIN + assert exc_info.value.translation_placeholders == translation_placeholders + + +async def test_switch_availability( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_charger: MagicMock, +) -> None: + """Test switch entity availability when is_on_fn returns None.""" + mock_charger.divertmode = None + mock_charger.shaper_active = True + + mock_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + state = hass.states.get("switch.openevse_mock_config_solar_pv_divert") + assert state is not None + assert state.state == STATE_UNAVAILABLE + + state = hass.states.get("switch.openevse_mock_config_current_shaper") + assert state is not None + assert state.state == STATE_ON diff --git a/tests/components/openrgb/test_light.py b/tests/components/openrgb/test_light.py index 61e16c216cb5..4006b9843c85 100644 --- a/tests/components/openrgb/test_light.py +++ b/tests/components/openrgb/test_light.py @@ -2,7 +2,7 @@ from collections.abc import Generator import copy -from unittest.mock import MagicMock, patch +from unittest.mock import MagicMock, call, patch from freezegun.api import FrozenDateTimeFactory from openrgb.utils import OpenRGBDisconnected, RGBColor @@ -620,10 +620,71 @@ async def test_turn_off_light_without_off_mode( blocking=True, ) - # Device should have set_color called with black/off color instead + # Device should have set_color called with black/off color instead, + # without any mode switch (the active mode is already color-capable) + mock_openrgb_device.set_mode.assert_not_called() mock_openrgb_device.set_color.assert_called_once_with(RGBColor(*OFF_COLOR), True) +@pytest.mark.usefixtures("mock_openrgb_client") +async def test_turn_off_light_without_off_mode_in_non_color_mode( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_openrgb_device: MagicMock, +) -> None: + """Test turning off a light without Off mode while a non-color mode is active. + + Color writes are ignored by the device while a mode without PER_LED + color support (e.g. a firmware effect) is active, so turning off must + first switch to the preferred no-effect mode — otherwise painting + black is a silent no-op and the light never turns off. + """ + # Modify the device to not have Off mode + mock_openrgb_device.modes = [ + mode_data + for mode_data in mock_openrgb_device.modes + if mode_data.name != OpenRGBMode.OFF + ] + # Activate a mode without PER_LED color support ("Spectrum Cycle") + mock_openrgb_device.active_mode = next( + index + for index, mode_data in enumerate(mock_openrgb_device.modes) + if mode_data.name == "Spectrum Cycle" + ) + + mock_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + assert mock_config_entry.state is ConfigEntryState.LOADED + + # Verify light is initially on + state = hass.states.get("light.ene_dram") + assert state + assert state.state == STATE_ON + + # Turn off the light + await hass.services.async_call( + LIGHT_DOMAIN, + SERVICE_TURN_OFF, + {ATTR_ENTITY_ID: "light.ene_dram"}, + blocking=True, + ) + + # The device must first be switched to the preferred no-effect mode + # (color-capable), then painted black — in that order + mock_openrgb_device.set_mode.assert_called_once_with(OpenRGBMode.DIRECT) + mock_openrgb_device.set_color.assert_called_once_with(RGBColor(*OFF_COLOR), True) + assert [ + call + for call in mock_openrgb_device.mock_calls + if call[0] in ("set_mode", "set_color") + ] == [ + call.set_mode(OpenRGBMode.DIRECT), + call.set_color(RGBColor(*OFF_COLOR), True), + ] + + # Test error handling @pytest.mark.usefixtures("init_integration") @pytest.mark.parametrize( diff --git a/tests/components/overkiz/snapshots/test_sensor.ambr b/tests/components/overkiz/snapshots/test_sensor.ambr index 49d9b0f58ddc..c7e7f66f57d2 100644 --- a/tests/components/overkiz/snapshots/test_sensor.ambr +++ b/tests/components/overkiz/snapshots/test_sensor.ambr @@ -1,5 +1,5 @@ # serializer version: 1 -# name: test_sensor_entities_snapshot[cloud_atlantic_cozytouch.json][sensor.living_room_temperature_temperature-entry] +# name: test_sensor_entities_snapshot[cloud_atlantic_cozytouch.json][sensor.somfy_tahoma_switch_living_room_temperature_temperature-entry] EntityRegistryEntrySnapshot({ 'aliases': list([ None, @@ -15,7 +15,7 @@ 'disabled_by': None, 'domain': 'sensor', 'entity_category': None, - 'entity_id': 'sensor.living_room_temperature_temperature', + 'entity_id': 'sensor.somfy_tahoma_switch_living_room_temperature_temperature', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -41,16 +41,16 @@ 'unit_of_measurement': , }) # --- -# name: test_sensor_entities_snapshot[cloud_atlantic_cozytouch.json][sensor.living_room_temperature_temperature-state] +# name: test_sensor_entities_snapshot[cloud_atlantic_cozytouch.json][sensor.somfy_tahoma_switch_living_room_temperature_temperature-state] StateSnapshot({ 'attributes': ReadOnlyDict({ : 'temperature', - : 'Living room temperature Temperature', + : 'Somfy TaHoma Switch Living room temperature Temperature', : , : , }), 'context': , - 'entity_id': 'sensor.living_room_temperature_temperature', + 'entity_id': 'sensor.somfy_tahoma_switch_living_room_temperature_temperature', 'last_changed': , 'last_reported': , 'last_updated': , @@ -9296,7 +9296,7 @@ 'state': 'unknown', }) # --- -# name: test_sensor_entities_snapshot[local_somfy_tahoma_switch_europe_3.json][sensor.garden_temp_probe_discrete_rssi_level-entry] +# name: test_sensor_entities_snapshot[local_somfy_tahoma_switch_europe_3.json][sensor.somfy_tahoma_switch_garden_temp_probe_discrete_rssi_level-entry] EntityRegistryEntrySnapshot({ 'aliases': list([ None, @@ -9317,7 +9317,7 @@ 'disabled_by': None, 'domain': 'sensor', 'entity_category': , - 'entity_id': 'sensor.garden_temp_probe_discrete_rssi_level', + 'entity_id': 'sensor.somfy_tahoma_switch_garden_temp_probe_discrete_rssi_level', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -9340,7 +9340,7 @@ 'unit_of_measurement': None, }) # --- -# name: test_sensor_entities_snapshot[local_somfy_tahoma_switch_europe_3.json][sensor.garden_temp_probe_discrete_rssi_level-state] +# name: test_sensor_entities_snapshot[local_somfy_tahoma_switch_europe_3.json][sensor.somfy_tahoma_switch_garden_temp_probe_discrete_rssi_level-state] StateSnapshot({ 'attributes': ReadOnlyDict({ : 'enum', @@ -9354,14 +9354,14 @@ ]), }), 'context': , - 'entity_id': 'sensor.garden_temp_probe_discrete_rssi_level', + 'entity_id': 'sensor.somfy_tahoma_switch_garden_temp_probe_discrete_rssi_level', 'last_changed': , 'last_reported': , 'last_updated': , 'state': 'normal', }) # --- -# name: test_sensor_entities_snapshot[local_somfy_tahoma_switch_europe_3.json][sensor.garden_temp_probe_rssi_level-entry] +# name: test_sensor_entities_snapshot[local_somfy_tahoma_switch_europe_3.json][sensor.somfy_tahoma_switch_garden_temp_probe_rssi_level-entry] EntityRegistryEntrySnapshot({ 'aliases': list([ None, @@ -9377,7 +9377,7 @@ 'disabled_by': None, 'domain': 'sensor', 'entity_category': , - 'entity_id': 'sensor.garden_temp_probe_rssi_level', + 'entity_id': 'sensor.somfy_tahoma_switch_garden_temp_probe_rssi_level', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -9400,7 +9400,7 @@ 'unit_of_measurement': 'dB', }) # --- -# name: test_sensor_entities_snapshot[local_somfy_tahoma_switch_europe_3.json][sensor.garden_temp_probe_rssi_level-state] +# name: test_sensor_entities_snapshot[local_somfy_tahoma_switch_europe_3.json][sensor.somfy_tahoma_switch_garden_temp_probe_rssi_level-state] StateSnapshot({ 'attributes': ReadOnlyDict({ : 'signal_strength', @@ -9409,14 +9409,14 @@ : 'dB', }), 'context': , - 'entity_id': 'sensor.garden_temp_probe_rssi_level', + 'entity_id': 'sensor.somfy_tahoma_switch_garden_temp_probe_rssi_level', 'last_changed': , 'last_reported': , 'last_updated': , 'state': '54', }) # --- -# name: test_sensor_entities_snapshot[local_somfy_tahoma_switch_europe_3.json][sensor.garden_temp_probe_sensor_defect-entry] +# name: test_sensor_entities_snapshot[local_somfy_tahoma_switch_europe_3.json][sensor.somfy_tahoma_switch_garden_temp_probe_sensor_defect-entry] EntityRegistryEntrySnapshot({ 'aliases': list([ None, @@ -9437,7 +9437,7 @@ 'disabled_by': None, 'domain': 'sensor', 'entity_category': , - 'entity_id': 'sensor.garden_temp_probe_sensor_defect', + 'entity_id': 'sensor.somfy_tahoma_switch_garden_temp_probe_sensor_defect', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -9460,7 +9460,7 @@ 'unit_of_measurement': None, }) # --- -# name: test_sensor_entities_snapshot[local_somfy_tahoma_switch_europe_3.json][sensor.garden_temp_probe_sensor_defect-state] +# name: test_sensor_entities_snapshot[local_somfy_tahoma_switch_europe_3.json][sensor.somfy_tahoma_switch_garden_temp_probe_sensor_defect-state] StateSnapshot({ 'attributes': ReadOnlyDict({ : 'enum', @@ -9473,14 +9473,14 @@ ]), }), 'context': , - 'entity_id': 'sensor.garden_temp_probe_sensor_defect', + 'entity_id': 'sensor.somfy_tahoma_switch_garden_temp_probe_sensor_defect', 'last_changed': , 'last_reported': , 'last_updated': , 'state': 'unknown', }) # --- -# name: test_sensor_entities_snapshot[local_somfy_tahoma_switch_europe_3.json][sensor.garden_temp_probe_temperature-entry] +# name: test_sensor_entities_snapshot[local_somfy_tahoma_switch_europe_3.json][sensor.somfy_tahoma_switch_garden_temp_probe_temperature-entry] EntityRegistryEntrySnapshot({ 'aliases': list([ None, @@ -9496,7 +9496,7 @@ 'disabled_by': None, 'domain': 'sensor', 'entity_category': None, - 'entity_id': 'sensor.garden_temp_probe_temperature', + 'entity_id': 'sensor.somfy_tahoma_switch_garden_temp_probe_temperature', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -9522,7 +9522,7 @@ 'unit_of_measurement': , }) # --- -# name: test_sensor_entities_snapshot[local_somfy_tahoma_switch_europe_3.json][sensor.garden_temp_probe_temperature-state] +# name: test_sensor_entities_snapshot[local_somfy_tahoma_switch_europe_3.json][sensor.somfy_tahoma_switch_garden_temp_probe_temperature-state] StateSnapshot({ 'attributes': ReadOnlyDict({ : 'temperature', @@ -9531,14 +9531,14 @@ : , }), 'context': , - 'entity_id': 'sensor.garden_temp_probe_temperature', + 'entity_id': 'sensor.somfy_tahoma_switch_garden_temp_probe_temperature', 'last_changed': , 'last_reported': , 'last_updated': , 'state': '24.2', }) # --- -# name: test_sensor_entities_snapshot[local_somfy_tahoma_switch_europe_3.json][sensor.garden_temperature_sensor_discrete_rssi_level-entry] +# name: test_sensor_entities_snapshot[local_somfy_tahoma_switch_europe_3.json][sensor.somfy_tahoma_switch_garden_temperature_sensor_discrete_rssi_level-entry] EntityRegistryEntrySnapshot({ 'aliases': list([ None, @@ -9559,7 +9559,7 @@ 'disabled_by': None, 'domain': 'sensor', 'entity_category': , - 'entity_id': 'sensor.garden_temperature_sensor_discrete_rssi_level', + 'entity_id': 'sensor.somfy_tahoma_switch_garden_temperature_sensor_discrete_rssi_level', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -9582,7 +9582,7 @@ 'unit_of_measurement': None, }) # --- -# name: test_sensor_entities_snapshot[local_somfy_tahoma_switch_europe_3.json][sensor.garden_temperature_sensor_discrete_rssi_level-state] +# name: test_sensor_entities_snapshot[local_somfy_tahoma_switch_europe_3.json][sensor.somfy_tahoma_switch_garden_temperature_sensor_discrete_rssi_level-state] StateSnapshot({ 'attributes': ReadOnlyDict({ : 'enum', @@ -9596,14 +9596,14 @@ ]), }), 'context': , - 'entity_id': 'sensor.garden_temperature_sensor_discrete_rssi_level', + 'entity_id': 'sensor.somfy_tahoma_switch_garden_temperature_sensor_discrete_rssi_level', 'last_changed': , 'last_reported': , 'last_updated': , 'state': 'good', }) # --- -# name: test_sensor_entities_snapshot[local_somfy_tahoma_switch_europe_3.json][sensor.garden_temperature_sensor_rssi_level-entry] +# name: test_sensor_entities_snapshot[local_somfy_tahoma_switch_europe_3.json][sensor.somfy_tahoma_switch_garden_temperature_sensor_rssi_level-entry] EntityRegistryEntrySnapshot({ 'aliases': list([ None, @@ -9619,7 +9619,7 @@ 'disabled_by': None, 'domain': 'sensor', 'entity_category': , - 'entity_id': 'sensor.garden_temperature_sensor_rssi_level', + 'entity_id': 'sensor.somfy_tahoma_switch_garden_temperature_sensor_rssi_level', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -9642,7 +9642,7 @@ 'unit_of_measurement': 'dB', }) # --- -# name: test_sensor_entities_snapshot[local_somfy_tahoma_switch_europe_3.json][sensor.garden_temperature_sensor_rssi_level-state] +# name: test_sensor_entities_snapshot[local_somfy_tahoma_switch_europe_3.json][sensor.somfy_tahoma_switch_garden_temperature_sensor_rssi_level-state] StateSnapshot({ 'attributes': ReadOnlyDict({ : 'signal_strength', @@ -9651,14 +9651,14 @@ : 'dB', }), 'context': , - 'entity_id': 'sensor.garden_temperature_sensor_rssi_level', + 'entity_id': 'sensor.somfy_tahoma_switch_garden_temperature_sensor_rssi_level', 'last_changed': , 'last_reported': , 'last_updated': , 'state': '98', }) # --- -# name: test_sensor_entities_snapshot[local_somfy_tahoma_switch_europe_3.json][sensor.garden_temperature_sensor_sensor_defect-entry] +# name: test_sensor_entities_snapshot[local_somfy_tahoma_switch_europe_3.json][sensor.somfy_tahoma_switch_garden_temperature_sensor_sensor_defect-entry] EntityRegistryEntrySnapshot({ 'aliases': list([ None, @@ -9679,7 +9679,7 @@ 'disabled_by': None, 'domain': 'sensor', 'entity_category': , - 'entity_id': 'sensor.garden_temperature_sensor_sensor_defect', + 'entity_id': 'sensor.somfy_tahoma_switch_garden_temperature_sensor_sensor_defect', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -9702,7 +9702,7 @@ 'unit_of_measurement': None, }) # --- -# name: test_sensor_entities_snapshot[local_somfy_tahoma_switch_europe_3.json][sensor.garden_temperature_sensor_sensor_defect-state] +# name: test_sensor_entities_snapshot[local_somfy_tahoma_switch_europe_3.json][sensor.somfy_tahoma_switch_garden_temperature_sensor_sensor_defect-state] StateSnapshot({ 'attributes': ReadOnlyDict({ : 'enum', @@ -9715,14 +9715,14 @@ ]), }), 'context': , - 'entity_id': 'sensor.garden_temperature_sensor_sensor_defect', + 'entity_id': 'sensor.somfy_tahoma_switch_garden_temperature_sensor_sensor_defect', 'last_changed': , 'last_reported': , 'last_updated': , 'state': 'unknown', }) # --- -# name: test_sensor_entities_snapshot[local_somfy_tahoma_switch_europe_3.json][sensor.garden_temperature_sensor_temperature-entry] +# name: test_sensor_entities_snapshot[local_somfy_tahoma_switch_europe_3.json][sensor.somfy_tahoma_switch_garden_temperature_sensor_temperature-entry] EntityRegistryEntrySnapshot({ 'aliases': list([ None, @@ -9738,7 +9738,7 @@ 'disabled_by': None, 'domain': 'sensor', 'entity_category': None, - 'entity_id': 'sensor.garden_temperature_sensor_temperature', + 'entity_id': 'sensor.somfy_tahoma_switch_garden_temperature_sensor_temperature', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -9764,7 +9764,7 @@ 'unit_of_measurement': , }) # --- -# name: test_sensor_entities_snapshot[local_somfy_tahoma_switch_europe_3.json][sensor.garden_temperature_sensor_temperature-state] +# name: test_sensor_entities_snapshot[local_somfy_tahoma_switch_europe_3.json][sensor.somfy_tahoma_switch_garden_temperature_sensor_temperature-state] StateSnapshot({ 'attributes': ReadOnlyDict({ : 'temperature', @@ -9773,7 +9773,7 @@ : , }), 'context': , - 'entity_id': 'sensor.garden_temperature_sensor_temperature', + 'entity_id': 'sensor.somfy_tahoma_switch_garden_temperature_sensor_temperature', 'last_changed': , 'last_reported': , 'last_updated': , @@ -10501,7 +10501,7 @@ 'state': '96', }) # --- -# name: test_sensor_entities_snapshot[local_somfy_tahoma_switch_europe_3.json][sensor.kitchen_temp_probe_discrete_rssi_level-entry] +# name: test_sensor_entities_snapshot[local_somfy_tahoma_switch_europe_3.json][sensor.somfy_tahoma_switch_kitchen_temp_probe_discrete_rssi_level-entry] EntityRegistryEntrySnapshot({ 'aliases': list([ None, @@ -10522,7 +10522,7 @@ 'disabled_by': None, 'domain': 'sensor', 'entity_category': , - 'entity_id': 'sensor.kitchen_temp_probe_discrete_rssi_level', + 'entity_id': 'sensor.somfy_tahoma_switch_kitchen_temp_probe_discrete_rssi_level', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -10545,7 +10545,7 @@ 'unit_of_measurement': None, }) # --- -# name: test_sensor_entities_snapshot[local_somfy_tahoma_switch_europe_3.json][sensor.kitchen_temp_probe_discrete_rssi_level-state] +# name: test_sensor_entities_snapshot[local_somfy_tahoma_switch_europe_3.json][sensor.somfy_tahoma_switch_kitchen_temp_probe_discrete_rssi_level-state] StateSnapshot({ 'attributes': ReadOnlyDict({ : 'enum', @@ -10559,14 +10559,14 @@ ]), }), 'context': , - 'entity_id': 'sensor.kitchen_temp_probe_discrete_rssi_level', + 'entity_id': 'sensor.somfy_tahoma_switch_kitchen_temp_probe_discrete_rssi_level', 'last_changed': , 'last_reported': , 'last_updated': , 'state': 'good', }) # --- -# name: test_sensor_entities_snapshot[local_somfy_tahoma_switch_europe_3.json][sensor.kitchen_temp_probe_rssi_level-entry] +# name: test_sensor_entities_snapshot[local_somfy_tahoma_switch_europe_3.json][sensor.somfy_tahoma_switch_kitchen_temp_probe_rssi_level-entry] EntityRegistryEntrySnapshot({ 'aliases': list([ None, @@ -10582,7 +10582,7 @@ 'disabled_by': None, 'domain': 'sensor', 'entity_category': , - 'entity_id': 'sensor.kitchen_temp_probe_rssi_level', + 'entity_id': 'sensor.somfy_tahoma_switch_kitchen_temp_probe_rssi_level', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -10605,7 +10605,7 @@ 'unit_of_measurement': 'dB', }) # --- -# name: test_sensor_entities_snapshot[local_somfy_tahoma_switch_europe_3.json][sensor.kitchen_temp_probe_rssi_level-state] +# name: test_sensor_entities_snapshot[local_somfy_tahoma_switch_europe_3.json][sensor.somfy_tahoma_switch_kitchen_temp_probe_rssi_level-state] StateSnapshot({ 'attributes': ReadOnlyDict({ : 'signal_strength', @@ -10614,14 +10614,14 @@ : 'dB', }), 'context': , - 'entity_id': 'sensor.kitchen_temp_probe_rssi_level', + 'entity_id': 'sensor.somfy_tahoma_switch_kitchen_temp_probe_rssi_level', 'last_changed': , 'last_reported': , 'last_updated': , 'state': '82', }) # --- -# name: test_sensor_entities_snapshot[local_somfy_tahoma_switch_europe_3.json][sensor.kitchen_temp_probe_sensor_defect-entry] +# name: test_sensor_entities_snapshot[local_somfy_tahoma_switch_europe_3.json][sensor.somfy_tahoma_switch_kitchen_temp_probe_sensor_defect-entry] EntityRegistryEntrySnapshot({ 'aliases': list([ None, @@ -10642,7 +10642,7 @@ 'disabled_by': None, 'domain': 'sensor', 'entity_category': , - 'entity_id': 'sensor.kitchen_temp_probe_sensor_defect', + 'entity_id': 'sensor.somfy_tahoma_switch_kitchen_temp_probe_sensor_defect', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -10665,7 +10665,7 @@ 'unit_of_measurement': None, }) # --- -# name: test_sensor_entities_snapshot[local_somfy_tahoma_switch_europe_3.json][sensor.kitchen_temp_probe_sensor_defect-state] +# name: test_sensor_entities_snapshot[local_somfy_tahoma_switch_europe_3.json][sensor.somfy_tahoma_switch_kitchen_temp_probe_sensor_defect-state] StateSnapshot({ 'attributes': ReadOnlyDict({ : 'enum', @@ -10678,14 +10678,14 @@ ]), }), 'context': , - 'entity_id': 'sensor.kitchen_temp_probe_sensor_defect', + 'entity_id': 'sensor.somfy_tahoma_switch_kitchen_temp_probe_sensor_defect', 'last_changed': , 'last_reported': , 'last_updated': , 'state': 'unknown', }) # --- -# name: test_sensor_entities_snapshot[local_somfy_tahoma_switch_europe_3.json][sensor.kitchen_temp_probe_temperature-entry] +# name: test_sensor_entities_snapshot[local_somfy_tahoma_switch_europe_3.json][sensor.somfy_tahoma_switch_kitchen_temp_probe_temperature-entry] EntityRegistryEntrySnapshot({ 'aliases': list([ None, @@ -10701,7 +10701,7 @@ 'disabled_by': None, 'domain': 'sensor', 'entity_category': None, - 'entity_id': 'sensor.kitchen_temp_probe_temperature', + 'entity_id': 'sensor.somfy_tahoma_switch_kitchen_temp_probe_temperature', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -10727,7 +10727,7 @@ 'unit_of_measurement': , }) # --- -# name: test_sensor_entities_snapshot[local_somfy_tahoma_switch_europe_3.json][sensor.kitchen_temp_probe_temperature-state] +# name: test_sensor_entities_snapshot[local_somfy_tahoma_switch_europe_3.json][sensor.somfy_tahoma_switch_kitchen_temp_probe_temperature-state] StateSnapshot({ 'attributes': ReadOnlyDict({ : 'temperature', @@ -10736,7 +10736,7 @@ : , }), 'context': , - 'entity_id': 'sensor.kitchen_temp_probe_temperature', + 'entity_id': 'sensor.somfy_tahoma_switch_kitchen_temp_probe_temperature', 'last_changed': , 'last_reported': , 'last_updated': , diff --git a/tests/components/overkiz/snapshots/test_switch.ambr b/tests/components/overkiz/snapshots/test_switch.ambr index 1fe6d83d8d1a..26dd2743df09 100644 --- a/tests/components/overkiz/snapshots/test_switch.ambr +++ b/tests/components/overkiz/snapshots/test_switch.ambr @@ -1,5 +1,5 @@ # serializer version: 1 -# name: test_switch_entities_snapshot[cloud_somfy_myfox_europe.json][switch.hot_water_tank-entry] +# name: test_switch_entities_snapshot[cloud_somfy_myfox_europe.json][switch.somfy_tahoma_switch_hot_water_tank-entry] EntityRegistryEntrySnapshot({ 'aliases': list([ None, @@ -13,7 +13,7 @@ 'disabled_by': None, 'domain': 'switch', 'entity_category': None, - 'entity_id': 'switch.hot_water_tank', + 'entity_id': 'switch.somfy_tahoma_switch_hot_water_tank', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -36,14 +36,14 @@ 'unit_of_measurement': None, }) # --- -# name: test_switch_entities_snapshot[cloud_somfy_myfox_europe.json][switch.hot_water_tank-state] +# name: test_switch_entities_snapshot[cloud_somfy_myfox_europe.json][switch.somfy_tahoma_switch_hot_water_tank-state] StateSnapshot({ 'attributes': ReadOnlyDict({ - : 'Hot Water Tank', + : 'Somfy TaHoma Switch Hot Water Tank', : 'mdi:water-boiler', }), 'context': , - 'entity_id': 'switch.hot_water_tank', + 'entity_id': 'switch.somfy_tahoma_switch_hot_water_tank', 'last_changed': , 'last_reported': , 'last_updated': , diff --git a/tests/components/overkiz/snapshots/test_water_heater.ambr b/tests/components/overkiz/snapshots/test_water_heater.ambr index e83aa39bb407..e6ffaf03aeef 100644 --- a/tests/components/overkiz/snapshots/test_water_heater.ambr +++ b/tests/components/overkiz/snapshots/test_water_heater.ambr @@ -147,7 +147,7 @@ 'state': 'auto', }) # --- -# name: test_water_heater_entities_snapshot[cloud_atlantic_cozytouch.json][water_heater.yutaki_dhw-entry] +# name: test_water_heater_entities_snapshot[cloud_atlantic_cozytouch.json][water_heater.somfy_tahoma_switch_yutaki_dhw-entry] EntityRegistryEntrySnapshot({ 'aliases': list([ None, @@ -169,7 +169,7 @@ 'disabled_by': None, 'domain': 'water_heater', 'entity_category': None, - 'entity_id': 'water_heater.yutaki_dhw', + 'entity_id': 'water_heater.somfy_tahoma_switch_yutaki_dhw', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -192,11 +192,11 @@ 'unit_of_measurement': None, }) # --- -# name: test_water_heater_entities_snapshot[cloud_atlantic_cozytouch.json][water_heater.yutaki_dhw-state] +# name: test_water_heater_entities_snapshot[cloud_atlantic_cozytouch.json][water_heater.somfy_tahoma_switch_yutaki_dhw-state] StateSnapshot({ 'attributes': ReadOnlyDict({ : 46, - : 'Yutaki DHW', + : 'Somfy TaHoma Switch Yutaki DHW', : 70, : 30, : list([ @@ -211,7 +211,7 @@ : 54, }), 'context': , - 'entity_id': 'water_heater.yutaki_dhw', + 'entity_id': 'water_heater.somfy_tahoma_switch_yutaki_dhw', 'last_changed': , 'last_reported': , 'last_updated': , diff --git a/tests/components/overkiz/test_switch.py b/tests/components/overkiz/test_switch.py index 96d73a950f38..97995cbe5c31 100644 --- a/tests/components/overkiz/test_switch.py +++ b/tests/components/overkiz/test_switch.py @@ -52,12 +52,12 @@ MYFOX_CAMERA = FixtureDevice( "myfox://SOMFY_PROTECT-1234567890ABCDEF/jQ5ul40RVLnipT6JB8b3JK96tUsf14mR", "switch.outdoor_camera_camera_shutter", ) -# Sub-device (#7 suffix) whose DomesticHotWaterTank description has no name set, -# so the entity name falls back to the device label alone. +# Sub-device (#7 suffix) whose device has no name set, so it takes the config +# entry title, and the entity id becomes device name + entity name. DOMESTIC_HOT_WATER_TANK = FixtureDevice( "setup/cloud_somfy_myfox_europe.json", "io://1234-5678-1202/6019143#7", - "switch.hot_water_tank", + "switch.somfy_tahoma_switch_hot_water_tank", ) diff --git a/tests/components/overkiz/test_water_heater.py b/tests/components/overkiz/test_water_heater.py index a9ed2fa26590..40301f405d51 100644 --- a/tests/components/overkiz/test_water_heater.py +++ b/tests/components/overkiz/test_water_heater.py @@ -42,7 +42,7 @@ DHW_CE_FLAT_C2 = FixtureDevice( DHW_HITACHI_YUTAKI = FixtureDevice( "setup/cloud_atlantic_cozytouch.json", "modbus://1234-5678-5643/6381497/1#4", - "water_heater.yutaki_dhw", + "water_heater.somfy_tahoma_switch_yutaki_dhw", ) # Thermor Aéromax 4 (io:AtlanticDomesticHotWaterProductionIOComponent) diff --git a/tests/components/portainer/fixtures/containers.json b/tests/components/portainer/fixtures/containers.json index 3728db9fbb04..51c2af256672 100644 --- a/tests/components/portainer/fixtures/containers.json +++ b/tests/components/portainer/fixtures/containers.json @@ -110,7 +110,9 @@ } ], "Labels": { - "com.docker.compose.project": "webstack" + "com.docker.compose.project": "webstack", + "org.opencontainers.image.version": "1.29.3", + "org.opencontainers.image.created": "2026-05-02T14:31:00Z" }, "State": "running", "Status": "Up 2 days" @@ -130,7 +132,9 @@ } ], "Labels": { - "com.docker.compose.project": "webstack" + "com.docker.compose.project": "webstack", + "org.opencontainers.image.version": "15.14", + "org.opencontainers.image.created": "not-a-timestamp" }, "State": "running", "Status": "Up 1 day" diff --git a/tests/components/portainer/snapshots/test_sensor.ambr b/tests/components/portainer/snapshots/test_sensor.ambr index 8336c03bfcfa..357a4042e3e0 100644 --- a/tests/components/portainer/snapshots/test_sensor.ambr +++ b/tests/components/portainer/snapshots/test_sensor.ambr @@ -3273,6 +3273,107 @@ 'state': 'docker.io/library/nginx:latest', }) # --- +# name: test_all_entities[sensor.serene_banach_image_created-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.serene_banach_image_created', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Image created', + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Image created', + 'platform': 'portainer', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'image_created', + 'unique_id': 'portainer_test_entry_123_serene_banach_image_created', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[sensor.serene_banach_image_created-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'timestamp', + : 'serene_banach Image created', + }), + 'context': , + 'entity_id': 'sensor.serene_banach_image_created', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '2026-05-02T14:31:00+00:00', + }) +# --- +# name: test_all_entities[sensor.serene_banach_image_version-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.serene_banach_image_version', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Image version', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Image version', + 'platform': 'portainer', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'image_version', + 'unique_id': 'portainer_test_entry_123_serene_banach_image_version', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[sensor.serene_banach_image_version-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'serene_banach Image version', + }), + 'context': , + 'entity_id': 'sensor.serene_banach_image_version', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '1.29.3', + }) +# --- # name: test_all_entities[sensor.serene_banach_memory_limit-entry] EntityRegistryEntrySnapshot({ 'aliases': list([ @@ -3691,6 +3792,107 @@ 'state': 'docker.io/library/postgres:15', }) # --- +# name: test_all_entities[sensor.stoic_turing_image_created-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.stoic_turing_image_created', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Image created', + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Image created', + 'platform': 'portainer', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'image_created', + 'unique_id': 'portainer_test_entry_123_stoic_turing_image_created', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[sensor.stoic_turing_image_created-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'timestamp', + : 'stoic_turing Image created', + }), + 'context': , + 'entity_id': 'sensor.stoic_turing_image_created', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_all_entities[sensor.stoic_turing_image_version-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.stoic_turing_image_version', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Image version', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Image version', + 'platform': 'portainer', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'image_version', + 'unique_id': 'portainer_test_entry_123_stoic_turing_image_version', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[sensor.stoic_turing_image_version-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'stoic_turing Image version', + }), + 'context': , + 'entity_id': 'sensor.stoic_turing_image_version', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '15.14', + }) +# --- # name: test_all_entities[sensor.stoic_turing_memory_limit-entry] EntityRegistryEntrySnapshot({ 'aliases': list([ diff --git a/tests/components/ps4/test_media_player.py b/tests/components/ps4/test_media_player.py index f47d9961a39f..a45406a2ebb0 100644 --- a/tests/components/ps4/test_media_player.py +++ b/tests/components/ps4/test_media_player.py @@ -313,7 +313,7 @@ async def test_device_info_is_set_from_status_correctly( mock_state = hass.states.get(mock_entity_id).state - mock_d_entries = device_registry.devices + mock_d_entries = device_registry._devices mock_entry = device_registry.async_get_device_by_identifier( (DOMAIN, MOCK_HOST_ID), MOCK_ENTRY_ID ) @@ -359,7 +359,7 @@ async def test_device_info_is_assummed( identifiers={(DOMAIN, MOCK_HOST_ID)}, sw_version=MOCK_HOST_VERSION, ) - mock_d_entries = device_registry.devices + mock_d_entries = device_registry._devices assert len(mock_d_entries) == 1 # Create a entity_registry entry which is using identifiers from device. @@ -389,7 +389,7 @@ async def test_device_info_assummed_works( """Reverse test that device info assumption works.""" mock_entity_id = await setup_mock_component(hass) mock_state = hass.states.get(mock_entity_id).state - mock_d_entries = device_registry.devices + mock_d_entries = device_registry._devices # Ensure that state is not set. assert mock_state == STATE_UNKNOWN diff --git a/tests/components/recorder/auto_repairs/statistics/test_duplicates.py b/tests/components/recorder/auto_repairs/statistics/test_duplicates.py index e51129b643de..90f21e575c46 100644 --- a/tests/components/recorder/auto_repairs/statistics/test_duplicates.py +++ b/tests/components/recorder/auto_repairs/statistics/test_duplicates.py @@ -183,7 +183,11 @@ async def test_delete_metadata_duplicates( def get_statistics_meta(hass: HomeAssistant) -> list: with session_scope(hass=hass, read_only=True) as session: - return list(session.query(recorder.db_schema.StatisticsMeta).all()) + return list( + session.query(recorder.db_schema.StatisticsMeta) + .order_by(recorder.db_schema.StatisticsMeta.id) + .all() + ) # Create some duplicated statistics_meta with schema version 28 with ( @@ -308,7 +312,11 @@ async def test_delete_metadata_duplicates_many( def get_statistics_meta(hass: HomeAssistant) -> list: with session_scope(hass=hass, read_only=True) as session: - return list(session.query(recorder.db_schema.StatisticsMeta).all()) + return list( + session.query(recorder.db_schema.StatisticsMeta) + .order_by(recorder.db_schema.StatisticsMeta.id) + .all() + ) # Create some duplicated statistics with schema version 28 with ( diff --git a/tests/components/recorder/test_util.py b/tests/components/recorder/test_util.py index fd886b99ae84..8c63967e3217 100644 --- a/tests/components/recorder/test_util.py +++ b/tests/components/recorder/test_util.py @@ -749,6 +749,210 @@ async def test_no_issue_for_mariadb_with_MDEV_25020( assert database_engine.optimizer.slow_dependent_subquery is False +async def test_issue_for_deprecated_pgsql_version( + hass: HomeAssistant, + issue_registry: ir.IssueRegistry, +) -> None: + """Test we create and delete an issue for a PostgreSQL version below the minimum.""" + instance_mock = MagicMock() + instance_mock.hass = hass + execute_args = [] + close_mock = MagicMock() + reported_version = "13.2" + + def execute_mock(statement): + nonlocal execute_args + execute_args.append(statement) + + def fetchall_mock(): + nonlocal execute_args + if execute_args[-1] == "SHOW server_version": + return [[reported_version]] + return None + + def _make_cursor_mock(*_): + return MagicMock(execute=execute_mock, close=close_mock, fetchall=fetchall_mock) + + dbapi_connection = MagicMock(cursor=_make_cursor_mock) + + database_engine = await hass.async_add_executor_job( + util.setup_connection_for_dialect, + instance_mock, + "postgresql", + dbapi_connection, + True, + ) + await hass.async_block_till_done() + + issue = issue_registry.async_get_issue(DOMAIN, "database_engine_too_old") + assert issue is not None + assert issue.breaks_in_ha_version == "2027.3.0" + assert issue.translation_placeholders == { + "database_engine": "PostgreSQL", + "server_version": "13.2", + "min_version": "15.0", + } + assert database_engine is not None + + reported_version = "15.2" + database_engine = await hass.async_add_executor_job( + util.setup_connection_for_dialect, + instance_mock, + "postgresql", + dbapi_connection, + True, + ) + await hass.async_block_till_done() + + assert issue_registry.async_get_issue(DOMAIN, "database_engine_too_old") is None + assert database_engine is not None + + +@pytest.mark.parametrize( + ( + "server_version", + "extracted_version", + "engine_name", + "lts_versions", + "supported_version", + ), + [ + ( + "10.6.0-MariaDB", + "10.6.0", + "MariaDB", + "10.11, 11.4, 11.8, 12.3", + "11.8.1-MariaDB", + ), + ( + "11.5.0-MariaDB", + "11.5.0", + "MariaDB", + "10.11, 11.4, 11.8, 12.3", + "11.8.1-MariaDB", + ), + ("8.0.0", "8.0.0", "MySQL", "8.4, 9.7", "8.4.0"), + ("8.2.0", "8.2.0", "MySQL", "8.4, 9.7", "9.7.0"), + ], +) +async def test_issue_for_not_supported_lts_version( + hass: HomeAssistant, + server_version: str, + extracted_version: str, + engine_name: str, + lts_versions: str, + supported_version: str, + issue_registry: ir.IssueRegistry, +) -> None: + """Test we warn about MariaDB/MySQL versions that are not a supported LTS release.""" + instance_mock = MagicMock() + instance_mock.hass = hass + execute_args = [] + close_mock = MagicMock() + reported_version = server_version + + def execute_mock(statement): + nonlocal execute_args + execute_args.append(statement) + + def fetchall_mock(): + nonlocal execute_args + if execute_args[-1] == "SELECT VERSION()": + return [[reported_version]] + return None + + def _make_cursor_mock(*_): + return MagicMock(execute=execute_mock, close=close_mock, fetchall=fetchall_mock) + + dbapi_connection = MagicMock(cursor=_make_cursor_mock) + + database_engine = await hass.async_add_executor_job( + util.setup_connection_for_dialect, + instance_mock, + "mysql", + dbapi_connection, + True, + ) + await hass.async_block_till_done() + + issue = issue_registry.async_get_issue(DOMAIN, "database_engine_not_supported_lts") + assert issue is not None + assert issue.breaks_in_ha_version == "2027.3.0" + assert issue.translation_placeholders == { + "database_engine": engine_name, + "server_version": extracted_version, + "lts_versions": lts_versions, + } + assert database_engine is not None + + reported_version = supported_version + database_engine = await hass.async_add_executor_job( + util.setup_connection_for_dialect, + instance_mock, + "mysql", + dbapi_connection, + True, + ) + await hass.async_block_till_done() + + assert ( + issue_registry.async_get_issue(DOMAIN, "database_engine_not_supported_lts") + is None + ) + assert database_engine is not None + + +@pytest.mark.parametrize( + "server_version", + [ + "12.4.0-MariaDB", # non-LTS MariaDB release newer than we know about + "13.4.0-MariaDB", # LTS MariaDB release newer than we know about + "9.8.0", # non-LTS MySQL release newer than we know about + "10.4.0", # LTS MySQL release newer than we know about + ], +) +async def test_no_issue_for_future_database_version( + hass: HomeAssistant, + server_version: str, + issue_registry: ir.IssueRegistry, +) -> None: + """Test we assume versions newer than the latest known non-LTS release are supported.""" + instance_mock = MagicMock() + instance_mock.hass = hass + execute_args = [] + close_mock = MagicMock() + + def execute_mock(statement): + nonlocal execute_args + execute_args.append(statement) + + def fetchall_mock(): + nonlocal execute_args + if execute_args[-1] == "SELECT VERSION()": + return [[server_version]] + return None + + def _make_cursor_mock(*_): + return MagicMock(execute=execute_mock, close=close_mock, fetchall=fetchall_mock) + + dbapi_connection = MagicMock(cursor=_make_cursor_mock) + + database_engine = await hass.async_add_executor_job( + util.setup_connection_for_dialect, + instance_mock, + "mysql", + dbapi_connection, + True, + ) + await hass.async_block_till_done() + + assert ( + issue_registry.async_get_issue(DOMAIN, "database_engine_not_supported_lts") + is None + ) + assert database_engine is not None + + @pytest.mark.skip_on_db_engine(["mysql", "postgresql"]) @pytest.mark.usefixtures("skip_by_db_engine") async def test_basic_sanity_check( diff --git a/tests/components/remember_the_milk/conftest.py b/tests/components/remember_the_milk/conftest.py index ac80cf2972bf..cd667cfd34ca 100644 --- a/tests/components/remember_the_milk/conftest.py +++ b/tests/components/remember_the_milk/conftest.py @@ -1,37 +1,71 @@ """Provide common pytest fixtures.""" from collections.abc import AsyncGenerator, Generator -from unittest.mock import MagicMock, patch +from unittest.mock import AsyncMock, MagicMock, patch import pytest +from homeassistant.components.remember_the_milk.const import DOMAIN from homeassistant.core import HomeAssistant -from .const import TOKEN +from .const import CREATE_ENTRY_DATA, PROFILE, TOKEN_RESPONSE + +from tests.common import MockConfigEntry + + +@pytest.fixture +def ignore_missing_translations(request: pytest.FixtureRequest) -> list[str]: + """Ignore translations for the per-account services registered at runtime. + + The services are only registered when the integration is set up, so only + ignore them for the test modules that load the integration. + """ + if request.module.__name__.endswith((".test_entity", ".test_init")): + return [ + f"component.{DOMAIN}.services.{PROFILE}_create_task.", + f"component.{DOMAIN}.services.{PROFILE}_complete_task.", + ] + return [] @pytest.fixture(name="client") def client_fixture() -> Generator[MagicMock]: """Create a mock client.""" - client = MagicMock() with ( patch( - "homeassistant.components.remember_the_milk.entity.Rtm" - ) as entity_client_class, - patch("homeassistant.components.remember_the_milk.Rtm") as client_class, + "homeassistant.components.remember_the_milk.AioRTMClient", + ) as client_class, + patch( + "homeassistant.components.remember_the_milk.config_flow.Auth.check_token", + AsyncMock(return_value=TOKEN_RESPONSE), + ), + patch( + "homeassistant.components.remember_the_milk.config_flow.Auth.authenticate_desktop", + AsyncMock(return_value=("https://test-url.com", "test-frob")), + ), + patch( + "homeassistant.components.remember_the_milk.config_flow.Auth.get_token", + AsyncMock(return_value=TOKEN_RESPONSE), + ), ): - entity_client_class.return_value = client - client_class.return_value = client - client.token = TOKEN - client.token_valid.return_value = True + client = client_class.return_value + client.rtm.api.check_token = AsyncMock(return_value=TOKEN_RESPONSE) timelines = MagicMock() - timelines.timeline.value = "1234" - client.rtm.timelines.create.return_value = timelines - add_response = MagicMock() - add_response.list.id = "1" - add_response.list.taskseries.id = "2" - add_response.list.taskseries.task.id = "3" - client.rtm.tasks.add.return_value = add_response + timelines.timeline = 1234 + client.rtm.timelines.create = AsyncMock(return_value=timelines) + response = MagicMock() + response.task_list.id = 1 + response.task_list.taskseries = [] + task_series = MagicMock() + task_series.id = 2 + task_series.task = [] + task = MagicMock() + task.id = 3 + task_series.task.append(task) + response.task_list.taskseries.append(task_series) + client.rtm.tasks.add = AsyncMock(return_value=response) + client.rtm.tasks.complete = AsyncMock(return_value=response) + client.rtm.tasks.set_name = AsyncMock(return_value=response) yield client @@ -43,6 +77,28 @@ async def storage(hass: HomeAssistant, client) -> AsyncGenerator[MagicMock]: "homeassistant.components.remember_the_milk.RememberTheMilkConfiguration" ) as storage_class: storage = storage_class.return_value - storage.get_token.return_value = TOKEN storage.get_rtm_id.return_value = None + storage.get_token.return_value = "test-token" yield storage + + +@pytest.fixture +def config_entry(hass: HomeAssistant) -> MockConfigEntry: + """Return a mock config entry.""" + entry = MockConfigEntry( + data=CREATE_ENTRY_DATA, + domain=DOMAIN, + unique_id="1234567", + ) + entry.add_to_hass(hass) + return entry + + +@pytest.fixture +def mock_setup_entry() -> Generator[AsyncMock]: + """Override async_setup_entry.""" + with patch( + "homeassistant.components.remember_the_milk.async_setup_entry", + return_value=True, + ) as mock_setup_entry: + yield mock_setup_entry diff --git a/tests/components/remember_the_milk/const.py b/tests/components/remember_the_milk/const.py index bed39eec5f85..f641024ce392 100644 --- a/tests/components/remember_the_milk/const.py +++ b/tests/components/remember_the_milk/const.py @@ -3,17 +3,33 @@ import json PROFILE = "myprofile" -CONFIG = { - "name": f"{PROFILE}", +CREATE_ENTRY_DATA = { "api_key": "test-api-key", - "shared_secret": "test-shared-secret", + "shared_secret": "test-secret", + "token": "test-token", + "username": PROFILE, } -TOKEN = "mytoken" -JSON_STRING = json.dumps( +TOKEN_RESPONSE = { + "token": "test-token", + "perms": "delete", + "user": {"id": "1234567", "username": PROFILE, "fullname": "John Smith"}, +} + +# The legacy configuration file format: +LEGACY_JSON_STRING = json.dumps( { - "myprofile": { + PROFILE: { "token": "mytoken", "id_map": {"123": {"list_id": "1", "timeseries_id": "2", "task_id": "3"}}, } } ) + +# The new configuration file format: +JSON_STRING = json.dumps( + { + PROFILE: { + "id_map": {"123": {"list_id": "1", "timeseries_id": "2", "task_id": "3"}}, + } + } +) diff --git a/tests/components/remember_the_milk/test_config_flow.py b/tests/components/remember_the_milk/test_config_flow.py new file mode 100644 index 000000000000..91af9c600372 --- /dev/null +++ b/tests/components/remember_the_milk/test_config_flow.py @@ -0,0 +1,270 @@ +"""Test the Remember The Milk config flow.""" + +import asyncio +from collections.abc import Awaitable +from typing import Any +from unittest.mock import AsyncMock, patch + +from aiortm import AioRTMError, AuthError +import pytest + +from homeassistant import config_entries +from homeassistant.components.remember_the_milk.config_flow import TOKEN_TIMEOUT_SEC +from homeassistant.components.remember_the_milk.const import DOMAIN +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType + +from .const import CREATE_ENTRY_DATA, PROFILE, TOKEN_RESPONSE + +from tests.common import MockConfigEntry + +pytestmark = pytest.mark.usefixtures("mock_setup_entry") + + +async def test_successful_flow( + hass: HomeAssistant, client: AsyncMock, mock_setup_entry: AsyncMock +) -> None: + """Test successful flow.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] is FlowResultType.FORM + assert not result["errors"] + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + "api_key": "test-api-key", + "shared_secret": "test-secret", + }, + ) + + result = await hass.config_entries.flow.async_configure(result["flow_id"], {}) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == TOKEN_RESPONSE["user"]["fullname"] + assert result["data"] == CREATE_ENTRY_DATA + assert result["result"].unique_id == TOKEN_RESPONSE["user"]["id"] + assert len(mock_setup_entry.mock_calls) == 1 + + +@pytest.mark.parametrize( + ("exception", "error"), + [ + (AuthError, "invalid_auth"), + (AioRTMError, "cannot_connect"), + (Exception, "unknown"), + ], +) +async def test_form_errors( + hass: HomeAssistant, + client: AsyncMock, + mock_setup_entry: AsyncMock, + exception: Exception, + error: str, +) -> None: + """Test form errors when getting the authentication URL.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + with patch( + "homeassistant.components.remember_the_milk.config_flow.Auth.authenticate_desktop", + side_effect=exception, + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + "api_key": "test-api-key", + "shared_secret": "test-secret", + }, + ) + + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {"base": error} + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + "api_key": "test-api-key", + "shared_secret": "test-secret", + }, + ) + + result = await hass.config_entries.flow.async_configure(result["flow_id"], {}) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == TOKEN_RESPONSE["user"]["fullname"] + assert result["data"] == CREATE_ENTRY_DATA + assert result["result"].unique_id == TOKEN_RESPONSE["user"]["id"] + assert len(mock_setup_entry.mock_calls) == 1 + + +async def mock_get_token(*args: Any) -> None: + """Handle get token.""" + await asyncio.Future() + + +@pytest.mark.parametrize( + ("side_effect", "reason", "timeout"), + [ + (AuthError, "invalid_auth", TOKEN_TIMEOUT_SEC), + (AioRTMError, "cannot_connect", TOKEN_TIMEOUT_SEC), + (Exception, "unknown", TOKEN_TIMEOUT_SEC), + (mock_get_token, "timeout_token", 0), + ], +) +async def test_token_abort_reasons( + hass: HomeAssistant, + client: AsyncMock, + side_effect: Exception | Awaitable[None], + reason: str, + timeout: int, +) -> None: + """Test abort result when getting token.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + "api_key": "test-api-key", + "shared_secret": "test-secret", + }, + ) + + with ( + patch( + "homeassistant.components.remember_the_milk.config_flow.Auth.get_token", + side_effect=side_effect, + ), + patch( + "homeassistant.components.remember_the_milk.config_flow.TOKEN_TIMEOUT_SEC", + timeout, + ), + ): + result = await hass.config_entries.flow.async_configure(result["flow_id"], {}) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == reason + + +async def test_abort_if_already_configured( + hass: HomeAssistant, client: AsyncMock, config_entry: MockConfigEntry +) -> None: + """Test abort if the same username is already configured.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] is FlowResultType.FORM + assert not result["errors"] + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + "api_key": "test-api-key", + "shared_secret": "test-secret", + }, + ) + + result = await hass.config_entries.flow.async_configure(result["flow_id"], {}) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" + + +async def test_import_flow( + hass: HomeAssistant, client: AsyncMock, mock_setup_entry: AsyncMock +) -> None: + """Test import flow with a valid stored token.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_IMPORT}, + data={ + "api_key": "test-api-key", + "shared_secret": "test-secret", + "name": PROFILE, + "token": "test-token", + }, + ) + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == TOKEN_RESPONSE["user"]["fullname"] + assert result["data"] == { + "api_key": "test-api-key", + "shared_secret": "test-secret", + "token": "test-token", + "username": PROFILE, + } + assert result["result"].unique_id == TOKEN_RESPONSE["user"]["id"] + assert len(mock_setup_entry.mock_calls) == 1 + + +@pytest.mark.parametrize( + ("token", "side_effect", "reason"), + [ + (None, None, "invalid_auth"), + ("test-token", AuthError, "invalid_auth"), + ("test-token", AioRTMError, "cannot_connect"), + ("test-token", Exception, "unknown"), + ], +) +async def test_import_flow_abort( + hass: HomeAssistant, + token: str | None, + side_effect: type[Exception] | None, + reason: str, +) -> None: + """Test import flow aborts without a valid token.""" + with patch( + "homeassistant.components.remember_the_milk.config_flow.Auth.check_token", + side_effect=side_effect, + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_IMPORT}, + data={ + "api_key": "test-api-key", + "shared_secret": "test-secret", + "name": "test-name", + "token": token, + }, + ) + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == reason + + +async def test_import_flow_username_mismatch( + hass: HomeAssistant, client: AsyncMock +) -> None: + """Test import flow aborts when the token username doesn't match the name.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_IMPORT}, + data={ + "api_key": "test-api-key", + "shared_secret": "test-secret", + "name": "other-name", + "token": "test-token", + }, + ) + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "invalid_auth" + + +async def test_import_flow_already_configured( + hass: HomeAssistant, client: AsyncMock, config_entry: MockConfigEntry +) -> None: + """Test import flow aborts when the account name is already configured.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_IMPORT}, + data={ + "api_key": "test-api-key", + "shared_secret": "test-secret", + "name": PROFILE, + "token": "test-token", + }, + ) + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" diff --git a/tests/components/remember_the_milk/test_entity.py b/tests/components/remember_the_milk/test_entity.py index bdd4189e394d..96f158aed786 100644 --- a/tests/components/remember_the_milk/test_entity.py +++ b/tests/components/remember_the_milk/test_entity.py @@ -3,29 +3,40 @@ from typing import Any from unittest.mock import MagicMock, call +from aiortm import AioRTMError, AuthError import pytest -from rtmapi import RtmRequestFailedException from homeassistant.components.remember_the_milk import DOMAIN +from homeassistant.config_entries import ConfigEntryState from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component -from .const import CONFIG, PROFILE +from .const import PROFILE + +from tests.common import MockConfigEntry + +CONFIG = { + "name": f"{PROFILE}", + "api_key": "test-api-key", + "shared_secret": "test-shared-secret", +} +@pytest.mark.usefixtures("storage") @pytest.mark.parametrize( - ("valid_token", "entity_state"), [(True, "ok"), (False, "API token invalid")] + ("check_token_side_effect", "entity_state"), + [(None, "ok"), (AuthError("Invalid token!"), "API token invalid")], ) async def test_entity_state( hass: HomeAssistant, client: MagicMock, - storage: MagicMock, - valid_token: bool, + config_entry: MockConfigEntry, + check_token_side_effect: Exception | None, entity_state: str, ) -> None: """Test the entity state.""" - client.token_valid.return_value = valid_token - assert await async_setup_component(hass, DOMAIN, {DOMAIN: CONFIG}) + client.rtm.api.check_token.side_effect = check_token_side_effect + await hass.config_entries.async_setup(config_entry.entry_id) entity_id = f"{DOMAIN}.{PROFILE}" state = hass.states.get(entity_id) @@ -50,7 +61,7 @@ async def test_entity_state( ), [ ( - ("1", "2", "3"), + (1, 2, 3), f"{PROFILE}_create_task", {"name": "Test 1"}, 0, @@ -59,9 +70,9 @@ async def test_entity_state( "rtm.tasks.add", 1, call( - timeline="1234", + timeline=1234, name="Test 1", - parse="1", + parse=True, ), "set_rtm_id", 0, @@ -77,36 +88,36 @@ async def test_entity_state( "rtm.tasks.add", 1, call( - timeline="1234", + timeline=1234, name="Test 1", - parse="1", + parse=True, ), "set_rtm_id", 1, - call(PROFILE, "test_1", "1", "2", "3"), + call(PROFILE, "test_1", 1, 2, 3), ), ( - ("1", "2", "3"), + (1, 2, 3), f"{PROFILE}_create_task", {"name": "Test 1", "id": "test_1"}, 1, call(PROFILE, "test_1"), 1, - "rtm.tasks.setName", + "rtm.tasks.set_name", 1, call( name="Test 1", - list_id="1", - taskseries_id="2", - task_id="3", - timeline="1234", + list_id=1, + taskseries_id=2, + task_id=3, + timeline=1234, ), "set_rtm_id", 0, None, ), ( - ("1", "2", "3"), + (1, 2, 3), f"{PROFILE}_complete_task", {"id": "test_1"}, 1, @@ -115,10 +126,10 @@ async def test_entity_state( "rtm.tasks.complete", 1, call( - list_id="1", - taskseries_id="2", - task_id="3", - timeline="1234", + list_id=1, + taskseries_id=2, + task_id=3, + timeline=1234, ), "delete_rtm_id", 1, @@ -173,52 +184,52 @@ async def test_services( ), [ ( - ("1", "2", "3"), + (1, 2, 3), f"{PROFILE}_create_task", {"name": "Test 1"}, "rtm.timelines.create", - RtmRequestFailedException("rtm.timelines.create", "400", "Bad request"), - "Request rtm.timelines.create failed. Status: 400, reason: Bad request.", + AioRTMError("Boom!"), + "Error creating new Remember The Milk task for account myprofile: Boom!", ), ( - ("1", "2", "3"), + (1, 2, 3), f"{PROFILE}_create_task", {"name": "Test 1"}, "rtm.tasks.add", - RtmRequestFailedException("rtm.tasks.add", "400", "Bad request"), - "Request rtm.tasks.add failed. Status: 400, reason: Bad request.", + AioRTMError("Boom!"), + "Error creating new Remember The Milk task for account myprofile: Boom!", ), ( None, f"{PROFILE}_create_task", {"name": "Test 1", "id": "test_1"}, "rtm.timelines.create", - RtmRequestFailedException("rtm.timelines.create", "400", "Bad request"), - "Request rtm.timelines.create failed. Status: 400, reason: Bad request.", + AioRTMError("Boom!"), + "Error creating new Remember The Milk task for account myprofile: Boom!", ), ( None, f"{PROFILE}_create_task", {"name": "Test 1", "id": "test_1"}, "rtm.tasks.add", - RtmRequestFailedException("rtm.tasks.add", "400", "Bad request"), - "Request rtm.tasks.add failed. Status: 400, reason: Bad request.", + AioRTMError("Boom!"), + "Error creating new Remember The Milk task for account myprofile: Boom!", ), ( - ("1", "2", "3"), + (1, 2, 3), f"{PROFILE}_create_task", {"name": "Test 1", "id": "test_1"}, "rtm.timelines.create", - RtmRequestFailedException("rtm.timelines.create", "400", "Bad request"), - "Request rtm.timelines.create failed. Status: 400, reason: Bad request.", + AioRTMError("Boom!"), + "Error creating new Remember The Milk task for account myprofile: Boom!", ), ( - ("1", "2", "3"), + (1, 2, 3), f"{PROFILE}_create_task", {"name": "Test 1", "id": "test_1"}, - "rtm.tasks.setName", - RtmRequestFailedException("rtm.tasks.setName", "400", "Bad request"), - "Request rtm.tasks.setName failed. Status: 400, reason: Bad request.", + "rtm.tasks.set_name", + AioRTMError("Boom!"), + "Error creating new Remember The Milk task for account myprofile: Boom!", ), ( None, @@ -232,20 +243,20 @@ async def test_services( ), ), ( - ("1", "2", "3"), + (1, 2, 3), f"{PROFILE}_complete_task", {"id": "test_1"}, "rtm.timelines.create", - RtmRequestFailedException("rtm.timelines.create", "400", "Bad request"), - "Request rtm.timelines.create failed. Status: 400, reason: Bad request.", + AioRTMError("Boom!"), + "Error completing task with id test_1 for account myprofile: Boom!", ), ( - ("1", "2", "3"), + (1, 2, 3), f"{PROFILE}_complete_task", {"id": "test_1"}, "rtm.tasks.complete", - RtmRequestFailedException("rtm.tasks.complete", "400", "Bad request"), - "Request rtm.tasks.complete failed. Status: 400, reason: Bad request.", + AioRTMError("Boom!"), + "Error completing task with id test_1 for account myprofile: Boom!", ), ], ) @@ -274,3 +285,74 @@ async def test_services_errors( await hass.services.async_call(DOMAIN, service, service_data, blocking=True) assert error_message in caplog.text + + +@pytest.mark.parametrize( + ( + "get_rtm_id_return_value", + "service", + "service_data", + "method", + "error_message", + ), + [ + ( + (1, 2, 3), + f"{PROFILE}_create_task", + {"name": "Test 1"}, + "rtm.timelines.create", + "Invalid authentication when creating task for account myprofile: Boom!", + ), + ( + (1, 2, 3), + f"{PROFILE}_create_task", + {"name": "Test 1", "id": "test_1"}, + "rtm.tasks.set_name", + "Invalid authentication when creating task for account myprofile: Boom!", + ), + ( + (1, 2, 3), + f"{PROFILE}_complete_task", + {"id": "test_1"}, + "rtm.tasks.complete", + ( + "Invalid authentication when completing task with id test_1 " + "for account myprofile: Boom!" + ), + ), + ], +) +async def test_services_auth_errors( + hass: HomeAssistant, + client: MagicMock, + storage: MagicMock, + caplog: pytest.LogCaptureFixture, + get_rtm_id_return_value: Any, + service: str, + service_data: dict[str, Any], + method: str, + error_message: str, +) -> None: + """Test that an auth error invalidates the token and reloads the entry.""" + assert await async_setup_component(hass, DOMAIN, {DOMAIN: CONFIG}) + storage.get_rtm_id.return_value = get_rtm_id_return_value + + entry = hass.config_entries.async_entries(DOMAIN)[0] + assert entry.state is ConfigEntryState.LOADED + state = hass.states.get(f"{DOMAIN}.{PROFILE}") + assert state + assert state.state == "ok" + + client_method = client + for name in method.split("."): + client_method = getattr(client_method, name) + + client_method.side_effect = AuthError("Boom!") + # The token is now invalid, so re-checking it during the reload fails too. + client.rtm.api.check_token.side_effect = AuthError("Invalid token!") + + await hass.services.async_call(DOMAIN, service, service_data, blocking=True) + await hass.async_block_till_done() + + assert error_message in caplog.text + assert entry.state is ConfigEntryState.SETUP_ERROR diff --git a/tests/components/remember_the_milk/test_init.py b/tests/components/remember_the_milk/test_init.py index e3cc2dbdd88c..89f41b087442 100644 --- a/tests/components/remember_the_milk/test_init.py +++ b/tests/components/remember_the_milk/test_init.py @@ -1,68 +1,115 @@ """Test the Remember The Milk integration.""" -from collections.abc import Generator -from unittest.mock import MagicMock, patch +from unittest.mock import MagicMock +from aiortm import AioRTMError, AuthError import pytest -from homeassistant.components.remember_the_milk import DOMAIN -from homeassistant.core import HomeAssistant +from homeassistant.components.remember_the_milk.const import DOMAIN +from homeassistant.config_entries import ConfigEntryState +from homeassistant.core import DOMAIN as HOMEASSISTANT_DOMAIN, HomeAssistant +from homeassistant.helpers import issue_registry as ir from homeassistant.setup import async_setup_component -from .const import CONFIG, PROFILE, TOKEN +from .const import PROFILE + +from tests.common import MockConfigEntry + +CONFIG = { + "name": "myprofile", + "api_key": "test-api-key", + "shared_secret": "test-shared-secret", +} -@pytest.fixture(autouse=True) -def configure_id() -> Generator[str]: - """Fixture to return a configure_id.""" - mock_id = "1-1" - with patch( - "homeassistant.components.configurator.Configurator._generate_unique_id" - ) as generate_id: - generate_id.return_value = mock_id - yield mock_id - - -@pytest.mark.parametrize( - ("token", "rtm_entity_exists", "configurator_end_state"), - [(TOKEN, True, "configured"), (None, False, "configure")], -) -@pytest.mark.parametrize( - "ignore_missing_translations", ["component.configurator.services.configure."] -) -async def test_configurator( +@pytest.mark.usefixtures("storage") +async def test_load_unload_config_entry( hass: HomeAssistant, client: MagicMock, - storage: MagicMock, - configure_id: str, - token: str | None, - rtm_entity_exists: bool, - configurator_end_state: str, + config_entry: MockConfigEntry, ) -> None: - """Test configurator.""" + """Test loading and unloading a config entry.""" + 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) + await hass.async_block_till_done() + + assert config_entry.state is ConfigEntryState.NOT_LOADED + + +@pytest.mark.usefixtures("storage") +@pytest.mark.parametrize( + ("side_effect", "entry_state", "ignore_missing_translations"), + [ + pytest.param( + AuthError("Invalid token!"), + ConfigEntryState.SETUP_ERROR, + [ + f"component.{DOMAIN}.services.{PROFILE}_create_task.", + f"component.{DOMAIN}.services.{PROFILE}_complete_task.", + ], + id="auth_error", + ), + pytest.param( + AioRTMError("Connection failed!"), + ConfigEntryState.SETUP_RETRY, + [], + id="rtm_error", + ), + ], +) +async def test_config_entry_check_token_fails( + hass: HomeAssistant, + client: MagicMock, + config_entry: MockConfigEntry, + side_effect: Exception, + entry_state: ConfigEntryState, +) -> None: + """Test that token check failures put the entry in the expected state.""" + client.rtm.api.check_token.side_effect = side_effect + + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + assert config_entry.state is entry_state + + +@pytest.mark.usefixtures("client", "storage") +async def test_import_creates_deprecation_issue( + hass: HomeAssistant, + issue_registry: ir.IssueRegistry, +) -> None: + """Test a successful YAML import creates a deprecation repair issue.""" + assert await async_setup_component(hass, DOMAIN, {DOMAIN: CONFIG}) + await hass.async_block_till_done() + + assert len(hass.config_entries.async_entries(DOMAIN)) == 1 + assert issue_registry.async_get_issue( + HOMEASSISTANT_DOMAIN, f"deprecated_yaml_{DOMAIN}" + ) + + +@pytest.mark.parametrize("ignore_missing_translations", [[]]) +@pytest.mark.usefixtures("client") +async def test_import_without_token_creates_issue( + hass: HomeAssistant, + storage: MagicMock, + issue_registry: ir.IssueRegistry, +) -> None: + """Test YAML import without a stored token aborts and creates an issue. + + Without a token the import can't be completed, so no config entry is + created and the user is guided to set the integration up via the UI. + """ storage.get_token.return_value = None - client.authenticate_desktop.return_value = ("test-url", "test-frob") - client.token = token - rtm_entity_id = f"{DOMAIN}.{PROFILE}" - configure_entity_id = f"configurator.{DOMAIN}_{PROFILE}" assert await async_setup_component(hass, DOMAIN, {DOMAIN: CONFIG}) await hass.async_block_till_done() - assert hass.states.get(rtm_entity_id) is None - state = hass.states.get(configure_entity_id) - assert state - assert state.state == "configure" - - await hass.services.async_call( - "configurator", - "configure", - {"configure_id": configure_id}, - blocking=True, + assert not hass.config_entries.async_entries(DOMAIN) + assert issue_registry.async_get_issue( + DOMAIN, "deprecated_yaml_import_issue_invalid_auth" ) - await hass.async_block_till_done() - - assert bool(hass.states.get(rtm_entity_id)) == rtm_entity_exists - state = hass.states.get(configure_entity_id) - assert state - assert state.state == configurator_end_state diff --git a/tests/components/remember_the_milk/test_storage.py b/tests/components/remember_the_milk/test_storage.py index 6ae774a3d0d3..e872fe657e57 100644 --- a/tests/components/remember_the_milk/test_storage.py +++ b/tests/components/remember_the_milk/test_storage.py @@ -8,51 +8,52 @@ import pytest from homeassistant.components import remember_the_milk as rtm from homeassistant.core import HomeAssistant -from .const import JSON_STRING, PROFILE, TOKEN +from .const import JSON_STRING, LEGACY_JSON_STRING, PROFILE -def test_set_get_delete_token(hass: HomeAssistant) -> None: - """Test set, get and delete token.""" - open_mock = mock_open() - with patch( - "homeassistant.components.remember_the_milk.storage.Path.open", open_mock - ): - config = rtm.RememberTheMilkConfiguration(hass) - assert open_mock.return_value.write.call_count == 0 - assert config.get_token(PROFILE) is None - assert open_mock.return_value.write.call_count == 0 - config.set_token(PROFILE, TOKEN) - assert open_mock.return_value.write.call_count == 1 - assert open_mock.return_value.write.call_args[0][0] == json.dumps( - { - "myprofile": { - "id_map": {}, - "token": "mytoken", - } - } - ) - assert config.get_token(PROFILE) == TOKEN - assert open_mock.return_value.write.call_count == 1 - config.delete_token(PROFILE) - assert open_mock.return_value.write.call_count == 2 - assert open_mock.return_value.write.call_args[0][0] == json.dumps({}) - assert config.get_token(PROFILE) is None - assert open_mock.return_value.write.call_count == 2 +@pytest.mark.parametrize( + "json_string", + [JSON_STRING, LEGACY_JSON_STRING], + ids=["new_format", "legacy_format"], +) +def test_config_load(hass: HomeAssistant, json_string: str) -> None: + """Test loading from the file. - -def test_config_load(hass: HomeAssistant) -> None: - """Test loading from the file.""" + The legacy configuration file format stored the ids as strings, so + check that the ids are always returned as integers. + """ + config = rtm.RememberTheMilkConfiguration(hass) with ( patch( "homeassistant.components.remember_the_milk.storage.Path.open", - mock_open(read_data=JSON_STRING), + mock_open(read_data=json_string), ), ): - config = rtm.RememberTheMilkConfiguration(hass) + config.setup() rtm_id = config.get_rtm_id(PROFILE, "123") assert rtm_id is not None - assert rtm_id == ("1", "2", "3") + assert rtm_id == (1, 2, 3) + + +@pytest.mark.parametrize( + ("json_string", "expected_token"), + [(LEGACY_JSON_STRING, "mytoken"), (JSON_STRING, None)], + ids=["legacy_format", "new_format"], +) +def test_get_token( + hass: HomeAssistant, json_string: str, expected_token: str | None +) -> None: + """Test getting the stored token for a profile.""" + config = rtm.RememberTheMilkConfiguration(hass) + with patch( + "homeassistant.components.remember_the_milk.storage.Path.open", + mock_open(read_data=json_string), + ): + config.setup() + + assert config.get_token(PROFILE) == expected_token + assert config.get_token("unknown-profile") is None @pytest.mark.parametrize( @@ -67,7 +68,7 @@ def test_config_load_file_error(hass: HomeAssistant, side_effect: Exception) -> side_effect=side_effect, ), ): - config = rtm.RememberTheMilkConfiguration(hass) + config.setup() # The config should be empty and we should not have any errors # when trying to access it. @@ -84,7 +85,7 @@ def test_config_load_invalid_data(hass: HomeAssistant) -> None: mock_open(read_data="random characters"), ), ): - config = rtm.RememberTheMilkConfiguration(hass) + config.setup() # The config should be empty and we should not have any errors # when trying to access it. @@ -95,15 +96,15 @@ def test_config_load_invalid_data(hass: HomeAssistant) -> None: def test_config_set_delete_id(hass: HomeAssistant) -> None: """Test setting and deleting an id from the config.""" hass_id = "123" - list_id = "1" - timeseries_id = "2" - rtm_id = "3" + list_id = 1 + timeseries_id = 2 + rtm_id = 3 open_mock = mock_open() config = rtm.RememberTheMilkConfiguration(hass) with patch( "homeassistant.components.remember_the_milk.storage.Path.open", open_mock ): - config = rtm.RememberTheMilkConfiguration(hass) + config.setup() assert open_mock.return_value.write.call_count == 0 assert config.get_rtm_id(PROFILE, hass_id) is None assert open_mock.return_value.write.call_count == 0 @@ -114,7 +115,11 @@ def test_config_set_delete_id(hass: HomeAssistant) -> None: { "myprofile": { "id_map": { - "123": {"list_id": "1", "timeseries_id": "2", "task_id": "3"} + "123": { + "list_id": "1", + "timeseries_id": "2", + "task_id": "3", + } } } } diff --git a/tests/components/reolink/snapshots/test_diagnostics.ambr b/tests/components/reolink/snapshots/test_diagnostics.ambr index 77628e28f682..48b1a55edccf 100644 --- a/tests/components/reolink/snapshots/test_diagnostics.ambr +++ b/tests/components/reolink/snapshots/test_diagnostics.ambr @@ -72,6 +72,10 @@ 0, ]), 'cmd list': dict({ + '115': dict({ + '0': 1, + 'null': 2, + }), '208': dict({ '0': 1, 'null': 1, @@ -84,10 +88,38 @@ '0': 1, 'null': 1, }), - '594': dict({ + '439': dict({ '0': 1, 'null': 1, }), + '483': dict({ + '0': 1, + 'null': 1, + }), + '527': dict({ + '0': 1, + 'null': 1, + }), + '529': dict({ + '0': 2, + 'null': 2, + }), + '531': dict({ + '0': 2, + 'null': 2, + }), + '549': dict({ + '0': 2, + 'null': 2, + }), + '551': dict({ + '0': 2, + 'null': 2, + }), + '594': dict({ + '0': 4, + 'null': 4, + }), '609': dict({ '0': 1, 'null': 1, @@ -101,12 +133,12 @@ 'null': 2, }), 'GetAiAlarm': dict({ - '0': 6, - 'null': 6, + '0': 12, + 'null': 12, }), 'GetAiCfg': dict({ - '0': 2, - 'null': 2, + '0': 4, + 'null': 4, }), 'GetAudioAlarm': dict({ '0': 1, @@ -125,8 +157,8 @@ 'null': 2, }), 'GetBatteryInfo': dict({ - '0': 1, - 'null': 1, + '0': 3, + 'null': 3, }), 'GetBuzzerAlarmV20': dict({ '0': 1, @@ -149,20 +181,27 @@ 'null': 2, }), 'GetEnc': dict({ - '0': 1, - 'null': 1, + '0': 7, + 'null': 7, }), 'GetFtp': dict({ '0': 1, 'null': 2, }), + 'GetHddInfo': dict({ + 'null': 1, + }), + 'GetImage': dict({ + '0': 5, + 'null': 5, + }), 'GetIrLights': dict({ '0': 1, 'null': 1, }), 'GetIsp': dict({ - '0': 1, - 'null': 1, + '0': 6, + 'null': 6, }), 'GetManualRec': dict({ '0': 1, @@ -176,10 +215,13 @@ '0': 1, 'null': 1, }), - 'GetPirInfo': dict({ - '0': 1, + 'GetPerformance': dict({ 'null': 1, }), + 'GetPirInfo': dict({ + '0': 4, + 'null': 4, + }), 'GetPowerLed': dict({ '0': 2, 'null': 2, @@ -192,13 +234,17 @@ '0': 2, 'null': 2, }), + 'GetPtzTraceSection': dict({ + '0': 2, + 'null': 2, + }), 'GetPush': dict({ '0': 1, 'null': 2, }), 'GetRec': dict({ - '0': 1, - 'null': 2, + '0': 2, + 'null': 4, }), 'GetScene': dict({ 'null': 1, @@ -207,8 +253,8 @@ 'null': 1, }), 'GetWhiteLed': dict({ - '0': 3, - 'null': 3, + '0': 7, + 'null': 7, }), 'GetZoomFocus': dict({ '0': 2, diff --git a/tests/components/reolink/test_diagnostics.py b/tests/components/reolink/test_diagnostics.py index 3e8ab4d0b2b9..07b13dda529a 100644 --- a/tests/components/reolink/test_diagnostics.py +++ b/tests/components/reolink/test_diagnostics.py @@ -2,6 +2,7 @@ from unittest.mock import MagicMock +import pytest from reolink_aio.api import Chime from syrupy.assertion import SnapshotAssertion @@ -12,6 +13,7 @@ from tests.components.diagnostics import get_diagnostics_for_config_entry from tests.typing import ClientSessionGenerator +@pytest.mark.usefixtures("entity_registry_enabled_by_default", "reolink_host") async def test_entry_diagnostics( hass: HomeAssistant, hass_client: ClientSessionGenerator, diff --git a/tests/components/repairs/test_models.py b/tests/components/repairs/test_models.py new file mode 100644 index 000000000000..4e4346d903a2 --- /dev/null +++ b/tests/components/repairs/test_models.py @@ -0,0 +1,177 @@ +"""Tests for repairs model.py.""" + +from collections.abc import Iterator +from contextlib import contextmanager + +import pytest + +from homeassistant.components.repairs import ( + DOMAIN, + FlowType, + RepairsFlow, + RepairsFlowResult, + repairs_flow_manager, +) +from homeassistant.config_entries import ( + SOURCE_RECONFIGURE, + ConfigFlow, + ConfigSubentryFlow, + OptionsFlow, + SubentryFlowResult, +) +from homeassistant.core import HomeAssistant, callback +import homeassistant.helpers.issue_registry as ir + +from tests.common import ( + AsyncMock, + Mock, + MockConfigEntry, + MockModule, + async_setup_component, + mock_config_flow, + mock_integration, + mock_platform, +) + + +@pytest.fixture(autouse=True) +async def mock_repairs_integration(hass: HomeAssistant) -> None: + """Mock a repairs integration.""" + hass.config.components.add("fake_integration") + + def async_create_fix_flow( + hass: HomeAssistant, + issue_id: str, + data: dict[str, str | int | float | None] | None, + ) -> RepairsFlow: + return MockFixFlowNextFlow() + + mock_platform( + hass, + "fake_integration.repairs", + Mock(async_create_fix_flow=AsyncMock(wraps=async_create_fix_flow)), + ) + + +@contextmanager +def mock_core_config_flow() -> Iterator[None]: + """Mock a config flow.""" + + class CompConfigSubentryFlowHandler(ConfigSubentryFlow): + """Config subentry flow.""" + + async def async_step_reconfigure(self, user_input=None) -> SubentryFlowResult: + return self.async_show_form(step_id="reconfigure") + + class CompOptionsFlowHandler(OptionsFlow): + """Options flow.""" + + async def async_step_init(self, user_input=None): + return self.async_show_form(step_id="init") + + class CompConfigFlow(ConfigFlow): + """Config flow with options and subentries flow.""" + + async def async_step_user(self, user_input=None): + return self.async_show_form(step_id="user") + + async def async_step_reconfigure(self, user_input=None): + return self.async_show_form(step_id="reconfigure") + + @classmethod + @callback + def async_get_supported_subentry_types( + cls, config_entry + ) -> dict[str, type[ConfigSubentryFlow]]: + return {"fake_subentry": CompConfigSubentryFlowHandler} + + @staticmethod + @callback + def async_get_options_flow(config_entry) -> CompOptionsFlowHandler: + return CompOptionsFlowHandler() + + with mock_config_flow("comp", CompConfigFlow): + yield + + +class MockFixFlowNextFlow(RepairsFlow): + """Mock flow fix supporting `next_flow`.""" + + async def async_step_init( + self, user_input: dict[str, str] | None = None + ) -> RepairsFlowResult: + """Handle the first step of a fix flow.""" + + mock_integration(self.hass, MockModule("comp")) + mock_platform(self.hass, "comp.config_flow", None) + + entries = self.hass.config_entries.async_entries("comp") + assert len(entries) == 1 + mock_entry: MockConfigEntry = entries[0] + + with mock_core_config_flow(): + if self.issue_id == FlowType.OPTIONS_FLOW: + next_flow = await self.hass.config_entries.options.async_init( + mock_entry.entry_id + ) + return self.async_create_entry( + next_flow=(FlowType.OPTIONS_FLOW, next_flow["flow_id"]), data={} + ) + # self.issue_id == "subentry_config_issue" + assert len(mock_entry.subentries) == 1 + next_flow = await self.hass.config_entries.subentries.async_init( + (mock_entry.entry_id, "fake_subentry"), + context={ + "entry_id": mock_entry.entry_id, + "subentry_id": list(mock_entry.subentries.keys())[0], + "source": SOURCE_RECONFIGURE, + }, + ) + return self.async_create_entry( + next_flow=(FlowType.CONFIG_SUBENTRIES_FLOW, next_flow["flow_id"]), + data={}, + ) + + +@pytest.mark.parametrize( + ("flow_type", "ignore_translations_for_mock_domains"), + [ + (FlowType.OPTIONS_FLOW, ["fake_integration"]), + (FlowType.CONFIG_SUBENTRIES_FLOW, ["fake_integration"]), + ], +) +async def test_fix_issue_next_flow(hass: HomeAssistant, flow_type: FlowType) -> None: + """Test that that a repair flow can refer to an options flow.""" + assert await async_setup_component(hass, DOMAIN, {}) + + mock_entry = MockConfigEntry( + domain="comp", + data={}, + subentries_data=[ + { + "unique_id": "test_1", + "title": "test 1", + "subentry_type": "fake_subentry", + "data": {}, + } + ], + ) + mock_entry.add_to_hass(hass) + + ir.async_create_issue( + hass, + issue_id=flow_type, + domain="fake_integration", + is_fixable=True, + severity="error", + translation_key="fake_key", + ) + + assert (repairs := repairs_flow_manager(hass)) + + flow = await repairs.async_init("fake_integration", data={"issue_id": flow_type}) + + next_flow_type, _ = flow["next_flow"] + + assert next_flow_type is flow_type + assert mock_entry == flow["result"] diff --git a/tests/components/repairs/test_websocket_api.py b/tests/components/repairs/test_websocket_api.py index 1343a5683bd5..b3d60ec57677 100644 --- a/tests/components/repairs/test_websocket_api.py +++ b/tests/components/repairs/test_websocket_api.py @@ -1,21 +1,32 @@ """Test the repairs websocket API.""" +from collections.abc import Iterator +from contextlib import contextmanager from http import HTTPStatus from typing import Any from unittest.mock import ANY, AsyncMock, Mock +import orjson import pytest import voluptuous as vol from homeassistant import data_entry_flow -from homeassistant.components.repairs import RepairsFlow +from homeassistant.components.repairs import FlowType, RepairsFlow, RepairsFlowResult from homeassistant.components.repairs.const import DOMAIN +from homeassistant.config_entries import ConfigFlow from homeassistant.const import __version__ as ha_version from homeassistant.core import HomeAssistant from homeassistant.helpers import issue_registry as ir from homeassistant.setup import async_setup_component -from tests.common import MockUser, mock_platform +from tests.common import ( + MockConfigEntry, + MockModule, + MockUser, + mock_config_flow, + mock_integration, + mock_platform, +) from tests.typing import ( ClientSessionGenerator, MockHAClientWebSocket, @@ -84,6 +95,10 @@ EXPECTED_DATA = { "issue_1": None, "issue_2": {"blah": "bleh"}, "abort_issue1": None, + "issue_3": None, + "invalid_flow": None, + "unknown_entry": None, + "unknown_entry_via_form": None, } @@ -135,6 +150,13 @@ async def mock_repairs_integration(hass: HomeAssistant) -> None: if issue_id == "abort_issue1": return MockFixFlowAbort() + if issue_id in [ + "issue_3", + "invalid_flow", + "unknown_entry", + "unknown_entry_via_form", + ]: + return MockFixFlowNextFlow() return MockFixFlow() mock_platform( @@ -371,6 +393,199 @@ async def test_fix_issue( assert msg["result"] == {"issues": []} +@contextmanager +def mock_core_config_flow() -> Iterator[None]: + """Mock a config flow.""" + + class CompConfigFlow(ConfigFlow): + """Config flow with options and subentries flow.""" + + async def async_step_user(self, user_input=None): + return self.async_show_form(step_id="user") + + async def async_step_reconfigure(self, user_input=None): + return self.async_show_form(step_id="reconfigure") + + with mock_config_flow("comp", CompConfigFlow): + yield + + +class MockFixFlowNextFlow(RepairsFlow): + """Mock flow fix supporting `next_flow`.""" + + async def async_step_init( + self, user_input: dict[str, str] | None = None + ) -> RepairsFlowResult: + """Handle the first step of a fix flow.""" + + assert self.issue_id in EXPECTED_DATA + + mock_integration(self.hass, MockModule("comp")) + mock_platform(self.hass, "comp.config_flow", None) + + entries = self.hass.config_entries.async_entries("comp") + assert len(entries) == 1 + mock_entry: MockConfigEntry = entries[0] + if self.issue_id == "unknown_entry_via_form": + return await self.async_step_user() + + with mock_core_config_flow(): + flow_type: str = FlowType.CONFIG_FLOW + match self.issue_id: + case "issue_3": + next_flow = await mock_entry.start_reconfigure_flow(self.hass) + case "invalid_flow": + flow_type = "fake_flow_type" + next_flow = {"flow_id": "fake_flow_id"} + case "unknown_entry": + next_flow = await mock_entry.start_reconfigure_flow(self.hass) + # Remove the entry to trigger UnknownEntry error. + await self.hass.config_entries.async_remove(mock_entry.entry_id) + return self.async_create_entry( + data={}, + next_flow=( + flow_type, + next_flow["flow_id"], + ), + ) + + async def async_step_user( + self, user_input: dict[str, str] | None = None + ) -> RepairsFlowResult: + """Handle an UnknownEntry error via a form. + + Test RepairsFlowResourceView.post error handling. + """ + if user_input: + entries = self.hass.config_entries.async_entries("comp") + mock_entry: MockConfigEntry = entries[0] + next_flow = await mock_entry.start_reconfigure_flow(self.hass) + await self.hass.config_entries.async_remove(mock_entry.entry_id) + return self.async_create_entry( + data={}, next_flow=(FlowType.CONFIG_FLOW, next_flow["flow_id"]) + ) + return self.async_show_form(step_id="user") + + +@pytest.mark.parametrize("ignore_translations_for_mock_domains", ["fake_integration"]) +async def test_fix_issue_next_flow( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + hass_ws_client: WebSocketGenerator, +) -> None: + """Test next_flow RepairFlows.""" + assert await async_setup_component(hass, "http", {}) + assert await async_setup_component(hass, DOMAIN, {}) + + ws_client = await hass_ws_client(hass) + client = await hass_client() + + issues = [{**DEFAULT_ISSUES[0], "issue_id": "issue_3"}] + await create_issues(hass, ws_client, issues=issues) + + mock_entry = MockConfigEntry( + domain="comp", + data={}, + ) + mock_entry.add_to_hass(hass) + + url = "/api/repairs/issues/fix" + + resp = await client.post( + url, json={"handler": "fake_integration", "issue_id": "issue_3"} + ) + + assert resp.status == HTTPStatus.OK, ( + f"Error: {resp.status} cause {await resp.text()}" + ) + + data = await resp.json() + + _, next_flow_id = data["next_flow"] + + assert data == { + "description_placeholders": None, + "flow_id": ANY, + "handler": "fake_integration", + "description": None, + "type": data_entry_flow.FlowResultType.CREATE_ENTRY, + "next_flow": [ + FlowType.CONFIG_FLOW, + next_flow_id, + ], + "result": orjson.loads(orjson.dumps(mock_entry.as_json_fragment)), + } + + +@pytest.mark.parametrize("ignore_translations_for_mock_domains", ["fake_integration"]) +async def test_fix_issue_next_flow_errors( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + hass_ws_client: WebSocketGenerator, +) -> None: + """Test next_flow RepairFlows.""" + assert await async_setup_component(hass, "http", {}) + assert await async_setup_component(hass, DOMAIN, {}) + + ws_client = await hass_ws_client(hass) + client = await hass_client() + + issues = [ + { + **DEFAULT_ISSUES[0], + "issue_id": "invalid_flow", + }, + { + **DEFAULT_ISSUES[0], + "issue_id": "unknown_entry", + }, + { + **DEFAULT_ISSUES[0], + "issue_id": "unknown_entry_via_form", + }, + ] + await create_issues(hass, ws_client, issues=issues) + mock_entry = MockConfigEntry( + domain="comp", + data={}, + ) + mock_entry.add_to_hass(hass) + + url = "/api/repairs/issues/fix" + + resp = await client.post( + url, json={"handler": "fake_integration", "issue_id": "invalid_flow"} + ) + + assert resp.status == HTTPStatus.NOT_FOUND + data = await resp.json() + assert "Invalid next_flow FlowType" in data["message"] + + resp = await client.post( + url, json={"handler": "fake_integration", "issue_id": "unknown_entry"} + ) + assert resp.status == HTTPStatus.BAD_REQUEST + data = await resp.json() + assert "not found in next_flow" in data["message"] + + # Re add removed mock entry + mock_entry = MockConfigEntry( + domain="comp", + data={}, + ) + mock_entry.add_to_hass(hass) + resp = await client.post( + url, json={"handler": "fake_integration", "issue_id": "unknown_entry_via_form"} + ) + assert resp.status == HTTPStatus.OK + data = await resp.json() + assert data["type"] == "form" + resp = await client.post(f"{url}/{data['flow_id']}", json={"submit": "True"}) + assert resp.status == HTTPStatus.BAD_REQUEST + data = await resp.json() + assert "not found in next_flow" in data["message"] + + async def test_fix_issue_unauth( hass: HomeAssistant, hass_client: ClientSessionGenerator, hass_admin_user: MockUser ) -> None: diff --git a/tests/components/roborock/snapshots/test_binary_sensor.ambr b/tests/components/roborock/snapshots/test_binary_sensor.ambr index 52230939514b..c08526c7f3b0 100644 --- a/tests/components/roborock/snapshots/test_binary_sensor.ambr +++ b/tests/components/roborock/snapshots/test_binary_sensor.ambr @@ -254,57 +254,6 @@ 'state': 'on', }) # --- -# name: test_binary_sensors[binary_sensor.roborock_s7_2_dock_mop_drying-entry] - EntityRegistryEntrySnapshot({ - 'aliases': list([ - None, - ]), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'binary_sensor', - 'entity_category': , - 'entity_id': 'binary_sensor.roborock_s7_2_dock_mop_drying', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'object_id_base': 'Mop drying', - 'options': dict({ - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': 'Mop drying', - 'platform': 'roborock', - 'previous_unique_id': None, - 'suggested_object_id': None, - 'supported_features': 0, - 'translation_key': 'mop_drying_status', - 'unique_id': 'dry_status_device_2', - 'unit_of_measurement': None, - }) -# --- -# name: test_binary_sensors[binary_sensor.roborock_s7_2_dock_mop_drying-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - : 'running', - : 'Roborock S7 2 Dock Mop drying', - }), - 'context': , - 'entity_id': 'binary_sensor.roborock_s7_2_dock_mop_drying', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'off', - }) -# --- # name: test_binary_sensors[binary_sensor.roborock_s7_2_mop_attached-entry] EntityRegistryEntrySnapshot({ 'aliases': list([ @@ -713,57 +662,6 @@ 'state': 'on', }) # --- -# name: test_binary_sensors[binary_sensor.roborock_s7_maxv_dock_mop_drying-entry] - EntityRegistryEntrySnapshot({ - 'aliases': list([ - None, - ]), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'binary_sensor', - 'entity_category': , - 'entity_id': 'binary_sensor.roborock_s7_maxv_dock_mop_drying', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'object_id_base': 'Mop drying', - 'options': dict({ - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': 'Mop drying', - 'platform': 'roborock', - 'previous_unique_id': None, - 'suggested_object_id': None, - 'supported_features': 0, - 'translation_key': 'mop_drying_status', - 'unique_id': 'dry_status_abc123', - 'unit_of_measurement': None, - }) -# --- -# name: test_binary_sensors[binary_sensor.roborock_s7_maxv_dock_mop_drying-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - : 'running', - : 'Roborock S7 MaxV Dock Mop drying', - }), - 'context': , - 'entity_id': 'binary_sensor.roborock_s7_maxv_dock_mop_drying', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'off', - }) -# --- # name: test_binary_sensors[binary_sensor.roborock_s7_maxv_mop_attached-entry] EntityRegistryEntrySnapshot({ 'aliases': list([ diff --git a/tests/components/roborock/test_binary_sensor.py b/tests/components/roborock/test_binary_sensor.py index e2e5f0c28f45..7eb19a554d6f 100644 --- a/tests/components/roborock/test_binary_sensor.py +++ b/tests/components/roborock/test_binary_sensor.py @@ -4,12 +4,17 @@ import copy from typing import Any import pytest +from roborock.data import RoborockDockTypeCode +from roborock.device_features import RoborockDockFeatures from roborock.exceptions import RoborockException from syrupy.assertion import SnapshotAssertion +from homeassistant.components.automation import DOMAIN as AUTOMATION_DOMAIN +from homeassistant.components.roborock.const import DOMAIN from homeassistant.const import STATE_UNAVAILABLE, Platform from homeassistant.core import HomeAssistant -from homeassistant.helpers import entity_registry as er +from homeassistant.helpers import entity_registry as er, issue_registry as ir +from homeassistant.setup import async_setup_component from .conftest import FakeDevice @@ -122,3 +127,182 @@ async def test_zeo_request_protocols_filtered_by_schema( # Verify that the second Zeo device has detergent entities but NOT softener entities assert hass.states.get("binary_sensor.zeo_two_detergent") is not None assert hass.states.get("binary_sensor.zeo_two_softener") is None + + +@pytest.fixture +def dock_type(request: pytest.FixtureRequest, fake_vacuum: FakeDevice) -> None: + """Report the parametrized dock type for the fake vacuum.""" + fake_vacuum.v1_properties.device_features.dock_features = ( + RoborockDockFeatures.from_dock_type(request.param) + ) + + +MOP_DRYING_UNIQUE_ID = "dry_status_abc123" +MOP_DRYING_ISSUE_ID = "deprecated_mop_drying_abc123" +MOP_DRYING_ENTITY_ID = "binary_sensor.roborock_s7_maxv_dock_mop_drying" + + +def register_mop_drying_sensor( + entity_registry: er.EntityRegistry, + config_entry: MockConfigEntry, + disabled_by: er.RegistryEntryDisabler | None = None, +) -> None: + """Register the mop drying binary sensor as an existing installation would have.""" + entity_registry.async_get_or_create( + Platform.BINARY_SENSOR, + DOMAIN, + MOP_DRYING_UNIQUE_ID, + config_entry=config_entry, + suggested_object_id="roborock_s7_maxv_dock_mop_drying", + disabled_by=disabled_by, + ) + + +async def test_mop_drying_sensor_not_created_for_new_installs( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + issue_registry: ir.IssueRegistry, + setup_entry: MockConfigEntry, +) -> None: + """Test the deprecated mop drying sensor is not created on a fresh install.""" + assert hass.states.get(MOP_DRYING_ENTITY_ID) is None + assert ( + entity_registry.async_get_entity_id( + Platform.BINARY_SENSOR, DOMAIN, MOP_DRYING_UNIQUE_ID + ) + is None + ) + assert (DOMAIN, MOP_DRYING_ISSUE_ID) not in issue_registry.issues + + +async def test_mop_drying_sensor_deprecated( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + issue_registry: ir.IssueRegistry, + mock_roborock_entry: MockConfigEntry, +) -> None: + """Test an existing mop drying sensor is kept and raises a repair issue.""" + register_mop_drying_sensor(entity_registry, mock_roborock_entry) + + await hass.config_entries.async_setup(mock_roborock_entry.entry_id) + await hass.async_block_till_done() + + assert hass.states.get(MOP_DRYING_ENTITY_ID).state == "off" + assert (DOMAIN, MOP_DRYING_ISSUE_ID) in issue_registry.issues + + +@pytest.mark.parametrize( + "dock_type", [RoborockDockTypeCode.o1_dock], indirect=True, ids=["collect-only"] +) +@pytest.mark.usefixtures("dock_type") +async def test_mop_drying_sensor_removed_for_dock_without_drying( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + issue_registry: ir.IssueRegistry, + mock_roborock_entry: MockConfigEntry, +) -> None: + """Test the sensor is removed without a repair issue when the dock cannot dry.""" + register_mop_drying_sensor(entity_registry, mock_roborock_entry) + + await hass.config_entries.async_setup(mock_roborock_entry.entry_id) + await hass.async_block_till_done() + + assert hass.states.get(MOP_DRYING_ENTITY_ID) is None + assert ( + entity_registry.async_get_entity_id( + Platform.BINARY_SENSOR, DOMAIN, MOP_DRYING_UNIQUE_ID + ) + is None + ) + assert (DOMAIN, MOP_DRYING_ISSUE_ID) not in issue_registry.issues + + +async def test_mop_drying_repair_cleared_when_dock_replaced( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + issue_registry: ir.IssueRegistry, + mock_roborock_entry: MockConfigEntry, + fake_vacuum: FakeDevice, +) -> None: + """Test the repair issue is cleared when the dock no longer supports drying.""" + register_mop_drying_sensor(entity_registry, mock_roborock_entry) + + await hass.config_entries.async_setup(mock_roborock_entry.entry_id) + await hass.async_block_till_done() + + assert (DOMAIN, MOP_DRYING_ISSUE_ID) in issue_registry.issues + + fake_vacuum.v1_properties.device_features.dock_features = ( + RoborockDockFeatures.from_dock_type(RoborockDockTypeCode.o1_dock) + ) + await hass.config_entries.async_reload(mock_roborock_entry.entry_id) + await hass.async_block_till_done() + + assert ( + entity_registry.async_get_entity_id( + Platform.BINARY_SENSOR, DOMAIN, MOP_DRYING_UNIQUE_ID + ) + is None + ) + assert (DOMAIN, MOP_DRYING_ISSUE_ID) not in issue_registry.issues + + +async def test_mop_drying_sensor_removed_when_disabled( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + issue_registry: ir.IssueRegistry, + mock_roborock_entry: MockConfigEntry, +) -> None: + """Test a disabled mop drying sensor is removed and the repair issue cleared.""" + register_mop_drying_sensor( + entity_registry, mock_roborock_entry, er.RegistryEntryDisabler.USER + ) + + await hass.config_entries.async_setup(mock_roborock_entry.entry_id) + await hass.async_block_till_done() + + assert ( + entity_registry.async_get_entity_id( + Platform.BINARY_SENSOR, DOMAIN, MOP_DRYING_UNIQUE_ID + ) + is None + ) + assert (DOMAIN, MOP_DRYING_ISSUE_ID) not in issue_registry.issues + + +async def test_mop_drying_sensor_kept_when_used_by_automation( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + issue_registry: ir.IssueRegistry, + mock_roborock_entry: MockConfigEntry, +) -> None: + """Test a mop drying sensor used by an automation is kept and the usage listed.""" + register_mop_drying_sensor( + entity_registry, mock_roborock_entry, er.RegistryEntryDisabler.USER + ) + assert await async_setup_component( + hass, + AUTOMATION_DOMAIN, + { + AUTOMATION_DOMAIN: { + "alias": "test_automation", + "triggers": { + "trigger": "state", + "entity_id": MOP_DRYING_ENTITY_ID, + }, + "actions": {"action": "notify.notify", "data": {}}, + } + }, + ) + + await hass.config_entries.async_setup(mock_roborock_entry.entry_id) + await hass.async_block_till_done() + + assert ( + entity_registry.async_get_entity_id( + Platform.BINARY_SENSOR, DOMAIN, MOP_DRYING_UNIQUE_ID + ) + is not None + ) + issue = issue_registry.async_get_issue(DOMAIN, MOP_DRYING_ISSUE_ID) + assert issue.translation_key == "deprecated_mop_drying_scripts" diff --git a/tests/components/roborock/test_init.py b/tests/components/roborock/test_init.py index b35d8e9be0cd..6b5b9bbc47c0 100644 --- a/tests/components/roborock/test_init.py +++ b/tests/components/roborock/test_init.py @@ -73,8 +73,8 @@ async def test_stale_device( await hass.async_block_till_done() if mock_roborock_entry._background_tasks: await asyncio.gather(*mock_roborock_entry._background_tasks) - existing_devices = device_registry.devices.get_devices_for_config_entry_id( - mock_roborock_entry.entry_id + existing_devices = dr.async_entries_for_config_entry( + device_registry, mock_roborock_entry.entry_id ) assert {device.name for device in existing_devices} == { "Roborock S7 MaxV", @@ -95,8 +95,8 @@ async def test_stale_device( await hass.async_block_till_done() if mock_roborock_entry._background_tasks: await asyncio.gather(*mock_roborock_entry._background_tasks) - new_devices = device_registry.devices.get_devices_for_config_entry_id( - mock_roborock_entry.entry_id + new_devices = dr.async_entries_for_config_entry( + device_registry, mock_roborock_entry.entry_id ) assert {device.name for device in new_devices} == { "Roborock S7 2", @@ -120,8 +120,8 @@ async def test_no_stale_device( await hass.async_block_till_done() if mock_roborock_entry._background_tasks: await asyncio.gather(*mock_roborock_entry._background_tasks) - existing_devices = device_registry.devices.get_devices_for_config_entry_id( - mock_roborock_entry.entry_id + existing_devices = dr.async_entries_for_config_entry( + device_registry, mock_roborock_entry.entry_id ) assert {device.name for device in existing_devices} == { "Roborock S7 MaxV", @@ -138,8 +138,8 @@ async def test_no_stale_device( await hass.async_block_till_done() if mock_roborock_entry._background_tasks: await asyncio.gather(*mock_roborock_entry._background_tasks) - new_devices = device_registry.devices.get_devices_for_config_entry_id( - mock_roborock_entry.entry_id + new_devices = dr.async_entries_for_config_entry( + device_registry, mock_roborock_entry.entry_id ) assert {device.name for device in new_devices} == { "Roborock S7 MaxV", @@ -700,8 +700,8 @@ async def test_disabled_device_no_coordinator( assert all(coord.duid != first_device.duid for coord in coordinators.v1) # Other devices should still be set up - found_devices = device_registry.devices.get_devices_for_config_entry_id( - mock_roborock_entry.entry_id + found_devices = dr.async_entries_for_config_entry( + device_registry, mock_roborock_entry.entry_id ) enabled_device_names = { device.name for device in found_devices if not device.disabled diff --git a/tests/components/roborock/test_select.py b/tests/components/roborock/test_select.py index c8a70293e906..68e99e67fc8e 100644 --- a/tests/components/roborock/test_select.py +++ b/tests/components/roborock/test_select.py @@ -2,7 +2,7 @@ import copy from typing import Any -from unittest.mock import AsyncMock, Mock, call +from unittest.mock import AsyncMock, Mock, call, patch import pytest from roborock import CleanTypeMapping, RoborockCommand @@ -87,6 +87,7 @@ async def test_update_success( ("select.roborock_s7_maxv_selected_map", "Downstairs"), ], ) +@patch("homeassistant.components.roborock.select.MAP_SLEEP", 0) async def test_update_success_selected_map( hass: HomeAssistant, setup_entry: MockConfigEntry, diff --git a/tests/components/samsungtv/test_trigger.py b/tests/components/samsungtv/test_trigger.py index 61d1a943df36..ba2355160285 100644 --- a/tests/components/samsungtv/test_trigger.py +++ b/tests/components/samsungtv/test_trigger.py @@ -33,7 +33,7 @@ async def test_turn_on_trigger_device_id( device = device_registry.async_get_device_by_identifier( (DOMAIN, "be9554b9-c9fb-41f4-8920-22da015376a4"), entry.entry_id ) - assert device, repr(device_registry.devices) + assert device, repr(device_registry._devices) assert await async_setup_component( hass, diff --git a/tests/components/search/test_init.py b/tests/components/search/test_init.py index daa6396e35be..fd71359d15fd 100644 --- a/tests/components/search/test_init.py +++ b/tests/components/search/test_init.py @@ -1143,10 +1143,10 @@ async def test_search_pre_migration_composite_device( # Simulate a migration split: both devices carry the pre-migration composite id composite_device_id = "composite00000000000000000000ab" - device_registry.devices[device_1.id] = attr.evolve( + device_registry._devices[device_1.id] = attr.evolve( device_1, composite_device_id=composite_device_id ) - device_registry.devices[device_2.id] = attr.evolve( + device_registry._devices[device_2.id] = attr.evolve( device_2, composite_device_id=composite_device_id ) diff --git a/tests/components/sensor/test_recorder.py b/tests/components/sensor/test_recorder.py index a02570ff0dbc..ef0f89545d8c 100644 --- a/tests/components/sensor/test_recorder.py +++ b/tests/components/sensor/test_recorder.py @@ -144,6 +144,20 @@ def disable_mariadb_issue() -> None: yield +@pytest.fixture(autouse=True) +def disable_deprecated_database_version_issue() -> None: + """Disable creating issues about deprecated database versions.""" + with ( + patch( + "homeassistant.components.recorder.util._async_create_issue_deprecated_version" + ), + patch( + "homeassistant.components.recorder.util._async_create_issue_not_supported_lts" + ), + ): + yield + + async def async_list_statistic_ids( hass: HomeAssistant, statistic_ids: set[str] | None = None, diff --git a/tests/components/shelly/conftest.py b/tests/components/shelly/conftest.py index 62941e0ffbe9..d032eec14fdd 100644 --- a/tests/components/shelly/conftest.py +++ b/tests/components/shelly/conftest.py @@ -466,6 +466,25 @@ MOCK_STATUS_RPC = { "wifi": {"rssi": -63}, } +MOCK_CAMERA_CONFIG = { + "camera:0": { + "id": 0, + "rtsp": {"enable": True}, + } +} + +MOCK_CAMERA_STATUS = { + "camera:0": { + "id": 0, + "privacy": False, + "arm": True, + "streamer": "running", + "motion": False, + "streams": 0, + "recordings": None, + } +} + MOCK_SCRIPTS = [ """" function eventHandler(event, userdata) { @@ -821,3 +840,14 @@ def disable_async_remove_shelly_rpc_entities() -> Generator[None]: "homeassistant.components.shelly.utils.async_remove_shelly_rpc_entities" ): yield + + +@pytest.fixture +def mock_camera_rpc_device( + monkeypatch: pytest.MonkeyPatch, mock_rpc_device: Mock +) -> Mock: + """Set up mock RPC device with camera component data.""" + monkeypatch.setattr(mock_rpc_device, "config", MOCK_CAMERA_CONFIG) + monkeypatch.setattr(mock_rpc_device, "status", MOCK_CAMERA_STATUS) + + return mock_rpc_device diff --git a/tests/components/shelly/snapshots/test_camera.ambr b/tests/components/shelly/snapshots/test_camera.ambr new file mode 100644 index 000000000000..d7ae1a3ffdab --- /dev/null +++ b/tests/components/shelly/snapshots/test_camera.ambr @@ -0,0 +1,111 @@ +# serializer version: 1 +# name: test_camera_entity_setup[camera.test_name_stream_0-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'camera', + 'entity_category': None, + 'entity_id': 'camera.test_name_stream_0', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Stream 0', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Stream 0', + 'platform': 'shelly', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': , + 'translation_key': 'stream', + 'unique_id': '123456789ABC-camera:0-stream_0', + 'unit_of_measurement': None, + }) +# --- +# name: test_camera_entity_setup[camera.test_name_stream_0-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : '1caab5c3b3', + : 'Shelly', + : '/api/camera_proxy/camera.test_name_stream_0?token=1caab5c3b3', + : 'Test name Stream 0', + : 'S1CM-0DXW00', + : , + }), + 'context': , + 'entity_id': 'camera.test_name_stream_0', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'idle', + }) +# --- +# name: test_camera_entity_setup[camera.test_name_stream_1-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'camera', + 'entity_category': None, + 'entity_id': 'camera.test_name_stream_1', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Stream 1', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Stream 1', + 'platform': 'shelly', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': , + 'translation_key': 'stream', + 'unique_id': '123456789ABC-camera:0-stream_1', + 'unit_of_measurement': None, + }) +# --- +# name: test_camera_entity_setup[camera.test_name_stream_1-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : '1caab5c3b3', + : 'Shelly', + : '/api/camera_proxy/camera.test_name_stream_1?token=1caab5c3b3', + : 'Test name Stream 1', + : 'S1CM-0DXW00', + : , + }), + 'context': , + 'entity_id': 'camera.test_name_stream_1', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'idle', + }) +# --- diff --git a/tests/components/shelly/test_camera.py b/tests/components/shelly/test_camera.py new file mode 100644 index 000000000000..fd49c646c309 --- /dev/null +++ b/tests/components/shelly/test_camera.py @@ -0,0 +1,234 @@ +"""Tests for Shelly camera platform.""" + +from collections.abc import Generator +from copy import deepcopy +from unittest.mock import Mock, patch + +from aioshelly.const import MODEL_CAMERA +import pytest +from syrupy.assertion import SnapshotAssertion + +from homeassistant.components.camera import ( + DATA_COMPONENT, + DOMAIN as CAMERA_DOMAIN, + CameraState, + get_camera_from_entity_id, +) +from homeassistant.components.shelly.const import CONF_SLEEP_PERIOD +from homeassistant.const import ( + CONF_HOST, + CONF_MODEL, + CONF_PASSWORD, + CONF_USERNAME, + Platform, +) +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_registry import EntityRegistry + +from . import MOCK_MAC, init_integration, patch_platforms, register_entity + +from tests.common import snapshot_platform + +CAMERA_ENTITY_ID = "camera.test_name_stream_0" + + +@pytest.fixture(autouse=True) +def fixture_platforms() -> Generator[None]: + """Limit platforms under test.""" + with patch_platforms([Platform.CAMERA]): + yield + + +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +async def test_camera_entity_setup( + hass: HomeAssistant, + mock_camera_rpc_device: Mock, + entity_registry: EntityRegistry, + snapshot: SnapshotAssertion, +) -> None: + """Test camera entity is created with correct unique_id and initial state.""" + with patch("random.SystemRandom.getrandbits", return_value=123123123123): + entry = await init_integration(hass, 3, model=MODEL_CAMERA) + + assert hass.states.get(CAMERA_ENTITY_ID) + await snapshot_platform(hass, entity_registry, snapshot, entry.entry_id) + + assert (er_entry := entity_registry.async_get(CAMERA_ENTITY_ID)) + assert er_entry.unique_id == f"{MOCK_MAC}-camera:0-stream_0" + + +async def test_camera_state_streaming( + hass: HomeAssistant, + mock_camera_rpc_device: Mock, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Test camera state is streaming when streams > 0.""" + await init_integration(hass, 3, model=MODEL_CAMERA) + + new_status = deepcopy(mock_camera_rpc_device.status) + new_status["camera:0"]["streams"] = 1 + monkeypatch.setattr(mock_camera_rpc_device, "status", new_status) + mock_camera_rpc_device.mock_update() + await hass.async_block_till_done() + + assert (state := hass.states.get(CAMERA_ENTITY_ID)) + assert state.state == CameraState.STREAMING + + +async def test_camera_state_recording( + hass: HomeAssistant, + mock_camera_rpc_device: Mock, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Test camera state is recording when recordings is set.""" + await init_integration(hass, 3, model=MODEL_CAMERA) + + new_status = deepcopy(mock_camera_rpc_device.status) + new_status["camera:0"]["recordings"] = {"id": 1} + monkeypatch.setattr(mock_camera_rpc_device, "status", new_status) + mock_camera_rpc_device.mock_update() + await hass.async_block_till_done() + + assert (state := hass.states.get(CAMERA_ENTITY_ID)) + assert state.state == CameraState.RECORDING + + +async def test_camera_use_stream_for_stills( + hass: HomeAssistant, + mock_camera_rpc_device: Mock, +) -> None: + """Test use_stream_for_stills returns True (still images from the RTSP stream).""" + await init_integration(hass, 3, model=MODEL_CAMERA) + + camera = get_camera_from_entity_id(hass, CAMERA_ENTITY_ID) + assert camera.use_stream_for_stills is True + + +async def test_camera_stream_source( + hass: HomeAssistant, + mock_camera_rpc_device: Mock, +) -> None: + """Test stream_source returns the RTSP URL for go2rtc.""" + await init_integration(hass, 3, model=MODEL_CAMERA) + + camera = get_camera_from_entity_id(hass, CAMERA_ENTITY_ID) + result = await camera.stream_source() + assert result == "rtsp://192.168.1.37/stream/0" + + +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +async def test_camera_stream_source_stream_1( + hass: HomeAssistant, + mock_camera_rpc_device: Mock, +) -> None: + """Test stream_source returns correct RTSP URL for stream 1.""" + await init_integration(hass, 3, model=MODEL_CAMERA) + + camera = get_camera_from_entity_id(hass, "camera.test_name_stream_1") + result = await camera.stream_source() + assert result == "rtsp://192.168.1.37/stream/1" + + +@pytest.mark.parametrize( + ("password", "expected_password"), + [ + ("password", "password"), + ("pass:word@1", "pass%3Aword%401"), + ], +) +async def test_camera_stream_source_with_credentials( + hass: HomeAssistant, + mock_camera_rpc_device: Mock, + password: str, + expected_password: str, +) -> None: + """Test stream_source returns the RTSP URL with credentials for go2rtc.""" + await init_integration( + hass, + 3, + model=MODEL_CAMERA, + data={ + CONF_HOST: "192.168.1.37", + CONF_MODEL: MODEL_CAMERA, + CONF_PASSWORD: password, + CONF_SLEEP_PERIOD: 0, + CONF_USERNAME: "admin", + }, + ) + + camera = get_camera_from_entity_id(hass, CAMERA_ENTITY_ID) + result = await camera.stream_source() + assert result == f"rtsp://admin:{expected_password}@192.168.1.37/stream/0" + + +async def test_camera_off_when_streamer_stopped( + hass: HomeAssistant, + mock_camera_rpc_device: Mock, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Test camera is off when the streamer is not running.""" + status = deepcopy(mock_camera_rpc_device.status) + status["camera:0"]["streamer"] = "stopped" + monkeypatch.setattr(mock_camera_rpc_device, "status", status) + + await init_integration(hass, 3, model=MODEL_CAMERA) + + camera = hass.data[DATA_COMPONENT].get_entity(CAMERA_ENTITY_ID) + assert camera is not None + assert camera.is_on is False + + +async def test_camera_properties_when_device_not_initialized( + hass: HomeAssistant, + mock_camera_rpc_device: Mock, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Test camera properties return safe values when the device is not initialized.""" + await init_integration(hass, 3, model=MODEL_CAMERA) + + camera = get_camera_from_entity_id(hass, CAMERA_ENTITY_ID) + + monkeypatch.setattr(mock_camera_rpc_device, "initialized", False) + + assert camera.is_on is False + assert camera.available is False + + +async def test_camera_not_created_when_rtsp_disabled( + hass: HomeAssistant, + mock_camera_rpc_device: Mock, + monkeypatch: pytest.MonkeyPatch, + entity_registry: EntityRegistry, +) -> None: + """Test camera entities are not created when RTSP is disabled.""" + new_config = deepcopy(mock_camera_rpc_device.config) + new_config["camera:0"]["rtsp"]["enable"] = False + monkeypatch.setattr(mock_camera_rpc_device, "config", new_config) + + await init_integration(hass, 3, model=MODEL_CAMERA) + + assert hass.states.get(CAMERA_ENTITY_ID) is None + assert entity_registry.async_get(CAMERA_ENTITY_ID) is None + + +async def test_rpc_camera_removal_when_rtsp_disabled( + hass: HomeAssistant, + mock_camera_rpc_device: Mock, + monkeypatch: pytest.MonkeyPatch, + entity_registry: EntityRegistry, +) -> None: + """Test RPC camera is removed due to removal_condition when RTSP disabled.""" + entity_id = register_entity( + hass, CAMERA_DOMAIN, "test_name_stream_0", "camera:0-stream_0" + ) + + assert entity_registry.async_get(entity_id) is not None + + new_config = deepcopy(mock_camera_rpc_device.config) + new_config["camera:0"]["rtsp"]["enable"] = False + monkeypatch.setattr(mock_camera_rpc_device, "config", new_config) + + await init_integration(hass, 3, model=MODEL_CAMERA) + + assert entity_registry.async_get(entity_id) is None + assert hass.states.get(entity_id) is None diff --git a/tests/components/shelly/test_repairs.py b/tests/components/shelly/test_repairs.py index 05c156af584b..8cdc9437aaa7 100644 --- a/tests/components/shelly/test_repairs.py +++ b/tests/components/shelly/test_repairs.py @@ -3,7 +3,7 @@ from typing import Any from unittest.mock import Mock, patch -from aioshelly.const import MODEL_PLUG, MODEL_WALL_DISPLAY +from aioshelly.const import MODEL_CAMERA, MODEL_PLUG, MODEL_WALL_DISPLAY from aioshelly.exceptions import DeviceConnectionError, NotInitialized, RpcCallError import pytest @@ -16,6 +16,7 @@ from homeassistant.components.shelly.const import ( OPEN_WIFI_AP_ISSUE_ID, OUTBOUND_WEBSOCKET_INCORRECTLY_ENABLED_ISSUE_ID, PUSH_UPDATE_ISSUE_ID, + RTSP_DISABLED_ISSUE_ID, BLEScannerMode, DeprecatedFirmwareInfo, ) @@ -761,3 +762,132 @@ async def test_plug_1_push_update_issue_created( assert issue_registry.async_get_issue(DOMAIN, issue_id) assert len(issue_registry.issues) == 1 + + +async def test_rtsp_disabled_issue( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + mock_camera_rpc_device: Mock, + issue_registry: ir.IssueRegistry, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Test repair issue when camera RTSP is disabled.""" + monkeypatch.setitem( + mock_camera_rpc_device.config["camera:0"]["rtsp"], "enable", False + ) + + issue_id = RTSP_DISABLED_ISSUE_ID.format(unique=MOCK_MAC) + assert await async_setup_component(hass, "repairs", {}) + await hass.async_block_till_done() + await init_integration(hass, 3, MODEL_CAMERA) + + assert issue_registry.async_get_issue(DOMAIN, issue_id) + assert len(issue_registry.issues) == 1 + + client = await hass_client() + result = await start_repair_fix_flow(client, DOMAIN, issue_id) + + assert result["step_id"] == "init" + assert result["type"] == "menu" + + result = await process_repair_fix_flow( + client, result["flow_id"], {"next_step_id": "confirm"} + ) + assert result["type"] == "create_entry" + assert mock_camera_rpc_device.set_camera_rtsp.call_count == 1 + assert mock_camera_rpc_device.set_camera_rtsp.call_args[0] == (0, True) + + assert not issue_registry.async_get_issue(DOMAIN, issue_id) + assert len(issue_registry.issues) == 0 + + +async def test_no_rtsp_disabled_issue_when_enabled( + hass: HomeAssistant, + mock_camera_rpc_device: Mock, + issue_registry: ir.IssueRegistry, +) -> None: + """Test no repair issue when camera RTSP is enabled.""" + issue_id = RTSP_DISABLED_ISSUE_ID.format(unique=MOCK_MAC) + await init_integration(hass, 3, MODEL_CAMERA) + + assert not issue_registry.async_get_issue(DOMAIN, issue_id) + assert len(issue_registry.issues) == 0 + + +async def test_rtsp_disabled_issue_ignore( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + mock_camera_rpc_device: Mock, + issue_registry: ir.IssueRegistry, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Test ignoring the RTSP disabled issue.""" + monkeypatch.setitem( + mock_camera_rpc_device.config["camera:0"]["rtsp"], "enable", False + ) + + issue_id = RTSP_DISABLED_ISSUE_ID.format(unique=MOCK_MAC) + assert await async_setup_component(hass, "repairs", {}) + await hass.async_block_till_done() + await init_integration(hass, 3, MODEL_CAMERA) + + assert issue_registry.async_get_issue(DOMAIN, issue_id) + assert len(issue_registry.issues) == 1 + + client = await hass_client() + result = await start_repair_fix_flow(client, DOMAIN, issue_id) + + assert result["step_id"] == "init" + assert result["type"] == "menu" + + result = await process_repair_fix_flow( + client, result["flow_id"], {"next_step_id": "ignore"} + ) + assert result["type"] == "abort" + assert result["reason"] == "issue_ignored" + assert mock_camera_rpc_device.set_camera_rtsp.call_count == 0 + + assert (issue := issue_registry.async_get_issue(DOMAIN, issue_id)) + assert issue.dismissed_version + + +@pytest.mark.parametrize( + "exception", [DeviceConnectionError, RpcCallError(999, "Unknown error")] +) +async def test_rtsp_disabled_issue_exc( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + mock_camera_rpc_device: Mock, + issue_registry: ir.IssueRegistry, + monkeypatch: pytest.MonkeyPatch, + exception: Exception, +) -> None: + """Test repair issue handling when set_camera_rtsp ends with an exception.""" + mock_camera_rpc_device.set_camera_rtsp.side_effect = exception + monkeypatch.setitem( + mock_camera_rpc_device.config["camera:0"]["rtsp"], "enable", False + ) + + issue_id = RTSP_DISABLED_ISSUE_ID.format(unique=MOCK_MAC) + assert await async_setup_component(hass, "repairs", {}) + await hass.async_block_till_done() + await init_integration(hass, 3, MODEL_CAMERA) + + assert issue_registry.async_get_issue(DOMAIN, issue_id) + assert len(issue_registry.issues) == 1 + + client = await hass_client() + result = await start_repair_fix_flow(client, DOMAIN, issue_id) + + assert result["step_id"] == "init" + assert result["type"] == "menu" + + result = await process_repair_fix_flow( + client, result["flow_id"], {"next_step_id": "confirm"} + ) + assert result["type"] == "abort" + assert result["reason"] == "cannot_connect" + assert mock_camera_rpc_device.set_camera_rtsp.call_count == 1 + + assert issue_registry.async_get_issue(DOMAIN, issue_id) + assert len(issue_registry.issues) == 1 diff --git a/tests/components/shelly/test_switch.py b/tests/components/shelly/test_switch.py index 7a2aa7c02aad..f8587a821cba 100644 --- a/tests/components/shelly/test_switch.py +++ b/tests/components/shelly/test_switch.py @@ -4,7 +4,7 @@ from copy import deepcopy from datetime import timedelta from unittest.mock import AsyncMock, Mock -from aioshelly.const import MODEL_1PM, MODEL_MOTION, MODEL_WALL_DISPLAY +from aioshelly.const import MODEL_1PM, MODEL_CAMERA, MODEL_MOTION, MODEL_WALL_DISPLAY from aioshelly.exceptions import DeviceConnectionError, InvalidAuthError, RpcCallError from freezegun.api import FrozenDateTimeFactory import pytest @@ -1112,3 +1112,36 @@ async def test_rpc_circuit_breaker_turn_on_errors( {ATTR_ENTITY_ID: "switch.test_name"}, blocking=True, ) + + +async def test_rpc_camera_privacy_switch( + hass: HomeAssistant, + mock_camera_rpc_device: Mock, + entity_registry: EntityRegistry, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Test the camera privacy switch.""" + entity_id = "switch.test_name_privacy" + + await init_integration(hass, 3, model=MODEL_CAMERA) + + assert (state := hass.states.get(entity_id)) + assert state.state == STATE_OFF + + assert (entry := entity_registry.async_get(entity_id)) + assert entry.unique_id == "123456789ABC-camera:0-camera_privacy" + + mutate_rpc_device_status( + monkeypatch, mock_camera_rpc_device, "camera:0", "privacy", True + ) + await hass.services.async_call( + SWITCH_DOMAIN, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: entity_id}, + blocking=True, + ) + mock_camera_rpc_device.mock_update() + mock_camera_rpc_device.set_camera_privacy.assert_called_with(0, True) + + assert (state := hass.states.get(entity_id)) + assert state.state == STATE_ON diff --git a/tests/components/simplepush/test_notify.py b/tests/components/simplepush/test_notify.py new file mode 100644 index 000000000000..5d4bdd70e838 --- /dev/null +++ b/tests/components/simplepush/test_notify.py @@ -0,0 +1,199 @@ +"""Test Simplepush notifications.""" + +from collections.abc import Generator +from typing import Any +from unittest.mock import MagicMock, patch + +import pytest +from simplepush import BadRequest, UnknownError + +from homeassistant.components.notify import DOMAIN as NOTIFY_DOMAIN +from homeassistant.components.simplepush.const import CONF_DEVICE_KEY, CONF_SALT, DOMAIN +from homeassistant.const import CONF_NAME, CONF_PASSWORD +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError, ServiceValidationError +from homeassistant.setup import async_setup_component + +from tests.common import MockConfigEntry + +MOCK_CONFIG = { + CONF_DEVICE_KEY: "abc", + CONF_NAME: "simplepush", +} + +SERVICE_NAME = "simplepush" + + +@pytest.fixture +def mock_send() -> Generator[MagicMock]: + """Mock the simplepush send call.""" + with patch("homeassistant.components.simplepush.notify.send") as mock: + yield mock + + +async def setup_config_entry(hass: HomeAssistant, data: dict[str, str]) -> None: + """Set up the simplepush integration.""" + entry = MockConfigEntry(domain=DOMAIN, data=data) + entry.add_to_hass(hass) + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + assert hass.services.has_service(NOTIFY_DOMAIN, SERVICE_NAME) + + +@pytest.mark.parametrize( + ("service_data", "expected_attachments", "expected_event"), + [ + pytest.param({}, None, None, id="message_only"), + pytest.param({"data": {"event": "event"}}, None, "event", id="event_in_data"), + pytest.param( + {"data": {"attachments": "image.jpg"}}, + None, + None, + id="attachments_not_a_list", + ), + pytest.param( + {"data": {"attachments": [{"image": "image.jpg"}]}}, + ["image.jpg"], + None, + id="image_attachment", + ), + pytest.param( + {"data": {"attachments": [{"video": "video.mp4"}]}}, + ["video.mp4"], + None, + id="video_attachment", + ), + pytest.param( + { + "data": { + "attachments": [{"video": "video.mp4", "thumbnail": "thumb.jpg"}] + } + }, + [{"video": "video.mp4", "thumbnail": "thumb.jpg"}], + None, + id="video_attachment_with_thumbnail", + ), + ], +) +async def test_send_message( + hass: HomeAssistant, + mock_send: MagicMock, + service_data: dict[str, Any], + expected_attachments: list[Any] | None, + expected_event: str | None, +) -> None: + """Test sending a message.""" + await setup_config_entry(hass, MOCK_CONFIG) + + await hass.services.async_call( + NOTIFY_DOMAIN, + SERVICE_NAME, + {"message": "Hello", **service_data}, + blocking=True, + ) + + mock_send.assert_called_once_with( + key="abc", + title="Home Assistant", + message="Hello", + attachments=expected_attachments, + event=expected_event, + ) + + +async def test_send_message_with_password( + hass: HomeAssistant, mock_send: MagicMock +) -> None: + """Test sending a message with an encryption password.""" + await setup_config_entry( + hass, {**MOCK_CONFIG, CONF_PASSWORD: "password", CONF_SALT: "salt"} + ) + + await hass.services.async_call( + NOTIFY_DOMAIN, + SERVICE_NAME, + {"message": "Hello"}, + blocking=True, + ) + + mock_send.assert_called_once_with( + key="abc", + password="password", + salt="salt", + title="Home Assistant", + message="Hello", + attachments=None, + event=None, + ) + + +async def test_send_message_with_invalid_attachment( + hass: HomeAssistant, mock_send: MagicMock, caplog: pytest.LogCaptureFixture +) -> None: + """Test that an invalid attachment format sends nothing.""" + await setup_config_entry(hass, MOCK_CONFIG) + + await hass.services.async_call( + NOTIFY_DOMAIN, + SERVICE_NAME, + {"message": "Hello", "data": {"attachments": [{"file": "image.jpg"}]}}, + blocking=True, + ) + + assert "Attachment format is incorrect" in caplog.text + mock_send.assert_not_called() + + +@pytest.mark.parametrize( + ("side_effect", "expected_exception", "translation_key"), + [ + pytest.param( + BadRequest, + ServiceValidationError, + "title_or_message_too_long", + id="bad_request", + ), + pytest.param( + UnknownError, + HomeAssistantError, + "send_message_failed", + id="unknown_error", + ), + ], +) +async def test_send_message_error( + hass: HomeAssistant, + mock_send: MagicMock, + side_effect: type[Exception], + expected_exception: type[HomeAssistantError], + translation_key: str, +) -> None: + """Test that a failing send raises the correct exception.""" + await setup_config_entry(hass, MOCK_CONFIG) + mock_send.side_effect = side_effect + + with pytest.raises(expected_exception) as exc_info: + await hass.services.async_call( + NOTIFY_DOMAIN, + SERVICE_NAME, + {"message": "Hello"}, + blocking=True, + ) + + assert exc_info.value.translation_domain == DOMAIN + assert exc_info.value.translation_key == translation_key + + +async def test_no_discovery_info( + hass: HomeAssistant, caplog: pytest.LogCaptureFixture +) -> None: + """Test setup of the legacy platform without discovery info.""" + assert await async_setup_component( + hass, + NOTIFY_DOMAIN, + {NOTIFY_DOMAIN: {"platform": DOMAIN}}, + ) + await hass.async_block_till_done() + + assert f"Failed to initialize notification service {DOMAIN}" in caplog.text + assert not hass.services.has_service(NOTIFY_DOMAIN, SERVICE_NAME) diff --git a/tests/components/smartthings/test_climate.py b/tests/components/smartthings/test_climate.py index 208345390a43..be68cc0da838 100644 --- a/tests/components/smartthings/test_climate.py +++ b/tests/components/smartthings/test_climate.py @@ -206,6 +206,201 @@ async def test_ac_set_hvac_mode_turns_on( ] +@pytest.mark.parametrize("device_fixture", ["da_ac_rac_000001"]) +async def test_ac_set_hvac_mode_auto_uses_aicomfort( + hass: HomeAssistant, + devices: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test setting AC HVAC mode auto uses aIComfort when auto is not supported.""" + set_attribute_value( + devices, + Capability.AIR_CONDITIONER_MODE, + Attribute.SUPPORTED_AC_MODES, + ["aIComfort", "cool", "dry", "heat", "fanOnly"], + ) + set_attribute_value(devices, Capability.SWITCH, Attribute.SWITCH, "on") + + await setup_integration(hass, mock_config_entry) + + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_HVAC_MODE, + { + ATTR_ENTITY_ID: "climate.theater_ac_office_granit", + ATTR_HVAC_MODE: HVACMode.AUTO, + }, + blocking=True, + ) + devices.execute_device_command.assert_called_once_with( + "96a5ef74-5832-a84b-f1f7-ca799957065d", + Capability.AIR_CONDITIONER_MODE, + Command.SET_AIR_CONDITIONER_MODE, + MAIN, + argument="aIComfort", + ) + + +@pytest.mark.parametrize("device_fixture", ["da_ac_rac_000001"]) +async def test_ac_set_hvac_mode_auto_prefers_auto_when_aicomfort_supported( + hass: HomeAssistant, + devices: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test setting AC HVAC mode auto uses auto when both auto and aIComfort are supported.""" + set_attribute_value( + devices, + Capability.AIR_CONDITIONER_MODE, + Attribute.SUPPORTED_AC_MODES, + ["aIComfort", "auto", "cool", "dry", "heat", "fanOnly"], + ) + set_attribute_value(devices, Capability.SWITCH, Attribute.SWITCH, "on") + + await setup_integration(hass, mock_config_entry) + + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_HVAC_MODE, + { + ATTR_ENTITY_ID: "climate.theater_ac_office_granit", + ATTR_HVAC_MODE: HVACMode.AUTO, + }, + blocking=True, + ) + devices.execute_device_command.assert_called_once_with( + "96a5ef74-5832-a84b-f1f7-ca799957065d", + Capability.AIR_CONDITIONER_MODE, + Command.SET_AIR_CONDITIONER_MODE, + MAIN, + argument="auto", + ) + + +@pytest.mark.parametrize("device_fixture", ["da_ac_rac_000001"]) +async def test_ac_set_hvac_mode_auto_turns_on_uses_aicomfort( + hass: HomeAssistant, + devices: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test setting AC HVAC mode auto turns on and uses aIComfort when auto is not supported.""" + set_attribute_value( + devices, + Capability.AIR_CONDITIONER_MODE, + Attribute.SUPPORTED_AC_MODES, + ["aIComfort", "cool", "dry", "heat", "fanOnly"], + ) + + await setup_integration(hass, mock_config_entry) + + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_HVAC_MODE, + { + ATTR_ENTITY_ID: "climate.theater_ac_office_granit", + ATTR_HVAC_MODE: HVACMode.AUTO, + }, + blocking=True, + ) + assert devices.execute_device_command.mock_calls == [ + call( + "96a5ef74-5832-a84b-f1f7-ca799957065d", + Capability.SWITCH, + Command.ON, + MAIN, + ), + call( + "96a5ef74-5832-a84b-f1f7-ca799957065d", + Capability.AIR_CONDITIONER_MODE, + Command.SET_AIR_CONDITIONER_MODE, + MAIN, + argument="aIComfort", + ), + ] + + +@pytest.mark.parametrize("device_fixture", ["da_ac_rac_000001"]) +async def test_ac_set_temperature_and_hvac_mode_auto_uses_aicomfort( + hass: HomeAssistant, + devices: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test setting AC temperature and HVAC mode auto uses aIComfort when auto is not supported.""" + set_attribute_value( + devices, + Capability.AIR_CONDITIONER_MODE, + Attribute.SUPPORTED_AC_MODES, + ["aIComfort", "cool", "dry", "heat", "fanOnly"], + ) + set_attribute_value(devices, Capability.SWITCH, Attribute.SWITCH, "on") + await setup_integration(hass, mock_config_entry) + + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_TEMPERATURE, + { + ATTR_ENTITY_ID: "climate.theater_ac_office_granit", + ATTR_TEMPERATURE: 23, + ATTR_HVAC_MODE: HVACMode.AUTO, + }, + blocking=True, + ) + assert devices.execute_device_command.mock_calls == [ + call( + "96a5ef74-5832-a84b-f1f7-ca799957065d", + Capability.THERMOSTAT_COOLING_SETPOINT, + Command.SET_COOLING_SETPOINT, + MAIN, + argument=23.0, + ), + call( + "96a5ef74-5832-a84b-f1f7-ca799957065d", + Capability.AIR_CONDITIONER_MODE, + Command.SET_AIR_CONDITIONER_MODE, + MAIN, + argument="aIComfort", + ), + ] + + +@pytest.mark.parametrize("device_fixture", ["da_ac_rac_000001"]) +async def test_ac_aicomfort_mode_state( + hass: HomeAssistant, + devices: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test aIComfort AC mode is reported as auto.""" + set_attribute_value(devices, Capability.SWITCH, Attribute.SWITCH, "on") + set_attribute_value( + devices, + Capability.AIR_CONDITIONER_MODE, + Attribute.AIR_CONDITIONER_MODE, + "aIComfort", + ) + await setup_integration(hass, mock_config_entry) + + assert hass.states.get("climate.theater_ac_office_granit").state == HVACMode.AUTO + + +@pytest.mark.parametrize("device_fixture", ["da_ac_rac_000001"]) +async def test_ac_hvac_modes_includes_auto_for_aicomfort( + hass: HomeAssistant, + devices: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test hvac_modes includes auto when only aIComfort is supported.""" + set_attribute_value( + devices, + Capability.AIR_CONDITIONER_MODE, + Attribute.SUPPORTED_AC_MODES, + ["aIComfort", "cool", "heat"], + ) + await setup_integration(hass, mock_config_entry) + + state = hass.states.get("climate.theater_ac_office_granit") + assert state + assert HVACMode.AUTO in state.attributes[ATTR_HVAC_MODES] + + @pytest.mark.parametrize("device_fixture", ["da_ac_rac_000001"]) @pytest.mark.parametrize("mode", ["fan", "wind"]) async def test_ac_set_hvac_mode_fan( diff --git a/tests/components/snooz/snapshots/test_init.ambr b/tests/components/snooz/snapshots/test_init.ambr index 2428bdd78c12..06f53bc4eaa1 100644 --- a/tests/components/snooz/snapshots/test_init.ambr +++ b/tests/components/snooz/snapshots/test_init.ambr @@ -26,7 +26,7 @@ 'manufacturer': None, 'model': None, 'model_id': None, - 'name': None, + 'name': 'Mock Title', 'name_by_user': None, 'serial_number': None, 'sw_version': None, diff --git a/tests/components/sonos/test_init.py b/tests/components/sonos/test_init.py index dde22673d85a..cf9e44771470 100644 --- a/tests/components/sonos/test_init.py +++ b/tests/components/sonos/test_init.py @@ -8,6 +8,7 @@ import logging from typing import Any from unittest.mock import MagicMock, Mock, PropertyMock, patch +from freezegun import freeze_time from freezegun.api import FrozenDateTimeFactory import pytest from requests import Response @@ -605,6 +606,70 @@ async def test_async_poll_manual_hosts_6( await hass.async_block_till_done(wait_background_tasks=True) +async def test_async_poll_manual_hosts_skips_ping_for_disabled_device( + hass: HomeAssistant, + soco_factory: SoCoMockFactory, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, +) -> None: + """Test disabled manual-host speakers are not pinged on heartbeat.""" + soco = soco_factory.cache_mock(MockSoCo(), "10.10.10.1", "Living Room") + soco.renderingControl = Mock() + soco.renderingControl.GetVolume = Mock() + + await _setup_hass(hass) + + assert "media_player.living_room" in entity_registry.entities + + # Mark the speaker unavailable via ZGS event with VanishedDevices. + async def fire_vanish_event(): + subscription = soco.zoneGroupTopology.subscribe.return_value + sub_callback = await subscription.wait_for_callback_to_be_set() + zgs_with_vanished = f""" + + + + + + + + + """ + event = SonosMockEvent( + soco, soco.zoneGroupTopology, {"ZoneGroupState": zgs_with_vanished} + ) + sub_callback(event) + await hass.async_block_till_done(wait_background_tasks=True) + + await fire_vanish_event() + + # Verify the speaker is marked unavailable. + state = hass.states.get("media_player.living_room") + assert state is not None + assert state.state == "unavailable" + + # Now disable the device. + entry = hass.config_entries.async_entries(sonos.DOMAIN)[0] + device = device_registry.async_get_device_by_identifier( + (sonos.DOMAIN, soco.uid), entry.entry_id + ) + assert device is not None + device_registry.async_update_device( + device.id, + disabled_by=dr.DeviceEntryDisabler.USER, + ) + + # SonosSpeaker.ping uses RenderingControl.GetVolume under the hood. + soco.renderingControl.GetVolume.reset_mock() + with freeze_time(dt_util.utcnow()) as freezer: + freezer.tick(DISCOVERY_INTERVAL) + async_fire_time_changed(hass) + await hass.async_block_till_done(wait_background_tasks=True) + + # The disabled speaker should not have been pinged. + soco.renderingControl.GetVolume.assert_not_called() + + async def test_async_poll_manual_hosts_7( hass: HomeAssistant, soco_factory: SoCoMockFactory, diff --git a/tests/components/squeezebox/snapshots/test_switch.ambr b/tests/components/squeezebox/snapshots/test_switch.ambr index 6a78d35e83e5..2454256db9f3 100644 --- a/tests/components/squeezebox/snapshots/test_switch.ambr +++ b/tests/components/squeezebox/snapshots/test_switch.ambr @@ -1,5 +1,5 @@ # serializer version: 1 -# name: test_entity_registry[switch.alarm_1-entry] +# name: test_entity_registry[switch.mock_title_alarm_1-entry] EntityRegistryEntrySnapshot({ 'aliases': list([ None, @@ -13,7 +13,7 @@ 'disabled_by': None, 'domain': 'switch', 'entity_category': , - 'entity_id': 'switch.alarm_1', + 'entity_id': 'switch.mock_title_alarm_1', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -36,21 +36,21 @@ 'unit_of_measurement': None, }) # --- -# name: test_entity_registry[switch.alarm_1-state] +# name: test_entity_registry[switch.mock_title_alarm_1-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'alarm_id': '1', - : 'Alarm (1)', + : 'Mock Title Alarm (1)', }), 'context': , - 'entity_id': 'switch.alarm_1', + 'entity_id': 'switch.mock_title_alarm_1', 'last_changed': , 'last_reported': , 'last_updated': , 'state': 'on', }) # --- -# name: test_entity_registry[switch.alarms_enabled-entry] +# name: test_entity_registry[switch.mock_title_alarms_enabled-entry] EntityRegistryEntrySnapshot({ 'aliases': list([ None, @@ -64,7 +64,7 @@ 'disabled_by': None, 'domain': 'switch', 'entity_category': , - 'entity_id': 'switch.alarms_enabled', + 'entity_id': 'switch.mock_title_alarms_enabled', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -87,13 +87,13 @@ 'unit_of_measurement': None, }) # --- -# name: test_entity_registry[switch.alarms_enabled-state] +# name: test_entity_registry[switch.mock_title_alarms_enabled-state] StateSnapshot({ 'attributes': ReadOnlyDict({ - : 'Alarms enabled', + : 'Mock Title Alarms enabled', }), 'context': , - 'entity_id': 'switch.alarms_enabled', + 'entity_id': 'switch.mock_title_alarms_enabled', 'last_changed': , 'last_reported': , 'last_updated': , diff --git a/tests/components/squeezebox/test_binary_sensor.py b/tests/components/squeezebox/test_binary_sensor.py index 5966142a2771..46098af5c4aa 100644 --- a/tests/components/squeezebox/test_binary_sensor.py +++ b/tests/components/squeezebox/test_binary_sensor.py @@ -8,11 +8,18 @@ from freezegun.api import FrozenDateTimeFactory import pytest from homeassistant.components.binary_sensor import BinarySensorDeviceClass -from homeassistant.components.squeezebox.const import PLAYER_UPDATE_INTERVAL +from homeassistant.components.squeezebox.const import ( + DOMAIN, + PLAYER_SENSOR_ALARM_ACTIVE, + PLAYER_SENSOR_ALARM_SNOOZE, + PLAYER_SENSOR_ALARM_UPCOMING, + PLAYER_UPDATE_INTERVAL, +) from homeassistant.const import STATE_OFF, STATE_ON, Platform from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er -from .conftest import FAKE_QUERY_RESPONSE +from .conftest import FAKE_QUERY_RESPONSE, TEST_MAC from tests.common import MockConfigEntry, async_fire_time_changed @@ -67,24 +74,35 @@ async def mock_player( async def test_player_alarm_sensors_device_class( hass: HomeAssistant, + entity_registry: er.EntityRegistry, mock_player: MagicMock, ) -> None: """Test player alarm binary sensors have correct device class.""" + upcoming_id = entity_registry.async_get_entity_id( + Platform.BINARY_SENSOR, DOMAIN, f"{TEST_MAC[0]}_{PLAYER_SENSOR_ALARM_UPCOMING}" + ) + active_id = entity_registry.async_get_entity_id( + Platform.BINARY_SENSOR, DOMAIN, f"{TEST_MAC[0]}_{PLAYER_SENSOR_ALARM_ACTIVE}" + ) + snooze_id = entity_registry.async_get_entity_id( + Platform.BINARY_SENSOR, DOMAIN, f"{TEST_MAC[0]}_{PLAYER_SENSOR_ALARM_SNOOZE}" + ) + # Test alarm upcoming sensor device class - upcoming_state = hass.states.get("binary_sensor.alarm_upcoming") + upcoming_state = hass.states.get(upcoming_id) assert upcoming_state is not None assert upcoming_state.attributes.get("device_class") is None # Test alarm active sensor device class - active_state = hass.states.get("binary_sensor.alarm_active") + active_state = hass.states.get(active_id) assert active_state is not None assert ( active_state.attributes.get("device_class") == BinarySensorDeviceClass.RUNNING ) # Test alarm snooze sensor device class - snooze_state = hass.states.get("binary_sensor.alarm_snoozed") + snooze_state = hass.states.get(snooze_id) assert snooze_state is not None assert ( snooze_state.attributes.get("device_class") == BinarySensorDeviceClass.RUNNING @@ -93,6 +111,7 @@ async def test_player_alarm_sensors_device_class( async def test_player_alarm_sensors_state( hass: HomeAssistant, + entity_registry: er.EntityRegistry, mock_player: MagicMock, freezer: FrozenDateTimeFactory, ) -> None: @@ -100,18 +119,28 @@ async def test_player_alarm_sensors_state( player = mock_player + upcoming_id = entity_registry.async_get_entity_id( + Platform.BINARY_SENSOR, DOMAIN, f"{TEST_MAC[0]}_{PLAYER_SENSOR_ALARM_UPCOMING}" + ) + active_id = entity_registry.async_get_entity_id( + Platform.BINARY_SENSOR, DOMAIN, f"{TEST_MAC[0]}_{PLAYER_SENSOR_ALARM_ACTIVE}" + ) + snooze_id = entity_registry.async_get_entity_id( + Platform.BINARY_SENSOR, DOMAIN, f"{TEST_MAC[0]}_{PLAYER_SENSOR_ALARM_SNOOZE}" + ) + # Test alarm upcoming sensor - upcoming_state = hass.states.get("binary_sensor.alarm_upcoming") + upcoming_state = hass.states.get(upcoming_id) assert upcoming_state is not None assert upcoming_state.state == STATE_ON # Test alarm active sensor - active_state = hass.states.get("binary_sensor.alarm_active") + active_state = hass.states.get(active_id) assert active_state is not None assert active_state.state == STATE_OFF # Test alarm snooze sensor - snooze_state = hass.states.get("binary_sensor.alarm_snoozed") + snooze_state = hass.states.get(snooze_id) assert snooze_state is not None assert snooze_state.state == STATE_OFF @@ -123,10 +152,10 @@ async def test_player_alarm_sensors_state( async_fire_time_changed(hass) await hass.async_block_till_done() - upcoming_state = hass.states.get("binary_sensor.alarm_upcoming") + upcoming_state = hass.states.get(upcoming_id) assert upcoming_state is not None assert upcoming_state.state == STATE_OFF - active_state = hass.states.get("binary_sensor.alarm_active") + active_state = hass.states.get(active_id) assert active_state is not None assert active_state.state == STATE_ON diff --git a/tests/components/squeezebox/test_button.py b/tests/components/squeezebox/test_button.py index 1ff623687ede..53015b3c769a 100644 --- a/tests/components/squeezebox/test_button.py +++ b/tests/components/squeezebox/test_button.py @@ -5,8 +5,12 @@ from unittest.mock import MagicMock, patch import pytest from homeassistant.components.button import DOMAIN as BUTTON_DOMAIN, SERVICE_PRESS +from homeassistant.components.squeezebox.const import DOMAIN from homeassistant.const import ATTR_ENTITY_ID, Platform from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from .conftest import TEST_MAC @pytest.fixture(autouse=True) @@ -17,13 +21,18 @@ def squeezebox_button_platform(): async def test_squeezebox_press( - hass: HomeAssistant, configured_player: MagicMock + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + configured_player: MagicMock, ) -> None: """Test press service call.""" + entity_id = entity_registry.async_get_entity_id( + Platform.BUTTON, DOMAIN, f"{TEST_MAC[0]}_preset_1" + ) await hass.services.async_call( BUTTON_DOMAIN, SERVICE_PRESS, - {ATTR_ENTITY_ID: "button.preset_1"}, + {ATTR_ENTITY_ID: entity_id}, blocking=True, ) diff --git a/tests/components/squeezebox/test_sensor.py b/tests/components/squeezebox/test_sensor.py index 2f66cbd0e39c..c75d8ee58c95 100644 --- a/tests/components/squeezebox/test_sensor.py +++ b/tests/components/squeezebox/test_sensor.py @@ -7,11 +7,16 @@ from unittest.mock import MagicMock, patch from freezegun.api import FrozenDateTimeFactory import pytest -from homeassistant.components.squeezebox.const import PLAYER_UPDATE_INTERVAL +from homeassistant.components.squeezebox.const import ( + DOMAIN, + PLAYER_SENSOR_NEXT_ALARM, + PLAYER_UPDATE_INTERVAL, +) from homeassistant.const import STATE_UNKNOWN, Platform from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er -from .conftest import FAKE_QUERY_RESPONSE, TEST_ALARM_NEXT_TIME +from .conftest import FAKE_QUERY_RESPONSE, TEST_ALARM_NEXT_TIME, TEST_MAC from tests.common import MockConfigEntry, async_fire_time_changed @@ -44,6 +49,7 @@ async def test_server_sensor( async def test_player_sensor_next_alarm( hass: HomeAssistant, + entity_registry: er.EntityRegistry, config_entry: MockConfigEntry, lms: MagicMock, freezer: FrozenDateTimeFactory, @@ -59,8 +65,12 @@ async def test_player_sensor_next_alarm( await hass.async_block_till_done(wait_background_tasks=True) player = (await lms.async_get_players())[0] + entity_id = entity_registry.async_get_entity_id( + Platform.SENSOR, DOMAIN, f"{TEST_MAC[0]}_{PLAYER_SENSOR_NEXT_ALARM}" + ) + # test alarm time is set from player - state = hass.states.get("sensor.next_alarm") + state = hass.states.get(entity_id) assert state is not None assert state.state == TEST_ALARM_NEXT_TIME.isoformat() @@ -70,6 +80,6 @@ async def test_player_sensor_next_alarm( async_fire_time_changed(hass) await hass.async_block_till_done() - state = hass.states.get("sensor.next_alarm") + state = hass.states.get(entity_id) assert state is not None assert state.state == STATE_UNKNOWN diff --git a/tests/components/squeezebox/test_switch.py b/tests/components/squeezebox/test_switch.py index 93eef1ab13d2..f457f49530a3 100644 --- a/tests/components/squeezebox/test_switch.py +++ b/tests/components/squeezebox/test_switch.py @@ -7,7 +7,7 @@ from freezegun.api import FrozenDateTimeFactory import pytest from syrupy.assertion import SnapshotAssertion -from homeassistant.components.squeezebox.const import PLAYER_UPDATE_INTERVAL +from homeassistant.components.squeezebox.const import DOMAIN, PLAYER_UPDATE_INTERVAL from homeassistant.components.switch import DOMAIN as SWITCH_DOMAIN from homeassistant.const import ( CONF_ENTITY_ID, @@ -18,7 +18,7 @@ from homeassistant.const import ( from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_registry import EntityRegistry -from .conftest import TEST_ALARM_ID +from .conftest import TEST_ALARM_ID, TEST_MAC from tests.common import MockConfigEntry, async_fire_time_changed, snapshot_platform @@ -70,43 +70,55 @@ async def test_entity_registry( async def test_switch_state( hass: HomeAssistant, + entity_registry: EntityRegistry, mock_alarms_player: MagicMock, freezer: FrozenDateTimeFactory, ) -> None: """Test the state of the switch.""" - assert hass.states.get(f"switch.alarm_{TEST_ALARM_ID}").state == "on" + entity_id = entity_registry.async_get_entity_id( + SWITCH_DOMAIN, DOMAIN, f"{TEST_MAC[0]}_alarm_{TEST_ALARM_ID}" + ) + assert hass.states.get(entity_id).state == "on" mock_alarms_player.alarms[0]["enabled"] = False freezer.tick(timedelta(seconds=PLAYER_UPDATE_INTERVAL)) async_fire_time_changed(hass) await hass.async_block_till_done() - assert hass.states.get(f"switch.alarm_{TEST_ALARM_ID}").state == "off" + assert hass.states.get(entity_id).state == "off" async def test_switch_deleted( hass: HomeAssistant, + entity_registry: EntityRegistry, mock_alarms_player: MagicMock, freezer: FrozenDateTimeFactory, ) -> None: """Test detecting switch deleted.""" - assert hass.states.get(f"switch.alarm_{TEST_ALARM_ID}").state == "on" + entity_id = entity_registry.async_get_entity_id( + SWITCH_DOMAIN, DOMAIN, f"{TEST_MAC[0]}_alarm_{TEST_ALARM_ID}" + ) + assert hass.states.get(entity_id).state == "on" mock_alarms_player.alarms = [] freezer.tick(timedelta(seconds=PLAYER_UPDATE_INTERVAL)) async_fire_time_changed(hass) await hass.async_block_till_done() - assert hass.states.get(f"switch.alarm_{TEST_ALARM_ID}") is None + assert hass.states.get(entity_id) is None async def test_turn_on( hass: HomeAssistant, + entity_registry: EntityRegistry, mock_alarms_player: MagicMock, ) -> None: """Test turning on the switch.""" + entity_id = entity_registry.async_get_entity_id( + SWITCH_DOMAIN, DOMAIN, f"{TEST_MAC[0]}_alarm_{TEST_ALARM_ID}" + ) await hass.services.async_call( SWITCH_DOMAIN, SERVICE_TURN_ON, - {CONF_ENTITY_ID: f"switch.alarm_{TEST_ALARM_ID}"}, + {CONF_ENTITY_ID: entity_id}, blocking=True, ) mock_alarms_player.async_update_alarm.assert_called_once_with( @@ -116,13 +128,17 @@ async def test_turn_on( async def test_turn_off( hass: HomeAssistant, + entity_registry: EntityRegistry, mock_alarms_player: MagicMock, ) -> None: """Test turning on the switch.""" + entity_id = entity_registry.async_get_entity_id( + SWITCH_DOMAIN, DOMAIN, f"{TEST_MAC[0]}_alarm_{TEST_ALARM_ID}" + ) await hass.services.async_call( SWITCH_DOMAIN, SERVICE_TURN_OFF, - {CONF_ENTITY_ID: f"switch.alarm_{TEST_ALARM_ID}"}, + {CONF_ENTITY_ID: entity_id}, blocking=True, ) mock_alarms_player.async_update_alarm.assert_called_once_with( @@ -132,30 +148,38 @@ async def test_turn_off( async def test_alarms_enabled_state( hass: HomeAssistant, + entity_registry: EntityRegistry, mock_alarms_player: MagicMock, freezer: FrozenDateTimeFactory, ) -> None: """Test the alarms enabled switch.""" + entity_id = entity_registry.async_get_entity_id( + SWITCH_DOMAIN, DOMAIN, f"{TEST_MAC[0]}_alarms_enabled" + ) - assert hass.states.get("switch.alarms_enabled").state == "on" + assert hass.states.get(entity_id).state == "on" mock_alarms_player.alarms_enabled = False freezer.tick(timedelta(seconds=PLAYER_UPDATE_INTERVAL)) async_fire_time_changed(hass) await hass.async_block_till_done() - assert hass.states.get("switch.alarms_enabled").state == "off" + assert hass.states.get(entity_id).state == "off" async def test_alarms_enabled_turn_on( hass: HomeAssistant, + entity_registry: EntityRegistry, mock_alarms_player: MagicMock, ) -> None: """Test turning on the alarms enabled switch.""" + entity_id = entity_registry.async_get_entity_id( + SWITCH_DOMAIN, DOMAIN, f"{TEST_MAC[0]}_alarms_enabled" + ) await hass.services.async_call( SWITCH_DOMAIN, SERVICE_TURN_ON, - {CONF_ENTITY_ID: "switch.alarms_enabled"}, + {CONF_ENTITY_ID: entity_id}, blocking=True, ) mock_alarms_player.async_set_alarms_enabled.assert_called_once_with(True) @@ -163,13 +187,17 @@ async def test_alarms_enabled_turn_on( async def test_alarms_enabled_turn_off( hass: HomeAssistant, + entity_registry: EntityRegistry, mock_alarms_player: MagicMock, ) -> None: """Test turning off the alarms enabled switch.""" + entity_id = entity_registry.async_get_entity_id( + SWITCH_DOMAIN, DOMAIN, f"{TEST_MAC[0]}_alarms_enabled" + ) await hass.services.async_call( SWITCH_DOMAIN, SERVICE_TURN_OFF, - {CONF_ENTITY_ID: "switch.alarms_enabled"}, + {CONF_ENTITY_ID: entity_id}, blocking=True, ) mock_alarms_player.async_set_alarms_enabled.assert_called_once_with(False) diff --git a/tests/components/statistics/test_init.py b/tests/components/statistics/test_init.py index df37c994d3a6..a65cd31ac5d6 100644 --- a/tests/components/statistics/test_init.py +++ b/tests/components/statistics/test_init.py @@ -115,7 +115,9 @@ async def test_async_handle_source_entity_changes_source_entity_removed( assert await hass.config_entries.async_setup(statistics_config_entry.entry_id) await hass.async_block_till_done() - statistics_entity_entry = entity_registry.async_get("sensor.my_statistics") + statistics_entity_entry = entity_registry.async_get( + "sensor.mock_title_my_statistics" + ) assert statistics_entity_entry.device_id == sensor_entity_entry.device_id sensor_device = device_registry.async_get(sensor_device.id) @@ -134,7 +136,7 @@ async def test_async_handle_source_entity_changes_source_entity_removed( mock_unload_entry.assert_called_once() # Check that the helper entity is removed - assert not entity_registry.async_get("sensor.my_statistics") + assert not entity_registry.async_get("sensor.mock_title_my_statistics") # Check that the device is removed assert not device_registry.async_get(sensor_device.id) @@ -162,7 +164,9 @@ async def test_async_handle_source_entity_changes_source_entity_removed_shared_d assert await hass.config_entries.async_setup(statistics_config_entry.entry_id) await hass.async_block_till_done() - statistics_entity_entry = entity_registry.async_get("sensor.my_statistics") + statistics_entity_entry = entity_registry.async_get( + "sensor.mock_title_my_statistics" + ) assert statistics_entity_entry.device_id == sensor_entity_entry.device_id sensor_device = device_registry.async_get(sensor_device.id) @@ -181,7 +185,7 @@ async def test_async_handle_source_entity_changes_source_entity_removed_shared_d mock_unload_entry.assert_called_once() # Check that the helper entity is removed - assert not entity_registry.async_get("sensor.my_statistics") + assert not entity_registry.async_get("sensor.mock_title_my_statistics") # Check that the source device is not removed assert device_registry.async_get(sensor_device.id) is not None @@ -209,7 +213,9 @@ async def test_async_handle_source_entity_changes_source_entity_removed_from_dev assert await hass.config_entries.async_setup(statistics_config_entry.entry_id) await hass.async_block_till_done() - statistics_entity_entry = entity_registry.async_get("sensor.my_statistics") + statistics_entity_entry = entity_registry.async_get( + "sensor.mock_title_my_statistics" + ) assert statistics_entity_entry.device_id == sensor_entity_entry.device_id sensor_device = device_registry.async_get(sensor_device.id) @@ -229,7 +235,9 @@ async def test_async_handle_source_entity_changes_source_entity_removed_from_dev mock_unload_entry.assert_called_once() # Check that the entity is no longer linked to the source device - statistics_entity_entry = entity_registry.async_get("sensor.my_statistics") + statistics_entity_entry = entity_registry.async_get( + "sensor.mock_title_my_statistics" + ) assert statistics_entity_entry.device_id is None # Check that the statistics config entry is not in the device @@ -261,7 +269,9 @@ async def test_async_handle_source_entity_changes_source_entity_moved_other_devi assert await hass.config_entries.async_setup(statistics_config_entry.entry_id) await hass.async_block_till_done() - statistics_entity_entry = entity_registry.async_get("sensor.my_statistics") + statistics_entity_entry = entity_registry.async_get( + "sensor.mock_title_my_statistics" + ) assert statistics_entity_entry.device_id == sensor_entity_entry.device_id sensor_device = device_registry.async_get(sensor_device.id) @@ -283,7 +293,9 @@ async def test_async_handle_source_entity_changes_source_entity_moved_other_devi mock_unload_entry.assert_called_once() # Check that the entity is linked to the other device - statistics_entity_entry = entity_registry.async_get("sensor.my_statistics") + statistics_entity_entry = entity_registry.async_get( + "sensor.mock_title_my_statistics" + ) assert statistics_entity_entry.device_id == sensor_device_2.id # Check that the history_stats config entry is not in any of the devices @@ -311,7 +323,9 @@ async def test_async_handle_source_entity_new_entity_id( assert await hass.config_entries.async_setup(statistics_config_entry.entry_id) await hass.async_block_till_done() - statistics_entity_entry = entity_registry.async_get("sensor.my_statistics") + statistics_entity_entry = entity_registry.async_get( + "sensor.mock_title_my_statistics" + ) assert statistics_entity_entry.device_id == sensor_entity_entry.device_id sensor_device = device_registry.async_get(sensor_device.id) @@ -380,7 +394,9 @@ async def test_migration_1_1( # is linked to the source device sensor_device = device_registry.async_get(sensor_device.id) assert statistics_config_entry.entry_id not in sensor_device.config_entries - statistics_entity_entry = entity_registry.async_get("sensor.my_statistics") + statistics_entity_entry = entity_registry.async_get( + "sensor.mock_title_my_statistics" + ) assert statistics_entity_entry.device_id == sensor_entity_entry.device_id assert statistics_config_entry.version == 1 diff --git a/tests/components/statistics/test_sensor.py b/tests/components/statistics/test_sensor.py index 7c10ae30469f..ad59427dde87 100644 --- a/tests/components/statistics/test_sensor.py +++ b/tests/components/statistics/test_sensor.py @@ -1694,14 +1694,14 @@ async def test_device_id( device_id=source_device_entry.id, ) await hass.async_block_till_done() - assert entity_registry.async_get("sensor.test_source") is not None + assert entity_registry.async_get("sensor.mock_title") is not None statistics_config_entry = MockConfigEntry( data={}, domain=DOMAIN, options={ "name": "Statistics", - "entity_id": "sensor.test_source", + "entity_id": "sensor.mock_title", "state_characteristic": "mean", "keep_last_sample": False, "percentile": 50.0, @@ -1715,7 +1715,7 @@ async def test_device_id( assert await hass.config_entries.async_setup(statistics_config_entry.entry_id) await hass.async_block_till_done() - statistics_entity = entity_registry.async_get("sensor.statistics") + statistics_entity = entity_registry.async_get("sensor.mock_title_statistics") assert statistics_entity is not None assert statistics_entity.device_id == source_entity.device_id diff --git a/tests/components/switch_as_x/test_init.py b/tests/components/switch_as_x/test_init.py index f3cddd346f03..842415fc5c28 100644 --- a/tests/components/switch_as_x/test_init.py +++ b/tests/components/switch_as_x/test_init.py @@ -226,7 +226,10 @@ async def test_device_registry_config_entry_1( assert await hass.config_entries.async_setup(switch_as_x_config_entry.entry_id) await hass.async_block_till_done() - entity_entry = entity_registry.async_get(f"{target_domain}.abc") + entity_id = entity_registry.async_get_entity_id( + target_domain, DOMAIN, switch_as_x_config_entry.entry_id + ) + entity_entry = entity_registry.async_get(entity_id) assert entity_entry.device_id == switch_entity_entry.device_id device_entry = device_registry.async_get(device_entry.id) @@ -305,7 +308,10 @@ async def test_device_registry_config_entry_2( assert await hass.config_entries.async_setup(switch_as_x_config_entry.entry_id) await hass.async_block_till_done() - entity_entry = entity_registry.async_get(f"{target_domain}.abc") + entity_id = entity_registry.async_get_entity_id( + target_domain, DOMAIN, switch_as_x_config_entry.entry_id + ) + entity_entry = entity_registry.async_get(entity_id) assert entity_entry.device_id == switch_entity_entry.device_id device_entry = device_registry.async_get(device_entry.id) @@ -387,7 +393,10 @@ async def test_device_registry_config_entry_3( assert await hass.config_entries.async_setup(switch_as_x_config_entry.entry_id) await hass.async_block_till_done() - entity_entry = entity_registry.async_get(f"{target_domain}.abc") + entity_id = entity_registry.async_get_entity_id( + target_domain, DOMAIN, switch_as_x_config_entry.entry_id + ) + entity_entry = entity_registry.async_get(entity_id) assert entity_entry.device_id == switch_entity_entry.device_id device_entry = device_registry.async_get(device_entry.id) @@ -531,7 +540,10 @@ async def test_device( assert await hass.config_entries.async_setup(switch_as_x_config_entry.entry_id) await hass.async_block_till_done() - entity_entry = entity_registry.async_get(f"{target_domain}.abc") + entity_id = entity_registry.async_get_entity_id( + target_domain, DOMAIN, switch_as_x_config_entry.entry_id + ) + entity_entry = entity_registry.async_get(entity_id) assert entity_entry assert entity_entry.device_id == switch_entity_entry.device_id @@ -1164,8 +1176,8 @@ async def test_migrate( assert config_entry.minor_version == SwitchAsXConfigFlowHandler.MINOR_VERSION # Check the state and entity registry entry are present - assert hass.states.get(f"{target_domain}.abc") is not None - assert entity_registry.async_get(f"{target_domain}.abc") is not None + assert hass.states.get(switch_as_x_entity_entry.entity_id) is not None + assert entity_registry.async_get(switch_as_x_entity_entry.entity_id) is not None # The switch_as_x config entry was never added to the device, so migration does # not change the switch_as_x entity's device link diff --git a/tests/components/switchbot_cloud/__init__.py b/tests/components/switchbot_cloud/__init__.py index dae9263d7add..bebd31077c72 100644 --- a/tests/components/switchbot_cloud/__init__.py +++ b/tests/components/switchbot_cloud/__init__.py @@ -65,7 +65,6 @@ BATTERY_CIRCULATOR_FAN_2_PRO_INFO = Device( hubDeviceId="test-hub-id", ) - METER_INFO = Device( version="V1.0", deviceId="meter-id-1", diff --git a/tests/components/switchbot_cloud/fixtures/sensor_status.json b/tests/components/switchbot_cloud/fixtures/sensor_status.json index 2001d96339ee..389028792190 100644 --- a/tests/components/switchbot_cloud/fixtures/sensor_status.json +++ b/tests/components/switchbot_cloud/fixtures/sensor_status.json @@ -47,6 +47,19 @@ "fanSpeed": 3, "battery": 22 }, + { + "deviceId": "A1C3E5F7D9B0", + "deviceType": "Battery Circulator Fan 2 Pro", + "hubDeviceId": "FFFFFFFFFFF", + "mode": "direct", + "version": "V6.3", + "power": "on", + "nightStatus": "off", + "oscillation": "on", + "verticalOscillation": "on", + "fanSpeed": 3, + "battery": 22 + }, { "deviceId": "9B0D2F4A6C8E", "deviceType": "Meter", diff --git a/tests/components/switchbot_cloud/test_select.py b/tests/components/switchbot_cloud/test_select.py new file mode 100644 index 000000000000..20e42e4475b6 --- /dev/null +++ b/tests/components/switchbot_cloud/test_select.py @@ -0,0 +1,144 @@ +"""Test for the switchbot_cloud select.""" + +from unittest.mock import AsyncMock, patch + +import pytest +from switchbot_api import Device, SwitchBotAPI + +from homeassistant.components.select import ( + DOMAIN as SELECT_DOMAIN, + SERVICE_SELECT_OPTION, +) +from homeassistant.config_entries import ConfigEntryState +from homeassistant.const import ATTR_ENTITY_ID +from homeassistant.core import HomeAssistant + +from . import configure_integration + + +@pytest.mark.parametrize( + "device", + [ + "Standing Fan", + "Battery Circulator Fan", + "Battery Circulator Fan 2 Pro", + ], +) +async def test_night_light_coordinator_data_is_none( + hass: HomeAssistant, + mock_list_devices: AsyncMock, + mock_get_status: AsyncMock, + device: str, +) -> None: + """Test coordinator data is none.""" + + mock_list_devices.return_value = [ + Device( + version="V1.0", + deviceId="device-id-1", + deviceName="device-1", + deviceType=device, + hubDeviceId="test-hub-id", + ), + ] + mock_get_status.side_effect = [None, None] + entry = await configure_integration(hass) + assert entry.state is ConfigEntryState.LOADED + entity_id = "select.device_1_night_light" + state = hass.states.get(entity_id) + assert state.state == "unknown" + + +@pytest.mark.parametrize( + ("device", "key_type", "expected"), + [ + ("Standing Fan", "on", "1"), + ("Standing Fan", "off", "off"), + ("Standing Fan", "bright", "1"), + ("Standing Fan", "soft", "2"), + ("Battery Circulator Fan 2 Pro", "bright", "0"), + ("Battery Circulator Fan 2 Pro", "soft", "1"), + ], +) +async def test_night_light_options( + hass: HomeAssistant, + mock_list_devices: AsyncMock, + mock_get_status: AsyncMock, + device: str, + key_type: str, + expected: str, +) -> None: + """Test night light options.""" + mock_list_devices.return_value = [ + Device( + version="V1.0", + deviceId="device-id-1", + deviceName="device-1", + deviceType=device, + hubDeviceId="test-hub-id", + ), + ] + + mock_get_status.side_effect = [ + { + "deviceId": "B0E9FEDEB68C", + "deviceType": device, + "power": "on", + "fanSpeed": 3, + "mode": "direct", + "nightStatus": expected, + }, + ] + entry = await configure_integration(hass) + assert entry.state is ConfigEntryState.LOADED + entity_id = "select.device_1_night_light" + + with ( + patch.object(SwitchBotAPI, "send_command") as mocked_send_command, + ): + await hass.services.async_call( + SELECT_DOMAIN, + SERVICE_SELECT_OPTION, + {ATTR_ENTITY_ID: entity_id, "option": key_type}, + blocking=True, + ) + + mocked_send_command.assert_awaited_once() + assert mocked_send_command.await_args.args[3] == expected + + state = hass.states.get(entity_id) + assert state.state == key_type + + +async def test_night_light_options_not_exist( + hass: HomeAssistant, + mock_list_devices: AsyncMock, + mock_get_status: AsyncMock, +) -> None: + """Test night light options.""" + mock_list_devices.return_value = [ + Device( + version="V1.0", + deviceId="standing-fan-id-1", + deviceName="standing-fan-1", + deviceType="Standing Fan", + hubDeviceId="test-hub-id", + ), + ] + + mock_get_status.side_effect = [ + { + "deviceId": "B0E9FEDEB68C", + "deviceType": "Standing Fan", + "power": "on", + "fanSpeed": 3, + "mode": "direct", + "nightStatus": "fake_option", + }, + ] + entry = await configure_integration(hass) + assert entry.state is ConfigEntryState.LOADED + entity_id = "select.standing_fan_1_night_light" + + state = hass.states.get(entity_id) + assert state.state == "unknown" diff --git a/tests/components/tasmota/test_discovery.py b/tests/components/tasmota/test_discovery.py index 0f59b1ce6140..efb8867d064d 100644 --- a/tests/components/tasmota/test_discovery.py +++ b/tests/components/tasmota/test_discovery.py @@ -31,7 +31,9 @@ def _get_device_for_config_entry( connections: set[tuple[str, str]] | None = None, ) -> dr.DeviceEntry | None: """Return the device for a config entry matching identifiers or connections.""" - for device in device_registry.devices.get_entries(identifiers, connections): + for device in device_registry.async_get_devices( + identifiers=identifiers, connections=connections + ): if device.config_entry_id == config_entry_id: return device return None diff --git a/tests/components/template/test_alarm_control_panel.py b/tests/components/template/test_alarm_control_panel.py index eb3450955efb..856e2fff5b5c 100644 --- a/tests/components/template/test_alarm_control_panel.py +++ b/tests/components/template/test_alarm_control_panel.py @@ -642,7 +642,9 @@ async def test_device_id( assert await hass.config_entries.async_setup(template_config_entry.entry_id) await hass.async_block_till_done() - template_entity = entity_registry.async_get("alarm_control_panel.my_template") + template_entity = entity_registry.async_get( + "alarm_control_panel.mock_title_my_template" + ) assert template_entity is not None assert template_entity.device_id == device_entry.id diff --git a/tests/components/template/test_binary_sensor.py b/tests/components/template/test_binary_sensor.py index e90df7d64ff8..22b8ef25f3eb 100644 --- a/tests/components/template/test_binary_sensor.py +++ b/tests/components/template/test_binary_sensor.py @@ -1536,7 +1536,7 @@ async def test_device_id( assert await hass.config_entries.async_setup(template_config_entry.entry_id) await hass.async_block_till_done() - template_entity = entity_registry.async_get("binary_sensor.my_template") + template_entity = entity_registry.async_get("binary_sensor.mock_title_my_template") assert template_entity is not None assert template_entity.device_id == device_entry.id diff --git a/tests/components/template/test_button.py b/tests/components/template/test_button.py index 8a9e8403e05d..cefbb2c9fc9d 100644 --- a/tests/components/template/test_button.py +++ b/tests/components/template/test_button.py @@ -341,7 +341,7 @@ async def test_device_id( assert await hass.config_entries.async_setup(template_config_entry.entry_id) await hass.async_block_till_done() - template_entity = entity_registry.async_get("button.my_template") + template_entity = entity_registry.async_get("button.mock_title_my_template") assert template_entity is not None assert template_entity.device_id == device_entry.id diff --git a/tests/components/template/test_device_tracker.py b/tests/components/template/test_device_tracker.py index 624d6208a6ee..a4192491471d 100644 --- a/tests/components/template/test_device_tracker.py +++ b/tests/components/template/test_device_tracker.py @@ -207,7 +207,7 @@ async def test_device_id( assert await hass.config_entries.async_setup(template_config_entry.entry_id) await hass.async_block_till_done() - template_entity = entity_registry.async_get("device_tracker.my_template") + template_entity = entity_registry.async_get("device_tracker.mock_title_my_template") assert template_entity is not None assert template_entity.device_id == device_entry.id diff --git a/tests/components/template/test_event.py b/tests/components/template/test_event.py index e3713fdd24b5..46d15f2a0dad 100644 --- a/tests/components/template/test_event.py +++ b/tests/components/template/test_event.py @@ -194,7 +194,7 @@ async def test_device_id( assert await hass.config_entries.async_setup(template_config_entry.entry_id) await hass.async_block_till_done() - template_entity = entity_registry.async_get("event.my_template") + template_entity = entity_registry.async_get("event.mock_title_my_template") assert template_entity is not None assert template_entity.device_id == device_entry.id diff --git a/tests/components/template/test_image.py b/tests/components/template/test_image.py index 541c9a5f06d3..02a9b5972dc3 100644 --- a/tests/components/template/test_image.py +++ b/tests/components/template/test_image.py @@ -597,7 +597,7 @@ async def test_device_id( assert await hass.config_entries.async_setup(template_config_entry.entry_id) await hass.async_block_till_done() - template_entity = entity_registry.async_get("image.my_template") + template_entity = entity_registry.async_get("image.mock_title_my_template") assert template_entity is not None assert template_entity.device_id == device_entry.id diff --git a/tests/components/template/test_init.py b/tests/components/template/test_init.py index f1c2233ba4ce..8727784f0827 100644 --- a/tests/components/template/test_init.py +++ b/tests/components/template/test_init.py @@ -438,7 +438,9 @@ async def test_change_device( assert await hass.config_entries.async_setup(template_config_entry.entry_id) await hass.async_block_till_done() - template_entity_id = f"{config_entry_options['template_type']}.my_template" + template_entity_id = ( + f"{config_entry_options['template_type']}.mock_title_my_template" + ) # Confirm that the template config entry has not been added to either device # and that the entities are linked to device 1 @@ -490,6 +492,56 @@ async def test_change_device( ) +@pytest.mark.parametrize( + "linked_device", + [ + pytest.param("main", id="main_device"), + pytest.param("child", id="child_device"), + ], +) +async def test_link_to_main_or_child_device( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, + linked_device: str, +) -> None: + """Test a template entity links to a selected main or child device.""" + source_entry = MockConfigEntry() + source_entry.add_to_hass(hass) + main_device = device_registry.async_get_or_create( + config_entry_id=source_entry.entry_id, + identifiers={("test", "main")}, + ) + child_device = device_registry.async_get_or_create_child( + config_entry_id=source_entry.entry_id, + identifiers={("test", "child")}, + parent_device_id=main_device.id, + ) + selected_device_id = {"main": main_device, "child": child_device}[linked_device].id + + template_config_entry = MockConfigEntry( + domain=DOMAIN, + options={ + "name": "My template", + "state": "{{10}}", + "template_type": "sensor", + "device_id": selected_device_id, + }, + title="Template", + ) + template_config_entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(template_config_entry.entry_id) + await hass.async_block_till_done() + + template_entities = list( + entity_registry.entities.get_entries_for_config_entry_id( + template_config_entry.entry_id + ) + ) + assert len(template_entities) == 1 + assert template_entities[0].device_id == selected_device_id + + async def test_setup_removes_stale_helper_device( hass: HomeAssistant, device_registry: dr.DeviceRegistry, @@ -676,7 +728,7 @@ async def test_migration_1_1( # entity is linked to the source device device_entry = device_registry.async_get(device_entry.id) assert template_config_entry.entry_id not in device_entry.config_entries - template_entity_entry = entity_registry.async_get("sensor.my_template") + template_entity_entry = entity_registry.async_get("sensor.mock_title_my_template") assert template_entity_entry.device_id == device_entry.id assert template_config_entry.version == 2 diff --git a/tests/components/template/test_number.py b/tests/components/template/test_number.py index 1a8339a93209..bb46638d52ea 100644 --- a/tests/components/template/test_number.py +++ b/tests/components/template/test_number.py @@ -352,7 +352,7 @@ async def test_device_id( assert await hass.config_entries.async_setup(template_config_entry.entry_id) await hass.async_block_till_done() - template_entity = entity_registry.async_get("number.my_template") + template_entity = entity_registry.async_get("number.mock_title_my_template") assert template_entity is not None assert template_entity.device_id == device_entry.id diff --git a/tests/components/template/test_repairs.py b/tests/components/template/test_repairs.py index 5e5f004abcd6..32017c95be17 100644 --- a/tests/components/template/test_repairs.py +++ b/tests/components/template/test_repairs.py @@ -50,13 +50,13 @@ def split_devices( identifiers={("itg2", "1")}, name="Split device 2", ) - device_registry.devices[device_1.id] = attr.evolve( + device_registry._devices[device_1.id] = attr.evolve( device_1, composite_device_id=COMPOSITE_ID ) - device_registry.devices[device_2.id] = attr.evolve( + device_registry._devices[device_2.id] = attr.evolve( device_2, composite_device_id=COMPOSITE_ID ) - return device_registry.devices[device_1.id], device_registry.devices[device_2.id] + return device_registry._devices[device_1.id], device_registry._devices[device_2.id] async def _setup_template_entry( @@ -176,6 +176,53 @@ async def test_composite_device_id_repair_flow( assert entity_entry.device_id == picked_device_id +@pytest.mark.usefixtures("split_devices") +async def test_composite_device_id_repair_flow_links_child_device( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, + issue_registry: ir.IssueRegistry, +) -> None: + """Test the repair flow accepts a child device and links the entity to it.""" + source_entry = MockConfigEntry(domain="itg3") + source_entry.add_to_hass(hass) + parent_device = device_registry.async_get_or_create( + config_entry_id=source_entry.entry_id, + identifiers={("itg3", "parent")}, + ) + child_device = device_registry.async_get_or_create_child( + config_entry_id=source_entry.entry_id, + identifiers={("itg3", "child")}, + parent_device_id=parent_device.id, + ) + + entry = await _setup_template_entry(hass, COMPOSITE_ID) + issue_id = f"composite_device_id_{entry.entry_id}" + assert issue_registry.async_get_issue(DOMAIN, issue_id) + + assert await async_setup_component(hass, "repairs", {}) + await hass.async_block_till_done() + client = await hass_client() + + result = await start_repair_fix_flow(client, DOMAIN, issue_id) + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "select_device" + + result = await process_repair_fix_flow( + client, result["flow_id"], json={CONF_DEVICE_ID: child_device.id} + ) + assert result["type"] == FlowResultType.CREATE_ENTRY + await hass.async_block_till_done() + + assert entry.options[CONF_DEVICE_ID] == child_device.id + assert not issue_registry.async_get_issue(DOMAIN, issue_id) + + entity_entry = entity_registry.async_get(TEMPLATE_ENTITY_ID) + assert entity_entry is not None + assert entity_entry.device_id == child_device.id + + async def test_composite_device_id_repair_flow_ambiguity_not_resolved( hass: HomeAssistant, hass_client: ClientSessionGenerator, diff --git a/tests/components/template/test_select.py b/tests/components/template/test_select.py index 9b8f4b322551..be2045d1ce2e 100644 --- a/tests/components/template/test_select.py +++ b/tests/components/template/test_select.py @@ -324,7 +324,7 @@ async def test_device_id( assert await hass.config_entries.async_setup(template_config_entry.entry_id) await hass.async_block_till_done() - template_entity = entity_registry.async_get("select.my_template") + template_entity = entity_registry.async_get("select.mock_title_my_template") assert template_entity is not None assert template_entity.device_id == device_entry.id diff --git a/tests/components/template/test_sensor.py b/tests/components/template/test_sensor.py index 4f2e7b33ccea..480046a4f54a 100644 --- a/tests/components/template/test_sensor.py +++ b/tests/components/template/test_sensor.py @@ -1836,7 +1836,7 @@ async def test_device_id( assert await hass.config_entries.async_setup(template_config_entry.entry_id) await hass.async_block_till_done() - template_entity = entity_registry.async_get("sensor.my_template") + template_entity = entity_registry.async_get("sensor.mock_title_my_template") assert template_entity is not None assert template_entity.device_id == device_entry.id diff --git a/tests/components/template/test_switch.py b/tests/components/template/test_switch.py index 99d84debb893..cc1732b499ff 100644 --- a/tests/components/template/test_switch.py +++ b/tests/components/template/test_switch.py @@ -712,7 +712,7 @@ async def test_device_id( assert await hass.config_entries.async_setup(template_config_entry.entry_id) await hass.async_block_till_done() - template_entity = entity_registry.async_get("switch.my_template") + template_entity = entity_registry.async_get("switch.mock_title_my_template") assert template_entity is not None assert template_entity.device_id == device_entry.id diff --git a/tests/components/template/test_update.py b/tests/components/template/test_update.py index b35f1d026642..f75c177c5c42 100644 --- a/tests/components/template/test_update.py +++ b/tests/components/template/test_update.py @@ -186,7 +186,7 @@ async def test_device_id( assert await hass.config_entries.async_setup(template_config_entry.entry_id) await hass.async_block_till_done() - template_entity = entity_registry.async_get(TEST_UPDATE.entity_id) + template_entity = entity_registry.async_get("update.mock_title_template_update") assert template_entity is not None assert template_entity.device_id == device_entry.id diff --git a/tests/components/thread/__init__.py b/tests/components/thread/__init__.py index 0b53c879c37d..5d4baa1d313a 100644 --- a/tests/components/thread/__init__.py +++ b/tests/components/thread/__init__.py @@ -6,6 +6,12 @@ DATASET_1 = ( "0212340410445F2B5CA6F2A93A55CE570A70EFEECB0C0402A0F7F8" ) +DATASET_1_LARGER_TIMESTAMP = ( + "0E080000000000020000000300000F35060004001FFFE0020811111111222222220708FDAD70BF" + "E5AA15DD051000112233445566778899AABBCCDDEEFF030E4F70656E54687265616444656D6F01" + "0212340410445F2B5CA6F2A93A55CE570A70EFEECB0C0402A0F7F8" +) + DATASET_2 = ( "0E080000000000010000000300000F35060004001FFFE0020811111111222222330708FDAD70BF" "E5AA15DD051000112233445566778899AABBCCDDEEFF030E486f6d65417373697374616e742101" diff --git a/tests/components/thread/test_dataset_store.py b/tests/components/thread/test_dataset_store.py index 468136a79bc2..305d5d6ad740 100644 --- a/tests/components/thread/test_dataset_store.py +++ b/tests/components/thread/test_dataset_store.py @@ -14,6 +14,7 @@ from homeassistant.exceptions import HomeAssistantError from . import ( DATASET_1, + DATASET_1_LARGER_TIMESTAMP, DATASET_2, DATASET_3, ROUTER_DISCOVERY_GOOGLE_1, @@ -55,12 +56,6 @@ DATASET_1_NO_ACTIVETIMESTAMP = ( "0212340410445F2B5CA6F2A93A55CE570A70EFEECB0C0402A0F7F8" ) -DATASET_1_LARGER_TIMESTAMP = ( - "0E080000000000020000000300000F35060004001FFFE0020811111111222222220708FDAD70BF" - "E5AA15DD051000112233445566778899AABBCCDDEEFF030E4F70656E54687265616444656D6F01" - "0212340410445F2B5CA6F2A93A55CE570A70EFEECB0C0402A0F7F8" -) - # Same as DATASET_1 but with WAKEUP_CHANNEL (type 0x4A) appended, same timestamp DATASET_1_WITH_WAKEUP_CHANNEL = ( "0E080000000000010000000300000F35060004001FFFE0020811111111222222220708FDAD70BF" @@ -97,7 +92,10 @@ async def test_add_invalid_dataset(hass: HomeAssistant) -> None: async def test_add_dataset_twice(hass: HomeAssistant) -> None: """Test adding dataset twice does nothing.""" - await dataset_store.async_add_dataset(hass, "source", DATASET_1) + assert ( + await dataset_store.async_add_dataset(hass, "source", DATASET_1) + is dataset_store.DatasetAddResult.STORED + ) store = await dataset_store.async_get_store(hass) assert len(store.datasets) == 1 @@ -116,9 +114,14 @@ async def test_add_dataset_reordered(hass: HomeAssistant) -> None: assert len(store.datasets) == 1 created = list(store.datasets.values())[0].created - await dataset_store.async_add_dataset(hass, "new_source", DATASET_1_REORDERED) + assert ( + await dataset_store.async_add_dataset(hass, "new_source", DATASET_1_REORDERED) + is dataset_store.DatasetAddResult.STORED + ) assert len(store.datasets) == 1 assert list(store.datasets.values())[0].created == created + # STORED is about the dataset, not the bytes: the stored TLV is untouched. + assert list(store.datasets.values())[0].tlv == DATASET_1 async def test_delete_dataset_twice(hass: HomeAssistant) -> None: @@ -242,7 +245,10 @@ async def test_update_dataset_newer( ) -> None: """Test updating a dataset.""" await dataset_store.async_add_dataset(hass, "test", DATASET_1) - await dataset_store.async_add_dataset(hass, "test", DATASET_1_LARGER_TIMESTAMP) + assert ( + await dataset_store.async_add_dataset(hass, "test", DATASET_1_LARGER_TIMESTAMP) + is dataset_store.DatasetAddResult.STORED + ) store = await dataset_store.async_get_store(hass) assert len(store.datasets) == 1 @@ -263,7 +269,10 @@ async def test_update_dataset_older( ) -> None: """Test updating a dataset.""" await dataset_store.async_add_dataset(hass, "test", DATASET_1_LARGER_TIMESTAMP) - await dataset_store.async_add_dataset(hass, "test", DATASET_1) + assert ( + await dataset_store.async_add_dataset(hass, "test", DATASET_1) + is dataset_store.DatasetAddResult.DISCARDED + ) store = await dataset_store.async_get_store(hass) assert len(store.datasets) == 1 @@ -1107,3 +1116,79 @@ async def test_automatically_set_preferred_dataset_no_router( == TEST_BORDER_AGENT_EXTENDED_ADDRESS.hex() ) assert await dataset_store.async_get_preferred_dataset(hass) is None + + +async def test_add_dataset_returns_stored(hass: HomeAssistant) -> None: + """Test adding a dataset for a new network reports it was stored.""" + store = await dataset_store.async_get_store(hass) + + assert ( + store.async_add("test", DATASET_1, None, None) + is dataset_store.DatasetAddResult.STORED + ) + assert ( + await dataset_store.async_add_dataset(hass, "test", DATASET_2) + is dataset_store.DatasetAddResult.STORED + ) + + +async def test_add_dataset_discarded_by_concurrent_write(hass: HomeAssistant) -> None: + """Test an add beaten to the store by a newer dataset is discarded.""" + await dataset_store.async_add_dataset(hass, "test", DATASET_1) + store = await dataset_store.async_get_store(hass) + + await dataset_store.async_add_dataset(hass, "other", DATASET_1_LARGER_TIMESTAMP) + + assert ( + await dataset_store.async_add_dataset(hass, "test", DATASET_1) + is dataset_store.DatasetAddResult.DISCARDED + ) + assert len(store.datasets) == 1 + assert list(store.datasets.values())[0].tlv == DATASET_1_LARGER_TIMESTAMP + + +async def test_add_dataset_stored_refreshes_border_agent(hass: HomeAssistant) -> None: + """Test STORED covers an identical dataset refreshing the border agent.""" + await dataset_store.async_add_dataset(hass, "test", DATASET_1) + store = await dataset_store.async_get_store(hass) + + assert ( + await dataset_store.async_add_dataset( + hass, + "test", + DATASET_1, + preferred_border_agent_id="230C6A1AC57F6F4BE262ACF32E5EF52C", + preferred_extended_address="AEEB2F594B570BBF", + ) + is dataset_store.DatasetAddResult.STORED + ) + entry = list(store.datasets.values())[0] + assert entry.preferred_border_agent_id == "230C6A1AC57F6F4BE262ACF32E5EF52C" + assert entry.preferred_extended_address == "AEEB2F594B570BBF" + + +async def test_add_dataset_discarded_keeps_border_agent(hass: HomeAssistant) -> None: + """Test DISCARDED means nothing about the entry changed.""" + await dataset_store.async_add_dataset( + hass, + "test", + DATASET_1_LARGER_TIMESTAMP, + preferred_border_agent_id="230C6A1AC57F6F4BE262ACF32E5EF52C", + preferred_extended_address="AEEB2F594B570BBF", + ) + store = await dataset_store.async_get_store(hass) + + assert ( + await dataset_store.async_add_dataset( + hass, + "test", + DATASET_1, + preferred_border_agent_id="230C6A1AC57F6F4BE262ACF32E5EF52D", + preferred_extended_address="AEEB2F594B570BB0", + ) + is dataset_store.DatasetAddResult.DISCARDED + ) + entry = list(store.datasets.values())[0] + assert entry.tlv == DATASET_1_LARGER_TIMESTAMP + assert entry.preferred_border_agent_id == "230C6A1AC57F6F4BE262ACF32E5EF52C" + assert entry.preferred_extended_address == "AEEB2F594B570BBF" diff --git a/tests/components/thread/test_websocket_api.py b/tests/components/thread/test_websocket_api.py index 1876cfa956d8..6cd938eb0f83 100644 --- a/tests/components/thread/test_websocket_api.py +++ b/tests/components/thread/test_websocket_api.py @@ -11,6 +11,7 @@ from homeassistant.setup import async_setup_component from . import ( DATASET_1, + DATASET_1_LARGER_TIMESTAMP, DATASET_2, DATASET_3, ROUTER_DISCOVERY_GOOGLE_1, @@ -34,7 +35,7 @@ async def test_add_dataset( ) msg = await client.receive_json() assert msg["success"] - assert msg["result"] is None + assert msg["result"] == {"result": "stored"} store = await dataset_store.async_get_store(hass) assert len(store.datasets) == 1 @@ -43,6 +44,32 @@ async def test_add_dataset( assert dataset.tlv == DATASET_1 +async def test_add_dataset_discarded( + hass: HomeAssistant, hass_ws_client: WebSocketGenerator +) -> None: + """Test a dataset the store discards is reported as discarded, not stored. + + The command still succeeds -- existing callers treat any error as a + failed transfer -- but the payload says the dataset was discarded. + """ + assert await async_setup_component(hass, DOMAIN, {}) + await hass.async_block_till_done() + await dataset_store.async_add_dataset(hass, "test", DATASET_1_LARGER_TIMESTAMP) + + client = await hass_ws_client(hass) + + await client.send_json( + {"id": 1, "type": "thread/add_dataset_tlv", "source": "test", "tlv": DATASET_1} + ) + msg = await client.receive_json() + assert msg["success"] + assert msg["result"] == {"result": "discarded"} + + store = await dataset_store.async_get_store(hass) + assert len(store.datasets) == 1 + assert next(iter(store.datasets.values())).tlv == DATASET_1_LARGER_TIMESTAMP + + async def test_add_invalid_dataset( hass: HomeAssistant, hass_ws_client: WebSocketGenerator ) -> None: @@ -237,7 +264,7 @@ async def test_set_preferred_border_agent( ) msg = await client.receive_json() assert msg["success"] - assert msg["result"] is None + assert msg["result"] == {"result": "stored"} await client.send_json_auto_id({"type": "thread/list_datasets"}) msg = await client.receive_json() diff --git a/tests/components/threshold/test_binary_sensor.py b/tests/components/threshold/test_binary_sensor.py index b227f757b9c5..10573a71f15c 100644 --- a/tests/components/threshold/test_binary_sensor.py +++ b/tests/components/threshold/test_binary_sensor.py @@ -563,13 +563,13 @@ async def test_device_id( device_id=source_device_entry.id, ) await hass.async_block_till_done() - assert entity_registry.async_get("sensor.test_source") is not None + assert entity_registry.async_get(source_entity.entity_id) is not None utility_meter_config_entry = MockConfigEntry( data={}, domain=DOMAIN, options={ - CONF_ENTITY_ID: "sensor.test_source", + CONF_ENTITY_ID: source_entity.entity_id, CONF_HYSTERESIS: 0.0, CONF_LOWER: -2.0, CONF_NAME: "Threshold", @@ -583,7 +583,9 @@ async def test_device_id( assert await hass.config_entries.async_setup(utility_meter_config_entry.entry_id) await hass.async_block_till_done() - utility_meter_entity = entity_registry.async_get("binary_sensor.threshold") + utility_meter_entity = entity_registry.async_get( + "binary_sensor.mock_title_threshold" + ) assert utility_meter_entity is not None assert utility_meter_entity.device_id == source_entity.device_id diff --git a/tests/components/threshold/test_init.py b/tests/components/threshold/test_init.py index 0f8c1539880f..ceafee7c78b6 100644 --- a/tests/components/threshold/test_init.py +++ b/tests/components/threshold/test_init.py @@ -196,7 +196,9 @@ async def test_entry_changed(hass: HomeAssistant, platform) -> None: assert config_entry.entry_id not in _get_device_config_entries(run1_entry) assert config_entry.entry_id not in _get_device_config_entries(run2_entry) - threshold_entity_entry = entity_registry.async_get("binary_sensor.my_threshold") + threshold_entity_entry = entity_registry.async_get( + "binary_sensor.initial_my_threshold" + ) assert threshold_entity_entry.device_id == run1_entry.device_id hass.config_entries.async_update_entry( @@ -208,7 +210,9 @@ async def test_entry_changed(hass: HomeAssistant, platform) -> None: # Check that the device association has updated assert config_entry.entry_id not in _get_device_config_entries(run1_entry) assert config_entry.entry_id not in _get_device_config_entries(run2_entry) - threshold_entity_entry = entity_registry.async_get("binary_sensor.my_threshold") + threshold_entity_entry = entity_registry.async_get( + "binary_sensor.initial_my_threshold" + ) assert threshold_entity_entry.device_id == run2_entry.device_id @@ -225,7 +229,9 @@ async def test_async_handle_source_entity_changes_source_entity_removed( assert await hass.config_entries.async_setup(threshold_config_entry.entry_id) await hass.async_block_till_done() - threshold_entity_entry = entity_registry.async_get("binary_sensor.my_threshold") + threshold_entity_entry = entity_registry.async_get( + "binary_sensor.mock_title_my_threshold" + ) assert threshold_entity_entry.device_id == sensor_entity_entry.device_id sensor_device = device_registry.async_get(sensor_device.id) @@ -244,7 +250,9 @@ async def test_async_handle_source_entity_changes_source_entity_removed( mock_unload_entry.assert_not_called() # Check that the entity is no longer linked to the source device - threshold_entity_entry = entity_registry.async_get("binary_sensor.my_threshold") + threshold_entity_entry = entity_registry.async_get( + "binary_sensor.mock_title_my_threshold" + ) assert threshold_entity_entry.device_id is None # Check that the device is removed @@ -269,7 +277,9 @@ async def test_async_handle_source_entity_changes_source_entity_removed_shared_d assert await hass.config_entries.async_setup(threshold_config_entry.entry_id) await hass.async_block_till_done() - threshold_entity_entry = entity_registry.async_get("binary_sensor.my_threshold") + threshold_entity_entry = entity_registry.async_get( + "binary_sensor.mock_title_my_threshold" + ) assert threshold_entity_entry.device_id == sensor_entity_entry.device_id sensor_device = device_registry.async_get(sensor_device.id) @@ -288,7 +298,9 @@ async def test_async_handle_source_entity_changes_source_entity_removed_shared_d mock_unload_entry.assert_not_called() # Check that the entity is no longer linked to the source device - threshold_entity_entry = entity_registry.async_get("binary_sensor.my_threshold") + threshold_entity_entry = entity_registry.async_get( + "binary_sensor.mock_title_my_threshold" + ) assert threshold_entity_entry.device_id is None # Check that the source device is not removed @@ -317,7 +329,9 @@ async def test_async_handle_source_entity_changes_source_entity_removed_from_dev assert await hass.config_entries.async_setup(threshold_config_entry.entry_id) await hass.async_block_till_done() - threshold_entity_entry = entity_registry.async_get("binary_sensor.my_threshold") + threshold_entity_entry = entity_registry.async_get( + "binary_sensor.mock_title_my_threshold" + ) assert threshold_entity_entry.device_id == sensor_entity_entry.device_id sensor_device = device_registry.async_get(sensor_device.id) @@ -337,7 +351,9 @@ async def test_async_handle_source_entity_changes_source_entity_removed_from_dev mock_unload_entry.assert_called_once() # Check that the entity is no longer linked to the source device - threshold_entity_entry = entity_registry.async_get("binary_sensor.my_threshold") + threshold_entity_entry = entity_registry.async_get( + "binary_sensor.mock_title_my_threshold" + ) assert threshold_entity_entry.device_id is None # Check that the threshold config entry is not in the device @@ -369,7 +385,9 @@ async def test_async_handle_source_entity_changes_source_entity_moved_other_devi assert await hass.config_entries.async_setup(threshold_config_entry.entry_id) await hass.async_block_till_done() - threshold_entity_entry = entity_registry.async_get("binary_sensor.my_threshold") + threshold_entity_entry = entity_registry.async_get( + "binary_sensor.mock_title_my_threshold" + ) assert threshold_entity_entry.device_id == sensor_entity_entry.device_id sensor_device = device_registry.async_get(sensor_device.id) @@ -391,7 +409,9 @@ async def test_async_handle_source_entity_changes_source_entity_moved_other_devi mock_unload_entry.assert_called_once() # Check that the entity is linked to the other device - threshold_entity_entry = entity_registry.async_get("binary_sensor.my_threshold") + threshold_entity_entry = entity_registry.async_get( + "binary_sensor.mock_title_my_threshold" + ) assert threshold_entity_entry.device_id == sensor_device_2.id # Check that the derivative config entry is not in any of the devices @@ -419,7 +439,9 @@ async def test_async_handle_source_entity_new_entity_id( assert await hass.config_entries.async_setup(threshold_config_entry.entry_id) await hass.async_block_till_done() - threshold_entity_entry = entity_registry.async_get("binary_sensor.my_threshold") + threshold_entity_entry = entity_registry.async_get( + "binary_sensor.mock_title_my_threshold" + ) assert threshold_entity_entry.device_id == sensor_entity_entry.device_id sensor_device = device_registry.async_get(sensor_device.id) @@ -486,7 +508,9 @@ async def test_migration_1_1( # is linked to the source device sensor_device = device_registry.async_get(sensor_device.id) assert threshold_config_entry.entry_id not in sensor_device.config_entries - threshold_entity_entry = entity_registry.async_get("binary_sensor.my_threshold") + threshold_entity_entry = entity_registry.async_get( + "binary_sensor.mock_title_my_threshold" + ) assert threshold_entity_entry.device_id == sensor_entity_entry.device_id assert threshold_config_entry.version == 1 diff --git a/tests/components/tplink/test_init.py b/tests/components/tplink/test_init.py index 0d757a3fc310..3cb8f8bf8064 100644 --- a/tests/components/tplink/test_init.py +++ b/tests/components/tplink/test_init.py @@ -375,7 +375,8 @@ async def test_update_attrs_fails_in_init( assert entity state = hass.states.get(entity_id) assert state.state == STATE_UNAVAILABLE - assert f"Unable to read data for MockLight {entity_id}:" in caplog.text + assert f"Unable to read data for {IP_ADDRESS} {entity_id}:" in caplog.text + assert "MockLight" not in caplog.text async def test_update_attrs_fails_on_update( @@ -418,7 +419,8 @@ async def test_update_attrs_fails_on_update( assert entity state = hass.states.get(entity_id) assert state.state == STATE_UNAVAILABLE - assert f"Unable to read data for MockLight {entity_id}:" in caplog.text + assert f"Unable to read data for {IP_ADDRESS} {entity_id}:" in caplog.text + assert "MockLight" not in caplog.text # Check only logs once caplog.clear() freezer.tick(5) @@ -427,7 +429,7 @@ async def test_update_attrs_fails_on_update( assert entity state = hass.states.get(entity_id) assert state.state == STATE_UNAVAILABLE - assert f"Unable to read data for MockLight {entity_id}:" not in caplog.text + assert f"Unable to read data for {IP_ADDRESS} {entity_id}:" not in caplog.text async def test_feature_no_category( diff --git a/tests/components/traccar_server/test_init.py b/tests/components/traccar_server/test_init.py index 5eaf7271227b..f1825f3698d9 100644 --- a/tests/components/traccar_server/test_init.py +++ b/tests/components/traccar_server/test_init.py @@ -4,16 +4,12 @@ import asyncio from collections.abc import Awaitable, Callable from datetime import timedelta import logging -import sys from unittest.mock import AsyncMock, patch from freezegun.api import FrozenDateTimeFactory import pytest from pytraccar import SubscriptionData, TraccarAuthenticationException, TraccarException -from homeassistant.components.traccar_server.coordinator import ( - _SUBSCRIPTION_RECONNECT_DELAY, -) from homeassistant.config_entries import ConfigEntryState from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryAuthFailed @@ -150,76 +146,6 @@ async def test_subscribe_raises_config_entry_auth_failed( assert mock_traccar_api_client.subscribe.call_count == 1 -async def test_subscribe_does_not_recurse_across_reconnects( - hass: HomeAssistant, - mock_traccar_api_client: AsyncMock, - mock_config_entry: MockConfigEntry, -) -> None: - """Subscribe retries must not grow the call stack.""" - attempts = 0 - target_attempts = sys.getrecursionlimit() * 2 - - async def _flaky_subscribe(_callback: object) -> None: - nonlocal attempts - attempts += 1 - if attempts >= target_attempts: - # End the task deterministically, the same way an unload would. - raise asyncio.CancelledError - raise TraccarException("Simulated dropped connection") - - mock_traccar_api_client.subscribe = AsyncMock(side_effect=_flaky_subscribe) - - with patch( - "homeassistant.components.traccar_server.coordinator._SUBSCRIPTION_RECONNECT_DELAY", - 0, - ): - await setup_integration(hass, mock_config_entry) - await hass.async_block_till_done(wait_background_tasks=True) - - assert attempts == target_attempts - - -async def test_subscribe_does_not_busy_loop_on_clean_return( - hass: HomeAssistant, - mock_traccar_api_client: AsyncMock, - mock_config_entry: MockConfigEntry, -) -> None: - """If client.subscribe() ever returns without raising, still throttle. - - pytraccar's subscribe() should always raise on disconnect (see - pytraccar#477), so a clean return isn't expected in practice. But the - retry loop must not assume that - if it ever happens, reconnecting - immediately with no delay would spin the event loop at 100% CPU. - """ - calls = 0 - - async def _clean_return_then_cancel(_callback: object) -> None: - nonlocal calls - calls += 1 - if calls >= 3: - raise asyncio.CancelledError - - mock_traccar_api_client.subscribe = AsyncMock(side_effect=_clean_return_then_cancel) - - with patch( - "homeassistant.components.traccar_server.coordinator.asyncio.sleep", - new=AsyncMock(), - ) as mock_sleep: - await setup_integration(hass, mock_config_entry) - await hass.async_block_till_done(wait_background_tasks=True) - - assert calls == 3 - # Only calls 1 and 2 (the clean returns) reach the loop's delay; - # call 3 raises CancelledError before that line, so exactly two real - # reconnect delays are attributable to this code path. - reconnect_delay_sleeps = [ - call - for call in mock_sleep.await_args_list - if call.args == (_SUBSCRIPTION_RECONNECT_DELAY,) - ] - assert len(reconnect_delay_sleeps) == 2 - - async def test_subscribe_retries_on_unexpected_exception( hass: HomeAssistant, mock_traccar_api_client: AsyncMock, @@ -254,104 +180,3 @@ async def test_subscribe_retries_on_unexpected_exception( await hass.async_block_till_done(wait_background_tasks=True) assert calls == 3 - - -async def test_subscribe_logs_error_once_then_periodic_reminder( - hass: HomeAssistant, - mock_traccar_api_client: AsyncMock, - mock_config_entry: MockConfigEntry, - caplog: pytest.LogCaptureFixture, -) -> None: - """The first failure logs an error; later failures throttle to a periodic warning.""" - calls = 0 - target_attempts = 61 # Crosses two 30-attempt reminder boundaries (30, 60). - - async def _always_fails(_callback: object) -> None: - nonlocal calls - calls += 1 - if calls >= target_attempts: - raise asyncio.CancelledError - raise TraccarException("Simulated dropped connection") - - mock_traccar_api_client.subscribe = AsyncMock(side_effect=_always_fails) - - with ( - patch( - "homeassistant.components.traccar_server.coordinator._SUBSCRIPTION_RECONNECT_DELAY", - 0, - ), - caplog.at_level(logging.INFO, logger="homeassistant.components.traccar_server"), - ): - await setup_integration(hass, mock_config_entry) - await hass.async_block_till_done(wait_background_tasks=True) - - error_records = [ - r - for r in caplog.records - if r.levelno == logging.ERROR - and r.name == "homeassistant.components.traccar_server" - ] - warning_records = [ - r - for r in caplog.records - if r.levelno == logging.WARNING - and r.name == "homeassistant.components.traccar_server" - ] - - assert len(error_records) == 1 - assert "Error while subscribing to Traccar" in error_records[0].message - assert len(warning_records) == 2 - assert all( - "Still unable to (re)connect to Traccar" in r.message for r in warning_records - ) - assert any("60" in r.message for r in warning_records) - - -async def test_subscribe_clean_return_resets_error_logging( - hass: HomeAssistant, - mock_traccar_api_client: AsyncMock, - mock_config_entry: MockConfigEntry, - caplog: pytest.LogCaptureFixture, -) -> None: - """A clean return re-arms error logging for the next failure streak. - - The should-log flag must reset alongside the failure counter - otherwise - a failure streak starting right after a clean return would be silently - throttled instead of logging its first error. - """ - calls = 0 - - async def _fail_then_clean_return_then_fail(_callback: object) -> None: - nonlocal calls - calls += 1 - if calls == 1: - raise TraccarException("First failure") - if calls == 2: - return # Clean return - should re-arm error logging. - if calls == 3: - raise TraccarException("Second failure, after clean return") - raise asyncio.CancelledError - - mock_traccar_api_client.subscribe = AsyncMock( - side_effect=_fail_then_clean_return_then_fail - ) - - with ( - patch( - "homeassistant.components.traccar_server.coordinator._SUBSCRIPTION_RECONNECT_DELAY", - 0, - ), - caplog.at_level(logging.INFO, logger="homeassistant.components.traccar_server"), - ): - await setup_integration(hass, mock_config_entry) - await hass.async_block_till_done(wait_background_tasks=True) - - error_records = [ - r - for r in caplog.records - if r.levelno == logging.ERROR - and r.name == "homeassistant.components.traccar_server" - ] - assert len(error_records) == 2 - assert "First failure" in error_records[0].message - assert "Second failure, after clean return" in error_records[1].message diff --git a/tests/components/tractive/test_config_flow.py b/tests/components/tractive/test_config_flow.py index 49428b4931e2..b2af9c13d8dd 100644 --- a/tests/components/tractive/test_config_flow.py +++ b/tests/components/tractive/test_config_flow.py @@ -140,9 +140,17 @@ async def test_flow_entry_already_exists(hass: HomeAssistant) -> None: ) first_entry.add_to_hass(hass) + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + with patch("aiotractive.api.API.user_id", return_value="USERID"): - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_USER}, data=USER_INPUT + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + USER_INPUT, ) assert result["type"] is FlowResultType.ABORT diff --git a/tests/components/trend/test_binary_sensor.py b/tests/components/trend/test_binary_sensor.py index 5d366b91564a..ee61f70c9de7 100644 --- a/tests/components/trend/test_binary_sensor.py +++ b/tests/components/trend/test_binary_sensor.py @@ -428,14 +428,14 @@ async def test_device_id( device_id=source_device_entry.id, ) await hass.async_block_till_done() - assert entity_registry.async_get("sensor.test_source") is not None + assert entity_registry.async_get(source_entity.entity_id) is not None trend_config_entry = MockConfigEntry( data={}, domain=DOMAIN, options={ "name": "Trend", - "entity_id": "sensor.test_source", + "entity_id": source_entity.entity_id, "invert": False, }, title="Trend", @@ -445,7 +445,7 @@ async def test_device_id( assert await hass.config_entries.async_setup(trend_config_entry.entry_id) await hass.async_block_till_done() - trend_entity = entity_registry.async_get("binary_sensor.trend") + trend_entity = entity_registry.async_get("binary_sensor.mock_title_trend") assert trend_entity is not None assert trend_entity.device_id == source_entity.device_id diff --git a/tests/components/trend/test_init.py b/tests/components/trend/test_init.py index 50533b8f1d64..632a2e09e6ca 100644 --- a/tests/components/trend/test_init.py +++ b/tests/components/trend/test_init.py @@ -147,7 +147,7 @@ async def test_async_handle_source_entity_changes_source_entity_removed( assert await hass.config_entries.async_setup(trend_config_entry.entry_id) await hass.async_block_till_done() - trend_entity_entry = entity_registry.async_get("binary_sensor.my_trend") + trend_entity_entry = entity_registry.async_get("binary_sensor.mock_title_my_trend") assert trend_entity_entry.device_id == sensor_entity_entry.device_id sensor_device = device_registry.async_get(sensor_device.id) @@ -166,7 +166,7 @@ async def test_async_handle_source_entity_changes_source_entity_removed( mock_unload_entry.assert_called_once() # Check that the helper entity is removed - assert not entity_registry.async_get("binary_sensor.my_trend") + assert not entity_registry.async_get(trend_entity_entry.entity_id) # Check that the device is removed assert not device_registry.async_get(sensor_device.id) @@ -194,7 +194,7 @@ async def test_async_handle_source_entity_changes_source_entity_removed_shared_d assert await hass.config_entries.async_setup(trend_config_entry.entry_id) await hass.async_block_till_done() - trend_entity_entry = entity_registry.async_get("binary_sensor.my_trend") + trend_entity_entry = entity_registry.async_get("binary_sensor.mock_title_my_trend") assert trend_entity_entry.device_id == sensor_entity_entry.device_id sensor_device = device_registry.async_get(sensor_device.id) @@ -213,7 +213,7 @@ async def test_async_handle_source_entity_changes_source_entity_removed_shared_d mock_unload_entry.assert_called_once() # Check that the helper entity is removed - assert not entity_registry.async_get("binary_sensor.my_trend") + assert not entity_registry.async_get(trend_entity_entry.entity_id) # Check that the source device is not removed assert device_registry.async_get(sensor_device.id) is not None @@ -241,7 +241,7 @@ async def test_async_handle_source_entity_changes_source_entity_removed_from_dev assert await hass.config_entries.async_setup(trend_config_entry.entry_id) await hass.async_block_till_done() - trend_entity_entry = entity_registry.async_get("binary_sensor.my_trend") + trend_entity_entry = entity_registry.async_get("binary_sensor.mock_title_my_trend") assert trend_entity_entry.device_id == sensor_entity_entry.device_id sensor_device = device_registry.async_get(sensor_device.id) @@ -261,7 +261,7 @@ async def test_async_handle_source_entity_changes_source_entity_removed_from_dev mock_unload_entry.assert_called_once() # Check that the entity is no longer linked to the source device - trend_entity_entry = entity_registry.async_get("binary_sensor.my_trend") + trend_entity_entry = entity_registry.async_get(trend_entity_entry.entity_id) assert trend_entity_entry.device_id is None # Check that the trend config entry is not in the device @@ -293,7 +293,7 @@ async def test_async_handle_source_entity_changes_source_entity_moved_other_devi assert await hass.config_entries.async_setup(trend_config_entry.entry_id) await hass.async_block_till_done() - trend_entity_entry = entity_registry.async_get("binary_sensor.my_trend") + trend_entity_entry = entity_registry.async_get("binary_sensor.mock_title_my_trend") assert trend_entity_entry.device_id == sensor_entity_entry.device_id sensor_device = device_registry.async_get(sensor_device.id) @@ -315,7 +315,7 @@ async def test_async_handle_source_entity_changes_source_entity_moved_other_devi mock_unload_entry.assert_called_once() # Check that the entity is linked to the other device - trend_entity_entry = entity_registry.async_get("binary_sensor.my_trend") + trend_entity_entry = entity_registry.async_get(trend_entity_entry.entity_id) assert trend_entity_entry.device_id == sensor_device_2.id # Check that the trend config entry is not in any of the devices @@ -343,7 +343,7 @@ async def test_async_handle_source_entity_new_entity_id( assert await hass.config_entries.async_setup(trend_config_entry.entry_id) await hass.async_block_till_done() - trend_entity_entry = entity_registry.async_get("binary_sensor.my_trend") + trend_entity_entry = entity_registry.async_get("binary_sensor.mock_title_my_trend") assert trend_entity_entry.device_id == sensor_entity_entry.device_id sensor_device = device_registry.async_get(sensor_device.id) @@ -408,7 +408,7 @@ async def test_migration_1_1( # is linked to the source device sensor_device = device_registry.async_get(sensor_device.id) assert trend_config_entry.entry_id not in sensor_device.config_entries - trend_entity_entry = entity_registry.async_get("binary_sensor.my_trend") + trend_entity_entry = entity_registry.async_get("binary_sensor.mock_title_my_trend") assert trend_entity_entry.device_id == sensor_entity_entry.device_id assert trend_config_entry.version == 1 diff --git a/tests/components/tts/test_init.py b/tests/components/tts/test_init.py index 9dd312fc3a5e..f9ae16712372 100644 --- a/tests/components/tts/test_init.py +++ b/tests/components/tts/test_init.py @@ -1930,6 +1930,54 @@ async def test_async_convert_audio_probe_size( ] +@pytest.mark.parametrize( + ("to_bitrate", "expected_encoder_args"), + [ + pytest.param(None, ["-q:a", "0"], id="default_vbr"), + pytest.param(48, ["-b:a", "48k"], id="cbr_48k"), + ], +) +async def test_async_convert_audio_mp3_bitrate( + hass: HomeAssistant, + to_bitrate: int | None, + expected_encoder_args: list[str], +) -> None: + """Test that a preferred bitrate produces a constant bitrate MP3.""" + assert await async_setup_component(hass, ffmpeg.DOMAIN, {}) + + mock_process = MagicMock() + mock_process.stdin.drain = AsyncMock() + mock_process.stdout.read = AsyncMock(return_value=b"") + mock_process.wait = AsyncMock(return_value=0) + + with patch( + "asyncio.create_subprocess_exec", return_value=mock_process + ) as mock_create_subprocess_exec: + async for _chunk in tts._async_convert_audio( + hass, + "wav", + _audio_data_gen(), + "mp3", + to_sample_rate=24000, + to_sample_channels=1, + to_bitrate=to_bitrate, + ): + pass + + command = list(mock_create_subprocess_exec.call_args.args) + input_index = command.index("-i") + assert command[input_index + 2 :] == [ + "-f", + "mp3", + "-ar", + "24000", + "-ac", + "1", + *expected_encoder_args, + "pipe:1", + ] + + async def test_default_engine_prefer_entity( hass: HomeAssistant, mock_tts_entity: MockTTSEntity, diff --git a/tests/components/tuya/snapshots/test_init.ambr b/tests/components/tuya/snapshots/test_init.ambr index 145718c8fbfd..43f740196fe0 100644 --- a/tests/components/tuya/snapshots/test_init.ambr +++ b/tests/components/tuya/snapshots/test_init.ambr @@ -2450,7 +2450,7 @@ 'labels': set({ }), 'manufacturer': 'Tuya', - 'model': 'Chasing Tape Light (unsupported)', + 'model': 'Chasing Tape Light', 'model_id': 'expmpw4xxd0kkifb', 'name': 'Chasing Tape Light', 'name_by_user': None, diff --git a/tests/components/tuya/snapshots/test_light.ambr b/tests/components/tuya/snapshots/test_light.ambr index a0aef98c4c16..a4c70cd5c273 100644 --- a/tests/components/tuya/snapshots/test_light.ambr +++ b/tests/components/tuya/snapshots/test_light.ambr @@ -808,6 +808,84 @@ 'state': 'off', }) # --- +# name: test_platform_setup_and_discovery[light.chasing_tape_light-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : 6500, + : 2000, + : list([ + , + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'light', + 'entity_category': None, + 'entity_id': 'light.chasing_tape_light', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': None, + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'tuya.bfikk0dxx4wpmpxeddchswitch_led', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[light.chasing_tape_light-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 255, + : , + : 2000, + : 'Chasing Tape Light', + : tuple( + 30.601, + 94.547, + ), + : 6500, + : 2000, + : tuple( + 255, + 137, + 14, + ), + : list([ + , + ]), + : , + : tuple( + 0.598, + 0.383, + ), + }), + 'context': , + 'entity_id': 'light.chasing_tape_light', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- # name: test_platform_setup_and_discovery[light.cleverio_pf100_light-entry] EntityRegistryEntrySnapshot({ 'aliases': list([ diff --git a/tests/components/tuya/test_diagnostics.py b/tests/components/tuya/test_diagnostics.py index 76608b1a1dda..90900bae43cf 100644 --- a/tests/components/tuya/test_diagnostics.py +++ b/tests/components/tuya/test_diagnostics.py @@ -69,7 +69,7 @@ async def test_device_diagnostics( device = device_registry.async_get_device_by_identifier( (DOMAIN, mock_device.id), mock_config_entry.entry_id ) - assert device, repr(device_registry.devices) + assert device, repr(device_registry._devices) result = await get_diagnostics_for_device( hass, hass_client, mock_config_entry, device diff --git a/tests/components/unifi/test_sensor.py b/tests/components/unifi/test_sensor.py index f610b515ebc2..bf346b4b12d3 100644 --- a/tests/components/unifi/test_sensor.py +++ b/tests/components/unifi/test_sensor.py @@ -362,6 +362,49 @@ PDU_OUTLETS_UPDATE_DATA = [ }, ] +UPS_DEVICE_1 = deepcopy(PDU_DEVICE_1) +UPS_DEVICE_1.update( + { + "device_id": "mock-ups", + "mac": "02:00:00:00:00:01", + "model": "USPDA2B", + "name": "Dummy UPS 2U Pro", + "type": "usp", + "outlet_table": [ + { + "index": 1, + "relay_state": True, + "cycle_enabled": False, + "name": "Outlet 1", + "outlet_caps": 65539, + "outlet_voltage": 121.7, + "outlet_current": 0.35, + "outlet_power": 42.5, + "outlet_power_factor": 0.98, + }, + { + "index": 2, + "relay_state": True, + "cycle_enabled": False, + "has_metering": True, + "name": "Outlet 2", + "outlet_voltage": 121.7, + "outlet_current": 0.1, + "outlet_power": 12.5, + "outlet_power_factor": 0.95, + }, + ], + "outlet_overrides": [ + { + "cycle_enabled": False, + "name": "Outlet 1", + "relay_state": True, + "index": 1, + } + ], + } +) + @pytest.mark.parametrize( "config_entry_options", @@ -956,6 +999,27 @@ async def test_outlet_power_readings( assert hass.states.get(f"sensor.{entity_id}").state == expected_update_value +@pytest.mark.parametrize("device_payload", [[UPS_DEVICE_1]]) +@pytest.mark.usefixtures("config_entry_setup") +async def test_outlet_power_reading_extended_caps( + hass: HomeAssistant, + mock_websocket_message: WebsocketMessageMock, +) -> None: + """Test outlet power reporting with extended capability bits and numeric values.""" + entity_id = "sensor.dummy_ups_2u_pro_outlet_1_outlet_power" + assert hass.states.get(entity_id).state == "42.5" + assert ( + hass.states.get("sensor.dummy_ups_2u_pro_outlet_2_outlet_power").state == "12.5" + ) + + updated_device_data = deepcopy(UPS_DEVICE_1) + updated_device_data["outlet_table"][0]["outlet_power"] = 43.5 + mock_websocket_message(message=MessageKey.DEVICE, data=updated_device_data) + await hass.async_block_till_done() + + assert hass.states.get(entity_id).state == "43.5" + + @pytest.mark.parametrize( "device_payload", [ diff --git a/tests/components/unifi/test_switch.py b/tests/components/unifi/test_switch.py index e1c980a0e210..4b223fde41f0 100644 --- a/tests/components/unifi/test_switch.py +++ b/tests/components/unifi/test_switch.py @@ -735,6 +735,34 @@ PDU_DEVICE_1 = { "x_has_ssh_hostkey": True, } +UPS_DEVICE_1 = deepcopy(PDU_DEVICE_1) +UPS_DEVICE_1.update( + { + "device_id": "mock-ups", + "mac": "02:00:00:00:00:01", + "model": "USPDA2B", + "name": "Dummy UPS 2U Pro", + "type": "usp", + "outlet_table": [ + { + "index": 1, + "relay_state": True, + "cycle_enabled": False, + "name": "Outlet 1", + "outlet_caps": 65539, + } + ], + "outlet_overrides": [ + { + "cycle_enabled": False, + "name": "Outlet 1", + "relay_state": True, + "index": 1, + } + ], + } +) + WLAN = { "_id": "012345678910111213141516", "bc_filter_enabled": False, @@ -1455,6 +1483,7 @@ async def test_object_oriented_network_configs( ([OUTLET_UP1], "plug_outlet_1", 1, 1), ([PDU_DEVICE_1], "dummy_usp_pdu_pro_usb_outlet_1", 1, 2), ([PDU_DEVICE_1], "dummy_usp_pdu_pro_outlet_2", 2, 2), + ([UPS_DEVICE_1], "dummy_ups_2u_pro_outlet_1", 1, 1), ], ) async def test_outlet_switches( diff --git a/tests/components/unifiprotect/test_init.py b/tests/components/unifiprotect/test_init.py index be6e8ee13fab..a24675a10d2e 100644 --- a/tests/components/unifiprotect/test_init.py +++ b/tests/components/unifiprotect/test_init.py @@ -417,7 +417,7 @@ async def test_device_remove_devices_nvr( await hass.config_entries.async_setup(ufp.entry.entry_id) await hass.async_block_till_done() - live_device_entry = list(device_registry.devices.values())[0] + live_device_entry = list(device_registry.devices)[0] client = await hass_ws_client(hass) response = await client.remove_device(live_device_entry.id) assert not response["success"] diff --git a/tests/components/unifiprotect/test_services.py b/tests/components/unifiprotect/test_services.py index 003d0e514b93..847d28950eac 100644 --- a/tests/components/unifiprotect/test_services.py +++ b/tests/components/unifiprotect/test_services.py @@ -44,7 +44,7 @@ async def device_fixture( await init_entry(hass, ufp, []) - return list(device_registry.devices.values())[0] + return list(device_registry.devices)[0] @pytest.fixture(name="subdevice") @@ -58,7 +58,7 @@ async def subdevice_fixture( await init_entry(hass, ufp, [light]) - return [d for d in device_registry.devices.values() if d.name != "UnifiProtect"][0] + return [d for d in device_registry.devices if d.name != "UnifiProtect"][0] async def test_global_service_bad_device( diff --git a/tests/components/usb/test_consumers.py b/tests/components/usb/test_consumers.py new file mode 100644 index 000000000000..e130bb46edcd --- /dev/null +++ b/tests/components/usb/test_consumers.py @@ -0,0 +1,586 @@ +"""Tests for serial port consumer attribution.""" + +from collections.abc import AsyncGenerator +from typing import Any +from unittest.mock import patch + +import pytest + +from homeassistant.components.hassio import HassioNotReadyError +from homeassistant.components.usb import DOMAIN +from homeassistant.components.usb.models import SerialDevice, USBDevice +from homeassistant.components.usb.utils import usb_service_info_from_device +from homeassistant.config_entries import ( + SOURCE_IGNORE, + SOURCE_USB, + SOURCE_USER, + ConfigEntryDisabler, + ConfigEntryState, + ConfigFlow, + ConfigFlowResult, +) +from homeassistant.core import HomeAssistant +from homeassistant.helpers.service_info.usb import UsbServiceInfo +from homeassistant.setup import async_setup_component + +from . import patch_scanned_serial_ports + +from tests.common import ( + MockConfigEntry, + MockModule, + mock_config_flow, + mock_integration, + mock_platform, +) +from tests.typing import WebSocketGenerator + +TTY_USB0 = "/dev/ttyUSB0" +TTY_USB0_BY_ID = "/dev/serial/by-id/usb-Silicon_Labs_CP2102-if00-port0" +TTY_USB1 = "/dev/ttyUSB1" +ESPHOME_PORT = "esphome-hass://01JZ/uart0" + +USB0_PORT = USBDevice( + device=TTY_USB0, + vid="10C4", + pid="EA60", + serial_number="001234", + manufacturer="Silicon Labs", + description="CP2102 USB to UART", +) + + +@pytest.fixture(name="setup_ports") +async def setup_ports_fixture( + hass: HomeAssistant, force_usb_polling_watcher: None +) -> AsyncGenerator[None]: + """Set up the USB integration with a local and a remote serial port.""" + with ( + patch("homeassistant.components.usb.async_get_usb", return_value=[]), + patch_scanned_serial_ports( + return_value=[ + USB0_PORT, + SerialDevice( + device=ESPHOME_PORT, + serial_number="01JZ-uart0", + manufacturer="ESPHome", + description="Serial proxy", + ), + ] + ), + ): + assert await async_setup_component(hass, DOMAIN, {"usb": {}}) + await hass.async_block_till_done() + yield + + +async def _async_get_serial_ports( + hass_ws_client: WebSocketGenerator, hass: HomeAssistant +) -> list[dict[str, Any]]: + """Return the result of the `usb/list_serial_ports` command with usage.""" + ws_client = await hass_ws_client(hass) + await ws_client.send_json( + {"id": 1, "type": "usb/list_serial_ports", "include_usage": True} + ) + response = await ws_client.receive_json() + + assert response["success"] + return response["result"] + + +@pytest.mark.usefixtures("setup_ports") +@pytest.mark.parametrize( + ("data", "options"), + [ + pytest.param({"device": TTY_USB0}, {}, id="device"), + pytest.param({"device": {"path": TTY_USB0}}, {}, id="nested_device_path"), + pytest.param({"port": TTY_USB0}, {}, id="port"), + pytest.param({"usb_path": TTY_USB0}, {}, id="usb_path"), + pytest.param({}, {"usb_path": TTY_USB0}, id="usb_path_in_options"), + pytest.param({"serial_port": TTY_USB0}, {}, id="serial_port"), + pytest.param({"device": TTY_USB0_BY_ID}, {}, id="by_id_symlink"), + pytest.param({"device": TTY_USB0}, {"device": TTY_USB0}, id="data_and_options"), + ], +) +async def test_config_entry_consumers( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + data: dict[str, Any], + options: dict[str, Any], +) -> None: + """Test detecting serial ports configured in config entries.""" + mock_integration(hass, MockModule("test_usb", dependencies=["usb"])) + entry = MockConfigEntry( + domain="test_usb", title="Test USB", data=data, options=options + ) + entry.add_to_hass(hass) + + with patch( + "homeassistant.components.usb.consumers.os.path.realpath", + side_effect=lambda path: TTY_USB0 if path == TTY_USB0_BY_ID else path, + ): + result = await _async_get_serial_ports(hass_ws_client, hass) + + assert [(port["device"], port["consumers"]) for port in result] == [ + ( + TTY_USB0, + [ + { + "kind": "config_entry", + "title": "Test USB", + "active": False, + "domain": "test_usb", + "config_entry_id": entry.entry_id, + "slug": None, + } + ], + ), + (ESPHOME_PORT, []), + ] + + +@pytest.mark.usefixtures("setup_ports") +@pytest.mark.parametrize( + "device", + [ + pytest.param(f"serial://{TTY_USB0}", id="serial_url"), + pytest.param(f"serial://{TTY_USB0}:4800", id="serial_url_with_baud"), + pytest.param(f"device://{TTY_USB0}:4800", id="device_url_with_baud"), + pytest.param(f"{TTY_USB0}:4800", id="bare_path_with_baud"), + ], +) +async def test_config_entry_upb_url( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + device: str, +) -> None: + """Test upb's URL forms with an optional baud rate suffix.""" + mock_integration(hass, MockModule("upb", dependencies=["usb"])) + MockConfigEntry(domain="upb", title="UPB", data={"device": device}).add_to_hass( + hass + ) + + result = await _async_get_serial_ports(hass_ws_client, hass) + + assert [(port["device"], len(port["consumers"])) for port in result] == [ + (TTY_USB0, 1), + (ESPHOME_PORT, 0), + ] + + +@pytest.mark.usefixtures("setup_ports") +@pytest.mark.parametrize( + ("state", "active"), + [ + pytest.param(ConfigEntryState.LOADED, True, id="loaded"), + pytest.param(ConfigEntryState.SETUP_RETRY, True, id="setup_retry"), + pytest.param(ConfigEntryState.SETUP_IN_PROGRESS, True, id="setup_in_progress"), + pytest.param( + ConfigEntryState.UNLOAD_IN_PROGRESS, True, id="unload_in_progress" + ), + pytest.param(ConfigEntryState.FAILED_UNLOAD, True, id="failed_unload"), + pytest.param(ConfigEntryState.NOT_LOADED, False, id="not_loaded"), + pytest.param(ConfigEntryState.SETUP_ERROR, False, id="setup_error"), + pytest.param(ConfigEntryState.MIGRATION_ERROR, False, id="migration_error"), + ], +) +async def test_config_entry_active_states( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + state: ConfigEntryState, + active: bool, +) -> None: + """Test which config entry states mark the consumer as active.""" + mock_integration(hass, MockModule("test_usb", dependencies=["usb"])) + MockConfigEntry( + domain="test_usb", title="Test USB", data={"device": TTY_USB0}, state=state + ).add_to_hass(hass) + + result = await _async_get_serial_ports(hass_ws_client, hass) + + assert [consumer["active"] for consumer in result[0]["consumers"]] == [active] + + +@pytest.mark.usefixtures("setup_ports") +@pytest.mark.parametrize( + "data", + [ + pytest.param({"port": 8080}, id="tcp_port"), + pytest.param({"port": "192.0.2.1:1234"}, id="host_and_port"), + pytest.param({"device": {"other": TTY_USB0}}, id="unknown_nested_key"), + pytest.param({"other": TTY_USB0}, id="unknown_key"), + ], +) +async def test_config_entry_non_serial_values( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + data: dict[str, Any], +) -> None: + """Test values that do not refer to a serial port are ignored.""" + mock_integration(hass, MockModule("test_usb", dependencies=["usb"])) + MockConfigEntry(domain="test_usb", title="Test USB", data=data).add_to_hass(hass) + + result = await _async_get_serial_ports(hass_ws_client, hass) + + assert [port["consumers"] for port in result] == [[], []] + + +@pytest.mark.usefixtures("setup_ports") +@pytest.mark.parametrize( + ("source", "disabled_by", "num_consumers"), + [ + pytest.param(SOURCE_IGNORE, None, 0, id="ignored_entry_hidden"), + pytest.param( + SOURCE_USER, ConfigEntryDisabler.USER, 1, id="disabled_entry_shown" + ), + ], +) +async def test_config_entry_ignored_and_disabled( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + source: str, + disabled_by: ConfigEntryDisabler | None, + num_consumers: int, +) -> None: + """Test ignored entries are hidden while disabled entries are shown.""" + mock_integration(hass, MockModule("test_usb", dependencies=["usb"])) + MockConfigEntry( + domain="test_usb", + title="Test USB", + data={"device": TTY_USB0}, + source=source, + disabled_by=disabled_by, + ).add_to_hass(hass) + + result = await _async_get_serial_ports(hass_ws_client, hass) + + assert [len(port["consumers"]) for port in result] == [num_consumers, 0] + + +@pytest.mark.usefixtures("setup_ports") +async def test_socket_path_psk_not_exposed( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, +) -> None: + """Test the noise PSK in zwave_js's esphome socket path is stripped.""" + mock_integration(hass, MockModule("test_usb", dependencies=["usb"])) + MockConfigEntry( + domain="test_usb", + title="Test USB", + data={"socket_path": "esphome://192.0.2.5:6053/?key=secret-psk"}, + ).add_to_hass(hass) + + result = await _async_get_serial_ports(hass_ws_client, hass) + + assert [ + (port["device"], port["present"], len(port["consumers"])) for port in result + ] == [ + (TTY_USB0, True, 0), + (ESPHOME_PORT, True, 0), + ("esphome://192.0.2.5:6053/", True, 1), + ] + assert "secret-psk" not in str(result) + + +@pytest.mark.usefixtures("setup_ports") +@pytest.mark.parametrize( + ("domain", "data"), + [ + pytest.param("alarmdecoder", {"device_path": TTY_USB0}, id="alarmdecoder"), + pytest.param("bryant_evolution", {"filename": TTY_USB0}, id="bryant_evolution"), + pytest.param("elkm1", {"host": f"serial://{TTY_USB0}:115200"}, id="elkm1"), + pytest.param("mysensors", {"device": TTY_USB0}, id="mysensors"), + ], +) +async def test_non_usb_serial_domains( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + domain: str, + data: dict[str, Any], +) -> None: + """Test integrations holding a serial port without a `usb` dependency.""" + mock_integration(hass, MockModule(domain)) + MockConfigEntry(domain=domain, title="Test", data=data).add_to_hass(hass) + + result = await _async_get_serial_ports(hass_ws_client, hass) + + assert [len(port["consumers"]) for port in result] == [1, 0] + + +@pytest.mark.usefixtures("setup_ports") +async def test_config_entry_unknown_integration( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, +) -> None: + """Test config entries of integrations that fail to resolve are ignored.""" + MockConfigEntry( + domain="removed_custom_component", title="Test", data={"device": TTY_USB0} + ).add_to_hass(hass) + + result = await _async_get_serial_ports(hass_ws_client, hass) + + assert [port["consumers"] for port in result] == [[], []] + + +@pytest.mark.usefixtures("setup_ports") +async def test_config_entry_without_usb_dependency( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, +) -> None: + """Test config entries of integrations not depending on `usb` are ignored.""" + mock_integration(hass, MockModule("test_no_usb")) + MockConfigEntry( + domain="test_no_usb", title="Test", data={"device": TTY_USB0} + ).add_to_hass(hass) + + result = await _async_get_serial_ports(hass_ws_client, hass) + + assert [port["consumers"] for port in result] == [[], []] + + +@pytest.mark.usefixtures("setup_ports") +async def test_config_entry_after_dependency( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, +) -> None: + """Test config entries of integrations depending on `usb` after setup.""" + mock_integration( + hass, + MockModule("test_after_usb", partial_manifest={"after_dependencies": ["usb"]}), + ) + MockConfigEntry( + domain="test_after_usb", title="Test", data={"device": TTY_USB0} + ).add_to_hass(hass) + + result = await _async_get_serial_ports(hass_ws_client, hass) + + assert [len(port["consumers"]) for port in result] == [1, 0] + + +@pytest.mark.usefixtures("setup_ports") +async def test_remote_port_consumer( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, +) -> None: + """Test a config entry using a remote serial port.""" + mock_integration(hass, MockModule("test_usb", dependencies=["usb"])) + MockConfigEntry( + domain="test_usb", title="Test USB", data={"device": ESPHOME_PORT} + ).add_to_hass(hass) + + result = await _async_get_serial_ports(hass_ws_client, hass) + + assert [(port["device"], len(port["consumers"])) for port in result] == [ + (TTY_USB0, 0), + (ESPHOME_PORT, 1), + ] + + +@pytest.mark.usefixtures("setup_ports") +@pytest.mark.parametrize( + ("device", "present"), + [ + pytest.param(TTY_USB1, False, id="local"), + pytest.param("esphome-hass://02AB/uart0", False, id="esphome_proxy"), + pytest.param("esphome://ttl-to-serial.local/uart1", True, id="esphome"), + pytest.param("socket://192.0.2.1:1234", True, id="socket"), + pytest.param("tcp://192.0.2.1:1234", True, id="tcp"), + pytest.param("rfc2217://192.0.2.1:1234", True, id="rfc2217"), + ], +) +async def test_configured_port_not_scanned( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + device: str, + present: bool, +) -> None: + """Test a configured port that is not in the scan. + + Scannable ports are absent, unscannable URLs are assumed present. + """ + mock_integration(hass, MockModule("test_usb", dependencies=["usb"])) + entry = MockConfigEntry( + domain="test_usb", title="Test USB", data={"device": device} + ) + entry.add_to_hass(hass) + + result = await _async_get_serial_ports(hass_ws_client, hass) + + assert [(port["device"], port["present"]) for port in result] == [ + (TTY_USB0, True), + (ESPHOME_PORT, True), + (device, present), + ] + assert result[2] == { + "device": device, + "resolved_device": None, + "serial_number": None, + "manufacturer": None, + "description": None, + "interface_description": None, + "interface_num": None, + "matching_integrations": [], + "present": present, + "discovery_flows": [], + "consumers": [ + { + "kind": "config_entry", + "title": "Test USB", + "active": False, + "domain": "test_usb", + "config_entry_id": entry.entry_id, + "slug": None, + } + ], + } + + +@pytest.mark.usefixtures("setup_ports") +async def test_app_consumers( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, +) -> None: + """Test detecting serial ports mapped into apps.""" + apps_info = { + "core_zwave_js": { + "name": "Z-Wave JS", + "state": "started", + "devices": [TTY_USB0_BY_ID, "/dev/dri/card0"], + }, + "some_app": { + "name": "Some App", + "state": "stopped", + "devices": [TTY_USB1], + }, + "uninstalled_app": None, + } + + with ( + patch("homeassistant.components.usb.consumers.is_hassio", return_value=True), + patch( + "homeassistant.components.usb.consumers.get_addons_info", + return_value=apps_info, + ), + patch( + "homeassistant.components.usb.consumers.os.path.realpath", + side_effect=lambda path: TTY_USB0 if path == TTY_USB0_BY_ID else path, + ), + ): + result = await _async_get_serial_ports(hass_ws_client, hass) + + assert [(port["device"], port["consumers"]) for port in result] == [ + ( + TTY_USB0, + [ + { + "kind": "app", + "title": "Z-Wave JS", + "active": True, + "domain": None, + "config_entry_id": None, + "slug": "core_zwave_js", + } + ], + ), + (ESPHOME_PORT, []), + ] + + +@pytest.mark.usefixtures("setup_ports") +async def test_app_consumers_without_supervisor( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, +) -> None: + """Test apps are not considered without a supervisor.""" + with patch( + "homeassistant.components.usb.consumers.get_addons_info" + ) as mock_apps_info: + result = await _async_get_serial_ports(hass_ws_client, hass) + + assert len(mock_apps_info.mock_calls) == 0 + assert [port["consumers"] for port in result] == [[], []] + + +@pytest.mark.usefixtures("setup_ports") +async def test_app_consumers_supervisor_not_ready( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, +) -> None: + """Test apps are not considered when the supervisor is not ready yet.""" + with ( + patch("homeassistant.components.usb.consumers.is_hassio", return_value=True), + patch( + "homeassistant.components.usb.consumers.get_addons_info", + side_effect=HassioNotReadyError("Not ready"), + ), + ): + result = await _async_get_serial_ports(hass_ws_client, hass) + + assert [port["consumers"] for port in result] == [[], []] + + +@pytest.mark.usefixtures("setup_ports") +async def test_multiple_consumers( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, +) -> None: + """Test a port used by both an integration and an app.""" + mock_integration(hass, MockModule("test_usb", dependencies=["usb"])) + entry = MockConfigEntry( + domain="test_usb", title="Test USB", data={"device": TTY_USB0} + ) + entry.add_to_hass(hass) + + apps_info = { + "some_app": {"name": "Some App", "state": "started", "devices": [TTY_USB0]} + } + + with ( + patch("homeassistant.components.usb.consumers.is_hassio", return_value=True), + patch( + "homeassistant.components.usb.consumers.get_addons_info", + return_value=apps_info, + ), + ): + result = await _async_get_serial_ports(hass_ws_client, hass) + + assert [ + (consumer["kind"], consumer["title"]) for consumer in result[0]["consumers"] + ] == [("config_entry", "Test USB"), ("app", "Some App")] + + +class MockUsbFlow(ConfigFlow): + """Config flow that keeps USB discoveries in progress.""" + + async def async_step_usb(self, discovery_info: UsbServiceInfo) -> ConfigFlowResult: + """Show a form so the discovery flow stays in progress.""" + return await self.async_step_confirm() + + async def async_step_confirm( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Show a form so the discovery flow stays in progress.""" + return self.async_show_form(step_id="confirm") + + +@pytest.mark.usefixtures("setup_ports") +async def test_discovery_flows( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, +) -> None: + """Test that in-progress discovery flows are listed for their serial port.""" + mock_integration(hass, MockModule("test_usb", dependencies=["usb"])) + mock_platform(hass, "test_usb.config_flow", None) + + with mock_config_flow("test_usb", MockUsbFlow): + flow = await hass.config_entries.flow.async_init( + "test_usb", + context={"source": SOURCE_USB}, + data=usb_service_info_from_device(USB0_PORT), + ) + + result = await _async_get_serial_ports(hass_ws_client, hass) + + assert [(port["device"], port["discovery_flows"]) for port in result] == [ + (TTY_USB0, [{"flow_id": flow["flow_id"], "domain": "test_usb"}]), + (ESPHOME_PORT, []), + ] diff --git a/tests/components/usb/test_init.py b/tests/components/usb/test_init.py index 39da6c140f5b..4e75ee9339e4 100644 --- a/tests/components/usb/test_init.py +++ b/tests/components/usb/test_init.py @@ -1327,12 +1327,14 @@ async def test_async_scan_serial_ports(hass: HomeAssistant) -> None: assert devices == [ SerialDevice( device="/dev/ttyAMA1", + resolved_device="/dev/ttyAMA1", serial_number=None, manufacturer=None, description="ttyAMA1", ), USBDevice( device="/dev/serial/by-id/usb-Nabu_Casa_ZBT-2_10B41DE589FC-if00", + resolved_device="/dev/ttyACM0", vid="303A", pid="4001", serial_number="10B41DE589FC", @@ -1693,6 +1695,7 @@ async def test_list_serial_ports( mock_ports = [ USBDevice( device="/dev/ttyUSB0", + resolved_device="/dev/ttyUSB0", vid="10C4", pid="EA60", serial_number="001234", @@ -1704,6 +1707,7 @@ async def test_list_serial_ports( ), USBDevice( device="/dev/ttyUSB1", + resolved_device="/dev/ttyUSB1", vid="DEAD", pid="BEEF", serial_number=None, @@ -1712,6 +1716,7 @@ async def test_list_serial_ports( ), USBDevice( device="/dev/ttyUSB2", + resolved_device="/dev/ttyUSB2", vid="0000", pid="0000", serial_number=None, @@ -1720,6 +1725,7 @@ async def test_list_serial_ports( ), SerialDevice( device="/dev/ttyS0", + resolved_device="/dev/ttyS0", serial_number=None, manufacturer=None, description="ttyS0", @@ -1741,6 +1747,7 @@ async def test_list_serial_ports( assert response["result"] == [ { "device": "/dev/ttyUSB0", + "resolved_device": "/dev/ttyUSB0", "vid": "10C4", "pid": "EA60", "serial_number": "001234", @@ -1750,9 +1757,11 @@ async def test_list_serial_ports( "interface_description": "CP2102 USB to UART Bridge", "interface_num": 0, "matching_integrations": ["homeassistant_sky_connect"], + "present": True, }, { "device": "/dev/ttyUSB1", + "resolved_device": "/dev/ttyUSB1", "vid": "DEAD", "pid": "BEEF", "serial_number": None, @@ -1762,9 +1771,11 @@ async def test_list_serial_ports( "interface_description": None, "interface_num": None, "matching_integrations": ["custom_component"], + "present": True, }, { "device": "/dev/ttyUSB2", + "resolved_device": "/dev/ttyUSB2", "vid": "0000", "pid": "0000", "serial_number": None, @@ -1774,15 +1785,18 @@ async def test_list_serial_ports( "interface_description": None, "interface_num": None, "matching_integrations": [], + "present": True, }, { "device": "/dev/ttyS0", + "resolved_device": "/dev/ttyS0", "serial_number": None, "manufacturer": None, "description": "ttyS0", "interface_description": None, "interface_num": None, "matching_integrations": [], + "present": True, }, ] diff --git a/tests/components/utility_meter/test_config_flow.py b/tests/components/utility_meter/test_config_flow.py index 9c4c7a40021b..0959f8b150b4 100644 --- a/tests/components/utility_meter/test_config_flow.py +++ b/tests/components/utility_meter/test_config_flow.py @@ -373,9 +373,9 @@ async def test_change_device_source( await hass.async_block_till_done() - input_sensor_entity_id_1 = "sensor.test_source1" - input_sensor_entity_id_2 = "sensor.test_source2" - input_sensor_entity_id_3 = "sensor.test_source3" + input_sensor_entity_id_1 = source_entity_1.entity_id + input_sensor_entity_id_2 = source_entity_2.entity_id + input_sensor_entity_id_3 = source_entity_3.entity_id # Test the existence of configured source entities assert entity_registry.async_get(input_sensor_entity_id_1) is not None diff --git a/tests/components/utility_meter/test_init.py b/tests/components/utility_meter/test_init.py index 31e3b80c493c..800692ab9546 100644 --- a/tests/components/utility_meter/test_init.py +++ b/tests/components/utility_meter/test_init.py @@ -560,13 +560,13 @@ async def test_setup_and_remove_config_entry( @pytest.mark.parametrize( ("tariffs", "expected_entities"), [ - ([], {"sensor.my_utility_meter"}), + ([], {"sensor.mock_title_my_utility_meter"}), ( ["peak", "offpeak"], { "select.my_utility_meter", - "sensor.my_utility_meter_offpeak", - "sensor.my_utility_meter_peak", + "sensor.mock_title_my_utility_meter_offpeak", + "sensor.mock_title_my_utility_meter_peak", }, ), ], @@ -632,13 +632,13 @@ async def test_async_handle_source_entity_changes_source_entity_removed( @pytest.mark.parametrize( ("tariffs", "expected_entities"), [ - ([], {"sensor.my_utility_meter"}), + ([], {"sensor.mock_title_my_utility_meter"}), ( ["peak", "offpeak"], { "select.my_utility_meter", - "sensor.my_utility_meter_offpeak", - "sensor.my_utility_meter_peak", + "sensor.mock_title_my_utility_meter_offpeak", + "sensor.mock_title_my_utility_meter_peak", }, ), ], @@ -706,13 +706,13 @@ async def test_async_handle_source_entity_changes_source_entity_removed_shared_d @pytest.mark.parametrize( ("tariffs", "expected_entities"), [ - ([], {"sensor.my_utility_meter"}), + ([], {"sensor.mock_title_my_utility_meter"}), ( ["peak", "offpeak"], { "select.my_utility_meter", - "sensor.my_utility_meter_offpeak", - "sensor.my_utility_meter_peak", + "sensor.mock_title_my_utility_meter_offpeak", + "sensor.mock_title_my_utility_meter_peak", }, ), ], @@ -779,13 +779,13 @@ async def test_async_handle_source_entity_changes_source_entity_removed_from_dev @pytest.mark.parametrize( ("tariffs", "expected_entities"), [ - ([], {"sensor.my_utility_meter"}), + ([], {"sensor.mock_title_my_utility_meter"}), ( ["peak", "offpeak"], { "select.my_utility_meter", - "sensor.my_utility_meter_offpeak", - "sensor.my_utility_meter_peak", + "sensor.mock_title_my_utility_meter_offpeak", + "sensor.mock_title_my_utility_meter_peak", }, ), ], @@ -862,13 +862,13 @@ async def test_async_handle_source_entity_changes_source_entity_moved_other_devi @pytest.mark.parametrize( ("tariffs", "expected_entities"), [ - ([], {"sensor.my_utility_meter"}), + ([], {"sensor.mock_title_my_utility_meter"}), ( ["peak", "offpeak"], { "select.my_utility_meter", - "sensor.my_utility_meter_offpeak", - "sensor.my_utility_meter_peak", + "sensor.mock_title_my_utility_meter_offpeak", + "sensor.mock_title_my_utility_meter_peak", }, ), ], @@ -930,13 +930,13 @@ async def test_async_handle_source_entity_new_entity_id( @pytest.mark.parametrize( ("tariffs", "expected_entities"), [ - ([], {"sensor.my_utility_meter"}), + ([], {"sensor.mock_title_my_utility_meter"}), ( ["peak", "offpeak"], { "select.my_utility_meter", - "sensor.my_utility_meter_offpeak", - "sensor.my_utility_meter_peak", + "sensor.mock_title_my_utility_meter_offpeak", + "sensor.mock_title_my_utility_meter_peak", }, ), ], diff --git a/tests/components/utility_meter/test_select.py b/tests/components/utility_meter/test_select.py index 1f54f3b500a1..ccd0fc22cc71 100644 --- a/tests/components/utility_meter/test_select.py +++ b/tests/components/utility_meter/test_select.py @@ -90,7 +90,7 @@ async def test_device_id( device_id=source_device_entry.id, ) await hass.async_block_till_done() - assert entity_registry.async_get("sensor.test_source") is not None + assert entity_registry.async_get(source_entity.entity_id) is not None utility_meter_config_entry = MockConfigEntry( data={}, @@ -102,7 +102,7 @@ async def test_device_id( "net_consumption": False, "offset": 0, "periodically_resetting": True, - "source": "sensor.test_source", + "source": source_entity.entity_id, "tariffs": ["peak", "offpeak"], }, title="Energy", diff --git a/tests/components/utility_meter/test_sensor.py b/tests/components/utility_meter/test_sensor.py index c0726cbb736f..6e1ca07e802b 100644 --- a/tests/components/utility_meter/test_sensor.py +++ b/tests/components/utility_meter/test_sensor.py @@ -2059,7 +2059,7 @@ async def test_device_id( device_id=source_device_entry.id, ) await hass.async_block_till_done() - assert entity_registry.async_get("sensor.test_source") is not None + assert entity_registry.async_get(source_entity.entity_id) is not None utility_meter_config_entry = MockConfigEntry( data={}, @@ -2071,7 +2071,7 @@ async def test_device_id( "net_consumption": False, "offset": 0, "periodically_resetting": True, - "source": "sensor.test_source", + "source": source_entity.entity_id, "tariffs": ["peak", "offpeak"], }, title="Energy", @@ -2082,11 +2082,11 @@ async def test_device_id( assert await hass.config_entries.async_setup(utility_meter_config_entry.entry_id) await hass.async_block_till_done() - utility_meter_entity = entity_registry.async_get("sensor.energy_peak") + utility_meter_entity = entity_registry.async_get("sensor.mock_title_energy_peak") assert utility_meter_entity is not None assert utility_meter_entity.device_id == source_entity.device_id - utility_meter_entity = entity_registry.async_get("sensor.energy_offpeak") + utility_meter_entity = entity_registry.async_get("sensor.mock_title_energy_offpeak") assert utility_meter_entity is not None assert utility_meter_entity.device_id == source_entity.device_id @@ -2100,7 +2100,7 @@ async def test_device_id( "net_consumption": False, "offset": 0, "periodically_resetting": True, - "source": "sensor.test_source", + "source": source_entity.entity_id, "tariffs": [], }, title="Energy", @@ -2113,7 +2113,9 @@ async def test_device_id( ) await hass.async_block_till_done() - utility_meter_no_tariffs_entity = entity_registry.async_get("sensor.energy") + utility_meter_no_tariffs_entity = entity_registry.async_get( + "sensor.mock_title_energy" + ) assert utility_meter_no_tariffs_entity is not None assert utility_meter_no_tariffs_entity.device_id == source_entity.device_id diff --git a/tests/components/vallox/test_fan.py b/tests/components/vallox/test_fan.py index 03ca3bca3652..4cea19718500 100644 --- a/tests/components/vallox/test_fan.py +++ b/tests/components/vallox/test_fan.py @@ -56,6 +56,7 @@ async def test_fan_state( (Profile.AWAY, "Away"), (Profile.BOOST, "Boost"), (Profile.FIREPLACE, "Fireplace"), + (Profile.AUTO, "Auto"), ], ) async def test_fan_profile( @@ -168,6 +169,7 @@ async def test_turn_on_with_parameters( ("Away", Profile.HOME, [call(Profile.AWAY)]), ("Boost", Profile.HOME, [call(Profile.BOOST)]), ("Fireplace", Profile.HOME, [call(Profile.FIREPLACE)]), + ("Auto", Profile.HOME, [call(Profile.AUTO)]), ("Home", Profile.HOME, []), # No change ], ) diff --git a/tests/components/vallox/test_init.py b/tests/components/vallox/test_init.py index 61904ecdb44b..d256e40f3c17 100644 --- a/tests/components/vallox/test_init.py +++ b/tests/components/vallox/test_init.py @@ -67,6 +67,8 @@ async def test_create_service( ("fireplace", 15), ("extra", None), ("extra", 15), + ("auto", None), + ("auto", 15), ], ) async def test_set_profile_service( diff --git a/tests/components/voip/conftest.py b/tests/components/voip/conftest.py index 9590c29f79b9..e7e988113e2a 100644 --- a/tests/components/voip/conftest.py +++ b/tests/components/voip/conftest.py @@ -1,5 +1,6 @@ """Test helpers for VoIP integration.""" +from collections.abc import Generator from unittest.mock import AsyncMock, Mock, patch import pytest @@ -7,6 +8,7 @@ from voip_utils import CallInfo from voip_utils.sip import get_sip_endpoint from homeassistant.components.voip import DOMAIN +from homeassistant.components.voip.assist_satellite import VoipAssistSatellite from homeassistant.components.voip.devices import VoIPDevice, VoIPDevices from homeassistant.config_entries import ConfigEntryState from homeassistant.core import HomeAssistant @@ -24,6 +26,40 @@ async def load_homeassistant(hass: HomeAssistant) -> None: assert await async_setup_component(hass, "homeassistant", {}) +@pytest.fixture(autouse=True) +def reduce_satellite_delays() -> Generator[None]: + """Shorten the delays that the satellite always waits out. + + Tests must send audio chunks more often than _HANGUP_SEC, or the satellite + treats the gap as the caller hanging up. Timeouts that only elapse when audio + never arrives are left alone: they cost nothing unless a test exercises them. + """ + with ( + patch("homeassistant.components.voip.assist_satellite._HANGUP_SEC", 0.2), + patch( + "homeassistant.components.voip.assist_satellite._ANNOUNCEMENT_BEFORE_DELAY", + 0.1, + ), + patch( + "homeassistant.components.voip.assist_satellite._ANNOUNCEMENT_AFTER_DELAY", + 0.1, + ), + ): + yield + + +@pytest.fixture +def silent_tones() -> Generator[None]: + """Give every tone empty audio. + + A real tone is up to two seconds streamed in real time, which outlasts the + shortened hangup window. The tone paths still run, so the processing tone + still gates _send_tts. + """ + with patch.object(VoipAssistSatellite, "_load_pcm", return_value=b""): + yield + + @pytest.fixture def config_entry(hass: HomeAssistant) -> MockConfigEntry: """Create a config entry.""" diff --git a/tests/components/voip/test_voip.py b/tests/components/voip/test_voip.py index 64dc3c22cf45..0961a1650913 100644 --- a/tests/components/voip/test_voip.py +++ b/tests/components/voip/test_voip.py @@ -511,6 +511,7 @@ async def test_tts_timeout( await done.wait() +@pytest.mark.usefixtures("silent_tones") async def test_tts_wrong_extension( hass: HomeAssistant, satellite: VoipAssistSatellite, @@ -587,13 +588,13 @@ async def test_tts_wrong_extension( # silence (assumes relaxed VAD sensitivity) satellite.on_chunk(bytes(_ONE_SECOND)) - await asyncio.sleep(0.2) + await asyncio.sleep(0.05) satellite.on_chunk(bytes(_ONE_SECOND)) - await asyncio.sleep(0.2) + await asyncio.sleep(0.05) satellite.on_chunk(bytes(_ONE_SECOND)) - await asyncio.sleep(0.2) + await asyncio.sleep(0.05) satellite.on_chunk(bytes(_ONE_SECOND)) - await asyncio.sleep(0.2) + await asyncio.sleep(0.05) satellite.on_chunk(bytes(_ONE_SECOND)) # Wait for mock pipeline to exhaust the audio stream @@ -601,6 +602,7 @@ async def test_tts_wrong_extension( await done.wait() +@pytest.mark.usefixtures("silent_tones") async def test_tts_wrong_wav_format( hass: HomeAssistant, satellite: VoipAssistSatellite, @@ -677,13 +679,13 @@ async def test_tts_wrong_wav_format( # silence (assumes relaxed VAD sensitivity) satellite.on_chunk(bytes(_ONE_SECOND)) - await asyncio.sleep(0.2) + await asyncio.sleep(0.05) satellite.on_chunk(bytes(_ONE_SECOND)) - await asyncio.sleep(0.2) + await asyncio.sleep(0.05) satellite.on_chunk(bytes(_ONE_SECOND)) - await asyncio.sleep(0.2) + await asyncio.sleep(0.05) satellite.on_chunk(bytes(_ONE_SECOND)) - await asyncio.sleep(0.2) + await asyncio.sleep(0.05) satellite.on_chunk(bytes(_ONE_SECOND)) # Wait for mock pipeline to exhaust the audio stream @@ -691,6 +693,7 @@ async def test_tts_wrong_wav_format( await done.wait() +@pytest.mark.usefixtures("silent_tones") async def test_empty_tts_output( hass: HomeAssistant, satellite: VoipAssistSatellite, @@ -757,16 +760,9 @@ async def test_empty_tts_output( # silence (assumes relaxed VAD sensitivity) satellite.on_chunk(bytes(_ONE_SECOND)) - await asyncio.sleep(0.2) - satellite.on_chunk(bytes(_ONE_SECOND)) - await asyncio.sleep(0.2) - satellite.on_chunk(bytes(_ONE_SECOND)) - await asyncio.sleep(0.2) - satellite.on_chunk(bytes(_ONE_SECOND)) - await asyncio.sleep(0.2) - satellite.on_chunk(bytes(_ONE_SECOND)) - # Wait for mock pipeline to finish + # No more chunks: another chunk would start a second pipeline run, which + # clears _tts_done again. async with asyncio.timeout(2): await satellite._tts_done.wait() @@ -877,9 +873,9 @@ async def test_announce( # Trigger announcement satellite.on_chunk(bytes(_ONE_SECOND)) - await asyncio.sleep(0.2) + await asyncio.sleep(0.05) satellite.on_chunk(bytes(_ONE_SECOND)) - await asyncio.sleep(0.2) + await asyncio.sleep(0.05) satellite.on_chunk(bytes(_ONE_SECOND)) async with asyncio.timeout(2): await announce_task @@ -937,9 +933,9 @@ async def test_voip_id_is_ip_address( # Trigger announcement satellite.on_chunk(bytes(_ONE_SECOND)) - await asyncio.sleep(0.2) + await asyncio.sleep(0.05) satellite.on_chunk(bytes(_ONE_SECOND)) - await asyncio.sleep(0.2) + await asyncio.sleep(0.05) satellite.on_chunk(bytes(_ONE_SECOND)) async with asyncio.timeout(2): await announce_task @@ -1032,11 +1028,11 @@ async def test_announce_disconnect( # Trigger announcement satellite.on_chunk(bytes(_ONE_SECOND)) - await asyncio.sleep(0.2) + await asyncio.sleep(0.05) satellite.on_chunk(bytes(_ONE_SECOND)) - await asyncio.sleep(0.2) + await asyncio.sleep(0.05) satellite.on_chunk(bytes(_ONE_SECOND)) - await asyncio.sleep(0.2) + await asyncio.sleep(0.05) assert satellite._announcement is announcement assert voip_device.is_active @@ -1196,18 +1192,17 @@ async def test_start_conversation( # Trigger announcement and wait for it to finish satellite.on_chunk(bytes(_ONE_SECOND)) - await asyncio.sleep(0.2) + await asyncio.sleep(0.05) satellite.on_chunk(bytes(_ONE_SECOND)) - await asyncio.sleep(0.2) + await asyncio.sleep(0.05) satellite.on_chunk(bytes(_ONE_SECOND)) async with asyncio.timeout(2): await tts_sent.wait() # Trigger pipeline satellite.on_chunk(bytes(_ONE_SECOND)) - await asyncio.sleep(0.2) + await asyncio.sleep(0.05) satellite.on_chunk(bytes(_ONE_SECOND)) - await asyncio.sleep(3) async with asyncio.timeout(3): # Wait for Conversation end await conversation_task diff --git a/tests/components/waqi/test_init.py b/tests/components/waqi/test_init.py index 92adbf32b6fa..a3d909717120 100644 --- a/tests/components/waqi/test_init.py +++ b/tests/components/waqi/test_init.py @@ -263,7 +263,7 @@ async def test_migration_from_v1_disabled( # validates it against the config entry's disabled state; write it # directly to simulate existing storage. device_1 = attr.evolve(device_1, disabled_by=DeviceEntryDisabler.CONFIG_ENTRY) - device_registry.devices[device_1.id] = device_1 + device_registry._devices[device_1.id] = device_1 entity_registry.async_get_or_create( "sensor", DOMAIN, @@ -284,7 +284,7 @@ async def test_migration_from_v1_disabled( # API; clear the flag directly to simulate existing storage with a stale # enabled device. device_2 = attr.evolve(device_2, disabled_by=None) - device_registry.devices[device_2.id] = device_2 + device_registry._devices[device_2.id] = device_2 entity_registry.async_get_or_create( "sensor", DOMAIN, diff --git a/tests/components/webostv/test_media_player.py b/tests/components/webostv/test_media_player.py index 5e541e5b3205..bd6f97e18f66 100644 --- a/tests/components/webostv/test_media_player.py +++ b/tests/components/webostv/test_media_player.py @@ -416,6 +416,48 @@ async def test_play_media(hass: HomeAssistant, client, media_id, ch_id) -> None: client.set_channel.assert_called_once_with(ch_id) +async def test_play_media_channel_name_over_number(hass: HomeAssistant, client) -> None: + """Test that an exact channel name match takes precedence over a channel number match.""" + await setup_webostv(hass) + await client.mock_state_update() + + client.tv_state.channels = [ + {"channelNumber": "1", "channelName": "20", "channelId": "ch_name_match"}, + {"channelNumber": "20", "channelName": "Ch 20", "channelId": "ch_number_match"}, + ] + + data = { + ATTR_ENTITY_ID: ENTITY_ID, + ATTR_MEDIA_CONTENT_TYPE: MediaType.CHANNEL, + ATTR_MEDIA_CONTENT_ID: "20", + } + await hass.services.async_call(MP_DOMAIN, SERVICE_PLAY_MEDIA, data, True) + + client.set_channel.assert_called_once_with("ch_name_match") + + +async def test_play_media_duplicate_channel_number_selects_first( + hass: HomeAssistant, client +) -> None: + """Test that the first channel is selected when two channels share the same number.""" + await setup_webostv(hass) + await client.mock_state_update() + + client.tv_state.channels = [ + {"channelNumber": "5", "channelName": "TV Channel", "channelId": "ch_first"}, + {"channelNumber": "5", "channelName": "Radio Channel", "channelId": "ch_last"}, + ] + + data = { + ATTR_ENTITY_ID: ENTITY_ID, + ATTR_MEDIA_CONTENT_TYPE: MediaType.CHANNEL, + ATTR_MEDIA_CONTENT_ID: "5", + } + await hass.services.async_call(MP_DOMAIN, SERVICE_PLAY_MEDIA, data, True) + + client.set_channel.assert_called_once_with("ch_first") + + async def test_update_sources_live_tv_find(hass: HomeAssistant, client) -> None: """Test finding live TV app id in update sources.""" await setup_webostv(hass) diff --git a/tests/components/websocket_api/test_commands.py b/tests/components/websocket_api/test_commands.py index 600eb9620ecd..1b145c38c5ff 100644 --- a/tests/components/websocket_api/test_commands.py +++ b/tests/components/websocket_api/test_commands.py @@ -311,7 +311,8 @@ async def target_entities( } assert set(label_registry.labels) == {"label_1", "label_2", "label_3"} assert set(area_registry.areas) == {"kitchen", "living_room", "bathroom", "garage"} - assert set(dr.async_get(hass).devices) == { # pylint: disable=home-assistant-tests-registry-fixtures + # pylint: disable-next=home-assistant-tests-registry-fixtures + assert {device.id for device in dr.async_get(hass).devices} == { "device1", "device2", "area_device", diff --git a/tests/components/wemo/test_coordinator.py b/tests/components/wemo/test_coordinator.py index 17061aea2f6f..b825d57af91e 100644 --- a/tests/components/wemo/test_coordinator.py +++ b/tests/components/wemo/test_coordinator.py @@ -50,7 +50,7 @@ async def test_async_register_device_longpress_fails( }, ) await hass.async_block_till_done() - device_entries = list(device_registry.devices.values()) + device_entries = list(device_registry.devices) assert len(device_entries) == 1 device = async_get_coordinator(hass, device_entries[0].id) assert device.supports_long_press is False @@ -170,7 +170,7 @@ async def test_device_info( hass: HomeAssistant, wemo_entity, device_registry: dr.DeviceRegistry ) -> None: """Verify the DeviceInfo data is set properly.""" - device_entries = list(device_registry.devices.values()) + device_entries = list(device_registry.devices) assert len(device_entries) == 1 assert device_entries[0].connections == { @@ -186,7 +186,7 @@ async def test_dli_device_info( hass: HomeAssistant, wemo_dli_entity, device_registry: dr.DeviceRegistry ) -> None: """Verify the DeviceInfo data for Digital Loggers emulated wemo device.""" - device_entries = list(device_registry.devices.values()) + device_entries = list(device_registry.devices) assert device_entries[0].configuration_url == "http://127.0.0.1" assert device_entries[0].identifiers == {(DOMAIN, "123456789")} diff --git a/tests/components/withings/test_sensor.py b/tests/components/withings/test_sensor.py index 718ca42b4ca6..010bf420f229 100644 --- a/tests/components/withings/test_sensor.py +++ b/tests/components/withings/test_sensor.py @@ -473,9 +473,7 @@ async def test_old_device_removal_only_removes_own_device( return next( ( device - for device in device_registry.devices.get_entries( - identifiers=identifiers - ) + for device in device_registry.async_get_devices(identifiers=identifiers) if device.config_entry_id == entry.entry_id ), None, diff --git a/tests/components/wmspro/test_init.py b/tests/components/wmspro/test_init.py index 53653415ddf3..76dcdf75fb05 100644 --- a/tests/components/wmspro/test_init.py +++ b/tests/components/wmspro/test_init.py @@ -91,8 +91,8 @@ async def test_device_setup( assert len(mock_hub_configuration.mock_calls) == 1 assert len(mock_hub_status.mock_calls) == len(mock_hub_configuration.destinations) - device_entries = device_registry.devices.get_devices_for_config_entry_id( - mock_config_entry.entry_id + device_entries = dr.async_entries_for_config_entry( + device_registry, mock_config_entry.entry_id ) assert len(device_entries) > len(mock_hub_configuration.destinations) diff --git a/tests/components/wolflink/test_config_flow.py b/tests/components/wolflink/test_config_flow.py index 51c7313f6805..e95c5c9eba21 100644 --- a/tests/components/wolflink/test_config_flow.py +++ b/tests/components/wolflink/test_config_flow.py @@ -26,6 +26,15 @@ DEVICE = Device(1234, 5678, "test-device") SECOND_DEVICE = Device(5678, 9999, "second-device") +async def _start_user_flow(hass: HomeAssistant) -> dict: + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "user" + return result + + async def test_show_form(hass: HomeAssistant) -> None: """Test we get the form.""" result = await hass.config_entries.flow.async_init( @@ -37,6 +46,8 @@ async def test_show_form(hass: HomeAssistant) -> None: async def test_create_entry(hass: HomeAssistant) -> None: """Test entry creation only stores credentials, not the device list.""" + result = await _start_user_flow(hass) + with ( patch( "homeassistant.components.wolflink.config_flow.WolfClient.fetch_system_list", @@ -44,8 +55,8 @@ async def test_create_entry(hass: HomeAssistant) -> None: ), patch("homeassistant.components.wolflink.async_setup_entry", return_value=True), ): - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_USER}, data=INPUT_CONFIG + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input=INPUT_CONFIG ) assert result["type"] is FlowResultType.CREATE_ENTRY @@ -66,12 +77,14 @@ async def test_user_flow_errors( hass: HomeAssistant, side_effect: Exception, expected_error: str ) -> None: """Test error handling in the user step keeps the form open with errors.""" + result = await _start_user_flow(hass) + with patch( "homeassistant.components.wolflink.config_flow.WolfClient.fetch_system_list", side_effect=side_effect, ): - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_USER}, data=INPUT_CONFIG + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input=INPUT_CONFIG ) assert result["type"] is FlowResultType.FORM @@ -80,12 +93,14 @@ async def test_user_flow_errors( async def test_no_devices_abort(hass: HomeAssistant) -> None: """Test we abort if the account has no devices.""" + result = await _start_user_flow(hass) + with patch( "homeassistant.components.wolflink.config_flow.WolfClient.fetch_system_list", return_value=[], ): - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_USER}, data=INPUT_CONFIG + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input=INPUT_CONFIG ) assert result["type"] is FlowResultType.ABORT @@ -98,8 +113,10 @@ async def test_already_configured_aborts( """Test entries with the same username can't be configured twice.""" mock_config_entry.add_to_hass(hass) - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_USER}, data=INPUT_CONFIG + result = await _start_user_flow(hass) + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input=INPUT_CONFIG ) assert result["type"] is FlowResultType.ABORT diff --git a/tests/components/wolflink/test_init.py b/tests/components/wolflink/test_init.py index a01af2871b30..a817f6f2120c 100644 --- a/tests/components/wolflink/test_init.py +++ b/tests/components/wolflink/test_init.py @@ -81,7 +81,7 @@ async def test_migration_v1_to_v2( # validates it against the config entry's disabled state; write it # directly to simulate existing storage. device = attr.evolve(device, disabled_by=dr.DeviceEntryDisabler.CONFIG_ENTRY) - device_registry.devices[device.id] = device + device_registry._devices[device.id] = device entity = entity_registry.async_get_or_create( domain="sensor", platform=DOMAIN, diff --git a/tests/components/wsdot/test_config_flow.py b/tests/components/wsdot/test_config_flow.py index bc86ecc3da8f..e49f56043b20 100644 --- a/tests/components/wsdot/test_config_flow.py +++ b/tests/components/wsdot/test_config_flow.py @@ -114,12 +114,12 @@ async def test_create_travel_time_subentry( assert result["type"] is FlowResultType.FORM assert result["step_id"] == "user" + assert result["errors"] == {} # User data; the user made a choice and hit submit - result = await hass.config_entries.subentries.async_init( - (init_integration.entry_id, SUBENTRY_TRAVEL_TIMES), - context={"source": SOURCE_USER}, - data=VALID_USER_TRAVEL_TIME_CONFIG, + result = await hass.config_entries.subentries.async_configure( + result["flow_id"], + VALID_USER_TRAVEL_TIME_CONFIG, ) assert result["type"] is FlowResultType.CREATE_ENTRY diff --git a/tests/components/zinvolt/test_init.py b/tests/components/zinvolt/test_init.py index fc0c0c365f30..0dfe5280c3ad 100644 --- a/tests/components/zinvolt/test_init.py +++ b/tests/components/zinvolt/test_init.py @@ -22,7 +22,7 @@ async def test_device( ) -> None: """Test the Zinvolt device.""" await setup_integration(hass, mock_config_entry) - devices = device_registry.devices + devices = device_registry._devices for device in devices.values(): assert device == snapshot(name=list(device.identifiers)[0][1]) diff --git a/tests/components/zwave_js/conftest.py b/tests/components/zwave_js/conftest.py index 6a5318897a91..3863bac207bc 100644 --- a/tests/components/zwave_js/conftest.py +++ b/tests/components/zwave_js/conftest.py @@ -256,6 +256,12 @@ def leviton_zw4sf_state_fixture() -> dict[str, Any]: return load_json_object_fixture("leviton_zw4sf_state.json", DOMAIN) +@pytest.fixture(name="leviton_vrf01_state", scope="package") +def leviton_vrf01_state_fixture() -> dict[str, Any]: + """Load the Leviton VRF01 node state fixture data.""" + return load_json_object_fixture("leviton_vrf01_state.json", DOMAIN) + + @pytest.fixture(name="fan_honeywell_39358_state", scope="package") def fan_honeywell_39358_state_fixture() -> dict[str, Any]: """Load the fan node state fixture data.""" @@ -636,9 +642,12 @@ def mock_client_fixture( listen_block: asyncio.Event, ): """Mock a client.""" - with patch( - "homeassistant.components.zwave_js.ZwaveClient", autospec=True - ) as client_class: + with ( + patch( + "homeassistant.components.zwave_js.ZwaveClient", autospec=True + ) as client_class, + patch("homeassistant.components.zwave_js.config_flow.Client", client_class), + ): client = client_class.return_value async def connect(): @@ -1068,6 +1077,14 @@ def leviton_zw4sf_fixture(client, leviton_zw4sf_state) -> Node: return node +@pytest.fixture(name="leviton_vrf01") +def leviton_vrf01_fixture(client, leviton_vrf01_state) -> Node: + """Mock a fan node.""" + node = Node(client, copy.deepcopy(leviton_vrf01_state)) + client.driver.controller.nodes[node.node_id] = node + return node + + @pytest.fixture(name="fan_honeywell_39358") def fan_honeywell_39358_fixture(client, fan_honeywell_39358_state) -> Node: """Mock a fan node.""" diff --git a/tests/components/zwave_js/fixtures/leviton_vrf01_state.json b/tests/components/zwave_js/fixtures/leviton_vrf01_state.json new file mode 100644 index 000000000000..bc500f58cdce --- /dev/null +++ b/tests/components/zwave_js/fixtures/leviton_vrf01_state.json @@ -0,0 +1,10439 @@ +{ + "nodeId": 35, + "index": 0, + "status": 4, + "ready": true, + "isListening": true, + "isRouting": true, + "isSecure": false, + "manufacturerId": 29, + "productId": 521, + "productType": 4097, + "firmwareVersion": "0.5", + "name": "Fan", + "location": "", + "deviceConfig": { + "filename": "/data/db/devices/0x001d/vrf01.json", + "isEmbedded": true, + "manufacturer": "Leviton", + "manufacturerId": 29, + "label": "VRF01", + "description": "Scene Capable Quiet Fan Speed Control", + "devices": [ + { + "productType": 4097, + "productId": 521 + }, + { + "productType": 4097, + "productId": 820 + } + ], + "firmwareVersion": { + "min": "0.0", + "max": "255.255" + }, + "preferred": false, + "associations": { + "1": { + "groupId": 1, + "label": "Lifeline", + "maxNodes": 5, + "isLifeline": true, + "multiChannel": "auto" + } + } + }, + "label": "VRF01", + "interviewAttempts": 1, + "isFrequentListening": false, + "maxDataRate": 40000, + "supportedDataRates": [40000], + "protocolVersion": 1, + "supportsBeaming": false, + "supportsSecurity": false, + "nodeType": 1, + "deviceClass": { + "basic": { + "key": 4, + "label": "Routing End Node" + }, + "generic": { + "key": 17, + "label": "Multilevel Switch" + }, + "specific": { + "key": 4, + "label": "Multilevel Scene Switch" + } + }, + "interviewStage": "Complete", + "deviceDatabaseUrl": "https://devices.zwave-js.io/?jumpTo=0x001d:0x1001:0x0209:0.5", + "statistics": { + "commandsTX": 55, + "commandsRX": 59, + "commandsDroppedRX": 2, + "commandsDroppedTX": 0, + "timeoutResponse": 23, + "lastSeen": "2026-07-04T16:21:27.053Z", + "rtt": 41.6, + "rssi": -84, + "lwr": { + "protocolDataRate": 2, + "repeaters": [11], + "rssi": -87, + "repeaterRSSI": [0] + }, + "nlwr": { + "protocolDataRate": 2, + "repeaters": [10], + "rssi": -72, + "repeaterRSSI": [0] + } + }, + "highestSecurityClass": -1, + "isControllerNode": false, + "keepAwake": false, + "lastSeen": "2026-07-04T16:21:27.053Z", + "protocol": 0, + "canSleep": false, + "hasSUCReturnRoute": true, + "manufacturer": "Leviton", + "values": [ + { + "endpoint": 0, + "commandClass": 38, + "commandClassName": "Multilevel Switch", + "property": "Down", + "propertyName": "Down", + "ccVersion": 1, + "metadata": { + "type": "boolean", + "readable": false, + "writeable": true, + "label": "Perform a level change (Down)", + "ccSpecific": { + "switchType": 2 + }, + "valueChangeOptions": ["transitionDuration"], + "states": { + "true": "Start", + "false": "Stop" + }, + "stateful": true, + "secret": false + }, + "value": false + }, + { + "endpoint": 0, + "commandClass": 38, + "commandClassName": "Multilevel Switch", + "property": "currentValue", + "propertyName": "currentValue", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "label": "Current value", + "min": 0, + "max": 99, + "stateful": true, + "secret": false + }, + "value": 73 + }, + { + "endpoint": 0, + "commandClass": 38, + "commandClassName": "Multilevel Switch", + "property": "targetValue", + "propertyName": "targetValue", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Target value", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 99, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 38, + "commandClassName": "Multilevel Switch", + "property": "Up", + "propertyName": "Up", + "ccVersion": 1, + "metadata": { + "type": "boolean", + "readable": false, + "writeable": true, + "label": "Perform a level change (Up)", + "ccSpecific": { + "switchType": 2 + }, + "valueChangeOptions": ["transitionDuration"], + "states": { + "true": "Start", + "false": "Stop" + }, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 38, + "commandClassName": "Multilevel Switch", + "property": "duration", + "propertyName": "duration", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": false, + "label": "Remaining duration", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 38, + "commandClassName": "Multilevel Switch", + "property": "restorePrevious", + "propertyName": "restorePrevious", + "ccVersion": 1, + "metadata": { + "type": "boolean", + "readable": false, + "writeable": true, + "label": "Restore previous value", + "states": { + "true": "Restore" + }, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 43, + "commandClassName": "Scene Activation", + "property": "sceneId", + "propertyName": "sceneId", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Scene ID", + "valueChangeOptions": ["transitionDuration"], + "min": 1, + "max": 255, + "stateful": false, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 43, + "commandClassName": "Scene Activation", + "property": "dimmingDuration", + "propertyName": "dimmingDuration", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 1, + "propertyName": "level", + "propertyKeyName": "1", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (1)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 1, + "propertyName": "dimmingDuration", + "propertyKeyName": "1", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (1)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 2, + "propertyName": "level", + "propertyKeyName": "2", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (2)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 2, + "propertyName": "dimmingDuration", + "propertyKeyName": "2", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (2)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 3, + "propertyName": "level", + "propertyKeyName": "3", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (3)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 3, + "propertyName": "dimmingDuration", + "propertyKeyName": "3", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (3)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 4, + "propertyName": "level", + "propertyKeyName": "4", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (4)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 4, + "propertyName": "dimmingDuration", + "propertyKeyName": "4", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (4)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 5, + "propertyName": "level", + "propertyKeyName": "5", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (5)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 5, + "propertyName": "dimmingDuration", + "propertyKeyName": "5", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (5)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 6, + "propertyName": "level", + "propertyKeyName": "6", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (6)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 6, + "propertyName": "dimmingDuration", + "propertyKeyName": "6", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (6)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 7, + "propertyName": "level", + "propertyKeyName": "7", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (7)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 7, + "propertyName": "dimmingDuration", + "propertyKeyName": "7", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (7)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 8, + "propertyName": "level", + "propertyKeyName": "8", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (8)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 8, + "propertyName": "dimmingDuration", + "propertyKeyName": "8", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (8)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 9, + "propertyName": "level", + "propertyKeyName": "9", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (9)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 9, + "propertyName": "dimmingDuration", + "propertyKeyName": "9", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (9)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 10, + "propertyName": "level", + "propertyKeyName": "10", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (10)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 10, + "propertyName": "dimmingDuration", + "propertyKeyName": "10", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (10)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 11, + "propertyName": "level", + "propertyKeyName": "11", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (11)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 11, + "propertyName": "dimmingDuration", + "propertyKeyName": "11", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (11)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 12, + "propertyName": "level", + "propertyKeyName": "12", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (12)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 12, + "propertyName": "dimmingDuration", + "propertyKeyName": "12", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (12)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 13, + "propertyName": "level", + "propertyKeyName": "13", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (13)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 13, + "propertyName": "dimmingDuration", + "propertyKeyName": "13", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (13)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 14, + "propertyName": "level", + "propertyKeyName": "14", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (14)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 14, + "propertyName": "dimmingDuration", + "propertyKeyName": "14", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (14)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 15, + "propertyName": "level", + "propertyKeyName": "15", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (15)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 15, + "propertyName": "dimmingDuration", + "propertyKeyName": "15", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (15)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 16, + "propertyName": "level", + "propertyKeyName": "16", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (16)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 16, + "propertyName": "dimmingDuration", + "propertyKeyName": "16", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (16)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 17, + "propertyName": "level", + "propertyKeyName": "17", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (17)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 17, + "propertyName": "dimmingDuration", + "propertyKeyName": "17", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (17)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 18, + "propertyName": "level", + "propertyKeyName": "18", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (18)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 18, + "propertyName": "dimmingDuration", + "propertyKeyName": "18", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (18)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 19, + "propertyName": "level", + "propertyKeyName": "19", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (19)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 19, + "propertyName": "dimmingDuration", + "propertyKeyName": "19", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (19)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 20, + "propertyName": "level", + "propertyKeyName": "20", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (20)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 20, + "propertyName": "dimmingDuration", + "propertyKeyName": "20", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (20)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 21, + "propertyName": "level", + "propertyKeyName": "21", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (21)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 21, + "propertyName": "dimmingDuration", + "propertyKeyName": "21", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (21)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 22, + "propertyName": "level", + "propertyKeyName": "22", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (22)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 22, + "propertyName": "dimmingDuration", + "propertyKeyName": "22", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (22)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 23, + "propertyName": "level", + "propertyKeyName": "23", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (23)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 23, + "propertyName": "dimmingDuration", + "propertyKeyName": "23", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (23)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 24, + "propertyName": "level", + "propertyKeyName": "24", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (24)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 24, + "propertyName": "dimmingDuration", + "propertyKeyName": "24", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (24)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 25, + "propertyName": "level", + "propertyKeyName": "25", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (25)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 25, + "propertyName": "dimmingDuration", + "propertyKeyName": "25", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (25)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 26, + "propertyName": "level", + "propertyKeyName": "26", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (26)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 26, + "propertyName": "dimmingDuration", + "propertyKeyName": "26", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (26)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 27, + "propertyName": "level", + "propertyKeyName": "27", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (27)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 27, + "propertyName": "dimmingDuration", + "propertyKeyName": "27", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (27)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 28, + "propertyName": "level", + "propertyKeyName": "28", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (28)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 28, + "propertyName": "dimmingDuration", + "propertyKeyName": "28", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (28)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 29, + "propertyName": "level", + "propertyKeyName": "29", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (29)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 29, + "propertyName": "dimmingDuration", + "propertyKeyName": "29", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (29)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 30, + "propertyName": "level", + "propertyKeyName": "30", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (30)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 30, + "propertyName": "dimmingDuration", + "propertyKeyName": "30", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (30)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 31, + "propertyName": "level", + "propertyKeyName": "31", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (31)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 31, + "propertyName": "dimmingDuration", + "propertyKeyName": "31", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (31)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 32, + "propertyName": "level", + "propertyKeyName": "32", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (32)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 32, + "propertyName": "dimmingDuration", + "propertyKeyName": "32", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (32)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 33, + "propertyName": "level", + "propertyKeyName": "33", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (33)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 33, + "propertyName": "dimmingDuration", + "propertyKeyName": "33", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (33)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 34, + "propertyName": "level", + "propertyKeyName": "34", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (34)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 34, + "propertyName": "dimmingDuration", + "propertyKeyName": "34", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (34)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 35, + "propertyName": "level", + "propertyKeyName": "35", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (35)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 35, + "propertyName": "dimmingDuration", + "propertyKeyName": "35", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (35)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 36, + "propertyName": "level", + "propertyKeyName": "36", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (36)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 36, + "propertyName": "dimmingDuration", + "propertyKeyName": "36", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (36)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 37, + "propertyName": "level", + "propertyKeyName": "37", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (37)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 37, + "propertyName": "dimmingDuration", + "propertyKeyName": "37", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (37)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 38, + "propertyName": "level", + "propertyKeyName": "38", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (38)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 38, + "propertyName": "dimmingDuration", + "propertyKeyName": "38", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (38)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 39, + "propertyName": "level", + "propertyKeyName": "39", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (39)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 39, + "propertyName": "dimmingDuration", + "propertyKeyName": "39", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (39)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 40, + "propertyName": "level", + "propertyKeyName": "40", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (40)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 40, + "propertyName": "dimmingDuration", + "propertyKeyName": "40", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (40)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 41, + "propertyName": "level", + "propertyKeyName": "41", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (41)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 41, + "propertyName": "dimmingDuration", + "propertyKeyName": "41", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (41)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 42, + "propertyName": "level", + "propertyKeyName": "42", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (42)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 42, + "propertyName": "dimmingDuration", + "propertyKeyName": "42", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (42)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 43, + "propertyName": "level", + "propertyKeyName": "43", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (43)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 43, + "propertyName": "dimmingDuration", + "propertyKeyName": "43", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (43)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 44, + "propertyName": "level", + "propertyKeyName": "44", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (44)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 44, + "propertyName": "dimmingDuration", + "propertyKeyName": "44", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (44)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 45, + "propertyName": "level", + "propertyKeyName": "45", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (45)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 45, + "propertyName": "dimmingDuration", + "propertyKeyName": "45", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (45)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 46, + "propertyName": "level", + "propertyKeyName": "46", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (46)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 46, + "propertyName": "dimmingDuration", + "propertyKeyName": "46", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (46)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 47, + "propertyName": "level", + "propertyKeyName": "47", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (47)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 47, + "propertyName": "dimmingDuration", + "propertyKeyName": "47", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (47)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 48, + "propertyName": "level", + "propertyKeyName": "48", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (48)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 48, + "propertyName": "dimmingDuration", + "propertyKeyName": "48", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (48)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 49, + "propertyName": "level", + "propertyKeyName": "49", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (49)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 49, + "propertyName": "dimmingDuration", + "propertyKeyName": "49", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (49)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 50, + "propertyName": "level", + "propertyKeyName": "50", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (50)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 50, + "propertyName": "dimmingDuration", + "propertyKeyName": "50", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (50)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 51, + "propertyName": "level", + "propertyKeyName": "51", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (51)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 51, + "propertyName": "dimmingDuration", + "propertyKeyName": "51", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (51)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 52, + "propertyName": "level", + "propertyKeyName": "52", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (52)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 52, + "propertyName": "dimmingDuration", + "propertyKeyName": "52", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (52)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 53, + "propertyName": "level", + "propertyKeyName": "53", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (53)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 53, + "propertyName": "dimmingDuration", + "propertyKeyName": "53", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (53)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 54, + "propertyName": "level", + "propertyKeyName": "54", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (54)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 54, + "propertyName": "dimmingDuration", + "propertyKeyName": "54", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (54)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 55, + "propertyName": "level", + "propertyKeyName": "55", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (55)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 55, + "propertyName": "dimmingDuration", + "propertyKeyName": "55", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (55)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 56, + "propertyName": "level", + "propertyKeyName": "56", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (56)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 56, + "propertyName": "dimmingDuration", + "propertyKeyName": "56", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (56)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 57, + "propertyName": "level", + "propertyKeyName": "57", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (57)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 57, + "propertyName": "dimmingDuration", + "propertyKeyName": "57", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (57)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 58, + "propertyName": "level", + "propertyKeyName": "58", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (58)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 58, + "propertyName": "dimmingDuration", + "propertyKeyName": "58", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (58)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 59, + "propertyName": "level", + "propertyKeyName": "59", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (59)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 59, + "propertyName": "dimmingDuration", + "propertyKeyName": "59", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (59)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 60, + "propertyName": "level", + "propertyKeyName": "60", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (60)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 60, + "propertyName": "dimmingDuration", + "propertyKeyName": "60", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (60)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 61, + "propertyName": "level", + "propertyKeyName": "61", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (61)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 61, + "propertyName": "dimmingDuration", + "propertyKeyName": "61", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (61)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 62, + "propertyName": "level", + "propertyKeyName": "62", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (62)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 62, + "propertyName": "dimmingDuration", + "propertyKeyName": "62", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (62)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 63, + "propertyName": "level", + "propertyKeyName": "63", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (63)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 63, + "propertyName": "dimmingDuration", + "propertyKeyName": "63", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (63)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 64, + "propertyName": "level", + "propertyKeyName": "64", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (64)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 64, + "propertyName": "dimmingDuration", + "propertyKeyName": "64", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (64)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 65, + "propertyName": "level", + "propertyKeyName": "65", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (65)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 65, + "propertyName": "dimmingDuration", + "propertyKeyName": "65", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (65)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 66, + "propertyName": "level", + "propertyKeyName": "66", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (66)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 66, + "propertyName": "dimmingDuration", + "propertyKeyName": "66", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (66)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 67, + "propertyName": "level", + "propertyKeyName": "67", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (67)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 67, + "propertyName": "dimmingDuration", + "propertyKeyName": "67", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (67)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 68, + "propertyName": "level", + "propertyKeyName": "68", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (68)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 68, + "propertyName": "dimmingDuration", + "propertyKeyName": "68", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (68)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 69, + "propertyName": "level", + "propertyKeyName": "69", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (69)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 69, + "propertyName": "dimmingDuration", + "propertyKeyName": "69", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (69)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 70, + "propertyName": "level", + "propertyKeyName": "70", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (70)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 70, + "propertyName": "dimmingDuration", + "propertyKeyName": "70", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (70)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 71, + "propertyName": "level", + "propertyKeyName": "71", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (71)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 71, + "propertyName": "dimmingDuration", + "propertyKeyName": "71", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (71)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 72, + "propertyName": "level", + "propertyKeyName": "72", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (72)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 72, + "propertyName": "dimmingDuration", + "propertyKeyName": "72", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (72)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 73, + "propertyName": "level", + "propertyKeyName": "73", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (73)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 73, + "propertyName": "dimmingDuration", + "propertyKeyName": "73", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (73)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 74, + "propertyName": "level", + "propertyKeyName": "74", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (74)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 74, + "propertyName": "dimmingDuration", + "propertyKeyName": "74", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (74)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 75, + "propertyName": "level", + "propertyKeyName": "75", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (75)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 75, + "propertyName": "dimmingDuration", + "propertyKeyName": "75", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (75)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 76, + "propertyName": "level", + "propertyKeyName": "76", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (76)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 76, + "propertyName": "dimmingDuration", + "propertyKeyName": "76", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (76)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 77, + "propertyName": "level", + "propertyKeyName": "77", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (77)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 77, + "propertyName": "dimmingDuration", + "propertyKeyName": "77", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (77)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 78, + "propertyName": "level", + "propertyKeyName": "78", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (78)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 78, + "propertyName": "dimmingDuration", + "propertyKeyName": "78", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (78)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 79, + "propertyName": "level", + "propertyKeyName": "79", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (79)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 79, + "propertyName": "dimmingDuration", + "propertyKeyName": "79", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (79)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 80, + "propertyName": "level", + "propertyKeyName": "80", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (80)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 80, + "propertyName": "dimmingDuration", + "propertyKeyName": "80", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (80)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 81, + "propertyName": "level", + "propertyKeyName": "81", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (81)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 81, + "propertyName": "dimmingDuration", + "propertyKeyName": "81", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (81)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 82, + "propertyName": "level", + "propertyKeyName": "82", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (82)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 82, + "propertyName": "dimmingDuration", + "propertyKeyName": "82", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (82)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 83, + "propertyName": "level", + "propertyKeyName": "83", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (83)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 83, + "propertyName": "dimmingDuration", + "propertyKeyName": "83", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (83)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 84, + "propertyName": "level", + "propertyKeyName": "84", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (84)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 84, + "propertyName": "dimmingDuration", + "propertyKeyName": "84", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (84)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 85, + "propertyName": "level", + "propertyKeyName": "85", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (85)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 85, + "propertyName": "dimmingDuration", + "propertyKeyName": "85", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (85)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 86, + "propertyName": "level", + "propertyKeyName": "86", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (86)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 86, + "propertyName": "dimmingDuration", + "propertyKeyName": "86", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (86)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 87, + "propertyName": "level", + "propertyKeyName": "87", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (87)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 87, + "propertyName": "dimmingDuration", + "propertyKeyName": "87", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (87)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 88, + "propertyName": "level", + "propertyKeyName": "88", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (88)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 88, + "propertyName": "dimmingDuration", + "propertyKeyName": "88", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (88)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 89, + "propertyName": "level", + "propertyKeyName": "89", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (89)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 89, + "propertyName": "dimmingDuration", + "propertyKeyName": "89", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (89)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 90, + "propertyName": "level", + "propertyKeyName": "90", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (90)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 90, + "propertyName": "dimmingDuration", + "propertyKeyName": "90", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (90)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 91, + "propertyName": "level", + "propertyKeyName": "91", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (91)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 91, + "propertyName": "dimmingDuration", + "propertyKeyName": "91", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (91)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 92, + "propertyName": "level", + "propertyKeyName": "92", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (92)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 92, + "propertyName": "dimmingDuration", + "propertyKeyName": "92", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (92)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 93, + "propertyName": "level", + "propertyKeyName": "93", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (93)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 93, + "propertyName": "dimmingDuration", + "propertyKeyName": "93", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (93)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 94, + "propertyName": "level", + "propertyKeyName": "94", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (94)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 94, + "propertyName": "dimmingDuration", + "propertyKeyName": "94", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (94)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 95, + "propertyName": "level", + "propertyKeyName": "95", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (95)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 95, + "propertyName": "dimmingDuration", + "propertyKeyName": "95", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (95)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 96, + "propertyName": "level", + "propertyKeyName": "96", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (96)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 96, + "propertyName": "dimmingDuration", + "propertyKeyName": "96", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (96)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 97, + "propertyName": "level", + "propertyKeyName": "97", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (97)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 97, + "propertyName": "dimmingDuration", + "propertyKeyName": "97", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (97)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 98, + "propertyName": "level", + "propertyKeyName": "98", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (98)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 98, + "propertyName": "dimmingDuration", + "propertyKeyName": "98", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (98)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 99, + "propertyName": "level", + "propertyKeyName": "99", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (99)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 99, + "propertyName": "dimmingDuration", + "propertyKeyName": "99", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (99)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 100, + "propertyName": "level", + "propertyKeyName": "100", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (100)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 100, + "propertyName": "dimmingDuration", + "propertyKeyName": "100", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (100)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 101, + "propertyName": "level", + "propertyKeyName": "101", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (101)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 101, + "propertyName": "dimmingDuration", + "propertyKeyName": "101", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (101)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 102, + "propertyName": "level", + "propertyKeyName": "102", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (102)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 102, + "propertyName": "dimmingDuration", + "propertyKeyName": "102", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (102)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 103, + "propertyName": "level", + "propertyKeyName": "103", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (103)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 103, + "propertyName": "dimmingDuration", + "propertyKeyName": "103", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (103)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 104, + "propertyName": "level", + "propertyKeyName": "104", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (104)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 104, + "propertyName": "dimmingDuration", + "propertyKeyName": "104", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (104)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 105, + "propertyName": "level", + "propertyKeyName": "105", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (105)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 105, + "propertyName": "dimmingDuration", + "propertyKeyName": "105", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (105)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 106, + "propertyName": "level", + "propertyKeyName": "106", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (106)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 106, + "propertyName": "dimmingDuration", + "propertyKeyName": "106", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (106)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 107, + "propertyName": "level", + "propertyKeyName": "107", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (107)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 107, + "propertyName": "dimmingDuration", + "propertyKeyName": "107", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (107)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 108, + "propertyName": "level", + "propertyKeyName": "108", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (108)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 108, + "propertyName": "dimmingDuration", + "propertyKeyName": "108", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (108)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 109, + "propertyName": "level", + "propertyKeyName": "109", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (109)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 109, + "propertyName": "dimmingDuration", + "propertyKeyName": "109", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (109)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 110, + "propertyName": "level", + "propertyKeyName": "110", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (110)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 110, + "propertyName": "dimmingDuration", + "propertyKeyName": "110", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (110)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 111, + "propertyName": "level", + "propertyKeyName": "111", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (111)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 111, + "propertyName": "dimmingDuration", + "propertyKeyName": "111", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (111)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 112, + "propertyName": "level", + "propertyKeyName": "112", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (112)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 112, + "propertyName": "dimmingDuration", + "propertyKeyName": "112", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (112)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 113, + "propertyName": "level", + "propertyKeyName": "113", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (113)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 113, + "propertyName": "dimmingDuration", + "propertyKeyName": "113", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (113)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 114, + "propertyName": "level", + "propertyKeyName": "114", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (114)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 114, + "propertyName": "dimmingDuration", + "propertyKeyName": "114", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (114)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 115, + "propertyName": "level", + "propertyKeyName": "115", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (115)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 115, + "propertyName": "dimmingDuration", + "propertyKeyName": "115", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (115)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 116, + "propertyName": "level", + "propertyKeyName": "116", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (116)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 116, + "propertyName": "dimmingDuration", + "propertyKeyName": "116", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (116)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 117, + "propertyName": "level", + "propertyKeyName": "117", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (117)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 117, + "propertyName": "dimmingDuration", + "propertyKeyName": "117", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (117)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 118, + "propertyName": "level", + "propertyKeyName": "118", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (118)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 118, + "propertyName": "dimmingDuration", + "propertyKeyName": "118", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (118)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 119, + "propertyName": "level", + "propertyKeyName": "119", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (119)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 119, + "propertyName": "dimmingDuration", + "propertyKeyName": "119", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (119)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 120, + "propertyName": "level", + "propertyKeyName": "120", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (120)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 120, + "propertyName": "dimmingDuration", + "propertyKeyName": "120", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (120)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 121, + "propertyName": "level", + "propertyKeyName": "121", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (121)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 121, + "propertyName": "dimmingDuration", + "propertyKeyName": "121", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (121)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 122, + "propertyName": "level", + "propertyKeyName": "122", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (122)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 122, + "propertyName": "dimmingDuration", + "propertyKeyName": "122", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (122)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 123, + "propertyName": "level", + "propertyKeyName": "123", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (123)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 123, + "propertyName": "dimmingDuration", + "propertyKeyName": "123", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (123)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 124, + "propertyName": "level", + "propertyKeyName": "124", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (124)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 124, + "propertyName": "dimmingDuration", + "propertyKeyName": "124", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (124)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 125, + "propertyName": "level", + "propertyKeyName": "125", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (125)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 125, + "propertyName": "dimmingDuration", + "propertyKeyName": "125", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (125)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 126, + "propertyName": "level", + "propertyKeyName": "126", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (126)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 126, + "propertyName": "dimmingDuration", + "propertyKeyName": "126", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (126)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 127, + "propertyName": "level", + "propertyKeyName": "127", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (127)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 127, + "propertyName": "dimmingDuration", + "propertyKeyName": "127", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (127)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 128, + "propertyName": "level", + "propertyKeyName": "128", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (128)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 128, + "propertyName": "dimmingDuration", + "propertyKeyName": "128", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (128)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 129, + "propertyName": "level", + "propertyKeyName": "129", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (129)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 129, + "propertyName": "dimmingDuration", + "propertyKeyName": "129", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (129)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 130, + "propertyName": "level", + "propertyKeyName": "130", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (130)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 130, + "propertyName": "dimmingDuration", + "propertyKeyName": "130", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (130)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 131, + "propertyName": "level", + "propertyKeyName": "131", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (131)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 131, + "propertyName": "dimmingDuration", + "propertyKeyName": "131", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (131)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 132, + "propertyName": "level", + "propertyKeyName": "132", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (132)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 132, + "propertyName": "dimmingDuration", + "propertyKeyName": "132", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (132)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 133, + "propertyName": "level", + "propertyKeyName": "133", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (133)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 133, + "propertyName": "dimmingDuration", + "propertyKeyName": "133", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (133)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 134, + "propertyName": "level", + "propertyKeyName": "134", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (134)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 134, + "propertyName": "dimmingDuration", + "propertyKeyName": "134", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (134)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 135, + "propertyName": "level", + "propertyKeyName": "135", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (135)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 135, + "propertyName": "dimmingDuration", + "propertyKeyName": "135", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (135)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 136, + "propertyName": "level", + "propertyKeyName": "136", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (136)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 136, + "propertyName": "dimmingDuration", + "propertyKeyName": "136", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (136)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 137, + "propertyName": "level", + "propertyKeyName": "137", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (137)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 137, + "propertyName": "dimmingDuration", + "propertyKeyName": "137", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (137)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 138, + "propertyName": "level", + "propertyKeyName": "138", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (138)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 138, + "propertyName": "dimmingDuration", + "propertyKeyName": "138", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (138)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 139, + "propertyName": "level", + "propertyKeyName": "139", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (139)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 139, + "propertyName": "dimmingDuration", + "propertyKeyName": "139", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (139)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 140, + "propertyName": "level", + "propertyKeyName": "140", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (140)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 140, + "propertyName": "dimmingDuration", + "propertyKeyName": "140", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (140)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 141, + "propertyName": "level", + "propertyKeyName": "141", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (141)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 141, + "propertyName": "dimmingDuration", + "propertyKeyName": "141", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (141)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 142, + "propertyName": "level", + "propertyKeyName": "142", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (142)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 142, + "propertyName": "dimmingDuration", + "propertyKeyName": "142", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (142)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 143, + "propertyName": "level", + "propertyKeyName": "143", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (143)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 143, + "propertyName": "dimmingDuration", + "propertyKeyName": "143", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (143)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 144, + "propertyName": "level", + "propertyKeyName": "144", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (144)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 144, + "propertyName": "dimmingDuration", + "propertyKeyName": "144", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (144)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 145, + "propertyName": "level", + "propertyKeyName": "145", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (145)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 145, + "propertyName": "dimmingDuration", + "propertyKeyName": "145", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (145)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 146, + "propertyName": "level", + "propertyKeyName": "146", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (146)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 146, + "propertyName": "dimmingDuration", + "propertyKeyName": "146", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (146)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 147, + "propertyName": "level", + "propertyKeyName": "147", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (147)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 147, + "propertyName": "dimmingDuration", + "propertyKeyName": "147", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (147)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 148, + "propertyName": "level", + "propertyKeyName": "148", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (148)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 148, + "propertyName": "dimmingDuration", + "propertyKeyName": "148", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (148)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 149, + "propertyName": "level", + "propertyKeyName": "149", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (149)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 149, + "propertyName": "dimmingDuration", + "propertyKeyName": "149", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (149)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 150, + "propertyName": "level", + "propertyKeyName": "150", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (150)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 150, + "propertyName": "dimmingDuration", + "propertyKeyName": "150", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (150)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 151, + "propertyName": "level", + "propertyKeyName": "151", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (151)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 151, + "propertyName": "dimmingDuration", + "propertyKeyName": "151", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (151)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 152, + "propertyName": "level", + "propertyKeyName": "152", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (152)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 152, + "propertyName": "dimmingDuration", + "propertyKeyName": "152", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (152)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 153, + "propertyName": "level", + "propertyKeyName": "153", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (153)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 153, + "propertyName": "dimmingDuration", + "propertyKeyName": "153", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (153)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 154, + "propertyName": "level", + "propertyKeyName": "154", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (154)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 154, + "propertyName": "dimmingDuration", + "propertyKeyName": "154", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (154)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 155, + "propertyName": "level", + "propertyKeyName": "155", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (155)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 155, + "propertyName": "dimmingDuration", + "propertyKeyName": "155", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (155)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 156, + "propertyName": "level", + "propertyKeyName": "156", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (156)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 156, + "propertyName": "dimmingDuration", + "propertyKeyName": "156", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (156)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 157, + "propertyName": "level", + "propertyKeyName": "157", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (157)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 157, + "propertyName": "dimmingDuration", + "propertyKeyName": "157", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (157)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 158, + "propertyName": "level", + "propertyKeyName": "158", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (158)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 158, + "propertyName": "dimmingDuration", + "propertyKeyName": "158", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (158)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 159, + "propertyName": "level", + "propertyKeyName": "159", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (159)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 159, + "propertyName": "dimmingDuration", + "propertyKeyName": "159", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (159)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 160, + "propertyName": "level", + "propertyKeyName": "160", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (160)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 160, + "propertyName": "dimmingDuration", + "propertyKeyName": "160", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (160)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 161, + "propertyName": "level", + "propertyKeyName": "161", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (161)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 161, + "propertyName": "dimmingDuration", + "propertyKeyName": "161", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (161)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 162, + "propertyName": "level", + "propertyKeyName": "162", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (162)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 162, + "propertyName": "dimmingDuration", + "propertyKeyName": "162", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (162)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 163, + "propertyName": "level", + "propertyKeyName": "163", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (163)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 163, + "propertyName": "dimmingDuration", + "propertyKeyName": "163", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (163)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 164, + "propertyName": "level", + "propertyKeyName": "164", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (164)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 164, + "propertyName": "dimmingDuration", + "propertyKeyName": "164", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (164)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 165, + "propertyName": "level", + "propertyKeyName": "165", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (165)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 165, + "propertyName": "dimmingDuration", + "propertyKeyName": "165", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (165)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 166, + "propertyName": "level", + "propertyKeyName": "166", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (166)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 166, + "propertyName": "dimmingDuration", + "propertyKeyName": "166", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (166)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 167, + "propertyName": "level", + "propertyKeyName": "167", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (167)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 167, + "propertyName": "dimmingDuration", + "propertyKeyName": "167", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (167)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 168, + "propertyName": "level", + "propertyKeyName": "168", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (168)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 168, + "propertyName": "dimmingDuration", + "propertyKeyName": "168", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (168)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 169, + "propertyName": "level", + "propertyKeyName": "169", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (169)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 169, + "propertyName": "dimmingDuration", + "propertyKeyName": "169", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (169)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 170, + "propertyName": "level", + "propertyKeyName": "170", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (170)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 170, + "propertyName": "dimmingDuration", + "propertyKeyName": "170", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (170)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 171, + "propertyName": "level", + "propertyKeyName": "171", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (171)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 171, + "propertyName": "dimmingDuration", + "propertyKeyName": "171", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (171)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 172, + "propertyName": "level", + "propertyKeyName": "172", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (172)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 172, + "propertyName": "dimmingDuration", + "propertyKeyName": "172", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (172)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 173, + "propertyName": "level", + "propertyKeyName": "173", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (173)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 173, + "propertyName": "dimmingDuration", + "propertyKeyName": "173", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (173)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 174, + "propertyName": "level", + "propertyKeyName": "174", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (174)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 174, + "propertyName": "dimmingDuration", + "propertyKeyName": "174", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (174)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 175, + "propertyName": "level", + "propertyKeyName": "175", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (175)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 175, + "propertyName": "dimmingDuration", + "propertyKeyName": "175", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (175)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 176, + "propertyName": "level", + "propertyKeyName": "176", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (176)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 176, + "propertyName": "dimmingDuration", + "propertyKeyName": "176", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (176)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 177, + "propertyName": "level", + "propertyKeyName": "177", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (177)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 177, + "propertyName": "dimmingDuration", + "propertyKeyName": "177", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (177)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 178, + "propertyName": "level", + "propertyKeyName": "178", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (178)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 178, + "propertyName": "dimmingDuration", + "propertyKeyName": "178", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (178)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 179, + "propertyName": "level", + "propertyKeyName": "179", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (179)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 179, + "propertyName": "dimmingDuration", + "propertyKeyName": "179", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (179)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 180, + "propertyName": "level", + "propertyKeyName": "180", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (180)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 180, + "propertyName": "dimmingDuration", + "propertyKeyName": "180", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (180)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 181, + "propertyName": "level", + "propertyKeyName": "181", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (181)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 181, + "propertyName": "dimmingDuration", + "propertyKeyName": "181", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (181)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 182, + "propertyName": "level", + "propertyKeyName": "182", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (182)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 182, + "propertyName": "dimmingDuration", + "propertyKeyName": "182", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (182)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 183, + "propertyName": "level", + "propertyKeyName": "183", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (183)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 183, + "propertyName": "dimmingDuration", + "propertyKeyName": "183", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (183)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 184, + "propertyName": "level", + "propertyKeyName": "184", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (184)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 184, + "propertyName": "dimmingDuration", + "propertyKeyName": "184", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (184)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 185, + "propertyName": "level", + "propertyKeyName": "185", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (185)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 185, + "propertyName": "dimmingDuration", + "propertyKeyName": "185", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (185)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 186, + "propertyName": "level", + "propertyKeyName": "186", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (186)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 186, + "propertyName": "dimmingDuration", + "propertyKeyName": "186", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (186)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 187, + "propertyName": "level", + "propertyKeyName": "187", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (187)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 187, + "propertyName": "dimmingDuration", + "propertyKeyName": "187", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (187)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 188, + "propertyName": "level", + "propertyKeyName": "188", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (188)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 188, + "propertyName": "dimmingDuration", + "propertyKeyName": "188", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (188)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 189, + "propertyName": "level", + "propertyKeyName": "189", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (189)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 189, + "propertyName": "dimmingDuration", + "propertyKeyName": "189", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (189)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 190, + "propertyName": "level", + "propertyKeyName": "190", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (190)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 190, + "propertyName": "dimmingDuration", + "propertyKeyName": "190", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (190)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 191, + "propertyName": "level", + "propertyKeyName": "191", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (191)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 191, + "propertyName": "dimmingDuration", + "propertyKeyName": "191", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (191)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 192, + "propertyName": "level", + "propertyKeyName": "192", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (192)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 192, + "propertyName": "dimmingDuration", + "propertyKeyName": "192", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (192)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 193, + "propertyName": "level", + "propertyKeyName": "193", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (193)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 193, + "propertyName": "dimmingDuration", + "propertyKeyName": "193", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (193)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 194, + "propertyName": "level", + "propertyKeyName": "194", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (194)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 194, + "propertyName": "dimmingDuration", + "propertyKeyName": "194", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (194)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 195, + "propertyName": "level", + "propertyKeyName": "195", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (195)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 195, + "propertyName": "dimmingDuration", + "propertyKeyName": "195", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (195)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 196, + "propertyName": "level", + "propertyKeyName": "196", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (196)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 196, + "propertyName": "dimmingDuration", + "propertyKeyName": "196", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (196)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 197, + "propertyName": "level", + "propertyKeyName": "197", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (197)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 197, + "propertyName": "dimmingDuration", + "propertyKeyName": "197", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (197)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 198, + "propertyName": "level", + "propertyKeyName": "198", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (198)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 198, + "propertyName": "dimmingDuration", + "propertyKeyName": "198", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (198)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 199, + "propertyName": "level", + "propertyKeyName": "199", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (199)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 199, + "propertyName": "dimmingDuration", + "propertyKeyName": "199", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (199)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 200, + "propertyName": "level", + "propertyKeyName": "200", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (200)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 200, + "propertyName": "dimmingDuration", + "propertyKeyName": "200", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (200)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 201, + "propertyName": "level", + "propertyKeyName": "201", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (201)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 201, + "propertyName": "dimmingDuration", + "propertyKeyName": "201", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (201)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 202, + "propertyName": "level", + "propertyKeyName": "202", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (202)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 202, + "propertyName": "dimmingDuration", + "propertyKeyName": "202", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (202)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 203, + "propertyName": "level", + "propertyKeyName": "203", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (203)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 203, + "propertyName": "dimmingDuration", + "propertyKeyName": "203", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (203)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 204, + "propertyName": "level", + "propertyKeyName": "204", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (204)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 204, + "propertyName": "dimmingDuration", + "propertyKeyName": "204", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (204)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 205, + "propertyName": "level", + "propertyKeyName": "205", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (205)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 205, + "propertyName": "dimmingDuration", + "propertyKeyName": "205", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (205)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 206, + "propertyName": "level", + "propertyKeyName": "206", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (206)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 206, + "propertyName": "dimmingDuration", + "propertyKeyName": "206", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (206)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 207, + "propertyName": "level", + "propertyKeyName": "207", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (207)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 207, + "propertyName": "dimmingDuration", + "propertyKeyName": "207", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (207)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 208, + "propertyName": "level", + "propertyKeyName": "208", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (208)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 208, + "propertyName": "dimmingDuration", + "propertyKeyName": "208", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (208)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 209, + "propertyName": "level", + "propertyKeyName": "209", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (209)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 209, + "propertyName": "dimmingDuration", + "propertyKeyName": "209", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (209)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 210, + "propertyName": "level", + "propertyKeyName": "210", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (210)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 210, + "propertyName": "dimmingDuration", + "propertyKeyName": "210", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (210)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 211, + "propertyName": "level", + "propertyKeyName": "211", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (211)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 211, + "propertyName": "dimmingDuration", + "propertyKeyName": "211", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (211)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 212, + "propertyName": "level", + "propertyKeyName": "212", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (212)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 212, + "propertyName": "dimmingDuration", + "propertyKeyName": "212", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (212)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 213, + "propertyName": "level", + "propertyKeyName": "213", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (213)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 213, + "propertyName": "dimmingDuration", + "propertyKeyName": "213", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (213)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 214, + "propertyName": "level", + "propertyKeyName": "214", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (214)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 214, + "propertyName": "dimmingDuration", + "propertyKeyName": "214", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (214)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 215, + "propertyName": "level", + "propertyKeyName": "215", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (215)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 215, + "propertyName": "dimmingDuration", + "propertyKeyName": "215", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (215)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 216, + "propertyName": "level", + "propertyKeyName": "216", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (216)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 216, + "propertyName": "dimmingDuration", + "propertyKeyName": "216", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (216)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 217, + "propertyName": "level", + "propertyKeyName": "217", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (217)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 217, + "propertyName": "dimmingDuration", + "propertyKeyName": "217", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (217)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 218, + "propertyName": "level", + "propertyKeyName": "218", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (218)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 218, + "propertyName": "dimmingDuration", + "propertyKeyName": "218", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (218)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 219, + "propertyName": "level", + "propertyKeyName": "219", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (219)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 219, + "propertyName": "dimmingDuration", + "propertyKeyName": "219", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (219)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 220, + "propertyName": "level", + "propertyKeyName": "220", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (220)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 220, + "propertyName": "dimmingDuration", + "propertyKeyName": "220", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (220)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 221, + "propertyName": "level", + "propertyKeyName": "221", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (221)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 221, + "propertyName": "dimmingDuration", + "propertyKeyName": "221", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (221)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 222, + "propertyName": "level", + "propertyKeyName": "222", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (222)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 222, + "propertyName": "dimmingDuration", + "propertyKeyName": "222", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (222)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 223, + "propertyName": "level", + "propertyKeyName": "223", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (223)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 223, + "propertyName": "dimmingDuration", + "propertyKeyName": "223", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (223)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 224, + "propertyName": "level", + "propertyKeyName": "224", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (224)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 224, + "propertyName": "dimmingDuration", + "propertyKeyName": "224", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (224)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 225, + "propertyName": "level", + "propertyKeyName": "225", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (225)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 225, + "propertyName": "dimmingDuration", + "propertyKeyName": "225", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (225)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 226, + "propertyName": "level", + "propertyKeyName": "226", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (226)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 226, + "propertyName": "dimmingDuration", + "propertyKeyName": "226", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (226)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 227, + "propertyName": "level", + "propertyKeyName": "227", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (227)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 227, + "propertyName": "dimmingDuration", + "propertyKeyName": "227", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (227)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 228, + "propertyName": "level", + "propertyKeyName": "228", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (228)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 228, + "propertyName": "dimmingDuration", + "propertyKeyName": "228", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (228)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 229, + "propertyName": "level", + "propertyKeyName": "229", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (229)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 229, + "propertyName": "dimmingDuration", + "propertyKeyName": "229", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (229)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 230, + "propertyName": "level", + "propertyKeyName": "230", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (230)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 230, + "propertyName": "dimmingDuration", + "propertyKeyName": "230", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (230)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 231, + "propertyName": "level", + "propertyKeyName": "231", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (231)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 231, + "propertyName": "dimmingDuration", + "propertyKeyName": "231", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (231)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 232, + "propertyName": "level", + "propertyKeyName": "232", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (232)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 232, + "propertyName": "dimmingDuration", + "propertyKeyName": "232", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (232)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 233, + "propertyName": "level", + "propertyKeyName": "233", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (233)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 233, + "propertyName": "dimmingDuration", + "propertyKeyName": "233", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (233)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 234, + "propertyName": "level", + "propertyKeyName": "234", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (234)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 234, + "propertyName": "dimmingDuration", + "propertyKeyName": "234", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (234)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 235, + "propertyName": "level", + "propertyKeyName": "235", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (235)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 235, + "propertyName": "dimmingDuration", + "propertyKeyName": "235", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (235)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 236, + "propertyName": "level", + "propertyKeyName": "236", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (236)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 236, + "propertyName": "dimmingDuration", + "propertyKeyName": "236", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (236)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 237, + "propertyName": "level", + "propertyKeyName": "237", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (237)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 237, + "propertyName": "dimmingDuration", + "propertyKeyName": "237", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (237)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 238, + "propertyName": "level", + "propertyKeyName": "238", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (238)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 238, + "propertyName": "dimmingDuration", + "propertyKeyName": "238", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (238)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 239, + "propertyName": "level", + "propertyKeyName": "239", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (239)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 239, + "propertyName": "dimmingDuration", + "propertyKeyName": "239", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (239)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 240, + "propertyName": "level", + "propertyKeyName": "240", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (240)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 240, + "propertyName": "dimmingDuration", + "propertyKeyName": "240", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (240)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 241, + "propertyName": "level", + "propertyKeyName": "241", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (241)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 241, + "propertyName": "dimmingDuration", + "propertyKeyName": "241", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (241)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 242, + "propertyName": "level", + "propertyKeyName": "242", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (242)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 242, + "propertyName": "dimmingDuration", + "propertyKeyName": "242", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (242)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 243, + "propertyName": "level", + "propertyKeyName": "243", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (243)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 243, + "propertyName": "dimmingDuration", + "propertyKeyName": "243", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (243)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 244, + "propertyName": "level", + "propertyKeyName": "244", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (244)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 244, + "propertyName": "dimmingDuration", + "propertyKeyName": "244", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (244)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 245, + "propertyName": "level", + "propertyKeyName": "245", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (245)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 245, + "propertyName": "dimmingDuration", + "propertyKeyName": "245", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (245)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 246, + "propertyName": "level", + "propertyKeyName": "246", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (246)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 246, + "propertyName": "dimmingDuration", + "propertyKeyName": "246", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (246)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 247, + "propertyName": "level", + "propertyKeyName": "247", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (247)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 247, + "propertyName": "dimmingDuration", + "propertyKeyName": "247", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (247)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 248, + "propertyName": "level", + "propertyKeyName": "248", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (248)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 248, + "propertyName": "dimmingDuration", + "propertyKeyName": "248", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (248)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 249, + "propertyName": "level", + "propertyKeyName": "249", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (249)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 249, + "propertyName": "dimmingDuration", + "propertyKeyName": "249", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (249)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 250, + "propertyName": "level", + "propertyKeyName": "250", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (250)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 250, + "propertyName": "dimmingDuration", + "propertyKeyName": "250", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (250)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 251, + "propertyName": "level", + "propertyKeyName": "251", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (251)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 251, + "propertyName": "dimmingDuration", + "propertyKeyName": "251", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (251)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 252, + "propertyName": "level", + "propertyKeyName": "252", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (252)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 252, + "propertyName": "dimmingDuration", + "propertyKeyName": "252", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (252)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 253, + "propertyName": "level", + "propertyKeyName": "253", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (253)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 253, + "propertyName": "dimmingDuration", + "propertyKeyName": "253", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (253)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 254, + "propertyName": "level", + "propertyKeyName": "254", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (254)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 254, + "propertyName": "dimmingDuration", + "propertyKeyName": "254", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (254)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 255, + "propertyName": "level", + "propertyKeyName": "255", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (255)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 255, + "propertyName": "dimmingDuration", + "propertyKeyName": "255", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (255)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 114, + "commandClassName": "Manufacturer Specific", + "property": "productId", + "propertyName": "productId", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "label": "Product ID", + "min": 0, + "max": 65535, + "stateful": true, + "secret": false + }, + "value": 521 + }, + { + "endpoint": 0, + "commandClass": 114, + "commandClassName": "Manufacturer Specific", + "property": "productType", + "propertyName": "productType", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "label": "Product type", + "min": 0, + "max": 65535, + "stateful": true, + "secret": false + }, + "value": 4097 + }, + { + "endpoint": 0, + "commandClass": 114, + "commandClassName": "Manufacturer Specific", + "property": "manufacturerId", + "propertyName": "manufacturerId", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "label": "Manufacturer ID", + "min": 0, + "max": 65535, + "stateful": true, + "secret": false + }, + "value": 29 + }, + { + "endpoint": 0, + "commandClass": 119, + "commandClassName": "Node Naming and Location", + "property": "name", + "propertyName": "name", + "ccVersion": 1, + "metadata": { + "type": "string", + "readable": true, + "writeable": true, + "label": "Node name", + "stateful": true, + "secret": false + }, + "value": "Fan" + }, + { + "endpoint": 0, + "commandClass": 119, + "commandClassName": "Node Naming and Location", + "property": "location", + "propertyName": "location", + "ccVersion": 1, + "metadata": { + "type": "string", + "readable": true, + "writeable": true, + "label": "Node location", + "stateful": true, + "secret": false + }, + "value": "Family Room" + }, + { + "endpoint": 0, + "commandClass": 134, + "commandClassName": "Version", + "property": "firmwareVersions", + "propertyName": "firmwareVersions", + "ccVersion": 1, + "metadata": { + "type": "string[]", + "readable": true, + "writeable": false, + "label": "Z-Wave chip firmware versions", + "stateful": true, + "secret": false + }, + "value": ["0.5"] + }, + { + "endpoint": 0, + "commandClass": 134, + "commandClassName": "Version", + "property": "protocolVersion", + "propertyName": "protocolVersion", + "ccVersion": 1, + "metadata": { + "type": "string", + "readable": true, + "writeable": false, + "label": "Z-Wave protocol version", + "stateful": true, + "secret": false + }, + "value": "2.9" + }, + { + "endpoint": 0, + "commandClass": 134, + "commandClassName": "Version", + "property": "libraryType", + "propertyName": "libraryType", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "label": "Library type", + "states": { + "0": "Unknown", + "1": "Static Controller", + "2": "Controller", + "3": "Enhanced Slave", + "4": "Slave", + "5": "Installer", + "6": "Routing Slave", + "7": "Bridge Controller", + "8": "Device under Test", + "9": "N/A", + "10": "AV Remote", + "11": "AV Device" + }, + "stateful": true, + "secret": false + }, + "value": 3 + } + ], + "endpoints": [ + { + "nodeId": 35, + "index": 0, + "deviceClass": { + "basic": { + "key": 4, + "label": "Routing End Node" + }, + "generic": { + "key": 17, + "label": "Multilevel Switch" + }, + "specific": { + "key": 4, + "label": "Multilevel Scene Switch" + } + }, + "commandClasses": [ + { + "id": 38, + "name": "Multilevel Switch", + "version": 1, + "isSecure": false + }, + { + "id": 43, + "name": "Scene Activation", + "version": 1, + "isSecure": false + }, + { + "id": 44, + "name": "Scene Actuator Configuration", + "version": 1, + "isSecure": false + }, + { + "id": 133, + "name": "Association", + "version": 1, + "isSecure": false + }, + { + "id": 114, + "name": "Manufacturer Specific", + "version": 1, + "isSecure": false + }, + { + "id": 134, + "name": "Version", + "version": 1, + "isSecure": false + }, + { + "id": 145, + "name": "Manufacturer Proprietary", + "version": 1, + "isSecure": false + }, + { + "id": 119, + "name": "Node Naming and Location", + "version": 1, + "isSecure": false + }, + { + "id": 115, + "name": "Powerlevel", + "version": 1, + "isSecure": false + } + ] + } + ] +} diff --git a/tests/components/zwave_js/test_api.py b/tests/components/zwave_js/test_api.py index 2d1dc6aef350..b417c244b39c 100644 --- a/tests/components/zwave_js/test_api.py +++ b/tests/components/zwave_js/test_api.py @@ -101,7 +101,11 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr from tests.common import MockConfigEntry, MockUser -from tests.typing import ClientSessionGenerator, WebSocketGenerator +from tests.typing import ( + ClientSessionGenerator, + MockHAClientWebSocket, + WebSocketGenerator, +) CONTROLLER_PATCH_PREFIX = "zwave_js_server.model.controller.Controller" @@ -5264,6 +5268,147 @@ async def test_subscribe_node_statistics( assert msg["error"]["code"] == ERR_NOT_LOADED +def _stats_updated_event(node_id: int, repeater_node_id: int) -> Event: + """Return a statistics updated event with a route through the repeater.""" + return Event( + "statistics updated", + { + "source": "node", + "event": "statistics updated", + "nodeId": node_id, + "statistics": { + "commandsTX": 1, + "commandsRX": 2, + "commandsDroppedTX": 3, + "commandsDroppedRX": 4, + "timeoutResponse": 5, + "lwr": { + "protocolDataRate": 1, + "rssi": 1, + "repeaters": [repeater_node_id], + "repeaterRSSI": [1], + }, + }, + }, + ) + + +async def _subscribe_node_statistics( + ws_client: MockHAClientWebSocket, device_id: str +) -> None: + """Subscribe to node statistics and consume the initial state event.""" + await ws_client.send_json_auto_id( + { + TYPE: "zwave_js/subscribe_node_statistics", + DEVICE_ID: device_id, + } + ) + msg = await ws_client.receive_json() + assert msg["success"] + msg = await ws_client.receive_json() + assert msg["event"]["event"] == "statistics updated" + + +async def test_node_statistics_route_with_removed_node( + hass: HomeAssistant, + multisensor_6: Node, + wallmote_central_scene: Node, + integration: MockConfigEntry, + client: MagicMock, + hass_ws_client: WebSocketGenerator, +) -> None: + """Test a route referencing a node that was removed from the network. + + Resolving the repeater in the controller's node collection raises + KeyError, which must null the route instead of breaking the subscription. + """ + ws_client = await hass_ws_client(hass) + device = get_device(hass, multisensor_6) + wallmote_device = get_device(hass, wallmote_central_scene) + await _subscribe_node_statistics(ws_client, device.id) + + event = _stats_updated_event(multisensor_6.node_id, 999) + event.data["statistics"]["nlwr"] = { + "protocolDataRate": 2, + "rssi": 2, + "repeaters": [wallmote_central_scene.node_id], + "repeaterRSSI": [2], + } + client.driver.controller.receive_event(event) + msg = await ws_client.receive_json() + + assert msg["event"]["commands_tx"] == 1 + assert msg["event"]["lwr"] is None + assert msg["event"]["nlwr"] == { + "protocol_data_rate": 2, + "rssi": 2, + "repeaters": [wallmote_device.id], + "repeater_rssi": [2], + "route_failed_between": None, + } + + +async def test_node_statistics_route_with_removed_device( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + multisensor_6: Node, + wallmote_central_scene: Node, + integration: MockConfigEntry, + client: MagicMock, + hass_ws_client: WebSocketGenerator, +) -> None: + """Test a route referencing a node without a device registry entry. + + Converting the repeater to a device ID raises ValueError, which must null + the route instead of breaking the subscription. + """ + ws_client = await hass_ws_client(hass) + device = get_device(hass, multisensor_6) + wallmote_device = get_device(hass, wallmote_central_scene) + await _subscribe_node_statistics(ws_client, device.id) + + device_registry.async_remove_device(wallmote_device.id) + await hass.async_block_till_done() + + client.driver.controller.receive_event( + _stats_updated_event(multisensor_6.node_id, wallmote_central_scene.node_id) + ) + msg = await ws_client.receive_json() + + assert msg["event"]["commands_tx"] == 1 + assert msg["event"]["lwr"] is None + + +async def test_node_statistics_route_with_unloaded_entry( + hass: HomeAssistant, + multisensor_6: Node, + wallmote_central_scene: Node, + integration: MockConfigEntry, + client: MagicMock, + hass_ws_client: WebSocketGenerator, +) -> None: + """Test a route received after the config entry was unloaded. + + async_get_config_entry_from_node raises StopIteration when no loaded + config entry owns the node, which must null the route instead of + breaking the subscription. + """ + ws_client = await hass_ws_client(hass) + device = get_device(hass, multisensor_6) + await _subscribe_node_statistics(ws_client, device.id) + + await hass.config_entries.async_unload(integration.entry_id) + await hass.async_block_till_done() + + client.driver.controller.receive_event( + _stats_updated_event(multisensor_6.node_id, wallmote_central_scene.node_id) + ) + msg = await ws_client.receive_json() + + assert msg["event"]["commands_tx"] == 1 + assert msg["event"]["lwr"] is None + + async def test_hard_reset_controller( hass: HomeAssistant, caplog: pytest.LogCaptureFixture, diff --git a/tests/components/zwave_js/test_config_flow.py b/tests/components/zwave_js/test_config_flow.py index 909e11b9f277..d1efc7e0df4d 100644 --- a/tests/components/zwave_js/test_config_flow.py +++ b/tests/components/zwave_js/test_config_flow.py @@ -14,7 +14,7 @@ from aiohasupervisor.models import AddonsOptions, Discovery import aiohttp import pytest from voluptuous import InInvalid -from zwave_js_server.exceptions import FailedCommand +from zwave_js_server.exceptions import ConnectionFailed, FailedCommand from zwave_js_server.model.node import Node from zwave_js_server.version import VersionInfo @@ -1058,7 +1058,7 @@ async def test_usb_discovery_migration( assert client.connect.call_count == 2 await hass.async_block_till_done() - assert client.connect.call_count == 4 + assert client.connect.call_count == 3 assert entry.state is config_entries.ConfigEntryState.LOADED assert client.driver.controller.async_restore_nvm.call_count == 1 assert len(events) == 2 @@ -1073,7 +1073,126 @@ async def test_usb_discovery_migration( assert entry.data["usb_path"] == USB_DISCOVERY_INFO.device assert entry.data["socket_path"] is None assert entry.data["use_addon"] is True - assert "keep_old_devices" not in entry.data + assert entry.unique_id == "3245146787" + + +@pytest.mark.usefixtures( + "supervisor", + "addon_running", + "backup_nvm", + "climate_radio_thermostat_ct100_plus", + "lock_schlage_be469", +) +async def test_usb_discovery_migration_new_stick( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + client: MagicMock, + integration: MockConfigEntry, + restart_addon: AsyncMock, + set_addon_options: AsyncMock, + addon_options: dict[str, Any], + mock_usb_serial_by_id: MagicMock, + get_server_version: AsyncMock, +) -> None: + """Test migration to a factory-new adapter keeps the old node devices.""" + addon_options["device"] = "/dev/ttyUSB0" + entry = integration + assert entry.unique_id == "3245146787" + hass.config_entries.async_update_entry( + entry, + data={ + "url": "ws://localhost:3000", + "use_addon": True, + "usb_path": "/dev/ttyUSB0", + }, + ) + + device_entries = dr.async_entries_for_config_entry(device_registry, entry.entry_id) + assert len(device_entries) == 3 + old_device_ids = {device.id for device in device_entries} + + nodes_snapshot = dict(client.driver.controller.nodes) + own_node_id = client.driver.controller.own_node.node_id + + async def mock_restart_addon(addon_slug: str) -> None: + # A factory-new adapter has its own home id and no nodes. + client.driver.controller.data["homeId"] = 1234 + client.driver.controller.nodes.clear() + client.driver.controller.nodes[own_node_id] = nodes_snapshot[own_node_id] + + restart_addon.side_effect = mock_restart_addon + + async def mock_restore_nvm(data: bytes, options: dict[str, bool] | None = None): + client.driver.controller.emit( + "nvm convert progress", + {"event": "nvm convert progress", "bytesRead": 100, "total": 200}, + ) + await asyncio.sleep(0) + client.driver.controller.emit( + "nvm restore progress", + {"event": "nvm restore progress", "bytesWritten": 100, "total": 200}, + ) + client.driver.controller.data["homeId"] = 3245146787 + client.driver.controller.nodes.update(nodes_snapshot) + client.driver.emit( + "driver ready", {"event": "driver ready", "source": "driver"} + ) + + client.driver.controller.async_restore_nvm = AsyncMock(side_effect=mock_restore_nvm) + + registry_events = async_capture_events(hass, dr.EVENT_DEVICE_REGISTRY_UPDATED) + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_USB}, + data=USB_DISCOVERY_INFO, + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "confirm_usb_migration" + + result = await hass.config_entries.flow.async_configure(result["flow_id"], {}) + + assert result["type"] is FlowResultType.SHOW_PROGRESS + assert result["step_id"] == "backup_nvm" + + with patch("pathlib.Path.write_bytes"): + await hass.async_block_till_done() + + result = await hass.config_entries.flow.async_configure(result["flow_id"]) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "instruct_unplug" + + result = await hass.config_entries.flow.async_configure(result["flow_id"], {}) + + assert result["type"] is FlowResultType.SHOW_PROGRESS + assert result["step_id"] == "start_addon" + + await hass.async_block_till_done() + + # The server, connected to the new adapter, reports the adapter's + # factory home id before the restore. + _set_home_id(get_server_version, 1234) + + result = await hass.config_entries.flow.async_configure(result["flow_id"]) + + assert result["type"] is FlowResultType.SHOW_PROGRESS + assert result["step_id"] == "restore_nvm" + assert entry.unique_id == "3245146787" + + await hass.async_block_till_done() + + result = await hass.config_entries.flow.async_configure(result["flow_id"]) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "migration_successful" + await hass.async_block_till_done() + + assert not [event for event in registry_events if event.data["action"] == "remove"] + device_entries = dr.async_entries_for_config_entry(device_registry, entry.entry_id) + assert len(device_entries) == 3 + assert {device.id for device in device_entries} == old_device_ids assert entry.unique_id == "3245146787" @@ -1197,8 +1316,7 @@ async def test_usb_discovery_migration_restore_driver_ready_timeout( assert entry.data["usb_path"] == USB_DISCOVERY_INFO.device assert entry.data["socket_path"] is None assert entry.data["use_addon"] is True - assert entry.unique_id == "1234" - assert "keep_old_devices" in entry.data + assert entry.unique_id == "3245146787" @pytest.mark.usefixtures("supervisor", "addon_info") @@ -1332,12 +1450,12 @@ async def test_esphome_discovery_intent_custom( assert len(mock_setup_entry.mock_calls) == 1 -@pytest.mark.usefixtures("supervisor", "addon_running", "addon_running", "addon_info") +@pytest.mark.usefixtures("supervisor", "addon_running", "addon_info") async def test_esphome_discovery_intent_recommended( hass: HomeAssistant, set_addon_options: AsyncMock, addon_options: dict, - stop_addon: AsyncMock, + restart_addon: AsyncMock, ) -> None: """Test ESPHome discovery success path.""" addon_options.update( @@ -1362,6 +1480,31 @@ async def test_esphome_discovery_intent_recommended( assert result["step_id"] == "installation_type" assert result["menu_options"] == ["intent_recommended", "intent_custom"] + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {"next_step_id": "intent_recommended"} + ) + + assert result["type"] is FlowResultType.SHOW_PROGRESS + assert result["step_id"] == "start_addon" + assert set_addon_options.call_args == call( + "core_zwave_js", + AddonsOptions( + config={ + "socket": "esphome://192.168.1.100:6053", + "s0_legacy_key": "new123", + "s2_access_control_key": "new456", + "s2_authenticated_key": "new789", + "s2_unauthenticated_key": "new987", + "lr_s2_access_control_key": "new654", + "lr_s2_authenticated_key": "new321", + } + ), + ) + + await hass.async_block_till_done() + + assert restart_addon.call_args == call("core_zwave_js") + with ( patch( "homeassistant.components.zwave_js.async_setup", return_value=True @@ -1371,9 +1514,8 @@ async def test_esphome_discovery_intent_recommended( return_value=True, ) as mock_setup_entry, ): - result = await hass.config_entries.flow.async_configure( - result["flow_id"], {"next_step_id": "intent_recommended"} - ) + result = await hass.config_entries.flow.async_configure(result["flow_id"]) + await hass.async_block_till_done() assert result["type"] is FlowResultType.CREATE_ENTRY assert result["title"] == TITLE @@ -1391,22 +1533,6 @@ async def test_esphome_discovery_intent_recommended( "use_addon": True, "integration_created_addon": False, } - assert set_addon_options.call_args == call( - "core_zwave_js", - AddonsOptions( - config={ - "socket": "esphome://192.168.1.100:6053", - "s0_legacy_key": "new123", - "s2_access_control_key": "new456", - "s2_authenticated_key": "new789", - "s2_unauthenticated_key": "new987", - "lr_s2_access_control_key": "new654", - "lr_s2_authenticated_key": "new321", - } - ), - ) - assert stop_addon.call_count == 1 - assert stop_addon.call_args == call("core_zwave_js") assert len(mock_setup.mock_calls) == 1 assert len(mock_setup_entry.mock_calls) == 1 @@ -1466,6 +1592,196 @@ async def test_esphome_discovery_already_configured( assert stop_addon.call_args == call("core_zwave_js") +@pytest.mark.usefixtures("supervisor", "addon_running", "backup_nvm", "restore_nvm") +@pytest.mark.parametrize( + "esphome_discovery_info", + [ + pytest.param(ESPHOME_DISCOVERY_INFO, id="different_home_id"), + pytest.param(ESPHOME_DISCOVERY_INFO_CLEAN, id="unknown_home_id"), + ], +) +async def test_esphome_discovery_migration( + hass: HomeAssistant, + addon_options: dict[str, Any], + set_addon_options: AsyncMock, + restart_addon: AsyncMock, + client: MagicMock, + integration: MockConfigEntry, + get_server_version: AsyncMock, + esphome_discovery_info: ESPHomeServiceInfo, +) -> None: + """Test ESPHome discovery of a different adapter starts migration.""" + addon_options["device"] = "/dev/ttyUSB0" + entry = integration + assert client.connect.call_count == 1 + assert entry.unique_id == "3245146787" + hass.config_entries.async_update_entry( + entry, + data={ + "url": "ws://localhost:3000", + "use_addon": True, + "usb_path": "/dev/ttyUSB0", + }, + ) + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_ESPHOME}, + data=esphome_discovery_info, + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "confirm_usb_migration" + # The add-on config is not touched before the user confirms. + set_addon_options.assert_not_called() + + result = await hass.config_entries.flow.async_configure(result["flow_id"], {}) + + assert result["type"] is FlowResultType.SHOW_PROGRESS + assert result["step_id"] == "backup_nvm" + + with patch("pathlib.Path.write_bytes") as mock_file: + await hass.async_block_till_done() + assert client.driver.controller.async_backup_nvm_raw.call_count == 1 + assert mock_file.call_count == 1 + + result = await hass.config_entries.flow.async_configure(result["flow_id"]) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "instruct_unplug" + + result = await hass.config_entries.flow.async_configure(result["flow_id"], {}) + + assert entry.state is config_entries.ConfigEntryState.NOT_LOADED + assert result["type"] is FlowResultType.SHOW_PROGRESS + assert result["step_id"] == "start_addon" + assert set_addon_options.call_args == call( + "core_zwave_js", + AddonsOptions( + config={ + CONF_ADDON_SOCKET: "esphome://192.168.1.100:6053", + } + ), + ) + + await hass.async_block_till_done() + + assert restart_addon.call_args == call("core_zwave_js") + # The add-on start has finished and the next configure call below + # runs the finish step, which routes to the migration finish. + flow = hass.config_entries.flow.async_get(result["flow_id"]) + assert flow["step_id"] == "finish_addon_setup" + + _set_home_id(get_server_version, 3245146787) + + result = await hass.config_entries.flow.async_configure(result["flow_id"]) + + assert result["type"] is FlowResultType.SHOW_PROGRESS + assert result["step_id"] == "restore_nvm" + assert client.connect.call_count == 2 + + await hass.async_block_till_done() + assert client.connect.call_count == 3 + assert entry.state is config_entries.ConfigEntryState.LOADED + assert client.driver.controller.async_restore_nvm.call_count == 1 + + result = await hass.config_entries.flow.async_configure(result["flow_id"]) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "migration_successful" + assert entry.data["url"] == "ws://host1:3001" + assert entry.data["usb_path"] is None + assert entry.data["socket_path"] == "esphome://192.168.1.100:6053" + assert entry.data["use_addon"] is True + + +@pytest.mark.usefixtures("supervisor", "addon_running") +async def test_esphome_discovery_no_home_id_configured_socket_no_migration( + hass: HomeAssistant, +) -> None: + """Test a no-home-ID reconnect of the configured socket isn't a migration.""" + entry = MockConfigEntry( + domain=DOMAIN, + data={ + CONF_SOCKET_PATH: "esphome://192.168.1.100:6053", + "use_addon": True, + "integration_created_addon": True, + }, + title=TITLE, + unique_id="1234", + ) + entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_ESPHOME}, + data=ESPHOME_DISCOVERY_INFO_CLEAN, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" + + +@pytest.mark.usefixtures("supervisor", "addon_running") +async def test_esphome_discovery_placeholder_then_home_id( + hass: HomeAssistant, +) -> None: + """Test a home ID discovery dedups against a pending placeholder prompt.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_ESPHOME}, + data=ESPHOME_DISCOVERY_INFO_CLEAN, + ) + + assert result["type"] is FlowResultType.MENU + assert result["step_id"] == "installation_type" + + # The same adapter now reports a home ID while its prompt is open. + home_id_info = ESPHomeServiceInfo( + name=ESPHOME_DISCOVERY_INFO_CLEAN.name, + zwave_home_id=1234, + ip_address=ESPHOME_DISCOVERY_INFO_CLEAN.ip_address, + port=ESPHOME_DISCOVERY_INFO_CLEAN.port, + ) + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_ESPHOME}, + data=home_id_info, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_in_progress" + + +@pytest.mark.usefixtures("supervisor", "addon_running") +async def test_esphome_discovery_placeholder_ignored_then_home_id( + hass: HomeAssistant, +) -> None: + """Test a home ID discovery honors a placeholder-based ignore.""" + entry = MockConfigEntry( + domain=DOMAIN, + source=config_entries.SOURCE_IGNORE, + unique_id="esphome_mock-name", + ) + entry.add_to_hass(hass) + + # The adapter that was ignored without a home ID now reports one. + home_id_info = ESPHomeServiceInfo( + name="mock-name", + zwave_home_id=1234, + ip_address="192.168.1.100", + port=6053, + ) + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_ESPHOME}, + data=home_id_info, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" + + @pytest.mark.usefixtures("supervisor", "addon_running", "addon_info") async def test_esphome_discovery_same_socket_no_reload( hass: HomeAssistant, @@ -1509,6 +1825,78 @@ async def test_esphome_discovery_same_socket_no_reload( } +@pytest.mark.usefixtures("supervisor", "addon_running") +@pytest.mark.parametrize( + ("esphome_discovery_info", "ignored_unique_id"), + [ + pytest.param(ESPHOME_DISCOVERY_INFO, "1234", id="home_id"), + pytest.param( + ESPHOME_DISCOVERY_INFO_CLEAN, "esphome_mock-name", id="no_home_id" + ), + ], +) +async def test_esphome_discovery_ignored( + hass: HomeAssistant, + esphome_discovery_info: ESPHomeServiceInfo, + ignored_unique_id: str, +) -> None: + """Test ESPHome discovery aborts when the discovery was ignored.""" + entry = MockConfigEntry( + domain=DOMAIN, + source=config_entries.SOURCE_IGNORE, + unique_id=ignored_unique_id, + ) + entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_ESPHOME}, + data=esphome_discovery_info, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" + + +@pytest.mark.usefixtures("supervisor", "addon_running") +async def test_esphome_discovery_without_home_id_can_be_ignored( + hass: HomeAssistant, +) -> None: + """Test a discovery without a home ID gets a unique id for ignoring.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_ESPHOME}, + data=ESPHOME_DISCOVERY_INFO_CLEAN, + ) + + assert result["type"] is FlowResultType.MENU + assert result["step_id"] == "installation_type" + + flows = hass.config_entries.flow.async_progress_by_handler( + DOMAIN, match_context={"source": config_entries.SOURCE_ESPHOME} + ) + assert len(flows) == 1 + assert flows[0]["context"]["unique_id"] == "esphome_mock-name" + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_IGNORE}, + data={"unique_id": "esphome_mock-name", "title": "ZWA-2 proxy"}, + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + + # The discovery prompt is gone and rediscovery aborts. + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_ESPHOME}, + data=ESPHOME_DISCOVERY_INFO_CLEAN, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" + + @pytest.mark.usefixtures("supervisor", "addon_running", "addon_info") async def test_esphome_discovery_already_configured_unmanaged_addon( hass: HomeAssistant, @@ -2114,7 +2502,7 @@ async def test_discovery_not_blocked_by_zeroconf_flow(hass: HomeAssistant) -> No assert result["reason"] == "already_in_progress" -@pytest.mark.usefixtures("supervisor", "addon_running") +@pytest.mark.usefixtures("supervisor", "addon_running", "restart_addon") async def test_usb_discovery_leaves_manual_entry_alone( hass: HomeAssistant, addon_options: dict[str, Any], @@ -2150,10 +2538,41 @@ async def test_usb_discovery_leaves_manual_entry_alone( result["flow_id"], {"next_step_id": "intent_recommended"} ) + assert result["type"] is FlowResultType.SHOW_PROGRESS + assert result["step_id"] == "start_addon" + + await hass.async_block_till_done() + + result = await hass.config_entries.flow.async_configure(result["flow_id"]) + assert result["type"] is FlowResultType.ABORT assert result["reason"] == "already_configured" + # The add-on config now points at the discovered adapter, but the + # manual entry stays untouched. assert entry.data == {"url": "ws://external-server:3000"} - set_addon_options.assert_not_called() + + +@pytest.mark.usefixtures("supervisor", "addon_info") +async def test_usb_discovery_ignored( + hass: HomeAssistant, + mock_usb_serial_by_id: MagicMock, +) -> None: + """Test USB discovery aborts when the discovery was ignored.""" + entry = MockConfigEntry( + domain=DOMAIN, + source=config_entries.SOURCE_IGNORE, + unique_id="AAAA:AAAA_1234_test_zwave radio", + ) + entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_USB}, + data=USB_DISCOVERY_INFO, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" @pytest.mark.usefixtures("supervisor", "addon_info") @@ -2285,6 +2704,94 @@ async def test_not_addon(hass: HomeAssistant) -> None: assert len(mock_setup_entry.mock_calls) == 1 +@pytest.mark.usefixtures("supervisor", "addon_running") +async def test_addon_already_configured( + hass: HomeAssistant, + addon_options: dict[str, Any], + set_addon_options: AsyncMock, +) -> None: + """Test flow aborts when another entry already uses the add-on.""" + addon_options["device"] = "/test" + + entry = MockConfigEntry( + domain=DOMAIN, + data={ + "url": "ws://localhost:3000", + "usb_path": "/other", + "use_addon": True, + }, + title=TITLE, + unique_id="4321", + ) + entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + assert result["type"] is FlowResultType.MENU + assert result["step_id"] == "installation_type" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {"next_step_id": "intent_custom"} + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "on_supervisor" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {"use_addon": True} + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "addon_already_configured" + assert len(hass.config_entries.async_entries(DOMAIN)) == 1 + # The flow must not have touched the add-on config of the existing entry. + set_addon_options.assert_not_called() + assert addon_options["device"] == "/test" + + +@pytest.mark.usefixtures("supervisor", "addon_running") +async def test_reconfigure_addon_already_configured( + hass: HomeAssistant, + integration: MockConfigEntry, + set_addon_options: AsyncMock, +) -> None: + """Test reconfigure to add-on aborts when another entry uses the add-on.""" + addon_entry = MockConfigEntry( + domain=DOMAIN, + data={ + "url": "ws://localhost:3000", + "usb_path": "/other", + "use_addon": True, + }, + title=TITLE, + unique_id="4321", + ) + addon_entry.add_to_hass(hass) + + result = await integration.start_reconfigure_flow(hass) + + assert result["type"] is FlowResultType.MENU + assert result["step_id"] == "reconfigure" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {"next_step_id": "intent_reconfigure"} + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "on_supervisor_reconfigure" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {"use_addon": True} + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "addon_already_configured" + # The other entry's add-on config is untouched. + set_addon_options.assert_not_called() + + @pytest.mark.usefixtures("supervisor", "addon_running") async def test_addon_running( hass: HomeAssistant, @@ -4568,7 +5075,6 @@ async def test_reconfigure_migrate_no_addon( assert result["type"] is FlowResultType.ABORT assert result["reason"] == "addon_required" - assert "keep_old_devices" not in entry.data @pytest.mark.usefixtures("mock_sdk_version") @@ -4593,35 +5099,16 @@ async def test_reconfigure_migrate_low_sdk_version( assert result["type"] is FlowResultType.ABORT assert result["reason"] == "migration_low_sdk_version" - assert "keep_old_devices" not in entry.data @pytest.mark.usefixtures("supervisor", "addon_running") @pytest.mark.parametrize( - ( - "form_data", - "new_addon_options", - "restore_server_version_side_effect", - "final_unique_id", - "keep_old_devices", - "device_entry_count", - ), + ("form_data", "new_addon_options"), [ - ( - {CONF_USB_PATH: "/test"}, - {CONF_ADDON_DEVICE: "/test"}, - None, - "3245146787", - False, - 2, - ), + ({CONF_USB_PATH: "/test"}, {CONF_ADDON_DEVICE: "/test"}), ( {CONF_SOCKET_PATH: "esphome://1.2.3.4:1234"}, {CONF_ADDON_SOCKET: "esphome://1.2.3.4:1234"}, - aiohttp.ClientError("Boom"), - "5678", - True, - 4, ), ], ) @@ -4638,10 +5125,6 @@ async def test_reconfigure_migrate_with_addon( get_server_version: AsyncMock, form_data: dict[str, Any], new_addon_options: dict, - restore_server_version_side_effect: Exception | None, - final_unique_id: str, - keep_old_devices: bool, - device_entry_count: int, ) -> None: """Test migration flow with add-on.""" entry = integration @@ -4759,16 +5242,14 @@ async def test_reconfigure_migrate_with_addon( with patch("homeassistant.components.zwave_js.async_ensure_addon_running"): result = await hass.config_entries.flow.async_configure(result["flow_id"]) - assert entry.unique_id == "5678" - get_server_version.side_effect = restore_server_version_side_effect - _set_home_id(get_server_version, 3245146787) + assert entry.unique_id == "3245146787" assert result["type"] is FlowResultType.SHOW_PROGRESS assert result["step_id"] == "restore_nvm" assert client.connect.call_count == 2 await hass.async_block_till_done() - assert client.connect.call_count == 4 + assert client.connect.call_count == 3 assert entry.state is config_entries.ConfigEntryState.LOADED assert client.driver.controller.async_restore_nvm.call_count == 1 assert len(events) == 2 @@ -4783,10 +5264,9 @@ async def test_reconfigure_migrate_with_addon( assert entry.data[CONF_USB_PATH] == new_addon_options.get(CONF_ADDON_DEVICE) assert entry.data[CONF_SOCKET_PATH] == new_addon_options.get(CONF_ADDON_SOCKET) assert entry.data["use_addon"] is True - assert ("keep_old_devices" in entry.data) is keep_old_devices - assert entry.unique_id == final_unique_id + assert entry.unique_id == "3245146787" - assert len(device_registry.devices) == device_entry_count + assert len(device_registry.devices) == 2 controller_device_id_ext = ( f"{controller_device_id}-{controller_node.manufacturer_id}:" f"{controller_node.product_type}:{controller_node.product_id}" @@ -4931,8 +5411,7 @@ async def test_reconfigure_migrate_restore_driver_ready_timeout( assert entry.data["usb_path"] == "/test" assert entry.data["socket_path"] is None assert entry.data["use_addon"] is True - assert "keep_old_devices" in entry.data - assert entry.unique_id == "1234" + assert entry.unique_id == "3245146787" async def test_reconfigure_migrate_backup_failure( @@ -4961,7 +5440,6 @@ async def test_reconfigure_migrate_backup_failure( assert result["type"] is FlowResultType.ABORT assert result["reason"] == "backup_failed" - assert "keep_old_devices" not in entry.data @pytest.mark.usefixtures("backup_nvm") @@ -4996,7 +5474,6 @@ async def test_reconfigure_migrate_backup_file_failure( assert result["type"] is FlowResultType.ABORT assert result["reason"] == "backup_failed" - assert "keep_old_devices" not in entry.data @pytest.mark.usefixtures("supervisor", "addon_running", "backup_nvm") @@ -5062,7 +5539,82 @@ async def test_reconfigure_migrate_start_addon_failure( assert result["type"] is FlowResultType.ABORT assert result["reason"] == "addon_start_failed" - assert "keep_old_devices" not in entry.data + + +@pytest.mark.usefixtures( + "supervisor", "addon_running", "restart_addon", "backup_nvm", "restore_nvm" +) +async def test_reconfigure_migrate_connect_failure( + hass: HomeAssistant, + client: MagicMock, + integration: MockConfigEntry, + set_addon_options: AsyncMock, +) -> None: + """Test the restore step can be retried after a connect failure.""" + entry = integration + hass.config_entries.async_update_entry( + entry, data={**entry.data, "use_addon": True} + ) + + connect_side_effect = client.connect.side_effect + client.connect.side_effect = ConnectionFailed("test_error") + + result = await entry.start_reconfigure_flow(hass) + + assert result["type"] is FlowResultType.MENU + assert result["step_id"] == "reconfigure" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {"next_step_id": "intent_migrate"} + ) + + assert result["type"] is FlowResultType.SHOW_PROGRESS + assert result["step_id"] == "backup_nvm" + + with patch("pathlib.Path.write_bytes"): + await hass.async_block_till_done() + + result = await hass.config_entries.flow.async_configure(result["flow_id"]) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "instruct_unplug" + + result = await hass.config_entries.flow.async_configure(result["flow_id"], {}) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "choose_serial_port" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ + CONF_USB_PATH: "/test", + }, + ) + + assert result["type"] is FlowResultType.SHOW_PROGRESS + assert result["step_id"] == "start_addon" + + await hass.async_block_till_done() + result = await hass.config_entries.flow.async_configure(result["flow_id"]) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "restore_failed" + assert client.driver.controller.async_restore_nvm.call_count == 0 + + client.connect.side_effect = connect_side_effect + + result = await hass.config_entries.flow.async_configure(result["flow_id"], {}) + + assert result["type"] is FlowResultType.SHOW_PROGRESS + assert result["step_id"] == "restore_nvm" + + await hass.async_block_till_done() + + result = await hass.config_entries.flow.async_configure(result["flow_id"]) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "migration_successful" + assert entry.unique_id == "3245146787" @pytest.mark.usefixtures("supervisor", "addon_running", "restart_addon", "backup_nvm") @@ -5158,7 +5710,6 @@ async def test_reconfigure_migrate_restore_failure( hass.config_entries.flow.async_abort(result["flow_id"]) assert len(hass.config_entries.flow.async_progress()) == 0 - assert "keep_old_devices" not in entry.data async def test_get_driver_failure_intent_migrate( @@ -5182,7 +5733,6 @@ async def test_get_driver_failure_intent_migrate( assert result["type"] is FlowResultType.ABORT assert result["reason"] == "config_entry_not_loaded" - assert "keep_old_devices" not in entry.data @pytest.mark.usefixtures("backup_nvm") @@ -6182,15 +6732,14 @@ async def test_addon_rf_region_migrate_network( result = await hass.config_entries.flow.async_configure(result["flow_id"]) - assert entry.unique_id == "5678" - _set_home_id(get_server_version, 3245146787) + assert entry.unique_id == "3245146787" assert result["type"] is FlowResultType.SHOW_PROGRESS assert result["step_id"] == "restore_nvm" assert client.connect.call_count == 2 await hass.async_block_till_done() - assert client.connect.call_count == 4 + assert client.connect.call_count == 3 assert entry.state is config_entries.ConfigEntryState.LOADED assert client.driver.controller.async_restore_nvm.call_count == 1 assert len(events) == 2 diff --git a/tests/components/zwave_js/test_discovery.py b/tests/components/zwave_js/test_discovery.py index 8763a8a49f6f..0dee5ff7b6f9 100644 --- a/tests/components/zwave_js/test_discovery.py +++ b/tests/components/zwave_js/test_discovery.py @@ -139,6 +139,17 @@ async def test_inovelli_lzw36( assert state +async def test_leviton_vrf01( + hass: HomeAssistant, client, leviton_vrf01, integration +) -> None: + """Test Leviton VRF01 multilevel switch is discovered as a fan, not a light.""" + node = leviton_vrf01 + assert node.device_class.specific.label == "Multilevel Scene Switch" + + assert hass.states.get("fan.fan") + assert not hass.states.get("light.fan") + + async def test_vision_security_zl7432( hass: HomeAssistant, client, vision_security_zl7432, integration ) -> None: diff --git a/tests/components/zwave_js/test_fan.py b/tests/components/zwave_js/test_fan.py index 49bac6d48262..01ad16c4b0a7 100644 --- a/tests/components/zwave_js/test_fan.py +++ b/tests/components/zwave_js/test_fan.py @@ -719,6 +719,76 @@ async def test_leviton_zw4sf_fan( assert state.attributes[ATTR_PRESET_MODES] == [] +async def test_leviton_vrf01_fan( + hass: HomeAssistant, client, leviton_vrf01, integration +) -> None: + """Test a Leviton VRF01 fan with 3 fixed speeds.""" + node = leviton_vrf01 + node_id = node.node_id + entity_id = "fan.fan" + + async def get_zwave_speed_from_percentage(percentage): + """Set the fan to a particular percentage and get the resulting Zwave speed.""" + client.async_send_command.reset_mock() + + await hass.services.async_call( + "fan", + "turn_on", + {"entity_id": entity_id, "percentage": percentage}, + blocking=True, + ) + + assert len(client.async_send_command.call_args_list) == 1 + args = client.async_send_command.call_args[0][0] + assert args["command"] == "node.set_value" + assert args["nodeId"] == node_id + return args["value"] + + async def get_percentage_from_zwave_speed(zwave_speed): + """Set the underlying device speed and get the resulting percentage.""" + event = Event( + type="value updated", + data={ + "source": "node", + "event": "value updated", + "nodeId": node_id, + "args": { + "commandClassName": "Multilevel Switch", + "commandClass": 38, + "endpoint": 0, + "property": "currentValue", + "newValue": zwave_speed, + "prevValue": 0, + "propertyName": "currentValue", + }, + }, + ) + node.receive_event(event) + state = hass.states.get(entity_id) + return state.attributes[ATTR_PERCENTAGE] + + # This device has the speeds: + # 1 = 1-32, 2 = 33-66, 3 = 67-99 + percentages_to_zwave_speeds = [ + [[0], [0]], + [range(1, 34), range(1, 33)], + [range(34, 67), range(33, 67)], + [range(67, 101), range(67, 100)], + ] + + for percentages, zwave_speeds in percentages_to_zwave_speeds: + for percentage in percentages: + actual_zwave_speed = await get_zwave_speed_from_percentage(percentage) + assert actual_zwave_speed in zwave_speeds + for zwave_speed in zwave_speeds: + actual_percentage = await get_percentage_from_zwave_speed(zwave_speed) + assert actual_percentage in percentages + + state = hass.states.get(entity_id) + assert state.attributes[ATTR_PERCENTAGE_STEP] == pytest.approx(100 / 3, rel=1e-3) + assert state.attributes[ATTR_PRESET_MODES] == [] + + async def test_enbrighten_55258_zw4002_fan( hass: HomeAssistant, client, enbrighten_55258_zw4002, integration ) -> None: diff --git a/tests/components/zwave_js/test_logbook.py b/tests/components/zwave_js/test_logbook.py index e56be4e8ea0b..0606529f0bcc 100644 --- a/tests/components/zwave_js/test_logbook.py +++ b/tests/components/zwave_js/test_logbook.py @@ -1,8 +1,10 @@ """The tests for Z-Wave JS logbook.""" +import pytest from zwave_js_server.const import CommandClass from homeassistant.components.zwave_js.const import ( + DOMAIN, ZWAVE_JS_NOTIFICATION_EVENT, ZWAVE_JS_VALUE_NOTIFICATION_EVENT, ) @@ -11,6 +13,7 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr from homeassistant.setup import async_setup_component +from tests.common import MockConfigEntry from tests.components.logbook.common import MockRow, mock_humanify @@ -154,3 +157,84 @@ async def test_humanifying_zwave_js_value_notification_event( events[0]["message"] == "fired Scene Activation CC 'value notification' event for 'Scene ID': '001'" ) + + +@pytest.fixture(name="nameless_device_id") +def nameless_device_id_fixture( + request: pytest.FixtureRequest, + device_registry: dr.DeviceRegistry, + integration: MockConfigEntry, +) -> str: + """Return the id of a device that humanify resolves to an empty name.""" + if not request.param: + # A device id that is not in the registry, e.g. a removed device + return "removed_device_id" + # A registered device with neither a user set name nor a device name. A new + # device defaults its name to the config entry title, so clear it afterwards. + device = device_registry.async_get_or_create( + config_entry_id=integration.entry_id, + identifiers={(DOMAIN, "nameless-node")}, + ) + device = device_registry.async_update_device(device.id, name=None) + assert device is not None + assert device.name_by_user is None + assert device.name is None + return device.id + + +@pytest.mark.parametrize( + "nameless_device_id", + [ + pytest.param(False, id="removed_device"), + pytest.param(True, id="unnamed_device"), + ], + indirect=True, +) +async def test_humanifying_zwave_js_events_no_device_name( + hass: HomeAssistant, + nameless_device_id: str, +) -> None: + """Test humanifying Z-Wave JS events when the device name is unavailable.""" + hass.config.components.add("recorder") + assert await async_setup_component(hass, "logbook", {}) + await hass.async_block_till_done() + + events = mock_humanify( + hass, + [ + MockRow( + ZWAVE_JS_NOTIFICATION_EVENT, + { + "device_id": nameless_device_id, + "command_class": CommandClass.NOTIFICATION.value, + "command_class_name": "Notification", + "label": "label", + "event_label": "event_label", + }, + ), + MockRow( + ZWAVE_JS_VALUE_NOTIFICATION_EVENT, + { + "device_id": nameless_device_id, + "command_class": CommandClass.SCENE_ACTIVATION.value, + "command_class_name": "Scene Activation", + "label": "Scene ID", + "value": "001", + }, + ), + ], + ) + + assert events[0]["name"] == "" + assert events[0]["domain"] == "zwave_js" + assert ( + events[0]["message"] + == "fired Notification CC 'notification' event 'label': 'event_label'" + ) + + assert events[1]["name"] == "" + assert events[1]["domain"] == "zwave_js" + assert ( + events[1]["message"] + == "fired Scene Activation CC 'value notification' event for 'Scene ID': '001'" + ) diff --git a/tests/components/zwave_js/test_repairs.py b/tests/components/zwave_js/test_repairs.py index 5dc19cd2f980..101fbc3436c1 100644 --- a/tests/components/zwave_js/test_repairs.py +++ b/tests/components/zwave_js/test_repairs.py @@ -9,7 +9,6 @@ from zwave_js_server.event import Event from zwave_js_server.model.node import Node, NodeDataType from homeassistant.components.zwave_js import DOMAIN -from homeassistant.components.zwave_js.const import CONF_KEEP_OLD_DEVICES from homeassistant.components.zwave_js.helpers import get_device_id from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr, issue_registry as ir @@ -342,8 +341,6 @@ async def test_migrate_unique_id( await hass.config_entries.async_setup(config_entry.entry_id) - assert CONF_KEEP_OLD_DEVICES in config_entry.data - assert config_entry.data[CONF_KEEP_OLD_DEVICES] is True stored_devices = dr.async_entries_for_config_entry( device_registry, config_entry.entry_id ) diff --git a/tests/components/zwave_js/test_services.py b/tests/components/zwave_js/test_services.py index bfb7dd8bf5b4..f0ffd0bcaa00 100644 --- a/tests/components/zwave_js/test_services.py +++ b/tests/components/zwave_js/test_services.py @@ -302,6 +302,7 @@ async def test_set_config_parameter( mode=None, object_id=None, order=None, + context=None, ) await hass.services.async_call( DOMAIN, @@ -800,6 +801,7 @@ async def test_bulk_set_config_parameters( mode=None, object_id=None, order=None, + context=None, ) await hass.services.async_call( DOMAIN, @@ -943,6 +945,7 @@ async def test_refresh_value( mode=None, object_id=None, order=None, + context=None, ) client.async_send_command.return_value = {"result": 2} await hass.services.async_call( @@ -1075,6 +1078,7 @@ async def test_set_value( mode=None, object_id=None, order=None, + context=None, ) await hass.services.async_call( DOMAIN, @@ -1385,6 +1389,7 @@ async def test_multicast_set_value( mode=None, object_id=None, order=None, + context=None, ) await hass.services.async_call( DOMAIN, @@ -1760,6 +1765,7 @@ async def test_ping( mode=None, object_id=None, order=None, + context=None, ) await hass.services.async_call( DOMAIN, diff --git a/tests/conftest.py b/tests/conftest.py index a65030266e4b..665d019330a2 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -959,22 +959,31 @@ def hass_ws_client( """Websocket client fixture connected to websocket server.""" async def create_client( - hass: HomeAssistant = hass, access_token: str | None = hass_access_token + hass: HomeAssistant = hass, + access_token: str | None = hass_access_token, + supervisor_unix_socket: bool = False, ) -> MockHAClientWebSocket: - """Create a websocket client.""" + """Create a client, skipping token auth for Supervisor Unix sockets.""" assert await async_setup_component(hass, "websocket_api", {}) client = await aiohttp_client(hass.http.app) websocket = await client.ws_connect(URL) auth_resp = await websocket.receive_json() - assert auth_resp["type"] == TYPE_AUTH_REQUIRED - - if access_token is None: - await websocket.send_json({"type": TYPE_AUTH, "access_token": "incorrect"}) + if supervisor_unix_socket: + assert auth_resp["type"] == TYPE_AUTH_OK else: - await websocket.send_json({"type": TYPE_AUTH, "access_token": access_token}) + assert auth_resp["type"] == TYPE_AUTH_REQUIRED - auth_ok = await websocket.receive_json() - assert auth_ok["type"] == TYPE_AUTH_OK + if access_token is None: + await websocket.send_json( + {"type": TYPE_AUTH, "access_token": "incorrect"} + ) + else: + await websocket.send_json( + {"type": TYPE_AUTH, "access_token": access_token} + ) + + auth_ok = await websocket.receive_json() + assert auth_ok["type"] == TYPE_AUTH_OK def _get_next_id() -> Generator[int]: i = 0 diff --git a/tests/helpers/template/extensions/test_devices.py b/tests/helpers/template/extensions/test_devices.py index 3c797b5ad182..c13c93ccb306 100644 --- a/tests/helpers/template/extensions/test_devices.py +++ b/tests/helpers/template/extensions/test_devices.py @@ -38,7 +38,7 @@ async def test_device_entities( assert info.rate_limit is None # Test device with single entity, which has no state - entity_registry.async_get_or_create( + entity_entry = entity_registry.async_get_or_create( "light", "hue", "5678", @@ -46,7 +46,7 @@ async def test_device_entities( device_id=device_entry.id, ) info = render_to_info(hass, f"{{{{ device_entities('{device_entry.id}') }}}}") - assert_result_info(info, ["light.hue_5678"], []) + assert_result_info(info, [entity_entry.entity_id], []) assert info.rate_limit is None info = render_to_info( hass, @@ -55,11 +55,11 @@ async def test_device_entities( "| sort(attribute='entity_id') | map(attribute='entity_id') | join(', ') }}" ), ) - assert_result_info(info, "", ["light.hue_5678"]) + assert_result_info(info, "", [entity_entry.entity_id]) assert info.rate_limit is None # Test device with single entity, with state - hass.states.async_set("light.hue_5678", "happy") + hass.states.async_set(entity_entry.entity_id, "happy") info = render_to_info( hass, ( @@ -67,20 +67,20 @@ async def test_device_entities( "| sort(attribute='entity_id') | map(attribute='entity_id') | join(', ') }}" ), ) - assert_result_info(info, "light.hue_5678", ["light.hue_5678"]) + assert_result_info(info, entity_entry.entity_id, [entity_entry.entity_id]) assert info.rate_limit is None # Test device with multiple entities, which have a state - entity_registry.async_get_or_create( + entity_entry_2 = entity_registry.async_get_or_create( "light", "hue", "ABCD", config_entry=config_entry, device_id=device_entry.id, ) - hass.states.async_set("light.hue_abcd", "camper") + hass.states.async_set(entity_entry_2.entity_id, "camper") info = render_to_info(hass, f"{{{{ device_entities('{device_entry.id}') }}}}") - assert_result_info(info, ["light.hue_5678", "light.hue_abcd"], []) + assert_result_info(info, [entity_entry.entity_id, entity_entry_2.entity_id], []) assert info.rate_limit is None info = render_to_info( hass, @@ -90,7 +90,9 @@ async def test_device_entities( ), ) assert_result_info( - info, "light.hue_5678, light.hue_abcd", ["light.hue_5678", "light.hue_abcd"] + info, + f"{entity_entry.entity_id}, {entity_entry_2.entity_id}", + [entity_entry.entity_id, entity_entry_2.entity_id], ) assert info.rate_limit is None diff --git a/tests/helpers/template/extensions/test_state.py b/tests/helpers/template/extensions/test_state.py index 7ca099dff527..1cf6afb684de 100644 --- a/tests/helpers/template/extensions/test_state.py +++ b/tests/helpers/template/extensions/test_state.py @@ -740,6 +740,7 @@ async def test_expand(hass: HomeAssistant) -> None: mode=None, object_id=None, order=None, + context=None, ) info = render_to_info( @@ -800,6 +801,7 @@ async def test_expand(hass: HomeAssistant) -> None: mode=None, object_id=None, order=None, + context=None, ) info = render_to_info( @@ -1301,6 +1303,7 @@ async def test_closest_function_home_vs_group_entity_id(hass: HomeAssistant) -> mode=None, object_id=None, order=None, + context=None, ) info = render_to_info(hass, '{{ closest("group.location_group").entity_id }}') @@ -1338,6 +1341,7 @@ async def test_closest_function_home_vs_group_state(hass: HomeAssistant) -> None mode=None, object_id=None, order=None, + context=None, ) info = render_to_info(hass, '{{ closest("group.location_group").entity_id }}') diff --git a/tests/helpers/test_device.py b/tests/helpers/test_device.py index 6c8dbd9d3f5c..c5cc48484b94 100644 --- a/tests/helpers/test_device.py +++ b/tests/helpers/test_device.py @@ -44,7 +44,7 @@ async def test_entity_id_to_device_device_id( device_id=device.id, ) await hass.async_block_till_done() - assert entity_registry.async_get("sensor.test_source") is not None + assert entity_registry.async_get(entity.entity_id) is not None device_id = async_entity_id_to_device_id( hass, @@ -130,7 +130,7 @@ async def test_device_info_to_link( device_id=device.id, ) await hass.async_block_till_done() - assert entity_registry.async_get("sensor.test_source") is not None + assert entity_registry.async_get(source_entity.entity_id) is not None # No link device_info is returned, even for an existing entity and device with patch("homeassistant.helpers.device.report_usage") as report_usage: diff --git a/tests/helpers/test_device_registry.py b/tests/helpers/test_device_registry.py index e633732f8857..2f732a57482c 100644 --- a/tests/helpers/test_device_registry.py +++ b/tests/helpers/test_device_registry.py @@ -48,10 +48,10 @@ def _downgrade_device_registry_deprecation_reports( ) -> Generator[None]: """Keep the deprecated device registry APIs from raising in tests. - async_get_device, the config entry parameters and merge_connections/merge_identifiers - parameters of async_update_device, and via_device on async_get_or_create are - deprecated and raise for core and core integration callers, disable them here so we - can run tests without triggering deprecation errors. + async_get_device, async_is_composite_device_id, the config entry parameters and + merge_connections/merge_identifiers parameters of async_update_device, and via_device + on async_get_or_create are deprecated and raise for core and core integration callers, + disable them here so we can run tests without triggering deprecation errors. Tests which use `mock_integration_frame` will not be affected by this fixture, so they can test the deprecation. @@ -78,7 +78,9 @@ def _get_device_for_config_entry( connections: set[tuple[str, str]] | None = None, ) -> dr.DeviceEntry | None: """Return the device for a config entry matching identifiers or connections.""" - for device in device_registry.devices.get_entries(identifiers, connections): + for device in device_registry.async_get_devices( + identifiers=identifiers, connections=connections + ): if device.config_entry_id == config_entry_id: return device return None @@ -451,7 +453,7 @@ async def test_loading_from_storage( await dr.async_load(hass) registry = dr.async_get(hass) assert len(registry.devices) == 1 - assert len(registry.deleted_devices) == 1 + assert len(registry._deleted_devices) == 1 # A stored child device is loaded, with disabled_by "device" restored to the enum loaded_child = registry.async_get("childdeviceid", include_main_devices=False) @@ -460,7 +462,7 @@ async def test_loading_from_storage( assert loaded_child.disabled_by is dr.DeviceEntryDisabler.DEVICE assert loaded_child.identifiers == {("test", "strip_outlet_1")} - assert registry.deleted_devices["bcdefghijklmn"] == dr.DeletedDeviceEntry( + assert registry._deleted_devices["bcdefghijklmn"] == dr.DeletedDeviceEntry( area_id="12345A", config_entry_id=mock_config_entry.entry_id, config_subentry_id=None, @@ -602,7 +604,7 @@ async def test_migration_from_1_1( ) assert entry.id == "abcdefghijklm" - deleted_entry = registry.deleted_devices["deletedid"] + deleted_entry = registry._deleted_devices["deletedid"] assert deleted_entry.disabled_by is UNDEFINED # Update to trigger a store @@ -1667,7 +1669,7 @@ async def test_migration_from_1_10( identifiers={("serial", "123456ABCDEF")}, ) assert entry.id == "abcdefghijklm" - deleted_entry = registry.deleted_devices.get_entry( + deleted_entry = registry._deleted_devices.get_entry( connections=set(), identifiers={("serial", "123456ABCDAB")}, ) @@ -1810,7 +1812,7 @@ async def test_migration_from_1_11( identifiers={("serial", "123456ABCDEF")}, ) assert entry.id == "abcdefghijklm" - deleted_entry = registry.deleted_devices.get_entry( + deleted_entry = registry._deleted_devices.get_entry( connections=set(), identifiers={("serial", "123456ABCDAB")}, ) @@ -2034,7 +2036,7 @@ async def test_migration_from_1_12( assert single.has_composite_identifiers is False # The composite spanning two config entries is split into one device per config entry - assert "composite0000000000000000000000" not in registry.devices + assert "composite0000000000000000000000" not in registry._devices entry_splits = registry.async_get_devices_for_composite_device_id( "composite0000000000000000000000" ) @@ -2063,7 +2065,7 @@ async def test_migration_from_1_12( # on one subentry - preferring a real subentry over the main entry (None) - rather # than split into duplicate devices sharing the same identifiers/connections. It # keeps its id and gains no composite bookkeeping. - assert "subentries00000000000000000000" in registry.devices + assert "subentries00000000000000000000" in registry._devices assert ( registry.async_get_devices_for_composite_device_id( "subentries00000000000000000000" @@ -2304,7 +2306,7 @@ async def test_migration_clears_composite_via_device_self_reference( await dr.async_load(hass) registry = dr.async_get(hass) - splits = registry.devices.get_devices_for_composite_device_id(composite_id) + splits = registry._devices.get_devices_for_composite_device_id(composite_id) assert len(splits) == 2 assert all(split.via_device_id is None for split in splits) @@ -2489,7 +2491,7 @@ async def test_async_get_device_returns_first_match_for_ambiguous_lookup( match = device_registry.async_get_device(identifiers={("test", "shared")}) # A real registry device (the first match), not a synthesized composite assert match is device_1 - assert match.id in device_registry.devices + assert match.id in device_registry._devices assert match.config_entries == {entry_1.entry_id} @@ -2736,17 +2738,17 @@ async def test_async_remove_device_fans_out_to_migration_composite( ) old_id = "composite00000000000000000000ab" # Simulate a migration split: both devices carry the pre-migration composite id - device_registry.devices[device_1.id] = attr.evolve( + device_registry._devices[device_1.id] = attr.evolve( device_1, composite_device_id=old_id ) - device_registry.devices[device_2.id] = attr.evolve( + device_registry._devices[device_2.id] = attr.evolve( device_2, composite_device_id=old_id ) device_registry.async_remove_device(old_id) - assert device_1.id not in device_registry.devices - assert device_2.id not in device_registry.devices + assert device_1.id not in device_registry._devices + assert device_2.id not in device_registry._devices async def test_async_update_device_fans_out_to_migration_composite( @@ -2765,10 +2767,10 @@ async def test_async_update_device_fans_out_to_migration_composite( ) old_id = "composite00000000000000000000ab" # Simulate a migration split: both devices carry the pre-migration composite id - device_registry.devices[device_1.id] = attr.evolve( + device_registry._devices[device_1.id] = attr.evolve( device_1, composite_device_id=old_id ) - device_registry.devices[device_2.id] = attr.evolve( + device_registry._devices[device_2.id] = attr.evolve( device_2, composite_device_id=old_id ) @@ -2788,7 +2790,7 @@ async def test_get_entry_by_connection_without_config_entry_scope( device = device_registry.async_get_or_create( config_entry_id=entry.entry_id, connections={connection} ) - assert device_registry.devices.get_entry(connections={connection}) is device + assert device_registry._devices.get_entry(connections={connection}) is device async def test_update_unknown_device_id_raises( @@ -2818,7 +2820,7 @@ async def test_cleanup_removes_device_referencing_missing_config_entry( with patch.object(hass.config_entries, "async_entry_ids", return_value=[]): dr.async_cleanup(hass, device_registry, entity_registry) - assert device.id not in device_registry.devices + assert device.id not in device_registry._devices async def test_clear_config_entry_removes_device_with_pending_move( @@ -2840,7 +2842,7 @@ async def test_clear_config_entry_removes_device_with_pending_move( device_registry.async_clear_config_entry(entry_1.entry_id) - assert device.id not in device_registry.devices + assert device.id not in device_registry._devices assert device_registry.async_get_device(identifiers={("test", "1")}) is None @@ -2872,7 +2874,7 @@ async def test_clear_config_entry_clears_pending_move_targeting_it( device.id, remove_config_entry_id=entry_1.entry_id ) assert result is None - assert device.id not in device_registry.devices + assert device.id not in device_registry._devices async def test_move_to_config_entry_clears_target_entry_deleted_device( @@ -2899,7 +2901,7 @@ async def test_move_to_config_entry_clears_target_entry_deleted_device( # Leave a deleted device owned by entry_b with the shared identity device_registry.async_remove_device(device_b.id) - assert device_b.id in device_registry.deleted_devices + assert device_b.id in device_registry._deleted_devices # Move device_a into entry_b, retaining its identity device_registry.async_update_device( @@ -2908,7 +2910,7 @@ async def test_move_to_config_entry_clears_target_entry_deleted_device( assert device_registry.async_get(device_a.id).config_entry_id == entry_b.entry_id # The deleted device entry_b held for the same identity is cleared, not left immortal - assert device_b.id not in device_registry.deleted_devices + assert device_b.id not in device_registry._deleted_devices async def test_get_or_create_via_device_and_via_device_id_raises_cleanly( @@ -2968,7 +2970,7 @@ async def test_add_current_config_entry_is_noop( ) assert result is None - assert device.id not in device_registry.devices + assert device.id not in device_registry._devices @pytest.mark.parametrize( @@ -2999,7 +3001,7 @@ async def test_reregister_restores_orphan( # Removing the config entry orphans the deleted device (config_entry_id=None) device_registry.async_clear_config_entry(entry.entry_id, clear_domain) - orphan = device_registry.deleted_devices[device.id] + orphan = device_registry._deleted_devices[device.id] assert orphan.config_entry_id is None assert orphan.domain == "light" @@ -3030,7 +3032,7 @@ async def test_orphan_not_restored_for_other_domain( config_entry_id=entry.entry_id, identifiers={("light", "1")} ) device_registry.async_clear_config_entry(entry.entry_id, entry.domain) - assert device_registry.deleted_devices[device.id].domain == "light" + assert device_registry._deleted_devices[device.id].domain == "light" # A different integration registering a device with the same identifiers gets a fresh # device, and the orphan is left intact for its own integration to restore later @@ -3040,7 +3042,7 @@ async def test_orphan_not_restored_for_other_domain( config_entry_id=other_entry.entry_id, identifiers={("light", "1")} ) assert fresh.id != device.id - assert device.id in device_registry.deleted_devices + assert device.id in device_registry._deleted_devices async def test_orphaning_replaces_colliding_same_domain_orphan( @@ -3069,12 +3071,12 @@ async def test_orphaning_replaces_colliding_same_domain_orphan( ) device_registry.async_clear_config_entry(entry_1.entry_id, entry_1.domain) - assert device_1.id in device_registry.deleted_devices + assert device_1.id in device_registry._deleted_devices device_registry.async_clear_config_entry(entry_2.entry_id, entry_2.domain) # The newer orphan replaces the stale one it collides with on the shared connection - assert device_1.id not in device_registry.deleted_devices - assert device_2.id in device_registry.deleted_devices + assert device_1.id not in device_registry._deleted_devices + assert device_2.id in device_registry._deleted_devices # Re-adding under the same domain restores the surviving orphan entry_3 = MockConfigEntry(domain="hue") @@ -3102,7 +3104,7 @@ async def test_orphaned_domain_survives_store_round_trip( await flush_store(device_registry._store) await registry2.async_load() - assert registry2.deleted_devices[device.id].domain == "hue" + assert registry2._deleted_devices[device.id].domain == "hue" async def test_orphan_keeps_domain_when_config_entry_removed( @@ -3124,7 +3126,7 @@ async def test_orphan_keeps_domain_when_config_entry_removed( await hass.config_entries.async_remove(entry.entry_id) - orphan = device_registry.deleted_devices[device.id] + orphan = device_registry._deleted_devices[device.id] assert orphan.config_entry_id is None assert orphan.domain == "hue" @@ -3187,7 +3189,7 @@ async def test_domainless_orphan_not_restored( # orphans over without one) with patch.object(hass.config_entries, "async_get_entry", return_value=None): device_registry.async_clear_config_entry(entry_1.entry_id) - assert device_registry.deleted_devices[device_1.id].domain is None + assert device_registry._deleted_devices[device_1.id].domain is None # Re-registering the shared identifier does not restore the domain-less orphan entry_2 = MockConfigEntry(domain="hue") @@ -3197,7 +3199,7 @@ async def test_domainless_orphan_not_restored( ) assert fresh.id != device_1.id # The un-restored orphan lingers until the periodic purge - assert device_1.id in device_registry.deleted_devices + assert device_1.id in device_registry._deleted_devices async def test_clear_config_subentry_removes_device_with_pending_move( @@ -3231,7 +3233,7 @@ async def test_clear_config_subentry_removes_device_with_pending_move( device_registry.async_clear_config_subentry(entry_1.entry_id, "mock-subentry-id-1") - assert device.id not in device_registry.devices + assert device.id not in device_registry._devices assert device_registry.async_get_device(identifiers={("test", "1")}) is None @@ -3277,7 +3279,7 @@ async def test_clear_config_subentry_clears_pending_move_targeting_it( device.id, remove_config_entry_id=entry_1.entry_id ) assert result is None - assert device.id not in device_registry.devices + assert device.id not in device_registry._devices async def test_async_is_composite_device_id( @@ -3296,10 +3298,10 @@ async def test_async_is_composite_device_id( ) old_id = "composite00000000000000000000ab" # Simulate a migration split: both devices carry the pre-migration composite id - device_registry.devices[device_1.id] = attr.evolve( + device_registry._devices[device_1.id] = attr.evolve( device_1, composite_device_id=old_id ) - device_registry.devices[device_2.id] = attr.evolve( + device_registry._devices[device_2.id] = attr.evolve( device_2, composite_device_id=old_id ) @@ -3309,6 +3311,153 @@ async def test_async_is_composite_device_id( assert device_registry.async_is_composite_device_id("unknown_id") is None +@pytest.mark.parametrize( + ("integration_frame_path", "expectation", "expected_log"), + [ + pytest.param( + "homeassistant/test_core", pytest.raises(RuntimeError), 0, id="core" + ), + pytest.param( + "homeassistant/components/test_integration", + pytest.raises(RuntimeError), + 1, + id="core integration", + ), + pytest.param( + "custom_components/test_integration", + nullcontext(), + 1, + id="custom integration", + ), + ], +) +@pytest.mark.usefixtures("mock_integration_frame") +async def test_async_is_composite_device_id_deprecated( + device_registry: dr.DeviceRegistry, + caplog: pytest.LogCaptureFixture, + expectation: AbstractContextManager, + expected_log: int, +) -> None: + """Test async_is_composite_device_id is deprecated. + + It logs for custom integrations and raises for core and core integrations. Use + async_get with include_composite_devices=False instead. + """ + what = "calls `device_registry.async_is_composite_device_id`" + with patch.object(frame, "_REPORTED_INTEGRATIONS", set()), expectation: + device_registry.async_is_composite_device_id("some_device_id") + + assert caplog.text.count(what) == expected_log + + +async def test_async_get_include_composite_devices( + hass: HomeAssistant, device_registry: dr.DeviceRegistry +) -> None: + """Test async_get gates main, child and composite devices independently.""" + entry_1 = MockConfigEntry(domain="test") + entry_1.add_to_hass(hass) + entry_2 = MockConfigEntry(domain="test") + entry_2.add_to_hass(hass) + device_1 = device_registry.async_get_or_create( + config_entry_id=entry_1.entry_id, identifiers={("test", "1")} + ) + device_2 = device_registry.async_get_or_create( + config_entry_id=entry_2.entry_id, identifiers={("test", "2")} + ) + child_device = device_registry.async_get_or_create_child( + config_entry_id=entry_1.entry_id, + identifiers={("test", "child")}, + parent_device_id=device_1.id, + name="Child", + ) + old_id = "composite00000000000000000000ab" + # Simulate a migration split: both devices carry the pre-migration composite id + device_registry._devices[device_1.id] = attr.evolve( + device_1, composite_device_id=old_id + ) + device_registry._devices[device_2.id] = attr.evolve( + device_2, composite_device_id=old_id + ) + + # By default a composite id resolves to the synthesized composite + composite = device_registry.async_get(old_id) + assert composite is not None + assert composite.id == old_id + assert device_registry.async_get(old_id, include_child_devices=False) == composite + + # include_composite_devices=False resolves a composite id to None, matching + # `old_id in device_registry._devices`, which is composite-blind + assert old_id not in device_registry._devices + assert device_registry.async_get(old_id, include_composite_devices=False) is None + assert ( + device_registry.async_get( + old_id, include_child_devices=False, include_composite_devices=False + ) + is None + ) + + # A registered main device resolves regardless of include_composite_devices + assert ( + device_registry.async_get(device_1.id, include_composite_devices=False).id + == device_1.id + ) + assert ( + device_registry.async_get( + device_1.id, include_child_devices=False, include_composite_devices=False + ).id + == device_1.id + ) + + # An unknown id is None with or without the flag + assert ( + device_registry.async_get("unknown_id", include_composite_devices=False) is None + ) + + # include_main_devices=False, include_child_devices=False resolves only a composite + assert ( + device_registry.async_get( + old_id, include_main_devices=False, include_child_devices=False + ) + == composite + ) + # a registered main device, a child device and an unknown id resolve to None + assert ( + device_registry.async_get( + device_1.id, include_main_devices=False, include_child_devices=False + ) + is None + ) + assert ( + device_registry.async_get( + child_device.id, include_main_devices=False, include_child_devices=False + ) + is None + ) + assert ( + device_registry.async_get( + "unknown_id", include_main_devices=False, include_child_devices=False + ) + is None + ) + + # include_main_devices=False, include_composite_devices=False resolves only a child: + # a composite id resolves to None, a child device still resolves + assert ( + device_registry.async_get( + old_id, include_main_devices=False, include_composite_devices=False + ) + is None + ) + assert ( + device_registry.async_get( + child_device.id, + include_main_devices=False, + include_composite_devices=False, + ) + == child_device + ) + + @pytest.mark.parametrize("load_registries", [False]) async def test_async_get_device_composite_reuses_pre_migration_id( hass: HomeAssistant, hass_storage: dict[str, Any] @@ -3372,12 +3521,12 @@ async def test_async_get_device_composite_reuses_pre_migration_id( ) assert composite is not None assert composite.id == "composite00000000000000000000" - assert composite.id not in registry.devices + assert composite.id not in registry._devices # It is the same composite async_get resolves for the old id assert registry.async_get("composite00000000000000000000").id == composite.id # An identifier lookup still domain-resolves to the single owning split (real id) resolved = registry.async_get_device(identifiers={("domain_a", "1")}) - assert resolved.id in registry.devices + assert resolved.id in registry._devices assert resolved.config_entry_id == entry_a.entry_id @@ -3416,10 +3565,10 @@ async def test_async_update_device_composite_drops_identity_args( ) old_id = "composite00000000000000000000ab" # Simulate a migration split: both devices carry the pre-migration composite id - device_registry.devices[device_1.id] = attr.evolve( + device_registry._devices[device_1.id] = attr.evolve( device_1, composite_device_id=old_id ) - device_registry.devices[device_2.id] = attr.evolve( + device_registry._devices[device_2.id] = attr.evolve( device_2, composite_device_id=old_id ) @@ -3451,10 +3600,10 @@ async def test_async_update_device_composite_drops_only_disallowed_args( ) old_id = "composite00000000000000000000ab" # Simulate a migration split: both devices carry the pre-migration composite id - device_registry.devices[device_1.id] = attr.evolve( + device_registry._devices[device_1.id] = attr.evolve( device_1, composite_device_id=old_id ) - device_registry.devices[device_2.id] = attr.evolve( + device_registry._devices[device_2.id] = attr.evolve( device_2, composite_device_id=old_id ) @@ -3500,10 +3649,10 @@ async def test_async_update_device_composite_drops_move_args( config_entry_id=entry_2.entry_id, identifiers={("test", "2")} ) old_id = "composite00000000000000000000ab" - device_registry.devices[device_1.id] = attr.evolve( + device_registry._devices[device_1.id] = attr.evolve( device_1, composite_device_id=old_id ) - device_registry.devices[device_2.id] = attr.evolve( + device_registry._devices[device_2.id] = attr.evolve( device_2, composite_device_id=old_id ) @@ -3592,7 +3741,7 @@ async def test_migration_drops_device_without_config_entries( # The orphan device was dropped, the normal device kept assert registry.async_get("orphan00000000000000000000000") is None - assert "orphan00000000000000000000000" not in registry.devices + assert "orphan00000000000000000000000" not in registry._devices kept = registry.async_get("keptdevice0000000000000000000") assert kept is not None assert kept.config_entry_id == mock_config_entry.entry_id @@ -3647,9 +3796,9 @@ async def test_migration_splits_deleted_device_with_multiple_config_entries( registry = dr.async_get(hass) # Split into one deleted device per config entry, each keeping identity/customizations - assert len(registry.deleted_devices) == 2 - assert "deletedcomposite0000000000000" not in registry.deleted_devices - by_entry = {d.config_entry_id: d for d in registry.deleted_devices.values()} + assert len(registry._deleted_devices) == 2 + assert "deletedcomposite0000000000000" not in registry._deleted_devices + by_entry = {d.config_entry_id: d for d in registry._deleted_devices.values()} assert set(by_entry) == {entry_a.entry_id, entry_b.entry_id} for deleted in by_entry.values(): assert deleted.identifiers == {("domain_a", "1")} @@ -3734,21 +3883,21 @@ async def test_deleted_device_removing_config_entries( device_registry.async_remove_device(entry.id) device_registry.async_remove_device(entry2.id) assert len(device_registry.devices) == 0 - assert len(device_registry.deleted_devices) == 2 + assert len(device_registry._deleted_devices) == 2 device_registry.async_clear_config_entry(config_entry_1.entry_id) # Deleted devices are kept but orphaned (config entry cleared) so they can be purged - assert len(device_registry.deleted_devices) == 2 - assert device_registry.deleted_devices[entry.id].config_entry_id is None + assert len(device_registry._deleted_devices) == 2 + assert device_registry._deleted_devices[entry.id].config_entry_id is None assert ( - device_registry.deleted_devices[entry2.id].config_entry_id + device_registry._deleted_devices[entry2.id].config_entry_id == config_entry_2.entry_id ) device_registry.async_clear_config_entry(config_entry_2.entry_id) - assert len(device_registry.deleted_devices) == 2 - assert device_registry.deleted_devices[entry2.id].config_entry_id is None + assert len(device_registry._deleted_devices) == 2 + assert device_registry._deleted_devices[entry2.id].config_entry_id is None async def test_removing_config_subentries( @@ -3841,17 +3990,17 @@ async def test_deleted_device_removing_config_subentries( device_registry.async_remove_device(entry.id) device_registry.async_remove_device(entry2.id) - assert len(device_registry.deleted_devices) == 2 + assert len(device_registry._deleted_devices) == 2 device_registry.async_clear_config_subentry( config_entry.entry_id, "mock-subentry-id-1" ) # Only the deleted device on the cleared subentry is orphaned - assert len(device_registry.deleted_devices) == 2 - assert device_registry.deleted_devices[entry.id].config_entry_id is None + assert len(device_registry._deleted_devices) == 2 + assert device_registry._deleted_devices[entry.id].config_entry_id is None assert ( - device_registry.deleted_devices[entry2.id].config_entry_id + device_registry._deleted_devices[entry2.id].config_entry_id == config_entry.entry_id ) @@ -4196,6 +4345,149 @@ async def test_update_device_unknown_via_device_id_raises_before_removal( assert device_registry.async_get(device.id) == device +async def test_devices_collection_operations( + device_registry: dr.DeviceRegistry, + mock_config_entry: MockConfigEntry, +) -> None: + """Test the supported `Collection[DeviceEntry]` surface of `DeviceRegistry.devices`. + + Iteration yields the entries (not the ids), `len()` returns the count, and + `DeviceEntry` membership works. + """ + entry = device_registry.async_get_or_create( + config_entry_id=mock_config_entry.entry_id, + identifiers={("bridgeid", "0123")}, + ) + + assert list(device_registry.devices) == [entry] + assert [device.id for device in device_registry.devices] == [entry.id] + assert len(device_registry.devices) == 1 + assert entry in device_registry.devices + + +@pytest.mark.parametrize( + ("integration_frame_path", "expectation", "expected_log"), + [ + pytest.param( + "homeassistant/test_core", pytest.raises(RuntimeError), 0, id="core" + ), + pytest.param( + "homeassistant/components/test_integration", + pytest.raises(RuntimeError), + 1, + id="core integration", + ), + pytest.param( + "custom_components/test_integration", + nullcontext(), + 1, + id="custom integration", + ), + ], +) +@pytest.mark.usefixtures("mock_integration_frame") +async def test_devices_mapping_access_deprecated( + device_registry: dr.DeviceRegistry, + mock_config_entry: MockConfigEntry, + caplog: pytest.LogCaptureFixture, + expectation: AbstractContextManager, + expected_log: int, +) -> None: + """Test mapping-style access to `DeviceRegistry.devices` is deprecated. + + It logs for custom integrations and raises for core and core integrations, while + iterating the view keeps working for every caller. + """ + entry = device_registry.async_get_or_create( + config_entry_id=mock_config_entry.entry_id, + identifiers={("bridgeid", "0123")}, + ) + what = "uses `device_registry.devices` as a mapping" + + # Iterating the view is the supported API and is never reported. + assert list(device_registry.devices) == [entry] + assert caplog.text.count(what) == 0 + + with patch.object(frame, "_REPORTED_INTEGRATIONS", set()), expectation: + _ = device_registry.devices[entry.id] + + assert caplog.text.count(what) == expected_log + + +@pytest.mark.parametrize( + "integration_frame_path", ["custom_components/test_integration"] +) +@pytest.mark.usefixtures("mock_integration_frame") +async def test_devices_membership_by_entry_supported_by_id_deprecated( + device_registry: dr.DeviceRegistry, + mock_config_entry: MockConfigEntry, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test `DeviceEntry` membership is supported while device-id (str) membership warns.""" + entry = device_registry.async_get_or_create( + config_entry_id=mock_config_entry.entry_id, + identifiers={("bridgeid", "0123")}, + ) + what = "uses `device_registry.devices` as a mapping" + + # DeviceEntry (value) membership is supported and never reported. + assert entry in device_registry.devices + assert caplog.text.count(what) == 0 + + # Device-id (str) membership is the deprecated key lookup; it warns here (custom + # integration). + with patch.object(frame, "_REPORTED_INTEGRATIONS", set()): + assert entry.id in device_registry.devices + assert caplog.text.count(what) == 1 + + +@pytest.mark.parametrize( + ("integration_frame_path", "expectation", "expected_log"), + [ + pytest.param( + "homeassistant/test_core", pytest.raises(RuntimeError), 0, id="core" + ), + pytest.param( + "homeassistant/components/test_integration", + pytest.raises(RuntimeError), + 1, + id="core integration", + ), + pytest.param( + "custom_components/test_integration", + nullcontext(), + 1, + id="custom integration", + ), + ], +) +@pytest.mark.usefixtures("mock_integration_frame") +async def test_deleted_devices_deprecated( + device_registry: dr.DeviceRegistry, + mock_config_entry: MockConfigEntry, + caplog: pytest.LogCaptureFixture, + expectation: AbstractContextManager, + expected_log: int, +) -> None: + """Test accessing `DeviceRegistry.deleted_devices` is deprecated. + + It logs for custom integrations and raises for core and core integrations. + """ + entry = device_registry.async_get_or_create( + config_entry_id=mock_config_entry.entry_id, + identifiers={("bridgeid", "0123")}, + ) + device_registry.async_remove_device(entry.id) + what = "accesses `device_registry.deleted_devices`" + + with patch.object(frame, "_REPORTED_INTEGRATIONS", set()), expectation: + deleted_devices = device_registry.deleted_devices + # Custom integrations still receive the underlying container. + assert entry.id in deleted_devices + + assert caplog.text.count(what) == expected_log + + @pytest.mark.parametrize( ("integration_frame_path", "expectation", "expected_log"), [ @@ -4235,9 +4527,13 @@ async def test_async_get_device_deprecated( @pytest.mark.parametrize( - "via_device", - [("some_domain", "via_id"), None], - ids=["value", "none"], + ("parameter", "value", "replacement"), + [ + ("default_manufacturer", "manufacturer", "manufacturer"), + ("default_model", "model", "model"), + ("default_name", "name", "name"), + ("via_device", ("some_domain", "via_id"), "via_device_id"), + ], ) @pytest.mark.parametrize( ("integration_frame_path", "expectation", "expected_log"), @@ -4260,17 +4556,19 @@ async def test_async_get_device_deprecated( ], ) @pytest.mark.usefixtures("mock_integration_frame") -async def test_async_get_or_create_via_device_deprecated( +async def test_async_get_or_create_deprecated_parameters( hass: HomeAssistant, device_registry: dr.DeviceRegistry, caplog: pytest.LogCaptureFixture, - via_device: tuple[str, str] | None, + parameter: str, + value: Any, + replacement: str, expectation: AbstractContextManager, expected_log: int, ) -> None: - """Test passing via_device to async_get_or_create is deprecated. + """Test passing deprecated parameters to async_get_or_create. - It logs for custom integrations and raises for core and core integrations. + They log for custom integrations and raise for core and core integrations. """ config_entry = MockConfigEntry() config_entry.add_to_hass(hass) @@ -4278,23 +4576,37 @@ async def test_async_get_or_create_via_device_deprecated( config_entry_id=config_entry.entry_id, identifiers={("some_domain", "via_id")} ) - what = "calls `device_registry.async_get_or_create` with a `via_device`" + what = ( + "calls `device_registry.async_get_or_create` with a deprecated " + f"`{parameter}` parameter; use `{replacement}` instead" + ) with patch.object(frame, "_REPORTED_INTEGRATIONS", set()), expectation: device_registry.async_get_or_create( config_entry_id=config_entry.entry_id, identifiers={("some_domain", "some_id")}, - via_device=via_device, + **{parameter: value}, ) assert caplog.text.count(what) == expected_log +@pytest.mark.parametrize( + ("parameter", "value"), + [ + ("default_manufacturer", "manufacturer"), + ("default_model", "model"), + ("default_name", "name"), + ("via_device", ("some_domain", "via_id")), + ], +) @pytest.mark.usefixtures("mock_integration_frame") -async def test_async_get_or_create_via_device_reported_before_mutation( +async def test_async_get_or_create_deprecated_parameter_reported_before_mutation( hass: HomeAssistant, device_registry: dr.DeviceRegistry, + parameter: str, + value: Any, ) -> None: - """The via_device deprecation is reported before the registry is mutated. + """A deprecated parameter is reported before the registry is mutated. The default frame is a core integration, so the report raises; the new device must not be left partially created. @@ -4309,7 +4621,7 @@ async def test_async_get_or_create_via_device_reported_before_mutation( device_registry.async_get_or_create( config_entry_id=config_entry.entry_id, identifiers={("some_domain", "new_device")}, - via_device=("some_domain", "via_id"), + **{parameter: value}, ) # The report raised before insertion, so no partial device was left behind. @@ -4321,6 +4633,25 @@ async def test_async_get_or_create_via_device_reported_before_mutation( ) +async def test_async_get_or_create_unexpected_keyword_argument( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, +) -> None: + """Test passing an unexpected keyword argument to async_get_or_create raises.""" + config_entry = MockConfigEntry() + config_entry.add_to_hass(hass) + + with pytest.raises( + TypeError, + match="got unexpected keyword arguments 'unexpected'", + ): + device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + identifiers={("some_domain", "some_id")}, + unexpected="value", + ) + + @pytest.mark.parametrize( ("integration_frame_path", "expectation", "expected_log"), [ @@ -4535,10 +4866,10 @@ async def test_update_device_composite_via_device_id_self_reference_raises_befor ) old_id = "composite00000000000000000000ab" # Simulate a migration split: both devices carry the pre-migration composite id - device_registry.devices[device_1.id] = attr.evolve( + device_registry._devices[device_1.id] = attr.evolve( device_1, composite_device_id=old_id ) - device_registry.devices[device_2.id] = attr.evolve( + device_registry._devices[device_2.id] = attr.evolve( device_2, composite_device_id=old_id ) @@ -4553,7 +4884,7 @@ async def test_update_device_composite_via_device_id_self_reference_raises_befor via_device_id=old_id, ) - assert device_1.id in device_registry.devices + assert device_1.id in device_registry._devices async def test_get_or_create_composite_via_device_id_resolved( @@ -4578,10 +4909,10 @@ async def test_get_or_create_composite_via_device_id_resolved( ) old_id = "composite00000000000000000000ab" # Simulate a migration split: both devices carry the pre-migration composite id - device_registry.devices[split_1.id] = attr.evolve( + device_registry._devices[split_1.id] = attr.evolve( split_1, composite_device_id=old_id ) - device_registry.devices[split_2.id] = attr.evolve( + device_registry._devices[split_2.id] = attr.evolve( split_2, composite_device_id=old_id ) @@ -4629,10 +4960,10 @@ async def test_update_device_composite_via_device_id_resolved( ) old_id = "composite00000000000000000000ab" # Simulate a migration split: both devices carry the pre-migration composite id - device_registry.devices[split_1.id] = attr.evolve( + device_registry._devices[split_1.id] = attr.evolve( split_1, composite_device_id=old_id ) - device_registry.devices[split_2.id] = attr.evolve( + device_registry._devices[split_2.id] = attr.evolve( split_2, composite_device_id=old_id ) child = device_registry.async_get_or_create( @@ -4813,7 +5144,7 @@ async def test_loading_saving_data( # different config entry, so it is a separate device (identifiers/connections are # unique per config entry) assert len(device_registry.devices) == 5 - assert len(device_registry.deleted_devices) == 1 + assert len(device_registry._deleted_devices) == 1 orig_via = device_registry.async_update_device( orig_via.id, @@ -4828,8 +5159,8 @@ async def test_loading_saving_data( await registry2.async_load() # Ensure same order - assert list(device_registry.devices) == list(registry2.devices) - assert list(device_registry.deleted_devices) == list(registry2.deleted_devices) + assert list(device_registry._devices) == list(registry2._devices) + assert list(device_registry._deleted_devices) == list(registry2._deleted_devices) new_via = registry2.async_get_device(identifiers={("hue", "0123")}) new_light = registry2.async_get_device(identifiers={("hue", "456")}) @@ -5647,8 +5978,8 @@ async def test_create_reflects_config_entry_disabled_state( # Restoring a deleted device from a legacy store without a recorded # disabled_by is reconciled the same way device_registry.async_remove_device(device.id) - deleted_entry = device_registry.deleted_devices[device.id] - device_registry.deleted_devices[device.id] = attr.evolve( + deleted_entry = device_registry._deleted_devices[device.id] + device_registry._deleted_devices[device.id] = attr.evolve( deleted_entry, disabled_by=UNDEFINED ) restored = device_registry.async_get_or_create( @@ -6076,20 +6407,20 @@ async def test_migration_from_3_1_rewrites_stale_via_device_id( registry = dr.async_get(hass) assert ( - registry.devices["childa000000000000000000000000"].via_device_id + registry._devices["childa000000000000000000000000"].via_device_id == "splita000000000000000000000000" ) assert ( - registry.devices["childa200000000000000000000000"].via_device_id + registry._devices["childa200000000000000000000000"].via_device_id == "splita000000000000000000000000" ) - assert registry.devices["childc000000000000000000000000"].via_device_id in { + assert registry._devices["childc000000000000000000000000"].via_device_id in { "splita000000000000000000000000", "splitb000000000000000000000000", } - assert registry.devices["childx000000000000000000000000"].via_device_id is None + assert registry._devices["childx000000000000000000000000"].via_device_id is None assert ( - registry.devices["childl000000000000000000000000"].via_device_id + registry._devices["childl000000000000000000000000"].via_device_id == "splitb000000000000000000000000" ) @@ -6426,12 +6757,12 @@ async def test_cleanup_device_registry_removes_expired_orphaned_devices( device_registry.async_clear_config_entry(config_entry.entry_id) assert len(device_registry.devices) == 0 - assert len(device_registry.deleted_devices) == 3 + assert len(device_registry._deleted_devices) == 3 dr.async_cleanup(hass, device_registry, entity_registry) assert len(device_registry.devices) == 0 - assert len(device_registry.deleted_devices) == 3 + assert len(device_registry._deleted_devices) == 3 future_time = time.time() + dr.ORPHANED_DEVICE_KEEP_SECONDS + 1 @@ -6439,7 +6770,7 @@ async def test_cleanup_device_registry_removes_expired_orphaned_devices( dr.async_cleanup(hass, device_registry, entity_registry) assert len(device_registry.devices) == 0 - assert len(device_registry.deleted_devices) == 0 + assert len(device_registry._deleted_devices) == 0 async def test_cleanup_startup(hass: HomeAssistant) -> None: @@ -6538,12 +6869,12 @@ async def test_restore_device( ) assert len(device_registry.devices) == 1 - assert len(device_registry.deleted_devices) == 0 + assert len(device_registry._deleted_devices) == 0 device_registry.async_remove_device(entry.id) assert len(device_registry.devices) == 0 - assert len(device_registry.deleted_devices) == 1 + assert len(device_registry._deleted_devices) == 1 # This will create a new device entry2 = device_registry.async_get_or_create( @@ -6621,7 +6952,7 @@ async def test_restore_device( assert entry.id == entry3.id assert entry.id != entry2.id assert len(device_registry.devices) == 2 - assert len(device_registry.deleted_devices) == 0 + assert len(device_registry._deleted_devices) == 0 assert isinstance(entry3.config_entries, set) assert isinstance(entry3.connections, set) @@ -6701,7 +7032,7 @@ async def test_restore_device_reflects_reregistered_identity( identifiers=stored_identifiers, ) device_registry.async_remove_device(entry.id) - assert len(device_registry.deleted_devices) == 1 + assert len(device_registry._deleted_devices) == 1 restored = device_registry.async_get_or_create( config_entry_id=mock_config_entry.entry_id, @@ -6749,7 +7080,7 @@ async def test_deleted_device_to_device_entry_uses_reregistered_identity( identifiers={("bridgeid", "0123")}, ) device_registry.async_remove_device(entry.id) - deleted_device = device_registry.deleted_devices[entry.id] + deleted_device = device_registry._deleted_devices[entry.id] restored = deleted_device.to_device_entry( mock_config_entry, @@ -6806,15 +7137,15 @@ async def test_restore_migrated_device_disabled_by( ) assert len(device_registry.devices) == 1 - assert len(device_registry.deleted_devices) == 0 + assert len(device_registry._deleted_devices) == 0 device_registry.async_remove_device(entry.id) assert len(device_registry.devices) == 0 - assert len(device_registry.deleted_devices) == 1 + assert len(device_registry._deleted_devices) == 1 - deleted_entry = device_registry.deleted_devices[entry.id] - device_registry.deleted_devices[entry.id] = attr.evolve( + deleted_entry = device_registry._deleted_devices[entry.id] + device_registry._deleted_devices[entry.id] = attr.evolve( deleted_entry, disabled_by=UNDEFINED ) @@ -6864,7 +7195,7 @@ async def test_restore_migrated_device_disabled_by( assert entry.id == entry3.id assert len(device_registry.devices) == 1 - assert len(device_registry.deleted_devices) == 0 + assert len(device_registry._deleted_devices) == 0 assert isinstance(entry3.config_entries, set) assert isinstance(entry3.connections, set) @@ -6975,20 +7306,20 @@ async def test_restore_disabled_by( ) assert len(device_registry.devices) == 1 - assert len(device_registry.deleted_devices) == 0 + assert len(device_registry._deleted_devices) == 0 device_registry.async_remove_device(entry.id) assert len(device_registry.devices) == 0 - assert len(device_registry.deleted_devices) == 1 + assert len(device_registry._deleted_devices) == 1 # Simulate the disabled_by flag the device had when it was deleted. The # device may have been deleted before the config entry's disabled state # last changed - deleted devices are not updated when a config entry is # enabled or disabled, so the stored flag can contradict the entry's # current disabled state. - deleted_entry = device_registry.deleted_devices[entry.id] - device_registry.deleted_devices[entry.id] = attr.evolve( + deleted_entry = device_registry._deleted_devices[entry.id] + device_registry._deleted_devices[entry.id] = attr.evolve( deleted_entry, disabled_by=device_disabled_by_deleted ) @@ -7038,7 +7369,7 @@ async def test_restore_disabled_by( assert entry.id == entry3.id assert len(device_registry.devices) == 1 - assert len(device_registry.deleted_devices) == 0 + assert len(device_registry._deleted_devices) == 0 assert isinstance(entry3.config_entries, set) assert isinstance(entry3.connections, set) @@ -7162,6 +7493,32 @@ async def test_get_or_create_sets_default_values( assert entry.manufacturer == "default manufacturer 1" +@pytest.mark.parametrize( + ("field", "default_field"), + [ + ("name", "default_name"), + ("manufacturer", "default_manufacturer"), + ("model", "default_model"), + ], +) +async def test_get_or_create_rejects_field_and_its_default( + device_registry: dr.DeviceRegistry, + mock_config_entry: MockConfigEntry, + field: str, + default_field: str, +) -> None: + """Test passing both an explicit field and its default_ counterpart is rejected.""" + with pytest.raises( + dr.DeviceInfoError, + match=f"passing both `{field}` and `{default_field}` is not allowed", + ): + device_registry.async_get_or_create( + config_entry_id=mock_config_entry.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, + **{field: "explicit value", default_field: "default value"}, + ) + + async def test_verify_suggested_area_does_not_overwrite_area_id( device_registry: dr.DeviceRegistry, area_registry: ar.AreaRegistry, @@ -7981,10 +8338,10 @@ async def test_device_registry_deleted_device_collision( manufacturer="manufacturer", model="model", ) - assert len(device_registry.deleted_devices) == 0 + assert len(device_registry._deleted_devices) == 0 device_registry.async_remove_device(device1.id) - assert len(device_registry.deleted_devices) == 1 + assert len(device_registry._deleted_devices) == 1 device2 = device_registry.async_get_or_create( config_entry_id=config_entry.entry_id, @@ -7992,13 +8349,13 @@ async def test_device_registry_deleted_device_collision( manufacturer="manufacturer", model="model", ) - assert len(device_registry.deleted_devices) == 1 + assert len(device_registry._deleted_devices) == 1 device_registry.async_update_device( device2.id, merge_connections={(dr.CONNECTION_NETWORK_MAC, "EE:EE:EE:EE:EE:EE")}, ) - assert len(device_registry.deleted_devices) == 0 + assert len(device_registry._deleted_devices) == 0 async def test_update_device_no_connections_or_identifiers( @@ -8219,7 +8576,7 @@ async def test_remove_shadowed_collision_keeps_index_consistent( ("test", "1"), ("test", "2"), } - assert shadowed.id in device_registry.devices + assert shadowed.id in device_registry._devices # Remove the shadowed device, then the indexed one - neither must raise device_registry.async_remove_device(shadowed.id) @@ -8442,7 +8799,7 @@ async def test_legacy_duplicate_fully_stripped_device_removed( assert registered.id == "device" assert registered.identifiers == {("test", "device"), ("test", "shared")} assert device_registry.async_get("stale") is None - assert "stale" not in device_registry.deleted_devices + assert "stale" not in device_registry._deleted_devices assert ( device_registry.async_get_device(identifiers={("test", "shared")}).id == "device" @@ -8541,7 +8898,7 @@ async def test_loading_from_storage_with_legacy_duplicates( ) assert registered.id == "new" assert registry.async_get("old") is None - assert "old" not in registry.deleted_devices + assert "old" not in registry._deleted_devices # The reconciled state is persisted await flush_store(registry._store) @@ -8572,13 +8929,13 @@ async def test_registration_purges_same_entry_deleted_duplicates( ), }, ) - device_registry.deleted_devices["deleted_shadowed"] = _mock_deleted_device( + device_registry._deleted_devices["deleted_shadowed"] = _mock_deleted_device( "deleted_shadowed", entry.entry_id, {("test", "shared"), ("test", "other")} ) - device_registry.deleted_devices["deleted_winner"] = _mock_deleted_device( + device_registry._deleted_devices["deleted_winner"] = _mock_deleted_device( "deleted_winner", entry.entry_id, {("test", "shared")} ) - device_registry.deleted_devices["deleted_other_entry"] = _mock_deleted_device( + device_registry._deleted_devices["deleted_other_entry"] = _mock_deleted_device( "deleted_other_entry", other_entry.entry_id, {("test", "shared")} ) @@ -8587,9 +8944,9 @@ async def test_registration_purges_same_entry_deleted_duplicates( ) assert registered.id == "device" - assert "deleted_winner" not in device_registry.deleted_devices - assert "deleted_shadowed" not in device_registry.deleted_devices - assert "deleted_other_entry" in device_registry.deleted_devices + assert "deleted_winner" not in device_registry._deleted_devices + assert "deleted_shadowed" not in device_registry._deleted_devices + assert "deleted_other_entry" in device_registry._deleted_devices # The purge is persisted await flush_store(device_registry._store) assert [ @@ -8605,10 +8962,10 @@ async def test_restore_purges_same_entry_deleted_duplicate( entry = MockConfigEntry(domain="test") entry.add_to_hass(hass) device_registry = mock_device_registry(hass) - device_registry.deleted_devices["deleted_shadowed"] = _mock_deleted_device( + device_registry._deleted_devices["deleted_shadowed"] = _mock_deleted_device( "deleted_shadowed", entry.entry_id, {("test", "shared")} ) - device_registry.deleted_devices["deleted_winner"] = _mock_deleted_device( + device_registry._deleted_devices["deleted_winner"] = _mock_deleted_device( "deleted_winner", entry.entry_id, {("test", "shared")} ) @@ -8617,8 +8974,8 @@ async def test_restore_purges_same_entry_deleted_duplicate( ) assert restored.id == "deleted_winner" - assert "deleted_winner" not in device_registry.deleted_devices - assert "deleted_shadowed" not in device_registry.deleted_devices + assert "deleted_winner" not in device_registry._deleted_devices + assert "deleted_shadowed" not in device_registry._deleted_devices assert len(device_registry.devices) == 1 @@ -8635,10 +8992,10 @@ async def test_add_identifier_prunes_shadowed_deleted_duplicates( device = device_registry.async_get_or_create( config_entry_id=entry.entry_id, identifiers={("test", "device")} ) - device_registry.deleted_devices["deleted_shadowed"] = _mock_deleted_device( + device_registry._deleted_devices["deleted_shadowed"] = _mock_deleted_device( "deleted_shadowed", entry.entry_id, {("test", "shared")} ) - device_registry.deleted_devices["deleted_winner"] = _mock_deleted_device( + device_registry._deleted_devices["deleted_winner"] = _mock_deleted_device( "deleted_winner", entry.entry_id, {("test", "shared"), ("test", "other")} ) @@ -8646,8 +9003,8 @@ async def test_add_identifier_prunes_shadowed_deleted_duplicates( device.id, merge_identifiers={("test", "shared")} ) - assert "deleted_winner" not in device_registry.deleted_devices - assert "deleted_shadowed" not in device_registry.deleted_devices + assert "deleted_winner" not in device_registry._deleted_devices + assert "deleted_shadowed" not in device_registry._deleted_devices async def test_via_device_id_to_removed_stale_duplicate_raises( @@ -9284,10 +9641,10 @@ async def test_composite_move_clears_sibling_pending_moves( ) old_id = "composite00000000000000000000ab" # Simulate a migration split: both devices carry the pre-migration composite id - device_registry.devices[device_1.id] = attr.evolve( + device_registry._devices[device_1.id] = attr.evolve( device_1, composite_device_id=old_id ) - device_registry.devices[device_2.id] = attr.evolve( + device_registry._devices[device_2.id] = attr.evolve( device_2, composite_device_id=old_id ) @@ -9338,10 +9695,10 @@ async def test_composite_move_unknown_via_device_id_keeps_sibling_moves( ) old_id = "composite00000000000000000000ab" # Simulate a migration split: both devices carry the pre-migration composite id - device_registry.devices[device_1.id] = attr.evolve( + device_registry._devices[device_1.id] = attr.evolve( device_1, composite_device_id=old_id ) - device_registry.devices[device_2.id] = attr.evolve( + device_registry._devices[device_2.id] = attr.evolve( device_2, composite_device_id=old_id ) @@ -9511,8 +9868,8 @@ async def test_async_get_returns_restored_composite( assert composite.serial_number == "SERIAL" # Invisible to membership, enumeration and identifier search - assert COMPOSITE_ID not in device_registry.devices - assert COMPOSITE_ID not in {d.id for d in device_registry.devices.values()} + assert COMPOSITE_ID not in device_registry._devices + assert COMPOSITE_ID not in {d.id for d in device_registry.devices} assert ( device_registry.async_get_device(identifiers={("domain_a", "1")}).id != COMPOSITE_ID @@ -9574,7 +9931,7 @@ async def test_get_composite_splits( device_registry, entry_b.entry_id, identifiers={("domain_b", "1")} ) - splits = device_registry.devices.get_composite_splits() + splits = device_registry._devices.get_composite_splits() assert set(splits) == {COMPOSITE_ID} assert {device.id for device in splits[COMPOSITE_ID]} == {split_a.id, split_b.id} @@ -9582,18 +9939,18 @@ async def test_get_composite_splits( device_registry.async_get_or_create( config_entry_id=entry_a.entry_id, identifiers={("domain_a", "2")} ) - splits = device_registry.devices.get_composite_splits() + splits = device_registry._devices.get_composite_splits() assert set(splits) == {COMPOSITE_ID} assert {device.id for device in splits[COMPOSITE_ID]} == {split_a.id, split_b.id} # A removed split is dropped from the mapping device_registry.async_remove_device(split_a.id) - splits = device_registry.devices.get_composite_splits() + splits = device_registry._devices.get_composite_splits() assert {device.id for device in splits[COMPOSITE_ID]} == {split_b.id} # Removing the last split drops the composite id from the mapping device_registry.async_remove_device(split_b.id) - assert device_registry.devices.get_composite_splits() == {} + assert device_registry._devices.get_composite_splits() == {} async def test_async_get_device_and_config_entry_for_domain( @@ -10347,8 +10704,8 @@ async def test_remove_parent_cascades_to_children( assert device_registry.async_get(parent.id) is None assert device_registry.async_get(child_device.id) is None assert not device_registry.child_devices - assert child_device.id in device_registry.deleted_devices - assert parent.id in device_registry.deleted_devices + assert child_device.id in device_registry._deleted_devices + assert parent.id in device_registry._deleted_devices await hass.async_block_till_done() assert [event.data for event in remove_events] == [ @@ -10766,7 +11123,7 @@ async def test_link_device_info_matching_child_raises( # The child device is left untouched: not converted, no new device created assert len(device_registry.devices) == 1 assert len(device_registry.child_devices) == 1 - assert device_registry.child_devices[child_device.id] == child_device + assert device_registry._child_devices[child_device.id] == child_device assert child_device.identifiers == {("test", "strip_outlet_1")} @@ -10813,7 +11170,7 @@ async def test_convert_device_to_child_detaches_via_links( # No live device links to a child device through via_device_id child_via_targets = [ device.id - for device in device_registry.devices.values() + for device in device_registry.devices if device.via_device_id is not None and device_registry.async_get(device.via_device_id, include_main_devices=False) is not None @@ -11000,7 +11357,7 @@ async def test_child_device_orphan_restore( device_registry.async_update_child_device(child_device.id, area_id="garden") device_registry.async_clear_config_entry(mock_config_entry.entry_id) - assert not device_registry.devices + assert not device_registry._devices assert not device_registry.child_devices new_entry = MockConfigEntry(title=None) @@ -11040,7 +11397,7 @@ async def test_child_device_load_and_save( first_save = deepcopy(hass_storage[dr.STORAGE_KEY]["data"]) await registry2.async_load() - assert list(device_registry.devices) == list(registry2.devices) + assert list(device_registry._devices) == list(registry2._devices) assert list(device_registry.child_devices) == list(registry2.child_devices) loaded_child = registry2.async_get(child_device.id, include_main_devices=False) assert loaded_child is not None @@ -11317,7 +11674,7 @@ async def test_async_cleanup_removes_child_device_with_missing_parent( device_registry, mock_config_entry.entry_id ) # Simulate store corruption: drop the parent without the remove cascade - del device_registry.devices[parent.id] + del device_registry._devices[parent.id] dr.async_cleanup(hass, device_registry, entity_registry) @@ -12063,7 +12420,7 @@ async def test_recreate_child_clears_stale_config_entry_disable( _, child_device = _create_parent_and_child( device_registry, mock_config_entry.entry_id ) - device_registry.child_devices[child_device.id] = attr.evolve( + device_registry._child_devices[child_device.id] = attr.evolve( device_registry.async_get(child_device.id, include_main_devices=False), disabled_by=dr.DeviceEntryDisabler.CONFIG_ENTRY, ) @@ -12094,13 +12451,13 @@ async def test_update_child_identifiers_purges_colliding_deleted_device( name="Ghost", ) device_registry.async_remove_device(ghost.id) - assert ghost.id in device_registry.deleted_devices + assert ghost.id in device_registry._deleted_devices device_registry.async_update_child_device( child_device.id, new_identifiers={("test", "strip_outlet_1"), ("test", "ghost")}, ) - assert ghost.id not in device_registry.deleted_devices + assert ghost.id not in device_registry._deleted_devices @pytest.mark.usefixtures("hass") @@ -12223,7 +12580,7 @@ async def test_clear_config_entry_removes_orphaned_child_device( parent, child_device = _create_parent_and_child( device_registry, mock_config_entry.entry_id ) - del device_registry.devices[parent.id] + del device_registry._devices[parent.id] device_registry.async_clear_config_entry(mock_config_entry.entry_id) @@ -12271,8 +12628,8 @@ async def test_clear_config_subentry_removes_orphaned_child_device( parent_device_id=parent_2.id, name="Outlet 2", ) - del device_registry.devices[parent_1.id] - del device_registry.devices[parent_2.id] + del device_registry._devices[parent_1.id] + del device_registry._devices[parent_2.id] device_registry.async_clear_config_subentry(entry_id, "mock-subentry-id-1-1") diff --git a/tests/helpers/test_entity.py b/tests/helpers/test_entity.py index 1406a12206a2..b88d69ff4963 100644 --- a/tests/helpers/test_entity.py +++ b/tests/helpers/test_entity.py @@ -1036,7 +1036,7 @@ async def _test_friendly_name( (False, None, "Device Bla", "Device Bla"), (True, "Entity Blu", "Device Bla", "Device Bla Entity Blu"), (True, None, "Device Bla", "Device Bla"), - (True, "Entity Blu", UNDEFINED, "Entity Blu"), + (True, "Entity Blu", UNDEFINED, "Mock Title Entity Blu"), (True, "Entity Blu", None, "Mock Title Entity Blu"), ], ) diff --git a/tests/helpers/test_entity_platform.py b/tests/helpers/test_entity_platform.py index e84fb9c69731..f8f524f52d58 100644 --- a/tests/helpers/test_entity_platform.py +++ b/tests/helpers/test_entity_platform.py @@ -1531,7 +1531,7 @@ async def test_device_info_called( async def test_device_info_not_overrides( hass: HomeAssistant, device_registry: dr.DeviceRegistry ) -> None: - """Test device info is forwarded correctly.""" + """Test re-registering a device does not override existing values.""" config_entry = MockConfigEntry(entry_id="super-mock-id") config_entry.add_to_hass(hass) device = device_registry.async_get_or_create( @@ -1556,9 +1556,6 @@ async def test_device_info_not_overrides( unique_id="qwer", device_info={ "connections": {(dr.CONNECTION_NETWORK_MAC, "abcd")}, - "default_name": "default name 1", - "default_model": "default model 1", - "default_manufacturer": "default manufacturer 1", }, ) ] @@ -2734,8 +2731,8 @@ async def test_device_name_defaulting_config_entry( hass: HomeAssistant, device_registry: dr.DeviceRegistry, config_entry_title: str, - entity_device_name: str, - entity_device_default_name: str, + entity_device_name: str | None, + entity_device_default_name: str | None, expected_device_name: str, ) -> None: """Test setting the device name based on input info.""" @@ -2767,8 +2764,11 @@ async def test_device_name_defaulting_config_entry( hass, platform_name=config_entry.domain, platform=platform ) - assert await entity_platform.async_setup_entry(config_entry) - await hass.async_block_till_done() + # `default_name` is deprecated in the device registry; suppress the deprecation + # report so it does not raise when the entity is added. + with patch.object(dr, "report_usage"): + assert await entity_platform.async_setup_entry(config_entry) + await hass.async_block_till_done() device = device_registry.async_get_device_by_connection( (dr.CONNECTION_NETWORK_MAC, "1234"), config_entry.entry_id @@ -2783,16 +2783,6 @@ async def test_device_name_defaulting_config_entry( # No identifiers ({}, 1), # Empty device info does not prevent the entity from being created ({"name": "bla"}, 0), - ({"default_name": "bla"}, 0), - # Match multiple types - ( - { - "identifiers": {("hue", "1234")}, - "name": "bla", - "default_name": "yo", - }, - 0, - ), ], ) async def test_device_type_error_checking( diff --git a/tests/helpers/test_entity_registry.py b/tests/helpers/test_entity_registry.py index b75f66280a33..39e13a77340f 100644 --- a/tests/helpers/test_entity_registry.py +++ b/tests/helpers/test_entity_registry.py @@ -210,7 +210,7 @@ def test_get_or_create_updates_data( assert set(entity_registry.async_device_ids()) == {orig_device_entry.id} assert orig_entry == er.RegistryEntry( - entity_id="light.hue_5678", + entity_id=orig_entry.entity_id, unique_id="5678", platform="hue", aliases=[er.COMPUTED_NAME], @@ -271,7 +271,7 @@ def test_get_or_create_updates_data( ) assert new_entry == er.RegistryEntry( - entity_id="light.hue_5678", + entity_id=new_entry.entity_id, unique_id="5678", platform="hue", aliases=[er.COMPUTED_NAME], @@ -327,7 +327,7 @@ def test_get_or_create_updates_data( ) assert new_entry == er.RegistryEntry( - entity_id="light.hue_5678", + entity_id=new_entry.entity_id, unique_id="5678", platform="hue", aliases=[er.COMPUTED_NAME], @@ -4235,15 +4235,15 @@ async def test_composite_device_id_ignored( ) old_id = "composite00000000000000000000ab" # Simulate a migration split: both devices carry the pre-migration composite id - device_registry.devices[device_1.id] = attr.evolve( + device_registry._devices[device_1.id] = attr.evolve( device_1, composite_device_id=old_id ) - device_registry.devices[device_2.id] = attr.evolve( + device_registry._devices[device_2.id] = attr.evolve( device_2, composite_device_id=old_id ) # The composite id resolves to a synthesized device, but is not a real registry entry assert device_registry.async_get(old_id) is not None - assert old_id not in device_registry.devices + assert old_id not in device_registry._devices warning = f"Ignoring request to link entity from integration hue to device {old_id}" @@ -6179,7 +6179,7 @@ async def test_async_entries_for_device_legacy_composite_id( entity_registry = er.async_get(hass) # The composite id is no longer a live device; its entities were repointed to splits - assert COMPOSITE_ID not in device_registry.devices + assert COMPOSITE_ID not in device_registry._devices # get_entries_for_device_id resolves the composite id to the split entities assert { @@ -6254,14 +6254,14 @@ async def test_async_entries_for_device_composite_id( ) old_id = "composite00000000000000000000ab" # Simulate a migration split: both devices carry the pre-migration composite id - device_registry.devices[device_1.id] = attr.evolve( + device_registry._devices[device_1.id] = attr.evolve( device_1, composite_device_id=old_id ) - device_registry.devices[device_2.id] = attr.evolve( + device_registry._devices[device_2.id] = attr.evolve( device_2, composite_device_id=old_id ) - assert old_id not in device_registry.devices + assert old_id not in device_registry._devices assert { entry.entity_id for entry in er.async_entries_for_device(entity_registry, old_id) diff --git a/tests/helpers/test_helper_integration.py b/tests/helpers/test_helper_integration.py index 7322453ecf58..e09b84661fdb 100644 --- a/tests/helpers/test_helper_integration.py +++ b/tests/helpers/test_helper_integration.py @@ -558,11 +558,11 @@ async def test_async_remove_helper_devices( identifiers=helper_identifiers, ) # Both are splits of the same pre-migration device, sharing its id - device_registry.devices[source_split.id] = attr.evolve( + device_registry._devices[source_split.id] = attr.evolve( source_split, composite_device_id=composite_id, ) - device_registry.devices[helper_split.id] = attr.evolve( + device_registry._devices[helper_split.id] = attr.evolve( helper_split, composite_device_id=composite_id, has_composite_identifiers=helper_has_composite_identifiers, diff --git a/tests/helpers/test_service.py b/tests/helpers/test_service.py index 7505798e9dc1..cca0ed954190 100644 --- a/tests/helpers/test_service.py +++ b/tests/helpers/test_service.py @@ -719,6 +719,7 @@ async def test_extract_entity_ids(hass: HomeAssistant) -> None: mode=None, object_id=None, order=None, + context=None, ) call = ServiceCall(hass, "light", "turn_on", {ATTR_ENTITY_ID: "light.Bowl"}) diff --git a/tests/helpers/test_target.py b/tests/helpers/test_target.py index e42cbc4eb389..27f57ae1d5f2 100644 --- a/tests/helpers/test_target.py +++ b/tests/helpers/test_target.py @@ -513,6 +513,7 @@ async def test_extract_referenced_entity_ids( mode=None, object_id=None, order=None, + context=None, ) target_selection = selection_class(selector_config) diff --git a/tests/script/test_gen_recorder_db_versions.py b/tests/script/test_gen_recorder_db_versions.py new file mode 100644 index 000000000000..fe48e9e0b3be --- /dev/null +++ b/tests/script/test_gen_recorder_db_versions.py @@ -0,0 +1,88 @@ +"""Tests for the gen_recorder_db_versions script.""" + +from datetime import date +import sys +from unittest.mock import patch +import urllib.error + +import pytest + +from script import gen_recorder_db_versions as gen + +# endoflife.date exposes `lts` as a boolean for most cycles, but as the date the +# cycle became LTS for some (e.g. MySQL 8.0), and `eol` as a date string or, when +# no end of life is announced yet, the boolean false. +MARIADB_CYCLES = [ + {"cycle": "12.3", "lts": True, "eol": "2029-06-12"}, # supported LTS + {"cycle": "12.2", "lts": False, "eol": "2026-05-28"}, # newest non-LTS + {"cycle": "11.8", "lts": True, "eol": "2028-06-04"}, # supported LTS + {"cycle": "10.6", "lts": True, "eol": "2026-07-06"}, # LTS past end of life + {"cycle": "10.3", "lts": False, "eol": "2023-05-25"}, # old non-LTS +] +MYSQL_CYCLES = [ + {"cycle": "9.7", "lts": True, "eol": "2034-04-21"}, # supported LTS (bool) + {"cycle": "9.6", "lts": False, "eol": "2026-04-21"}, # newest non-LTS + {"cycle": "8.4", "lts": True, "eol": "2032-04-30"}, # supported LTS (bool) + {"cycle": "8.0", "lts": "2023-07-18", "eol": "2026-04-30"}, # LTS-as-date, past EOL +] + + +@pytest.mark.parametrize( + ("cycles", "expected"), + [ + (MARIADB_CYCLES, {"supported_lts": ["11.8", "12.3"], "latest_non_lts": "12.2"}), + (MYSQL_CYCLES, {"supported_lts": ["8.4", "9.7"], "latest_non_lts": "9.6"}), + ], + ids=["mariadb", "mysql"], +) +def test_engine_versions(cycles: list[dict], expected: dict) -> None: + """Test end-of-life filtering, LTS bool/date handling, and series ordering.""" + assert gen._engine_versions(cycles, date(2026, 8, 20)) == expected + + +def test_eol_handles_missing_and_date() -> None: + """Test a missing end-of-life date maps to date.max and a date string is parsed.""" + assert gen._eol({"eol": False}) == date.max + assert gen._eol({"eol": "2028-02-16"}) == date(2028, 2, 16) + + +def test_render_matches_committed() -> None: + """Test the committed file is exactly what render() produces.""" + assert gen.render(gen.load_committed()) == gen.OUTPUT_FILE.read_text() + + +def test_main_validate_up_to_date() -> None: + """Test validate succeeds when the committed file matches the fetched data.""" + with ( + patch.object(gen, "fetch_versions", return_value=gen.load_committed()), + patch.object(sys, "argv", ["prog", "validate"]), + ): + assert gen.main() == 0 + + +def test_main_validate_out_of_date(capsys: pytest.CaptureFixture[str]) -> None: + """Test validate fails and reports the generated file path when out of date.""" + stale = { + "mariadb": {"supported_lts": ["0.0"], "latest_non_lts": "0.0"}, + "mysql": {"supported_lts": ["0.0"], "latest_non_lts": "0.0"}, + } + with ( + patch.object(gen, "fetch_versions", return_value=stale), + patch.object(sys, "argv", ["prog", "validate"]), + ): + assert gen.main() == 1 + output = capsys.readouterr().out + assert "homeassistant/generated/recorder_database_versions.py" in output + assert "components/recorder/database_versions.py" not in output + + +def test_main_validate_skips_on_network_error( + capsys: pytest.CaptureFixture[str], +) -> None: + """Test validate skips (instead of failing) when endoflife.date is unreachable.""" + with ( + patch.object(gen, "fetch_versions", side_effect=urllib.error.URLError("boom")), + patch.object(sys, "argv", ["prog", "validate"]), + ): + assert gen.main() == 0 + assert "Skipping validation" in capsys.readouterr().out diff --git a/tests/test_util/aiohttp.py b/tests/test_util/aiohttp.py index 687de2ece0b1..5a62ae1672e7 100644 --- a/tests/test_util/aiohttp.py +++ b/tests/test_util/aiohttp.py @@ -64,6 +64,7 @@ class AiohttpClientMocker: side_effect=None, closing=None, timeout=None, + history=(), ): """Mock a request.""" if not isinstance(url, RETYPE): @@ -83,6 +84,7 @@ class AiohttpClientMocker: headers=headers, side_effect=side_effect, closing=closing, + history=history, ) self._mocks.append(resp) return resp @@ -185,6 +187,7 @@ class AiohttpClientMockResponse: headers=None, side_effect=None, closing=None, + history=(), ) -> None: """Initialize a fake response.""" if json is not None: @@ -197,6 +200,7 @@ class AiohttpClientMockResponse: self.method = method self._url = url self.status = status + self.history = history self._response = response self.exc = exc self.side_effect = side_effect